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

+29 -5
View File
@@ -3,6 +3,7 @@ package api
import (
"archive/tar"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/docker"
"containr/internal/secrets"
"context"
@@ -331,6 +332,11 @@ type managedDatabaseCreateRequest struct {
Region string
PublicPort bool
RuntimeVariables map[string]string
// ProjectID + NetworkAlias attach the container to a project network
// under Alias — set by graph-template deploys so sibling services can
// resolve the database by name.
ProjectID string
NetworkAlias string
}
func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID string, req managedDatabaseCreateRequest) (databaseID string, name string, dbType string, err error) {
@@ -398,10 +404,17 @@ func (h *DatabaseHandler) createManagedDatabaseAndProvision(ctx context.Context,
return "", err
}
go h.provisionDatabaseWithVariables(databaseID, name, dbType, req.RuntimeVariables)
go h.provisionDatabaseWithVariables(databaseID, name, dbType, req.RuntimeVariables, dbNetworkAttach{ProjectID: req.ProjectID, Alias: req.NetworkAlias})
return databaseID, nil
}
// dbNetworkAttach carries the optional project-network membership for a
// managed database provisioned by a graph template.
type dbNetworkAttach struct {
ProjectID string
Alias string
}
// UpdateDatabase updates a database service
func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
@@ -1350,7 +1363,7 @@ type databaseRuntimePlan struct {
}
func (h *DatabaseHandler) provisionDatabase(databaseID, databaseName, dbType string) {
h.provisionDatabaseWithVariables(databaseID, databaseName, dbType, nil)
h.provisionDatabaseWithVariables(databaseID, databaseName, dbType, nil, dbNetworkAttach{})
}
// reprovisionManagedDatabase recreates a managed container so changed
@@ -1364,7 +1377,7 @@ func (h *DatabaseHandler) reprovisionManagedDatabase(databaseID, databaseName, d
h.provisionDatabase(databaseID, databaseName, dbType)
}
func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseName, dbType string, runtimeVariables map[string]string) {
func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseName, dbType string, runtimeVariables map[string]string, attach dbNetworkAttach) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
@@ -1373,7 +1386,7 @@ func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseNam
return
}
connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables)
connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables, attach)
if err != nil {
log.Printf("containr: managed database %s (%s) provisioning failed: %v", databaseID, dbType, err)
_ = h.setDatabaseStatusAndConnection(databaseID, "error", sql.NullString{})
@@ -1395,7 +1408,7 @@ func (h *DatabaseHandler) setDatabaseStatusAndConnection(databaseID, status stri
})
}
func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, databaseID, databaseName, dbType string, runtimeVariables map[string]string) (string, error) {
func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, databaseID, databaseName, dbType string, runtimeVariables map[string]string, attach dbNetworkAttach) (string, error) {
plan, err := buildDatabaseRuntimePlan(dbType, databaseName, runtimeVariables)
if err != nil {
return "", err
@@ -1483,6 +1496,17 @@ func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, database
return "", fmt.Errorf("failed to start container: %w", err)
}
// Graph-template deploys attach the database to the project network so
// sibling services resolve it by alias ({{service.<key>.host}}).
if attach.ProjectID != "" && attach.Alias != "" {
projectNet := deployment.ProjectNetworkName(attach.ProjectID)
if err := h.dockerClient.ConnectNetwork(ctx, projectNet, containerID, network.EndpointSettings{
Aliases: []string{attach.Alias},
}); err != nil {
log.Printf("containr: database %s attach to %s failed: %v", databaseID, projectNet, err)
}
}
hostPort, err := h.resolvePublishedHostPort(ctx, containerID, plan.Port)
if err != nil {
return "", err
+3
View File
@@ -417,6 +417,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.PUT("/templates/:id", handleUpdateTemplate)
authed.DELETE("/templates/:id", handleDeleteTemplate)
authed.POST("/templates/:id/deploy", handleCreateFromTemplate)
authed.POST("/templates/:id/plan", handlePlanTemplate)
authed.POST("/templates/import/compose", handleImportCompose)
authed.POST("/templates/deploy", handleDeployInlineGraph)
authed.POST("/cron-jobs", handleCreateCronJob)
authed.PUT("/cron-jobs/:id", handleUpdateCronJob)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,159 @@
package api
import (
"strings"
"testing"
)
func TestTopoSortServicesOrdersByDependsOn(t *testing.T) {
sorted, err := topoSortServices([]TemplateServiceSpec{
{Key: "web", DependsOn: []string{"db"}},
{Key: "db", Type: "database"},
{Key: "worker", DependsOn: []string{"web", "db"}},
})
if err != nil {
t.Fatalf("topoSortServices: %v", err)
}
if sorted[0].Key != "db" || sorted[1].Key != "web" || sorted[2].Key != "worker" {
t.Fatalf("unexpected order: %v", []string{sorted[0].Key, sorted[1].Key, sorted[2].Key})
}
}
func TestTopoSortServicesRejectsCycle(t *testing.T) {
_, err := topoSortServices([]TemplateServiceSpec{
{Key: "a", DependsOn: []string{"b"}},
{Key: "b", DependsOn: []string{"a"}},
})
if err == nil || !strings.Contains(err.Error(), "cycle") {
t.Fatalf("expected cycle error, got %v", err)
}
}
func TestTopoSortServicesRejectsUnknownDep(t *testing.T) {
_, err := topoSortServices([]TemplateServiceSpec{
{Key: "a", DependsOn: []string{"ghost"}},
})
if err == nil || !strings.Contains(err.Error(), "unknown key") {
t.Fatalf("expected unknown-key error, got %v", err)
}
}
func TestResolveStaticExpr(t *testing.T) {
unresolved := []string{}
var generated bool
v := resolveStaticExpr("{{secret}}", nil, &unresolved, &generated)
if len(v) != 32 || !generated {
t.Fatalf("secret: got %q generated=%v", v, generated)
}
v = resolveStaticExpr("{{secret(8)}}", nil, &unresolved, &generated)
if len(v) != 16 {
t.Fatalf("secret(8): got %q", v)
}
v = resolveStaticExpr("port-{{random:10}}", nil, &unresolved, &generated)
if !strings.HasPrefix(v, "port-") {
t.Fatalf("random: got %q", v)
}
v = resolveStaticExpr("{{DOMAIN:-example.com}}", nil, &unresolved, &generated)
if v != "example.com" {
t.Fatalf("var default: got %q", v)
}
v = resolveStaticExpr("{{DOMAIN}}", map[string]string{"DOMAIN": "x.io"}, &unresolved, &generated)
if v != "x.io" {
t.Fatalf("var override: got %q", v)
}
v = resolveStaticExpr("{{MISSING}}", nil, &unresolved, &generated)
if v != "" || len(unresolved) == 0 || unresolved[0] != "MISSING" {
t.Fatalf("missing var: got %q unresolved=%v", v, unresolved)
}
}
func TestResolveTemplateGraphDatabaseRefs(t *testing.T) {
sorted, err := topoSortServices([]TemplateServiceSpec{
{Key: "db", Type: "database", Runtime: "postgresql", Environment: map[string]string{
"POSTGRES_DB": "shop",
}},
{Key: "web", Type: "web", Runtime: "nginx", DependsOn: []string{"db"}, Environment: map[string]string{
"DATABASE_URL": "{{service.db.url}}",
"DB_HOST": "{{service.db.host}}",
"DB_PORT": "{{service.db.port}}",
"SESSION_TOKEN": "{{secret}}",
}},
})
if err != nil {
t.Fatalf("topo: %v", err)
}
graph := resolveTemplateGraph(sorted, nil)
if len(graph.unresolved) > 0 {
t.Fatalf("unresolved: %v", graph.unresolved)
}
web := graph.members[1]
if web.env["DB_HOST"] != "db" {
t.Fatalf("DB_HOST=%q", web.env["DB_HOST"])
}
if web.env["DB_PORT"] != "5432" {
t.Fatalf("DB_PORT=%q", web.env["DB_PORT"])
}
dbURL := web.env["DATABASE_URL"]
if !strings.HasPrefix(dbURL, "postgresql://") || !strings.Contains(dbURL, "@db:5432/shop") {
t.Fatalf("DATABASE_URL=%q", dbURL)
}
if len(web.env["SESSION_TOKEN"]) != 32 || !web.secretKeys["SESSION_TOKEN"] {
t.Fatalf("SESSION_TOKEN=%q secret=%v", web.env["SESSION_TOKEN"], web.secretKeys["SESSION_TOKEN"])
}
// Database member must carry the generated password so refs and the
// container agree.
db := graph.members[0]
if pw := db.env["POSTGRES_PASSWORD"]; len(pw) != 32 || !db.secretKeys["POSTGRES_PASSWORD"] {
t.Fatalf("POSTGRES_PASSWORD=%q secret=%v", pw, db.secretKeys["POSTGRES_PASSWORD"])
}
if !strings.Contains(dbURL, db.env["POSTGRES_PASSWORD"]) {
t.Fatalf("url %q does not carry the resolved password", dbURL)
}
}
func TestResolveTemplateGraphServiceRefs(t *testing.T) {
sorted, err := topoSortServices([]TemplateServiceSpec{
{Key: "api", Type: "web", Runtime: "ghcr.io/x/api:1", Port: 8080},
{Key: "front", Type: "web", Runtime: "ghcr.io/x/front:1", Port: 3000, DependsOn: []string{"api"}, Environment: map[string]string{
"API_URL": "{{service.api.url}}/v2",
}},
})
if err != nil {
t.Fatalf("topo: %v", err)
}
graph := resolveTemplateGraph(sorted, nil)
if got := graph.members[1].env["API_URL"]; got != "http://api:8080/v2" {
t.Fatalf("API_URL=%q", got)
}
}
func TestResolveTemplateGraphUnresolvedServiceProp(t *testing.T) {
sorted, _ := topoSortServices([]TemplateServiceSpec{
{Key: "web", Type: "web", Runtime: "nginx", Environment: map[string]string{
"X": "{{service.nope.host}}",
}},
})
graph := resolveTemplateGraph(sorted, nil)
if len(graph.unresolved) == 0 {
t.Fatal("expected unresolved ref")
}
}
func TestValidateTemplateGraph(t *testing.T) {
if err := validateTemplateGraph([]TemplateServiceSpec{
{Key: "db", Type: "database", Runtime: "postgresql"},
}); err != nil {
t.Fatalf("valid db spec rejected: %v", err)
}
if err := validateTemplateGraph([]TemplateServiceSpec{
{Key: "db", Type: "database", Runtime: "nonsense"},
}); err == nil {
t.Fatal("expected unsupported db engine error")
}
if err := validateTemplateGraph([]TemplateServiceSpec{
{Key: "web", Type: "web"},
}); err == nil {
t.Fatal("expected missing runtime error")
}
}
+85 -6
View File
@@ -3,6 +3,8 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/secrets"
"context"
"database/sql"
"encoding/json"
@@ -34,6 +36,7 @@ type ServiceTemplate struct {
}
type TemplateConfig struct {
Version int `json:"version,omitempty"`
Type string `json:"type"`
Runtime string `json:"runtime"`
BuildCommand string `json:"build_command"`
@@ -43,6 +46,9 @@ type TemplateConfig struct {
Environment map[string]string `json:"environment"`
Dockerfile string `json:"dockerfile,omitempty"`
NixpacksConfig map[string]string `json:"nixpacks_config,omitempty"`
// Services makes this a v2 graph template. When non-empty the flat
// fields above are ignored and deployTemplateGraph handles the deploy.
Services []TemplateServiceSpec `json:"services,omitempty"`
}
type TemplateVariable struct {
@@ -147,7 +153,7 @@ func handleCreateFromTemplate(c *gin.Context) {
var req struct {
ProjectID string `json:"project_id" binding:"required"`
Name string `json:"name" binding:"required"`
Name string `json:"name"`
Plan string `json:"plan,omitempty"`
Region string `json:"region,omitempty"`
Variables map[string]string `json:"variables"`
@@ -160,10 +166,6 @@ func handleCreateFromTemplate(c *gin.Context) {
req.ProjectID = strings.TrimSpace(req.ProjectID)
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
projectID, err := uuid.Parse(req.ProjectID)
if err != nil {
@@ -213,6 +215,51 @@ func handleCreateFromTemplate(c *gin.Context) {
return
}
if len(config.Services) > 0 {
var engine *deployment.DeploymentEngine
if v, exists := c.Get("deployment_engine"); exists && v != nil {
engine, _ = v.(*deployment.DeploymentEngine)
}
var dbHandler *DatabaseHandler
if v, exists := c.Get("database_handler"); exists {
dbHandler, _ = v.(*DatabaseHandler)
}
userVars := map[string]string{}
for _, v := range templateVars {
if override, ok := req.Variables[v.Key]; ok {
userVars[v.Key] = override
} else if v.Default != "" {
userVars[v.Key] = v.Default
}
}
for k, v := range req.Variables {
userVars[k] = v
}
created, fail := deployTemplateGraph(c, db, engine, dbHandler, projectID, userID,
config.Services, templateVars, userVars, req.Plan, req.Region, "template:"+templateID)
if fail != nil {
status := http.StatusBadRequest
if (*fail)["code"] == "CONFLICT" {
status = http.StatusConflict
}
c.JSON(status, *fail)
return
}
c.JSON(http.StatusCreated, gin.H{
"resource": "stack",
"created": created,
"message": fmt.Sprintf("Deployed %d resources from template %s", len(created), template.Name),
})
return
}
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
serviceType, err := normalizeTemplateServiceType(config.Type)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -351,11 +398,15 @@ func handleCreateFromTemplate(c *gin.Context) {
continue
}
stored := value
if secretKeys[key] {
stored = secrets.Encrypt(value)
}
err = txQueries.UpsertEnvironmentVariable(ctx, sqlcdb.UpsertEnvironmentVariableParams{
ID: uuid.New(),
ServiceID: serviceID,
Key: key,
Value: value,
Value: stored,
IsSecret: sql.NullBool{Bool: secretKeys[key], Valid: true},
CreatedAt: sql.NullTime{Time: now, Valid: true},
UpdatedAt: sql.NullTime{Time: now, Valid: true},
@@ -927,6 +978,34 @@ func SeedTemplates() []ServiceTemplate {
Variables: `[]`,
IsOfficial: true,
},
// Multi-service stacks — v2 graph configs. Database members become
// managed databases attached to the project network under their key.
{
ID: "tpl-wordpress-stack",
Name: "WordPress + MySQL",
Description: "WordPress with a managed MySQL database on a private project network",
Category: "stack",
Logo: "https://cdn.simpleicons.org/wordpress",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"mysql","environment":{"MYSQL_DATABASE":"wordpress"}},
{"key":"web","name":"wordpress","type":"web","runtime":"wordpress:6-apache","port":80,"health_check":"/wp-login.php","depends_on":["db"],
"environment":{"WORDPRESS_DB_HOST":"{{service.db.host}}","WORDPRESS_DB_USER":"{{service.db.user}}","WORDPRESS_DB_PASSWORD":"{{service.db.password}}","WORDPRESS_DB_NAME":"{{service.db.database}}"}}]}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-n8n-stack",
Name: "n8n + PostgreSQL",
Description: "n8n workflow automation backed by a managed PostgreSQL database",
Category: "stack",
Logo: "https://cdn.simpleicons.org/n8n",
Config: `{"version":2,"services":[
{"key":"db","type":"database","runtime":"postgresql"},
{"key":"n8n","type":"web","runtime":"n8nio/n8n:latest","port":5678,"health_check":"/healthz","depends_on":["db"],
"environment":{"DB_TYPE":"postgresdb","DB_POSTGRESDB_HOST":"{{service.db.host}}","DB_POSTGRESDB_PORT":"{{service.db.port}}","DB_POSTGRESDB_DATABASE":"{{service.db.database}}","DB_POSTGRESDB_USER":"{{service.db.user}}","DB_POSTGRESDB_PASSWORD":"{{service.db.password}}","N8N_ENCRYPTION_KEY":"{{secret}}"}}]}`,
Variables: `[]`,
IsOfficial: true,
},
}
return templates
}