mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +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>
262 lines
6.8 KiB
Go
262 lines
6.8 KiB
Go
package commands
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// TemplatesCmd manages the template catalog.
|
|
var TemplatesCmd = &cobra.Command{
|
|
Use: "templates",
|
|
Aliases: []string{"template"},
|
|
Short: "Browse and deploy templates",
|
|
}
|
|
|
|
var templatesListCmd = &cobra.Command{
|
|
Use: "list",
|
|
Short: "List templates",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, err := c.Do("GET", "/templates", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
items, err := unwrapList(data, "templates")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows := make([][]string, 0, len(items))
|
|
for _, t := range items {
|
|
rows = append(rows, []string{
|
|
str(t, "id"), str(t, "name"), str(t, "category"), str(t, "description"),
|
|
})
|
|
}
|
|
printRows(data, []string{"ID", "NAME", "CATEGORY", "DESCRIPTION"}, rows)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var templatesGetCmd = &cobra.Command{
|
|
Use: "get <id>",
|
|
Short: "Show a template definition",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, err := c.Do("GET", "/templates/"+args[0], nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
PrintRaw(data)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var templatesCreateCmd = &cobra.Command{
|
|
Use: "create <file>",
|
|
Short: "Create a user template from a JSON file",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
raw, err := os.ReadFile(args[0])
|
|
if err != nil {
|
|
return fmt.Errorf("cannot read %s: %w", args[0], err)
|
|
}
|
|
c, err := client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, err := c.Do("POST", "/templates", raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
PrintRaw(data)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var templatesDeleteCmd = &cobra.Command{
|
|
Use: "delete <id>",
|
|
Short: "Delete a user template",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !Confirm(fmt.Sprintf("Delete template %s?", args[0])) {
|
|
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
|
|
}
|
|
if _, err := c.Do("DELETE", "/templates/"+args[0], nil); err != nil {
|
|
return err
|
|
}
|
|
if JSONMode() {
|
|
return PrintJSON(map[string]string{"status": "deleted", "id": args[0]})
|
|
}
|
|
fmt.Printf("Deleted template %s\n", args[0])
|
|
return nil
|
|
},
|
|
}
|
|
|
|
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
|
|
}
|
|
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
|
|
}
|
|
PrintRaw(data)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
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() {
|
|
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)
|
|
}
|