diff --git a/cmd/dew-win/main.go b/cmd/dew-win/main.go index 22f0a3f..fefd475 100644 --- a/cmd/dew-win/main.go +++ b/cmd/dew-win/main.go @@ -31,11 +31,14 @@ import ( "net/http" "os" "os/exec" + "os/signal" "path/filepath" "runtime" "sort" "strings" "time" + + "github.com/solcreek/dew/internal/services" ) const ( @@ -95,6 +98,7 @@ func printUsage() { Usage: dew setup Install/update WSL2 distro dew up [dir] Detect a Node project + run its dev server in WSL2 + dew up --with Start services too (postgres,redis,mysql,mongo,minio) dew run [--] Run a one-shot command in the WSL2 distro dew exec Run a command inside the WSL2 distro dew vm start Ensure the WSL2 distro is running @@ -446,6 +450,183 @@ func cmdExec(args []string) error { return runPassthrough(cmd) } +// parseUpArgs extracts the project dir and any `--with ` / +// `--with=` services from `dew up` arguments. +func parseUpArgs(args []string) (dir string, with []string, err error) { + dir = "." + dirSet := false + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "--with": + if i+1 >= len(args) { + return "", nil, fmt.Errorf("--with needs a comma-separated service list") + } + svcs := splitCSV(args[i+1]) + if len(svcs) == 0 { + return "", nil, fmt.Errorf("--with needs a comma-separated service list") + } + with = append(with, svcs...) + i++ + case strings.HasPrefix(a, "--with="): + svcs := splitCSV(strings.TrimPrefix(a, "--with=")) + if len(svcs) == 0 { + return "", nil, fmt.Errorf("--with needs a comma-separated service list") + } + with = append(with, svcs...) + case strings.HasPrefix(a, "--"): + return "", nil, fmt.Errorf("unknown flag %q for dew up", a) + default: + // Reject a second positional rather than silently taking the + // last one (e.g. `dew up ./a ./b` is a user error, not "./b"). + if dirSet { + return "", nil, fmt.Errorf("dew up takes at most one project dir, got %q and %q", dir, a) + } + dir, dirSet = a, true + } + } + return dir, dedupPreserveOrder(with), nil +} + +// dedupPreserveOrder removes duplicate names, keeping first-seen order, so +// `--with redis,redis` (or repeated --with flags) doesn't start the same +// container twice. +func dedupPreserveOrder(names []string) []string { + seen := map[string]bool{} + var out []string + for _, n := range names { + if !seen[n] { + seen[n] = true + out = append(out, n) + } + } + return out +} + +// splitCSV splits a comma-separated list, dropping blanks. +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 +} + +// serviceContainer is the podman container name for a dew service. +func serviceContainer(name string) string { return "dew-svc-" + name } + +// ensurePodman installs podman in the distro on first --with use. The dew +// rootfs is minimal Alpine with no container runtime baked in, so apk-add it +// once (persisted on the distro's disk). +func ensurePodman() error { + if exec.Command("wsl", "-d", distroName, "--exec", "sh", "-c", "command -v podman").Run() == nil { + return nil + } + fmt.Println("dew: installing podman in the distro (first --with use)...") + add := exec.Command("wsl", "-d", distroName, "--exec", "sh", "-c", "apk add --no-cache podman") + add.Stdout, add.Stderr = os.Stdout, os.Stderr + if err := add.Run(); err != nil { + return fmt.Errorf("apk add podman: %w", err) + } + return nil +} + +// podmanRunArgs builds the wsl.exe argv that starts a service container on +// the distro's host network. Pure, so the exact flags — host networking, the +// -e env pairs, image, and trailing server args — are unit-testable. +func podmanRunArgs(s services.Service) []string { + args := []string{"-d", distroName, "--exec", "podman", "run", "-d", + "--name", serviceContainer(s.Name), "--network=host"} + for _, e := range s.Env { + args = append(args, "-e", e) + } + args = append(args, s.Image) + return append(args, s.Args...) +} + +// startService runs one service via podman on the distro's host network +// (required: rootful podman's bridge conflicts with WSL2 mirrored mode) and +// waits until its port is listening. Returns the client connection string. +func startService(s services.Service) (string, error) { + // Clear any stale container from a previous run. + exec.Command("wsl", "-d", distroName, "--exec", "podman", "rm", "-f", serviceContainer(s.Name)).Run() + + if out, err := exec.Command("wsl", podmanRunArgs(s)...).CombinedOutput(); err != nil { + return "", fmt.Errorf("podman run %s: %w: %s", s.Name, err, strings.TrimSpace(string(out))) + } + if err := waitForServicePort(s.Port); err != nil { + return "", fmt.Errorf("%s: %w", s.Name, err) + } + return services.ConnString(s, s.Port), nil +} + +// waitForServicePort polls ListenProbeCmd inside the distro until the port is +// listening. A host-network container binds the distro's netns, so the port +// shows up in the distro's own /proc/net/tcp. +func waitForServicePort(port int) error { + probe := services.ListenProbeCmd(port) + for i := 0; i < 120; i++ { // ~60s at 500ms + if exec.Command("wsl", "-d", distroName, "--exec", "sh", "-c", probe).Run() == nil { + return nil + } + time.Sleep(500 * time.Millisecond) + } + return fmt.Errorf("port %d not listening after 60s", port) +} + +// stopServices force-removes the service containers. Idempotent. +func stopServices(names []string) { + for _, n := range names { + exec.Command("wsl", "-d", distroName, "--exec", "podman", "rm", "-f", serviceContainer(n)).Run() + } +} + +// startWithServices starts the requested services and returns a cleanup func. +// It also installs a Ctrl+C handler that stops them and exits, since the dev +// server's os.Exit (or the services-only blocking wait) would otherwise skip +// cleanup. No-op when names is empty. +func startWithServices(names []string) (func(), error) { + stop := func() { stopServices(names) } + if len(names) == 0 { + return func() {}, nil + } + if err := ensurePodman(); err != nil { + return nil, err + } + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt) + go func() { + <-sig + fmt.Println("\ndew: stopping services...") + stop() + // The dev server runs as a child wsl.exe; on Windows, exiting dew + // doesn't reliably kill it, so terminate the distro to bring down + // the dev server (and anything else) before we exit. + exec.Command("wsl", "--terminate", distroName).Run() + os.Exit(130) + }() + for _, name := range names { + svc := services.Lookup(name) + if svc == nil { + // cmdUp validates names first, but don't panic if a future + // caller doesn't — this function already returns an error. + stop() + return nil, fmt.Errorf("unknown service %q", name) + } + s := *svc + fmt.Printf("dew: starting %s (%s)...\n", name, s.Image) + conn, err := startService(s) + if err != nil { + stop() + return nil, err + } + fmt.Printf("dew: %s ready -> %s\n", name, conn) + } + return stop, nil +} + // cmdUp detects a Node-style project in dir (or cwd) and runs its // dev server inside the WSL2 distro. The project is reached via // the auto-mounted /mnt//... path; WSL2's mirrored @@ -460,26 +641,66 @@ func cmdExec(args []string) error { // Heavier project-aware behavior can land iteratively as Windows // users hit specific gaps. func cmdUp(args []string) error { - dir := "." - if len(args) > 0 && !strings.HasPrefix(args[0], "--") { - dir = args[0] + dir, withServices, err := parseUpArgs(args) + if err != nil { + return err + } + for _, name := range withServices { + if services.Lookup(name) == nil { + // services.Names() iterates a map; sort for a stable message. + avail := services.Names() + sort.Strings(avail) + return fmt.Errorf("unknown service %q (available: %s)", name, strings.Join(avail, ", ")) + } } absDir, err := filepath.Abs(dir) if err != nil { return fmt.Errorf("resolve %q: %w", dir, err) } + // Validate the dir itself so a typoed path (e.g. `dew up --with redis + // ./typo`) surfaces an error instead of silently falling into + // services-only mode. A real dir without package.json still works. + if fi, err := os.Stat(absDir); err != nil { + return fmt.Errorf("dew up %s: %w", dir, err) + } else if !fi.IsDir() { + return fmt.Errorf("dew up: %s is not a directory", dir) + } - // Node-only fast check. If we ever support Python/Go/etc. - // projects on Windows, branch here per project type. + // The dev-server half is Node-only; with --with the project is optional + // (services can run on their own). Only a genuine "not found" means no + // project — surface permission/IO stat errors instead of silently + // treating them as a missing package.json. pkgPath := filepath.Join(absDir, "package.json") - if _, err := os.Stat(pkgPath); err != nil { - return fmt.Errorf("no package.json in %s — dew up on Windows currently supports Node-style projects only", absDir) + _, statErr := os.Stat(pkgPath) + if statErr != nil && !os.IsNotExist(statErr) { + return fmt.Errorf("stat %s: %w", pkgPath, statErr) + } + hasProject := statErr == nil + if !hasProject && len(withServices) == 0 { + return fmt.Errorf("no package.json in %s — dew up on Windows supports Node-style projects; or use --with ", absDir) } if err := ensureDistro(); err != nil { return err } + stop, err := startWithServices(withServices) + if err != nil { + return err + } + // Guarantee cleanup on every early error return below (winPathToWSL, + // npm install, missing script). The dev-server path calls stop() + // explicitly before its os.Exit (which would skip this defer), and the + // Ctrl+C handler stops them too; stopServices is idempotent. + defer stop() + + // Services-only: no project to run, so hold the distro open (which keeps + // the containers alive) until Ctrl+C, handled by startWithServices. + if !hasProject { + fmt.Println("dew: services running. Ctrl+C to stop.") + select {} + } + // Translate the Windows path to the WSL2 mount path // (e.g. C:\Users\foo\proj → /mnt/c/Users/foo/proj). Defer to // wslpath inside the distro so we don't have to mirror its @@ -524,7 +745,15 @@ func cmdUp(args []string) error { dev.Stdin = os.Stdin dev.Stdout = os.Stdout dev.Stderr = os.Stderr - return runPassthrough(dev) + runErr := dev.Run() + var ee *exec.ExitError + if errors.As(runErr, &ee) { + // os.Exit skips the deferred stop(), so clean up explicitly first. + stop() + os.Exit(ee.ExitCode()) + } + // Non-exit-code paths fall through to the deferred stop(). + return runErr } // winPathToWSL converts a Windows absolute path into its WSL2 diff --git a/cmd/dew-win/main_test.go b/cmd/dew-win/main_test.go index 24ee7ca..32670ce 100644 --- a/cmd/dew-win/main_test.go +++ b/cmd/dew-win/main_test.go @@ -11,6 +11,8 @@ import ( "slices" "strings" "testing" + + "github.com/solcreek/dew/internal/services" ) // withStubWSL replaces the wslQuery seam with a fake that answers the @@ -455,6 +457,101 @@ func TestDewDataDir(t *testing.T) { } } +func TestParseUpArgs(t *testing.T) { + cases := []struct { + name string + args []string + dir string + with []string + wantErr bool + }{ + {"empty", nil, ".", nil, false}, + {"dir only", []string{"./app"}, "./app", nil, false}, + {"with space form", []string{"--with", "postgres,redis"}, ".", []string{"postgres", "redis"}, false}, + {"with equals form", []string{"--with=postgres"}, ".", []string{"postgres"}, false}, + {"dir and with", []string{"./app", "--with", "redis"}, "./app", []string{"redis"}, false}, + {"with then dir (PR example)", []string{"--with", "redis", "./app"}, "./app", []string{"redis"}, false}, + {"csv trims blanks", []string{"--with", "a, b ,,c"}, ".", []string{"a", "b", "c"}, false}, + {"dedup preserves order", []string{"--with", "redis,redis,postgres,redis"}, ".", []string{"redis", "postgres"}, false}, + {"with needs arg", []string{"--with"}, "", nil, true}, + {"with empty equals rejected", []string{"--with="}, "", nil, true}, + {"with blank value rejected", []string{"--with", " , "}, "", nil, true}, + {"unknown flag", []string{"--bogus"}, "", nil, true}, + {"multiple dirs rejected", []string{"./a", "./b"}, "", nil, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + dir, with, err := parseUpArgs(c.args) + if (err != nil) != c.wantErr { + t.Fatalf("err = %v, wantErr %v", err, c.wantErr) + } + if c.wantErr { + return + } + if dir != c.dir { + t.Errorf("dir = %q, want %q", dir, c.dir) + } + if !reflect.DeepEqual(with, c.with) { + t.Errorf("with = %v, want %v", with, c.with) + } + }) + } +} + +func TestSplitCSV(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"a,b,c", []string{"a", "b", "c"}}, + {"a, b ,,c", []string{"a", "b", "c"}}, + {"", nil}, + {" ", nil}, + {"solo", []string{"solo"}}, + } + for _, c := range cases { + if got := splitCSV(c.in); !reflect.DeepEqual(got, c.want) { + t.Errorf("splitCSV(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +func TestServiceContainer(t *testing.T) { + if got := serviceContainer("redis"); got != "dew-svc-redis" { + t.Errorf("serviceContainer(redis) = %q, want dew-svc-redis", got) + } +} + +func TestPodmanRunArgs(t *testing.T) { + // No env, no server args: exact argv. + redis := services.Service{Name: "redis", Image: "docker.io/library/redis:7-alpine", Port: 6379} + want := []string{"-d", "dew", "--exec", "podman", "run", "-d", + "--name", "dew-svc-redis", "--network=host", "docker.io/library/redis:7-alpine"} + if got := podmanRunArgs(redis); !reflect.DeepEqual(got, want) { + t.Errorf("redis: got %v, want %v", got, want) + } + + // Env pairs must precede the image; server args must follow it. + mysql := services.Service{ + Name: "mysql", Image: "docker.io/library/mysql:8-oracle", Port: 3306, + Env: []string{"MYSQL_ROOT_PASSWORD=dew"}, + Args: []string{"--bind-address=0.0.0.0"}, + } + got := podmanRunArgs(mysql) + if !slices.Contains(got, "--network=host") { + t.Errorf("mysql: --network=host missing in %v", got) + } + env := slices.Index(got, "MYSQL_ROOT_PASSWORD=dew") + img := slices.Index(got, mysql.Image) + arg := slices.Index(got, "--bind-address=0.0.0.0") + if env < 0 || img < 0 || arg < 0 { + t.Fatalf("mysql: missing element in %v", got) + } + if !(env < img && img < arg) { + t.Errorf("mysql: order wrong (env=%d img=%d arg=%d) in %v", env, img, arg, got) + } +} + func TestHasMirroredNetworking(t *testing.T) { cases := []struct { name string diff --git a/cmd/dew-win/smoke-test.ps1 b/cmd/dew-win/smoke-test.ps1 index 7fe3fe5..614b1a8 100644 --- a/cmd/dew-win/smoke-test.ps1 +++ b/cmd/dew-win/smoke-test.ps1 @@ -137,6 +137,46 @@ Check "dev server reachable at localhost:$port" { $body -eq 'smoke-ok' } if ($up -and -not $up.HasExited) { $up.Kill() } wsl --terminate dew 2>&1 | Out-Null +# --- up --with: service starts, is reachable, and is cleaned up ---- +Write-Host "== up --with: service lifecycle ==" -ForegroundColor Cyan +$svcProj = Join-Path $env:TEMP 'dew-smoke-svc' +New-Item -ItemType Directory -Force -Path $svcProj | Out-Null +# dev script that self-exits after ~15s so the cleanup path (dev exits -> +# stop()) runs on its own without needing a Ctrl+C we can't send here. +Set-Content -Path (Join-Path $svcProj 'package.json') -Encoding ascii -Value '{"name":"svc","private":true,"scripts":{"dev":"node -e \"setTimeout(()=>process.exit(0),15000)\""}}' +$svcLog = Join-Path $env:TEMP 'dew-smoke-svc.log' +Remove-Item $svcLog -ErrorAction SilentlyContinue +$svc = Start-Process $dew -ArgumentList "up --with redis `"$svcProj`"" -RedirectStandardOutput $svcLog -RedirectStandardError "$svcLog.err" -PassThru -NoNewWindow +$svcReady = $false +foreach ($i in 1..90) { + Start-Sleep -Seconds 1 + if ((Get-Content $svcLog -Raw -ErrorAction SilentlyContinue) -match 'redis ready') { $svcReady = $true; break } + if ($svc.HasExited) { break } +} +Check "up --with redis reports the service ready" { $svcReady } +Check "redis reachable on Windows localhost:6379" { + (Test-NetConnection -ComputerName 127.0.0.1 -Port 6379 -WarningAction SilentlyContinue).TcpTestSucceeded +} +Check "service container is running" { + (wsl -d dew -e podman ps --format '{{.Names}}' | Out-String) -match 'dew-svc-redis' +} +# Let the dev server self-exit so stop() removes the container. If it +# overruns the wait, Kill() it and rm the container ourselves -- a force +# kill skips stop(), so this prevents a leak into the rest of the run. +if (-not $svc.HasExited) { $svc.WaitForExit(25000) | Out-Null } +if (-not $svc.HasExited) { + $svc.Kill() + wsl -d dew -e podman rm -f dew-svc-redis 2>&1 | Out-Null +} +# Poll for removal instead of a fixed sleep (slower machines/CI). +$removed = $false +foreach ($i in 1..20) { + if (-not ((wsl -d dew -e podman ps -a --format '{{.Names}}' | Out-String) -match 'dew-svc-redis')) { $removed = $true; break } + Start-Sleep -Milliseconds 500 +} +Check "service container removed after dev server exits" { $removed } +wsl --terminate dew 2>&1 | Out-Null + # --- Result -------------------------------------------------------- Write-Host "" if ($script:Failures -eq 0) {