From ad58028c4d0a2c72b43e0adf5b8456f5194a6ba9 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Tue, 30 Jun 2026 19:41:58 -0300 Subject: [PATCH] feat(template): `template new` authoring wizard + scaffold builder (spec 23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `devstack template new` — the interactive sibling of `template init` that authors a complete template bundle (template.yaml + optional build/ tree + golden) through one deterministic builder fed by two front-ends. - internal/template/scaffold: pure, deterministic Build(Spec)->Bundle (app vs engine is a compile-time branch; meta emitted as literal YAML via ordered goccy MapSlice; actions only under service:/build); WriteBundle atomic + no-clobber/--force backup; PreviewSource for the real Resolve+LintResolved preview. - Three authoring lints (meta-templating hard error; delimiter-collision and param-type warnings) shared by `template new` preview AND wired into `template lint` (template.ParseCheck added for collision detection). - CLI: newTemplateNewCmd with the full flag surface (--kind/--name/--from/ --extends/--base-image/--param/--port/--provides/--exports/--entrypoint/ --golden/--regold/--dir/--dry-run/--force/--print-spec) + a Bubble Tea v2 wizard gated on prompt.IsInteractive so --json/--quiet/--no-input/non-TTY/CI never enter bubbletea. --print-spec ⇒ --from is a byte-identical round-trip. - template.ValidRef exported for name validation; `template init` unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/cli/template.go | 56 +++- internal/cli/template_new.go | 396 +++++++++++++++++++++++ internal/cli/template_new_test.go | 176 ++++++++++ internal/cli/template_new_tui.go | 186 +++++++++++ internal/template/engine.go | 15 + internal/template/scaffold/build.go | 302 +++++++++++++++++ internal/template/scaffold/build_test.go | 172 ++++++++++ internal/template/scaffold/lint.go | 162 ++++++++++ internal/template/scaffold/lint_test.go | 86 +++++ internal/template/scaffold/spec.go | 63 ++++ internal/template/scaffold/write.go | 120 +++++++ internal/template/scaffold/write_test.go | 60 ++++ internal/template/source.go | 6 + 13 files changed, 1791 insertions(+), 9 deletions(-) create mode 100644 internal/cli/template_new.go create mode 100644 internal/cli/template_new_test.go create mode 100644 internal/cli/template_new_tui.go create mode 100644 internal/template/scaffold/build.go create mode 100644 internal/template/scaffold/build_test.go create mode 100644 internal/template/scaffold/lint.go create mode 100644 internal/template/scaffold/lint_test.go create mode 100644 internal/template/scaffold/spec.go create mode 100644 internal/template/scaffold/write.go create mode 100644 internal/template/scaffold/write_test.go diff --git a/internal/cli/template.go b/internal/cli/template.go index 35fb013..def0a44 100644 --- a/internal/cli/template.go +++ b/internal/cli/template.go @@ -10,6 +10,7 @@ import ( "github.com/open-source-cloud/devstack/internal/generate" "github.com/open-source-cloud/devstack/internal/store" "github.com/open-source-cloud/devstack/internal/template" + "github.com/open-source-cloud/devstack/internal/template/scaffold" ) // newTemplateCmd wires `devstack template list|lint|test|init` — the M1 template @@ -25,6 +26,7 @@ func newTemplateCmd(g *GlobalOpts) *cobra.Command { newTemplateLintCmd(g), newTemplateTestCmd(g), newTemplateInitCmd(g), + newTemplateNewCmd(g), ) return cmd } @@ -74,12 +76,15 @@ func newTemplateLintCmd(g *GlobalOpts) *cobra.Command { Short: "Render a template with defaults and validate it through compose-go", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - compose, name, err := lintTemplateDir(args[0]) + compose, name, warnings, err := lintTemplateDir(args[0]) if err != nil { return err } if g.JSON { - return writeJSON(cmd, map[string]any{"ok": true, "template": name}) + return writeJSON(cmd, map[string]any{"ok": true, "template": name, "warnings": warnings}) + } + for _, w := range warnings { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: %s\n", w) } if show { fmt.Fprintf(cmd.OutOrStdout(), "%s", compose) @@ -101,7 +106,7 @@ func newTemplateTestCmd(g *GlobalOpts) *cobra.Command { Short: "Render a template with defaults and assert it validates (and matches golden, if present)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - compose, name, err := lintTemplateDir(args[0]) + compose, name, _, err := lintTemplateDir(args[0]) if err != nil { return err } @@ -146,21 +151,54 @@ func newTemplateInitCmd(g *GlobalOpts) *cobra.Command { } // lintTemplateDir resolves and validates a template directory, returning the -// rendered single-service compose and the template name. -func lintTemplateDir(dir string) ([]byte, string, error) { +// rendered single-service compose, the template name, and any authoring-lint +// warnings. The authoring lints (spec 23: meta-templating/delimiter-collision/ +// param-type) run first — a meta-templating action is a hard error — then the +// compose-go render+validation. +func lintTemplateDir(dir string) ([]byte, string, []string, error) { src, name, err := template.NewDirSource(dir) if err != nil { - return nil, "", err + return nil, "", nil, err + } + manifest, err := os.ReadFile(filepath.Join(dir, template.TemplateFile)) + if err != nil { + return nil, name, nil, err + } + lr, err := scaffold.Lint(manifest, readBuildFiles(dir)) + if err != nil { + return nil, name, nil, err } res, err := template.Resolve(src, name, nil) if err != nil { - return nil, name, err + return nil, name, lr.Warnings, err } compose, err := generate.LintResolved(name, res) if err != nil { - return nil, name, err + return nil, name, lr.Warnings, err } - return compose, name, nil + return compose, name, lr.Warnings, nil +} + +// readBuildFiles reads a template dir's build/ tree into a relpath→bytes map +// (keys like "build/Dockerfile"), the shape scaffold.Lint expects. A missing +// build/ dir yields an empty map. +func readBuildFiles(dir string) map[string][]byte { + out := map[string][]byte{} + buildDir := filepath.Join(dir, template.BuildDir) + _ = filepath.WalkDir(buildDir, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + rel, rerr := filepath.Rel(dir, p) + if rerr != nil { + return nil + } + if data, rerr := os.ReadFile(p); rerr == nil { + out[filepath.ToSlash(rel)] = data + } + return nil + }) + return out } // scaffoldTemplate writes a minimal template.yaml + build/Dockerfile skeleton. diff --git a/internal/cli/template_new.go b/internal/cli/template_new.go new file mode 100644 index 0000000..e99f363 --- /dev/null +++ b/internal/cli/template_new.go @@ -0,0 +1,396 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/generate" + "github.com/open-source-cloud/devstack/internal/prompt" + "github.com/open-source-cloud/devstack/internal/store" + "github.com/open-source-cloud/devstack/internal/template" + "github.com/open-source-cloud/devstack/internal/template/scaffold" +) + +// errTemplateNewCancelled signals the wizard was dismissed; new prints a notice and +// exits 0. +var errTemplateNewCancelled = errors.New("template new cancelled") + +// newTemplateNewCmd wires `devstack template new` — the interactive sibling of +// `template init` (spec 23). It authors a complete template bundle (template.yaml + +// optional build/ tree + golden) through ONE deterministic builder (scaffold.Build) +// fed by two front-ends: a Bubble Tea wizard on a TTY, and a flag/`--from` path that +// CI, `--json`, `--quiet`, and non-TTY always take. It writes only store files; it +// takes no lock, touches no ledger, and starts no Docker. +func newTemplateNewCmd(g *GlobalOpts) *cobra.Command { + var ( + kind string + name string + from string + extends string + description string + baseImage string + params []string + port int + provides string + exports []string + entrypoint bool + golden bool + noGolden bool + regold string + dir string + dryRun bool + force bool + printSpec bool + extraBuild []string + noInput bool + ) + cmd := &cobra.Command{ + Use: "new [name]", + Short: "Author a service template interactively (or from flags/--from)", + Long: "new scaffolds a complete template bundle — template.yaml + an optional build/\n" + + "tree (Dockerfile, entrypoint) + an optional golden fixture — through one\n" + + "deterministic builder. Run it bare in a terminal for the wizard; run it with\n" + + "flags or --from for a scriptable, byte-stable result. It writes only store\n" + + "templates; it takes no lock, touches no ledger, and starts no Docker.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 1 && name == "" { + name = args[0] + } + if dir == "" { + dir = store.TemplatesPath() + } + + // --regold: re-render an existing template's golden.yaml and stop. + if regold != "" { + return regoldTemplate(cmd, g, regold) + } + + // Resolve the Spec from one of three faces: --from file, the TUI wizard, or + // flags. Only a bare TTY invocation with no input flags enters bubbletea. + hasFlags := kind != "" || name != "" || from != "" || extends != "" || + baseImage != "" || provides != "" || len(params) > 0 || len(exports) > 0 || + len(extraBuild) > 0 || port != 0 || entrypoint + var spec scaffold.Spec + var err error + switch { + case from != "": + spec, err = specFromFile(from) + case prompt.IsInteractive(g.JSON, g.Quiet, noInput) && !hasFlags: + spec, err = runTemplateNewWizard(builtinSource()) + if errors.Is(err, errTemplateNewCancelled) { + if !g.Quiet { + fmt.Fprintln(cmd.ErrOrStderr(), "template new cancelled — nothing written") + } + return nil + } + default: + spec, err = specFromFlags(specFlags{ + kind: kind, name: name, extends: extends, description: description, + baseImage: baseImage, params: params, port: port, provides: provides, + exports: exports, entrypoint: entrypoint, extraBuild: extraBuild, + golden: golden && !noGolden, + }) + } + if err != nil { + return err + } + // Apply golden default to the --from / wizard paths too unless suppressed. + if noGolden { + spec.Golden = false + } + + if !template.ValidRef(spec.Name) { + return fmt.Errorf("invalid template name %q: must be a single path segment (dots ok, no slash/.., not absolute)", spec.Name) + } + + if printSpec { + return printResolvedSpec(cmd, spec) + } + + // Refuse to clobber an existing template name (store or built-in) unless --force. + if !force { + if builtinSource().Has(spec.Name) { + return fmt.Errorf("template %q already resolves (store or built-in); pass --force to author over it", spec.Name) + } + } + + bundle, err := scaffold.Build(spec) + if err != nil { + return err + } + + // Live preview through the REAL render + validation path. A render or + // compose-go error blocks the write (you cannot author an ungeneratable + // bundle). Authoring-lint warnings are surfaced; a meta-templating action is + // a hard error from scaffold.Lint. + compose, warnings, err := previewBundle(spec.Name, bundle) + if err != nil { + return err + } + for _, w := range warnings { + if !g.Quiet { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: %s\n", w) + } + } + if spec.Golden { + bundle["golden.yaml"] = compose + } + + if dryRun { + return templateNewDryRun(cmd, g, dir, spec.Name, bundle) + } + + written, err := scaffold.WriteBundle(dir, spec.Name, bundle, force) + if err != nil { + return err + } + + if g.JSON { + return writeJSON(cmd, templateNewSummary(spec.Name, filepath.Join(dir, spec.Name), bundle, true)) + } + if !g.Quiet { + w := cmd.OutOrStdout() + target := filepath.Join(dir, spec.Name) + fmt.Fprintf(w, "wrote template %q (%d files) at %s\n", spec.Name, len(written), target) + fmt.Fprintf(w, "next: %s template lint %s\n", rootName(cmd), target) + fmt.Fprintf(w, " %s template test %s\n", rootName(cmd), target) + fmt.Fprintf(w, " reference it from a repo: services: { app: { template: %s } }\n", spec.Name) + } + return nil + }, + } + f := cmd.Flags() + f.StringVar(&kind, "kind", "", "template kind: app (buildable) | engine (image-based shared service)") + f.StringVar(&name, "name", "", "template name (single path segment; dots ok, no slash/..)") + f.StringVar(&from, "from", "", "read a scaffold.Spec (YAML) instead of prompting/flags") + f.StringVar(&extends, "extends", "", "parent template to extend") + f.StringVar(&description, "description", "", "human description") + f.StringVar(&baseImage, "base-image", "", "app: Dockerfile FROM; engine: image:") + f.StringArrayVar(¶ms, "param", nil, "param name:type[:default][:required], repeatable") + f.IntVar(&port, "port", 0, "engine: defaultPort; app: published-port hint (advisory)") + f.StringVar(&provides, "provides", "", "engine only: capability provided (e.g. postgres)") + f.StringSliceVar(&exports, "exports", nil, "engine only: importable attrs (csv: host,port,user)") + f.BoolVar(&entrypoint, "entrypoint", false, "app: also scaffold build/entrypoint.sh") + f.StringArrayVar(&extraBuild, "extra-build", nil, "app: extra build/ file to seed, repeatable") + f.BoolVar(&golden, "golden", true, "scaffold /golden.yaml") + f.BoolVar(&noGolden, "no-golden", false, "do not scaffold golden.yaml") + f.StringVar(®old, "regold", "", "re-render the golden for an existing template dir, write nothing else") + f.StringVar(&dir, "dir", "", "parent dir for the new template (default: the store templates dir)") + f.BoolVar(&dryRun, "dry-run", false, "print the would-be bundle as a tree, write nothing") + f.BoolVar(&force, "force", false, "overwrite an existing template dir (backs up first)") + f.BoolVar(&printSpec, "print-spec", false, "emit the resolved scaffold.Spec as YAML and exit") + f.BoolVar(&noInput, "no-input", false, "never launch the wizard; use flags only (implied by --json/--quiet/non-TTY/CI)") + return cmd +} + +// specFlags carries the raw flag surface into the parser. +type specFlags struct { + kind, name, extends, description, baseImage, provides string + params, exports, extraBuild []string + port int + entrypoint, golden bool +} + +// specFromFlags assembles a scaffold.Spec from the flag surface. --kind and --name +// are required on this path (there is no TTY to fill them). +func specFromFlags(f specFlags) (scaffold.Spec, error) { + if f.kind == "" { + return scaffold.Spec{}, fmt.Errorf("no TTY; pass --kind app|engine (or --name / --from), or use `template init`") + } + if f.name == "" { + return scaffold.Spec{}, fmt.Errorf("no TTY; pass --name (or --from), or use `template init`") + } + parsed, err := parseParamFlags(f.params) + if err != nil { + return scaffold.Spec{}, err + } + return scaffold.Spec{ + Kind: scaffold.Kind(f.kind), + Name: f.name, + Extends: f.extends, + Description: f.description, + BaseImage: f.baseImage, + Params: parsed, + Provides: f.provides, + Exports: f.exports, + DefaultPort: f.port, + Entrypoint: f.entrypoint, + ExtraBuild: f.extraBuild, + Golden: f.golden, + }, nil +} + +// parseParamFlags parses repeatable `name:type[:default][:required]` entries. +func parseParamFlags(raw []string) ([]scaffold.Param, error) { + var out []scaffold.Param + for _, r := range raw { + parts := strings.Split(r, ":") + if parts[0] == "" { + return nil, fmt.Errorf("--param %q: empty name", r) + } + p := scaffold.Param{Name: parts[0]} + if len(parts) > 1 { + p.Type = parts[1] + } + if len(parts) > 2 { + p.Default = parts[2] + } + if len(parts) > 3 { + req, err := strconv.ParseBool(parts[3]) + if err != nil { + return nil, fmt.Errorf("--param %q: required must be true/false, got %q", r, parts[3]) + } + p.Required = req + } + if len(parts) > 4 { + return nil, fmt.Errorf("--param %q: expected name:type[:default][:required]", r) + } + out = append(out, p) + } + return out, nil +} + +// specFromFile reads a scaffold.Spec from a YAML file (the --from round-trip). +func specFromFile(path string) (scaffold.Spec, error) { + raw, err := os.ReadFile(path) + if err != nil { + return scaffold.Spec{}, err + } + var spec scaffold.Spec + if err := yaml.Unmarshal(raw, &spec); err != nil { + return scaffold.Spec{}, fmt.Errorf("parse spec %s: %w", path, err) + } + if spec.Name == "" || spec.Kind == "" { + return scaffold.Spec{}, fmt.Errorf("spec %s: kind and name are required", path) + } + return spec, nil +} + +// printResolvedSpec emits the resolved Spec as deterministic YAML (Spec has no maps, +// so struct field order is stable) for the --print-spec ⇒ --from round-trip. +func printResolvedSpec(cmd *cobra.Command, spec scaffold.Spec) error { + out, err := yaml.Marshal(spec) + if err != nil { + return err + } + _, err = cmd.OutOrStdout().Write(out) + return err +} + +// previewBundle renders the authored bundle through the production path +// (template.Resolve → generate.LintResolved) and runs the authoring lints. A +// render/validation error or a meta-templating hard error blocks the write. +func previewBundle(name string, bundle scaffold.Bundle) ([]byte, []string, error) { + lr, err := scaffold.Lint(bundle["template.yaml"], scaffold.BuildFilesOf(bundle)) + if err != nil { + return nil, nil, err + } + src := scaffold.PreviewSource(name, bundle, builtinSource()) + res, err := template.Resolve(src, name, nil) + if err != nil { + return nil, lr.Warnings, err + } + compose, err := generate.LintResolved(name, res) + if err != nil { + return nil, lr.Warnings, err + } + return compose, lr.Warnings, nil +} + +// regoldTemplate re-renders /golden.yaml from the live template, writing +// nothing else. +func regoldTemplate(cmd *cobra.Command, g *GlobalOpts, dir string) error { + compose, name, _, err := lintTemplateDir(dir) + if err != nil { + return err + } + golden := filepath.Join(dir, "golden.yaml") + if err := scaffold.WriteFile(golden, compose); err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{"template": name, "golden": golden, "wrote": true}) + } + if !g.Quiet { + fmt.Fprintf(cmd.OutOrStdout(), "rewrote %s\n", golden) + } + return nil +} + +// templateNewDryRun prints the would-be bundle as a sorted file tree, writing +// nothing. +func templateNewDryRun(cmd *cobra.Command, g *GlobalOpts, dir, name string, bundle scaffold.Bundle) error { + target := filepath.Join(dir, name) + if g.JSON { + return writeJSON(cmd, templateNewSummary(name, target, bundle, false)) + } + if g.Quiet { + return nil + } + w := cmd.OutOrStdout() + fmt.Fprintf(w, "%s/\n", target) + for _, rel := range sortedBundleKeys(bundle) { + fmt.Fprintf(w, " %s (%d bytes)\n", rel, len(bundle[rel])) + } + fmt.Fprintln(w, "(dry-run: validates ok, nothing written)") + return nil +} + +type tmplNewSummary struct { + Template string `json:"template"` + Path string `json:"path"` + Files []string `json:"files"` + Wrote bool `json:"wrote"` +} + +func templateNewSummary(name, path string, bundle scaffold.Bundle, wrote bool) tmplNewSummary { + return tmplNewSummary{Template: name, Path: path, Files: sortedBundleKeys(bundle), Wrote: wrote} +} + +func sortedBundleKeys(b scaffold.Bundle) []string { + out := make([]string, 0, len(b)) + for k := range b { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// optionalInt validates an optional integer text field (empty is allowed). +func optionalInt(s string) error { + if s == "" { + return nil + } + if _, err := strconv.Atoi(s); err != nil { + return fmt.Errorf("must be a number") + } + return nil +} + +func atoiOrZero(s string) int { + n, err := strconv.Atoi(s) + if err != nil { + return 0 + } + return n +} + +// splitCSV splits a comma-separated list, trimming spaces and dropping empties. +func splitCSV(s string) []string { + var out []string + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/internal/cli/template_new_test.go b/internal/cli/template_new_test.go new file mode 100644 index 0000000..5a0edaa --- /dev/null +++ b/internal/cli/template_new_test.go @@ -0,0 +1,176 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// readDirBytes reads every file under root into a relpath→bytes map for byte +// comparison. +func readDirBytes(t *testing.T, root string) map[string]string { + t.Helper() + out := map[string]string{} + err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + rel, _ := filepath.Rel(root, p) + b, rerr := os.ReadFile(p) + if rerr != nil { + return rerr + } + out[filepath.ToSlash(rel)] = string(b) + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", root, err) + } + return out +} + +// TestTemplateNewFromRoundTrip asserts `--print-spec` then `--from` yields a +// byte-identical bundle to the equivalent flag invocation. +func TestTemplateNewFromRoundTrip(t *testing.T) { + t.Setenv("DEVSTACK_HOME", t.TempDir()) + flagArgs := []string{ + "--kind", "app", "--name", "node.bun", "--base-image", "oven/bun:1", + "--param", "bunVersion:string:1", "--param", "port:int:5173", "--entrypoint", + } + + specOut, err := runCmd(t, append([]string{"template", "new", "--print-spec"}, flagArgs...)...) + if err != nil { + t.Fatalf("print-spec: %v\n%s", err, specOut) + } + specFile := filepath.Join(t.TempDir(), "spec.yaml") + if err := os.WriteFile(specFile, []byte(specOut), 0o644); err != nil { + t.Fatal(err) + } + + d1 := t.TempDir() + if out, err := runCmd(t, append([]string{"template", "new", "--no-input", "--dir", d1}, flagArgs...)...); err != nil { + t.Fatalf("flag path: %v\n%s", err, out) + } + d2 := t.TempDir() + if out, err := runCmd(t, "template", "new", "--from", specFile, "--json", "--dir", d2); err != nil { + t.Fatalf("from path: %v\n%s", err, out) + } + + a := readDirBytes(t, filepath.Join(d1, "node.bun")) + b := readDirBytes(t, filepath.Join(d2, "node.bun")) + if len(a) != len(b) { + t.Fatalf("file sets differ: %v vs %v", keysOf(a), keysOf(b)) + } + for k, av := range a { + if av != b[k] { + t.Errorf("file %q differs between flag and --from path", k) + } + } +} + +func keysOf(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +// TestTemplateNewNonTTYNeverPrompts asserts that with no TTY / --json / --no-input +// and no name, `template new` returns a clear error instead of entering bubbletea +// (the test would hang if it tried to prompt). +func TestTemplateNewNonTTYNeverPrompts(t *testing.T) { + out, err := runCmd(t, "template", "new", "--json") + if err == nil { + t.Fatalf("expected a no-TTY error, got success:\n%s", out) + } + if !strings.Contains(err.Error(), "no TTY") && !strings.Contains(err.Error(), "--kind") { + t.Errorf("error should mention the no-TTY fallback, got: %v", err) + } + + // --no-input with --kind but no name also fails fast, never prompts. + if _, err := runCmd(t, "template", "new", "--no-input", "--kind", "app"); err == nil { + t.Error("expected an error requiring --name on the non-interactive path") + } +} + +// TestTemplateNewAuthoredAppPassesLintAndTest asserts an authored app bundle passes +// `template lint` and `template test` immediately (golden matches rendered output). +func TestTemplateNewAuthoredAppPassesLintAndTest(t *testing.T) { + t.Setenv("DEVSTACK_HOME", t.TempDir()) + dir := t.TempDir() + if out, err := runCmd(t, "template", "new", "--no-input", "--dir", dir, + "--kind", "app", "--name", "node.bun", "--base-image", "oven/bun:1", + "--param", "bunVersion:string:1", "--entrypoint"); err != nil { + t.Fatalf("author: %v\n%s", err, out) + } + tdir := filepath.Join(dir, "node.bun") + if _, err := os.Stat(filepath.Join(tdir, "golden.yaml")); err != nil { + t.Fatalf("expected golden.yaml: %v", err) + } + if out, err := runCmd(t, "template", "lint", tdir); err != nil { + t.Fatalf("lint authored: %v\n%s", err, out) + } + if out, err := runCmd(t, "template", "test", tdir); err != nil { + t.Fatalf("test authored: %v\n%s", err, out) + } +} + +// TestTemplateNewEngineHasNoBuildTree asserts --kind engine emits image:/provides +// and no build/ tree. +func TestTemplateNewEngineHasNoBuildTree(t *testing.T) { + t.Setenv("DEVSTACK_HOME", t.TempDir()) + dir := t.TempDir() + if out, err := runCmd(t, "template", "new", "--no-input", "--dir", dir, + "--kind", "engine", "--name", "mariadb", "--base-image", "mariadb:11", + "--provides", "mariadb", "--exports", "host,port,user", "--port", "3306"); err != nil { + t.Fatalf("author engine: %v\n%s", err, out) + } + if _, err := os.Stat(filepath.Join(dir, "mariadb", "build")); !os.IsNotExist(err) { + t.Errorf("engine template must have no build/ tree, stat err = %v", err) + } + manifest, _ := os.ReadFile(filepath.Join(dir, "mariadb", "template.yaml")) + for _, want := range []string{"image: mariadb:11", "provides: mariadb"} { + if !strings.Contains(string(manifest), want) { + t.Errorf("engine template.yaml missing %q:\n%s", want, manifest) + } + } +} + +// TestTemplateNewRejectsBadName mirrors source.validRef exactly. +func TestTemplateNewRejectsBadName(t *testing.T) { + t.Setenv("DEVSTACK_HOME", t.TempDir()) + for _, bad := range []string{"my/app", "..", "/abs"} { + if _, err := runCmd(t, "template", "new", "--no-input", "--dir", t.TempDir(), + "--kind", "app", "--name", bad, "--base-image", "x"); err == nil { + t.Errorf("name %q should be rejected", bad) + } + } +} + +// TestTemplateInitUnchanged asserts `template init` still writes only template.yaml +// + build/Dockerfile and NO golden.yaml (spec 23 keeps init exactly as it was). +func TestTemplateInitUnchanged(t *testing.T) { + dir := t.TempDir() + if out, err := runCmd(t, "template", "init", "myapp", "--dir", dir); err != nil { + t.Fatalf("template init: %v\n%s", err, out) + } + files := readDirBytes(t, filepath.Join(dir, "myapp")) + want := map[string]bool{"template.yaml": true, "build/Dockerfile": true} + if len(files) != len(want) { + t.Fatalf("template init wrote %v, want exactly %v", keysOf(files), want) + } + for k := range want { + if _, ok := files[k]; !ok { + t.Errorf("template init missing %q", k) + } + } + if _, ok := files["golden.yaml"]; ok { + t.Error("template init must NOT write golden.yaml") + } + // Re-init refuses to overwrite (unchanged behavior). + if _, err := runCmd(t, "template", "init", "myapp", "--dir", dir); err == nil { + t.Error("template init must refuse to overwrite an existing dir") + } +} diff --git a/internal/cli/template_new_tui.go b/internal/cli/template_new_tui.go new file mode 100644 index 0000000..d6aff2f --- /dev/null +++ b/internal/cli/template_new_tui.go @@ -0,0 +1,186 @@ +package cli + +import ( + "errors" + "fmt" + "sort" + + huh "charm.land/huh/v2" + + "github.com/open-source-cloud/devstack/internal/prompt" + "github.com/open-source-cloud/devstack/internal/template" + "github.com/open-source-cloud/devstack/internal/template/scaffold" +) + +// runTemplateNewWizard drives the interactive `template new` flow (huh on Bubble +// Tea v2) and returns the same scaffold.Spec the flag path produces — the spec-23 +// "two faces, one builder" seam. Kind/name/extends/base-image/description, then a +// params loop, then a live preview rendered through the REAL Resolve + +// LintResolved path and a confirm. It never runs unless prompt.IsInteractive +// already cleared a real TTY (the caller gates this). +func runTemplateNewWizard(src template.TemplateSource) (scaffold.Spec, error) { + spec := scaffold.Spec{Golden: true} + + kind := string(scaffold.KindApp) + parents := extendsOptions(src) + + main := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Kind"). + Description("app = buildable (Dockerfile) · engine = image-based shared service"). + Options( + huh.NewOption("app (buildable)", string(scaffold.KindApp)), + huh.NewOption("engine (shared service)", string(scaffold.KindEngine)), + ).Value(&kind), + huh.NewInput().Title("Name"). + Description("single path segment; dots ok (php.laravel.nginx), no slash/.."). + Value(&spec.Name). + Validate(func(s string) error { + if !template.ValidRef(s) { + return fmt.Errorf("must be a single path segment (dots ok, no slash/.., not absolute)") + } + return nil + }), + huh.NewSelect[string](). + Title("Extends (optional)"). + Options(parents...).Value(&spec.Extends), + huh.NewInput().Title("Base image"). + Description("app: Dockerfile FROM · engine: image:"). + Value(&spec.BaseImage).Validate(huh.ValidateNotEmpty()), + huh.NewInput().Title("Description (optional)").Value(&spec.Description), + ), + ).WithTheme(prompt.Theme()) + if err := main.Run(); err != nil { + return scaffold.Spec{}, templateNewErr(err) + } + spec.Kind = scaffold.Kind(kind) + + // Engine specifics. + if spec.Kind == scaffold.KindEngine { + var exportsCSV, portStr string + eng := huh.NewForm( + huh.NewGroup( + huh.NewInput().Title("Provides (capability)"). + Description("e.g. postgres, redis").Value(&spec.Provides), + huh.NewInput().Title("Exports (comma-separated)"). + Description("importable attrs: host,port,user,password").Value(&exportsCSV), + huh.NewInput().Title("Default port").Value(&portStr). + Validate(optionalInt), + ), + ).WithTheme(prompt.Theme()) + if err := eng.Run(); err != nil { + return scaffold.Spec{}, templateNewErr(err) + } + spec.Exports = splitCSV(exportsCSV) + spec.DefaultPort = atoiOrZero(portStr) + } else { + addEntry := false + ef := huh.NewForm( + huh.NewGroup( + huh.NewConfirm().Title("Scaffold build/entrypoint.sh?").Value(&addEntry), + ), + ).WithTheme(prompt.Theme()) + if err := ef.Run(); err != nil { + return scaffold.Spec{}, templateNewErr(err) + } + spec.Entrypoint = addEntry + } + + // Params loop: keep offering "add a parameter" until the author declines. + for { + more := false + ask := huh.NewForm( + huh.NewGroup(huh.NewConfirm().Title("Add a parameter?").Value(&more)), + ).WithTheme(prompt.Theme()) + if err := ask.Run(); err != nil { + return scaffold.Spec{}, templateNewErr(err) + } + if !more { + break + } + p, err := askParam() + if err != nil { + return scaffold.Spec{}, err + } + spec.Params = append(spec.Params, p) + } + + // Build + preview through the production path, then confirm. + preview := "(preview unavailable)" + if bundle, err := scaffold.Build(spec); err == nil { + if compose, _, perr := previewBundle(spec.Name, bundle); perr == nil { + preview = string(bundle["template.yaml"]) + "\n--- compose ---\n" + string(compose) + } else { + preview = string(bundle["template.yaml"]) + "\n--- preview error ---\n" + perr.Error() + } + } else { + preview = "build error: " + err.Error() + } + + confirm := true + cf := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Write this template?"). + Description(prompt.PreviewBox(preview)). + Value(&confirm), + ), + ).WithTheme(prompt.Theme()) + if err := cf.Run(); err != nil { + return scaffold.Spec{}, templateNewErr(err) + } + if !confirm { + return scaffold.Spec{}, errTemplateNewCancelled + } + return spec, nil +} + +// askParam runs a single param-entry sub-form. +func askParam() (scaffold.Param, error) { + p := scaffold.Param{Type: "string"} + f := huh.NewForm( + huh.NewGroup( + huh.NewInput().Title("Param name").Value(&p.Name).Validate(huh.ValidateNotEmpty()), + huh.NewSelect[string]().Title("Type"). + Options( + huh.NewOption("string", "string"), + huh.NewOption("int", "int"), + huh.NewOption("bool", "bool"), + ).Value(&p.Type), + huh.NewInput().Title("Default (optional)").Value(&p.Default), + huh.NewConfirm().Title("Required?").Value(&p.Required), + ).Title("Parameter"), + ).WithTheme(prompt.Theme()) + if err := f.Run(); err != nil { + return scaffold.Param{}, templateNewErr(err) + } + return p, nil +} + +// extendsOptions builds the extends-parent picker, "(none)" first. +func extendsOptions(src template.TemplateSource) []huh.Option[string] { + opts := []huh.Option[string]{huh.NewOption("(none)", "")} + names := append([]string(nil), src.List()...) + sort.Strings(names) + for _, n := range names { + label := n + if d, err := template.Describe(src, n); err == nil { + if d.Provides != "" { + label = fmt.Sprintf("%s — provides %s", n, d.Provides) + } else if d.Description != "" { + label = fmt.Sprintf("%s — %s", n, d.Description) + } + } + opts = append(opts, huh.NewOption(label, n)) + } + return opts +} + +// templateNewErr maps a huh user-abort (ctrl+c / esc) to errTemplateNewCancelled. +func templateNewErr(err error) error { + if errors.Is(err, huh.ErrUserAborted) { + return errTemplateNewCancelled + } + return err +} diff --git a/internal/template/engine.go b/internal/template/engine.go index 5729b6d..af6d0aa 100644 --- a/internal/template/engine.go +++ b/internal/template/engine.go @@ -52,6 +52,21 @@ func RenderText(name string, src []byte, data any) ([]byte, error) { return buf.Bytes(), nil } +// ParseCheck parses src as a template with the production delimiters + FuncMap but +// does NOT execute it, returning any parse error. The authoring lints (spec 23) use +// it to detect delimiter collisions: a literal "[[" or "]]" in a build/ file that is +// not a valid template action makes Parse fail. +func ParseCheck(name string, src []byte) error { + if _, err := template.New(name). + Delims(LeftDelim, RightDelim). + Funcs(funcMap()). + Option("missingkey=error"). + Parse(string(src)); err != nil { + return fmt.Errorf("template %s: parse: %w", name, err) + } + return nil +} + // RenderYAML renders src as a text template (so params are substituted) and then // decodes the result into a map[string]any. It is for template *fragments* only. // An empty render decodes to an empty (non-nil) map. diff --git a/internal/template/scaffold/build.go b/internal/template/scaffold/build.go new file mode 100644 index 0000000..cb3ded9 --- /dev/null +++ b/internal/template/scaffold/build.go @@ -0,0 +1,302 @@ +package scaffold + +import ( + "fmt" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/goccy/go-yaml" +) + +// Build turns a Spec into a deterministic Bundle. It is pure: the same Spec always +// yields byte-identical files — there is no clock/uuid/random anywhere (the engine +// FuncMap omits them by design). App-vs-engine is a compile-time branch, so an +// engine can never acquire a build/ tree and an app can never emit provides:. +// +// Build does NOT emit the golden fixture (golden.yaml) — that is the rendered +// compose, produced by the caller through the real template.Resolve + +// generate.LintResolved path and added to the Bundle when Spec.Golden is set. +func Build(s Spec) (Bundle, error) { + if err := s.Validate(); err != nil { + return nil, err + } + b := Bundle{} + switch s.Kind { + case KindApp: + b["template.yaml"] = appManifest(s) + b["build/Dockerfile"] = appDockerfile(s) + if s.Entrypoint { + b["build/entrypoint.sh"] = entrypointScript() + } + for _, name := range s.ExtraBuild { + b["build/"+path.Clean(name)] = extraBuildFile(name) + } + case KindEngine: + b["template.yaml"] = engineManifest(s) + } + return b, nil +} + +// Validate enforces the app/engine split structurally so the wizard and the flag +// path cannot author an inconsistent bundle. +func (s Spec) Validate() error { + switch s.Kind { + case KindApp: + if s.BaseImage == "" { + return fmt.Errorf("app template %q: --base-image is required (it becomes the Dockerfile FROM)", s.Name) + } + if s.Provides != "" || len(s.Exports) > 0 { + return fmt.Errorf("app template %q: provides/exports are engine-only (an app is buildable, not a shared service)", s.Name) + } + for _, name := range s.ExtraBuild { + if !safeRel(name) { + return fmt.Errorf("app template %q: invalid extra build file %q (must be a single relative path, no slash/..)", s.Name, name) + } + } + case KindEngine: + if s.BaseImage == "" { + return fmt.Errorf("engine template %q: --base-image is required (it becomes image:)", s.Name) + } + if s.Entrypoint || len(s.ExtraBuild) > 0 { + return fmt.Errorf("engine template %q: a build/ tree is app-only (shared services are image-based)", s.Name) + } + default: + return fmt.Errorf("template %q: kind must be %q or %q", s.Name, KindApp, KindEngine) + } + for _, p := range s.Params { + if p.Name == "" { + return fmt.Errorf("template %q: a param has an empty name", s.Name) + } + } + return nil +} + +// appManifest emits an app template.yaml: meta as literal YAML, a service.build +// block, and one build arg per STRING-typed param (the version-style params that +// Docker build args naturally carry). Int/bool params stay declared but are not +// wired as build args (they are runtime config). Actions appear ONLY under +// service: — never in a meta key. +func appManifest(s Spec) []byte { + doc := metaHead(s) + doc = appendParams(doc, s.Params) + + build := yaml.MapSlice{ + {Key: "context", Value: "build"}, + {Key: "dockerfile", Value: "Dockerfile"}, + } + if args := buildArgs(s.Params); len(args) > 0 { + build = append(build, yaml.MapItem{Key: "args", Value: args}) + } + service := yaml.MapSlice{ + {Key: "build", Value: build}, + {Key: "restart", Value: "unless-stopped"}, + } + doc = append(doc, yaml.MapItem{Key: "service", Value: service}) + return marshalDoc(doc) +} + +// engineManifest emits an image-based shared-engine template.yaml: image: + +// provides/exports/defaultPort + an optional top-level volumes: block. No build/ +// tree is ever produced. +func engineManifest(s Spec) []byte { + doc := metaHead(s) + if s.Provides != "" { + doc = append(doc, yaml.MapItem{Key: "provides", Value: s.Provides}) + } + if len(s.Exports) > 0 { + doc = append(doc, yaml.MapItem{Key: "exports", Value: append([]string(nil), s.Exports...)}) + } + if s.DefaultPort > 0 { + doc = append(doc, yaml.MapItem{Key: "defaultPort", Value: s.DefaultPort}) + } + doc = appendParams(doc, s.Params) + + service := yaml.MapSlice{ + {Key: "image", Value: s.BaseImage}, + {Key: "restart", Value: "unless-stopped"}, + } + if len(s.Volumes) > 0 { + vols := append([]string(nil), s.Volumes...) + sort.Strings(vols) + mounts := make([]string, 0, len(vols)) + for _, v := range vols { + mounts = append(mounts, v+":/var/lib/"+v) + } + service = append(service, yaml.MapItem{Key: "volumes", Value: mounts}) + } + doc = append(doc, yaml.MapItem{Key: "service", Value: service}) + + if len(s.Volumes) > 0 { + vols := append([]string(nil), s.Volumes...) + sort.Strings(vols) + top := yaml.MapSlice{} + for _, v := range vols { + top = append(top, yaml.MapItem{Key: v, Value: yaml.MapSlice{}}) + } + doc = append(doc, yaml.MapItem{Key: "volumes", Value: top}) + } + return marshalDoc(doc) +} + +// metaHead emits the leading meta keys common to both kinds: schemaVersion, then +// optional extends/description. All literal YAML — never templated. +func metaHead(s Spec) yaml.MapSlice { + doc := yaml.MapSlice{{Key: "schemaVersion", Value: 1}} + if s.Extends != "" { + doc = append(doc, yaml.MapItem{Key: "extends", Value: s.Extends}) + } + if s.Description != "" { + doc = append(doc, yaml.MapItem{Key: "description", Value: s.Description}) + } + return doc +} + +// appendParams appends a sorted params: block. Each entry carries its declared +// type/default/description/required as literal scalars. +func appendParams(doc yaml.MapSlice, params []Param) yaml.MapSlice { + if len(params) == 0 { + return doc + } + byName := map[string]Param{} + names := make([]string, 0, len(params)) + for _, p := range params { + byName[p.Name] = p + names = append(names, p.Name) + } + sort.Strings(names) + block := yaml.MapSlice{} + for _, n := range names { + p := byName[n] + entry := yaml.MapSlice{} + if p.Type != "" { + entry = append(entry, yaml.MapItem{Key: "type", Value: p.Type}) + } + if p.Default != "" { + entry = append(entry, yaml.MapItem{Key: "default", Value: p.Default}) + } + if p.Description != "" { + entry = append(entry, yaml.MapItem{Key: "description", Value: p.Description}) + } + if p.Required { + entry = append(entry, yaml.MapItem{Key: "required", Value: true}) + } + block = append(block, yaml.MapItem{Key: n, Value: entry}) + } + return append(doc, yaml.MapItem{Key: "params", Value: block}) +} + +// buildArgs maps each string-typed param to a build arg (UPPER_SNAKE name → +// "[[ .params. ]]"), sorted by arg name for deterministic output. +func buildArgs(params []Param) yaml.MapSlice { + type kv struct{ arg, param string } + var pairs []kv + for _, p := range params { + if p.Type != "" && p.Type != "string" { + continue + } + pairs = append(pairs, kv{arg: upperSnake(p.Name), param: p.Name}) + } + sort.Slice(pairs, func(i, j int) bool { return pairs[i].arg < pairs[j].arg }) + out := yaml.MapSlice{} + for _, kv := range pairs { + out = append(out, yaml.MapItem{Key: kv.arg, Value: "[[ .params." + kv.param + " ]]"}) + } + return out +} + +// appDockerfile seeds a build/Dockerfile: a `# syntax` header, one `ARG NAME= +// [[ .params.x ]]` line per string param (the only template actions), a literal +// FROM, and an optional entrypoint wiring. Author $-syntax stays literal. +func appDockerfile(s Spec) []byte { + var b strings.Builder + b.WriteString("# syntax=docker/dockerfile:1\n") + b.WriteString("# Template actions use double-square-bracket delimiters; $-syntax stays literal.\n") + args := buildArgs(s.Params) + for _, a := range args { + b.WriteString(fmt.Sprintf("ARG %s=%s\n", a.Key, a.Value)) + } + b.WriteString("FROM " + s.BaseImage + "\n") + b.WriteString("WORKDIR /app\n") + if s.Entrypoint { + b.WriteString("COPY build/entrypoint.sh /entrypoint.sh\n") + b.WriteString("RUN chmod +x /entrypoint.sh\n") + b.WriteString("ENTRYPOINT [\"/entrypoint.sh\"]\n") + } + return []byte(b.String()) +} + +// entrypointScript seeds a build/entrypoint.sh. Author shell stays literal — +// ${PORT:-3000} is NOT a template action (spec 02 delimiter non-collision). +func entrypointScript() []byte { + return []byte("#!/bin/sh\n" + + "# Authored by `devstack template new`. Author shell stays literal —\n" + + "# ${PORT:-3000} is NOT a template action.\n" + + "set -e\n" + + "\n" + + "exec \"$@\"\n") +} + +// extraBuildFile seeds an arbitrary build/ config file with a placeholder. +func extraBuildFile(name string) []byte { + return []byte("# " + path.Base(name) + " — authored by `devstack template new`; edit freely.\n") +} + +// marshalDoc renders an ordered MapSlice to YAML (never a Go map: goccy randomizes +// map order and renders 16 as 16.0). Output is normalized to a single trailing LF. +func marshalDoc(doc yaml.MapSlice) []byte { + out, err := yaml.Marshal(doc) + if err != nil { + // MapSlice of scalars/strings cannot fail to marshal; treat as programmer error. + panic(fmt.Sprintf("scaffold: marshal template.yaml: %v", err)) + } + return out +} + +// upperSnake converts a camelCase/dotted/dashed param name to UPPER_SNAKE_CASE +// (bunVersion → BUN_VERSION, php.version → PHP_VERSION). +func upperSnake(s string) string { + var b strings.Builder + prevLower := false + for i, r := range s { + switch { + case r == '.' || r == '-' || r == '_' || r == ' ': + b.WriteByte('_') + prevLower = false + case r >= 'A' && r <= 'Z': + if prevLower && i > 0 { + b.WriteByte('_') + } + b.WriteRune(r) + prevLower = false + case r >= 'a' && r <= 'z': + b.WriteRune(r - 32) + prevLower = true + default: + b.WriteRune(r) + prevLower = (r >= '0' && r <= '9') + } + } + return collapseUnderscore(b.String()) +} + +func collapseUnderscore(s string) string { + for strings.Contains(s, "__") { + s = strings.ReplaceAll(s, "__", "_") + } + return strings.Trim(s, "_") +} + +// safeRel reports whether name is a single safe relative build-file path (no +// absolute, no "..", cleans to itself). +func safeRel(name string) bool { + if name == "" || filepath.IsAbs(name) { + return false + } + clean := path.Clean(name) + if clean != name || clean == "." || strings.HasPrefix(clean, "..") { + return false + } + return !strings.Contains(clean, "../") +} diff --git a/internal/template/scaffold/build_test.go b/internal/template/scaffold/build_test.go new file mode 100644 index 0000000..564c855 --- /dev/null +++ b/internal/template/scaffold/build_test.go @@ -0,0 +1,172 @@ +package scaffold + +import ( + "bytes" + "strings" + "testing" +) + +func appSpec() Spec { + return Spec{ + Kind: KindApp, + Name: "node.bun", + Description: "Bun dev server", + BaseImage: "oven/bun:1", + Params: []Param{ + {Name: "bunVersion", Type: "string", Default: "1"}, + {Name: "port", Type: "int", Default: "5173"}, + }, + Entrypoint: true, + Golden: true, + } +} + +func engineSpec() Spec { + return Spec{ + Kind: KindEngine, + Name: "mariadb", + Description: "MariaDB engine", + BaseImage: "mariadb:11", + Provides: "mariadb", + Exports: []string{"host", "port", "user"}, + DefaultPort: 3306, + Params: []Param{{Name: "version", Type: "string", Default: "11"}}, + Volumes: []string{"data"}, + } +} + +// TestBuildDeterministic asserts the same Spec yields byte-identical bytes (the +// spec-23 acceptance gate: no clock/uuid/random anywhere). +func TestBuildDeterministic(t *testing.T) { + for _, s := range []Spec{appSpec(), engineSpec()} { + a, err := Build(s) + if err != nil { + t.Fatalf("Build: %v", err) + } + b, err := Build(s) + if err != nil { + t.Fatalf("Build (2nd): %v", err) + } + if len(a) != len(b) { + t.Fatalf("%s: file-count differs: %d vs %d", s.Name, len(a), len(b)) + } + for k, av := range a { + if !bytes.Equal(av, b[k]) { + t.Errorf("%s: file %q not byte-stable", s.Name, k) + } + } + } +} + +// TestBuildAppBranch asserts an app emits build/Dockerfile + service.build and NO +// provides:; build args come only from string params. +func TestBuildAppBranch(t *testing.T) { + b, err := Build(appSpec()) + if err != nil { + t.Fatalf("Build: %v", err) + } + mustHave := []string{"template.yaml", "build/Dockerfile", "build/entrypoint.sh"} + for _, f := range mustHave { + if _, ok := b[f]; !ok { + t.Errorf("app bundle missing %q", f) + } + } + manifest := string(b["template.yaml"]) + if strings.Contains(manifest, "provides:") { + t.Error("app template.yaml must not contain provides:") + } + if !strings.Contains(manifest, "build:") { + t.Error("app template.yaml must contain service.build") + } + // Only the string param becomes a build arg; the int param does not. + if !strings.Contains(manifest, "BUN_VERSION:") { + t.Error("expected BUN_VERSION build arg from the string param") + } + if strings.Contains(manifest, "PORT:") { + t.Error("int param must NOT become a build arg") + } + df := string(b["build/Dockerfile"]) + if !strings.Contains(df, "FROM oven/bun:1") { + t.Error("Dockerfile must FROM the base image") + } + if !strings.Contains(df, "[[ .params.bunVersion ]]") { + t.Error("Dockerfile must wire the string param via a [[ ]] action") + } +} + +// TestBuildEngineBranch asserts an engine emits image: + provides + volumes and NO +// build/ tree (generate.buildSharedService rejects build: on a shared service). +func TestBuildEngineBranch(t *testing.T) { + b, err := Build(engineSpec()) + if err != nil { + t.Fatalf("Build: %v", err) + } + for f := range b { + if strings.HasPrefix(f, "build/") { + t.Errorf("engine bundle must have no build/ tree, found %q", f) + } + } + manifest := string(b["template.yaml"]) + for _, want := range []string{"image: mariadb:11", "provides: mariadb", "defaultPort: 3306", "volumes:"} { + if !strings.Contains(manifest, want) { + t.Errorf("engine template.yaml missing %q\n%s", want, manifest) + } + } + if strings.Contains(manifest, "build:") { + t.Error("engine template.yaml must not contain a build: key") + } +} + +// TestBuildMetaIsLiteral asserts no meta key carries a [[ ]] action — actions live +// only under service:/build. +func TestBuildMetaIsLiteral(t *testing.T) { + b, _ := Build(appSpec()) + manifest := string(b["template.yaml"]) + idx := strings.Index(manifest, "service:") + if idx < 0 { + t.Fatal("no service: block") + } + meta := manifest[:idx] + if strings.Contains(meta, "[[") || strings.Contains(meta, "]]") { + t.Errorf("meta section must contain no template actions:\n%s", meta) + } +} + +// TestBuildValidate covers the compile-time app/engine guards. +func TestBuildValidate(t *testing.T) { + cases := []struct { + name string + spec Spec + want string + }{ + {"app needs base image", Spec{Kind: KindApp, Name: "a"}, "base-image"}, + {"app rejects provides", Spec{Kind: KindApp, Name: "a", BaseImage: "x", Provides: "p"}, "engine-only"}, + {"engine needs base image", Spec{Kind: KindEngine, Name: "e"}, "base-image"}, + {"engine rejects build tree", Spec{Kind: KindEngine, Name: "e", BaseImage: "x", Entrypoint: true}, "app-only"}, + {"unknown kind", Spec{Kind: "weird", Name: "z"}, "kind must be"}, + {"bad extra build", Spec{Kind: KindApp, Name: "a", BaseImage: "x", ExtraBuild: []string{"../escape"}}, "invalid extra build"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := Build(tc.spec) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("want error containing %q, got %v", tc.want, err) + } + }) + } +} + +func TestUpperSnake(t *testing.T) { + cases := map[string]string{ + "bunVersion": "BUN_VERSION", + "phpVersion": "PHP_VERSION", + "php.version": "PHP_VERSION", + "node-major": "NODE_MAJOR", + "already_ok": "ALREADY_OK", + } + for in, want := range cases { + if got := upperSnake(in); got != want { + t.Errorf("upperSnake(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/template/scaffold/lint.go b/internal/template/scaffold/lint.go new file mode 100644 index 0000000..5c0cb6c --- /dev/null +++ b/internal/template/scaffold/lint.go @@ -0,0 +1,162 @@ +package scaffold + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/goccy/go-yaml" + + "github.com/open-source-cloud/devstack/internal/template" +) + +// LintResult carries the soft findings of the authoring lints. Hard findings are +// returned as an error from Lint. +type LintResult struct { + Warnings []string +} + +// Lint runs the three authoring lints (spec 23) over a template's raw manifest + +// rendered/seeded build files. These are the NET-NEW checks that the compose-go +// pass (generate.LintResolved) does not perform, shared by BOTH `template new`'s +// live preview and `template lint`: +// +// - meta-templating (HARD error): a `[[ ]]` action in a meta key. parseMeta reads +// meta keys UNrendered, so a stray action there is silent garbage. +// - delimiter-collision (warn): a literal `[[`/`]]` in a build/ file that is not a +// valid template action (it fails to parse with the production delimiters). +// - param-type (warn): a param default that does not parse as its declared type. +// +// buildFiles is keyed by the file's display path (e.g. "build/Dockerfile"). +func Lint(manifest []byte, buildFiles map[string][]byte) (LintResult, error) { + var res LintResult + + // 1. meta-templating — HARD error. Walk the un-rendered manifest; any meta key + // (everything except service:/volumes:) whose scalar carries a `[[ ]]` action is + // rejected, because parseMeta never renders it. + if err := lintMetaTemplating(manifest); err != nil { + return res, err + } + + // 2. param-type — warn on a default that does not parse as its declared type. + res.Warnings = append(res.Warnings, lintParamTypes(manifest)...) + + // 3. delimiter-collision — warn on a build/ file that fails to parse (a literal + // `[[`/`]]` that is not a valid action). + names := make([]string, 0, len(buildFiles)) + for n := range buildFiles { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + if err := template.ParseCheck(n, buildFiles[n]); err != nil { + res.Warnings = append(res.Warnings, + fmt.Sprintf("delimiter-collision: %s contains a literal %q/%q that is not a valid template action (%v)", + n, template.LeftDelim, template.RightDelim, err)) + } + } + sort.Strings(res.Warnings) + return res, nil +} + +// metaParams is a minimal projection of the manifest for the param-type check. +type metaParams struct { + Params map[string]struct { + Type string `yaml:"type"` + Default any `yaml:"default"` + } `yaml:"params"` +} + +func lintParamTypes(manifest []byte) []string { + var mp metaParams + if err := yaml.Unmarshal(manifest, &mp); err != nil { + return nil // a malformed manifest is caught elsewhere (Resolve/compose-go) + } + var warnings []string + for _, name := range sortedKeys(mp.Params) { + p := mp.Params[name] + if p.Default == nil { + continue + } + def := fmt.Sprint(p.Default) + switch p.Type { + case "int": + if _, err := strconv.Atoi(def); err != nil { + warnings = append(warnings, fmt.Sprintf("param-type: param %q default %q does not parse as int", name, def)) + } + case "bool": + if _, err := strconv.ParseBool(def); err != nil { + warnings = append(warnings, fmt.Sprintf("param-type: param %q default %q does not parse as bool", name, def)) + } + } + } + return warnings +} + +// lintMetaTemplating rejects a `[[ ]]` action anywhere under a meta key. +func lintMetaTemplating(manifest []byte) error { + var doc map[string]any + if err := yaml.Unmarshal(manifest, &doc); err != nil { + return nil // malformed YAML is surfaced by Resolve/compose-go with positions + } + for _, key := range sortedKeys(doc) { + if key == "service" || key == "volumes" { + continue // the only blocks that may carry actions + } + if path := findAction(doc[key]); path != "" { + return fmt.Errorf("meta-templating: meta key %q contains a template action %q…%q (%s) — meta keys are read un-rendered; actions are only valid under service:/volumes:/build/", + key, template.LeftDelim, template.RightDelim, joinPath(key, path)) + } + } + return nil +} + +// findAction returns a sub-path (relative) to the first scalar carrying a template +// action under v, or "" if none. +func findAction(v any) string { + switch t := v.(type) { + case string: + if hasAction(t) { + return "." + } + case map[string]any: + for _, k := range sortedKeys(t) { + if sub := findAction(t[k]); sub != "" { + return joinPath(k, sub) + } + } + case []any: + for i, e := range t { + if sub := findAction(e); sub != "" { + return joinPath(fmt.Sprintf("[%d]", i), sub) + } + } + } + return "" +} + +// hasAction reports whether s contains a `[[ … ]]` action. +func hasAction(s string) bool { + i := strings.Index(s, template.LeftDelim) + return i >= 0 && strings.Contains(s[i+len(template.LeftDelim):], template.RightDelim) +} + +func joinPath(head, tail string) string { + if tail == "." { + return head + } + if strings.HasPrefix(tail, "[") { + return head + tail + } + return head + "." + tail +} + +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/internal/template/scaffold/lint_test.go b/internal/template/scaffold/lint_test.go new file mode 100644 index 0000000..cd0e58c --- /dev/null +++ b/internal/template/scaffold/lint_test.go @@ -0,0 +1,86 @@ +package scaffold + +import ( + "strings" + "testing" +) + +// TestLintMetaTemplatingHardError asserts a [[ ]] action in a meta key is a hard +// error (parseMeta reads meta keys un-rendered). +func TestLintMetaTemplatingHardError(t *testing.T) { + manifest := []byte("schemaVersion: 1\n" + + "description: \"uses [[ .params.x ]] in meta\"\n" + + "service:\n image: x\n") + _, err := Lint(manifest, nil) + if err == nil || !strings.Contains(err.Error(), "meta-templating") { + t.Fatalf("want meta-templating hard error, got %v", err) + } +} + +// TestLintMetaActionInParamDefault asserts an action in a param default (a meta +// key) is also a hard error. +func TestLintMetaActionInParamDefault(t *testing.T) { + manifest := []byte("schemaVersion: 1\nparams:\n v:\n default: \"[[ .params.v ]]\"\nservice:\n image: x\n") + if _, err := Lint(manifest, nil); err == nil { + t.Fatal("want hard error for action in a param default") + } +} + +// TestLintAllowsActionsUnderService asserts actions under service:/volumes: are NOT +// flagged. +func TestLintAllowsActionsUnderService(t *testing.T) { + manifest := []byte("schemaVersion: 1\nservice:\n image: \"redis:[[ .params.v ]]\"\nvolumes:\n d:\n name: \"[[ .params.v ]]\"\n") + res, err := Lint(manifest, nil) + if err != nil { + t.Fatalf("actions under service:/volumes: must be allowed, got %v", err) + } + if len(res.Warnings) != 0 { + t.Errorf("unexpected warnings: %v", res.Warnings) + } +} + +// TestLintParamTypeWarn asserts a default that does not parse as its declared type +// warns (not a hard error). +func TestLintParamTypeWarn(t *testing.T) { + manifest := []byte("schemaVersion: 1\nparams:\n count:\n type: int\n default: \"abc\"\n flag:\n type: bool\n default: \"nope\"\nservice:\n image: x\n") + res, err := Lint(manifest, nil) + if err != nil { + t.Fatalf("param-type is a warning, not an error: %v", err) + } + joined := strings.Join(res.Warnings, "\n") + if !strings.Contains(joined, "param-type") || !strings.Contains(joined, "count") || !strings.Contains(joined, "flag") { + t.Errorf("expected param-type warnings for count+flag, got %v", res.Warnings) + } +} + +// TestLintDelimiterCollisionWarn asserts a build/ file with a literal [[ that is not +// a valid action warns. +func TestLintDelimiterCollisionWarn(t *testing.T) { + manifest := []byte("schemaVersion: 1\nservice:\n image: x\n") + build := map[string][]byte{ + "build/entrypoint.sh": []byte("#!/bin/sh\nif [[ -z \"$X\" ]]; then echo no; fi\n"), + } + res, err := Lint(manifest, build) + if err != nil { + t.Fatalf("delimiter-collision is a warning: %v", err) + } + if len(res.Warnings) == 0 || !strings.Contains(res.Warnings[0], "delimiter-collision") { + t.Errorf("expected a delimiter-collision warning, got %v", res.Warnings) + } +} + +// TestLintCleanBundle asserts a well-formed bundle produces no warnings/errors — +// including a valid [[ ]] action in a build file. +func TestLintCleanBundle(t *testing.T) { + b, err := Build(appSpec()) + if err != nil { + t.Fatalf("Build: %v", err) + } + res, err := Lint(b["template.yaml"], BuildFilesOf(b)) + if err != nil { + t.Fatalf("clean bundle must not hard-error: %v", err) + } + if len(res.Warnings) != 0 { + t.Errorf("clean bundle must produce no warnings, got %v", res.Warnings) + } +} diff --git a/internal/template/scaffold/spec.go b/internal/template/scaffold/spec.go new file mode 100644 index 0000000..96fe625 --- /dev/null +++ b/internal/template/scaffold/spec.go @@ -0,0 +1,63 @@ +// Package scaffold is the deterministic builder behind `devstack template new` +// (spec 23): it turns a typed authoring Spec into an on-disk template bundle +// (template.yaml + an optional build/ tree + an optional golden fixture). It is the +// shared, UI-agnostic core — both the flag path and the Bubble Tea wizard populate +// a Spec, call Build, then WriteBundle. +// +// It is strictly additive over internal/template: it produces nothing the engine +// cannot already consume, introduces no new template feature, and emits byte-stable +// bytes for a given Spec (sorted keys, ordered goccy MapSlice — never marshal a Go +// map). It takes no lock, touches no ledger, starts no Docker, and resolves no +// secret:// ref. NOTE: this is distinct from internal/scaffold (the workspace +// builder behind `devstack init`). +package scaffold + +// Kind is the template shape. An app is buildable (service.build + a build/ +// Dockerfile, never provides:); an engine is image-based (image: + provides/ +// exports/defaultPort/volumes, never a build/ tree — generate.buildSharedService +// rejects build: on a shared service). App-vs-engine is a compile-time branch in +// Build, so an engine can never structurally acquire a build context. +type Kind string + +const ( + KindApp Kind = "app" + KindEngine Kind = "engine" +) + +// Param is one typed, defaulted parameter declaration (mirrors +// template.ParamSpec). Type is advisory in v1 (string|int|bool). +type Param struct { + Name string `yaml:"name"` + Type string `yaml:"type,omitempty"` + Default string `yaml:"default,omitempty"` + Description string `yaml:"description,omitempty"` + Required bool `yaml:"required,omitempty"` +} + +// Spec is the complete authoring input for one template. It contains no maps, so +// yaml.Marshal of a Spec is deterministic (struct field order) — that is what makes +// the `--print-spec` ⇒ `--from` round-trip byte-stable. +type Spec struct { + Kind Kind `yaml:"kind"` + Name string `yaml:"name"` + Extends string `yaml:"extends,omitempty"` + Description string `yaml:"description,omitempty"` + BaseImage string `yaml:"baseImage,omitempty"` + Params []Param `yaml:"params,omitempty"` + + // engine-only: + Provides string `yaml:"provides,omitempty"` + Exports []string `yaml:"exports,omitempty"` + DefaultPort int `yaml:"defaultPort,omitempty"` + Volumes []string `yaml:"volumes,omitempty"` + + // app-only: + Entrypoint bool `yaml:"entrypoint,omitempty"` + ExtraBuild []string `yaml:"extraBuild,omitempty"` + + Golden bool `yaml:"golden,omitempty"` +} + +// Bundle is a relpath ("template.yaml", "build/Dockerfile", …) → bytes map, emitted +// byte-stably. +type Bundle map[string][]byte diff --git a/internal/template/scaffold/write.go b/internal/template/scaffold/write.go new file mode 100644 index 0000000..91c0966 --- /dev/null +++ b/internal/template/scaffold/write.go @@ -0,0 +1,120 @@ +package scaffold + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing/fstest" + "time" + + "github.com/open-source-cloud/devstack/internal/template" +) + +// WriteBundle writes b under parentDir// atomically (same-dir temp + Sync + +// Chmod 0644 + Rename), creating sub-directories as needed. It refuses to clobber an +// existing template dir unless force, which first backs the prior dir up to +// .bak./. It returns the written file paths, sorted. +func WriteBundle(parentDir, name string, b Bundle, force bool) ([]string, error) { + target := filepath.Join(parentDir, name) + if _, err := os.Stat(target); err == nil { + if !force { + return nil, fmt.Errorf("%s already exists; pass --force to overwrite (the original is backed up)", target) + } + backup := fmt.Sprintf("%s.bak.%d", target, time.Now().Unix()) + if err := os.Rename(target, backup); err != nil { + return nil, fmt.Errorf("back up %s: %w", target, err) + } + } + var written []string + for _, rel := range bundlePaths(b) { + dst := filepath.Join(target, filepath.FromSlash(rel)) + if err := writeFileAtomic(dst, b[rel]); err != nil { + return written, err + } + written = append(written, dst) + } + sort.Strings(written) + return written, nil +} + +// bundlePaths returns the bundle's relpaths sorted (deterministic write order). +func bundlePaths(b Bundle) []string { + out := make([]string, 0, len(b)) + for p := range b { + out = append(out, p) + } + sort.Strings(out) + return out +} + +// WriteFile writes data to path atomically (same-dir temp + Sync + Chmod 0644 + +// Rename), creating the parent dir as needed. Used by `template new --regold`. +func WriteFile(path string, data []byte) error { return writeFileAtomic(path, data) } + +// writeFileAtomic writes data to path via a same-dir temp file + Sync + Chmod 0644 +// + Rename — the writeIfChanged crash-safety pattern (a kill leaves the old file or +// the new file, never a half-written one). +func writeFileAtomic(path string, data []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create %s: %w", dir, err) + } + tmp, err := os.CreateTemp(dir, ".devstack-tmpl-*") + if err != nil { + return fmt.Errorf("create temp in %s: %w", dir, err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write temp: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("sync temp: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp: %w", err) + } + if err := os.Chmod(tmpName, 0o644); err != nil { + return fmt.Errorf("chmod temp: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("rename %s -> %s: %w", tmpName, path, err) + } + return nil +} + +// PreviewSource exposes a Bundle as a TemplateSource so the live preview can run the +// REAL template.Resolve + generate.LintResolved path (no mock renderer). The bundle +// is chained ahead of fallback so an `extends` parent resolves from the built-ins/ +// store, while the authored template wins by name. golden.yaml is excluded (it is +// the render OUTPUT, not template input). +func PreviewSource(name string, b Bundle, fallback template.TemplateSource) template.TemplateSource { + m := fstest.MapFS{} + for rel, data := range b { + if rel == "golden.yaml" { + continue + } + m[name+"/"+rel] = &fstest.MapFile{Data: data} + } + authored := template.NewFSSource(m) + if fallback == nil { + return authored + } + return template.NewChainSource(authored, fallback) +} + +// BuildFilesOf returns the bundle's build/ entries keyed by their relpath — the +// input the delimiter-collision lint consumes. +func BuildFilesOf(b Bundle) map[string][]byte { + out := map[string][]byte{} + for rel, data := range b { + if strings.HasPrefix(rel, "build/") { + out[rel] = data + } + } + return out +} diff --git a/internal/template/scaffold/write_test.go b/internal/template/scaffold/write_test.go new file mode 100644 index 0000000..afa5cd8 --- /dev/null +++ b/internal/template/scaffold/write_test.go @@ -0,0 +1,60 @@ +package scaffold + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWriteBundleAtomicAndLayout(t *testing.T) { + dir := t.TempDir() + b, _ := Build(appSpec()) + written, err := WriteBundle(dir, "node.bun", b, false) + if err != nil { + t.Fatalf("WriteBundle: %v", err) + } + if len(written) != len(b) { + t.Fatalf("wrote %d files, bundle has %d", len(written), len(b)) + } + // Files land under // at mode 0644 with the build/ subtree intact. + df := filepath.Join(dir, "node.bun", "build", "Dockerfile") + info, err := os.Stat(df) + if err != nil { + t.Fatalf("stat Dockerfile: %v", err) + } + if info.Mode().Perm() != 0o644 { + t.Errorf("Dockerfile mode = %v, want 0644", info.Mode().Perm()) + } +} + +func TestWriteBundleNoClobber(t *testing.T) { + dir := t.TempDir() + b, _ := Build(appSpec()) + if _, err := WriteBundle(dir, "node.bun", b, false); err != nil { + t.Fatalf("first write: %v", err) + } + if _, err := WriteBundle(dir, "node.bun", b, false); err == nil { + t.Fatal("second write without --force should refuse to clobber") + } +} + +func TestWriteBundleForceBacksUp(t *testing.T) { + dir := t.TempDir() + b, _ := Build(appSpec()) + if _, err := WriteBundle(dir, "node.bun", b, false); err != nil { + t.Fatalf("first write: %v", err) + } + if _, err := WriteBundle(dir, "node.bun", b, true); err != nil { + t.Fatalf("force write: %v", err) + } + entries, _ := os.ReadDir(dir) + var backups int + for _, e := range entries { + if e.IsDir() && len(e.Name()) > len("node.bun.bak.") && e.Name()[:len("node.bun.bak.")] == "node.bun.bak." { + backups++ + } + } + if backups != 1 { + t.Errorf("expected exactly one backup dir, found %d", backups) + } +} diff --git a/internal/template/source.go b/internal/template/source.go index da95235..05e8987 100644 --- a/internal/template/source.go +++ b/internal/template/source.go @@ -43,6 +43,12 @@ func validRef(ref string) bool { return ref == path.Base(ref) && !filepath.IsAbs(ref) } +// ValidRef reports whether ref is a valid template reference (a single path +// segment — dots allowed, no slash/".."/absolute). Exported for the authoring +// wizard (spec 23), which must validate a new template name with the EXACT rule the +// source layer enforces. +func ValidRef(ref string) bool { return validRef(ref) } + func (s *FSSource) Resolve(ref string) (fs.FS, error) { if !validRef(ref) { return nil, fmt.Errorf("invalid template reference %q", ref)