Skip to content

Commit 272f27a

Browse files
feat(migrate): X9 — devstack import devdock → two-file schema (spec 14) (#76)
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.<name>`; 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 <project.yaml> [--dry-run] [--out <dir>] [--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) <noreply@anthropic.com>
1 parent 334d9f3 commit 272f27a

6 files changed

Lines changed: 715 additions & 1 deletion

File tree

internal/cli/import.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"sort"
8+
"time"
9+
10+
"github.com/spf13/cobra"
11+
12+
"github.com/open-source-cloud/devstack/internal/migrate"
13+
)
14+
15+
// newImportCmd wires `devstack import <project.yaml>` (spec 14 §import): convert a
16+
// legacy devdock single-file project.yaml into a workspace.yaml + per-repo
17+
// devstack.yaml split, plus a lossless-or-loud conversion report. No-clobber by
18+
// default; `--force` backs up originals first; `--dry-run` writes nothing.
19+
func newImportCmd(g *GlobalOpts) *cobra.Command {
20+
var (
21+
dryRun bool
22+
outDir string
23+
force bool
24+
)
25+
cmd := &cobra.Command{
26+
Use: "import <path/to/project.yaml>",
27+
Short: "Convert a legacy devdock project.yaml into workspace.yaml + per-repo devstack.yaml",
28+
Long: "import reads an old devdock single-file project.yaml and emits the clean-slate\n" +
29+
"two-file schema — a workspace.yaml (shared layer) plus a devstack.yaml per repo —\n" +
30+
"and a conversion report listing every field it could not convert (nothing is\n" +
31+
"dropped silently). It refuses to overwrite existing files without --force.",
32+
Args: cobra.ExactArgs(1),
33+
RunE: func(cmd *cobra.Command, args []string) error {
34+
src, err := os.ReadFile(args[0])
35+
if err != nil {
36+
return err
37+
}
38+
if outDir == "" {
39+
outDir = "."
40+
}
41+
abs, _ := filepath.Abs(outDir)
42+
res, err := migrate.Convert(src, filepath.Base(abs))
43+
if err != nil {
44+
return err
45+
}
46+
47+
type target struct {
48+
path string
49+
body []byte
50+
}
51+
targets := []target{{path: filepath.Join(outDir, "workspace.yaml"), body: res.WorkspaceYAML}}
52+
projNames := make([]string, 0, len(res.Projects))
53+
for name := range res.Projects {
54+
projNames = append(projNames, name)
55+
}
56+
sort.Strings(projNames)
57+
for _, name := range projNames {
58+
targets = append(targets, target{path: filepath.Join(outDir, name, "devstack.yaml"), body: res.Projects[name]})
59+
}
60+
reportPath := filepath.Join(outDir, "devstack-import-report.txt")
61+
report := migrate.RenderReport(res.Report)
62+
63+
paths := make([]string, 0, len(targets)+1)
64+
for _, t := range targets {
65+
paths = append(paths, t.path)
66+
}
67+
paths = append(paths, reportPath)
68+
69+
if dryRun {
70+
if g.JSON {
71+
return writeJSON(cmd, importSummary(paths, res.Report, true))
72+
}
73+
w := cmd.OutOrStdout()
74+
for _, t := range targets {
75+
fmt.Fprintf(w, "\n--- %s ---\n%s", t.path, t.body)
76+
}
77+
fmt.Fprintf(w, "\n--- %s ---\n%s", reportPath, report)
78+
fmt.Fprintln(w, "\n(dry-run: nothing written)")
79+
return nil
80+
}
81+
82+
// No-clobber unless --force (then back up the originals first).
83+
for _, t := range targets {
84+
if _, err := os.Stat(t.path); err == nil {
85+
if !force {
86+
return fmt.Errorf("%s already exists; pass --force to overwrite (originals are backed up)", t.path)
87+
}
88+
if err := os.Rename(t.path, fmt.Sprintf("%s.bak.%d", t.path, time.Now().Unix())); err != nil {
89+
return fmt.Errorf("back up %s: %w", t.path, err)
90+
}
91+
}
92+
}
93+
for _, t := range targets {
94+
if err := os.MkdirAll(filepath.Dir(t.path), 0o755); err != nil {
95+
return err
96+
}
97+
if err := os.WriteFile(t.path, t.body, 0o644); err != nil {
98+
return err
99+
}
100+
}
101+
if err := os.WriteFile(reportPath, []byte(report), 0o644); err != nil {
102+
return err
103+
}
104+
105+
if g.JSON {
106+
return writeJSON(cmd, importSummary(paths, res.Report, false))
107+
}
108+
w := cmd.OutOrStdout()
109+
for _, p := range paths {
110+
fmt.Fprintf(w, "[ok] wrote %s\n", p)
111+
}
112+
fmt.Fprint(w, "\n"+report)
113+
if len(res.Report) > 0 {
114+
fmt.Fprintln(w, "\nReview the report above and the generated files before committing (see docs/MIGRATION.md).")
115+
}
116+
return nil
117+
},
118+
}
119+
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print the converted files + report without writing anything")
120+
cmd.Flags().StringVar(&outDir, "out", "", "output directory (default: current directory)")
121+
cmd.Flags().BoolVar(&force, "force", false, "overwrite existing files (backs up originals first)")
122+
return cmd
123+
}
124+
125+
func importSummary(paths []string, report []migrate.ReportEntry, dry bool) map[string]any {
126+
entries := make([]map[string]string, 0, len(report))
127+
for _, e := range report {
128+
entries = append(entries, map[string]string{"path": e.Path, "value": e.Value, "reason": e.Reason})
129+
}
130+
return map[string]any{"files": paths, "report": entries, "dryRun": dry}
131+
}

internal/cli/import_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package cli
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
)
9+
10+
const importSampleYAML = `name: shop
11+
services:
12+
postgres:
13+
template: postgres
14+
params: { version: "16" }
15+
api:
16+
template: php.laravel.nginx
17+
repo: shop/api
18+
uses: [postgres]
19+
`
20+
21+
func writeSample(t *testing.T) (dir, src string) {
22+
t.Helper()
23+
dir = t.TempDir()
24+
src = filepath.Join(dir, "project.yaml")
25+
if err := os.WriteFile(src, []byte(importSampleYAML), 0o644); err != nil {
26+
t.Fatal(err)
27+
}
28+
return dir, src
29+
}
30+
31+
func TestImportRegistered(t *testing.T) {
32+
root := NewRootCmd(Options{})
33+
c, _, err := root.Find([]string{"import"})
34+
if err != nil || c.Name() != "import" || c.RunE == nil {
35+
t.Fatalf("import not registered as a real command: %v", err)
36+
}
37+
}
38+
39+
func TestImportDryRunWritesNothing(t *testing.T) {
40+
dir, src := writeSample(t)
41+
out := filepath.Join(dir, "ws")
42+
var buf strings.Builder
43+
root := NewRootCmd(Options{})
44+
root.SetArgs([]string{"import", src, "--out", out, "--dry-run"})
45+
root.SetOut(&buf)
46+
root.SetErr(&buf)
47+
if err := root.Execute(); err != nil {
48+
t.Fatalf("import --dry-run: %v\n%s", err, buf.String())
49+
}
50+
if _, err := os.Stat(filepath.Join(out, "workspace.yaml")); !os.IsNotExist(err) {
51+
t.Error("--dry-run must not write workspace.yaml")
52+
}
53+
if !strings.Contains(buf.String(), "workspace.yaml") || !strings.Contains(buf.String(), "shared") {
54+
t.Errorf("dry-run should preview the workspace:\n%s", buf.String())
55+
}
56+
}
57+
58+
func TestImportWritesSplitAndReport(t *testing.T) {
59+
dir, src := writeSample(t)
60+
out := filepath.Join(dir, "ws")
61+
var buf strings.Builder
62+
root := NewRootCmd(Options{})
63+
root.SetArgs([]string{"import", src, "--out", out})
64+
root.SetOut(&buf)
65+
root.SetErr(&buf)
66+
if err := root.Execute(); err != nil {
67+
t.Fatalf("import: %v\n%s", err, buf.String())
68+
}
69+
for _, rel := range []string{"workspace.yaml", filepath.Join("api", "devstack.yaml"), "devstack-import-report.txt"} {
70+
if _, err := os.Stat(filepath.Join(out, rel)); err != nil {
71+
t.Errorf("expected %s to be written: %v", rel, err)
72+
}
73+
}
74+
ws, _ := os.ReadFile(filepath.Join(out, "workspace.yaml"))
75+
if !strings.Contains(string(ws), "postgres") {
76+
t.Errorf("workspace missing shared postgres:\n%s", ws)
77+
}
78+
api, _ := os.ReadFile(filepath.Join(out, "api", "devstack.yaml"))
79+
if !strings.Contains(string(api), "workspace.shared.postgres") {
80+
t.Errorf("api uses not rewritten:\n%s", api)
81+
}
82+
}
83+
84+
func TestImportNoClobberWithoutForce(t *testing.T) {
85+
dir, src := writeSample(t)
86+
out := filepath.Join(dir, "ws")
87+
if err := os.MkdirAll(out, 0o755); err != nil {
88+
t.Fatal(err)
89+
}
90+
if err := os.WriteFile(filepath.Join(out, "workspace.yaml"), []byte("existing: keep\n"), 0o644); err != nil {
91+
t.Fatal(err)
92+
}
93+
root := NewRootCmd(Options{})
94+
root.SetArgs([]string{"import", src, "--out", out})
95+
root.SetOut(&strings.Builder{})
96+
root.SetErr(&strings.Builder{})
97+
if err := root.Execute(); err == nil {
98+
t.Fatal("import must refuse to overwrite an existing workspace.yaml without --force")
99+
}
100+
// The original is untouched.
101+
if b, _ := os.ReadFile(filepath.Join(out, "workspace.yaml")); !strings.Contains(string(b), "existing: keep") {
102+
t.Error("existing workspace.yaml was modified despite no --force")
103+
}
104+
105+
// With --force it backs up the original and writes the new one.
106+
root2 := NewRootCmd(Options{})
107+
root2.SetArgs([]string{"import", src, "--out", out, "--force"})
108+
root2.SetOut(&strings.Builder{})
109+
root2.SetErr(&strings.Builder{})
110+
if err := root2.Execute(); err != nil {
111+
t.Fatalf("import --force: %v", err)
112+
}
113+
matches, _ := filepath.Glob(filepath.Join(out, "workspace.yaml.bak.*"))
114+
if len(matches) == 0 {
115+
t.Error("--force should back up the original workspace.yaml")
116+
}
117+
}

internal/cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ func NewRootCmd(opts Options) *cobra.Command {
9292
newWsCmd(g),
9393
newWorkspaceCmd(g),
9494
newUninstallCmd(g),
95+
newImportCmd(g),
9596
newSelfCmd(g),
9697
newStoreCmd(g),
9798
newAliasCmd(g),

internal/cli/stubs.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,5 @@ func addStubCommands(root *cobra.Command, _ *GlobalOpts) {
3232
root.AddCommand(
3333
stub("shell", "Open a shell in a service container", "M2"),
3434
stub("logs", "Stream service logs", "M2"),
35-
stub("import", "Import an old devdock project.yaml into workspace.yaml + devstack.yaml", "M1"),
3635
)
3736
}

0 commit comments

Comments
 (0)