Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions internal/cli/import.go
Original file line number Diff line number Diff line change
@@ -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 <project.yaml>` (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 <path/to/project.yaml>",
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}
}
117 changes: 117 additions & 0 deletions internal/cli/import_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ func NewRootCmd(opts Options) *cobra.Command {
newWsCmd(g),
newWorkspaceCmd(g),
newUninstallCmd(g),
newImportCmd(g),
newSelfCmd(g),
newStoreCmd(g),
newAliasCmd(g),
Expand Down
1 change: 0 additions & 1 deletion internal/cli/stubs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)
}
Loading
Loading