diff --git a/app/backend/go.mod b/app/backend/go.mod index f3fae01..52feecf 100644 --- a/app/backend/go.mod +++ b/app/backend/go.mod @@ -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 ) diff --git a/app/backend/go.sum b/app/backend/go.sum index 5d253b0..ce49d5e 100644 --- a/app/backend/go.sum +++ b/app/backend/go.sum @@ -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= diff --git a/app/backend/internal/api/databases.go b/app/backend/internal/api/databases.go index aa533a2..46579eb 100644 --- a/app/backend/internal/api/databases.go +++ b/app/backend/internal/api/databases.go @@ -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..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 diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index ea09d40..469bcbc 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -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) diff --git a/app/backend/internal/api/templategraph.go b/app/backend/internal/api/templategraph.go new file mode 100644 index 0000000..d6f9488 --- /dev/null +++ b/app/backend/internal/api/templategraph.go @@ -0,0 +1,1225 @@ +package api + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "math/big" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "containr/internal/database" + "containr/internal/database/sqlcdb" + "containr/internal/deployment" + "containr/internal/deployqueue" + "containr/internal/secrets" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gopkg.in/yaml.v3" +) + +// TemplateServiceSpec describes one member of a multi-service (v2) template +// graph. A graph template carries services[] on TemplateConfig; legacy +// templates keep the flat single-service fields and are untouched by this +// path. +type TemplateServiceSpec struct { + Key string `json:"key"` + Name string `json:"name,omitempty"` + Type string `json:"type"` // web | worker | cron | database + Runtime string `json:"runtime,omitempty"` + Repo string `json:"repo,omitempty"` + Branch string `json:"branch,omitempty"` + BuildCommand string `json:"build_command,omitempty"` + StartCommand string `json:"start_command,omitempty"` + Port int `json:"port,omitempty"` + HealthCheck string `json:"health_check,omitempty"` + Builder string `json:"builder,omitempty"` + Environment map[string]string `json:"environment,omitempty"` + Volumes []ServiceVolume `json:"volumes,omitempty"` + DependsOn []string `json:"depends_on,omitempty"` +} + +var ( + tmplExprService = regexp.MustCompile(`\{\{\s*service\.([A-Za-z0-9_-]+)\.([a-z_]+)\s*\}\}`) + tmplExprSecret = regexp.MustCompile(`\{\{\s*secret(?:\((\d+)\))?\s*\}\}`) + tmplExprRandom = regexp.MustCompile(`\{\{\s*random:(\d+)\s*\}\}`) + tmplExprVar = regexp.MustCompile(`\{\{\s*([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\s*\}\}`) + tmplKeyValid = regexp.MustCompile(`^[a-z][a-z0-9_-]*$`) +) + +// databaseRefSpec maps a managed-database engine to the env keys its +// provisioning plan reads, the internal port and the URL scheme used when +// another member references it via {{service..url}}. +type databaseRefSpec struct { + userKey string + passKey string + dbKey string + scheme string + port int + // defUser mirrors the fallback used by buildDatabaseRuntimePlan so refs + // resolve to the same value the container ends up with. + defUser string +} + +var databaseRefSpecs = map[string]databaseRefSpec{ + "postgresql": {userKey: "POSTGRES_USER", passKey: "POSTGRES_PASSWORD", dbKey: "POSTGRES_DB", scheme: "postgresql", port: 5432, defUser: "app"}, + "redis": {passKey: "REDIS_PASSWORD", scheme: "redis", port: 6379, defUser: "default"}, + "dragonfly": {passKey: "DRAGONFLY_PASSWORD", scheme: "redis", port: 6379, defUser: "default"}, + "mysql": {userKey: "MYSQL_USER", passKey: "MYSQL_PASSWORD", dbKey: "MYSQL_DATABASE", scheme: "mysql", port: 3306, defUser: "app"}, + "mariadb": {userKey: "MARIADB_USER", passKey: "MARIADB_PASSWORD", dbKey: "MARIADB_DATABASE", scheme: "mysql", port: 3306, defUser: "app"}, + "mongodb": {userKey: "MONGO_INITDB_ROOT_USERNAME", passKey: "MONGO_INITDB_ROOT_PASSWORD", dbKey: "MONGO_INITDB_DATABASE", scheme: "mongodb", port: 27017, defUser: "admin"}, + "clickhouse": {userKey: "CLICKHOUSE_USER", passKey: "CLICKHOUSE_PASSWORD", dbKey: "CLICKHOUSE_DB", scheme: "http", port: 8123, defUser: "default"}, +} + +// normalizeGraphAlias turns a member key into a DNS-safe network alias. +func normalizeGraphAlias(key string) string { + alias := strings.ToLower(strings.TrimSpace(key)) + var b strings.Builder + for _, r := range alias { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-': + b.WriteRune(r) + default: + b.WriteRune('-') + } + } + return strings.Trim(b.String(), "-") +} + +// topoSortServices orders graph members so every depends_on edge resolves +// before its dependent. Returns an error on cycles or unknown keys. +func topoSortServices(specs []TemplateServiceSpec) ([]TemplateServiceSpec, error) { + byKey := make(map[string]TemplateServiceSpec, len(specs)) + for _, s := range specs { + key := normalizeGraphAlias(s.Key) + if key == "" || !tmplKeyValid.MatchString(key) { + return nil, fmt.Errorf("invalid service key %q — use lowercase letters, digits, '-' or '_'", s.Key) + } + if _, dup := byKey[key]; dup { + return nil, fmt.Errorf("duplicate service key %q", key) + } + s.Key = key + byKey[key] = s + } + + indegree := make(map[string]int, len(byKey)) + for key := range byKey { + indegree[key] = 0 + } + for key, s := range byKey { + for _, dep := range s.DependsOn { + d := normalizeGraphAlias(dep) + if _, ok := byKey[d]; !ok { + return nil, fmt.Errorf("service %q depends on unknown key %q", key, dep) + } + if d == key { + return nil, fmt.Errorf("service %q depends on itself", key) + } + indegree[key]++ + } + } + + var ready []string + for key, deg := range indegree { + if deg == 0 { + ready = append(ready, key) + } + } + // Deterministic order: keep declaration order for equal in-degrees. + declOrder := make(map[string]int, len(specs)) + for i, s := range specs { + declOrder[normalizeGraphAlias(s.Key)] = i + } + sortKeys := func(keys []string) { + for i := 1; i < len(keys); i++ { + for j := i; j > 0 && declOrder[keys[j]] < declOrder[keys[j-1]]; j-- { + keys[j], keys[j-1] = keys[j-1], keys[j] + } + } + } + sortKeys(ready) + + var sorted []TemplateServiceSpec + for len(ready) > 0 { + key := ready[0] + ready = ready[1:] + sorted = append(sorted, byKey[key]) + for other, s := range byKey { + for _, dep := range s.DependsOn { + if normalizeGraphAlias(dep) == key { + indegree[other]-- + if indegree[other] == 0 { + ready = append(ready, other) + } + } + } + } + sortKeys(ready) + } + + if len(sorted) != len(byKey) { + return nil, fmt.Errorf("service dependency graph contains a cycle") + } + return sorted, nil +} + +// generatedSecrets records which member/key pairs received a generated +// secret so the value can be persisted with is_secret=true. +type resolvedGraph struct { + members []resolvedMember + unresolved []string +} + +type resolvedMember struct { + spec TemplateServiceSpec + env map[string]string + secretKeys map[string]bool +} + +func generateSecretHex(n int) string { + if n <= 0 || n > 128 { + n = 16 + } + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "" + } + return hex.EncodeToString(buf) +} + +// resolveStaticExpr expands {{secret}}, {{secret(N)}}, {{random:N}}, {{VAR}} +// and {{VAR:-default}} within a single value. Unknown vars without defaults +// are recorded in unresolved. +func resolveStaticExpr(value string, userVars map[string]string, unresolved *[]string, generated *bool) string { + v := tmplExprSecret.ReplaceAllStringFunc(value, func(m string) string { + if generated != nil { + *generated = true + } + sub := tmplExprSecret.FindStringSubmatch(m) + n, _ := strconv.Atoi(sub[1]) + return generateSecretHex(n) + }) + v = tmplExprRandom.ReplaceAllStringFunc(v, func(m string) string { + sub := tmplExprRandom.FindStringSubmatch(m) + max, err := strconv.ParseInt(sub[1], 10, 64) + if err != nil || max <= 0 { + return m + } + n, err := rand.Int(rand.Reader, big.NewInt(max)) + if err != nil { + return m + } + return n.String() + }) + v = tmplExprVar.ReplaceAllStringFunc(v, func(m string) string { + sub := tmplExprVar.FindStringSubmatch(m) + key := sub[1] + if val, ok := userVars[key]; ok { + return val + } + if sub[2] != "" { + return sub[2] + } + if unresolved != nil { + *unresolved = append(*unresolved, key) + } + return "" + }) + return v +} + +// resolveTemplateGraph performs the two-pass resolution: pass 1 expands +// static expressions per member; pass 2 builds the per-service property +// table (host/port/url/credentials) and expands {{service.KEY.prop}}. +func resolveTemplateGraph(sorted []TemplateServiceSpec, userVars map[string]string) resolvedGraph { + graph := resolvedGraph{members: make([]resolvedMember, 0, len(sorted)), unresolved: []string{}} + + // Pass 1: static expressions. Database members get their credential + // keys pre-filled so {{service.db.password}} always resolves — the same + // value is then passed to the engine's own env vars at provisioning. + for _, spec := range sorted { + env := map[string]string{} + for k, v := range spec.Environment { + env[k] = v + } + if normalizeTemplateMemberType(spec) == "database" { + engine := normalizeDatabaseType(spec.Runtime) + if ref, ok := databaseRefSpecs[engine]; ok { + if ref.passKey != "" && strings.TrimSpace(env[ref.passKey]) == "" { + env[ref.passKey] = "{{secret}}" + } + } + } + member := resolvedMember{spec: spec, env: map[string]string{}, secretKeys: map[string]bool{}} + for k, v := range env { + generated := false + resolved := resolveStaticExpr(v, userVars, &graph.unresolved, &generated) + member.env[k] = resolved + if generated { + member.secretKeys[k] = true + } + } + graph.members = append(graph.members, member) + } + + // Pass 2: property tables. + propTables := make(map[string]map[string]string, len(sorted)) + for _, m := range graph.members { + props := map[string]string{} + if normalizeTemplateMemberType(m.spec) == "database" { + engine := normalizeDatabaseType(m.spec.Runtime) + ref := databaseRefSpecs[engine] + alias := m.spec.Key + dbName := m.spec.Name + if dbName == "" { + dbName = m.spec.Key + } + user := "" + if ref.userKey != "" { + user = m.env[ref.userKey] + if user == "" { + user = ref.defUser + } + } + password := "" + if ref.passKey != "" { + password = m.env[ref.passKey] + } + database := "" + if ref.dbKey != "" { + database = m.env[ref.dbKey] + if database == "" { + database = dbName + } + } + user = sanitizeDBIdentifier(user, ref.defUser) + database = sanitizeDBIdentifier(database, "app") + + props["host"] = alias + props["port"] = strconv.Itoa(ref.port) + if user != "" { + props["user"] = user + props["username"] = user + } + if password != "" { + props["password"] = password + } + if database != "" { + props["database"] = database + props["db"] = database + } + url := graphDatabaseURL(ref, user, password, alias, database) + if url != "" { + props["url"] = url + props["connection_url"] = url + } + } else { + name := m.spec.Name + if name == "" { + name = m.spec.Key + } + host := normalizeGraphAlias(name) + props["host"] = host + if m.spec.Port > 0 { + props["port"] = strconv.Itoa(m.spec.Port) + props["url"] = fmt.Sprintf("http://%s:%d", host, m.spec.Port) + } + } + propTables[m.spec.Key] = props + } + + for i := range graph.members { + m := &graph.members[i] + for k, v := range m.env { + m.env[k] = tmplExprService.ReplaceAllStringFunc(v, func(match string) string { + sub := tmplExprService.FindStringSubmatch(match) + refKey := normalizeGraphAlias(sub[1]) + prop := sub[2] + if props, ok := propTables[refKey]; ok { + if val, ok := props[prop]; ok { + // Credential-bearing refs mark the consuming key + // secret so it stores encrypted and masks in plan. + if prop == "password" || prop == "url" || prop == "connection_url" { + m.secretKeys[k] = true + } + return val + } + } + graph.unresolved = append(graph.unresolved, fmt.Sprintf("service.%s.%s", refKey, prop)) + return match + }) + } + } + + return graph +} + +func graphDatabaseURL(ref databaseRefSpec, user, password, host, database string) string { + port := strconv.Itoa(ref.port) + switch ref.scheme { + case "redis": + // redis://[:password@]host:port/0 — user slot stays "default" for + // clients that require it. + u := user + if u == "" { + u = "default" + } + return fmt.Sprintf("redis://%s:%s@%s:%s/0", u, password, host, port) + case "mysql": + return fmt.Sprintf("mysql://%s:%s@%s:%s/%s", user, password, host, port, database) + case "mongodb": + return fmt.Sprintf("mongodb://%s:%s@%s:%s/%s", user, password, host, port, database) + case "http": + return fmt.Sprintf("http://%s:%s@%s:%s/%s", user, password, host, port, database) + default: + return fmt.Sprintf("postgresql://%s:%s@%s:%s/%s?sslmode=disable", user, password, host, port, database) + } +} + +func normalizeTemplateMemberType(spec TemplateServiceSpec) string { + t, err := normalizeTemplateServiceType(spec.Type) + if err != nil { + return "web" + } + return t +} + +// validateTemplateGraph checks member shapes that topo sort and resolution +// don't cover: supported types, image/runtime presence, database engines. +func validateTemplateGraph(sorted []TemplateServiceSpec) error { + for _, spec := range sorted { + t := normalizeTemplateMemberType(spec) + if t == "database" { + engine := normalizeDatabaseType(spec.Runtime) + if _, ok := supportedDatabaseTypes[engine]; !ok { + return fmt.Errorf("service %q: unsupported database type %q", spec.Key, spec.Runtime) + } + continue + } + if strings.TrimSpace(spec.Runtime) == "" && strings.TrimSpace(spec.Repo) == "" { + return fmt.Errorf("service %q: needs a runtime (image) or repo", spec.Key) + } + } + return nil +} + +// ---- Plan endpoint -------------------------------------------------------- + +type templatePlanMember struct { + Key string `json:"key"` + Name string `json:"name"` + Type string `json:"type"` + Runtime string `json:"runtime,omitempty"` + Port int `json:"port,omitempty"` + DependsOn []string `json:"depends_on,omitempty"` + Environment map[string]string `json:"environment"` +} + +// handlePlanTemplate dry-resolves a template (v1 or v2): variable merging, +// expression expansion and service refs, without creating anything. +// Generated secrets are shown masked — deploy regenerates them. +func handlePlanTemplate(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + db := c.MustGet("db").(*database.DB) + queries := sqlcdb.New(db.DB) + ctx := c.Request.Context() + + var req struct { + ProjectID string `json:"project_id"` + Variables map[string]string `json:"variables"` + } + if err := c.ShouldBindJSON(&req); err != nil && err.Error() != "EOF" { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + _ = userID + + templateRow, err := queries.GetServiceTemplateByID(ctx, c.Param("id")) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"}) + return + } + template := mapSQLCTemplate(templateRow) + if template.ID == "" || !templateVisibleTo(c, template) { + c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"}) + return + } + + var config TemplateConfig + if err := json.Unmarshal([]byte(template.Config), &config); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Template configuration is invalid"}) + return + } + var templateVars []TemplateVariable + _ = json.Unmarshal([]byte(template.Variables), &templateVars) + + varsByKey := map[string]TemplateVariable{} + for _, v := range templateVars { + varsByKey[v.Key] = v + } + userVars := map[string]string{} + for _, v := range templateVars { + if req.Variables != nil { + if override, ok := req.Variables[v.Key]; ok { + userVars[v.Key] = override + continue + } + } + if v.Default != "" { + userVars[v.Key] = v.Default + } + } + for k, v := range req.Variables { + userVars[k] = v + } + + missing := []string{} + for _, v := range templateVars { + if v.Required && strings.TrimSpace(userVars[v.Key]) == "" { + missing = append(missing, v.Key) + } + } + + maskSecrets := func(env map[string]string, member resolvedMember) map[string]string { + out := map[string]string{} + for k, v := range env { + if member.secretKeys[k] || (varsByKey[k].Secret) { + out[k] = "********" + } else { + out[k] = v + } + } + return out + } + + if len(config.Services) == 0 { + env, secretKeys, missingReq := mergeTemplateVariables(config.Environment, templateVars, req.Variables) + resolved := resolvedMember{ + spec: TemplateServiceSpec{Key: "service", Type: config.Type, Runtime: config.Runtime, Port: config.Port}, + env: env, + secretKeys: secretKeys, + } + c.JSON(http.StatusOK, gin.H{ + "members": []templatePlanMember{{ + Key: "service", + Type: config.Type, + Runtime: config.Runtime, + Port: config.Port, + Environment: maskSecrets(resolved.env, resolved), + }}, + "missing_variables": missingReq, + "unresolved": []string{}, + }) + return + } + + sorted, err := topoSortServices(config.Services) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := validateTemplateGraph(sorted); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + graph := resolveTemplateGraph(sorted, userVars) + members := make([]templatePlanMember, 0, len(graph.members)) + for _, m := range graph.members { + name := m.spec.Name + if name == "" { + name = m.spec.Key + } + members = append(members, templatePlanMember{ + Key: m.spec.Key, + Name: name, + Type: normalizeTemplateMemberType(m.spec), + Runtime: m.spec.Runtime, + Port: m.spec.Port, + DependsOn: m.spec.DependsOn, + Environment: maskSecrets(m.env, m), + }) + } + + c.JSON(http.StatusOK, gin.H{ + "members": members, + "missing_variables": missing, + "unresolved": graph.unresolved, + }) +} + +// ---- Graph deploy --------------------------------------------------------- + +type graphCreatedResource struct { + Key string `json:"key"` + Kind string `json:"kind"` // service | database + ID string `json:"id"` + Name string `json:"name"` + DeploymentID string `json:"deployment_id,omitempty"` +} + +// deployTemplateGraph runs a resolved graph against a project: databases +// first (async provision + project-network alias), then services, then +// per-service deploy jobs. Partial failure removes rows/containers created +// by this call. +func deployTemplateGraph( + c *gin.Context, + db *database.DB, + engine *deployment.DeploymentEngine, + dbHandler *DatabaseHandler, + projectID uuid.UUID, + userID string, + specs []TemplateServiceSpec, + templateVars []TemplateVariable, + userVars map[string]string, + plan, region string, + source string, +) ([]graphCreatedResource, *gin.H) { + sorted, err := topoSortServices(specs) + if err != nil { + return nil, &gin.H{"error": err.Error()} + } + if err := validateTemplateGraph(sorted); err != nil { + return nil, &gin.H{"error": err.Error()} + } + + missing := []string{} + for _, v := range templateVars { + if v.Required && strings.TrimSpace(userVars[v.Key]) == "" { + missing = append(missing, v.Key) + } + } + if len(missing) > 0 { + return nil, &gin.H{ + "error": fmt.Sprintf("Missing required template variables: %s", strings.Join(missing, ", ")), + "missing_variables": missing, + } + } + + graph := resolveTemplateGraph(sorted, userVars) + if len(graph.unresolved) > 0 { + return nil, &gin.H{ + "error": fmt.Sprintf("Unresolved template references: %s", strings.Join(dedupStrings(graph.unresolved), ", ")), + "unresolved": dedupStrings(graph.unresolved), + } + } + + queries := sqlcdb.New(db.DB) + ctx := c.Request.Context() + + if engine != nil { + // Project network must exist before database containers attach. + if _, err := engine.EnsureProjectNetwork(ctx, projectID.String()); err != nil { + log.Printf("containr: template deploy: ensure project network: %v", err) + } + } + + created := []graphCreatedResource{} + createdServiceIDs := []uuid.UUID{} + createdDatabaseIDs := []string{} + cleanup := func() { + for _, sid := range createdServiceIDs { + if engine != nil { + _ = engine.RemoveServiceContainers(context.Background(), sid.String()) + } + _, _ = db.Exec(`DELETE FROM environment_variables WHERE service_id = $1`, sid) + _, _ = db.Exec(`DELETE FROM services WHERE id = $1`, sid) + } + for _, did := range createdDatabaseIDs { + if dbHandler != nil && dbHandler.dockerClient != nil { + _ = dbHandler.dockerClient.RemoveContainer(context.Background(), managedDatabaseContainerName(did), true) + _ = dbHandler.dockerClient.RemoveVolume(context.Background(), managedDatabaseVolumeName(did), true) + } + _, _ = db.Exec(`DELETE FROM database_services WHERE id = $1`, did) + } + } + + secretTemplateKeys := map[string]bool{} + for _, v := range templateVars { + if v.Secret { + secretTemplateKeys[v.Key] = true + } + } + + for _, m := range graph.members { + memberName := strings.TrimSpace(m.spec.Name) + if memberName == "" { + memberName = m.spec.Key + } + memberType := normalizeTemplateMemberType(m.spec) + + if memberType == "database" { + if dbHandler == nil { + cleanup() + return nil, &gin.H{"error": "Database handler unavailable"} + } + databaseID, err := dbHandler.createManagedDatabaseAndProvision(ctx, userID, managedDatabaseCreateRequest{ + Name: memberName, + Type: m.spec.Runtime, + Plan: plan, + Region: region, + RuntimeVariables: m.env, + ProjectID: projectID.String(), + NetworkAlias: m.spec.Key, + }) + if err != nil { + cleanup() + switch { + case errors.Is(err, errDatabaseNameRequired), errors.Is(err, errUnsupportedDatabaseType), errors.Is(err, errUnsupportedDatabasePlan): + return nil, &gin.H{"error": err.Error()} + case errors.Is(err, errDatabaseNameAlreadyInUse): + return nil, &gin.H{"error": err.Error(), "code": "CONFLICT"} + default: + return nil, &gin.H{"error": "Failed to create managed database: " + err.Error()} + } + } + createdDatabaseIDs = append(createdDatabaseIDs, databaseID) + created = append(created, graphCreatedResource{Key: m.spec.Key, Kind: "database", ID: databaseID, Name: memberName}) + LogAudit(userID, "database", databaseID, "create", map[string]interface{}{ + "source": source, "name": memberName, "type": normalizeDatabaseType(m.spec.Runtime), "project_id": projectID.String(), + }) + continue + } + + count, err := queries.CountServicesByProjectAndName(ctx, sqlcdb.CountServicesByProjectAndNameParams{ + ProjectID: projectID, + Name: memberName, + }) + if err != nil { + cleanup() + return nil, &gin.H{"error": "Failed to validate service name"} + } + if count > 0 { + cleanup() + return nil, &gin.H{"error": fmt.Sprintf("Service name %q already exists in this project", memberName), "code": "CONFLICT"} + } + + serviceImage := resolveTemplateRuntimeImage(m.spec.Runtime) + serviceCommand := strings.TrimSpace(m.spec.StartCommand) + buildCommand := strings.TrimSpace(m.spec.BuildCommand) + cpu, memory := defaultTemplateResources(memberType) + + environmentID, err := getProjectEnvironmentID(db, projectID, "production") + if err != nil { + cleanup() + return nil, &gin.H{"error": "Failed to resolve service environment"} + } + + serviceID := uuid.New() + now := time.Now() + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + cleanup() + return nil, &gin.H{"error": "Failed to initialize service"} + } + txQueries := queries.WithTx(tx) + err = txQueries.CreateServiceFromTemplate(ctx, sqlcdb.CreateServiceFromTemplateParams{ + ID: serviceID, + ProjectID: projectID, + Name: memberName, + EnvironmentID: environmentID, + ServiceType: memberType, + SourceType: "template", + ImageName: sql.NullString{String: serviceImage, Valid: serviceImage != ""}, + BuildCommand: sql.NullString{String: buildCommand, Valid: buildCommand != ""}, + StartCommand: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""}, + Type: sql.NullString{String: memberType, Valid: true}, + Status: sql.NullString{String: "stopped", Valid: true}, + Image: sql.NullString{String: serviceImage, Valid: serviceImage != ""}, + Command: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""}, + Environment: sql.NullString{String: "production", Valid: true}, + Cpu: sql.NullString{String: cpu, Valid: true}, + Memory: sql.NullString{String: memory, Valid: true}, + CreatedAt: sql.NullTime{Time: now, Valid: true}, + UpdatedAt: sql.NullTime{Time: now, Valid: true}, + }) + if err != nil { + tx.Rollback() + cleanup() + return nil, &gin.H{"error": "Failed to create service"} + } + + var volumesJSON interface{} + if len(m.spec.Volumes) > 0 { + if raw, err := json.Marshal(m.spec.Volumes); err == nil { + volumesJSON = string(raw) + } + } + builder := strings.TrimSpace(m.spec.Builder) + if builder == "" { + builder = "auto" + } + if _, err = tx.ExecContext(ctx, + `UPDATE services SET port = $1, healthcheck_path = $2, volumes = COALESCE($3::jsonb, '[]'::jsonb), + git_repo = NULLIF($4, ''), git_branch = NULLIF($5, ''), builder = $6 + WHERE id = $7`, + m.spec.Port, strings.TrimSpace(m.spec.HealthCheck), volumesJSON, + strings.TrimSpace(m.spec.Repo), strings.TrimSpace(m.spec.Branch), builder, + serviceID, + ); err != nil { + tx.Rollback() + cleanup() + return nil, &gin.H{"error": "Failed to configure service"} + } + + for key, value := range m.env { + if strings.TrimSpace(key) == "" { + continue + } + isSecret := m.secretKeys[key] || secretTemplateKeys[key] + stored := value + if isSecret { + stored = secrets.Encrypt(value) + } + if err = txQueries.UpsertEnvironmentVariable(ctx, sqlcdb.UpsertEnvironmentVariableParams{ + ID: uuid.New(), + ServiceID: serviceID, + Key: key, + Value: stored, + IsSecret: sql.NullBool{Bool: isSecret, Valid: true}, + CreatedAt: sql.NullTime{Time: now, Valid: true}, + UpdatedAt: sql.NullTime{Time: now, Valid: true}, + }); err != nil { + tx.Rollback() + cleanup() + return nil, &gin.H{"error": "Failed to save service variables"} + } + } + + if err := tx.Commit(); err != nil { + cleanup() + return nil, &gin.H{"error": "Failed to finalize service"} + } + createdServiceIDs = append(createdServiceIDs, serviceID) + + res := graphCreatedResource{Key: m.spec.Key, Kind: "service", ID: serviceID.String(), Name: memberName} + + if engine != nil { + service := Service{ + ID: serviceID, + ProjectID: projectID, + Name: memberName, + Type: memberType, + Status: "building", + Image: serviceImage, + Command: serviceCommand, + Environment: "production", + GitRepo: strings.TrimSpace(m.spec.Repo), + GitBranch: strings.TrimSpace(m.spec.Branch), + Replicas: 1, + Port: m.spec.Port, + HealthCheckPath: strings.TrimSpace(m.spec.HealthCheck), + RestartPolicy: "unless-stopped", + Builder: builder, + } + depID := queueTemplateDeployment(c, db, engine, service, userID) + if depID != uuid.Nil { + res.DeploymentID = depID.String() + } + } + + created = append(created, res) + LogAudit(userID, "service", serviceID.String(), "create", map[string]interface{}{ + "source": source, "name": memberName, "type": memberType, + }) + } + + return created, nil +} + +// queueTemplateDeployment creates a deployment row and enqueues the shared +// build+run job — the same path a manual POST /deployments takes. +func queueTemplateDeployment(c *gin.Context, db *database.DB, engine *deployment.DeploymentEngine, service Service, userID string) uuid.UUID { + now := time.Now() + dep := DeploymentModel{ + ID: uuid.New(), + ServiceID: service.ID, + Status: "queued", + CreatedAt: now, + UpdatedAt: now, + } + if _, err := db.Exec( + `INSERT INTO deployments + (id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at) + VALUES ($1, $2, $3, NULL, $4, '', '', $5, $6)`, + dep.ID, dep.ServiceID, fmt.Sprintf("v%d", now.Unix()), dep.Status, dep.CreatedAt, dep.UpdatedAt, + ); err != nil { + return uuid.Nil + } + _, _ = db.Exec(`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`, time.Now(), service.ID) + + getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{ + DeploymentID: dep.ID, + Run: func(jctx context.Context) { + runDeploymentAndSync(jctx, db, engine, &dep, service, CreateDeploymentRequest{Trigger: "template"}, userID) + }, + }) + return dep.ID +} + +// handleDeployInlineGraph deploys an ad-hoc service graph without a stored +// template — the companion to compose import: import returns a config, +// this deploys it. +func handleDeployInlineGraph(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + db := c.MustGet("db").(*database.DB) + ctx := c.Request.Context() + + var req struct { + ProjectID string `json:"project_id" binding:"required"` + Config struct { + Services []TemplateServiceSpec `json:"services"` + } `json:"config" binding:"required"` + Variables map[string]string `json:"variables"` + Plan string `json:"plan"` + Region string `json:"region"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if len(req.Config.Services) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "config.services is empty"}) + return + } + + projectID, err := uuid.Parse(strings.TrimSpace(req.ProjectID)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"}) + return + } + queries := sqlcdb.New(db.DB) + ownerID, err := queries.GetProjectOwnerID(ctx, projectID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch project"}) + return + } + if ownerID.String() != userID && !contextIsAdmin(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + + 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) + } + + created, fail := deployTemplateGraph(c, db, engine, dbHandler, projectID, userID, + req.Config.Services, nil, req.Variables, req.Plan, req.Region, "inline") + 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, + }) +} + +func dedupStrings(in []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(in)) + for _, s := range in { + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + return out +} + +// ---- Compose import -------------------------------------------------------- + +type composeServiceDef struct { + Image string `yaml:"image"` + Build interface{} `yaml:"build"` + Command interface{} `yaml:"command"` + Entrypoint interface{} `yaml:"entrypoint"` + Environment interface{} `yaml:"environment"` + Ports []interface{} `yaml:"ports"` + DependsOn interface{} `yaml:"depends_on"` + Volumes []interface{} `yaml:"volumes"` + Restart string `yaml:"restart"` +} + +type composeFileDef struct { + Services map[string]composeServiceDef `yaml:"services"` +} + +// composeImageDatabase maps well-known compose image names to managed +// database engines so imported stacks get real managed databases. +var composeImageDatabase = map[string]string{ + "postgres": "postgresql", "postgresql": "postgresql", + "redis": "redis", "dragonfly": "dragonfly", "dragonflydb": "dragonfly", + "mongo": "mongodb", "mongodb": "mongodb", + "mysql": "mysql", "mariadb": "mariadb", + "clickhouse": "clickhouse", "clickhouse-server": "clickhouse", +} + +// handleImportCompose converts a docker-compose YAML document into a v2 +// template config. It does not persist anything — the response feeds the +// template create form or a direct deploy. +func handleImportCompose(c *gin.Context) { + if _, ok := requireAuthenticatedUserID(c); !ok { + return + } + var req struct { + ComposeYAML string `json:"compose_yaml" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if len(req.ComposeYAML) > 512*1024 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Compose file exceeds 512 KiB"}) + return + } + + var file composeFileDef + if err := yaml.Unmarshal([]byte(req.ComposeYAML), &file); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid compose YAML: " + err.Error()}) + return + } + if len(file.Services) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Compose file defines no services"}) + return + } + + warnings := []string{} + services := make([]TemplateServiceSpec, 0, len(file.Services)) + variables := []TemplateVariable{} + + // Compose service names become keys; declaration order follows YAML + // document order — yaml.v3 maps lose order, so sort keys for stability. + names := make([]string, 0, len(file.Services)) + for name := range file.Services { + names = append(names, name) + } + for i := 1; i < len(names); i++ { + for j := i; j > 0 && names[j] < names[j-1]; j-- { + names[j], names[j-1] = names[j-1], names[j] + } + } + + for _, name := range names { + svc := file.Services[name] + key := normalizeGraphAlias(name) + if key == "" { + warnings = append(warnings, fmt.Sprintf("service %q skipped: name is not usable as a key", name)) + continue + } + spec := TemplateServiceSpec{ + Key: key, + Name: name, + DependsOn: composeDependsOn(svc.DependsOn), + } + + imageName := strings.TrimSpace(svc.Image) + engineHint := "" + if imageName != "" { + base := imageName + if idx := strings.IndexAny(base, ":@"); idx > 0 { + base = base[:idx] + } + if slash := strings.LastIndex(base, "/"); slash >= 0 { + base = base[slash+1:] + } + engineHint = composeImageDatabase[strings.ToLower(base)] + } + + if engineHint != "" && svc.Build == nil { + spec.Type = "database" + spec.Runtime = engineHint + spec.Environment = composeEnvironment(svc.Environment, warnings, key) + } else { + spec.Type = "web" + spec.Runtime = imageName + spec.Environment = composeEnvironment(svc.Environment, warnings, key) + spec.Port = composeFirstPort(svc.Ports) + spec.StartCommand = composeCommand(svc.Command) + if svc.Build != nil { + if imageName == "" { + warnings = append(warnings, fmt.Sprintf("%s: build contexts are not supported — publish an image or link a git repo", key)) + } + } + if spec.Runtime == "" { + warnings = append(warnings, fmt.Sprintf("%s: no image defined — set a runtime before deploying", key)) + } + } + + for _, vol := range svc.Volumes { + v := composeVolume(vol) + if v != nil { + spec.Volumes = append(spec.Volumes, *v) + } + } + services = append(services, spec) + } + + if len(services) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Compose file produced no importable services"}) + return + } + + config := TemplateConfig{Version: 2, Services: services} + configJSON, _ := json.Marshal(config) + + c.JSON(http.StatusOK, gin.H{ + "config": json.RawMessage(configJSON), + "variables": variables, + "warnings": warnings, + }) +} + +func composeEnvironment(env interface{}, warnings []string, svcKey string) map[string]string { + out := map[string]string{} + switch e := env.(type) { + case map[string]interface{}: + for k, v := range e { + if v == nil { + // `KEY:` in a list/map forwards the host value — flag it. + warnings = append(warnings, fmt.Sprintf("%s: env %s has no value — set it at deploy", svcKey, k)) + continue + } + out[k] = fmt.Sprintf("%v", v) + } + case []interface{}: + for _, item := range e { + s, ok := item.(string) + if !ok { + continue + } + if idx := strings.Index(s, "="); idx > 0 { + out[s[:idx]] = s[idx+1:] + } else { + warnings = append(warnings, fmt.Sprintf("%s: env %s has no value — set it at deploy", svcKey, s)) + } + } + } + return out +} + +func composeDependsOn(dep interface{}) []string { + switch d := dep.(type) { + case []interface{}: + out := []string{} + for _, item := range d { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + case map[string]interface{}: + out := make([]string, 0, len(d)) + for k := range d { + out = append(out, k) + } + return out + } + return nil +} + +func composeFirstPort(ports []interface{}) int { + for _, p := range ports { + switch v := p.(type) { + case string: + // "8080:80", "127.0.0.1:8080:80", "80" + parts := strings.Split(v, ":") + last := parts[len(parts)-1] + if slash := strings.Index(last, "/"); slash > 0 { + last = last[:slash] + } + if n, err := strconv.Atoi(last); err == nil && n > 0 { + return n + } + case int: + return v + case float64: + return int(v) + case map[string]interface{}: + if t, ok := v["target"]; ok { + if f, ok := t.(float64); ok { + return int(f) + } + if i, ok := t.(int); ok { + return i + } + } + } + } + return 0 +} + +func composeCommand(cmd interface{}) string { + switch v := cmd.(type) { + case string: + return v + case []interface{}: + parts := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + parts = append(parts, s) + } + } + return strings.Join(parts, " ") + } + return "" +} + +func composeVolume(vol interface{}) *ServiceVolume { + s, ok := vol.(string) + if !ok { + return nil + } + parts := strings.SplitN(s, ":", 3) + if len(parts) < 2 { + return nil + } + source := strings.TrimSpace(parts[0]) + target := strings.TrimSpace(parts[1]) + if source == "" || target == "" { + return nil + } + v := &ServiceVolume{Source: source, Target: target} + if strings.HasPrefix(source, "/") || strings.HasPrefix(source, ".") { + v.Type = "bind" + } else { + v.Type = "volume" + } + if len(parts) == 3 && strings.Contains(parts[2], "ro") { + v.ReadOnly = true + } + return v +} diff --git a/app/backend/internal/api/templategraph_test.go b/app/backend/internal/api/templategraph_test.go new file mode 100644 index 0000000..d701dad --- /dev/null +++ b/app/backend/internal/api/templategraph_test.go @@ -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") + } +} diff --git a/app/backend/internal/api/templates.go b/app/backend/internal/api/templates.go index 936ab84..966cb9a 100644 --- a/app/backend/internal/api/templates.go +++ b/app/backend/internal/api/templates.go @@ -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 } diff --git a/app/backend/internal/cli/commands/templates.go b/app/backend/internal/cli/commands/templates.go index 9cf3ccf..f1b1000 100644 --- a/app/backend/internal/cli/commands/templates.go +++ b/app/backend/internal/cli/commands/templates.go @@ -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 ", 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 ", + 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 ", + 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 ", + 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) } diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index 2dabe45..ec9e20f 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -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}) diff --git a/app/frontend/src/features/templates/pages/TemplatesPage.tsx b/app/frontend/src/features/templates/pages/TemplatesPage.tsx index 2ca4835..0d9903e 100644 --- a/app/frontend/src/features/templates/pages/TemplatesPage.tsx +++ b/app/frontend/src/features/templates/pages/TemplatesPage.tsx @@ -7,6 +7,7 @@ import { deployTemplate, getCurrentUserProfile, getTemplateById, + importComposeTemplate, listProjects, listTemplates, updateTemplate, @@ -36,6 +37,8 @@ import { Trash2, Upload, X, + FileCode, + Network, } from 'lucide-react'; function toServiceName(value: string): string { @@ -147,10 +150,16 @@ export function TemplatesPage() { const [variableValuesByTemplate, setVariableValuesByTemplate] = useState< Record> >({}); + const [composeOpen, setComposeOpen] = useState(false); + const [composeYaml, setComposeYaml] = useState(''); + const [composeWarnings, setComposeWarnings] = useState([]); + const [composeError, setComposeError] = useState(null); + const [lastDeployment, setLastDeployment] = useState<{ projectId: string; - serviceId: string; + serviceId?: string; serviceName: string; + created?: { key: string; kind: string; id: string; name: string; deploymentId?: string }[]; } | null>(null); const templatesQuery = useQuery({ @@ -281,6 +290,12 @@ export function TemplatesPage() { }); }, [selectedDetail, variableValues]); + const graphMembers = useMemo( + () => selectedDetail?.config.services ?? [], + [selectedDetail], + ); + const isGraphTemplate = graphMembers.length > 0; + const deployMutation = useMutation({ mutationFn: async () => { if (!selectedTemplateId) { @@ -296,17 +311,21 @@ export function TemplatesPage() { return deployTemplate(selectedTemplateId, { projectId: deployProjectId, - name: deployName.trim(), + name: isGraphTemplate ? undefined : deployName.trim(), variables, }); }, onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['projects'] }); queryClient.invalidateQueries({ queryKey: ['project-services'] }); + queryClient.invalidateQueries({ queryKey: ['databases'] }); setLastDeployment({ projectId: deployProjectId, 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 = !selectedTemplateId || !deployProjectId || - deployName.trim().length === 0 || + (!isGraphTemplate && deployName.trim().length === 0) || missingRequiredVariables.length > 0 || 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 = () => { setEditingTemplate(null); setEditorJson(EMPTY_TEMPLATE_JSON); @@ -438,13 +488,27 @@ export function TemplatesPage() { {filteredTemplates.length} templates {signedIn && !isDemoMode ? ( - + <> + + + ) : null} @@ -725,6 +789,42 @@ export function TemplatesPage() { )} + {isGraphTemplate ? ( +
+
+ +

+ Stack — {graphMembers.length} members +

+
+
+ {graphMembers.map((member) => ( +
+
+ {member.type === 'database' ? ( + + ) : ( + + )} + {member.name || member.key} + {member.dependsOn && member.dependsOn.length > 0 ? ( + + after {member.dependsOn.join(', ')} + + ) : null} +
+ + {member.type === 'database' ? `managed ${member.runtime}` : member.runtime || member.repo || '—'} + +
+ ))} +
+

+ Members join a private project network; references like {'{{service.db.url}}'} resolve to the member key. +

+
+ ) : null} + {/* Deploy Section */}
@@ -733,7 +833,9 @@ export function TemplatesPage() {

Deploy from Template

-

Configure and create a new service

+

+ {isGraphTemplate ? 'Deploy all members into a project' : 'Configure and create a new service'} +

@@ -767,6 +869,7 @@ export function TemplatesPage() { ))} + {!isGraphTemplate ? (
+ ) : null} {selectedDetail.variables.length > 0 && ( @@ -846,15 +950,30 @@ export function TemplatesPage() {
- Service {lastDeployment.serviceName} created + {lastDeployment.created + ? `Stack deployed — ${lastDeployment.created.length} resources` + : <>Service {lastDeployment.serviceName} created} View
+ {lastDeployment.created ? ( +
+ {lastDeployment.created.map((m) => ( +
+ {m.kind === 'database' ? : } + {m.name} + ({m.kind}) +
+ ))} +
+ ) : null} )} @@ -868,12 +987,12 @@ export function TemplatesPage() { {deployMutation.isPending ? ( <> - Creating... + Deploying... ) : ( <> - Create Service + {isGraphTemplate ? 'Deploy Stack' : 'Create Service'} )} @@ -929,6 +1048,14 @@ export function TemplatesPage() { 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" /> + {composeWarnings.length > 0 ? ( +
+

Compose import notes

+ {composeWarnings.map((w) => ( +

• {w}

+ ))} +
+ ) : null} {editorError ? (
{editorError} @@ -966,6 +1093,59 @@ export function TemplatesPage() {
) : null} + + {/* Compose import */} + {composeOpen ? ( +
+
+
+
+

Import docker-compose

+

+ Paste a compose file — known database images become managed databases, everything else becomes services. +

+
+ +
+