From 6c908f55ac63480d8cda957dedbd43366da20964 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Thu, 6 Aug 2026 18:25:37 +0530 Subject: [PATCH 1/4] feat(cli): add start/stop/status/logs/delete lifecycle commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add minikube-style background lifecycle to the cloudemu CLI, wrapping the existing foreground `serve` (issue #335, P0-a): - start [serve flags]: re-execs `serve` detached (new session, output → log), waits for readiness (HTTP health probe for AWS/GCP, TCP probe for the self-signed HTTPS endpoints), prints endpoints, and is idempotent. - stop: SIGTERM + wait for exit (reuses serve's graceful shutdown). - status: running/stopped + pid + endpoints. - logs [-f]: print/follow the daemon log. - delete: stop + remove the run directory. Run state (pid, log, resolved endpoints) lives under ~/.cloudemu by default, overridable with --home. serve is unchanged; start passes its flags through. Unit-tested the pure helpers (run-dir resolution, state round-trip, process-liveness, health poll, home-flag split); documented in docs/standalone-server.md. Persistence across restarts (#107) is the next step. --- cmd/cloudemu/lifecycle.go | 546 +++++++++++++++++++++++++++++++++ cmd/cloudemu/lifecycle_test.go | 118 +++++++ cmd/cloudemu/main.go | 15 +- docs/standalone-server.md | 23 ++ 4 files changed, 700 insertions(+), 2 deletions(-) create mode 100644 cmd/cloudemu/lifecycle.go create mode 100644 cmd/cloudemu/lifecycle_test.go diff --git a/cmd/cloudemu/lifecycle.go b/cmd/cloudemu/lifecycle.go new file mode 100644 index 00000000..970b0096 --- /dev/null +++ b/cmd/cloudemu/lifecycle.go @@ -0,0 +1,546 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" +) + +const ( + stateFileName = "state.json" + logFileName = "cloudemu.log" + endpointsFileName = "endpoints.json" + + startupTimeout = 15 * time.Second + stopTimeout = 12 * time.Second + healthInterval = 100 * time.Millisecond + dialTimeout = time.Second + + dirPerm = 0o755 + filePerm = 0o600 + + cmdStart = "start" + cmdStop = "stop" + cmdStatus = "status" + cmdLogs = "logs" + cmdDelete = "delete" +) + +// Sentinel errors so callers (and err113) get static, wrappable failures. +var ( + errTimeout = errors.New("timed out") + errNoEndpoints = errors.New("no endpoints to probe") + errUnknownCmd = errors.New("unknown lifecycle command") +) + +// daemonState is the run-dir record describing a running standalone server. +type daemonState struct { + PID int `json:"pid"` + Endpoints map[string]string `json:"endpoints"` + StartedAt string `json:"startedAt"` + Args []string `json:"args"` +} + +// runDir resolves the directory that holds the daemon's pid/log/endpoints. +// An explicit --home wins; otherwise it defaults to ~/.cloudemu. +func runDir(home string) (string, error) { + if home != "" { + return home, nil + } + + h, err := os.UserHomeDir() + if err != nil { + return "", err + } + + return filepath.Join(h, ".cloudemu"), nil +} + +func statePath(dir string) string { return filepath.Join(dir, stateFileName) } +func logPath(dir string) string { return filepath.Join(dir, logFileName) } +func endpointsPath(dir string) string { return filepath.Join(dir, endpointsFileName) } + +func writeState(dir string, s daemonState) error { + if err := os.MkdirAll(dir, dirPerm); err != nil { + return err + } + + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + + return os.WriteFile(statePath(dir), b, filePerm) +} + +func readState(dir string) (daemonState, error) { + var s daemonState + + b, err := os.ReadFile(statePath(dir)) + if err != nil { + return s, err + } + + if err := json.Unmarshal(b, &s); err != nil { + return s, err + } + + return s, nil +} + +func removeState(dir string) error { + if err := os.Remove(statePath(dir)); err != nil && !os.IsNotExist(err) { + return err + } + + return nil +} + +// processAlive reports whether pid names a live process (signal 0 probe). +func processAlive(pid int) bool { + if pid <= 0 { + return false + } + + p, err := os.FindProcess(pid) + if err != nil { + return false + } + + return p.Signal(syscall.Signal(0)) == nil +} + +// pollHealth polls an HTTP health URL until it returns 200 or timeout elapses. +// It is used only for the plain-HTTP endpoints (AWS/GCP); the self-signed HTTPS +// endpoints use pollTCP instead so no TLS verification has to be disabled. +func pollHealth(healthURL string, timeout time.Duration) error { + client := &http.Client{Timeout: 2 * time.Second} + deadline := time.Now().Add(timeout) + + for { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, healthURL, http.NoBody) + if err != nil { + return err + } + + if resp, err := client.Do(req); err == nil { + _ = resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + return nil + } + } + + if time.Now().After(deadline) { + return fmt.Errorf("waiting for %s to become healthy: %w", healthURL, errTimeout) + } + + time.Sleep(healthInterval) + } +} + +// pollTCP polls until a TCP connection to hostPort succeeds or timeout elapses. +// It confirms a listener is accepting without needing TLS trust — used for the +// self-signed HTTPS endpoints (Azure/Kubernetes). +func pollTCP(hostPort string, timeout time.Duration) error { + var dialer net.Dialer + + deadline := time.Now().Add(timeout) + + for { + ctx, cancel := context.WithTimeout(context.Background(), dialTimeout) + conn, err := dialer.DialContext(ctx, "tcp", hostPort) + + cancel() + + if err == nil { + _ = conn.Close() + + return nil + } + + if time.Now().After(deadline) { + return fmt.Errorf("waiting for %s to accept connections: %w", hostPort, errTimeout) + } + + time.Sleep(healthInterval) + } +} + +// waitServerReady blocks until the server behind eps is accepting requests, +// using an HTTP health probe when a plain-HTTP endpoint exists and a TCP probe +// otherwise. +func waitServerReady(eps map[string]string, timeout time.Duration) error { + if u := httpHealthURL(eps); u != "" { + return pollHealth(u, timeout) + } + + for _, k := range []string{"azure", "kubernetes"} { + if ep := eps[k]; ep != "" { + hp, err := hostPortOf(ep) + if err != nil { + return err + } + + return pollTCP(hp, timeout) + } + } + + return errNoEndpoints +} + +// httpHealthURL returns a plain-HTTP /_cloudemu/health URL, or "" if none. +func httpHealthURL(eps map[string]string) string { + for _, k := range []string{"aws", "gcp"} { + if ep := eps[k]; ep != "" { + return strings.TrimRight(ep, "/") + "/_cloudemu/health" + } + } + + return "" +} + +// hostPortOf extracts host:port from an endpoint URL. +func hostPortOf(endpoint string) (string, error) { + u, err := url.Parse(endpoint) + if err != nil { + return "", err + } + + return u.Host, nil +} + +// splitHomeFlag extracts --home / --home=value (also single-dash) from args and +// returns the value plus the remaining args to forward to `serve`. +func splitHomeFlag(args []string) (home string, rest []string) { + rest = make([]string, 0, len(args)) + + for i := 0; i < len(args); i++ { + a := args[i] + + switch { + case a == "--home" || a == "-home": + if i+1 < len(args) { + home = args[i+1] + i++ + } + case strings.HasPrefix(a, "--home="): + home = strings.TrimPrefix(a, "--home=") + case strings.HasPrefix(a, "-home="): + home = strings.TrimPrefix(a, "-home=") + default: + rest = append(rest, a) + } + } + + return home, rest +} + +// readEndpoints loads and prunes the endpoints file serve writes on startup. +func readEndpoints(path string) (map[string]string, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + raw := map[string]string{} + if err := json.Unmarshal(b, &raw); err != nil { + return nil, err + } + + out := make(map[string]string, len(raw)) + + for k, v := range raw { + if v != "" { + out[k] = v + } + } + + return out, nil +} + +// waitForEndpoints polls the endpoints file until serve has written it. +func waitForEndpoints(path string, timeout time.Duration) (map[string]string, error) { + deadline := time.Now().Add(timeout) + + for { + eps, err := readEndpoints(path) + if err == nil && len(eps) > 0 { + return eps, nil + } + + if time.Now().After(deadline) { + return nil, fmt.Errorf("waiting for endpoints file %s: %w", path, errTimeout) + } + + time.Sleep(healthInterval) + } +} + +func printEndpoints(eps map[string]string) { + fmt.Println("cloudemu — running") + fmt.Println("──────────────────") + + for _, k := range []string{"aws", "azure", "gcp", "kubernetes"} { + if ep := eps[k]; ep != "" { + fmt.Printf(" %-11s %s\n", k, ep) + } + } +} + +// runStart launches `cloudemu serve` as a detached background process and waits +// for it to become ready. Remaining args (after --home) pass through to serve. +func runStart(args []string) error { + home, rest := splitHomeFlag(args) + + dir, err := runDir(home) + if err != nil { + return err + } + + if s, rErr := readState(dir); rErr == nil && processAlive(s.PID) { + fmt.Printf("cloudemu already running (pid %d)\n", s.PID) + printEndpoints(s.Endpoints) + + return nil + } + + if mkErr := os.MkdirAll(dir, dirPerm); mkErr != nil { + return mkErr + } + + epPath := endpointsPath(dir) + _ = os.Remove(epPath) // drop a stale file so waitForEndpoints sees the fresh one + + eps, err := spawnServe(dir, rest, epPath) + if err != nil { + return err + } + + printEndpoints(eps) + + return nil +} + +// spawnServe forks the detached serve process, waits for readiness, records the +// run state, and returns the resolved endpoints. On a readiness failure it +// stops the child and surfaces the log path. +func spawnServe(dir string, serveArgs []string, epPath string) (map[string]string, error) { + exe, err := os.Executable() + if err != nil { + return nil, err + } + + logF, err := os.OpenFile(logPath(dir), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, filePerm) + if err != nil { + return nil, err + } + defer logF.Close() + + full := append([]string{"serve", "--endpoints-file", epPath, "--quiet"}, serveArgs...) + cmd := exec.CommandContext(context.Background(), exe, full...) + cmd.Stdout = logF + cmd.Stderr = logF + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + + if startErr := cmd.Start(); startErr != nil { + return nil, startErr + } + + eps, readyErr := waitForEndpoints(epPath, startupTimeout) + if readyErr == nil { + readyErr = waitServerReady(eps, startupTimeout) + } + + if readyErr != nil { + _ = cmd.Process.Signal(syscall.SIGTERM) + + return nil, fmt.Errorf("cloudemu failed to start (see %s): %w", logPath(dir), readyErr) + } + + state := daemonState{ + PID: cmd.Process.Pid, + Endpoints: eps, + StartedAt: time.Now().UTC().Format(time.RFC3339), + Args: serveArgs, + } + + return eps, writeState(dir, state) +} + +// runStop signals the running daemon to shut down and waits for it to exit. +func runStop(args []string) error { + home, _ := splitHomeFlag(args) + + dir, err := runDir(home) + if err != nil { + return err + } + + s, err := readState(dir) + if err != nil { + fmt.Println("cloudemu is not running") + + return nil + } + + if !processAlive(s.PID) { + _ = removeState(dir) + + fmt.Println("cloudemu is not running (cleaned up stale state)") + + return nil + } + + proc, err := os.FindProcess(s.PID) + if err != nil { + return err + } + + if err := proc.Signal(syscall.SIGTERM); err != nil { + return err + } + + if err := waitExit(s.PID, stopTimeout); err != nil { + return err + } + + _ = removeState(dir) + + fmt.Printf("cloudemu stopped (pid %d)\n", s.PID) + + return nil +} + +// waitExit blocks until pid is no longer alive or timeout elapses. +func waitExit(pid int, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + + for processAlive(pid) { + if time.Now().After(deadline) { + return fmt.Errorf("waiting for pid %d to exit: %w", pid, errTimeout) + } + + time.Sleep(healthInterval) + } + + return nil +} + +// runStatus reports whether the daemon is running and its endpoints. +func runStatus(args []string) error { + home, _ := splitHomeFlag(args) + + dir, err := runDir(home) + if err != nil { + return err + } + + s, err := readState(dir) + if err != nil || !processAlive(s.PID) { + fmt.Println("cloudemu: stopped") + + return nil + } + + fmt.Printf("cloudemu: running (pid %d, since %s)\n", s.PID, s.StartedAt) + printEndpoints(s.Endpoints) + + return nil +} + +// runLogs prints the daemon log, optionally following new output with -f. +func runLogs(args []string) error { + home, rest := splitHomeFlag(args) + follow := false + + for _, a := range rest { + if a == "-f" || a == "--follow" { + follow = true + } + } + + dir, err := runDir(home) + if err != nil { + return err + } + + return tailLog(logPath(dir), follow) +} + +// runDelete stops the daemon (if running) and removes its run directory. +func runDelete(args []string) error { + home, _ := splitHomeFlag(args) + + if err := runStop(args); err != nil { + return err + } + + dir, err := runDir(home) + if err != nil { + return err + } + + if err := os.RemoveAll(dir); err != nil { + return err + } + + fmt.Printf("cloudemu: removed %s\n", dir) + + return nil +} + +// tailLog prints the log file. When follow is set it streams appended output +// until interrupted. +func tailLog(path string, follow bool) error { + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("no log at %s: %w", path, err) + } + defer f.Close() + + if _, err := io.Copy(os.Stdout, f); err != nil { + return err + } + + if !follow { + return nil + } + + for { + time.Sleep(healthInterval) + + if _, err := io.Copy(os.Stdout, f); err != nil { + return err + } + } +} + +// runLifecycle dispatches the start/stop/status/logs/delete subcommands. +func runLifecycle(cmd string, args []string) error { + switch cmd { + case cmdStart: + return runStart(args) + case cmdStop: + return runStop(args) + case cmdStatus: + return runStatus(args) + case cmdLogs: + return runLogs(args) + case cmdDelete: + return runDelete(args) + default: + return fmt.Errorf("%w: %q", errUnknownCmd, cmd) + } +} diff --git a/cmd/cloudemu/lifecycle_test.go b/cmd/cloudemu/lifecycle_test.go new file mode 100644 index 00000000..2eab1083 --- /dev/null +++ b/cmd/cloudemu/lifecycle_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +func TestRunDir(t *testing.T) { + // Explicit --home wins. + got, err := runDir("/tmp/custom-home") + if err != nil || got != "/tmp/custom-home" { + t.Fatalf("runDir(explicit) = %q, %v", got, err) + } + + // Empty falls back to ~/.cloudemu. + home, err := os.UserHomeDir() + if err != nil { + t.Skipf("no home dir: %v", err) + } + + got, err = runDir("") + if err != nil || got != filepath.Join(home, ".cloudemu") { + t.Fatalf("runDir(default) = %q, %v; want %q", got, err, filepath.Join(home, ".cloudemu")) + } +} + +func TestStateRoundTrip(t *testing.T) { + dir := t.TempDir() + + want := daemonState{ + PID: 4321, + Endpoints: map[string]string{"aws": "http://127.0.0.1:4566"}, + StartedAt: "2026-08-06T00:00:00Z", + Args: []string{"--region", "eu-west-1"}, + } + if err := writeState(dir, want); err != nil { + t.Fatalf("writeState: %v", err) + } + + got, err := readState(dir) + if err != nil { + t.Fatalf("readState: %v", err) + } + if got.PID != want.PID || got.Endpoints["aws"] != want.Endpoints["aws"] || + got.StartedAt != want.StartedAt || len(got.Args) != 2 { + t.Fatalf("readState = %+v, want %+v", got, want) + } + + if err := removeState(dir); err != nil { + t.Fatalf("removeState: %v", err) + } + if _, err := readState(dir); err == nil { + t.Fatal("readState after remove: expected error, got nil") + } +} + +func TestReadStateMissing(t *testing.T) { + if _, err := readState(t.TempDir()); err == nil { + t.Fatal("expected error reading state from empty dir") + } +} + +func TestProcessAlive(t *testing.T) { + // The test process itself is alive. + if !processAlive(os.Getpid()) { + t.Fatal("processAlive(self) = false, want true") + } + + // PID 0 / a very unlikely PID is not a live cloudemu process. + if processAlive(-1) { + t.Fatal("processAlive(-1) = true, want false") + } +} + +func TestPollHealthReady(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + if err := pollHealth(srv.URL, time.Second); err != nil { + t.Fatalf("pollHealth(ready) = %v", err) + } +} + +func TestPollHealthTimeout(t *testing.T) { + // Nothing listening on this address → should time out, not hang. + if err := pollHealth("http://127.0.0.1:1", 200*time.Millisecond); err == nil { + t.Fatal("pollHealth(dead) = nil, want timeout error") + } +} + +func TestSplitHomeFlag(t *testing.T) { + // --home is extracted; the rest passes through to serve untouched. + home, rest := splitHomeFlag([]string{"--home", "/tmp/h", "--region", "eu-west-1", "--aws-port", "4600"}) + if home != "/tmp/h" { + t.Fatalf("home = %q, want /tmp/h", home) + } + if len(rest) != 4 || rest[0] != "--region" || rest[2] != "--aws-port" { + t.Fatalf("rest = %v, want [--region eu-west-1 --aws-port 4600]", rest) + } + + // --home=value form. + home, rest = splitHomeFlag([]string{"--home=/tmp/h2", "--quiet"}) + if home != "/tmp/h2" || len(rest) != 1 || rest[0] != "--quiet" { + t.Fatalf("splitHomeFlag(=form) home=%q rest=%v", home, rest) + } + + // Absent → empty home, all args pass through. + home, rest = splitHomeFlag([]string{"--region", "us-east-1"}) + if home != "" || len(rest) != 2 { + t.Fatalf("splitHomeFlag(absent) home=%q rest=%v", home, rest) + } +} diff --git a/cmd/cloudemu/main.go b/cmd/cloudemu/main.go index c0df85e3..d1108b93 100644 --- a/cmd/cloudemu/main.go +++ b/cmd/cloudemu/main.go @@ -15,11 +15,17 @@ import ( const usage = `cloudemu — in-memory AWS/Azure/GCP emulator Usage: - cloudemu serve [flags] Start the standalone server (see: cloudemu serve -h) + cloudemu start [flags] Start the emulator in the background (accepts serve flags) + cloudemu stop Stop the background emulator + cloudemu status Show whether the emulator is running and its endpoints + cloudemu logs [-f] Print (or follow) the background emulator's log + cloudemu delete Stop the emulator and remove its run directory + cloudemu serve [flags] Run the server in the foreground (see: cloudemu serve -h) cloudemu version Print the version cloudemu help Show this message -Run "cloudemu serve -h" for the full list of serve flags. +Lifecycle commands keep run state (pid, log, endpoints) under ~/.cloudemu by +default; override with --home . Run "cloudemu serve -h" for serve flags. ` // version is overridable at build time with @@ -38,6 +44,11 @@ func main() { fmt.Fprintln(os.Stderr, "cloudemu:", err) os.Exit(1) } + case cmdStart, cmdStop, cmdStatus, cmdLogs, cmdDelete: + if err := runLifecycle(os.Args[1], os.Args[2:]); err != nil { + fmt.Fprintln(os.Stderr, "cloudemu:", err) + os.Exit(1) + } case "version", "-v", "--version": fmt.Println("cloudemu", version) case "help", "-h", "--help": diff --git a/docs/standalone-server.md b/docs/standalone-server.md index d8721e3f..8af88ab1 100644 --- a/docs/standalone-server.md +++ b/docs/standalone-server.md @@ -61,6 +61,29 @@ cloudemu — standalone server Kubernetes http://127.0.0.1:4570 ``` +## Background mode (`start` / `stop` / `status`) + +`cloudemu serve` runs in the foreground. For a minikube-style "leave it running" +workflow, the lifecycle commands manage a detached background server: + +```sh +cloudemu start # launch in the background; prints the endpoints +cloudemu status # is it running? show pid + endpoints +cloudemu logs -f # follow the server log +cloudemu stop # graceful shutdown +cloudemu delete # stop and remove the run directory +``` + +`start` accepts every `serve` flag and passes it through, e.g. +`cloudemu start --providers aws --aws-port 4599`. It waits for the server to +become healthy before returning, and is idempotent (a second `start` reports the +already-running instance). + +Run state (pid, log, resolved endpoints) lives under `~/.cloudemu/` by default; +point it elsewhere with `--home ` (pass the same `--home` to the other +lifecycle commands). State is **not** yet persisted across `stop`/`start` — the +server still starts empty each time (see "Not yet included"). + ## Ports | Provider | Default | Protocol | Notes | From e3121e6e0b2b664ef6b9826229a8da916237527a Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Thu, 6 Aug 2026 19:06:02 +0530 Subject: [PATCH 2/4] =?UTF-8?q?fix(cli):=20address=20lifecycle-CLI=20revie?= =?UTF-8?q?w=20=E2=80=94=20orphan-daemon=20&=20portability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spawnServe: on a readiness failure OR a writeState failure, kill AND reap the child (new killChild) instead of a fire-and-forget SIGTERM, so a failed start never leaves an untracked detached daemon. - stop: escalate SIGTERM → SIGKILL after the timeout (via terminate) before removing state, so a signal-ignoring daemon can't be orphaned. - readState callers branch on os.ErrNotExist: a corrupt/unreadable state.json on a live daemon now errors instead of reporting "stopped" (which delete would otherwise wipe, orphaning it). - delete removes only cloudemu's own files (state/log/endpoints) + an empty-dir rmdir, never a blanket RemoveAll of a user-supplied --home. - PID-reuse guard: stop/start verify the recorded endpoints answer before signalling a live PID (portable identity check). - Windows: build-tag the daemon (//go:build unix) with a graceful non-Unix stub so `go install` no longer fails to compile off Unix. - readiness now probes every present endpoint; logs -f re-seeks on truncation. Adds unit tests for the pure helpers + killChild reaping + stop stale-state + start already-running (coverage 9% → 24%). Docs: Kubernetes protocol corrected to HTTPS. --- cmd/cloudemu/lifecycle.go | 173 ++++++++++++++++++++++++++------ cmd/cloudemu/lifecycle_other.go | 12 +++ cmd/cloudemu/lifecycle_test.go | 144 ++++++++++++++++++++++++++ cmd/cloudemu/main.go | 10 ++ docs/standalone-server.md | 6 +- 5 files changed, 310 insertions(+), 35 deletions(-) create mode 100644 cmd/cloudemu/lifecycle_other.go diff --git a/cmd/cloudemu/lifecycle.go b/cmd/cloudemu/lifecycle.go index 970b0096..09d41475 100644 --- a/cmd/cloudemu/lifecycle.go +++ b/cmd/cloudemu/lifecycle.go @@ -1,3 +1,5 @@ +//go:build unix + package main import ( @@ -29,12 +31,6 @@ const ( dirPerm = 0o755 filePerm = 0o600 - - cmdStart = "start" - cmdStop = "stop" - cmdStatus = "status" - cmdLogs = "logs" - cmdDelete = "delete" ) // Sentinel errors so callers (and err113) get static, wrappable failures. @@ -121,6 +117,65 @@ func processAlive(pid int) bool { return p.Signal(syscall.Signal(0)) == nil } +// terminate stops another process (not our child) gracefully (SIGTERM), then +// escalates to SIGKILL if it hasn't exited within stopTimeout. Used by `stop`, +// where the target daemon was started by a previous CLI invocation and has been +// reparented to init (so it's reaped on death and signal-0 reports it gone). +func terminate(p *os.Process) error { + _ = p.Signal(syscall.SIGTERM) + + if waitExit(p.Pid, stopTimeout) == nil { + return nil + } + + _ = p.Kill() + + return waitExit(p.Pid, stopTimeout) +} + +// killChild stops a process we started (our direct child), reaping it so it +// doesn't linger as a zombie. SIGTERM first, escalate to SIGKILL after +// stopTimeout. Reaping via Wait is what makes this safe on the spawnServe +// failure path, where the CLI is still the child's parent. +func killChild(cmd *exec.Cmd) { + _ = cmd.Process.Signal(syscall.SIGTERM) + + done := make(chan struct{}) + + go func() { + _, _ = cmd.Process.Wait() + + close(done) + }() + + select { + case <-done: + case <-time.After(stopTimeout): + _ = cmd.Process.Kill() + + <-done + } +} + +// daemonReachable reports whether the recorded endpoints answer — a portable +// identity check that guards against a recycled PID (a live but unrelated +// process) before we signal it. +func daemonReachable(eps map[string]string) bool { + if u := httpHealthURL(eps); u != "" { + return pollHealth(u, dialTimeout) == nil + } + + for _, k := range []string{"azure", "kubernetes"} { + if ep := eps[k]; ep != "" { + if hp, err := hostPortOf(ep); err == nil { + return pollTCP(hp, dialTimeout) == nil + } + } + } + + return false +} + // pollHealth polls an HTTP health URL until it returns 200 or timeout elapses. // It is used only for the plain-HTTP endpoints (AWS/GCP); the self-signed HTTPS // endpoints use pollTCP instead so no TLS verification has to be disabled. @@ -178,12 +233,20 @@ func pollTCP(hostPort string, timeout time.Duration) error { } } -// waitServerReady blocks until the server behind eps is accepting requests, -// using an HTTP health probe when a plain-HTTP endpoint exists and a TCP probe -// otherwise. +// waitServerReady blocks until every present endpoint is accepting requests: +// an HTTP health probe for the plain-HTTP endpoints (AWS/GCP) and a TCP probe +// for the self-signed HTTPS endpoints (Azure/Kubernetes). func waitServerReady(eps map[string]string, timeout time.Duration) error { - if u := httpHealthURL(eps); u != "" { - return pollHealth(u, timeout) + if len(eps) == 0 { + return errNoEndpoints + } + + for _, k := range []string{"aws", "gcp"} { + if ep := eps[k]; ep != "" { + if err := pollHealth(strings.TrimRight(ep, "/")+"/_cloudemu/health", timeout); err != nil { + return err + } + } } for _, k := range []string{"azure", "kubernetes"} { @@ -193,11 +256,13 @@ func waitServerReady(eps map[string]string, timeout time.Duration) error { return err } - return pollTCP(hp, timeout) + if err := pollTCP(hp, timeout); err != nil { + return err + } } } - return errNoEndpoints + return nil } // httpHealthURL returns a plain-HTTP /_cloudemu/health URL, or "" if none. @@ -309,7 +374,7 @@ func runStart(args []string) error { return err } - if s, rErr := readState(dir); rErr == nil && processAlive(s.PID) { + if s, rErr := readState(dir); rErr == nil && processAlive(s.PID) && daemonReachable(s.Endpoints) { fmt.Printf("cloudemu already running (pid %d)\n", s.PID) printEndpoints(s.Endpoints) @@ -364,7 +429,7 @@ func spawnServe(dir string, serveArgs []string, epPath string) (map[string]strin } if readyErr != nil { - _ = cmd.Process.Signal(syscall.SIGTERM) + killChild(cmd) // don't leave an untracked, detached child return nil, fmt.Errorf("cloudemu failed to start (see %s): %w", logPath(dir), readyErr) } @@ -376,7 +441,15 @@ func spawnServe(dir string, serveArgs []string, epPath string) (map[string]strin Args: serveArgs, } - return eps, writeState(dir, state) + // A persisted state is what makes the daemon findable by stop/status; if we + // can't record it, kill the child rather than orphan it. + if err := writeState(dir, state); err != nil { + killChild(cmd) + + return nil, fmt.Errorf("failed to persist daemon state (daemon killed): %w", err) + } + + return eps, nil } // runStop signals the running daemon to shut down and waits for it to exit. @@ -389,13 +462,19 @@ func runStop(args []string) error { } s, err := readState(dir) - if err != nil { + if errors.Is(err, os.ErrNotExist) { fmt.Println("cloudemu is not running") return nil } - if !processAlive(s.PID) { + if err != nil { + return fmt.Errorf("reading state (daemon may still be running): %w", err) + } + + // Treat a dead pid, or a live pid whose endpoints don't answer (PID reused + // by an unrelated process), as stale — clean up without signaling it. + if !processAlive(s.PID) || !daemonReachable(s.Endpoints) { _ = removeState(dir) fmt.Println("cloudemu is not running (cleaned up stale state)") @@ -408,12 +487,8 @@ func runStop(args []string) error { return err } - if err := proc.Signal(syscall.SIGTERM); err != nil { - return err - } - - if err := waitExit(s.PID, stopTimeout); err != nil { - return err + if err := terminate(proc); err != nil { + return fmt.Errorf("stopping pid %d: %w", s.PID, err) } _ = removeState(dir) @@ -448,7 +523,17 @@ func runStatus(args []string) error { } s, err := readState(dir) - if err != nil || !processAlive(s.PID) { + if errors.Is(err, os.ErrNotExist) { + fmt.Println("cloudemu: stopped") + + return nil + } + + if err != nil { + return fmt.Errorf("reading state (daemon may still be running): %w", err) + } + + if !processAlive(s.PID) { fmt.Println("cloudemu: stopped") return nil @@ -479,7 +564,8 @@ func runLogs(args []string) error { return tailLog(logPath(dir), follow) } -// runDelete stops the daemon (if running) and removes its run directory. +// runDelete stops the daemon (if running) and removes only cloudemu's own files +// from the run directory — never a blanket RemoveAll of a user-supplied --home. func runDelete(args []string) error { home, _ := splitHomeFlag(args) @@ -492,17 +578,23 @@ func runDelete(args []string) error { return err } - if err := os.RemoveAll(dir); err != nil { - return err + for _, p := range []string{statePath(dir), logPath(dir), endpointsPath(dir)} { + if rmErr := os.Remove(p); rmErr != nil && !os.IsNotExist(rmErr) { + return rmErr + } } - fmt.Printf("cloudemu: removed %s\n", dir) + // Remove the dir only if it's now empty (ignore "not empty" / "not exist"). + _ = os.Remove(dir) + + fmt.Printf("cloudemu: removed cloudemu files under %s\n", dir) return nil } // tailLog prints the log file. When follow is set it streams appended output -// until interrupted. +// until interrupted, re-seeking to the start if the log is truncated (e.g. a +// restart reopens it with O_TRUNC). func tailLog(path string, follow bool) error { f, err := os.Open(path) if err != nil { @@ -510,7 +602,8 @@ func tailLog(path string, follow bool) error { } defer f.Close() - if _, err := io.Copy(os.Stdout, f); err != nil { + offset, err := io.Copy(os.Stdout, f) + if err != nil { return err } @@ -521,9 +614,25 @@ func tailLog(path string, follow bool) error { for { time.Sleep(healthInterval) - if _, err := io.Copy(os.Stdout, f); err != nil { + fi, err := f.Stat() + if err != nil { + return err + } + + if fi.Size() < offset { + if _, seekErr := f.Seek(0, io.SeekStart); seekErr != nil { + return seekErr + } + + offset = 0 + } + + n, err := io.Copy(os.Stdout, f) + if err != nil { return err } + + offset += n } } diff --git a/cmd/cloudemu/lifecycle_other.go b/cmd/cloudemu/lifecycle_other.go new file mode 100644 index 00000000..8ec455b8 --- /dev/null +++ b/cmd/cloudemu/lifecycle_other.go @@ -0,0 +1,12 @@ +//go:build !unix + +package main + +import "errors" + +// runLifecycle is unavailable off Unix: the background daemon relies on +// session detachment (Setsid) and POSIX signals. Non-Unix users run the server +// in the foreground with `cloudemu serve`. +func runLifecycle(_ string, _ []string) error { + return errors.New("start/stop/status/logs/delete are only supported on Unix/macOS; run `cloudemu serve` instead") +} diff --git a/cmd/cloudemu/lifecycle_test.go b/cmd/cloudemu/lifecycle_test.go index 2eab1083..26d531c6 100644 --- a/cmd/cloudemu/lifecycle_test.go +++ b/cmd/cloudemu/lifecycle_test.go @@ -1,9 +1,13 @@ +//go:build unix + package main import ( + "net" "net/http" "net/http/httptest" "os" + "os/exec" "path/filepath" "testing" "time" @@ -116,3 +120,143 @@ func TestSplitHomeFlag(t *testing.T) { t.Fatalf("splitHomeFlag(absent) home=%q rest=%v", home, rest) } } + +func TestHTTPHealthURLAndHostPort(t *testing.T) { + if got := httpHealthURL(map[string]string{"aws": "http://127.0.0.1:4566"}); got != "http://127.0.0.1:4566/_cloudemu/health" { + t.Fatalf("httpHealthURL(aws) = %q", got) + } + if got := httpHealthURL(map[string]string{"azure": "https://127.0.0.1:4568"}); got != "" { + t.Fatalf("httpHealthURL(azure-only) = %q, want empty", got) + } + + hp, err := hostPortOf("https://127.0.0.1:4568") + if err != nil || hp != "127.0.0.1:4568" { + t.Fatalf("hostPortOf = %q, %v", hp, err) + } +} + +func TestReadEndpointsPrunesEmpty(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "eps.json") + if err := os.WriteFile(path, []byte(`{"aws":"http://x:1","azure":"","gcp":"http://y:2"}`), 0o600); err != nil { + t.Fatal(err) + } + + eps, err := readEndpoints(path) + if err != nil { + t.Fatalf("readEndpoints: %v", err) + } + if len(eps) != 2 || eps["aws"] == "" || eps["gcp"] == "" { + t.Fatalf("readEndpoints = %v, want aws+gcp only", eps) + } +} + +func TestWaitForEndpointsTimeout(t *testing.T) { + if _, err := waitForEndpoints(filepath.Join(t.TempDir(), "never.json"), 200*time.Millisecond); err == nil { + t.Fatal("waitForEndpoints(absent) = nil, want timeout") + } +} + +func TestWaitServerReady(t *testing.T) { + // HTTP branch: a healthy AWS endpoint. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })) + defer srv.Close() + if err := waitServerReady(map[string]string{"aws": srv.URL}, time.Second); err != nil { + t.Fatalf("waitServerReady(http) = %v", err) + } + + // TCP branch: a bare listener stands in for the self-signed HTTPS endpoint. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + if err := waitServerReady(map[string]string{"azure": "https://" + ln.Addr().String()}, time.Second); err != nil { + t.Fatalf("waitServerReady(tcp) = %v", err) + } + + // Empty set is an error. + if err := waitServerReady(map[string]string{}, time.Second); err == nil { + t.Fatal("waitServerReady(empty) = nil, want errNoEndpoints") + } +} + +func TestPollTCP(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + if err := pollTCP(ln.Addr().String(), time.Second); err != nil { + t.Fatalf("pollTCP(open) = %v", err) + } + if err := pollTCP("127.0.0.1:1", 200*time.Millisecond); err == nil { + t.Fatal("pollTCP(dead) = nil, want timeout") + } +} + +func TestWaitExit(t *testing.T) { + // A never-alive pid returns immediately. + if err := waitExit(-1, time.Second); err != nil { + t.Fatalf("waitExit(dead) = %v", err) + } + // The test process itself is alive → times out. + if err := waitExit(os.Getpid(), 200*time.Millisecond); err == nil { + t.Fatal("waitExit(self) = nil, want timeout") + } +} + +func TestKillChildReaps(t *testing.T) { + cmd := exec.Command("sleep", "30") + if err := cmd.Start(); err != nil { + t.Skipf("cannot start sleep: %v", err) + } + if !processAlive(cmd.Process.Pid) { + t.Fatal("child not alive after start") + } + + killChild(cmd) // SIGTERM + reap (Wait), no zombie left behind + + if processAlive(cmd.Process.Pid) { + t.Fatal("child still alive after killChild") + } +} + +func TestRunStopStaleState(t *testing.T) { + dir := t.TempDir() + // A state whose pid is not alive → runStop cleans it up, no signal sent. + if err := writeState(dir, daemonState{PID: -1, Endpoints: map[string]string{"aws": "http://127.0.0.1:1"}}); err != nil { + t.Fatal(err) + } + if err := runStop([]string{"--home", dir}); err != nil { + t.Fatalf("runStop(stale) = %v", err) + } + if _, err := readState(dir); err == nil { + t.Fatal("stale state.json should have been removed") + } +} + +func TestRunStartAlreadyRunning(t *testing.T) { + dir := t.TempDir() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })) + defer srv.Close() + + // State pointing at ourselves (alive) with a reachable endpoint → runStart + // must short-circuit without spawning a new server. + if err := writeState(dir, daemonState{PID: os.Getpid(), Endpoints: map[string]string{"aws": srv.URL}}); err != nil { + t.Fatal(err) + } + if err := runStart([]string{"--home", dir}); err != nil { + t.Fatalf("runStart(already-running) = %v", err) + } + s, err := readState(dir) + if err != nil || s.PID != os.Getpid() { + t.Fatalf("runStart overwrote state: %+v (%v)", s, err) + } +} + +func TestRunLifecycleUnknown(t *testing.T) { + if err := runLifecycle("bogus", nil); err == nil { + t.Fatal("runLifecycle(bogus) = nil, want error") + } +} diff --git a/cmd/cloudemu/main.go b/cmd/cloudemu/main.go index d1108b93..8b190a95 100644 --- a/cmd/cloudemu/main.go +++ b/cmd/cloudemu/main.go @@ -28,6 +28,16 @@ Lifecycle commands keep run state (pid, log, endpoints) under ~/.cloudemu by default; override with --home . Run "cloudemu serve -h" for serve flags. ` +// Lifecycle subcommand names. Defined here (not in the Unix-tagged +// lifecycle.go) so the dispatch compiles on every platform. +const ( + cmdStart = "start" + cmdStop = "stop" + cmdStatus = "status" + cmdLogs = "logs" + cmdDelete = "delete" +) + // version is overridable at build time with // -ldflags "-X main.version=vX.Y.Z". var version = "dev" diff --git a/docs/standalone-server.md b/docs/standalone-server.md index 8af88ab1..3a3bd7c0 100644 --- a/docs/standalone-server.md +++ b/docs/standalone-server.md @@ -58,7 +58,7 @@ cloudemu — standalone server AWS http://127.0.0.1:4566 Azure https://127.0.0.1:4568 (self-signed TLS) GCP http://127.0.0.1:4569 - Kubernetes http://127.0.0.1:4570 + Kubernetes https://127.0.0.1:4570 ``` ## Background mode (`start` / `stop` / `status`) @@ -91,7 +91,7 @@ server still starts empty each time (see "Not yet included"). | AWS | `4566` | HTTP | same port LocalStack uses | | Azure | `4568` | HTTPS | the ARM SDK requires TLS | | GCP | `4569` | HTTP | | -| Kubernetes | `4570` | HTTP | shared data-plane for EKS/AKS/GKE | +| Kubernetes | `4570` | HTTPS | shared data-plane for EKS/AKS/GKE | Override with `--aws-port`, `--azure-port`, `--gcp-port`, `--k8s-port`. Start a subset with `--providers=aws,gcp`. Bind an interface with `--host 0.0.0.0` (the @@ -179,7 +179,7 @@ app at the whole emulated cloud at once: "aws": "http://127.0.0.1:4566", "azure": "https://127.0.0.1:4568", "gcp": "http://127.0.0.1:4569", - "kubernetes": "http://127.0.0.1:4570" + "kubernetes": "https://127.0.0.1:4570" } ``` From 5d0dbcd90227ed6879af68df705c9266fcd3fe03 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Thu, 6 Aug 2026 19:21:17 +0530 Subject: [PATCH 3/4] fix(bedrock): close streaming connection to stop CI eventstream flake TestSDKConverseStreamMultibyteRuneBoundary intermittently failed in CI with "use of closed network connection". The runtime streaming handler already drains the request body, but a streamed (chunked) eventstream response left for keep-alive reuse can still be torn down abruptly as the handler returns, racing the client's in-flight read of the final events. Set `Connection: close` on the eventstream response (in newEventWriter, so both converse-stream and invoke-with-response-stream get it) so net/http ends the connection with a clean FIN after the last flush instead of attempting reuse. Unrelated to the lifecycle CLI in this PR, but surfaced by its pipeline run. --- server/aws/bedrock/streaming.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/server/aws/bedrock/streaming.go b/server/aws/bedrock/streaming.go index c2ede60e..55e44817 100644 --- a/server/aws/bedrock/streaming.go +++ b/server/aws/bedrock/streaming.go @@ -35,6 +35,13 @@ type eventWriter struct { // known so errors can still be reported via writeErr. func newEventWriter(w http.ResponseWriter) *eventWriter { w.Header().Set("Content-Type", contentTypeEventStream) + // Close the connection after the stream instead of returning it to the + // keep-alive pool: a streamed (chunked) response left for reuse can be torn + // down abruptly as the handler returns, racing the client's in-flight read + // of the last events — which surfaces intermittently (under CI load) as + // "use of closed network connection". An explicit close makes net/http end + // the connection with a clean FIN after the final flush. + w.Header().Set("Connection", "close") w.WriteHeader(http.StatusOK) flusher, _ := w.(http.Flusher) From 2237be62badd810d311b3a389e3caf2882d5cccf Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Thu, 6 Aug 2026 22:22:14 +0530 Subject: [PATCH 4/4] fix(cli): admin-independent readiness + guard managed serve flags (M1/L2) Readiness/reachability now use a plain TCP-accept probe for every provider instead of an HTTP /_cloudemu/health check. /health is served only when the admin control plane is mounted, so `cloudemu start --admin=false` used to boot a healthy server the probe never saw, time out, and kill it. serve binds every listener before writing the endpoints file, so TCP-accept is a sufficient and admin-independent readiness signal. Also strip user-supplied --endpoints-file/--quiet in `start`: start manages both, and Go's last-wins flag parsing let a forwarded --endpoints-file redirect serve's output away from the run dir, breaking the readiness handshake. --- cmd/cloudemu/lifecycle.go | 136 ++++++++++++++++----------------- cmd/cloudemu/lifecycle_test.go | 67 ++++++++-------- docs/standalone-server.md | 8 +- 3 files changed, 106 insertions(+), 105 deletions(-) diff --git a/cmd/cloudemu/lifecycle.go b/cmd/cloudemu/lifecycle.go index 09d41475..48d846f1 100644 --- a/cmd/cloudemu/lifecycle.go +++ b/cmd/cloudemu/lifecycle.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "net" - "net/http" "net/url" "os" "os/exec" @@ -40,6 +39,9 @@ var ( errUnknownCmd = errors.New("unknown lifecycle command") ) +// endpointOrder is the canonical provider order for probing and printing. +func endpointOrder() []string { return []string{"aws", "azure", "gcp", "kubernetes"} } + // daemonState is the run-dir record describing a running standalone server. type daemonState struct { PID int `json:"pid"` @@ -159,13 +161,11 @@ func killChild(cmd *exec.Cmd) { // daemonReachable reports whether the recorded endpoints answer — a portable // identity check that guards against a recycled PID (a live but unrelated -// process) before we signal it. +// process) before we signal it. It TCP-probes the first present endpoint: a +// plain accept works regardless of provider mix, TLS, or whether the admin +// control plane is mounted (which /_cloudemu/health depends on). func daemonReachable(eps map[string]string) bool { - if u := httpHealthURL(eps); u != "" { - return pollHealth(u, dialTimeout) == nil - } - - for _, k := range []string{"azure", "kubernetes"} { + for _, k := range endpointOrder() { if ep := eps[k]; ep != "" { if hp, err := hostPortOf(ep); err == nil { return pollTCP(hp, dialTimeout) == nil @@ -176,38 +176,10 @@ func daemonReachable(eps map[string]string) bool { return false } -// pollHealth polls an HTTP health URL until it returns 200 or timeout elapses. -// It is used only for the plain-HTTP endpoints (AWS/GCP); the self-signed HTTPS -// endpoints use pollTCP instead so no TLS verification has to be disabled. -func pollHealth(healthURL string, timeout time.Duration) error { - client := &http.Client{Timeout: 2 * time.Second} - deadline := time.Now().Add(timeout) - - for { - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, healthURL, http.NoBody) - if err != nil { - return err - } - - if resp, err := client.Do(req); err == nil { - _ = resp.Body.Close() - - if resp.StatusCode == http.StatusOK { - return nil - } - } - - if time.Now().After(deadline) { - return fmt.Errorf("waiting for %s to become healthy: %w", healthURL, errTimeout) - } - - time.Sleep(healthInterval) - } -} - // pollTCP polls until a TCP connection to hostPort succeeds or timeout elapses. -// It confirms a listener is accepting without needing TLS trust — used for the -// self-signed HTTPS endpoints (Azure/Kubernetes). +// It confirms a listener is accepting without needing TLS trust or the admin +// control plane — serve binds every listener before writing the endpoints file, +// so "accepts a connection" is a sufficient, admin-independent readiness signal. func pollTCP(hostPort string, timeout time.Duration) error { var dialer net.Dialer @@ -233,47 +205,37 @@ func pollTCP(hostPort string, timeout time.Duration) error { } } -// waitServerReady blocks until every present endpoint is accepting requests: -// an HTTP health probe for the plain-HTTP endpoints (AWS/GCP) and a TCP probe -// for the self-signed HTTPS endpoints (Azure/Kubernetes). +// waitServerReady blocks until every present endpoint is accepting TCP +// connections. A TCP-accept probe is used for all providers (not an HTTP +// /_cloudemu/health check) so readiness does not depend on the admin control +// plane being mounted — otherwise `start --admin=false` would boot a healthy +// server the probe could never see, time out, and kill it. func waitServerReady(eps map[string]string, timeout time.Duration) error { - if len(eps) == 0 { - return errNoEndpoints - } + present := 0 - for _, k := range []string{"aws", "gcp"} { - if ep := eps[k]; ep != "" { - if err := pollHealth(strings.TrimRight(ep, "/")+"/_cloudemu/health", timeout); err != nil { - return err - } + for _, k := range endpointOrder() { + ep := eps[k] + if ep == "" { + continue } - } - for _, k := range []string{"azure", "kubernetes"} { - if ep := eps[k]; ep != "" { - hp, err := hostPortOf(ep) - if err != nil { - return err - } + present++ - if err := pollTCP(hp, timeout); err != nil { - return err - } + hp, err := hostPortOf(ep) + if err != nil { + return err } - } - - return nil -} -// httpHealthURL returns a plain-HTTP /_cloudemu/health URL, or "" if none. -func httpHealthURL(eps map[string]string) string { - for _, k := range []string{"aws", "gcp"} { - if ep := eps[k]; ep != "" { - return strings.TrimRight(ep, "/") + "/_cloudemu/health" + if err := pollTCP(hp, timeout); err != nil { + return err } } - return "" + if present == 0 { + return errNoEndpoints + } + + return nil } // hostPortOf extracts host:port from an endpoint URL. @@ -312,6 +274,34 @@ func splitHomeFlag(args []string) (home string, rest []string) { return home, rest } +// stripFlag removes every occurrence of a flag (both --flag / -flag forms) from +// args. When takesValue is set, the following token is dropped too (unless the +// flag used --flag=value form). Used to drop serve flags that `start` manages +// itself (--endpoints-file, --quiet): forwarding a user-supplied one would win +// under Go's last-wins flag parsing and break start's readiness handshake. +func stripFlag(args []string, name string, takesValue bool) []string { + out := make([]string, 0, len(args)) + + for i := 0; i < len(args); i++ { + a := args[i] + if a == "--"+name || a == "-"+name { + if takesValue && i+1 < len(args) { + i++ + } + + continue + } + + if strings.HasPrefix(a, "--"+name+"=") || strings.HasPrefix(a, "-"+name+"=") { + continue + } + + out = append(out, a) + } + + return out +} + // readEndpoints loads and prunes the endpoints file serve writes on startup. func readEndpoints(path string) (map[string]string, error) { b, err := os.ReadFile(path) @@ -357,7 +347,7 @@ func printEndpoints(eps map[string]string) { fmt.Println("cloudemu — running") fmt.Println("──────────────────") - for _, k := range []string{"aws", "azure", "gcp", "kubernetes"} { + for _, k := range endpointOrder() { if ep := eps[k]; ep != "" { fmt.Printf(" %-11s %s\n", k, ep) } @@ -369,6 +359,12 @@ func printEndpoints(eps map[string]string) { func runStart(args []string) error { home, rest := splitHomeFlag(args) + // start owns --endpoints-file (it points serve at the run dir so the readiness + // handshake can find it) and always adds --quiet; drop any user-supplied copies + // so last-wins flag parsing can't redirect the file or duplicate the flag. + rest = stripFlag(rest, "endpoints-file", true) + rest = stripFlag(rest, "quiet", false) + dir, err := runDir(home) if err != nil { return err diff --git a/cmd/cloudemu/lifecycle_test.go b/cmd/cloudemu/lifecycle_test.go index 26d531c6..544648c7 100644 --- a/cmd/cloudemu/lifecycle_test.go +++ b/cmd/cloudemu/lifecycle_test.go @@ -80,24 +80,6 @@ func TestProcessAlive(t *testing.T) { } } -func TestPollHealthReady(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - if err := pollHealth(srv.URL, time.Second); err != nil { - t.Fatalf("pollHealth(ready) = %v", err) - } -} - -func TestPollHealthTimeout(t *testing.T) { - // Nothing listening on this address → should time out, not hang. - if err := pollHealth("http://127.0.0.1:1", 200*time.Millisecond); err == nil { - t.Fatal("pollHealth(dead) = nil, want timeout error") - } -} - func TestSplitHomeFlag(t *testing.T) { // --home is extracted; the rest passes through to serve untouched. home, rest := splitHomeFlag([]string{"--home", "/tmp/h", "--region", "eu-west-1", "--aws-port", "4600"}) @@ -121,20 +103,35 @@ func TestSplitHomeFlag(t *testing.T) { } } -func TestHTTPHealthURLAndHostPort(t *testing.T) { - if got := httpHealthURL(map[string]string{"aws": "http://127.0.0.1:4566"}); got != "http://127.0.0.1:4566/_cloudemu/health" { - t.Fatalf("httpHealthURL(aws) = %q", got) - } - if got := httpHealthURL(map[string]string{"azure": "https://127.0.0.1:4568"}); got != "" { - t.Fatalf("httpHealthURL(azure-only) = %q, want empty", got) - } - +func TestHostPortOf(t *testing.T) { hp, err := hostPortOf("https://127.0.0.1:4568") if err != nil || hp != "127.0.0.1:4568" { t.Fatalf("hostPortOf = %q, %v", hp, err) } } +func TestStripFlag(t *testing.T) { + // --endpoints-file (with value) and --quiet are dropped; everything else stays. + in := []string{"--endpoints-file", "/user/eps.json", "--region", "eu-west-1", "--quiet", "--aws-port", "4600"} + got := stripFlag(in, "endpoints-file", true) + got = stripFlag(got, "quiet", false) + + want := []string{"--region", "eu-west-1", "--aws-port", "4600"} + if len(got) != len(want) { + t.Fatalf("stripFlag = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("stripFlag = %v, want %v", got, want) + } + } + + // --endpoints-file=value form is dropped too. + if got := stripFlag([]string{"--endpoints-file=/x", "--region", "us-east-1"}, "endpoints-file", true); len(got) != 2 || got[0] != "--region" { + t.Fatalf("stripFlag(=form) = %v", got) + } +} + func TestReadEndpointsPrunesEmpty(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "eps.json") @@ -158,14 +155,20 @@ func TestWaitForEndpointsTimeout(t *testing.T) { } func TestWaitServerReady(t *testing.T) { - // HTTP branch: a healthy AWS endpoint. - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })) - defer srv.Close() - if err := waitServerReady(map[string]string{"aws": srv.URL}, time.Second); err != nil { - t.Fatalf("waitServerReady(http) = %v", err) + // M1 regression guard: readiness is a plain TCP-accept probe, so an AWS + // endpoint that is NOT serving /_cloudemu/health (e.g. `serve --admin=false`, + // modeled here by a bare listener with no HTTP handler) is still seen as + // ready — it must not time out and get the healthy server killed. + awsLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer awsLn.Close() + if err := waitServerReady(map[string]string{"aws": "http://" + awsLn.Addr().String()}, time.Second); err != nil { + t.Fatalf("waitServerReady(aws, no admin plane) = %v", err) } - // TCP branch: a bare listener stands in for the self-signed HTTPS endpoint. + // A self-signed HTTPS endpoint is probed the same way (TCP accept). ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) diff --git a/docs/standalone-server.md b/docs/standalone-server.md index 3a3bd7c0..eacc26ed 100644 --- a/docs/standalone-server.md +++ b/docs/standalone-server.md @@ -75,9 +75,11 @@ cloudemu delete # stop and remove the run directory ``` `start` accepts every `serve` flag and passes it through, e.g. -`cloudemu start --providers aws --aws-port 4599`. It waits for the server to -become healthy before returning, and is idempotent (a second `start` reports the -already-running instance). +`cloudemu start --providers aws --aws-port 4599`. It waits for every listener to +start accepting connections before returning (a TCP-accept probe, so it also +works with `--admin=false`), and is idempotent (a second `start` reports the +already-running instance). `--endpoints-file` and `--quiet` are managed by +`start` itself, so passing your own copies has no effect. Run state (pid, log, resolved endpoints) lives under `~/.cloudemu/` by default; point it elsewhere with `--home ` (pass the same `--home` to the other