Merge pull request #28 from Dvorinka/feat/templates-v2

feat(templates): v2 — multi-service graph templates, expressions, compose import
This commit is contained in:
Tomáš Dvořák authored and GitHub committed 2026-10-09 05:29:24 +02:00
commit f90fc5f5ad
16 files changed
+2479 -48

No files matched your search

+2 -2
View File
@@ -13,6 +13,7 @@ require (
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/miekg/dns v1.1.72 github.com/miekg/dns v1.1.72
github.com/minio/minio-go/v7 v7.0.95
github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/pressly/goose/v3 v3.24.1 github.com/pressly/goose/v3 v3.24.1
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
@@ -23,6 +24,7 @@ require (
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.57.0
golang.org/x/term v0.46.0 golang.org/x/term v0.46.0
gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
@@ -59,7 +61,6 @@ require (
github.com/mfridman/interpolate v0.0.2 // indirect github.com/mfridman/interpolate v0.0.2 // indirect
github.com/minio/crc64nvme v1.0.2 // indirect github.com/minio/crc64nvme v1.0.2 // indirect
github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.95 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect github.com/moby/term v0.5.2 // indirect
@@ -106,7 +107,6 @@ require (
golang.org/x/time v0.15.0 // indirect golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.49.0 // indirect golang.org/x/tools v0.49.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect gotest.tools/v3 v3.5.2 // indirect
modernc.org/sqlite v1.39.0 // indirect modernc.org/sqlite v1.39.0 // indirect
) )
-5
View File
@@ -65,8 +65,6 @@ github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -90,8 +88,6 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -248,7 +244,6 @@ golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+29 -5
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"archive/tar" "archive/tar"
"containr/internal/database/sqlcdb" "containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/docker" "containr/internal/docker"
"containr/internal/secrets" "containr/internal/secrets"
"context" "context"
@@ -331,6 +332,11 @@ type managedDatabaseCreateRequest struct {
Region string Region string
PublicPort bool PublicPort bool
RuntimeVariables map[string]string 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) { 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 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 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 // UpdateDatabase updates a database service
func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) { func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c) userID, ok := requireAuthenticatedUserID(c)
@@ -1350,7 +1363,7 @@ type databaseRuntimePlan struct {
} }
func (h *DatabaseHandler) provisionDatabase(databaseID, databaseName, dbType string) { 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 // reprovisionManagedDatabase recreates a managed container so changed
@@ -1364,7 +1377,7 @@ func (h *DatabaseHandler) reprovisionManagedDatabase(databaseID, databaseName, d
h.provisionDatabase(databaseID, databaseName, dbType) 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) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel() defer cancel()
@@ -1373,7 +1386,7 @@ func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseNam
return return
} }
connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables) connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables, attach)
if err != nil { if err != nil {
log.Printf("containr: managed database %s (%s) provisioning failed: %v", databaseID, dbType, err) log.Printf("containr: managed database %s (%s) provisioning failed: %v", databaseID, dbType, err)
_ = h.setDatabaseStatusAndConnection(databaseID, "error", sql.NullString{}) _ = 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) plan, err := buildDatabaseRuntimePlan(dbType, databaseName, runtimeVariables)
if err != nil { if err != nil {
return "", err return "", err
@@ -1483,6 +1496,17 @@ func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, database
return "", fmt.Errorf("failed to start container: %w", err) 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) hostPort, err := h.resolvePublishedHostPort(ctx, containerID, plan.Port)
if err != nil { if err != nil {
return "", err return "", err
+3
View File
@@ -421,6 +421,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.PUT("/templates/:id", handleUpdateTemplate) authed.PUT("/templates/:id", handleUpdateTemplate)
authed.DELETE("/templates/:id", handleDeleteTemplate) authed.DELETE("/templates/:id", handleDeleteTemplate)
authed.POST("/templates/:id/deploy", handleCreateFromTemplate) 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.POST("/cron-jobs", handleCreateCronJob)
authed.PUT("/cron-jobs/:id", handleUpdateCronJob) 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 ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb" "containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/secrets"
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
@@ -34,6 +36,7 @@ type ServiceTemplate struct {
} }
type TemplateConfig struct { type TemplateConfig struct {
Version int `json:"version,omitempty"`
Type string `json:"type"` Type string `json:"type"`
Runtime string `json:"runtime"` Runtime string `json:"runtime"`
BuildCommand string `json:"build_command"` BuildCommand string `json:"build_command"`
@@ -43,6 +46,9 @@ type TemplateConfig struct {
Environment map[string]string `json:"environment"` Environment map[string]string `json:"environment"`
Dockerfile string `json:"dockerfile,omitempty"` Dockerfile string `json:"dockerfile,omitempty"`
NixpacksConfig map[string]string `json:"nixpacks_config,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 { type TemplateVariable struct {
@@ -147,7 +153,7 @@ func handleCreateFromTemplate(c *gin.Context) {
var req struct { var req struct {
ProjectID string `json:"project_id" binding:"required"` ProjectID string `json:"project_id" binding:"required"`
Name string `json:"name" binding:"required"` Name string `json:"name"`
Plan string `json:"plan,omitempty"` Plan string `json:"plan,omitempty"`
Region string `json:"region,omitempty"` Region string `json:"region,omitempty"`
Variables map[string]string `json:"variables"` Variables map[string]string `json:"variables"`
@@ -160,10 +166,6 @@ func handleCreateFromTemplate(c *gin.Context) {
req.ProjectID = strings.TrimSpace(req.ProjectID) req.ProjectID = strings.TrimSpace(req.ProjectID)
req.Name = strings.TrimSpace(req.Name) 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) projectID, err := uuid.Parse(req.ProjectID)
if err != nil { if err != nil {
@@ -213,6 +215,51 @@ func handleCreateFromTemplate(c *gin.Context) {
return 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) serviceType, err := normalizeTemplateServiceType(config.Type)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -351,11 +398,15 @@ func handleCreateFromTemplate(c *gin.Context) {
continue continue
} }
stored := value
if secretKeys[key] {
stored = secrets.Encrypt(value)
}
err = txQueries.UpsertEnvironmentVariable(ctx, sqlcdb.UpsertEnvironmentVariableParams{ err = txQueries.UpsertEnvironmentVariable(ctx, sqlcdb.UpsertEnvironmentVariableParams{
ID: uuid.New(), ID: uuid.New(),
ServiceID: serviceID, ServiceID: serviceID,
Key: key, Key: key,
Value: value, Value: stored,
IsSecret: sql.NullBool{Bool: secretKeys[key], Valid: true}, IsSecret: sql.NullBool{Bool: secretKeys[key], Valid: true},
CreatedAt: sql.NullTime{Time: now, Valid: true}, CreatedAt: sql.NullTime{Time: now, Valid: true},
UpdatedAt: sql.NullTime{Time: now, Valid: true}, UpdatedAt: sql.NullTime{Time: now, Valid: true},
@@ -927,6 +978,34 @@ func SeedTemplates() []ServiceTemplate {
Variables: `[]`, Variables: `[]`,
IsOfficial: true, 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 return templates
} }
+136 -2
View File
@@ -1,8 +1,11 @@
package commands package commands
import ( import (
"encoding/json"
"fmt" "fmt"
"io"
"os" "os"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -104,16 +107,31 @@ var templatesDeleteCmd = &cobra.Command{
}, },
} }
var tplDeployVars []string
var tplDeployName string
var templatesDeployCmd = &cobra.Command{ var templatesDeployCmd = &cobra.Command{
Use: "deploy <template-id> <project-id>", Use: "deploy <template-id> <project-id>",
Short: "Deploy a template into a project", Short: "Deploy a template into a project",
Long: "Deploys a single-service template or a multi-service graph (v2) template. Pass --name for single-service templates; graph members take names from the template. Use --var KEY=VALUE to override template variables.",
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
c, err := client() c, err := client()
if err != nil { if err != nil {
return err return err
} }
data, err := c.Do("POST", "/templates/"+args[0]+"/deploy", map[string]string{"project_id": args[1]}) body := map[string]interface{}{"project_id": args[1]}
if tplDeployName != "" {
body["name"] = tplDeployName
}
vars, err := parseKVFlags(tplDeployVars)
if err != nil {
return err
}
if len(vars) > 0 {
body["variables"] = vars
}
data, err := c.Do("POST", "/templates/"+args[0]+"/deploy", body)
if err != nil { if err != nil {
return err return err
} }
@@ -122,6 +140,122 @@ var templatesDeployCmd = &cobra.Command{
}, },
} }
var templatesPlanCmd = &cobra.Command{
Use: "plan <template-id>",
Short: "Dry-resolve a template — expanded expressions, service refs, missing variables",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
vars, err := parseKVFlags(tplDeployVars)
if err != nil {
return err
}
body := map[string]interface{}{}
if len(vars) > 0 {
body["variables"] = vars
}
data, err := c.Do("POST", "/templates/"+args[0]+"/plan", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var templatesImportComposeCmd = &cobra.Command{
Use: "import-compose <compose.yml|->",
Short: "Convert a docker-compose file into a template graph config",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var raw []byte
var err error
if args[0] == "-" {
raw, err = io.ReadAll(os.Stdin)
} else {
raw, err = os.ReadFile(args[0])
}
if err != nil {
return fmt.Errorf("cannot read compose file: %w", err)
}
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/templates/import/compose", map[string]string{"compose_yaml": string(raw)})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var templatesDeployGraphCmd = &cobra.Command{
Use: "deploy-graph <project-id> <config.json|->",
Short: "Deploy an ad-hoc service graph (v2 config with services[]) without a stored template",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
var raw []byte
var err error
if args[1] == "-" {
raw, err = io.ReadAll(os.Stdin)
} else {
raw, err = os.ReadFile(args[1])
}
if err != nil {
return fmt.Errorf("cannot read config: %w", err)
}
var config json.RawMessage
if err := json.Unmarshal(raw, &config); err != nil {
return fmt.Errorf("invalid config JSON: %w", err)
}
c, err := client()
if err != nil {
return err
}
vars, err := parseKVFlags(tplDeployVars)
if err != nil {
return err
}
body := map[string]interface{}{"project_id": args[0]}
var cfg map[string]interface{}
if err := json.Unmarshal(config, &cfg); err != nil {
return fmt.Errorf("invalid config JSON: %w", err)
}
body["config"] = cfg
if len(vars) > 0 {
body["variables"] = vars
}
data, err := c.Do("POST", "/templates/deploy", body)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
// parseKVFlags converts repeated --var KEY=VALUE flags into a map.
func parseKVFlags(flags []string) (map[string]string, error) {
out := map[string]string{}
for _, f := range flags {
idx := strings.Index(f, "=")
if idx <= 0 {
return nil, fmt.Errorf("invalid --var %q — expected KEY=VALUE", f)
}
out[f[:idx]] = f[idx+1:]
}
return out, nil
}
func init() { func init() {
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd) templatesDeployCmd.Flags().StringVar(&tplDeployName, "name", "", "Name for the created service (single-service templates)")
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)
} }
+3
View File
@@ -128,6 +128,9 @@ func init() {
register(spec{"containr_templates_list", "List templates", "GET", "/templates", nil, nil, false}) register(spec{"containr_templates_list", "List templates", "GET", "/templates", nil, nil, false})
register(spec{"containr_templates_get", "Get a template definition", "GET", "/templates/{id}", []string{"id"}, nil, false}) register(spec{"containr_templates_get", "Get a template definition", "GET", "/templates/{id}", []string{"id"}, nil, 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_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_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})
// Notifications // Notifications
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false}) register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
@@ -7,6 +7,7 @@ import {
deployTemplate, deployTemplate,
getCurrentUserProfile, getCurrentUserProfile,
getTemplateById, getTemplateById,
importComposeTemplate,
listProjects, listProjects,
listTemplates, listTemplates,
updateTemplate, updateTemplate,
@@ -36,6 +37,8 @@ import {
Trash2, Trash2,
Upload, Upload,
X, X,
FileCode,
Network,
} from 'lucide-react'; } from 'lucide-react';
function toServiceName(value: string): string { function toServiceName(value: string): string {
@@ -147,10 +150,16 @@ export function TemplatesPage() {
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState< const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
Record<string, Record<string, string>> Record<string, Record<string, string>>
>({}); >({});
const [composeOpen, setComposeOpen] = useState(false);
const [composeYaml, setComposeYaml] = useState('');
const [composeWarnings, setComposeWarnings] = useState<string[]>([]);
const [composeError, setComposeError] = useState<string | null>(null);
const [lastDeployment, setLastDeployment] = useState<{ const [lastDeployment, setLastDeployment] = useState<{
projectId: string; projectId: string;
serviceId: string; serviceId?: string;
serviceName: string; serviceName: string;
created?: { key: string; kind: string; id: string; name: string; deploymentId?: string }[];
} | null>(null); } | null>(null);
const templatesQuery = useQuery({ const templatesQuery = useQuery({
@@ -281,6 +290,12 @@ export function TemplatesPage() {
}); });
}, [selectedDetail, variableValues]); }, [selectedDetail, variableValues]);
const graphMembers = useMemo(
() => selectedDetail?.config.services ?? [],
[selectedDetail],
);
const isGraphTemplate = graphMembers.length > 0;
const deployMutation = useMutation({ const deployMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!selectedTemplateId) { if (!selectedTemplateId) {
@@ -296,17 +311,21 @@ export function TemplatesPage() {
return deployTemplate(selectedTemplateId, { return deployTemplate(selectedTemplateId, {
projectId: deployProjectId, projectId: deployProjectId,
name: deployName.trim(), name: isGraphTemplate ? undefined : deployName.trim(),
variables, variables,
}); });
}, },
onSuccess: (result) => { onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['projects'] }); queryClient.invalidateQueries({ queryKey: ['projects'] });
queryClient.invalidateQueries({ queryKey: ['project-services'] }); queryClient.invalidateQueries({ queryKey: ['project-services'] });
queryClient.invalidateQueries({ queryKey: ['databases'] });
setLastDeployment({ setLastDeployment({
projectId: deployProjectId, projectId: deployProjectId,
serviceId: result.serviceId, serviceId: result.serviceId,
serviceName: deployName.trim(), serviceName: isGraphTemplate
? `${result.created?.length ?? 0} resources`
: deployName.trim(),
created: result.created,
}); });
}, },
}); });
@@ -314,7 +333,7 @@ export function TemplatesPage() {
const isDeployDisabled = const isDeployDisabled =
!selectedTemplateId || !selectedTemplateId ||
!deployProjectId || !deployProjectId ||
deployName.trim().length === 0 || (!isGraphTemplate && deployName.trim().length === 0) ||
missingRequiredVariables.length > 0 || missingRequiredVariables.length > 0 ||
deployMutation.isPending; deployMutation.isPending;
@@ -344,6 +363,37 @@ export function TemplatesPage() {
}, },
}); });
const composeImportMutation = useMutation({
mutationFn: (yaml: string) => importComposeTemplate(yaml),
onSuccess: (result) => {
setComposeWarnings(result.warnings);
setComposeError(null);
setComposeOpen(false);
// Load the converted graph into the template editor for review/save.
setEditingTemplate(null);
setEditorIsPublic(false);
setEditorError(null);
setEditorJson(
JSON.stringify(
{
name: 'compose-stack',
description: 'Imported from docker-compose',
category: 'stack',
logo: '',
config: result.config,
variables: [],
},
null,
2,
),
);
setEditorOpen(true);
},
onError: (error) => {
setComposeError(error instanceof Error ? error.message : 'Failed to convert compose file');
},
});
const openCreateEditor = () => { const openCreateEditor = () => {
setEditingTemplate(null); setEditingTemplate(null);
setEditorJson(EMPTY_TEMPLATE_JSON); setEditorJson(EMPTY_TEMPLATE_JSON);
@@ -438,13 +488,27 @@ export function TemplatesPage() {
<Layers size={16} /> <Layers size={16} />
<span>{filteredTemplates.length} templates</span> <span>{filteredTemplates.length} templates</span>
{signedIn && !isDemoMode ? ( {signedIn && !isDemoMode ? (
<button <>
type="button" <button
onClick={openCreateEditor} type="button"
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 text-[12.5px] font-semibold text-[var(--accent-on)]" onClick={() => {
> setComposeYaml('');
<Plus size={14} /> New template setComposeWarnings([]);
</button> setComposeError(null);
setComposeOpen(true);
}}
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3.5 text-[12.5px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
<FileCode size={14} /> Import Compose
</button>
<button
type="button"
onClick={openCreateEditor}
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 text-[12.5px] font-semibold text-[var(--accent-on)]"
>
<Plus size={14} /> New template
</button>
</>
) : null} ) : null}
</div> </div>
</div> </div>
@@ -725,6 +789,42 @@ export function TemplatesPage() {
)} )}
</div> </div>
{isGraphTemplate ? (
<div className="panel-soft p-4 mb-6">
<div className="flex items-center gap-2 mb-3">
<Network size={14} className="text-[var(--accent-primary)]" />
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Stack — {graphMembers.length} members
</p>
</div>
<div className="space-y-2">
{graphMembers.map((member) => (
<div key={member.key} className="flex items-center justify-between gap-3 text-sm">
<div className="flex items-center gap-2 min-w-0">
{member.type === 'database' ? (
<Database size={13} className="text-[var(--info)] shrink-0" />
) : (
<Box size={13} className="text-[var(--text-tertiary)] shrink-0" />
)}
<span className="mono text-[var(--text-primary)] truncate">{member.name || member.key}</span>
{member.dependsOn && member.dependsOn.length > 0 ? (
<span className="text-[10px] text-[var(--text-tertiary)]">
after {member.dependsOn.join(', ')}
</span>
) : null}
</div>
<span className="mono text-[11px] text-[var(--text-tertiary)] truncate">
{member.type === 'database' ? `managed ${member.runtime}` : member.runtime || member.repo || '—'}
</span>
</div>
))}
</div>
<p className="text-xs text-[var(--text-tertiary)] mt-3">
Members join a private project network; references like {'{{service.db.url}}'} resolve to the member key.
</p>
</div>
) : null}
{/* Deploy Section */} {/* Deploy Section */}
<div className="border-t border-[var(--border-subtle)] pt-6"> <div className="border-t border-[var(--border-subtle)] pt-6">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
@@ -733,7 +833,9 @@ export function TemplatesPage() {
</div> </div>
<div> <div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Deploy from Template</h3> <h3 className="text-lg font-semibold text-[var(--text-primary)]">Deploy from Template</h3>
<p className="text-sm text-[var(--text-secondary)]">Configure and create a new service</p> <p className="text-sm text-[var(--text-secondary)]">
{isGraphTemplate ? 'Deploy all members into a project' : 'Configure and create a new service'}
</p>
</div> </div>
</div> </div>
@@ -767,6 +869,7 @@ export function TemplatesPage() {
))} ))}
</select> </select>
</div> </div>
{!isGraphTemplate ? (
<div> <div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Service Name Service Name
@@ -784,6 +887,7 @@ export function TemplatesPage() {
placeholder="service-name" placeholder="service-name"
/> />
</div> </div>
) : null}
</div> </div>
{selectedDetail.variables.length > 0 && ( {selectedDetail.variables.length > 0 && (
@@ -846,15 +950,30 @@ export function TemplatesPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Check size={16} /> <Check size={16} />
<span> <span>
Service <span className="mono font-medium">{lastDeployment.serviceName}</span> created {lastDeployment.created
? `Stack deployed — ${lastDeployment.created.length} resources`
: <>Service <span className="mono font-medium">{lastDeployment.serviceName}</span> created</>}
</span> </span>
<Link <Link
to={`/projects/${lastDeployment.projectId}/services/${lastDeployment.serviceId}${isDemoMode ? '?demo=1' : ''}`} to={lastDeployment.created
? `/projects/${lastDeployment.projectId}${isDemoMode ? '?demo=1' : ''}`
: `/projects/${lastDeployment.projectId}/services/${lastDeployment.serviceId}${isDemoMode ? '?demo=1' : ''}`}
className="flex items-center gap-1 ml-2 underline underline-offset-2 hover:no-underline" className="flex items-center gap-1 ml-2 underline underline-offset-2 hover:no-underline"
> >
View <ArrowRight size={12} /> View <ArrowRight size={12} />
</Link> </Link>
</div> </div>
{lastDeployment.created ? (
<div className="mt-2 space-y-1">
{lastDeployment.created.map((m) => (
<div key={m.id} className="flex items-center gap-2 text-xs">
{m.kind === 'database' ? <Database size={11} /> : <Box size={11} />}
<span className="mono">{m.name}</span>
<span className="opacity-70">({m.kind})</span>
</div>
))}
</div>
) : null}
</div> </div>
)} )}
@@ -868,12 +987,12 @@ export function TemplatesPage() {
{deployMutation.isPending ? ( {deployMutation.isPending ? (
<> <>
<Loader2 size={16} className="animate-spin" /> <Loader2 size={16} className="animate-spin" />
Creating... Deploying...
</> </>
) : ( ) : (
<> <>
<Play size={16} /> <Play size={16} />
Create Service {isGraphTemplate ? 'Deploy Stack' : 'Create Service'}
</> </>
)} )}
</button> </button>
@@ -929,6 +1048,14 @@ export function TemplatesPage() {
spellCheck={false} spellCheck={false}
className="mono min-h-[380px] 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-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
/> />
{composeWarnings.length > 0 ? (
<div className="border-t border-[var(--border-subtle)] bg-[var(--warning-soft)]/60 px-5 py-2.5 text-xs text-[var(--warning)]">
<p className="font-medium mb-1">Compose import notes</p>
{composeWarnings.map((w) => (
<p key={w}>• {w}</p>
))}
</div>
) : null}
{editorError ? ( {editorError ? (
<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)]">
{editorError} {editorError}
@@ -966,6 +1093,59 @@ export function TemplatesPage() {
</div> </div>
</div> </div>
) : null} ) : null}
{/* Compose import */}
{composeOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
<div className="flex items-center justify-between border-b border-[var(--border-subtle)] px-5 py-4">
<div>
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Import docker-compose</h2>
<p className="text-xs text-[var(--text-tertiary)]">
Paste a compose file — known database images become managed databases, everything else becomes services.
</p>
</div>
<button
type="button"
onClick={() => setComposeOpen(false)}
className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
>
<X size={15} />
</button>
</div>
<textarea
value={composeYaml}
onChange={(e) => setComposeYaml(e.target.value)}
spellCheck={false}
placeholder={'services:\n db:\n image: postgres:16\n web:\n image: myapp:latest\n depends_on: [db]'}
className="mono min-h-[320px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
/>
{composeError ? (
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
{composeError}
</div>
) : null}
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
<button
type="button"
onClick={() => setComposeOpen(false)}
className="rounded-[var(--radius-md)] px-3.5 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
>
Cancel
</button>
<button
type="button"
onClick={() => composeImportMutation.mutate(composeYaml)}
disabled={!composeYaml.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"
>
{composeImportMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <FileCode size={13} />}
Convert to template
</button>
</div>
</div>
</div>
) : null}
</div> </div>
); );
} }
+243 -1
View File
@@ -2801,6 +2801,10 @@ export interface paths {
* Create resource from template * Create resource from template
* @description Create a new resource in a project using a selected template. * @description Create a new resource in a project using a selected template.
* For `database` templates, this creates a managed database service. * 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: { post: {
parameters: { parameters: {
@@ -2835,6 +2839,187 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/agent-tokens": {
parameters: { parameters: {
query?: never; query?: never;
@@ -9305,6 +9490,8 @@ export interface components {
is_public?: boolean; is_public?: boolean;
}; };
TemplateConfig: { TemplateConfig: {
/** @description Config version — 2 when services[] is used */
version?: number;
/** @description Service type for created service */ /** @description Service type for created service */
type?: string; type?: string;
/** @description Runtime or base image */ /** @description Runtime or base image */
@@ -9327,6 +9514,52 @@ export interface components {
nixpacks_config?: { nixpacks_config?: {
[key: string]: string; [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: { TemplateVariable: {
/** @description Variable key */ /** @description Variable key */
@@ -9369,11 +9602,20 @@ export interface components {
* @description Type of resource created by template deployment * @description Type of resource created by template deployment
* @enum {string} * @enum {string}
*/ */
resource?: "service" | "database"; resource?: "service" | "database" | "stack";
/** @description Newly created service ID (set for non-database templates) */ /** @description Newly created service ID (set for non-database templates) */
service_id?: string; service_id?: string;
/** @description Newly created managed database ID (set for database templates) */ /** @description Newly created managed database ID (set for database templates) */
database_id?: string; 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 */ /** @description Deployment result message */
message?: string; message?: string;
}; };
+1 -1
View File
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
"path": "guides/CLI.md", "path": "guides/CLI.md",
"section": "guides", "section": "guides",
"title": "Containr CLI", "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", "path": "guides/CLOUDFLARE_SETUP.md",
+122 -8
View File
@@ -164,7 +164,20 @@ export type TemplateEntity = {
updatedAt?: string; updatedAt?: string;
}; };
export type TemplateServiceSpecEntity = {
key: string;
name?: string;
type: string;
runtime?: string;
repo?: string;
branch?: string;
port?: number;
dependsOn?: string[];
environment: Record<string, string>;
};
export type TemplateConfigEntity = { export type TemplateConfigEntity = {
version?: number;
type: string; type: string;
runtime: string; runtime: string;
buildCommand: string; buildCommand: string;
@@ -174,6 +187,7 @@ export type TemplateConfigEntity = {
environment: Record<string, string>; environment: Record<string, string>;
dockerfile?: string; dockerfile?: string;
nixpacksConfig: Record<string, string>; nixpacksConfig: Record<string, string>;
services?: TemplateServiceSpecEntity[];
}; };
export type TemplateVariableEntity = { export type TemplateVariableEntity = {
@@ -197,12 +211,15 @@ export type ListTemplatesInput = {
export type DeployTemplateInput = { export type DeployTemplateInput = {
projectId: string; projectId: string;
name: string; name?: string;
variables?: Record<string, string>; variables?: Record<string, string>;
}; };
export type DeployTemplateResult = { export type DeployTemplateResult = {
serviceId: string; resource: string;
serviceId?: string;
databaseId?: string;
created?: { key: string; kind: string; id: string; name: string; deploymentId?: string }[];
message: string; message: string;
}; };
@@ -696,7 +713,21 @@ function normalizeStringRecord(raw: unknown): Record<string, string> {
} }
function normalizeTemplateConfig(config?: RawTemplateConfig): TemplateConfigEntity { function normalizeTemplateConfig(config?: RawTemplateConfig): TemplateConfigEntity {
const services = Array.isArray(config?.services)
? config.services.map((s) => ({
key: s?.key ?? '',
name: s?.name ?? undefined,
type: s?.type ?? 'web',
runtime: s?.runtime ?? undefined,
repo: s?.repo ?? undefined,
branch: s?.branch ?? undefined,
port: s?.port ?? undefined,
dependsOn: Array.isArray(s?.depends_on) ? s.depends_on.filter((d): d is string => typeof d === 'string') : undefined,
environment: normalizeStringRecord(s?.environment),
}))
: undefined;
return { return {
version: config?.version ?? undefined,
type: config?.type ?? '', type: config?.type ?? '',
runtime: config?.runtime ?? '', runtime: config?.runtime ?? '',
buildCommand: config?.build_command ?? '', buildCommand: config?.build_command ?? '',
@@ -706,6 +737,7 @@ function normalizeTemplateConfig(config?: RawTemplateConfig): TemplateConfigEnti
environment: normalizeStringRecord(config?.environment), environment: normalizeStringRecord(config?.environment),
dockerfile: config?.dockerfile ?? undefined, dockerfile: config?.dockerfile ?? undefined,
nixpacksConfig: normalizeStringRecord(config?.nixpacks_config), nixpacksConfig: normalizeStringRecord(config?.nixpacks_config),
services,
}; };
} }
@@ -1602,21 +1634,103 @@ export async function deployTemplate(
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
project_id: input.projectId, project_id: input.projectId,
name: input.name, name: input.name ?? '',
variables: input.variables ?? {}, variables: input.variables ?? {},
}), }),
}); });
if (!payload.service_id) { const created = Array.isArray(payload.created)
throw new ApiError('Template deployment response is invalid', 500); ? payload.created
} .filter((m) => m && typeof m.id === 'string')
.map((m) => ({
key: m.key ?? '',
kind: m.kind ?? '',
id: m.id ?? '',
name: m.name ?? '',
deploymentId: m.deployment_id ?? undefined,
}))
: undefined;
return { return {
serviceId: payload.service_id, resource: payload.resource ?? 'service',
message: payload.message ?? 'Service created from template', serviceId: payload.service_id ?? undefined,
databaseId: payload.database_id ?? undefined,
created,
message: payload.message ?? 'Template deployed',
}; };
} }
export type TemplatePlanMember = {
key: string;
name: string;
type: string;
runtime?: string;
port?: number;
dependsOn?: string[];
environment: Record<string, string>;
};
export type TemplatePlanResult = {
members: TemplatePlanMember[];
missingVariables: string[];
unresolved: string[];
};
export async function planTemplate(
templateId: string,
input: { projectId?: string; variables?: Record<string, string> },
): Promise<TemplatePlanResult> {
const payload = await requestJson<{
members?: {
key?: string;
name?: string;
type?: string;
runtime?: string;
port?: number;
depends_on?: string[];
environment?: Record<string, string>;
}[];
missing_variables?: string[];
unresolved?: string[];
}>(`/templates/${templateId}/plan`, {
method: 'POST',
body: JSON.stringify({
project_id: input.projectId ?? '',
variables: input.variables ?? {},
}),
});
return {
members: (payload.members ?? []).map((m) => ({
key: m.key ?? '',
name: m.name ?? '',
type: m.type ?? '',
runtime: m.runtime ?? undefined,
port: m.port ?? undefined,
dependsOn: m.depends_on ?? undefined,
environment: normalizeStringRecord(m.environment),
})),
missingVariables: payload.missing_variables ?? [],
unresolved: payload.unresolved ?? [],
};
}
export type ComposeImportResult = {
config: Record<string, unknown>;
warnings: string[];
};
export async function importComposeTemplate(composeYaml: string): Promise<ComposeImportResult> {
const payload = await requestJson<{ config?: Record<string, unknown>; warnings?: string[] }>(
'/templates/import/compose',
{ method: 'POST', body: JSON.stringify({ compose_yaml: composeYaml }) },
);
if (!payload.config || typeof payload.config !== 'object') {
throw new ApiError('Compose import response is invalid', 500);
}
return { config: payload.config, warnings: payload.warnings ?? [] };
}
export function serviceStatusClass(status: ServiceStatus): string { export function serviceStatusClass(status: ServiceStatus): string {
switch (status) { switch (status) {
case 'running': case 'running':
+269 -1
View File
@@ -1870,6 +1870,10 @@ paths:
description: | description: |
Create a new resource in a project using a selected template. Create a new resource in a project using a selected template.
For `database` templates, this creates a managed database service. 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.
security: security:
- bearerAuth: [] - bearerAuth: []
parameters: parameters:
@@ -1893,6 +1897,169 @@ paths:
schema: schema:
$ref: '#/components/schemas/DeployTemplateResponse' $ref: '#/components/schemas/DeployTemplateResponse'
/templates/{id}/plan:
post:
tags:
- Templates
summary: 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.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
description: Template ID
schema:
type: string
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
project_id:
type: string
variables:
type: object
additionalProperties:
type: string
responses:
'200':
description: Resolved plan
content:
application/json:
schema:
type: object
properties:
members:
type: array
items:
$ref: '#/components/schemas/TemplatePlanMember'
missing_variables:
type: array
items:
type: string
unresolved:
type: array
items:
type: string
/templates/import/compose:
post:
tags:
- Templates
summary: 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.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- compose_yaml
properties:
compose_yaml:
type: string
description: Raw docker-compose YAML (max 512 KiB)
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
/templates/deploy:
post:
tags:
- Templates
summary: 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.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- project_id
- config
properties:
project_id:
type: string
config:
$ref: '#/components/schemas/TemplateConfig'
variables:
type: object
additionalProperties:
type: string
plan:
type: string
region:
type: string
responses:
'201':
description: Graph deployed
content:
application/json:
schema:
type: object
properties:
resource:
type: string
created:
type: array
items:
type: object
properties:
key:
type: string
kind:
type: string
id:
type: string
name:
type: string
deployment_id:
type: string
# Agent Onboarding Tokens # Agent Onboarding Tokens
/agent-tokens: /agent-tokens:
post: post:
@@ -6341,6 +6508,9 @@ components:
TemplateConfig: TemplateConfig:
type: object type: object
properties: properties:
version:
type: integer
description: Config version — 2 when services[] is used
type: type:
type: string type: string
description: Service type for created service description: Service type for created service
@@ -6372,6 +6542,87 @@ components:
additionalProperties: additionalProperties:
type: string type: string
description: Optional nixpacks-specific config description: Optional nixpacks-specific config
services:
type: array
description: |
v2 graph members. When non-empty, the flat fields above are
ignored and deploy creates the whole graph in dependency order.
items:
$ref: '#/components/schemas/TemplateServiceSpec'
TemplateServiceSpec:
type: object
required:
- key
- type
properties:
key:
type: string
description: Member key — DNS alias on the project network and the target of {{service.<key>.*}} refs
name:
type: string
description: Display/deployed name (defaults to key)
type:
type: string
enum: [web, worker, cron, database]
runtime:
type: string
description: Image reference (web/worker/cron) or managed engine (database)
repo:
type: string
description: Git repository URL — when set, the member builds from source
branch:
type: string
build_command:
type: string
start_command:
type: string
port:
type: integer
health_check:
type: string
builder:
type: string
enum: [auto, nixpacks, dockerfile, static]
environment:
type: object
additionalProperties:
type: string
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.
volumes:
type: array
items:
$ref: '#/components/schemas/ServiceVolume'
depends_on:
type: array
items:
type: string
description: Member keys that must be created first
TemplatePlanMember:
type: object
properties:
key:
type: string
name:
type: string
type:
type: string
runtime:
type: string
port:
type: integer
depends_on:
type: array
items:
type: string
environment:
type: object
additionalProperties:
type: string
TemplateVariable: TemplateVariable:
type: object type: object
@@ -6437,7 +6688,7 @@ components:
properties: properties:
resource: resource:
type: string type: string
enum: [service, database] enum: [service, database, stack]
description: Type of resource created by template deployment description: Type of resource created by template deployment
service_id: service_id:
type: string type: string
@@ -6445,6 +6696,23 @@ components:
database_id: database_id:
type: string type: string
description: Newly created managed database ID (set for database templates) description: Newly created managed database ID (set for database templates)
created:
type: array
description: Created resources (set for graph templates — resource=stack)
items:
type: object
properties:
key:
type: string
kind:
type: string
enum: [service, database]
id:
type: string
name:
type: string
deployment_id:
type: string
message: message:
type: string type: string
description: Deployment result message description: Deployment result message
+5 -1
View File
@@ -61,7 +61,11 @@ backup-targets list|add|update|remove|test
first, empty --access-key/--secret-key on update keep stored keys; first, empty --access-key/--secret-key on update keep stored keys;
update <db-id> --backup-target assigns a target ("" clears it) update <db-id> --backup-target assigns a target ("" clears it)
cron list|get|create|delete|trigger|executions cron list|get|create|delete|trigger|executions
templates list|get|create|delete|deploy templates list|get|create|delete|deploy|plan|import-compose|deploy-graph
deploy --name --var K=V; plan dry-resolves expressions;
import-compose <compose.yml|-> converts a compose file into
a v2 graph config; deploy-graph <project-id> <config.json|->
deploys an ad-hoc service graph
nodes list|get|delete|tokens node agents + onboarding tokens (admin) nodes list|get|delete|tokens node agents + onboarding tokens (admin)
scaling status|policies|services|scale scaling status|policies|services|scale
ha status|alerts|health|policies|enable|disable|failover ha status|alerts|health|policies|enable|disable|failover
+1
View File
@@ -26,6 +26,7 @@ Base URL: `https://<host>/api/v1`. OpenAPI: `docs/api/openapi.yaml`.
- **Public DB port**: managed databases bind `127.0.0.1` by default; `public_port:true` on create or `PUT /databases/{id} {public_port}` binds `0.0.0.0`. Toggling recreates the container — the data volume persists. - **Public DB port**: managed databases bind `127.0.0.1` by default; `public_port:true` on create or `PUT /databases/{id} {public_port}` binds `0.0.0.0`. Toggling recreates the container — the data volume persists.
- **Backup targets**: S3-compatible destinations (`/backup-targets` CRUD + `POST /backup-targets/{id}/test`). Endpoints/buckets are probed before storing; keys encrypted at rest (`has_credentials` only). Assign via `PUT /databases/{id} {backup_target_id}` — empty string clears; external databases reject assignment. Managed backups stay local; a copy ships to `[prefix/]databases/<db-id>/<archive>` and rows expose `remote:true`. Restore prefers the local archive and falls back to the remote object. Deleting a target is refused (409) while databases reference it. - **Backup targets**: S3-compatible destinations (`/backup-targets` CRUD + `POST /backup-targets/{id}/test`). Endpoints/buckets are probed before storing; keys encrypted at rest (`has_credentials` only). Assign via `PUT /databases/{id} {backup_target_id}` — empty string clears; external databases reject assignment. Managed backups stay local; a copy ships to `[prefix/]databases/<db-id>/<archive>` and rows expose `remote:true`. Restore prefers the local archive and falls back to the remote object. Deleting a target is refused (409) while databases reference it.
- **Backup import**: `POST /databases/{id}/backups/import` — upload a `.tar.gz` (multipart `file` or raw `application/gzip` body, ≤2 GiB) to create a restore point; also ships offsite when a target is assigned. CLI: `databases import-backup <id> <file|->`. - **Backup import**: `POST /databases/{id}/backups/import` — upload a `.tar.gz` (multipart `file` or raw `application/gzip` body, ≤2 GiB) to create a restore point; also ships offsite when a target is assigned. CLI: `databases import-backup <id> <file|->`.
- **Template graphs (v2)**: a template `config` with `services[]` deploys a multi-service stack. Members declare `key`, `type` (`web|worker|cron|database`), `runtime`, `environment`, `depends_on`. Env values support expressions: `{{secret}}`, `{{secret(N)}}`, `{{random:N}}`, `{{VAR}}`, `{{VAR:-default}}`, `{{service.<key>.<prop>}}` (props: `host`, `port`, `url`, `user`, `password`, `database`). `database` members become managed databases attached to the project network under their key — siblings resolve them by name. `POST /templates/{id}/plan` dry-resolves; `POST /templates/deploy` deploys an inline graph; `POST /templates/import/compose` converts docker-compose YAML (known db images → managed databases). Service↔service refs `${{svc.KEY}}` also resolve at runtime (HOST/PORT or env vars).
- **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens. - **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens.
## Core objects ## Core objects