mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
Merge pull request #28 from Dvorinka/feat/templates-v2
feat(templates): v2 — multi-service graph templates, expressions, compose import
This commit is contained in:
16 files changed
+2479
-48
No files matched your search
+2
-2
@@ -13,6 +13,7 @@ require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/minio/minio-go/v7 v7.0.95
|
||||
github.com/modelcontextprotocol/go-sdk v1.7.0
|
||||
github.com/pressly/goose/v3 v3.24.1
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
@@ -23,6 +24,7 @@ require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/term v0.46.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -59,7 +61,6 @@ require (
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/minio/crc64nvme v1.0.2 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/minio/minio-go/v7 v7.0.95 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
@@ -106,7 +107,6 @@ require (
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
modernc.org/sqlite v1.39.0 // indirect
|
||||
)
|
||||
@@ -65,8 +65,6 @@ github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
@@ -90,8 +88,6 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -248,7 +244,6 @@ golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"archive/tar"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/docker"
|
||||
"containr/internal/secrets"
|
||||
"context"
|
||||
@@ -331,6 +332,11 @@ type managedDatabaseCreateRequest struct {
|
||||
Region string
|
||||
PublicPort bool
|
||||
RuntimeVariables map[string]string
|
||||
// ProjectID + NetworkAlias attach the container to a project network
|
||||
// under Alias — set by graph-template deploys so sibling services can
|
||||
// resolve the database by name.
|
||||
ProjectID string
|
||||
NetworkAlias string
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) createManagedDatabase(ctx context.Context, userID string, req managedDatabaseCreateRequest) (databaseID string, name string, dbType string, err error) {
|
||||
@@ -398,10 +404,17 @@ func (h *DatabaseHandler) createManagedDatabaseAndProvision(ctx context.Context,
|
||||
return "", err
|
||||
}
|
||||
|
||||
go h.provisionDatabaseWithVariables(databaseID, name, dbType, req.RuntimeVariables)
|
||||
go h.provisionDatabaseWithVariables(databaseID, name, dbType, req.RuntimeVariables, dbNetworkAttach{ProjectID: req.ProjectID, Alias: req.NetworkAlias})
|
||||
return databaseID, nil
|
||||
}
|
||||
|
||||
// dbNetworkAttach carries the optional project-network membership for a
|
||||
// managed database provisioned by a graph template.
|
||||
type dbNetworkAttach struct {
|
||||
ProjectID string
|
||||
Alias string
|
||||
}
|
||||
|
||||
// UpdateDatabase updates a database service
|
||||
func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
@@ -1350,7 +1363,7 @@ type databaseRuntimePlan struct {
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) provisionDatabase(databaseID, databaseName, dbType string) {
|
||||
h.provisionDatabaseWithVariables(databaseID, databaseName, dbType, nil)
|
||||
h.provisionDatabaseWithVariables(databaseID, databaseName, dbType, nil, dbNetworkAttach{})
|
||||
}
|
||||
|
||||
// reprovisionManagedDatabase recreates a managed container so changed
|
||||
@@ -1364,7 +1377,7 @@ func (h *DatabaseHandler) reprovisionManagedDatabase(databaseID, databaseName, d
|
||||
h.provisionDatabase(databaseID, databaseName, dbType)
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseName, dbType string, runtimeVariables map[string]string) {
|
||||
func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseName, dbType string, runtimeVariables map[string]string, attach dbNetworkAttach) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
@@ -1373,7 +1386,7 @@ func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseNam
|
||||
return
|
||||
}
|
||||
|
||||
connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables)
|
||||
connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables, attach)
|
||||
if err != nil {
|
||||
log.Printf("containr: managed database %s (%s) provisioning failed: %v", databaseID, dbType, err)
|
||||
_ = h.setDatabaseStatusAndConnection(databaseID, "error", sql.NullString{})
|
||||
@@ -1395,7 +1408,7 @@ func (h *DatabaseHandler) setDatabaseStatusAndConnection(databaseID, status stri
|
||||
})
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, databaseID, databaseName, dbType string, runtimeVariables map[string]string) (string, error) {
|
||||
func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, databaseID, databaseName, dbType string, runtimeVariables map[string]string, attach dbNetworkAttach) (string, error) {
|
||||
plan, err := buildDatabaseRuntimePlan(dbType, databaseName, runtimeVariables)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -1483,6 +1496,17 @@ func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, database
|
||||
return "", fmt.Errorf("failed to start container: %w", err)
|
||||
}
|
||||
|
||||
// Graph-template deploys attach the database to the project network so
|
||||
// sibling services resolve it by alias ({{service.<key>.host}}).
|
||||
if attach.ProjectID != "" && attach.Alias != "" {
|
||||
projectNet := deployment.ProjectNetworkName(attach.ProjectID)
|
||||
if err := h.dockerClient.ConnectNetwork(ctx, projectNet, containerID, network.EndpointSettings{
|
||||
Aliases: []string{attach.Alias},
|
||||
}); err != nil {
|
||||
log.Printf("containr: database %s attach to %s failed: %v", databaseID, projectNet, err)
|
||||
}
|
||||
}
|
||||
|
||||
hostPort, err := h.resolvePublishedHostPort(ctx, containerID, plan.Port)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -421,6 +421,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
authed.PUT("/templates/:id", handleUpdateTemplate)
|
||||
authed.DELETE("/templates/:id", handleDeleteTemplate)
|
||||
authed.POST("/templates/:id/deploy", handleCreateFromTemplate)
|
||||
authed.POST("/templates/:id/plan", handlePlanTemplate)
|
||||
authed.POST("/templates/import/compose", handleImportCompose)
|
||||
authed.POST("/templates/deploy", handleDeployInlineGraph)
|
||||
|
||||
authed.POST("/cron-jobs", handleCreateCronJob)
|
||||
authed.PUT("/cron-jobs/:id", handleUpdateCronJob)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,159 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTopoSortServicesOrdersByDependsOn(t *testing.T) {
|
||||
sorted, err := topoSortServices([]TemplateServiceSpec{
|
||||
{Key: "web", DependsOn: []string{"db"}},
|
||||
{Key: "db", Type: "database"},
|
||||
{Key: "worker", DependsOn: []string{"web", "db"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("topoSortServices: %v", err)
|
||||
}
|
||||
if sorted[0].Key != "db" || sorted[1].Key != "web" || sorted[2].Key != "worker" {
|
||||
t.Fatalf("unexpected order: %v", []string{sorted[0].Key, sorted[1].Key, sorted[2].Key})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopoSortServicesRejectsCycle(t *testing.T) {
|
||||
_, err := topoSortServices([]TemplateServiceSpec{
|
||||
{Key: "a", DependsOn: []string{"b"}},
|
||||
{Key: "b", DependsOn: []string{"a"}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "cycle") {
|
||||
t.Fatalf("expected cycle error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopoSortServicesRejectsUnknownDep(t *testing.T) {
|
||||
_, err := topoSortServices([]TemplateServiceSpec{
|
||||
{Key: "a", DependsOn: []string{"ghost"}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown key") {
|
||||
t.Fatalf("expected unknown-key error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStaticExpr(t *testing.T) {
|
||||
unresolved := []string{}
|
||||
var generated bool
|
||||
|
||||
v := resolveStaticExpr("{{secret}}", nil, &unresolved, &generated)
|
||||
if len(v) != 32 || !generated {
|
||||
t.Fatalf("secret: got %q generated=%v", v, generated)
|
||||
}
|
||||
v = resolveStaticExpr("{{secret(8)}}", nil, &unresolved, &generated)
|
||||
if len(v) != 16 {
|
||||
t.Fatalf("secret(8): got %q", v)
|
||||
}
|
||||
v = resolveStaticExpr("port-{{random:10}}", nil, &unresolved, &generated)
|
||||
if !strings.HasPrefix(v, "port-") {
|
||||
t.Fatalf("random: got %q", v)
|
||||
}
|
||||
v = resolveStaticExpr("{{DOMAIN:-example.com}}", nil, &unresolved, &generated)
|
||||
if v != "example.com" {
|
||||
t.Fatalf("var default: got %q", v)
|
||||
}
|
||||
v = resolveStaticExpr("{{DOMAIN}}", map[string]string{"DOMAIN": "x.io"}, &unresolved, &generated)
|
||||
if v != "x.io" {
|
||||
t.Fatalf("var override: got %q", v)
|
||||
}
|
||||
v = resolveStaticExpr("{{MISSING}}", nil, &unresolved, &generated)
|
||||
if v != "" || len(unresolved) == 0 || unresolved[0] != "MISSING" {
|
||||
t.Fatalf("missing var: got %q unresolved=%v", v, unresolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTemplateGraphDatabaseRefs(t *testing.T) {
|
||||
sorted, err := topoSortServices([]TemplateServiceSpec{
|
||||
{Key: "db", Type: "database", Runtime: "postgresql", Environment: map[string]string{
|
||||
"POSTGRES_DB": "shop",
|
||||
}},
|
||||
{Key: "web", Type: "web", Runtime: "nginx", DependsOn: []string{"db"}, Environment: map[string]string{
|
||||
"DATABASE_URL": "{{service.db.url}}",
|
||||
"DB_HOST": "{{service.db.host}}",
|
||||
"DB_PORT": "{{service.db.port}}",
|
||||
"SESSION_TOKEN": "{{secret}}",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("topo: %v", err)
|
||||
}
|
||||
graph := resolveTemplateGraph(sorted, nil)
|
||||
if len(graph.unresolved) > 0 {
|
||||
t.Fatalf("unresolved: %v", graph.unresolved)
|
||||
}
|
||||
web := graph.members[1]
|
||||
if web.env["DB_HOST"] != "db" {
|
||||
t.Fatalf("DB_HOST=%q", web.env["DB_HOST"])
|
||||
}
|
||||
if web.env["DB_PORT"] != "5432" {
|
||||
t.Fatalf("DB_PORT=%q", web.env["DB_PORT"])
|
||||
}
|
||||
dbURL := web.env["DATABASE_URL"]
|
||||
if !strings.HasPrefix(dbURL, "postgresql://") || !strings.Contains(dbURL, "@db:5432/shop") {
|
||||
t.Fatalf("DATABASE_URL=%q", dbURL)
|
||||
}
|
||||
if len(web.env["SESSION_TOKEN"]) != 32 || !web.secretKeys["SESSION_TOKEN"] {
|
||||
t.Fatalf("SESSION_TOKEN=%q secret=%v", web.env["SESSION_TOKEN"], web.secretKeys["SESSION_TOKEN"])
|
||||
}
|
||||
// Database member must carry the generated password so refs and the
|
||||
// container agree.
|
||||
db := graph.members[0]
|
||||
if pw := db.env["POSTGRES_PASSWORD"]; len(pw) != 32 || !db.secretKeys["POSTGRES_PASSWORD"] {
|
||||
t.Fatalf("POSTGRES_PASSWORD=%q secret=%v", pw, db.secretKeys["POSTGRES_PASSWORD"])
|
||||
}
|
||||
if !strings.Contains(dbURL, db.env["POSTGRES_PASSWORD"]) {
|
||||
t.Fatalf("url %q does not carry the resolved password", dbURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTemplateGraphServiceRefs(t *testing.T) {
|
||||
sorted, err := topoSortServices([]TemplateServiceSpec{
|
||||
{Key: "api", Type: "web", Runtime: "ghcr.io/x/api:1", Port: 8080},
|
||||
{Key: "front", Type: "web", Runtime: "ghcr.io/x/front:1", Port: 3000, DependsOn: []string{"api"}, Environment: map[string]string{
|
||||
"API_URL": "{{service.api.url}}/v2",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("topo: %v", err)
|
||||
}
|
||||
graph := resolveTemplateGraph(sorted, nil)
|
||||
if got := graph.members[1].env["API_URL"]; got != "http://api:8080/v2" {
|
||||
t.Fatalf("API_URL=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTemplateGraphUnresolvedServiceProp(t *testing.T) {
|
||||
sorted, _ := topoSortServices([]TemplateServiceSpec{
|
||||
{Key: "web", Type: "web", Runtime: "nginx", Environment: map[string]string{
|
||||
"X": "{{service.nope.host}}",
|
||||
}},
|
||||
})
|
||||
graph := resolveTemplateGraph(sorted, nil)
|
||||
if len(graph.unresolved) == 0 {
|
||||
t.Fatal("expected unresolved ref")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTemplateGraph(t *testing.T) {
|
||||
if err := validateTemplateGraph([]TemplateServiceSpec{
|
||||
{Key: "db", Type: "database", Runtime: "postgresql"},
|
||||
}); err != nil {
|
||||
t.Fatalf("valid db spec rejected: %v", err)
|
||||
}
|
||||
if err := validateTemplateGraph([]TemplateServiceSpec{
|
||||
{Key: "db", Type: "database", Runtime: "nonsense"},
|
||||
}); err == nil {
|
||||
t.Fatal("expected unsupported db engine error")
|
||||
}
|
||||
if err := validateTemplateGraph([]TemplateServiceSpec{
|
||||
{Key: "web", Type: "web"},
|
||||
}); err == nil {
|
||||
t.Fatal("expected missing runtime error")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package api
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/secrets"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
@@ -34,6 +36,7 @@ type ServiceTemplate struct {
|
||||
}
|
||||
|
||||
type TemplateConfig struct {
|
||||
Version int `json:"version,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Runtime string `json:"runtime"`
|
||||
BuildCommand string `json:"build_command"`
|
||||
@@ -43,6 +46,9 @@ type TemplateConfig struct {
|
||||
Environment map[string]string `json:"environment"`
|
||||
Dockerfile string `json:"dockerfile,omitempty"`
|
||||
NixpacksConfig map[string]string `json:"nixpacks_config,omitempty"`
|
||||
// Services makes this a v2 graph template. When non-empty the flat
|
||||
// fields above are ignored and deployTemplateGraph handles the deploy.
|
||||
Services []TemplateServiceSpec `json:"services,omitempty"`
|
||||
}
|
||||
|
||||
type TemplateVariable struct {
|
||||
@@ -147,7 +153,7 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
|
||||
var req struct {
|
||||
ProjectID string `json:"project_id" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Name string `json:"name"`
|
||||
Plan string `json:"plan,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Variables map[string]string `json:"variables"`
|
||||
@@ -160,10 +166,6 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
|
||||
req.ProjectID = strings.TrimSpace(req.ProjectID)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
projectID, err := uuid.Parse(req.ProjectID)
|
||||
if err != nil {
|
||||
@@ -213,6 +215,51 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(config.Services) > 0 {
|
||||
var engine *deployment.DeploymentEngine
|
||||
if v, exists := c.Get("deployment_engine"); exists && v != nil {
|
||||
engine, _ = v.(*deployment.DeploymentEngine)
|
||||
}
|
||||
var dbHandler *DatabaseHandler
|
||||
if v, exists := c.Get("database_handler"); exists {
|
||||
dbHandler, _ = v.(*DatabaseHandler)
|
||||
}
|
||||
|
||||
userVars := map[string]string{}
|
||||
for _, v := range templateVars {
|
||||
if override, ok := req.Variables[v.Key]; ok {
|
||||
userVars[v.Key] = override
|
||||
} else if v.Default != "" {
|
||||
userVars[v.Key] = v.Default
|
||||
}
|
||||
}
|
||||
for k, v := range req.Variables {
|
||||
userVars[k] = v
|
||||
}
|
||||
|
||||
created, fail := deployTemplateGraph(c, db, engine, dbHandler, projectID, userID,
|
||||
config.Services, templateVars, userVars, req.Plan, req.Region, "template:"+templateID)
|
||||
if fail != nil {
|
||||
status := http.StatusBadRequest
|
||||
if (*fail)["code"] == "CONFLICT" {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, *fail)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"resource": "stack",
|
||||
"created": created,
|
||||
"message": fmt.Sprintf("Deployed %d resources from template %s", len(created), template.Name),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
serviceType, err := normalizeTemplateServiceType(config.Type)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -351,11 +398,15 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
stored := value
|
||||
if secretKeys[key] {
|
||||
stored = secrets.Encrypt(value)
|
||||
}
|
||||
err = txQueries.UpsertEnvironmentVariable(ctx, sqlcdb.UpsertEnvironmentVariableParams{
|
||||
ID: uuid.New(),
|
||||
ServiceID: serviceID,
|
||||
Key: key,
|
||||
Value: value,
|
||||
Value: stored,
|
||||
IsSecret: sql.NullBool{Bool: secretKeys[key], Valid: true},
|
||||
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
@@ -927,6 +978,34 @@ func SeedTemplates() []ServiceTemplate {
|
||||
Variables: `[]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
// Multi-service stacks — v2 graph configs. Database members become
|
||||
// managed databases attached to the project network under their key.
|
||||
{
|
||||
ID: "tpl-wordpress-stack",
|
||||
Name: "WordPress + MySQL",
|
||||
Description: "WordPress with a managed MySQL database on a private project network",
|
||||
Category: "stack",
|
||||
Logo: "https://cdn.simpleicons.org/wordpress",
|
||||
Config: `{"version":2,"services":[
|
||||
{"key":"db","type":"database","runtime":"mysql","environment":{"MYSQL_DATABASE":"wordpress"}},
|
||||
{"key":"web","name":"wordpress","type":"web","runtime":"wordpress:6-apache","port":80,"health_check":"/wp-login.php","depends_on":["db"],
|
||||
"environment":{"WORDPRESS_DB_HOST":"{{service.db.host}}","WORDPRESS_DB_USER":"{{service.db.user}}","WORDPRESS_DB_PASSWORD":"{{service.db.password}}","WORDPRESS_DB_NAME":"{{service.db.database}}"}}]}`,
|
||||
Variables: `[]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-n8n-stack",
|
||||
Name: "n8n + PostgreSQL",
|
||||
Description: "n8n workflow automation backed by a managed PostgreSQL database",
|
||||
Category: "stack",
|
||||
Logo: "https://cdn.simpleicons.org/n8n",
|
||||
Config: `{"version":2,"services":[
|
||||
{"key":"db","type":"database","runtime":"postgresql"},
|
||||
{"key":"n8n","type":"web","runtime":"n8nio/n8n:latest","port":5678,"health_check":"/healthz","depends_on":["db"],
|
||||
"environment":{"DB_TYPE":"postgresdb","DB_POSTGRESDB_HOST":"{{service.db.host}}","DB_POSTGRESDB_PORT":"{{service.db.port}}","DB_POSTGRESDB_DATABASE":"{{service.db.database}}","DB_POSTGRESDB_USER":"{{service.db.user}}","DB_POSTGRESDB_PASSWORD":"{{service.db.password}}","N8N_ENCRYPTION_KEY":"{{secret}}"}}]}`,
|
||||
Variables: `[]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
}
|
||||
return templates
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -104,16 +107,31 @@ var templatesDeleteCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var tplDeployVars []string
|
||||
var tplDeployName string
|
||||
|
||||
var templatesDeployCmd = &cobra.Command{
|
||||
Use: "deploy <template-id> <project-id>",
|
||||
Short: "Deploy a template into a project",
|
||||
Long: "Deploys a single-service template or a multi-service graph (v2) template. Pass --name for single-service templates; graph members take names from the template. Use --var KEY=VALUE to override template variables.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", "/templates/"+args[0]+"/deploy", map[string]string{"project_id": args[1]})
|
||||
body := map[string]interface{}{"project_id": args[1]}
|
||||
if tplDeployName != "" {
|
||||
body["name"] = tplDeployName
|
||||
}
|
||||
vars, err := parseKVFlags(tplDeployVars)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(vars) > 0 {
|
||||
body["variables"] = vars
|
||||
}
|
||||
data, err := c.Do("POST", "/templates/"+args[0]+"/deploy", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -122,6 +140,122 @@ var templatesDeployCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var templatesPlanCmd = &cobra.Command{
|
||||
Use: "plan <template-id>",
|
||||
Short: "Dry-resolve a template — expanded expressions, service refs, missing variables",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vars, err := parseKVFlags(tplDeployVars)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
if len(vars) > 0 {
|
||||
body["variables"] = vars
|
||||
}
|
||||
data, err := c.Do("POST", "/templates/"+args[0]+"/plan", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var templatesImportComposeCmd = &cobra.Command{
|
||||
Use: "import-compose <compose.yml|->",
|
||||
Short: "Convert a docker-compose file into a template graph config",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var raw []byte
|
||||
var err error
|
||||
if args[0] == "-" {
|
||||
raw, err = io.ReadAll(os.Stdin)
|
||||
} else {
|
||||
raw, err = os.ReadFile(args[0])
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read compose file: %w", err)
|
||||
}
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", "/templates/import/compose", map[string]string{"compose_yaml": string(raw)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var templatesDeployGraphCmd = &cobra.Command{
|
||||
Use: "deploy-graph <project-id> <config.json|->",
|
||||
Short: "Deploy an ad-hoc service graph (v2 config with services[]) without a stored template",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var raw []byte
|
||||
var err error
|
||||
if args[1] == "-" {
|
||||
raw, err = io.ReadAll(os.Stdin)
|
||||
} else {
|
||||
raw, err = os.ReadFile(args[1])
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read config: %w", err)
|
||||
}
|
||||
var config json.RawMessage
|
||||
if err := json.Unmarshal(raw, &config); err != nil {
|
||||
return fmt.Errorf("invalid config JSON: %w", err)
|
||||
}
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vars, err := parseKVFlags(tplDeployVars)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{"project_id": args[0]}
|
||||
var cfg map[string]interface{}
|
||||
if err := json.Unmarshal(config, &cfg); err != nil {
|
||||
return fmt.Errorf("invalid config JSON: %w", err)
|
||||
}
|
||||
body["config"] = cfg
|
||||
if len(vars) > 0 {
|
||||
body["variables"] = vars
|
||||
}
|
||||
data, err := c.Do("POST", "/templates/deploy", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// parseKVFlags converts repeated --var KEY=VALUE flags into a map.
|
||||
func parseKVFlags(flags []string) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
for _, f := range flags {
|
||||
idx := strings.Index(f, "=")
|
||||
if idx <= 0 {
|
||||
return nil, fmt.Errorf("invalid --var %q — expected KEY=VALUE", f)
|
||||
}
|
||||
out[f[:idx]] = f[idx+1:]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd)
|
||||
templatesDeployCmd.Flags().StringVar(&tplDeployName, "name", "", "Name for the created service (single-service templates)")
|
||||
templatesDeployCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
|
||||
templatesPlanCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
|
||||
templatesDeployGraphCmd.Flags().StringArrayVar(&tplDeployVars, "var", nil, "Template variable override (KEY=VALUE, repeatable)")
|
||||
TemplatesCmd.AddCommand(templatesListCmd, templatesGetCmd, templatesCreateCmd, templatesDeleteCmd, templatesDeployCmd, templatesPlanCmd, templatesImportComposeCmd, templatesDeployGraphCmd)
|
||||
}
|
||||
@@ -128,6 +128,9 @@ func init() {
|
||||
register(spec{"containr_templates_list", "List templates", "GET", "/templates", nil, nil, false})
|
||||
register(spec{"containr_templates_get", "Get a template definition", "GET", "/templates/{id}", []string{"id"}, nil, false})
|
||||
register(spec{"containr_templates_deploy", "Deploy a template into a project. Args: id (template), project_id.", "POST", "/templates/{id}/deploy", []string{"id"}, []string{"project_id"}, false})
|
||||
register(spec{"containr_templates_plan", "Dry-resolve a template: expanded expressions, service refs, missing variables. Args: id, variables (object).", "POST", "/templates/{id}/plan", []string{"id"}, nil, false})
|
||||
register(spec{"containr_templates_import_compose", "Convert docker-compose YAML into a v2 template graph config. Args: compose_yaml.", "POST", "/templates/import/compose", nil, []string{"compose_yaml"}, false})
|
||||
register(spec{"containr_templates_deploy_graph", "Deploy an ad-hoc service graph without a stored template. Args: project_id, config ({services:[...]}), variables, plan, region.", "POST", "/templates/deploy", nil, []string{"project_id", "config", "variables", "plan", "region"}, false})
|
||||
|
||||
// Notifications
|
||||
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
deployTemplate,
|
||||
getCurrentUserProfile,
|
||||
getTemplateById,
|
||||
importComposeTemplate,
|
||||
listProjects,
|
||||
listTemplates,
|
||||
updateTemplate,
|
||||
@@ -36,6 +37,8 @@ import {
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
FileCode,
|
||||
Network,
|
||||
} from 'lucide-react';
|
||||
|
||||
function toServiceName(value: string): string {
|
||||
@@ -147,10 +150,16 @@ export function TemplatesPage() {
|
||||
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
|
||||
Record<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<{
|
||||
projectId: string;
|
||||
serviceId: string;
|
||||
serviceId?: string;
|
||||
serviceName: string;
|
||||
created?: { key: string; kind: string; id: string; name: string; deploymentId?: string }[];
|
||||
} | null>(null);
|
||||
|
||||
const templatesQuery = useQuery({
|
||||
@@ -281,6 +290,12 @@ export function TemplatesPage() {
|
||||
});
|
||||
}, [selectedDetail, variableValues]);
|
||||
|
||||
const graphMembers = useMemo(
|
||||
() => selectedDetail?.config.services ?? [],
|
||||
[selectedDetail],
|
||||
);
|
||||
const isGraphTemplate = graphMembers.length > 0;
|
||||
|
||||
const deployMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedTemplateId) {
|
||||
@@ -296,17 +311,21 @@ export function TemplatesPage() {
|
||||
|
||||
return deployTemplate(selectedTemplateId, {
|
||||
projectId: deployProjectId,
|
||||
name: deployName.trim(),
|
||||
name: isGraphTemplate ? undefined : deployName.trim(),
|
||||
variables,
|
||||
});
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['project-services'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['databases'] });
|
||||
setLastDeployment({
|
||||
projectId: deployProjectId,
|
||||
serviceId: result.serviceId,
|
||||
serviceName: deployName.trim(),
|
||||
serviceName: isGraphTemplate
|
||||
? `${result.created?.length ?? 0} resources`
|
||||
: deployName.trim(),
|
||||
created: result.created,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -314,7 +333,7 @@ export function TemplatesPage() {
|
||||
const isDeployDisabled =
|
||||
!selectedTemplateId ||
|
||||
!deployProjectId ||
|
||||
deployName.trim().length === 0 ||
|
||||
(!isGraphTemplate && deployName.trim().length === 0) ||
|
||||
missingRequiredVariables.length > 0 ||
|
||||
deployMutation.isPending;
|
||||
|
||||
@@ -344,6 +363,37 @@ export function TemplatesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const composeImportMutation = useMutation({
|
||||
mutationFn: (yaml: string) => importComposeTemplate(yaml),
|
||||
onSuccess: (result) => {
|
||||
setComposeWarnings(result.warnings);
|
||||
setComposeError(null);
|
||||
setComposeOpen(false);
|
||||
// Load the converted graph into the template editor for review/save.
|
||||
setEditingTemplate(null);
|
||||
setEditorIsPublic(false);
|
||||
setEditorError(null);
|
||||
setEditorJson(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'compose-stack',
|
||||
description: 'Imported from docker-compose',
|
||||
category: 'stack',
|
||||
logo: '',
|
||||
config: result.config,
|
||||
variables: [],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
setEditorOpen(true);
|
||||
},
|
||||
onError: (error) => {
|
||||
setComposeError(error instanceof Error ? error.message : 'Failed to convert compose file');
|
||||
},
|
||||
});
|
||||
|
||||
const openCreateEditor = () => {
|
||||
setEditingTemplate(null);
|
||||
setEditorJson(EMPTY_TEMPLATE_JSON);
|
||||
@@ -438,13 +488,27 @@ export function TemplatesPage() {
|
||||
<Layers size={16} />
|
||||
<span>{filteredTemplates.length} templates</span>
|
||||
{signedIn && !isDemoMode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateEditor}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 text-[12.5px] font-semibold text-[var(--accent-on)]"
|
||||
>
|
||||
<Plus size={14} /> New template
|
||||
</button>
|
||||
<>
|
||||
<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
|
||||
type="button"
|
||||
onClick={openCreateEditor}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 text-[12.5px] font-semibold text-[var(--accent-on)]"
|
||||
>
|
||||
<Plus size={14} /> New template
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -725,6 +789,42 @@ export function TemplatesPage() {
|
||||
)}
|
||||
</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 */}
|
||||
<div className="border-t border-[var(--border-subtle)] pt-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
@@ -733,7 +833,9 @@ export function TemplatesPage() {
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
@@ -767,6 +869,7 @@ export function TemplatesPage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{!isGraphTemplate ? (
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
Service Name
|
||||
@@ -784,6 +887,7 @@ export function TemplatesPage() {
|
||||
placeholder="service-name"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{selectedDetail.variables.length > 0 && (
|
||||
@@ -846,15 +950,30 @@ export function TemplatesPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Check size={16} />
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
View <ArrowRight size={12} />
|
||||
</Link>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -868,12 +987,12 @@ export function TemplatesPage() {
|
||||
{deployMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Creating...
|
||||
Deploying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={16} />
|
||||
Create Service
|
||||
{isGraphTemplate ? 'Deploy Stack' : 'Create Service'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -929,6 +1048,14 @@ export function TemplatesPage() {
|
||||
spellCheck={false}
|
||||
className="mono min-h-[380px] flex-1 resize-none bg-[var(--bg-void)] p-4 text-[12.5px] leading-relaxed text-[var(--text-secondary)] outline-none"
|
||||
/>
|
||||
{composeWarnings.length > 0 ? (
|
||||
<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 ? (
|
||||
<div className="border-t border-[var(--border-subtle)] bg-[var(--error-soft)] px-5 py-2.5 text-xs text-[var(--error)]">
|
||||
{editorError}
|
||||
@@ -966,6 +1093,59 @@ export function TemplatesPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
@@ -2801,6 +2801,10 @@ export interface paths {
|
||||
* Create resource from template
|
||||
* @description Create a new resource in a project using a selected template.
|
||||
* 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: {
|
||||
parameters: {
|
||||
@@ -2835,6 +2839,187 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -9305,6 +9490,8 @@ export interface components {
|
||||
is_public?: boolean;
|
||||
};
|
||||
TemplateConfig: {
|
||||
/** @description Config version — 2 when services[] is used */
|
||||
version?: number;
|
||||
/** @description Service type for created service */
|
||||
type?: string;
|
||||
/** @description Runtime or base image */
|
||||
@@ -9327,6 +9514,52 @@ export interface components {
|
||||
nixpacks_config?: {
|
||||
[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: {
|
||||
/** @description Variable key */
|
||||
@@ -9369,11 +9602,20 @@ export interface components {
|
||||
* @description Type of resource created by template deployment
|
||||
* @enum {string}
|
||||
*/
|
||||
resource?: "service" | "database";
|
||||
resource?: "service" | "database" | "stack";
|
||||
/** @description Newly created service ID (set for non-database templates) */
|
||||
service_id?: string;
|
||||
/** @description Newly created managed database ID (set for database templates) */
|
||||
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 */
|
||||
message?: string;
|
||||
};
|
||||
|
||||
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
|
||||
"path": "guides/CLI.md",
|
||||
"section": "guides",
|
||||
"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",
|
||||
|
||||
@@ -164,7 +164,20 @@ export type TemplateEntity = {
|
||||
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 = {
|
||||
version?: number;
|
||||
type: string;
|
||||
runtime: string;
|
||||
buildCommand: string;
|
||||
@@ -174,6 +187,7 @@ export type TemplateConfigEntity = {
|
||||
environment: Record<string, string>;
|
||||
dockerfile?: string;
|
||||
nixpacksConfig: Record<string, string>;
|
||||
services?: TemplateServiceSpecEntity[];
|
||||
};
|
||||
|
||||
export type TemplateVariableEntity = {
|
||||
@@ -197,12 +211,15 @@ export type ListTemplatesInput = {
|
||||
|
||||
export type DeployTemplateInput = {
|
||||
projectId: string;
|
||||
name: string;
|
||||
name?: string;
|
||||
variables?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type DeployTemplateResult = {
|
||||
serviceId: string;
|
||||
resource: string;
|
||||
serviceId?: string;
|
||||
databaseId?: string;
|
||||
created?: { key: string; kind: string; id: string; name: string; deploymentId?: string }[];
|
||||
message: string;
|
||||
};
|
||||
|
||||
@@ -696,7 +713,21 @@ function normalizeStringRecord(raw: unknown): Record<string, string> {
|
||||
}
|
||||
|
||||
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 {
|
||||
version: config?.version ?? undefined,
|
||||
type: config?.type ?? '',
|
||||
runtime: config?.runtime ?? '',
|
||||
buildCommand: config?.build_command ?? '',
|
||||
@@ -706,6 +737,7 @@ function normalizeTemplateConfig(config?: RawTemplateConfig): TemplateConfigEnti
|
||||
environment: normalizeStringRecord(config?.environment),
|
||||
dockerfile: config?.dockerfile ?? undefined,
|
||||
nixpacksConfig: normalizeStringRecord(config?.nixpacks_config),
|
||||
services,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1602,21 +1634,103 @@ export async function deployTemplate(
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
project_id: input.projectId,
|
||||
name: input.name,
|
||||
name: input.name ?? '',
|
||||
variables: input.variables ?? {},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!payload.service_id) {
|
||||
throw new ApiError('Template deployment response is invalid', 500);
|
||||
}
|
||||
const created = Array.isArray(payload.created)
|
||||
? 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 {
|
||||
serviceId: payload.service_id,
|
||||
message: payload.message ?? 'Service created from template',
|
||||
resource: payload.resource ?? 'service',
|
||||
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 {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
|
||||
+269
-1
@@ -1870,6 +1870,10 @@ paths:
|
||||
description: |
|
||||
Create a new resource in a project using a selected template.
|
||||
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:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
@@ -1893,6 +1897,169 @@ paths:
|
||||
schema:
|
||||
$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-tokens:
|
||||
post:
|
||||
@@ -6341,6 +6508,9 @@ components:
|
||||
TemplateConfig:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: integer
|
||||
description: Config version — 2 when services[] is used
|
||||
type:
|
||||
type: string
|
||||
description: Service type for created service
|
||||
@@ -6372,6 +6542,87 @@ components:
|
||||
additionalProperties:
|
||||
type: string
|
||||
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:
|
||||
type: object
|
||||
@@ -6437,7 +6688,7 @@ components:
|
||||
properties:
|
||||
resource:
|
||||
type: string
|
||||
enum: [service, database]
|
||||
enum: [service, database, stack]
|
||||
description: Type of resource created by template deployment
|
||||
service_id:
|
||||
type: string
|
||||
@@ -6445,6 +6696,23 @@ components:
|
||||
database_id:
|
||||
type: string
|
||||
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:
|
||||
type: string
|
||||
description: Deployment result message
|
||||
|
||||
+5
-1
@@ -61,7 +61,11 @@ backup-targets list|add|update|remove|test
|
||||
first, empty --access-key/--secret-key on update keep stored keys;
|
||||
update <db-id> --backup-target assigns a target ("" clears it)
|
||||
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)
|
||||
scaling status|policies|services|scale
|
||||
ha status|alerts|health|policies|enable|disable|failover
|
||||
|
||||
@@ -26,6 +26,7 @@ Base URL: `https://<host>/api/v1`. OpenAPI: `docs/api/openapi.yaml`.
|
||||
- **Public DB port**: managed databases bind `127.0.0.1` by default; `public_port:true` on create or `PUT /databases/{id} {public_port}` binds `0.0.0.0`. Toggling recreates the container — the data volume persists.
|
||||
- **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|->`.
|
||||
- **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.
|
||||
|
||||
## Core objects
|
||||
|
||||
Reference in new issue
Block a user