mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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:
1 parent
15045b8dd3
commit
b4baa853b2
16 files changed
+2479
-48
No files matched your search
+2
-2
@@ -13,6 +13,7 @@ require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/lib/pq v1.10.9
|
||||
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/pressly/goose/v3 v3.24.1
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
@@ -23,6 +24,7 @@ require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/term v0.46.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -59,7 +61,6 @@ require (
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/minio/crc64nvme v1.0.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/sys/atomicwriter v0.1.0 // 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/tools v0.49.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
modernc.org/sqlite v1.39.0 // indirect
|
||||
)
|
||||
@@ -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-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
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/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
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/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.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/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
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/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
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.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -104,16 +107,31 @@ var templatesDeleteCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var tplDeployVars []string
|
||||
var tplDeployName string
|
||||
|
||||
var templatesDeployCmd = &cobra.Command{
|
||||
Use: "deploy <template-id> <project-id>",
|
||||
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),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
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 {
|
||||
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() {
|
||||
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)
|
||||
}
|
||||
@@ -126,6 +126,9 @@ func init() {
|
||||
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_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
|
||||
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
|
||||
|
||||
Reference in new issue
Block a user