mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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>
160 lines
5.2 KiB
Go
160 lines
5.2 KiB
Go
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")
|
|
}
|
|
}
|