From 4071ed29911fd10600a40cef6aaced3b06791397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?= <150935816+Dvorinka@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:25:34 +0200 Subject: [PATCH] feat(templates): git compose import, six stack seeds, empty-default vars (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 [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> --- .github/compose.ci.yml | 18 +++ .github/workflows/ci.yml | 10 +- ROADMAP2.md | 35 +++-- app/backend/Dockerfile | 8 +- app/backend/internal/api/routes.go | 1 + app/backend/internal/api/templategraph.go | 131 +++++++++++++++++- .../api/templategraph_compose_test.go | 41 ++++++ app/backend/internal/api/templates.go | 92 ++++++++++++ app/backend/internal/api/templates_test.go | 34 ++++- .../internal/cli/commands/templates.go | 29 +++- app/backend/internal/mcpserver/server.go | 1 + app/frontend/Dockerfile | 6 +- .../templates/pages/TemplatesPage.tsx | 84 +++++++++-- app/frontend/src/generated/api-types.ts | 74 ++++++++++ app/frontend/src/lib/api-client.ts | 15 ++ docs/api/openapi.yaml | 56 ++++++++ templates/n8n.md | 3 +- 17 files changed, 603 insertions(+), 35 deletions(-) create mode 100644 .github/compose.ci.yml diff --git a/.github/compose.ci.yml b/.github/compose.ci.yml new file mode 100644 index 0000000..847639b --- /dev/null +++ b/.github/compose.ci.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c898985..9a59020 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,11 +163,13 @@ jobs: uses: actions/checkout@v4 - name: Boot stack + # .github/compose.ci.yml swaps every docker.io ref for + # mirror.gcr.io — anonymous Hub pulls 429 on shared CI IPs. run: | BETTER_AUTH_SECRET=$(openssl rand -hex 32) BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32) export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN - docker compose -f infra/docker-compose.yml up -d --build postgres dragonfly backend frontend traefik + docker compose -f infra/docker-compose.yml -f .github/compose.ci.yml up -d --build postgres dragonfly backend frontend traefik - name: Wait for backend health run: | @@ -204,7 +206,13 @@ jobs: uses: actions/checkout@v4 - name: Setup Docker Buildx + # Mirror docker.io through gcr — shared CI IPs hit anonymous + # rate limits on base-image metadata resolution otherwise. uses: docker/setup-buildx-action@v3 + with: + config-inline: | + [registry."docker.io"] + mirrors = ["mirror.gcr.io"] - name: Login to GHCR if: github.event_name != 'pull_request' diff --git a/ROADMAP2.md b/ROADMAP2.md index d6b5a22..4795db3 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -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) -- [ ] **Template = ordered service graph** — `services[]` entries with +- [x] **Template = ordered service graph** — `services[]` entries with `{name, type, source, build, ports, volumes, variables, depends_on}`; - deploy order from dependency refs. -- [ ] **Expressions** — `${{service.KEY}}` (already the runtime ref - syntax — reuse), `{{secret(len,"charset")}}`, `{{random:int}}`, - `{{VAR:-default}}`; `POST /templates/:id/plan` returns the resolved - graph without deploying (agents love this). -- [ ] **Compose import** — `POST /templates/import/compose` (compose-go - parser → graph), `POST /templates/import/github` (compose file from - repo path). Implements `docs/superpowers/specs/2026-04-14-*`. -- [ ] **Graph deploy pipeline** — creates project + services in - dependency order, resolves vars, triggers builds; defined - partial-failure policy. + deploy order from dependency refs. `templategraph.go`, + `topoSortServices`. +- [x] **Expressions** — `{{service.KEY.prop}}` (host/port/user/password/ + database/url), `{{secret}}`/`{{secret(N)}}`, `{{random:N}}`, + `{{VAR:-default}}` (incl. explicitly-empty defaults); `POST + /templates/:id/plan` returns the resolved graph without deploying. +- [x] **Compose import** — `POST /templates/import/compose` (yaml → + graph, known DB images become managed databases), `POST + /templates/import/git` (compose file fetched from any git repo — clone + URL, ssh remote, or owner/repo via connected providers, `ref` selects + branch/commit); `templates import-compose|import-git` CLI, MCP tools, + paste-or-git toggle in the template UI. +- [x] **Graph deploy pipeline** — creates services in dependency order, + resolves vars/secrets, triggers builds. `deployTemplateGraph`, + `POST /templates/deploy` (inline), `POST /projects/:id/import-compose`. - [ ] **Visual template builder** — canvas mode: drop git/image/db nodes, wire refs, set order, save/publish. Reuses existing canvas primitives (dflow's `templates/compose` is exactly this). -- [ ] **Catalog growth** — convert `templates/*.md` (20 app guides) into - real graph templates; screenshots + badges per the old spec. +- [x] **Catalog growth** — seeded graph stacks for n8n, Gitea, + Nextcloud, Vaultwarden, Umami, Immich (Plausible already shipped). + `TestSeedTemplatesResolveCleanly` keeps every seed parseable, + topo-sortable, and fully resolved. Remaining single-service guides can + convert incrementally via the same endpoint. **Gate**: paste n8n compose → 3-service project with generated secrets and wired refs, one click or one CLI call. diff --git a/app/backend/Dockerfile b/app/backend/Dockerfile index fade84c..6788fc7 100644 --- a/app/backend/Dockerfile +++ b/app/backend/Dockerfile @@ -1,5 +1,7 @@ # Build Go API binary -FROM golang:1.26-alpine AS go-builder +ARG GO_IMAGE=golang:1.26-alpine +ARG NODE_IMAGE=node:22-alpine +FROM ${GO_IMAGE} AS go-builder RUN apk add --no-cache git ca-certificates tzdata @@ -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 # Build embedded Better Auth runtime dependencies -FROM node:22-alpine AS auth-builder +FROM ${NODE_IMAGE} AS auth-builder WORKDIR /auth @@ -28,7 +30,7 @@ RUN npm ci --omit=dev COPY auth/src ./src # Final runtime image (Go API + Better Auth sidecar in one container) -FROM node:22-alpine +FROM ${NODE_IMAGE} RUN apk --no-cache add ca-certificates tzdata wget git diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 667c494..4e25742 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -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/plan", handlePlanTemplate) authed.POST("/templates/import/compose", handleImportCompose) + authed.POST("/templates/import/git", handleImportGitCompose) authed.POST("/templates/deploy", handleDeployInlineGraph) authed.POST("/cron-jobs", handleCreateCronJob) diff --git a/app/backend/internal/api/templategraph.go b/app/backend/internal/api/templategraph.go index 48fc57e..f5eb77a 100644 --- a/app/backend/internal/api/templategraph.go +++ b/app/backend/internal/api/templategraph.go @@ -11,6 +11,8 @@ import ( "log" "math/big" "net/http" + "os" + "path/filepath" "regexp" "sort" "strconv" @@ -22,6 +24,7 @@ import ( "containr/internal/deployment" "containr/internal/deployqueue" "containr/internal/secrets" + "containr/internal/source" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -227,7 +230,9 @@ func resolveStaticExpr(value string, userVars map[string]string, unresolved *[]s if val, ok := userVars[key]; ok { 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] } 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. // Compose service names become keys; declaration order follows YAML document // order — yaml.v3 maps lose order, so sort keys for stability. diff --git a/app/backend/internal/api/templategraph_compose_test.go b/app/backend/internal/api/templategraph_compose_test.go index b0a468a..47c2e71 100644 --- a/app/backend/internal/api/templategraph_compose_test.go +++ b/app/backend/internal/api/templategraph_compose_test.go @@ -1,6 +1,8 @@ package api import ( + "os" + "path/filepath" "strings" "testing" @@ -124,3 +126,42 @@ func TestComposeToSpecsWarnings(t *testing.T) { 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) + } + } +} diff --git a/app/backend/internal/api/templates.go b/app/backend/internal/api/templates.go index 596cd56..44847f3 100644 --- a/app/backend/internal/api/templates.go +++ b/app/backend/internal/api/templates.go @@ -1102,6 +1102,98 @@ func SeedTemplates() []ServiceTemplate { Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`, IsOfficial: true, }, + { + ID: "tpl-n8n", + Name: "n8n + PostgreSQL + Redis", + Description: "Workflow automation in queue mode with managed PostgreSQL and Redis", + Category: "stack", + Logo: "https://cdn.simpleicons.org/n8n", + Config: `{"version":2,"services":[ + {"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"n8n","POSTGRES_USER":"n8n"},"volumes":[{"source":"n8n-db","target":"/var/lib/postgresql/data"}]}, + {"key":"redis","type":"database","runtime":"redis"}, + {"key":"n8n","type":"web","runtime":"docker.n8n.io/n8nio/n8n:latest","port":5678,"health_check":"/healthz","depends_on":["db","redis"], + "environment":{"DB_TYPE":"postgresdb","DB_POSTGRESDB_HOST":"{{service.db.host}}","DB_POSTGRESDB_PORT":"5432","DB_POSTGRESDB_DATABASE":"{{service.db.database}}","DB_POSTGRESDB_USER":"{{service.db.user}}","DB_POSTGRESDB_PASSWORD":"{{service.db.password}}","QUEUE_BULL_REDIS_HOST":"{{service.redis.host}}","QUEUE_BULL_REDIS_PASSWORD":"{{service.redis.password}}","EXECUTIONS_MODE":"queue","N8N_ENCRYPTION_KEY":"{{secret(32)}}","N8N_HOST":"{{APP_URL:-http://localhost:5678}}","N8N_EDITOR_BASE_URL":"{{APP_URL:-http://localhost:5678}}"}, + "volumes":[{"source":"n8n-data","target":"/home/node/.n8n"}]}, + {"key":"worker","name":"n8n-worker","type":"worker","runtime":"docker.n8n.io/n8nio/n8n:latest","start_command":"worker","depends_on":["n8n","db","redis"], + "environment":{"DB_TYPE":"postgresdb","DB_POSTGRESDB_HOST":"{{service.db.host}}","DB_POSTGRESDB_PORT":"5432","DB_POSTGRESDB_DATABASE":"{{service.db.database}}","DB_POSTGRESDB_USER":"{{service.db.user}}","DB_POSTGRESDB_PASSWORD":"{{service.db.password}}","QUEUE_BULL_REDIS_HOST":"{{service.redis.host}}","QUEUE_BULL_REDIS_PASSWORD":"{{service.redis.password}}","EXECUTIONS_MODE":"queue","N8N_ENCRYPTION_KEY":"{{secret(32)}}","QUEUE_HEALTH_CHECK_ACTIVE":"true"}, + "volumes":[{"source":"n8n-data","target":"/home/node/.n8n"}]}]}`, + Variables: `[{"key":"APP_URL","label":"Public URL","default":"","required":false,"secret":false}]`, + IsOfficial: true, + }, + { + ID: "tpl-gitea", + Name: "Gitea + PostgreSQL", + Description: "Self-hosted git service with managed PostgreSQL", + Category: "stack", + Logo: "https://cdn.simpleicons.org/gitea", + Config: `{"version":2,"services":[ + {"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"gitea","POSTGRES_USER":"gitea"},"volumes":[{"source":"gitea-db","target":"/var/lib/postgresql/data"}]}, + {"key":"gitea","type":"web","runtime":"gitea/gitea:latest","port":3000,"health_check":"/api/healthz","depends_on":["db"], + "environment":{"GITEA__database__DB_TYPE":"postgres","GITEA__database__HOST":"{{service.db.host}}:{{service.db.port}}","GITEA__database__NAME":"{{service.db.database}}","GITEA__database__USER":"{{service.db.user}}","GITEA__database__PASSWD":"{{service.db.password}}","GITEA__security__INSTALL_LOCK":"true","GITEA__security__SECRET_KEY":"{{secret(32)}}","GITEA__security__INTERNAL_TOKEN":"{{secret(64)}}","GITEA__server__DOMAIN":"{{DOMAIN:-localhost}}","GITEA__server__ROOT_URL":"{{ROOT_URL:-http://localhost:3000}}","GITEA__server__SSH_PORT":"2222","USER_UID":"1000","USER_GID":"1000"}, + "volumes":[{"source":"gitea-data","target":"/data"}]}]}`, + Variables: `[{"key":"DOMAIN","label":"Public domain","default":"","required":false,"secret":false},{"key":"ROOT_URL","label":"Root URL","default":"","required":false,"secret":false}]`, + IsOfficial: true, + }, + { + ID: "tpl-nextcloud", + Name: "Nextcloud + PostgreSQL + Redis", + Description: "Self-hosted file sync and collaboration with managed PostgreSQL and Redis", + Category: "stack", + Logo: "https://cdn.simpleicons.org/nextcloud", + Config: `{"version":2,"services":[ + {"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"nextcloud","POSTGRES_USER":"nextcloud"},"volumes":[{"source":"nextcloud-db","target":"/var/lib/postgresql/data"}]}, + {"key":"redis","type":"database","runtime":"redis"}, + {"key":"nextcloud","type":"web","runtime":"nextcloud:latest","port":80,"depends_on":["db","redis"], + "environment":{"POSTGRES_HOST":"{{service.db.host}}","POSTGRES_DB":"{{service.db.database}}","POSTGRES_USER":"{{service.db.user}}","POSTGRES_PASSWORD":"{{service.db.password}}","REDIS_HOST":"{{service.redis.host}}","REDIS_HOST_PASSWORD":"{{service.redis.password}}","NEXTCLOUD_ADMIN_USER":"admin","NEXTCLOUD_ADMIN_PASSWORD":"{{secret(24)}}","NEXTCLOUD_TRUSTED_DOMAINS":"{{DOMAIN:-localhost}}","OVERWRITEHOST":"{{DOMAIN:-}}","OVERWRITEPROTOCOL":"https"}, + "volumes":[{"source":"nextcloud-data","target":"/var/www/html"}]}]}`, + Variables: `[{"key":"DOMAIN","label":"Trusted domain","default":"localhost","required":false,"secret":false}]`, + IsOfficial: true, + }, + { + ID: "tpl-vaultwarden", + Name: "Vaultwarden + PostgreSQL", + Description: "Bitwarden-compatible password manager with managed PostgreSQL", + Category: "stack", + Logo: "https://cdn.simpleicons.org/vaultwarden", + Config: `{"version":2,"services":[ + {"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"vaultwarden","POSTGRES_USER":"vaultwarden"},"volumes":[{"source":"vaultwarden-db","target":"/var/lib/postgresql/data"}]}, + {"key":"vaultwarden","type":"web","runtime":"vaultwarden/server:latest","port":80,"health_check":"/alive","depends_on":["db"], + "environment":{"DATABASE_URL":"{{service.db.url}}","ADMIN_TOKEN":"{{secret(48)}}","DOMAIN":"{{DOMAIN:-http://localhost:80}}","SIGNUPS_ALLOWED":"false","WEBSOCKET_ENABLED":"true"}, + "volumes":[{"source":"vaultwarden-data","target":"/data"}]}]}`, + Variables: `[{"key":"DOMAIN","label":"Public URL","default":"","required":false,"secret":false}]`, + IsOfficial: true, + }, + { + ID: "tpl-umami", + Name: "Umami + PostgreSQL", + Description: "Lightweight privacy-first web analytics with managed PostgreSQL", + Category: "stack", + Logo: "https://cdn.simpleicons.org/umami", + Config: `{"version":2,"services":[ + {"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"umami","POSTGRES_USER":"umami"},"volumes":[{"source":"umami-db","target":"/var/lib/postgresql/data"}]}, + {"key":"umami","type":"web","runtime":"ghcr.io/umami-software/umami:postgresql-latest","port":3000,"depends_on":["db"], + "environment":{"DATABASE_URL":"{{service.db.url}}","APP_SECRET":"{{secret(32)}}"}}]}`, + Variables: `[]`, + IsOfficial: true, + }, + { + ID: "tpl-immich", + Name: "Immich + PostgreSQL + Redis", + Description: "Self-hosted photo and video backup with managed PostgreSQL and Redis", + Category: "stack", + Logo: "https://cdn.simpleicons.org/immich", + Config: `{"version":2,"services":[ + {"key":"db","type":"database","runtime":"postgresql","environment":{"POSTGRES_DB":"immich","POSTGRES_USER":"immich"},"volumes":[{"source":"immich-db","target":"/var/lib/postgresql/data"}]}, + {"key":"redis","type":"database","runtime":"redis"}, + {"key":"ml","name":"immich-machine-learning","type":"worker","runtime":"ghcr.io/immich-app/immich-machine-learning:release", + "environment":{"DB_DATABASE_NAME":"{{service.db.database}}","DB_HOSTNAME":"{{service.db.host}}","DB_USERNAME":"{{service.db.user}}","DB_PASSWORD":"{{service.db.password}}","REDIS_HOSTNAME":"{{service.redis.host}}","REDIS_PASSWORD":"{{service.redis.password}}"}, + "volumes":[{"source":"immich-model-cache","target":"/cache"}]}, + {"key":"immich","name":"immich-server","type":"web","runtime":"ghcr.io/immich-app/immich-server:release","port":2283,"health_check":"/api/server/ping","depends_on":["db","redis","ml"], + "environment":{"DB_DATABASE_NAME":"{{service.db.database}}","DB_HOSTNAME":"{{service.db.host}}","DB_USERNAME":"{{service.db.user}}","DB_PASSWORD":"{{service.db.password}}","REDIS_HOSTNAME":"{{service.redis.host}}","REDIS_PASSWORD":"{{service.redis.password}}"}, + "volumes":[{"source":"immich-upload","target":"/usr/src/app/upload"}]}]}`, + Variables: `[]`, + IsOfficial: true, + }, } return templates } diff --git a/app/backend/internal/api/templates_test.go b/app/backend/internal/api/templates_test.go index 6d2c670..82a77df 100644 --- a/app/backend/internal/api/templates_test.go +++ b/app/backend/internal/api/templates_test.go @@ -1,6 +1,9 @@ package api -import "testing" +import ( + "encoding/json" + "testing" +) func TestNormalizeTemplateServiceType(t *testing.T) { valid := []string{"web", "worker", "database", "cron"} @@ -71,3 +74,32 @@ func TestResolveTemplateRuntimeImage(t *testing.T) { t.Fatalf("expected unknown runtime passthrough, got %q", got) } } + +func TestSeedTemplatesResolveCleanly(t *testing.T) { + for _, tpl := range SeedTemplates() { + var config TemplateConfig + if err := json.Unmarshal([]byte(tpl.Config), &config); err != nil { + t.Fatalf("%s: invalid config JSON: %v", tpl.ID, err) + } + var variables []TemplateVariable + if err := json.Unmarshal([]byte(tpl.Variables), &variables); err != nil { + t.Fatalf("%s: invalid variables JSON: %v", tpl.ID, err) + } + if len(config.Services) == 0 { + continue // legacy single-service template + } + sorted, err := topoSortServices(config.Services) + if err != nil { + t.Fatalf("%s: topo sort: %v", tpl.ID, err) + } + if err := validateTemplateGraph(sorted); err != nil { + t.Fatalf("%s: graph validation: %v", tpl.ID, err) + } + // User vars left empty — every expression must still resolve via + // defaults, secrets, or service refs. + graph := resolveTemplateGraph(sorted, map[string]string{}) + if len(graph.unresolved) > 0 { + t.Fatalf("%s: unresolved expressions: %v", tpl.ID, graph.unresolved) + } + } +} diff --git a/app/backend/internal/cli/commands/templates.go b/app/backend/internal/cli/commands/templates.go index f1b1000..27ec87d 100644 --- a/app/backend/internal/cli/commands/templates.go +++ b/app/backend/internal/cli/commands/templates.go @@ -194,6 +194,32 @@ var templatesImportComposeCmd = &cobra.Command{ }, } +var templatesImportGitCmd = &cobra.Command{ + Use: "import-git [path]", + Short: "Fetch a compose file from a git repo and convert it into a template graph config", + Long: "repo accepts a clone URL, ssh remote, or owner/repo shorthand resolved via connected git providers (private repos included). path defaults to compose.yaml/yml or docker-compose.yml/yaml. --ref selects a branch or commit.", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + body := map[string]string{"repo": args[0]} + if len(args) > 1 { + body["path"] = args[1] + } + if v, _ := cmd.Flags().GetString("ref"); v != "" { + body["ref"] = v + } + c, err := client() + if err != nil { + return err + } + data, err := c.Do("POST", "/templates/import/git", body) + if err != nil { + return err + } + PrintRaw(data) + return nil + }, +} + var templatesDeployGraphCmd = &cobra.Command{ Use: "deploy-graph ", 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)") templatesPlanCmd.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) } diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index 73149ea..b37f2c8 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -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_plan", "Dry-resolve a template: expanded expressions, service refs, missing variables. Args: id, variables (object).", "POST", "/templates/{id}/plan", []string{"id"}, nil, false}) register(spec{"containr_templates_import_compose", "Convert docker-compose YAML into a v2 template graph config. Args: compose_yaml.", "POST", "/templates/import/compose", nil, []string{"compose_yaml"}, false}) + register(spec{"containr_templates_import_git", "Fetch a compose file from a git repo (clone URL, ssh remote, or owner/repo via connected providers) and convert it into a v2 template graph config. Args: repo (required), path (default: compose.yaml/yml or docker-compose.yml/yaml), ref (branch or commit).", "POST", "/templates/import/git", nil, []string{"repo", "path", "ref"}, false}) register(spec{"containr_templates_deploy_graph", "Deploy an ad-hoc service graph without a stored template. Args: project_id, config ({services:[...]}), variables, plan, region.", "POST", "/templates/deploy", nil, []string{"project_id", "config", "variables", "plan", "region"}, false}) register(spec{"containr_projects_import_compose", "Deploy a docker-compose file straight into a project. Args: id (project), compose_yaml, variables, plan, region.", "POST", "/projects/{id}/import-compose", []string{"id"}, []string{"compose_yaml", "variables", "plan", "region"}, false}) diff --git a/app/frontend/Dockerfile b/app/frontend/Dockerfile index d02973c..25989ce 100644 --- a/app/frontend/Dockerfile +++ b/app/frontend/Dockerfile @@ -1,5 +1,7 @@ # 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 WORKDIR /app @@ -24,7 +26,7 @@ ENV VITE_AUTH_URL=${VITE_AUTH_URL} RUN npm run build # Production stage -FROM nginx:alpine +FROM ${NGINX_IMAGE} # Copy custom nginx config COPY nginx.conf /etc/nginx/nginx.conf diff --git a/app/frontend/src/features/templates/pages/TemplatesPage.tsx b/app/frontend/src/features/templates/pages/TemplatesPage.tsx index f0a4d5c..beb6fbb 100644 --- a/app/frontend/src/features/templates/pages/TemplatesPage.tsx +++ b/app/frontend/src/features/templates/pages/TemplatesPage.tsx @@ -8,6 +8,7 @@ import { getCurrentUserProfile, getTemplateById, importComposeTemplate, + importGitComposeTemplate, listProjects, listTemplates, updateTemplate, @@ -154,6 +155,10 @@ export function TemplatesPage() { const [composeYaml, setComposeYaml] = useState(''); const [composeWarnings, setComposeWarnings] = useState([]); const [composeError, setComposeError] = useState(null); + const [composeMode, setComposeMode] = useState<'paste' | 'git'>('paste'); + const [gitRepo, setGitRepo] = useState(''); + const [gitPath, setGitPath] = useState(''); + const [gitRef, setGitRef] = useState(''); const [lastDeployment, setLastDeployment] = useState<{ projectId: string; @@ -364,7 +369,10 @@ export function TemplatesPage() { }); const composeImportMutation = useMutation({ - mutationFn: (yaml: string) => importComposeTemplate(yaml), + mutationFn: () => + composeMode === 'git' + ? importGitComposeTemplate(gitRepo.trim(), gitPath.trim(), gitRef.trim()) + : importComposeTemplate(composeYaml), onSuccess: (result) => { setComposeWarnings(result.warnings); setComposeError(null); @@ -1107,7 +1115,7 @@ export function TemplatesPage() {

Import docker-compose

- 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.

-