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
+2472
-41
No files matched your search
+2
-2
@@ -13,6 +13,7 @@ require (
|
|||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/miekg/dns v1.1.72
|
github.com/miekg/dns v1.1.72
|
||||||
|
github.com/minio/minio-go/v7 v7.0.95
|
||||||
github.com/modelcontextprotocol/go-sdk v1.7.0
|
github.com/modelcontextprotocol/go-sdk v1.7.0
|
||||||
github.com/pressly/goose/v3 v3.24.1
|
github.com/pressly/goose/v3 v3.24.1
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
@@ -23,6 +24,7 @@ require (
|
|||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.57.0
|
||||||
golang.org/x/term v0.46.0
|
golang.org/x/term v0.46.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -59,7 +61,6 @@ require (
|
|||||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||||
github.com/minio/crc64nvme v1.0.2 // indirect
|
github.com/minio/crc64nvme v1.0.2 // indirect
|
||||||
github.com/minio/md5-simd v1.1.2 // indirect
|
github.com/minio/md5-simd v1.1.2 // indirect
|
||||||
github.com/minio/minio-go/v7 v7.0.95 // indirect
|
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||||
github.com/moby/term v0.5.2 // indirect
|
github.com/moby/term v0.5.2 // indirect
|
||||||
@@ -106,7 +107,6 @@ require (
|
|||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
golang.org/x/tools v0.49.0 // indirect
|
golang.org/x/tools v0.49.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
gotest.tools/v3 v3.5.2 // indirect
|
gotest.tools/v3 v3.5.2 // indirect
|
||||||
modernc.org/sqlite v1.39.0 // indirect
|
modernc.org/sqlite v1.39.0 // indirect
|
||||||
)
|
)
|
||||||
@@ -65,8 +65,6 @@ github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC
|
|||||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
@@ -90,8 +88,6 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt
|
|||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
@@ -248,7 +244,6 @@ golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
|||||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"archive/tar"
|
"archive/tar"
|
||||||
"containr/internal/database/sqlcdb"
|
"containr/internal/database/sqlcdb"
|
||||||
|
"containr/internal/deployment"
|
||||||
"containr/internal/docker"
|
"containr/internal/docker"
|
||||||
"containr/internal/secrets"
|
"containr/internal/secrets"
|
||||||
"context"
|
"context"
|
||||||
@@ -331,6 +332,11 @@ type managedDatabaseCreateRequest struct {
|
|||||||
Region string
|
Region string
|
||||||
PublicPort bool
|
PublicPort bool
|
||||||
RuntimeVariables map[string]string
|
RuntimeVariables map[string]string
|
||||||
|
// ProjectID + NetworkAlias attach the container to a project network
|
||||||
|
// under Alias — set by graph-template deploys so sibling services can
|
||||||
|
// resolve the database by name.
|
||||||
|
ProjectID string
|
||||||
|
NetworkAlias string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID string, req managedDatabaseCreateRequest) (databaseID string, name string, dbType string, err error) {
|
func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID string, req managedDatabaseCreateRequest) (databaseID string, name string, dbType string, err error) {
|
||||||
@@ -398,10 +404,17 @@ func (h *DatabaseHandler) createManagedDatabaseAndProvision(ctx context.Context,
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
go h.provisionDatabaseWithVariables(databaseID, name, dbType, req.RuntimeVariables)
|
go h.provisionDatabaseWithVariables(databaseID, name, dbType, req.RuntimeVariables, dbNetworkAttach{ProjectID: req.ProjectID, Alias: req.NetworkAlias})
|
||||||
return databaseID, nil
|
return databaseID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dbNetworkAttach carries the optional project-network membership for a
|
||||||
|
// managed database provisioned by a graph template.
|
||||||
|
type dbNetworkAttach struct {
|
||||||
|
ProjectID string
|
||||||
|
Alias string
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateDatabase updates a database service
|
// UpdateDatabase updates a database service
|
||||||
func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
|
func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
|
||||||
userID, ok := requireAuthenticatedUserID(c)
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
@@ -1350,7 +1363,7 @@ type databaseRuntimePlan struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DatabaseHandler) provisionDatabase(databaseID, databaseName, dbType string) {
|
func (h *DatabaseHandler) provisionDatabase(databaseID, databaseName, dbType string) {
|
||||||
h.provisionDatabaseWithVariables(databaseID, databaseName, dbType, nil)
|
h.provisionDatabaseWithVariables(databaseID, databaseName, dbType, nil, dbNetworkAttach{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// reprovisionManagedDatabase recreates a managed container so changed
|
// reprovisionManagedDatabase recreates a managed container so changed
|
||||||
@@ -1364,7 +1377,7 @@ func (h *DatabaseHandler) reprovisionManagedDatabase(databaseID, databaseName, d
|
|||||||
h.provisionDatabase(databaseID, databaseName, dbType)
|
h.provisionDatabase(databaseID, databaseName, dbType)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseName, dbType string, runtimeVariables map[string]string) {
|
func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseName, dbType string, runtimeVariables map[string]string, attach dbNetworkAttach) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -1373,7 +1386,7 @@ func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseNam
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables)
|
connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables, attach)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("containr: managed database %s (%s) provisioning failed: %v", databaseID, dbType, err)
|
log.Printf("containr: managed database %s (%s) provisioning failed: %v", databaseID, dbType, err)
|
||||||
_ = h.setDatabaseStatusAndConnection(databaseID, "error", sql.NullString{})
|
_ = h.setDatabaseStatusAndConnection(databaseID, "error", sql.NullString{})
|
||||||
@@ -1395,7 +1408,7 @@ func (h *DatabaseHandler) setDatabaseStatusAndConnection(databaseID, status stri
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, databaseID, databaseName, dbType string, runtimeVariables map[string]string) (string, error) {
|
func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, databaseID, databaseName, dbType string, runtimeVariables map[string]string, attach dbNetworkAttach) (string, error) {
|
||||||
plan, err := buildDatabaseRuntimePlan(dbType, databaseName, runtimeVariables)
|
plan, err := buildDatabaseRuntimePlan(dbType, databaseName, runtimeVariables)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -1483,6 +1496,17 @@ func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, database
|
|||||||
return "", fmt.Errorf("failed to start container: %w", err)
|
return "", fmt.Errorf("failed to start container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Graph-template deploys attach the database to the project network so
|
||||||
|
// sibling services resolve it by alias ({{service.<key>.host}}).
|
||||||
|
if attach.ProjectID != "" && attach.Alias != "" {
|
||||||
|
projectNet := deployment.ProjectNetworkName(attach.ProjectID)
|
||||||
|
if err := h.dockerClient.ConnectNetwork(ctx, projectNet, containerID, network.EndpointSettings{
|
||||||
|
Aliases: []string{attach.Alias},
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("containr: database %s attach to %s failed: %v", databaseID, projectNet, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
hostPort, err := h.resolvePublishedHostPort(ctx, containerID, plan.Port)
|
hostPort, err := h.resolvePublishedHostPort(ctx, containerID, plan.Port)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -417,6 +417,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
authed.PUT("/templates/:id", handleUpdateTemplate)
|
authed.PUT("/templates/:id", handleUpdateTemplate)
|
||||||
authed.DELETE("/templates/:id", handleDeleteTemplate)
|
authed.DELETE("/templates/:id", handleDeleteTemplate)
|
||||||
authed.POST("/templates/:id/deploy", handleCreateFromTemplate)
|
authed.POST("/templates/:id/deploy", handleCreateFromTemplate)
|
||||||
|
authed.POST("/templates/:id/plan", handlePlanTemplate)
|
||||||
|
authed.POST("/templates/import/compose", handleImportCompose)
|
||||||
|
authed.POST("/templates/deploy", handleDeployInlineGraph)
|
||||||
|
|
||||||
authed.POST("/cron-jobs", handleCreateCronJob)
|
authed.POST("/cron-jobs", handleCreateCronJob)
|
||||||
authed.PUT("/cron-jobs/:id", handleUpdateCronJob)
|
authed.PUT("/cron-jobs/:id", handleUpdateCronJob)
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,159 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTopoSortServicesOrdersByDependsOn(t *testing.T) {
|
||||||
|
sorted, err := topoSortServices([]TemplateServiceSpec{
|
||||||
|
{Key: "web", DependsOn: []string{"db"}},
|
||||||
|
{Key: "db", Type: "database"},
|
||||||
|
{Key: "worker", DependsOn: []string{"web", "db"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("topoSortServices: %v", err)
|
||||||
|
}
|
||||||
|
if sorted[0].Key != "db" || sorted[1].Key != "web" || sorted[2].Key != "worker" {
|
||||||
|
t.Fatalf("unexpected order: %v", []string{sorted[0].Key, sorted[1].Key, sorted[2].Key})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTopoSortServicesRejectsCycle(t *testing.T) {
|
||||||
|
_, err := topoSortServices([]TemplateServiceSpec{
|
||||||
|
{Key: "a", DependsOn: []string{"b"}},
|
||||||
|
{Key: "b", DependsOn: []string{"a"}},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "cycle") {
|
||||||
|
t.Fatalf("expected cycle error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTopoSortServicesRejectsUnknownDep(t *testing.T) {
|
||||||
|
_, err := topoSortServices([]TemplateServiceSpec{
|
||||||
|
{Key: "a", DependsOn: []string{"ghost"}},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "unknown key") {
|
||||||
|
t.Fatalf("expected unknown-key error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveStaticExpr(t *testing.T) {
|
||||||
|
unresolved := []string{}
|
||||||
|
var generated bool
|
||||||
|
|
||||||
|
v := resolveStaticExpr("{{secret}}", nil, &unresolved, &generated)
|
||||||
|
if len(v) != 32 || !generated {
|
||||||
|
t.Fatalf("secret: got %q generated=%v", v, generated)
|
||||||
|
}
|
||||||
|
v = resolveStaticExpr("{{secret(8)}}", nil, &unresolved, &generated)
|
||||||
|
if len(v) != 16 {
|
||||||
|
t.Fatalf("secret(8): got %q", v)
|
||||||
|
}
|
||||||
|
v = resolveStaticExpr("port-{{random:10}}", nil, &unresolved, &generated)
|
||||||
|
if !strings.HasPrefix(v, "port-") {
|
||||||
|
t.Fatalf("random: got %q", v)
|
||||||
|
}
|
||||||
|
v = resolveStaticExpr("{{DOMAIN:-example.com}}", nil, &unresolved, &generated)
|
||||||
|
if v != "example.com" {
|
||||||
|
t.Fatalf("var default: got %q", v)
|
||||||
|
}
|
||||||
|
v = resolveStaticExpr("{{DOMAIN}}", map[string]string{"DOMAIN": "x.io"}, &unresolved, &generated)
|
||||||
|
if v != "x.io" {
|
||||||
|
t.Fatalf("var override: got %q", v)
|
||||||
|
}
|
||||||
|
v = resolveStaticExpr("{{MISSING}}", nil, &unresolved, &generated)
|
||||||
|
if v != "" || len(unresolved) == 0 || unresolved[0] != "MISSING" {
|
||||||
|
t.Fatalf("missing var: got %q unresolved=%v", v, unresolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTemplateGraphDatabaseRefs(t *testing.T) {
|
||||||
|
sorted, err := topoSortServices([]TemplateServiceSpec{
|
||||||
|
{Key: "db", Type: "database", Runtime: "postgresql", Environment: map[string]string{
|
||||||
|
"POSTGRES_DB": "shop",
|
||||||
|
}},
|
||||||
|
{Key: "web", Type: "web", Runtime: "nginx", DependsOn: []string{"db"}, Environment: map[string]string{
|
||||||
|
"DATABASE_URL": "{{service.db.url}}",
|
||||||
|
"DB_HOST": "{{service.db.host}}",
|
||||||
|
"DB_PORT": "{{service.db.port}}",
|
||||||
|
"SESSION_TOKEN": "{{secret}}",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("topo: %v", err)
|
||||||
|
}
|
||||||
|
graph := resolveTemplateGraph(sorted, nil)
|
||||||
|
if len(graph.unresolved) > 0 {
|
||||||
|
t.Fatalf("unresolved: %v", graph.unresolved)
|
||||||
|
}
|
||||||
|
web := graph.members[1]
|
||||||
|
if web.env["DB_HOST"] != "db" {
|
||||||
|
t.Fatalf("DB_HOST=%q", web.env["DB_HOST"])
|
||||||
|
}
|
||||||
|
if web.env["DB_PORT"] != "5432" {
|
||||||
|
t.Fatalf("DB_PORT=%q", web.env["DB_PORT"])
|
||||||
|
}
|
||||||
|
dbURL := web.env["DATABASE_URL"]
|
||||||
|
if !strings.HasPrefix(dbURL, "postgresql://") || !strings.Contains(dbURL, "@db:5432/shop") {
|
||||||
|
t.Fatalf("DATABASE_URL=%q", dbURL)
|
||||||
|
}
|
||||||
|
if len(web.env["SESSION_TOKEN"]) != 32 || !web.secretKeys["SESSION_TOKEN"] {
|
||||||
|
t.Fatalf("SESSION_TOKEN=%q secret=%v", web.env["SESSION_TOKEN"], web.secretKeys["SESSION_TOKEN"])
|
||||||
|
}
|
||||||
|
// Database member must carry the generated password so refs and the
|
||||||
|
// container agree.
|
||||||
|
db := graph.members[0]
|
||||||
|
if pw := db.env["POSTGRES_PASSWORD"]; len(pw) != 32 || !db.secretKeys["POSTGRES_PASSWORD"] {
|
||||||
|
t.Fatalf("POSTGRES_PASSWORD=%q secret=%v", pw, db.secretKeys["POSTGRES_PASSWORD"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(dbURL, db.env["POSTGRES_PASSWORD"]) {
|
||||||
|
t.Fatalf("url %q does not carry the resolved password", dbURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTemplateGraphServiceRefs(t *testing.T) {
|
||||||
|
sorted, err := topoSortServices([]TemplateServiceSpec{
|
||||||
|
{Key: "api", Type: "web", Runtime: "ghcr.io/x/api:1", Port: 8080},
|
||||||
|
{Key: "front", Type: "web", Runtime: "ghcr.io/x/front:1", Port: 3000, DependsOn: []string{"api"}, Environment: map[string]string{
|
||||||
|
"API_URL": "{{service.api.url}}/v2",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("topo: %v", err)
|
||||||
|
}
|
||||||
|
graph := resolveTemplateGraph(sorted, nil)
|
||||||
|
if got := graph.members[1].env["API_URL"]; got != "http://api:8080/v2" {
|
||||||
|
t.Fatalf("API_URL=%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTemplateGraphUnresolvedServiceProp(t *testing.T) {
|
||||||
|
sorted, _ := topoSortServices([]TemplateServiceSpec{
|
||||||
|
{Key: "web", Type: "web", Runtime: "nginx", Environment: map[string]string{
|
||||||
|
"X": "{{service.nope.host}}",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
graph := resolveTemplateGraph(sorted, nil)
|
||||||
|
if len(graph.unresolved) == 0 {
|
||||||
|
t.Fatal("expected unresolved ref")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTemplateGraph(t *testing.T) {
|
||||||
|
if err := validateTemplateGraph([]TemplateServiceSpec{
|
||||||
|
{Key: "db", Type: "database", Runtime: "postgresql"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("valid db spec rejected: %v", err)
|
||||||
|
}
|
||||||
|
if err := validateTemplateGraph([]TemplateServiceSpec{
|
||||||
|
{Key: "db", Type: "database", Runtime: "nonsense"},
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("expected unsupported db engine error")
|
||||||
|
}
|
||||||
|
if err := validateTemplateGraph([]TemplateServiceSpec{
|
||||||
|
{Key: "web", Type: "web"},
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("expected missing runtime error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package api
|
|||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/database/sqlcdb"
|
"containr/internal/database/sqlcdb"
|
||||||
|
"containr/internal/deployment"
|
||||||
|
"containr/internal/secrets"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -34,6 +36,7 @@ type ServiceTemplate struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TemplateConfig struct {
|
type TemplateConfig struct {
|
||||||
|
Version int `json:"version,omitempty"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Runtime string `json:"runtime"`
|
Runtime string `json:"runtime"`
|
||||||
BuildCommand string `json:"build_command"`
|
BuildCommand string `json:"build_command"`
|
||||||
@@ -43,6 +46,9 @@ type TemplateConfig struct {
|
|||||||
Environment map[string]string `json:"environment"`
|
Environment map[string]string `json:"environment"`
|
||||||
Dockerfile string `json:"dockerfile,omitempty"`
|
Dockerfile string `json:"dockerfile,omitempty"`
|
||||||
NixpacksConfig map[string]string `json:"nixpacks_config,omitempty"`
|
NixpacksConfig map[string]string `json:"nixpacks_config,omitempty"`
|
||||||
|
// Services makes this a v2 graph template. When non-empty the flat
|
||||||
|
// fields above are ignored and deployTemplateGraph handles the deploy.
|
||||||
|
Services []TemplateServiceSpec `json:"services,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TemplateVariable struct {
|
type TemplateVariable struct {
|
||||||
@@ -147,7 +153,7 @@ func handleCreateFromTemplate(c *gin.Context) {
|
|||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
ProjectID string `json:"project_id" binding:"required"`
|
ProjectID string `json:"project_id" binding:"required"`
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name"`
|
||||||
Plan string `json:"plan,omitempty"`
|
Plan string `json:"plan,omitempty"`
|
||||||
Region string `json:"region,omitempty"`
|
Region string `json:"region,omitempty"`
|
||||||
Variables map[string]string `json:"variables"`
|
Variables map[string]string `json:"variables"`
|
||||||
@@ -160,10 +166,6 @@ func handleCreateFromTemplate(c *gin.Context) {
|
|||||||
|
|
||||||
req.ProjectID = strings.TrimSpace(req.ProjectID)
|
req.ProjectID = strings.TrimSpace(req.ProjectID)
|
||||||
req.Name = strings.TrimSpace(req.Name)
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
if req.Name == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
projectID, err := uuid.Parse(req.ProjectID)
|
projectID, err := uuid.Parse(req.ProjectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -213,6 +215,51 @@ func handleCreateFromTemplate(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(config.Services) > 0 {
|
||||||
|
var engine *deployment.DeploymentEngine
|
||||||
|
if v, exists := c.Get("deployment_engine"); exists && v != nil {
|
||||||
|
engine, _ = v.(*deployment.DeploymentEngine)
|
||||||
|
}
|
||||||
|
var dbHandler *DatabaseHandler
|
||||||
|
if v, exists := c.Get("database_handler"); exists {
|
||||||
|
dbHandler, _ = v.(*DatabaseHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
userVars := map[string]string{}
|
||||||
|
for _, v := range templateVars {
|
||||||
|
if override, ok := req.Variables[v.Key]; ok {
|
||||||
|
userVars[v.Key] = override
|
||||||
|
} else if v.Default != "" {
|
||||||
|
userVars[v.Key] = v.Default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range req.Variables {
|
||||||
|
userVars[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
created, fail := deployTemplateGraph(c, db, engine, dbHandler, projectID, userID,
|
||||||
|
config.Services, templateVars, userVars, req.Plan, req.Region, "template:"+templateID)
|
||||||
|
if fail != nil {
|
||||||
|
status := http.StatusBadRequest
|
||||||
|
if (*fail)["code"] == "CONFLICT" {
|
||||||
|
status = http.StatusConflict
|
||||||
|
}
|
||||||
|
c.JSON(status, *fail)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"resource": "stack",
|
||||||
|
"created": created,
|
||||||
|
"message": fmt.Sprintf("Deployed %d resources from template %s", len(created), template.Name),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
serviceType, err := normalizeTemplateServiceType(config.Type)
|
serviceType, err := normalizeTemplateServiceType(config.Type)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -351,11 +398,15 @@ func handleCreateFromTemplate(c *gin.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stored := value
|
||||||
|
if secretKeys[key] {
|
||||||
|
stored = secrets.Encrypt(value)
|
||||||
|
}
|
||||||
err = txQueries.UpsertEnvironmentVariable(ctx, sqlcdb.UpsertEnvironmentVariableParams{
|
err = txQueries.UpsertEnvironmentVariable(ctx, sqlcdb.UpsertEnvironmentVariableParams{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
ServiceID: serviceID,
|
ServiceID: serviceID,
|
||||||
Key: key,
|
Key: key,
|
||||||
Value: value,
|
Value: stored,
|
||||||
IsSecret: sql.NullBool{Bool: secretKeys[key], Valid: true},
|
IsSecret: sql.NullBool{Bool: secretKeys[key], Valid: true},
|
||||||
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
||||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||||
@@ -927,6 +978,34 @@ func SeedTemplates() []ServiceTemplate {
|
|||||||
Variables: `[]`,
|
Variables: `[]`,
|
||||||
IsOfficial: true,
|
IsOfficial: true,
|
||||||
},
|
},
|
||||||
|
// Multi-service stacks — v2 graph configs. Database members become
|
||||||
|
// managed databases attached to the project network under their key.
|
||||||
|
{
|
||||||
|
ID: "tpl-wordpress-stack",
|
||||||
|
Name: "WordPress + MySQL",
|
||||||
|
Description: "WordPress with a managed MySQL database on a private project network",
|
||||||
|
Category: "stack",
|
||||||
|
Logo: "https://cdn.simpleicons.org/wordpress",
|
||||||
|
Config: `{"version":2,"services":[
|
||||||
|
{"key":"db","type":"database","runtime":"mysql","environment":{"MYSQL_DATABASE":"wordpress"}},
|
||||||
|
{"key":"web","name":"wordpress","type":"web","runtime":"wordpress:6-apache","port":80,"health_check":"/wp-login.php","depends_on":["db"],
|
||||||
|
"environment":{"WORDPRESS_DB_HOST":"{{service.db.host}}","WORDPRESS_DB_USER":"{{service.db.user}}","WORDPRESS_DB_PASSWORD":"{{service.db.password}}","WORDPRESS_DB_NAME":"{{service.db.database}}"}}]}`,
|
||||||
|
Variables: `[]`,
|
||||||
|
IsOfficial: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "tpl-n8n-stack",
|
||||||
|
Name: "n8n + PostgreSQL",
|
||||||
|
Description: "n8n workflow automation backed by a managed PostgreSQL database",
|
||||||
|
Category: "stack",
|
||||||
|
Logo: "https://cdn.simpleicons.org/n8n",
|
||||||
|
Config: `{"version":2,"services":[
|
||||||
|
{"key":"db","type":"database","runtime":"postgresql"},
|
||||||
|
{"key":"n8n","type":"web","runtime":"n8nio/n8n:latest","port":5678,"health_check":"/healthz","depends_on":["db"],
|
||||||
|
"environment":{"DB_TYPE":"postgresdb","DB_POSTGRESDB_HOST":"{{service.db.host}}","DB_POSTGRESDB_PORT":"{{service.db.port}}","DB_POSTGRESDB_DATABASE":"{{service.db.database}}","DB_POSTGRESDB_USER":"{{service.db.user}}","DB_POSTGRESDB_PASSWORD":"{{service.db.password}}","N8N_ENCRYPTION_KEY":"{{secret}}"}}]}`,
|
||||||
|
Variables: `[]`,
|
||||||
|
IsOfficial: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return templates
|
return templates
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package commands
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -104,16 +107,31 @@ var templatesDeleteCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var tplDeployVars []string
|
||||||
|
var tplDeployName string
|
||||||
|
|
||||||
var templatesDeployCmd = &cobra.Command{
|
var templatesDeployCmd = &cobra.Command{
|
||||||
Use: "deploy <template-id> <project-id>",
|
Use: "deploy <template-id> <project-id>",
|
||||||
Short: "Deploy a template into a project",
|
Short: "Deploy a template into a project",
|
||||||
|
Long: "Deploys a single-service template or a multi-service graph (v2) template. Pass --name for single-service templates; graph members take names from the template. Use --var KEY=VALUE to override template variables.",
|
||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := client()
|
c, err := client()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
data, err := c.Do("POST", "/templates/"+args[0]+"/deploy", map[string]string{"project_id": args[1]})
|
body := map[string]interface{}{"project_id": args[1]}
|
||||||
|
if tplDeployName != "" {
|
||||||
|
body["name"] = tplDeployName
|
||||||
|
}
|
||||||
|
vars, err := parseKVFlags(tplDeployVars)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(vars) > 0 {
|
||||||
|
body["variables"] = vars
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/templates/"+args[0]+"/deploy", body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -122,6 +140,122 @@ var templatesDeployCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var templatesPlanCmd = &cobra.Command{
|
||||||
|
Use: "plan <template-id>",
|
||||||
|
Short: "Dry-resolve a template — expanded expressions, service refs, missing variables",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
vars, err := parseKVFlags(tplDeployVars)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{}
|
||||||
|
if len(vars) > 0 {
|
||||||
|
body["variables"] = vars
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/templates/"+args[0]+"/plan", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var templatesImportComposeCmd = &cobra.Command{
|
||||||
|
Use: "import-compose <compose.yml|->",
|
||||||
|
Short: "Convert a docker-compose file into a template graph config",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
var raw []byte
|
||||||
|
var err error
|
||||||
|
if args[0] == "-" {
|
||||||
|
raw, err = io.ReadAll(os.Stdin)
|
||||||
|
} else {
|
||||||
|
raw, err = os.ReadFile(args[0])
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot read compose file: %w", err)
|
||||||
|
}
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/templates/import/compose", map[string]string{"compose_yaml": string(raw)})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var templatesDeployGraphCmd = &cobra.Command{
|
||||||
|
Use: "deploy-graph <project-id> <config.json|->",
|
||||||
|
Short: "Deploy an ad-hoc service graph (v2 config with services[]) without a stored template",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
var raw []byte
|
||||||
|
var err error
|
||||||
|
if args[1] == "-" {
|
||||||
|
raw, err = io.ReadAll(os.Stdin)
|
||||||
|
} else {
|
||||||
|
raw, err = os.ReadFile(args[1])
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot read config: %w", err)
|
||||||
|
}
|
||||||
|
var config json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &config); err != nil {
|
||||||
|
return fmt.Errorf("invalid config JSON: %w", err)
|
||||||
|
}
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
vars, err := parseKVFlags(tplDeployVars)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]interface{}{"project_id": args[0]}
|
||||||
|
var cfg map[string]interface{}
|
||||||
|
if err := json.Unmarshal(config, &cfg); err != nil {
|
||||||
|
return fmt.Errorf("invalid config JSON: %w", err)
|
||||||
|
}
|
||||||
|
body["config"] = cfg
|
||||||
|
if len(vars) > 0 {
|
||||||
|
body["variables"] = vars
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/templates/deploy", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseKVFlags converts repeated --var KEY=VALUE flags into a map.
|
||||||
|
func parseKVFlags(flags []string) (map[string]string, error) {
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, f := range flags {
|
||||||
|
idx := strings.Index(f, "=")
|
||||||
|
if idx <= 0 {
|
||||||
|
return nil, fmt.Errorf("invalid --var %q — expected KEY=VALUE", f)
|
||||||
|
}
|
||||||
|
out[f[:idx]] = f[idx+1:]
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd)
|
templatesDeployCmd.Flags().StringVar(&tplDeployName, "name", "", "Name for the created service (single-service templates)")
|
||||||
|
templatesDeployCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
|
||||||
|
templatesPlanCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
|
||||||
|
templatesDeployGraphCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
|
||||||
|
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd, templatesPlanCmd, templatesImportComposeCmd, templatesDeployGraphCmd)
|
||||||
}
|
}
|
||||||
@@ -126,6 +126,9 @@ func init() {
|
|||||||
register(spec{"containr_templates_list", "List templates", "GET", "/templates", nil, nil, false})
|
register(spec{"containr_templates_list", "List templates", "GET", "/templates", nil, nil, false})
|
||||||
register(spec{"containr_templates_get", "Get a template definition", "GET", "/templates/{id}", []string{"id"}, nil, false})
|
register(spec{"containr_templates_get", "Get a template definition", "GET", "/templates/{id}", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_templates_deploy", "Deploy a template into a project. Args: id (template), project_id.", "POST", "/templates/{id}/deploy", []string{"id"}, []string{"project_id"}, false})
|
register(spec{"containr_templates_deploy", "Deploy a template into a project. Args: id (template), project_id.", "POST", "/templates/{id}/deploy", []string{"id"}, []string{"project_id"}, false})
|
||||||
|
register(spec{"containr_templates_plan", "Dry-resolve a template: expanded expressions, service refs, missing variables. Args: id, variables (object).", "POST", "/templates/{id}/plan", []string{"id"}, nil, false})
|
||||||
|
register(spec{"containr_templates_import_compose", "Convert docker-compose YAML into a v2 template graph config. Args: compose_yaml.", "POST", "/templates/import/compose", nil, []string{"compose_yaml"}, false})
|
||||||
|
register(spec{"containr_templates_deploy_graph", "Deploy an ad-hoc service graph without a stored template. Args: project_id, config ({services:[...]}), variables, plan, region.", "POST", "/templates/deploy", nil, []string{"project_id", "config", "variables", "plan", "region"}, false})
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
|
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
deployTemplate,
|
deployTemplate,
|
||||||
getCurrentUserProfile,
|
getCurrentUserProfile,
|
||||||
getTemplateById,
|
getTemplateById,
|
||||||
|
importComposeTemplate,
|
||||||
listProjects,
|
listProjects,
|
||||||
listTemplates,
|
listTemplates,
|
||||||
updateTemplate,
|
updateTemplate,
|
||||||
@@ -36,6 +37,8 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
|
FileCode,
|
||||||
|
Network,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
function toServiceName(value: string): string {
|
function toServiceName(value: string): string {
|
||||||
@@ -147,10 +150,16 @@ export function TemplatesPage() {
|
|||||||
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
|
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
|
||||||
Record<string, Record<string, string>>
|
Record<string, Record<string, string>>
|
||||||
>({});
|
>({});
|
||||||
|
const [composeOpen, setComposeOpen] = useState(false);
|
||||||
|
const [composeYaml, setComposeYaml] = useState('');
|
||||||
|
const [composeWarnings, setComposeWarnings] = useState<string[]>([]);
|
||||||
|
const [composeError, setComposeError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [lastDeployment, setLastDeployment] = useState<{
|
const [lastDeployment, setLastDeployment] = useState<{
|
||||||
projectId: string;
|
projectId: string;
|
||||||
serviceId: string;
|
serviceId?: string;
|
||||||
serviceName: string;
|
serviceName: string;
|
||||||
|
created?: { key: string; kind: string; id: string; name: string; deploymentId?: string }[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const templatesQuery = useQuery({
|
const templatesQuery = useQuery({
|
||||||
@@ -281,6 +290,12 @@ export function TemplatesPage() {
|
|||||||
});
|
});
|
||||||
}, [selectedDetail, variableValues]);
|
}, [selectedDetail, variableValues]);
|
||||||
|
|
||||||
|
const graphMembers = useMemo(
|
||||||
|
() => selectedDetail?.config.services ?? [],
|
||||||
|
[selectedDetail],
|
||||||
|
);
|
||||||
|
const isGraphTemplate = graphMembers.length > 0;
|
||||||
|
|
||||||
const deployMutation = useMutation({
|
const deployMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!selectedTemplateId) {
|
if (!selectedTemplateId) {
|
||||||
@@ -296,17 +311,21 @@ export function TemplatesPage() {
|
|||||||
|
|
||||||
return deployTemplate(selectedTemplateId, {
|
return deployTemplate(selectedTemplateId, {
|
||||||
projectId: deployProjectId,
|
projectId: deployProjectId,
|
||||||
name: deployName.trim(),
|
name: isGraphTemplate ? undefined : deployName.trim(),
|
||||||
variables,
|
variables,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['databases'] });
|
||||||
setLastDeployment({
|
setLastDeployment({
|
||||||
projectId: deployProjectId,
|
projectId: deployProjectId,
|
||||||
serviceId: result.serviceId,
|
serviceId: result.serviceId,
|
||||||
serviceName: deployName.trim(),
|
serviceName: isGraphTemplate
|
||||||
|
? `${result.created?.length ?? 0} resources`
|
||||||
|
: deployName.trim(),
|
||||||
|
created: result.created,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -314,7 +333,7 @@ export function TemplatesPage() {
|
|||||||
const isDeployDisabled =
|
const isDeployDisabled =
|
||||||
!selectedTemplateId ||
|
!selectedTemplateId ||
|
||||||
!deployProjectId ||
|
!deployProjectId ||
|
||||||
deployName.trim().length === 0 ||
|
(!isGraphTemplate && deployName.trim().length === 0) ||
|
||||||
missingRequiredVariables.length > 0 ||
|
missingRequiredVariables.length > 0 ||
|
||||||
deployMutation.isPending;
|
deployMutation.isPending;
|
||||||
|
|
||||||
@@ -344,6 +363,37 @@ export function TemplatesPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const composeImportMutation = useMutation({
|
||||||
|
mutationFn: (yaml: string) => importComposeTemplate(yaml),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setComposeWarnings(result.warnings);
|
||||||
|
setComposeError(null);
|
||||||
|
setComposeOpen(false);
|
||||||
|
// Load the converted graph into the template editor for review/save.
|
||||||
|
setEditingTemplate(null);
|
||||||
|
setEditorIsPublic(false);
|
||||||
|
setEditorError(null);
|
||||||
|
setEditorJson(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: 'compose-stack',
|
||||||
|
description: 'Imported from docker-compose',
|
||||||
|
category: 'stack',
|
||||||
|
logo: '',
|
||||||
|
config: result.config,
|
||||||
|
variables: [],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setEditorOpen(true);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setComposeError(error instanceof Error ? error.message : 'Failed to convert compose file');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const openCreateEditor = () => {
|
const openCreateEditor = () => {
|
||||||
setEditingTemplate(null);
|
setEditingTemplate(null);
|
||||||
setEditorJson(EMPTY_TEMPLATE_JSON);
|
setEditorJson(EMPTY_TEMPLATE_JSON);
|
||||||
@@ -438,6 +488,19 @@ export function TemplatesPage() {
|
|||||||
<Layers size={16} />
|
<Layers size={16} />
|
||||||
<span>{filteredTemplates.length} templates</span>
|
<span>{filteredTemplates.length} templates</span>
|
||||||
{signedIn && !isDemoMode ? (
|
{signedIn && !isDemoMode ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setComposeYaml('');
|
||||||
|
setComposeWarnings([]);
|
||||||
|
setComposeError(null);
|
||||||
|
setComposeOpen(true);
|
||||||
|
}}
|
||||||
|
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3.5 text-[12.5px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
<FileCode size={14} /> Import Compose
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openCreateEditor}
|
onClick={openCreateEditor}
|
||||||
@@ -445,6 +508,7 @@ export function TemplatesPage() {
|
|||||||
>
|
>
|
||||||
<Plus size={14} /> New template
|
<Plus size={14} /> New template
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -725,6 +789,42 @@ export function TemplatesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isGraphTemplate ? (
|
||||||
|
<div className="panel-soft p-4 mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Network size={14} className="text-[var(--accent-primary)]" />
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||||
|
Stack — {graphMembers.length} members
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{graphMembers.map((member) => (
|
||||||
|
<div key={member.key} className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{member.type === 'database' ? (
|
||||||
|
<Database size={13} className="text-[var(--info)] shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Box size={13} className="text-[var(--text-tertiary)] shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="mono text-[var(--text-primary)] truncate">{member.name || member.key}</span>
|
||||||
|
{member.dependsOn && member.dependsOn.length > 0 ? (
|
||||||
|
<span className="text-[10px] text-[var(--text-tertiary)]">
|
||||||
|
after {member.dependsOn.join(', ')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span className="mono text-[11px] text-[var(--text-tertiary)] truncate">
|
||||||
|
{member.type === 'database' ? `managed ${member.runtime}` : member.runtime || member.repo || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)] mt-3">
|
||||||
|
Members join a private project network; references like {'{{service.db.url}}'} resolve to the member key.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Deploy Section */}
|
{/* Deploy Section */}
|
||||||
<div className="border-t border-[var(--border-subtle)] pt-6">
|
<div className="border-t border-[var(--border-subtle)] pt-6">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
@@ -733,7 +833,9 @@ export function TemplatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Deploy from Template</h3>
|
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Deploy from Template</h3>
|
||||||
<p className="text-sm text-[var(--text-secondary)]">Configure and create a new service</p>
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{isGraphTemplate ? 'Deploy all members into a project' : 'Configure and create a new service'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -767,6 +869,7 @@ export function TemplatesPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
{!isGraphTemplate ? (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||||
Service Name
|
Service Name
|
||||||
@@ -784,6 +887,7 @@ export function TemplatesPage() {
|
|||||||
placeholder="service-name"
|
placeholder="service-name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedDetail.variables.length > 0 && (
|
{selectedDetail.variables.length > 0 && (
|
||||||
@@ -846,15 +950,30 @@ export function TemplatesPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Check size={16} />
|
<Check size={16} />
|
||||||
<span>
|
<span>
|
||||||
Service <span className="mono font-medium">{lastDeployment.serviceName}</span> created
|
{lastDeployment.created
|
||||||
|
? `Stack deployed — ${lastDeployment.created.length} resources`
|
||||||
|
: <>Service <span className="mono font-medium">{lastDeployment.serviceName}</span> created</>}
|
||||||
</span>
|
</span>
|
||||||
<Link
|
<Link
|
||||||
to={`/projects/${lastDeployment.projectId}/services/${lastDeployment.serviceId}${isDemoMode ? '?demo=1' : ''}`}
|
to={lastDeployment.created
|
||||||
|
? `/projects/${lastDeployment.projectId}${isDemoMode ? '?demo=1' : ''}`
|
||||||
|
: `/projects/${lastDeployment.projectId}/services/${lastDeployment.serviceId}${isDemoMode ? '?demo=1' : ''}`}
|
||||||
className="flex items-center gap-1 ml-2 underline underline-offset-2 hover:no-underline"
|
className="flex items-center gap-1 ml-2 underline underline-offset-2 hover:no-underline"
|
||||||
>
|
>
|
||||||
View <ArrowRight size={12} />
|
View <ArrowRight size={12} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
{lastDeployment.created ? (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{lastDeployment.created.map((m) => (
|
||||||
|
<div key={m.id} className="flex items-center gap-2 text-xs">
|
||||||
|
{m.kind === 'database' ? <Database size={11} /> : <Box size={11} />}
|
||||||
|
<span className="mono">{m.name}</span>
|
||||||
|
<span className="opacity-70">({m.kind})</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -868,12 +987,12 @@ export function TemplatesPage() {
|
|||||||
{deployMutation.isPending ? (
|
{deployMutation.isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 size={16} className="animate-spin" />
|
<Loader2 size={16} className="animate-spin" />
|
||||||
Creating...
|
Deploying...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Play size={16} />
|
<Play size={16} />
|
||||||
Create Service
|
{isGraphTemplate ? 'Deploy Stack' : 'Create Service'}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -929,6 +1048,14 @@ export function TemplatesPage() {
|
|||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
|
className="mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
|
||||||
/>
|
/>
|
||||||
|
{composeWarnings.length > 0 ? (
|
||||||
|
<div className="border-t border-[var(--border-subtle)] bg-[var(--warning-soft)]/60 px-5 py-2.5 text-xs text-[var(--warning)]">
|
||||||
|
<p className="font-medium mb-1">Compose import notes</p>
|
||||||
|
{composeWarnings.map((w) => (
|
||||||
|
<p key={w}>• {w}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{editorError ? (
|
{editorError ? (
|
||||||
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
|
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
|
||||||
{editorError}
|
{editorError}
|
||||||
@@ -966,6 +1093,59 @@ export function TemplatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Compose import */}
|
||||||
|
{composeOpen ? (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||||
|
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--border-subtle)] px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Import docker-compose</h2>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
Paste a compose file — known database images become managed databases, everything else becomes services.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setComposeOpen(false)}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
<X size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={composeYaml}
|
||||||
|
onChange={(e) => setComposeYaml(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={'services:\n db:\n image: postgres:16\n web:\n image: myapp:latest\n depends_on: [db]'}
|
||||||
|
className="mono min-h-[320px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
|
||||||
|
/>
|
||||||
|
{composeError ? (
|
||||||
|
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
|
||||||
|
{composeError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setComposeOpen(false)}
|
||||||
|
className="rounded-[var(--radius-md)] px-3.5 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => composeImportMutation.mutate(composeYaml)}
|
||||||
|
disabled={!composeYaml.trim() || composeImportMutation.isPending}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{composeImportMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <FileCode size={13} />}
|
||||||
|
Convert to template
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2801,6 +2801,10 @@ export interface paths {
|
|||||||
* Create resource from template
|
* Create resource from template
|
||||||
* @description Create a new resource in a project using a selected template.
|
* @description Create a new resource in a project using a selected template.
|
||||||
* For `database` templates, this creates a managed database service.
|
* For `database` templates, this creates a managed database service.
|
||||||
|
* For v2 graph templates (config.services non-empty), this deploys
|
||||||
|
* the entire service graph — databases first, then services in
|
||||||
|
* dependency order — and returns `resource: stack` with a `created`
|
||||||
|
* list. Deployments are enqueued for each service member.
|
||||||
*/
|
*/
|
||||||
post: {
|
post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -2835,6 +2839,187 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/templates/{id}/plan": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Dry-resolve a template
|
||||||
|
* @description Resolves a template without creating resources: variable merging,
|
||||||
|
* `{{secret}}`/`{{random:N}}`/`{{VAR}}`/`{{VAR:-default}}` expression
|
||||||
|
* expansion and `{{service.<key>.<prop>}}` references. Generated
|
||||||
|
* secrets are returned masked — a real deploy regenerates them.
|
||||||
|
* Returns `members` (with resolved environment), `missing_variables`
|
||||||
|
* and `unresolved` reference names.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Template ID */
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
project_id?: string;
|
||||||
|
variables?: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Resolved plan */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
members?: components["schemas"]["TemplatePlanMember"][];
|
||||||
|
missing_variables?: string[];
|
||||||
|
unresolved?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/templates/import/compose": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Convert docker-compose YAML to a template graph
|
||||||
|
* @description Parses a docker-compose YAML document and returns a v2 template
|
||||||
|
* config (`version: 2`, `services[]`). Well-known database images
|
||||||
|
* (postgres, redis, mongo, mysql, mariadb, clickhouse, dragonfly)
|
||||||
|
* become managed-database members; other images become web services.
|
||||||
|
* `build:` contexts are flagged in `warnings` — Containr deploys
|
||||||
|
* images or git repos, not local build contexts. The returned config
|
||||||
|
* can be saved via POST /templates or deployed directly via
|
||||||
|
* POST /templates/deploy.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** @description Raw docker-compose YAML (max 512 KiB) */
|
||||||
|
compose_yaml: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Converted config */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
config?: components["schemas"]["TemplateConfig"];
|
||||||
|
variables?: Record<string, never>[];
|
||||||
|
warnings?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/templates/deploy": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Deploy an ad-hoc service graph
|
||||||
|
* @description Deploys a v2 graph config (config.services[]) without a stored
|
||||||
|
* template — the companion to POST /templates/import/compose.
|
||||||
|
* Members are created in dependency order; `database` members become
|
||||||
|
* managed databases attached to the project network under their key
|
||||||
|
* as DNS alias, and deployments are enqueued for service members.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
project_id: string;
|
||||||
|
config: components["schemas"]["TemplateConfig"];
|
||||||
|
variables?: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
plan?: string;
|
||||||
|
region?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Graph deployed */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
resource?: string;
|
||||||
|
created?: {
|
||||||
|
key?: string;
|
||||||
|
kind?: string;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
deployment_id?: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/agent-tokens": {
|
"/agent-tokens": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -9213,6 +9398,8 @@ export interface components {
|
|||||||
is_public?: boolean;
|
is_public?: boolean;
|
||||||
};
|
};
|
||||||
TemplateConfig: {
|
TemplateConfig: {
|
||||||
|
/** @description Config version — 2 when services[] is used */
|
||||||
|
version?: number;
|
||||||
/** @description Service type for created service */
|
/** @description Service type for created service */
|
||||||
type?: string;
|
type?: string;
|
||||||
/** @description Runtime or base image */
|
/** @description Runtime or base image */
|
||||||
@@ -9235,6 +9422,52 @@ export interface components {
|
|||||||
nixpacks_config?: {
|
nixpacks_config?: {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* @description v2 graph members. When non-empty, the flat fields above are
|
||||||
|
* ignored and deploy creates the whole graph in dependency order.
|
||||||
|
*/
|
||||||
|
services?: components["schemas"]["TemplateServiceSpec"][];
|
||||||
|
};
|
||||||
|
TemplateServiceSpec: {
|
||||||
|
/** @description Member key — DNS alias on the project network and the target of {{service.<key>.*}} refs */
|
||||||
|
key: string;
|
||||||
|
/** @description Display/deployed name (defaults to key) */
|
||||||
|
name?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
type: "web" | "worker" | "cron" | "database";
|
||||||
|
/** @description Image reference (web/worker/cron) or managed engine (database) */
|
||||||
|
runtime?: string;
|
||||||
|
/** @description Git repository URL — when set, the member builds from source */
|
||||||
|
repo?: string;
|
||||||
|
branch?: string;
|
||||||
|
build_command?: string;
|
||||||
|
start_command?: string;
|
||||||
|
port?: number;
|
||||||
|
health_check?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
builder?: "auto" | "nixpacks" | "dockerfile" | "static";
|
||||||
|
/**
|
||||||
|
* @description Env vars. Values support {{secret}}, {{secret(N)}}, {{random:N}},
|
||||||
|
* {{VAR}}, {{VAR:-default}} and {{service.<key>.<prop>}} where prop
|
||||||
|
* is host, port, url, user, password or database.
|
||||||
|
*/
|
||||||
|
environment?: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
volumes?: components["schemas"]["ServiceVolume"][];
|
||||||
|
/** @description Member keys that must be created first */
|
||||||
|
depends_on?: string[];
|
||||||
|
};
|
||||||
|
TemplatePlanMember: {
|
||||||
|
key?: string;
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
runtime?: string;
|
||||||
|
port?: number;
|
||||||
|
depends_on?: string[];
|
||||||
|
environment?: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
TemplateVariable: {
|
TemplateVariable: {
|
||||||
/** @description Variable key */
|
/** @description Variable key */
|
||||||
@@ -9277,11 +9510,20 @@ export interface components {
|
|||||||
* @description Type of resource created by template deployment
|
* @description Type of resource created by template deployment
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
resource?: "service" | "database";
|
resource?: "service" | "database" | "stack";
|
||||||
/** @description Newly created service ID (set for non-database templates) */
|
/** @description Newly created service ID (set for non-database templates) */
|
||||||
service_id?: string;
|
service_id?: string;
|
||||||
/** @description Newly created managed database ID (set for database templates) */
|
/** @description Newly created managed database ID (set for database templates) */
|
||||||
database_id?: string;
|
database_id?: string;
|
||||||
|
/** @description Created resources (set for graph templates — resource=stack) */
|
||||||
|
created?: {
|
||||||
|
key?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
kind?: "service" | "database";
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
deployment_id?: string;
|
||||||
|
}[];
|
||||||
/** @description Deployment result message */
|
/** @description Deployment result message */
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
|
|||||||
"path": "guides/CLI.md",
|
"path": "guides/CLI.md",
|
||||||
"section": "guides",
|
"section": "guides",
|
||||||
"title": "Containr CLI",
|
"title": "Containr CLI",
|
||||||
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy <id>` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete <name>` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add <id> app.example.com --default\ncontainr services domains check <id> # DNS preflight per domain\ncontainr services domains remove <id> <domain-id>\n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update <id> --maintenance on\ncontainr services update <id> --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `<BASE
Line truncated
|
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy <id>` to apply immediately.\n\n`voLine truncated
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "guides/CLOUDFLARE_SETUP.md",
|
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||||
|
|||||||
@@ -164,7 +164,20 @@ export type TemplateEntity = {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TemplateServiceSpecEntity = {
|
||||||
|
key: string;
|
||||||
|
name?: string;
|
||||||
|
type: string;
|
||||||
|
runtime?: string;
|
||||||
|
repo?: string;
|
||||||
|
branch?: string;
|
||||||
|
port?: number;
|
||||||
|
dependsOn?: string[];
|
||||||
|
environment: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
export type TemplateConfigEntity = {
|
export type TemplateConfigEntity = {
|
||||||
|
version?: number;
|
||||||
type: string;
|
type: string;
|
||||||
runtime: string;
|
runtime: string;
|
||||||
buildCommand: string;
|
buildCommand: string;
|
||||||
@@ -174,6 +187,7 @@ export type TemplateConfigEntity = {
|
|||||||
environment: Record<string, string>;
|
environment: Record<string, string>;
|
||||||
dockerfile?: string;
|
dockerfile?: string;
|
||||||
nixpacksConfig: Record<string, string>;
|
nixpacksConfig: Record<string, string>;
|
||||||
|
services?: TemplateServiceSpecEntity[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TemplateVariableEntity = {
|
export type TemplateVariableEntity = {
|
||||||
@@ -197,12 +211,15 @@ export type ListTemplatesInput = {
|
|||||||
|
|
||||||
export type DeployTemplateInput = {
|
export type DeployTemplateInput = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
name: string;
|
name?: string;
|
||||||
variables?: Record<string, string>;
|
variables?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DeployTemplateResult = {
|
export type DeployTemplateResult = {
|
||||||
serviceId: string;
|
resource: string;
|
||||||
|
serviceId?: string;
|
||||||
|
databaseId?: string;
|
||||||
|
created?: { key: string; kind: string; id: string; name: string; deploymentId?: string }[];
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -696,7 +713,21 @@ function normalizeStringRecord(raw: unknown): Record<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTemplateConfig(config?: RawTemplateConfig): TemplateConfigEntity {
|
function normalizeTemplateConfig(config?: RawTemplateConfig): TemplateConfigEntity {
|
||||||
|
const services = Array.isArray(config?.services)
|
||||||
|
? config.services.map((s) => ({
|
||||||
|
key: s?.key ?? '',
|
||||||
|
name: s?.name ?? undefined,
|
||||||
|
type: s?.type ?? 'web',
|
||||||
|
runtime: s?.runtime ?? undefined,
|
||||||
|
repo: s?.repo ?? undefined,
|
||||||
|
branch: s?.branch ?? undefined,
|
||||||
|
port: s?.port ?? undefined,
|
||||||
|
dependsOn: Array.isArray(s?.depends_on) ? s.depends_on.filter((d): d is string => typeof d === 'string') : undefined,
|
||||||
|
environment: normalizeStringRecord(s?.environment),
|
||||||
|
}))
|
||||||
|
: undefined;
|
||||||
return {
|
return {
|
||||||
|
version: config?.version ?? undefined,
|
||||||
type: config?.type ?? '',
|
type: config?.type ?? '',
|
||||||
runtime: config?.runtime ?? '',
|
runtime: config?.runtime ?? '',
|
||||||
buildCommand: config?.build_command ?? '',
|
buildCommand: config?.build_command ?? '',
|
||||||
@@ -706,6 +737,7 @@ function normalizeTemplateConfig(config?: RawTemplateConfig): TemplateConfigEnti
|
|||||||
environment: normalizeStringRecord(config?.environment),
|
environment: normalizeStringRecord(config?.environment),
|
||||||
dockerfile: config?.dockerfile ?? undefined,
|
dockerfile: config?.dockerfile ?? undefined,
|
||||||
nixpacksConfig: normalizeStringRecord(config?.nixpacks_config),
|
nixpacksConfig: normalizeStringRecord(config?.nixpacks_config),
|
||||||
|
services,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1586,21 +1618,103 @@ export async function deployTemplate(
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
project_id: input.projectId,
|
project_id: input.projectId,
|
||||||
name: input.name,
|
name: input.name ?? '',
|
||||||
variables: input.variables ?? {},
|
variables: input.variables ?? {},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!payload.service_id) {
|
const created = Array.isArray(payload.created)
|
||||||
throw new ApiError('Template deployment response is invalid', 500);
|
? payload.created
|
||||||
}
|
.filter((m) => m && typeof m.id === 'string')
|
||||||
|
.map((m) => ({
|
||||||
|
key: m.key ?? '',
|
||||||
|
kind: m.kind ?? '',
|
||||||
|
id: m.id ?? '',
|
||||||
|
name: m.name ?? '',
|
||||||
|
deploymentId: m.deployment_id ?? undefined,
|
||||||
|
}))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
serviceId: payload.service_id,
|
resource: payload.resource ?? 'service',
|
||||||
message: payload.message ?? 'Service created from template',
|
serviceId: payload.service_id ?? undefined,
|
||||||
|
databaseId: payload.database_id ?? undefined,
|
||||||
|
created,
|
||||||
|
message: payload.message ?? 'Template deployed',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TemplatePlanMember = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
runtime?: string;
|
||||||
|
port?: number;
|
||||||
|
dependsOn?: string[];
|
||||||
|
environment: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TemplatePlanResult = {
|
||||||
|
members: TemplatePlanMember[];
|
||||||
|
missingVariables: string[];
|
||||||
|
unresolved: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function planTemplate(
|
||||||
|
templateId: string,
|
||||||
|
input: { projectId?: string; variables?: Record<string, string> },
|
||||||
|
): Promise<TemplatePlanResult> {
|
||||||
|
const payload = await requestJson<{
|
||||||
|
members?: {
|
||||||
|
key?: string;
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
runtime?: string;
|
||||||
|
port?: number;
|
||||||
|
depends_on?: string[];
|
||||||
|
environment?: Record<string, string>;
|
||||||
|
}[];
|
||||||
|
missing_variables?: string[];
|
||||||
|
unresolved?: string[];
|
||||||
|
}>(`/templates/${templateId}/plan`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
project_id: input.projectId ?? '',
|
||||||
|
variables: input.variables ?? {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
members: (payload.members ?? []).map((m) => ({
|
||||||
|
key: m.key ?? '',
|
||||||
|
name: m.name ?? '',
|
||||||
|
type: m.type ?? '',
|
||||||
|
runtime: m.runtime ?? undefined,
|
||||||
|
port: m.port ?? undefined,
|
||||||
|
dependsOn: m.depends_on ?? undefined,
|
||||||
|
environment: normalizeStringRecord(m.environment),
|
||||||
|
})),
|
||||||
|
missingVariables: payload.missing_variables ?? [],
|
||||||
|
unresolved: payload.unresolved ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ComposeImportResult = {
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
warnings: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function importComposeTemplate(composeYaml: string): Promise<ComposeImportResult> {
|
||||||
|
const payload = await requestJson<{ config?: Record<string, unknown>; warnings?: string[] }>(
|
||||||
|
'/templates/import/compose',
|
||||||
|
{ method: 'POST', body: JSON.stringify({ compose_yaml: composeYaml }) },
|
||||||
|
);
|
||||||
|
if (!payload.config || typeof payload.config !== 'object') {
|
||||||
|
throw new ApiError('Compose import response is invalid', 500);
|
||||||
|
}
|
||||||
|
return { config: payload.config, warnings: payload.warnings ?? [] };
|
||||||
|
}
|
||||||
|
|
||||||
export function serviceStatusClass(status: ServiceStatus): string {
|
export function serviceStatusClass(status: ServiceStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'running':
|
case 'running':
|
||||||
|
|||||||
+269
-1
@@ -1870,6 +1870,10 @@ paths:
|
|||||||
description: |
|
description: |
|
||||||
Create a new resource in a project using a selected template.
|
Create a new resource in a project using a selected template.
|
||||||
For `database` templates, this creates a managed database service.
|
For `database` templates, this creates a managed database service.
|
||||||
|
For v2 graph templates (config.services non-empty), this deploys
|
||||||
|
the entire service graph — databases first, then services in
|
||||||
|
dependency order — and returns `resource: stack` with a `created`
|
||||||
|
list. Deployments are enqueued for each service member.
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
parameters:
|
parameters:
|
||||||
@@ -1893,6 +1897,169 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/DeployTemplateResponse'
|
$ref: '#/components/schemas/DeployTemplateResponse'
|
||||||
|
|
||||||
|
/templates/{id}/plan:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Templates
|
||||||
|
summary: Dry-resolve a template
|
||||||
|
description: |
|
||||||
|
Resolves a template without creating resources: variable merging,
|
||||||
|
`{{secret}}`/`{{random:N}}`/`{{VAR}}`/`{{VAR:-default}}` expression
|
||||||
|
expansion and `{{service.<key>.<prop>}}` references. Generated
|
||||||
|
secrets are returned masked — a real deploy regenerates them.
|
||||||
|
Returns `members` (with resolved environment), `missing_variables`
|
||||||
|
and `unresolved` reference names.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: Template ID
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
project_id:
|
||||||
|
type: string
|
||||||
|
variables:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Resolved plan
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
members:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/TemplatePlanMember'
|
||||||
|
missing_variables:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
unresolved:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
/templates/import/compose:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Templates
|
||||||
|
summary: Convert docker-compose YAML to a template graph
|
||||||
|
description: |
|
||||||
|
Parses a docker-compose YAML document and returns a v2 template
|
||||||
|
config (`version: 2`, `services[]`). Well-known database images
|
||||||
|
(postgres, redis, mongo, mysql, mariadb, clickhouse, dragonfly)
|
||||||
|
become managed-database members; other images become web services.
|
||||||
|
`build:` contexts are flagged in `warnings` — Containr deploys
|
||||||
|
images or git repos, not local build contexts. The returned config
|
||||||
|
can be saved via POST /templates or deployed directly via
|
||||||
|
POST /templates/deploy.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- compose_yaml
|
||||||
|
properties:
|
||||||
|
compose_yaml:
|
||||||
|
type: string
|
||||||
|
description: Raw docker-compose YAML (max 512 KiB)
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Converted config
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
config:
|
||||||
|
$ref: '#/components/schemas/TemplateConfig'
|
||||||
|
variables:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
warnings:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
/templates/deploy:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Templates
|
||||||
|
summary: Deploy an ad-hoc service graph
|
||||||
|
description: |
|
||||||
|
Deploys a v2 graph config (config.services[]) without a stored
|
||||||
|
template — the companion to POST /templates/import/compose.
|
||||||
|
Members are created in dependency order; `database` members become
|
||||||
|
managed databases attached to the project network under their key
|
||||||
|
as DNS alias, and deployments are enqueued for service members.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- project_id
|
||||||
|
- config
|
||||||
|
properties:
|
||||||
|
project_id:
|
||||||
|
type: string
|
||||||
|
config:
|
||||||
|
$ref: '#/components/schemas/TemplateConfig'
|
||||||
|
variables:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
plan:
|
||||||
|
type: string
|
||||||
|
region:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Graph deployed
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
resource:
|
||||||
|
type: string
|
||||||
|
created:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
type: string
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
deployment_id:
|
||||||
|
type: string
|
||||||
|
|
||||||
# Agent Onboarding Tokens
|
# Agent Onboarding Tokens
|
||||||
/agent-tokens:
|
/agent-tokens:
|
||||||
post:
|
post:
|
||||||
@@ -6269,6 +6436,9 @@ components:
|
|||||||
TemplateConfig:
|
TemplateConfig:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
version:
|
||||||
|
type: integer
|
||||||
|
description: Config version — 2 when services[] is used
|
||||||
type:
|
type:
|
||||||
type: string
|
type: string
|
||||||
description: Service type for created service
|
description: Service type for created service
|
||||||
@@ -6300,6 +6470,87 @@ components:
|
|||||||
additionalProperties:
|
additionalProperties:
|
||||||
type: string
|
type: string
|
||||||
description: Optional nixpacks-specific config
|
description: Optional nixpacks-specific config
|
||||||
|
services:
|
||||||
|
type: array
|
||||||
|
description: |
|
||||||
|
v2 graph members. When non-empty, the flat fields above are
|
||||||
|
ignored and deploy creates the whole graph in dependency order.
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/TemplateServiceSpec'
|
||||||
|
|
||||||
|
TemplateServiceSpec:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- key
|
||||||
|
- type
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
type: string
|
||||||
|
description: Member key — DNS alias on the project network and the target of {{service.<key>.*}} refs
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Display/deployed name (defaults to key)
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [web, worker, cron, database]
|
||||||
|
runtime:
|
||||||
|
type: string
|
||||||
|
description: Image reference (web/worker/cron) or managed engine (database)
|
||||||
|
repo:
|
||||||
|
type: string
|
||||||
|
description: Git repository URL — when set, the member builds from source
|
||||||
|
branch:
|
||||||
|
type: string
|
||||||
|
build_command:
|
||||||
|
type: string
|
||||||
|
start_command:
|
||||||
|
type: string
|
||||||
|
port:
|
||||||
|
type: integer
|
||||||
|
health_check:
|
||||||
|
type: string
|
||||||
|
builder:
|
||||||
|
type: string
|
||||||
|
enum: [auto, nixpacks, dockerfile, static]
|
||||||
|
environment:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
Env vars. Values support {{secret}}, {{secret(N)}}, {{random:N}},
|
||||||
|
{{VAR}}, {{VAR:-default}} and {{service.<key>.<prop>}} where prop
|
||||||
|
is host, port, url, user, password or database.
|
||||||
|
volumes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ServiceVolume'
|
||||||
|
depends_on:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Member keys that must be created first
|
||||||
|
|
||||||
|
TemplatePlanMember:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
runtime:
|
||||||
|
type: string
|
||||||
|
port:
|
||||||
|
type: integer
|
||||||
|
depends_on:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
environment:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
|
||||||
TemplateVariable:
|
TemplateVariable:
|
||||||
type: object
|
type: object
|
||||||
@@ -6365,7 +6616,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
resource:
|
resource:
|
||||||
type: string
|
type: string
|
||||||
enum: [service, database]
|
enum: [service, database, stack]
|
||||||
description: Type of resource created by template deployment
|
description: Type of resource created by template deployment
|
||||||
service_id:
|
service_id:
|
||||||
type: string
|
type: string
|
||||||
@@ -6373,6 +6624,23 @@ components:
|
|||||||
database_id:
|
database_id:
|
||||||
type: string
|
type: string
|
||||||
description: Newly created managed database ID (set for database templates)
|
description: Newly created managed database ID (set for database templates)
|
||||||
|
created:
|
||||||
|
type: array
|
||||||
|
description: Created resources (set for graph templates — resource=stack)
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
type: string
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
enum: [service, database]
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
deployment_id:
|
||||||
|
type: string
|
||||||
message:
|
message:
|
||||||
type: string
|
type: string
|
||||||
description: Deployment result message
|
description: Deployment result message
|
||||||
|
|||||||
+5
-1
@@ -59,7 +59,11 @@ backup-targets list|add|update|remove|test
|
|||||||
first, empty --access-key/--secret-key on update keep stored keys;
|
first, empty --access-key/--secret-key on update keep stored keys;
|
||||||
update <db-id> --backup-target assigns a target ("" clears it)
|
update <db-id> --backup-target assigns a target ("" clears it)
|
||||||
cron list|get|create|delete|trigger|executions
|
cron list|get|create|delete|trigger|executions
|
||||||
templates list|get|create|delete|deploy
|
templates list|get|create|delete|deploy|plan|import-compose|deploy-graph
|
||||||
|
deploy --name --var K=V; plan dry-resolves expressions;
|
||||||
|
import-compose <compose.yml|-> converts a compose file into
|
||||||
|
a v2 graph config; deploy-graph <project-id> <config.json|->
|
||||||
|
deploys an ad-hoc service graph
|
||||||
nodes list|get|delete|tokens node agents + onboarding tokens (admin)
|
nodes list|get|delete|tokens node agents + onboarding tokens (admin)
|
||||||
scaling status|policies|services|scale
|
scaling status|policies|services|scale
|
||||||
ha status|alerts|health|policies|enable|disable|failover
|
ha status|alerts|health|policies|enable|disable|failover
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ Base URL: `https://<host>/api/v1`. OpenAPI: `docs/api/openapi.yaml`.
|
|||||||
- **Public DB port**: managed databases bind `127.0.0.1` by default; `public_port:true` on create or `PUT /databases/{id} {public_port}` binds `0.0.0.0`. Toggling recreates the container — the data volume persists.
|
- **Public DB port**: managed databases bind `127.0.0.1` by default; `public_port:true` on create or `PUT /databases/{id} {public_port}` binds `0.0.0.0`. Toggling recreates the container — the data volume persists.
|
||||||
- **Backup targets**: S3-compatible destinations (`/backup-targets` CRUD + `POST /backup-targets/{id}/test`). Endpoints/buckets are probed before storing; keys encrypted at rest (`has_credentials` only). Assign via `PUT /databases/{id} {backup_target_id}` — empty string clears; external databases reject assignment. Managed backups stay local; a copy ships to `[prefix/]databases/<db-id>/<archive>` and rows expose `remote:true`. Restore prefers the local archive and falls back to the remote object. Deleting a target is refused (409) while databases reference it.
|
- **Backup targets**: S3-compatible destinations (`/backup-targets` CRUD + `POST /backup-targets/{id}/test`). Endpoints/buckets are probed before storing; keys encrypted at rest (`has_credentials` only). Assign via `PUT /databases/{id} {backup_target_id}` — empty string clears; external databases reject assignment. Managed backups stay local; a copy ships to `[prefix/]databases/<db-id>/<archive>` and rows expose `remote:true`. Restore prefers the local archive and falls back to the remote object. Deleting a target is refused (409) while databases reference it.
|
||||||
- **Backup import**: `POST /databases/{id}/backups/import` — upload a `.tar.gz` (multipart `file` or raw `application/gzip` body, ≤2 GiB) to create a restore point; also ships offsite when a target is assigned. CLI: `databases import-backup <id> <file|->`.
|
- **Backup import**: `POST /databases/{id}/backups/import` — upload a `.tar.gz` (multipart `file` or raw `application/gzip` body, ≤2 GiB) to create a restore point; also ships offsite when a target is assigned. CLI: `databases import-backup <id> <file|->`.
|
||||||
|
- **Template graphs (v2)**: a template `config` with `services[]` deploys a multi-service stack. Members declare `key`, `type` (`web|worker|cron|database`), `runtime`, `environment`, `depends_on`. Env values support expressions: `{{secret}}`, `{{secret(N)}}`, `{{random:N}}`, `{{VAR}}`, `{{VAR:-default}}`, `{{service.<key>.<prop>}}` (props: `host`, `port`, `url`, `user`, `password`, `database`). `database` members become managed databases attached to the project network under their key — siblings resolve them by name. `POST /templates/{id}/plan` dry-resolves; `POST /templates/deploy` deploys an inline graph; `POST /templates/import/compose` converts docker-compose YAML (known db images → managed databases). Service↔service refs `${{svc.KEY}}` also resolve at runtime (HOST/PORT or env vars).
|
||||||
- **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens.
|
- **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens.
|
||||||
|
|
||||||
## Core objects
|
## Core objects
|
||||||
|
|||||||
Reference in new issue
Block a user