feat(templates): v2 — multi-service graph templates, expressions, compose import

Config v2 adds services[] to TemplateConfig: members declare key, type
(web|worker|cron|database), runtime, environment, volumes and depends_on.
Deploy topo-sorts the graph, creates databases first (managed DBs attached
to the project network under their key as DNS alias), then services with
resolved env, and enqueues a real deployment per service.

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

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

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

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

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

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

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-10-08 23:42:08 +02:00
1 parent 15045b8dd3
commit b4baa853b2
16 files changed
+2479 -48

No files matched your search

+243 -1
View File
@@ -2801,6 +2801,10 @@ export interface paths {
* Create resource from template
* @description Create a new resource in a project using a selected template.
* For `database` templates, this creates a managed database service.
* For v2 graph templates (config.services non-empty), this deploys
* the entire service graph — databases first, then services in
* dependency order — and returns `resource: stack` with a `created`
* list. Deployments are enqueued for each service member.
*/
post: {
parameters: {
@@ -2835,6 +2839,187 @@ export interface paths {
patch?: never;
trace?: never;
};
"/templates/{id}/plan": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Dry-resolve a template
* @description Resolves a template without creating resources: variable merging,
* `{{secret}}`/`{{random:N}}`/`{{VAR}}`/`{{VAR:-default}}` expression
* expansion and `{{service.<key>.<prop>}}` references. Generated
* secrets are returned masked — a real deploy regenerates them.
* Returns `members` (with resolved environment), `missing_variables`
* and `unresolved` reference names.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Template ID */
id: string;
};
cookie?: never;
};
requestBody?: {
content: {
"application/json": {
project_id?: string;
variables?: {
[key: string]: string;
};
};
};
};
responses: {
/** @description Resolved plan */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
members?: components["schemas"]["TemplatePlanMember"][];
missing_variables?: string[];
unresolved?: string[];
};
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/templates/import/compose": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Convert docker-compose YAML to a template graph
* @description Parses a docker-compose YAML document and returns a v2 template
* config (`version: 2`, `services[]`). Well-known database images
* (postgres, redis, mongo, mysql, mariadb, clickhouse, dragonfly)
* become managed-database members; other images become web services.
* `build:` contexts are flagged in `warnings` — Containr deploys
* images or git repos, not local build contexts. The returned config
* can be saved via POST /templates or deployed directly via
* POST /templates/deploy.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description Raw docker-compose YAML (max 512 KiB) */
compose_yaml: string;
};
};
};
responses: {
/** @description Converted config */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
config?: components["schemas"]["TemplateConfig"];
variables?: Record<string, never>[];
warnings?: string[];
};
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/templates/deploy": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Deploy an ad-hoc service graph
* @description Deploys a v2 graph config (config.services[]) without a stored
* template — the companion to POST /templates/import/compose.
* Members are created in dependency order; `database` members become
* managed databases attached to the project network under their key
* as DNS alias, and deployments are enqueued for service members.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
project_id: string;
config: components["schemas"]["TemplateConfig"];
variables?: {
[key: string]: string;
};
plan?: string;
region?: string;
};
};
};
responses: {
/** @description Graph deployed */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
resource?: string;
created?: {
key?: string;
kind?: string;
id?: string;
name?: string;
deployment_id?: string;
}[];
};
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/agent-tokens": {
parameters: {
query?: never;
@@ -9213,6 +9398,8 @@ export interface components {
is_public?: boolean;
};
TemplateConfig: {
/** @description Config version — 2 when services[] is used */
version?: number;
/** @description Service type for created service */
type?: string;
/** @description Runtime or base image */
@@ -9235,6 +9422,52 @@ export interface components {
nixpacks_config?: {
[key: string]: string;
};
/**
* @description v2 graph members. When non-empty, the flat fields above are
* ignored and deploy creates the whole graph in dependency order.
*/
services?: components["schemas"]["TemplateServiceSpec"][];
};
TemplateServiceSpec: {
/** @description Member key — DNS alias on the project network and the target of {{service.<key>.*}} refs */
key: string;
/** @description Display/deployed name (defaults to key) */
name?: string;
/** @enum {string} */
type: "web" | "worker" | "cron" | "database";
/** @description Image reference (web/worker/cron) or managed engine (database) */
runtime?: string;
/** @description Git repository URL — when set, the member builds from source */
repo?: string;
branch?: string;
build_command?: string;
start_command?: string;
port?: number;
health_check?: string;
/** @enum {string} */
builder?: "auto" | "nixpacks" | "dockerfile" | "static";
/**
* @description Env vars. Values support {{secret}}, {{secret(N)}}, {{random:N}},
* {{VAR}}, {{VAR:-default}} and {{service.<key>.<prop>}} where prop
* is host, port, url, user, password or database.
*/
environment?: {
[key: string]: string;
};
volumes?: components["schemas"]["ServiceVolume"][];
/** @description Member keys that must be created first */
depends_on?: string[];
};
TemplatePlanMember: {
key?: string;
name?: string;
type?: string;
runtime?: string;
port?: number;
depends_on?: string[];
environment?: {
[key: string]: string;
};
};
TemplateVariable: {
/** @description Variable key */
@@ -9277,11 +9510,20 @@ export interface components {
* @description Type of resource created by template deployment
* @enum {string}
*/
resource?: "service" | "database";
resource?: "service" | "database" | "stack";
/** @description Newly created service ID (set for non-database templates) */
service_id?: string;
/** @description Newly created managed database ID (set for database templates) */
database_id?: string;
/** @description Created resources (set for graph templates — resource=stack) */
created?: {
key?: string;
/** @enum {string} */
kind?: "service" | "database";
id?: string;
name?: string;
deployment_id?: string;
}[];
/** @description Deployment result message */
message?: string;
};
+1 -1
View File
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
"path": "guides/CLI.md",
"section": "guides",
"title": "Containr CLI",
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy <id>` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete <name>` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add <id> app.example.com --default\ncontainr services domains check <id> # DNS preflight per domain\ncontainr services domains remove <id> <domain-id>\n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update <id> --maintenance on\ncontainr services update <id> --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `<BASE Line truncated
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy <id>` to apply immediately.\n\n`voLine truncated
},
{
"path": "guides/CLOUDFLARE_SETUP.md",