From 9943f188dc51998eee36764f0b96974789603f7d Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Mon, 29 Jun 2026 19:37:43 -0300 Subject: [PATCH] =?UTF-8?q?feat(migrate):=20X9=20=E2=80=94=20`devstack=20i?= =?UTF-8?q?mport`=20devdock=20=E2=86=92=20two-file=20schema=20(spec=2014)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the spec-14 importer: reads a legacy devdock single-file project.yaml and emits the clean-slate split — workspace.yaml (shared layer) + a per-repo devstack.yaml — plus a lossless-or-loud conversion report. `internal/migrate.Convert` is a TOLERANT converter (the exact devdock dialect isn't pinned, so parse leniently and report rather than guess): services with a `repo` become projects (git shorthand expanded via internal/git); recognized stateful engines (postgres/redis/minio, by template or image) with no repo become `shared:`; per-service params/uses/env carry into each devstack.yaml; `uses` rewrites to `workspace.shared.`; devdock `${svc.var}` interpolation rewrites to the typed `${ref:workspace.shared.svc.var}` grammar. Every field it can't confidently convert (non-shared uses/refs, image-only services, unplaceable services) is recorded in the report — nothing is dropped silently (spec 14 §lossless-or-loud). Output is ordered (yaml.MapSlice) and carries an apiVersion/v1 header pointing at docs/MIGRATION.md. CLI `devstack import [--dry-run] [--out ] [--force]`: no-clobber by default (refuses to overwrite workspace.yaml / a target devstack.yaml), `--force` backs up originals first, `--dry-run` previews + writes nothing, `--json` for scripted use. Replaces the M1 stub. Tested: shared/project split + uses/env-ref rewrite + git expansion + report for an unconvertible ref; every emitted file is valid Project/Workspace YAML; no-services input is loud; CLI dry-run writes nothing, real run writes the split + report, no-clobber refuses without --force and backs up with it. `make ci` + `make determinism` green. Note: the converter follows spec 14's described mapping; if a real devdock file uses different field names, only the tolerant accessors in migrate.go need a tweak, and the conversion report already surfaces anything unmapped. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/cli/import.go | 131 ++++++++++++ internal/cli/import_test.go | 117 +++++++++++ internal/cli/root.go | 1 + internal/cli/stubs.go | 1 - internal/migrate/migrate.go | 343 +++++++++++++++++++++++++++++++ internal/migrate/migrate_test.go | 123 +++++++++++ 6 files changed, 715 insertions(+), 1 deletion(-) create mode 100644 internal/cli/import.go create mode 100644 internal/cli/import_test.go create mode 100644 internal/migrate/migrate.go create mode 100644 internal/migrate/migrate_test.go diff --git a/internal/cli/import.go b/internal/cli/import.go new file mode 100644 index 0000000..5661189 --- /dev/null +++ b/internal/cli/import.go @@ -0,0 +1,131 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/migrate" +) + +// newImportCmd wires `devstack import ` (spec 14 §import): convert a +// legacy devdock single-file project.yaml into a workspace.yaml + per-repo +// devstack.yaml split, plus a lossless-or-loud conversion report. No-clobber by +// default; `--force` backs up originals first; `--dry-run` writes nothing. +func newImportCmd(g *GlobalOpts) *cobra.Command { + var ( + dryRun bool + outDir string + force bool + ) + cmd := &cobra.Command{ + Use: "import ", + Short: "Convert a legacy devdock project.yaml into workspace.yaml + per-repo devstack.yaml", + Long: "import reads an old devdock single-file project.yaml and emits the clean-slate\n" + + "two-file schema — a workspace.yaml (shared layer) plus a devstack.yaml per repo —\n" + + "and a conversion report listing every field it could not convert (nothing is\n" + + "dropped silently). It refuses to overwrite existing files without --force.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + src, err := os.ReadFile(args[0]) + if err != nil { + return err + } + if outDir == "" { + outDir = "." + } + abs, _ := filepath.Abs(outDir) + res, err := migrate.Convert(src, filepath.Base(abs)) + if err != nil { + return err + } + + type target struct { + path string + body []byte + } + targets := []target{{path: filepath.Join(outDir, "workspace.yaml"), body: res.WorkspaceYAML}} + projNames := make([]string, 0, len(res.Projects)) + for name := range res.Projects { + projNames = append(projNames, name) + } + sort.Strings(projNames) + for _, name := range projNames { + targets = append(targets, target{path: filepath.Join(outDir, name, "devstack.yaml"), body: res.Projects[name]}) + } + reportPath := filepath.Join(outDir, "devstack-import-report.txt") + report := migrate.RenderReport(res.Report) + + paths := make([]string, 0, len(targets)+1) + for _, t := range targets { + paths = append(paths, t.path) + } + paths = append(paths, reportPath) + + if dryRun { + if g.JSON { + return writeJSON(cmd, importSummary(paths, res.Report, true)) + } + w := cmd.OutOrStdout() + for _, t := range targets { + fmt.Fprintf(w, "\n--- %s ---\n%s", t.path, t.body) + } + fmt.Fprintf(w, "\n--- %s ---\n%s", reportPath, report) + fmt.Fprintln(w, "\n(dry-run: nothing written)") + return nil + } + + // No-clobber unless --force (then back up the originals first). + for _, t := range targets { + if _, err := os.Stat(t.path); err == nil { + if !force { + return fmt.Errorf("%s already exists; pass --force to overwrite (originals are backed up)", t.path) + } + if err := os.Rename(t.path, fmt.Sprintf("%s.bak.%d", t.path, time.Now().Unix())); err != nil { + return fmt.Errorf("back up %s: %w", t.path, err) + } + } + } + for _, t := range targets { + if err := os.MkdirAll(filepath.Dir(t.path), 0o755); err != nil { + return err + } + if err := os.WriteFile(t.path, t.body, 0o644); err != nil { + return err + } + } + if err := os.WriteFile(reportPath, []byte(report), 0o644); err != nil { + return err + } + + if g.JSON { + return writeJSON(cmd, importSummary(paths, res.Report, false)) + } + w := cmd.OutOrStdout() + for _, p := range paths { + fmt.Fprintf(w, "[ok] wrote %s\n", p) + } + fmt.Fprint(w, "\n"+report) + if len(res.Report) > 0 { + fmt.Fprintln(w, "\nReview the report above and the generated files before committing (see docs/MIGRATION.md).") + } + return nil + }, + } + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print the converted files + report without writing anything") + cmd.Flags().StringVar(&outDir, "out", "", "output directory (default: current directory)") + cmd.Flags().BoolVar(&force, "force", false, "overwrite existing files (backs up originals first)") + return cmd +} + +func importSummary(paths []string, report []migrate.ReportEntry, dry bool) map[string]any { + entries := make([]map[string]string, 0, len(report)) + for _, e := range report { + entries = append(entries, map[string]string{"path": e.Path, "value": e.Value, "reason": e.Reason}) + } + return map[string]any{"files": paths, "report": entries, "dryRun": dry} +} diff --git a/internal/cli/import_test.go b/internal/cli/import_test.go new file mode 100644 index 0000000..da452f6 --- /dev/null +++ b/internal/cli/import_test.go @@ -0,0 +1,117 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const importSampleYAML = `name: shop +services: + postgres: + template: postgres + params: { version: "16" } + api: + template: php.laravel.nginx + repo: shop/api + uses: [postgres] +` + +func writeSample(t *testing.T) (dir, src string) { + t.Helper() + dir = t.TempDir() + src = filepath.Join(dir, "project.yaml") + if err := os.WriteFile(src, []byte(importSampleYAML), 0o644); err != nil { + t.Fatal(err) + } + return dir, src +} + +func TestImportRegistered(t *testing.T) { + root := NewRootCmd(Options{}) + c, _, err := root.Find([]string{"import"}) + if err != nil || c.Name() != "import" || c.RunE == nil { + t.Fatalf("import not registered as a real command: %v", err) + } +} + +func TestImportDryRunWritesNothing(t *testing.T) { + dir, src := writeSample(t) + out := filepath.Join(dir, "ws") + var buf strings.Builder + root := NewRootCmd(Options{}) + root.SetArgs([]string{"import", src, "--out", out, "--dry-run"}) + root.SetOut(&buf) + root.SetErr(&buf) + if err := root.Execute(); err != nil { + t.Fatalf("import --dry-run: %v\n%s", err, buf.String()) + } + if _, err := os.Stat(filepath.Join(out, "workspace.yaml")); !os.IsNotExist(err) { + t.Error("--dry-run must not write workspace.yaml") + } + if !strings.Contains(buf.String(), "workspace.yaml") || !strings.Contains(buf.String(), "shared") { + t.Errorf("dry-run should preview the workspace:\n%s", buf.String()) + } +} + +func TestImportWritesSplitAndReport(t *testing.T) { + dir, src := writeSample(t) + out := filepath.Join(dir, "ws") + var buf strings.Builder + root := NewRootCmd(Options{}) + root.SetArgs([]string{"import", src, "--out", out}) + root.SetOut(&buf) + root.SetErr(&buf) + if err := root.Execute(); err != nil { + t.Fatalf("import: %v\n%s", err, buf.String()) + } + for _, rel := range []string{"workspace.yaml", filepath.Join("api", "devstack.yaml"), "devstack-import-report.txt"} { + if _, err := os.Stat(filepath.Join(out, rel)); err != nil { + t.Errorf("expected %s to be written: %v", rel, err) + } + } + ws, _ := os.ReadFile(filepath.Join(out, "workspace.yaml")) + if !strings.Contains(string(ws), "postgres") { + t.Errorf("workspace missing shared postgres:\n%s", ws) + } + api, _ := os.ReadFile(filepath.Join(out, "api", "devstack.yaml")) + if !strings.Contains(string(api), "workspace.shared.postgres") { + t.Errorf("api uses not rewritten:\n%s", api) + } +} + +func TestImportNoClobberWithoutForce(t *testing.T) { + dir, src := writeSample(t) + out := filepath.Join(dir, "ws") + if err := os.MkdirAll(out, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(out, "workspace.yaml"), []byte("existing: keep\n"), 0o644); err != nil { + t.Fatal(err) + } + root := NewRootCmd(Options{}) + root.SetArgs([]string{"import", src, "--out", out}) + root.SetOut(&strings.Builder{}) + root.SetErr(&strings.Builder{}) + if err := root.Execute(); err == nil { + t.Fatal("import must refuse to overwrite an existing workspace.yaml without --force") + } + // The original is untouched. + if b, _ := os.ReadFile(filepath.Join(out, "workspace.yaml")); !strings.Contains(string(b), "existing: keep") { + t.Error("existing workspace.yaml was modified despite no --force") + } + + // With --force it backs up the original and writes the new one. + root2 := NewRootCmd(Options{}) + root2.SetArgs([]string{"import", src, "--out", out, "--force"}) + root2.SetOut(&strings.Builder{}) + root2.SetErr(&strings.Builder{}) + if err := root2.Execute(); err != nil { + t.Fatalf("import --force: %v", err) + } + matches, _ := filepath.Glob(filepath.Join(out, "workspace.yaml.bak.*")) + if len(matches) == 0 { + t.Error("--force should back up the original workspace.yaml") + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index f60a83e..fcdc603 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -92,6 +92,7 @@ func NewRootCmd(opts Options) *cobra.Command { newWsCmd(g), newWorkspaceCmd(g), newUninstallCmd(g), + newImportCmd(g), newSelfCmd(g), newStoreCmd(g), newAliasCmd(g), diff --git a/internal/cli/stubs.go b/internal/cli/stubs.go index b4360cb..a9eb66d 100644 --- a/internal/cli/stubs.go +++ b/internal/cli/stubs.go @@ -32,6 +32,5 @@ func addStubCommands(root *cobra.Command, _ *GlobalOpts) { root.AddCommand( stub("shell", "Open a shell in a service container", "M2"), stub("logs", "Stream service logs", "M2"), - stub("import", "Import an old devdock project.yaml into workspace.yaml + devstack.yaml", "M1"), ) } diff --git a/internal/migrate/migrate.go b/internal/migrate/migrate.go new file mode 100644 index 0000000..40a3428 --- /dev/null +++ b/internal/migrate/migrate.go @@ -0,0 +1,343 @@ +// Package migrate converts a legacy devdock single-file project.yaml into the +// clean-slate two-file devstack schema: a workspace.yaml (shared layer) plus a +// per-repo devstack.yaml (portable project layer) — spec 14 §import. It is a +// converter PLUS a guide, not byte-compatible: the mapping is intentional +// (devdock global services → workspace shared; devdock per-service +// template/params/env/uses → each repo's devstack.yaml; devdock `${svc.var}` → +// the typed `${ref:workspace.shared.svc.var}` grammar; `!Repo`/repo shorthand → +// the internal/git shorthand superset). +// +// It is LOSSLESS-OR-LOUD (spec 14): parsing is tolerant — every field it cannot +// confidently convert is recorded in a conversion report (path, value, reason) +// rather than dropped silently — so an unknown devdock dialect degrades to a +// reviewable report instead of a wrong-but-plausible config. +package migrate + +import ( + "fmt" + "regexp" + "sort" + "strings" + + "github.com/goccy/go-yaml" + + "github.com/open-source-cloud/devstack/internal/git" +) + +// APIVersion stamped on every emitted file. +const APIVersion = "devstack/v1" + +// knownEngines maps a devdock template/image keyword to a devstack shared engine. +var knownEngines = map[string]string{ + "postgres": "postgres", "postgresql": "postgres", "postgis": "postgres", + "redis": "redis", "valkey": "redis", + "minio": "minio", +} + +// ReportEntry is one unconvertible (or noteworthy) field. +type ReportEntry struct { + Path string + Value string + Reason string +} + +// Result is the converted output: the workspace file, each project's file, and +// the conversion report. +type Result struct { + WorkspaceYAML []byte + Projects map[string][]byte // project name -> devstack.yaml bytes + Report []ReportEntry +} + +var interpRe = regexp.MustCompile(`\$\{([A-Za-z0-9_-]+)\.([A-Za-z0-9_.-]+)\}`) + +// Convert parses a devdock project.yaml and returns the two-file split + report. +// workspaceName defaults the workspace name when the source declares none. +func Convert(src []byte, workspaceName string) (*Result, error) { + var doc map[string]any + if err := yaml.Unmarshal(src, &doc); err != nil { + return nil, fmt.Errorf("parse devdock project.yaml: %w", err) + } + res := &Result{Projects: map[string][]byte{}} + + name := workspaceName + if n := getStr(doc, "name", "project", "workspace"); n != "" { + name = n + } + if name == "" { + name = "imported" + } + + services := getMap(doc["services"]) + if services == nil { + // Tolerant fallback: some dialects put services at the top level. + services = topLevelServices(doc) + if services == nil { + res.note("services", "", "no `services` map found; nothing to convert") + } + } + + // Pass 1: classify every service so uses/env rewrites know the shared set. + type svc struct { + def map[string]any + engine string // "" if not a known engine + repo string // "" if not a project service + } + svcs := map[string]svc{} + sharedSet := map[string]bool{} + for _, sname := range sortedKeys(services) { + def := getMap(services[sname]) + if def == nil { + res.note("services."+sname, fmt.Sprintf("%v", services[sname]), "service is not a mapping; skipped") + continue + } + repo := getStr(def, "repo", "git", "Repo") + engine := classifyEngine(getStr(def, "template"), getStr(def, "image")) + svcs[sname] = svc{def: def, engine: engine, repo: repo} + if repo == "" && engine != "" { + sharedSet[sname] = true + } + } + + // Pass 2: build the workspace shared map + per-project files. + shared := yaml.MapSlice{} + var projectRefs []yaml.MapSlice + for _, sname := range sortedKeys(services) { + s, ok := svcs[sname] + if !ok { + continue + } + switch { + case s.repo != "": + projectRefs = append(projectRefs, projectRef(sname, s.repo)) + res.Projects[sname] = res.buildProjectFile(sname, s.def, sharedSet) + case s.engine != "": + shared = append(shared, yaml.MapItem{Key: sname, Value: buildSharedEntry(s.engine, s.def)}) + default: + // No repo and not a known engine: can't place it confidently. Emit it as + // a standalone project (no git) and flag it for the user to resolve. + res.note("services."+sname, getStr(s.def, "template", "image"), + "no repo and not a recognized engine — emitted as a git-less project; set its `git:` or move it under `shared:`") + projectRefs = append(projectRefs, projectRef(sname, "")) + res.Projects[sname] = res.buildProjectFile(sname, s.def, sharedSet) + } + } + + ws := yaml.MapSlice{ + {Key: "apiVersion", Value: APIVersion}, + {Key: "kind", Value: "Workspace"}, + {Key: "name", Value: name}, + } + if len(shared) > 0 { + ws = append(ws, yaml.MapItem{Key: "shared", Value: shared}) + } + ws = append(ws, yaml.MapItem{Key: "projects", Value: projectRefs}) + + out, err := yaml.Marshal(ws) + if err != nil { + return nil, err + } + res.WorkspaceYAML = append([]byte("# Generated by `devstack import` — review before committing. See docs/MIGRATION.md\n"), out...) + return res, nil +} + +// buildSharedEntry renders one workspace `shared:` value. +func buildSharedEntry(engine string, def map[string]any) yaml.MapSlice { + entry := yaml.MapSlice{{Key: "template", Value: engine}} + if params := getMap(def["params"]); len(params) > 0 { + entry = append(entry, yaml.MapItem{Key: "params", Value: orderedMap(params)}) + } + return entry +} + +// buildProjectFile renders one project's devstack.yaml. +func (r *Result) buildProjectFile(project string, def map[string]any, sharedSet map[string]bool) []byte { + svc := yaml.MapSlice{} + if t := getStr(def, "template"); t != "" { + svc = append(svc, yaml.MapItem{Key: "template", Value: t}) + } else if img := getStr(def, "image"); img != "" { + r.note("services."+project+".image", img, "devdock image had no template; left as a `image:` note — pick a devstack template") + svc = append(svc, yaml.MapItem{Key: "image", Value: img}) + } + if params := getMap(def["params"]); len(params) > 0 { + svc = append(svc, yaml.MapItem{Key: "params", Value: orderedMap(params)}) + } + if uses := r.mapUses(project, def, sharedSet); len(uses) > 0 { + svc = append(svc, yaml.MapItem{Key: "uses", Value: uses}) + } + if env := r.mapEnv(project, def, sharedSet); len(env) > 0 { + svc = append(svc, yaml.MapItem{Key: "env", Value: env}) + } + + doc := yaml.MapSlice{ + {Key: "apiVersion", Value: APIVersion}, + {Key: "kind", Value: "Project"}, + {Key: "name", Value: project}, + {Key: "services", Value: yaml.MapSlice{{Key: project, Value: svc}}}, + } + out, err := yaml.Marshal(doc) + if err != nil { + return []byte("# conversion error: " + err.Error() + "\n") + } + return append([]byte("# Generated by `devstack import` — review before committing. See docs/MIGRATION.md\n"), out...) +} + +// mapUses rewrites devdock `uses`/`depends_on` to workspace.shared. refs. +func (r *Result) mapUses(project string, def map[string]any, sharedSet map[string]bool) []any { + raw := getList(def["uses"]) + if raw == nil { + raw = getList(def["depends_on"]) + } + var out []any + for _, u := range raw { + if sharedSet[u] { + out = append(out, "workspace.shared."+u) + } else { + r.note("services."+project+".uses", u, "target is not a recognized shared service; left as-is — point it at a workspace.shared. or workspace..") + out = append(out, u) + } + } + return out +} + +// mapEnv rewrites devdock `${svc.var}` interpolation to the typed +// `${ref:workspace.shared.svc.var}` grammar for known shared services. +func (r *Result) mapEnv(project string, def map[string]any, sharedSet map[string]bool) yaml.MapSlice { + env := getMap(def["env"]) + if env == nil { + env = getMap(def["environment"]) + } + if len(env) == 0 { + return nil + } + out := yaml.MapSlice{} + for _, k := range sortedKeys(env) { + val := fmt.Sprintf("%v", env[k]) + val = interpRe.ReplaceAllStringFunc(val, func(m string) string { + sub := interpRe.FindStringSubmatch(m) + svc, attr := sub[1], sub[2] + if sharedSet[svc] { + return "${ref:workspace.shared." + svc + "." + attr + "}" + } + r.note("services."+project+".env."+k, m, "interpolation references a non-shared service; left as-is — express it with ${ref:workspace...}") + return m + }) + out = append(out, yaml.MapItem{Key: k, Value: val}) + } + return out +} + +func (r *Result) note(path, value, reason string) { + r.Report = append(r.Report, ReportEntry{Path: path, Value: value, Reason: reason}) +} + +// projectRef builds a workspace `projects:` entry; git is the expanded shorthand. +func projectRef(name, repo string) yaml.MapSlice { + ref := yaml.MapSlice{{Key: "name", Value: name}, {Key: "path", Value: name}} + if repo != "" { + ref = append(ref, yaml.MapItem{Key: "git", Value: git.ExpandURL(repo)}) + } + return ref +} + +// classifyEngine returns the devstack shared engine for a devdock template/image, +// or "" if it is not a recognized stateful engine. +func classifyEngine(template, image string) string { + for _, hint := range []string{template, image} { + h := strings.ToLower(hint) + for kw, engine := range knownEngines { + if strings.Contains(h, kw) { + return engine + } + } + } + return "" +} + +// --- tolerant accessors ----------------------------------------------------- + +func getMap(v any) map[string]any { + if m, ok := v.(map[string]any); ok { + return m + } + return nil +} + +func getStr(m map[string]any, keys ...string) string { + for _, k := range keys { + if v, ok := m[k]; ok { + if s, ok := v.(string); ok { + return s + } + } + } + return "" +} + +func getList(v any) []string { + s, ok := v.([]any) + if !ok { + return nil + } + var out []string + for _, e := range s { + if str, ok := e.(string); ok { + out = append(out, str) + } + } + return out +} + +// topLevelServices treats the doc's mapping-valued top-level keys as services +// (a tolerant fallback when there is no explicit `services:` block). +func topLevelServices(doc map[string]any) map[string]any { + out := map[string]any{} + for k, v := range doc { + switch k { + case "name", "project", "workspace", "version", "apiVersion": + continue + } + if getMap(v) != nil { + out[k] = v + } + } + if len(out) == 0 { + return nil + } + return out +} + +func orderedMap(m map[string]any) yaml.MapSlice { + out := yaml.MapSlice{} + for _, k := range sortedKeys(m) { + out = append(out, yaml.MapItem{Key: k, Value: m[k]}) + } + return out +} + +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 +} + +// RenderReport formats the conversion report for printing / writing next to the +// output (spec 14 §lossless-or-loud). +func RenderReport(entries []ReportEntry) string { + if len(entries) == 0 { + return "conversion report: clean — every field converted.\n" + } + var b strings.Builder + fmt.Fprintf(&b, "conversion report: %d field(s) need your attention\n", len(entries)) + for _, e := range entries { + fmt.Fprintf(&b, " - %s", e.Path) + if e.Value != "" { + fmt.Fprintf(&b, " (%q)", e.Value) + } + fmt.Fprintf(&b, ": %s\n", e.Reason) + } + return b.String() +} diff --git a/internal/migrate/migrate_test.go b/internal/migrate/migrate_test.go new file mode 100644 index 0000000..f074a88 --- /dev/null +++ b/internal/migrate/migrate_test.go @@ -0,0 +1,123 @@ +package migrate + +import ( + "strings" + "testing" + + "github.com/goccy/go-yaml" +) + +const devdockSample = ` +name: acme +services: + postgres: + template: postgres + params: { version: "16" } + redis: + image: redis:7 + api: + template: php.laravel.nginx + repo: acme/api + uses: [postgres, redis] + env: + DATABASE_URL: "postgres://${postgres.host}:5432/api" + CACHE: "${redis.host}" + EXTERNAL: "${billing.url}" + web: + template: node.vite + repo: "git@github.com:acme/web.git" +` + +func TestConvertSplitsSharedAndProjects(t *testing.T) { + res, err := Convert([]byte(devdockSample), "fallback") + if err != nil { + t.Fatal(err) + } + + // Workspace: name from the source, shared postgres+redis, two project refs. + var ws map[string]any + if err := yaml.Unmarshal(res.WorkspaceYAML, &ws); err != nil { + t.Fatalf("workspace not valid yaml: %v\n%s", err, res.WorkspaceYAML) + } + if ws["name"] != "acme" { + t.Errorf("workspace name = %v, want acme", ws["name"]) + } + shared, _ := ws["shared"].(map[string]any) + if _, ok := shared["postgres"]; !ok { + t.Errorf("postgres not under shared: %v", shared) + } + if _, ok := shared["redis"]; !ok { + t.Errorf("redis (from image) not under shared: %v", shared) + } + // api + web are projects (have repos), not shared. + if _, ok := shared["api"]; ok { + t.Error("api should be a project, not shared") + } + if res.Projects["api"] == nil || res.Projects["web"] == nil { + t.Fatalf("expected api+web project files, got %v", keys(res.Projects)) + } + + // api's devstack.yaml: uses rewritten to workspace.shared.*, env ref rewritten. + apiBody := string(res.Projects["api"]) + if !strings.Contains(apiBody, "workspace.shared.postgres") || !strings.Contains(apiBody, "workspace.shared.redis") { + t.Errorf("api uses not rewritten:\n%s", apiBody) + } + if !strings.Contains(apiBody, "${ref:workspace.shared.postgres.host}") { + t.Errorf("api env interpolation not rewritten to typed ref:\n%s", apiBody) + } + + // web's git shorthand expanded — lives in the workspace project ref. + if !strings.Contains(string(res.WorkspaceYAML), "github.com") { + t.Errorf("web git not expanded in workspace:\n%s", res.WorkspaceYAML) + } + + // Lossless-or-loud: the non-shared ${billing.url} ref is reported, not silently kept. + if !hasReport(res.Report, "EXTERNAL", "billing") { + t.Errorf("unconvertible ${billing.url} not reported: %+v", res.Report) + } +} + +func TestConvertParsesValidProjectYAML(t *testing.T) { + res, err := Convert([]byte(devdockSample), "x") + if err != nil { + t.Fatal(err) + } + // Each emitted project file must itself be valid YAML with kind: Project. + for name, body := range res.Projects { + var p map[string]any + if err := yaml.Unmarshal(body, &p); err != nil { + t.Errorf("project %s not valid yaml: %v", name, err) + continue + } + if p["kind"] != "Project" || p["name"] != name { + t.Errorf("project %s header wrong: kind=%v name=%v", name, p["kind"], p["name"]) + } + } +} + +func TestConvertNoServicesIsLoud(t *testing.T) { + res, err := Convert([]byte("name: empty\n"), "empty") + if err != nil { + t.Fatal(err) + } + if len(res.Report) == 0 { + t.Error("a project.yaml with no services should produce a report entry, not silence") + } +} + +func hasReport(entries []ReportEntry, pathSub, valueSub string) bool { + for _, e := range entries { + if strings.Contains(e.Path, pathSub) && strings.Contains(e.Value, valueSub) { + return true + } + } + return false +} + +func keys(m map[string][]byte) []string { + var out []string + for k := range m { + out = append(out, k) + } + return out +}