-
Notifications
You must be signed in to change notification settings - Fork 0
feat(dew-win): dew up --with <service> (podman-backed services) #39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
90a5bb6
feat(dew-win): dew up --with <service> (podman-backed services)
linyiru 9090b58
fix(dew-win): reject multiple project dirs in dew up
linyiru bce3c74
docs(dew-win): fix the shellQuote POSIX-escape smart quote
linyiru 93256dc
test(dew-win): kill the dev server if it overruns the --with smoke wait
linyiru b761fa3
fix(dew-win): reliably clean up --with services on all exit paths
linyiru ed3da7d
fix(dew-win): harden --with argument parsing
linyiru 1a1fc25
fix(dew-win): surface non-not-exist package.json stat errors in dew up
linyiru f9697af
docs(dew-win): show --with takes a service list in usage
linyiru 609079f
fix(dew-win): deduplicate --with service names
linyiru ef69c97
test(dew-win): make the --with cleanup smoke check robust
linyiru a32f6e7
fix(dew-win): validate the dew up project dir exists
linyiru d799f83
fix(dew-win): return an error instead of panicking on unknown service
linyiru File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 <svc,...> Start services too (postgres,redis,mysql,mongo,minio) | ||
| dew run [--] <cmd> Run a one-shot command in the WSL2 distro | ||
| dew exec <cmd> 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 <csv>` / | ||
| // `--with=<csv>` 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/<drive>/... 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 <service>", absDir) | ||
| } | ||
|
Comment on lines
+674
to
681
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adopted in 1a1fc25 — dew up now returns the stat error unless it's os.IsNotExist, so a permission/IO problem on package.json no longer silently falls into the services-only path. |
||
|
|
||
| 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 | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adopted in 609079f — parseUpArgs now dedups the service list preserving first-seen order, so '--with redis,redis' starts redis once. Added a test case.