diff --git a/cmd/cloudemu/lifecycle.go b/cmd/cloudemu/lifecycle.go new file mode 100644 index 00000000..48d846f1 --- /dev/null +++ b/cmd/cloudemu/lifecycle.go @@ -0,0 +1,651 @@ +//go:build unix + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "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 +) + +// 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") +) + +// 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"` + 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 +} + +// 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. 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 { + for _, k := range endpointOrder() { + if ep := eps[k]; ep != "" { + if hp, err := hostPortOf(ep); err == nil { + return pollTCP(hp, dialTimeout) == nil + } + } + } + + return false +} + +// pollTCP polls until a TCP connection to hostPort succeeds or timeout elapses. +// 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 + + 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 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 { + present := 0 + + for _, k := range endpointOrder() { + ep := eps[k] + if ep == "" { + continue + } + + present++ + + hp, err := hostPortOf(ep) + if err != nil { + return err + } + + if err := pollTCP(hp, timeout); err != nil { + return err + } + } + + if present == 0 { + return errNoEndpoints + } + + return nil +} + +// 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 +} + +// 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) + 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 endpointOrder() { + 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) + + // 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 + } + + 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) + + 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 { + killChild(cmd) // don't leave an untracked, detached child + + 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, + } + + // 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. +func runStop(args []string) error { + home, _ := splitHomeFlag(args) + + dir, err := runDir(home) + if err != nil { + return err + } + + s, err := readState(dir) + if errors.Is(err, os.ErrNotExist) { + fmt.Println("cloudemu is not running") + + return nil + } + + 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)") + + return nil + } + + proc, err := os.FindProcess(s.PID) + if err != nil { + return err + } + + if err := terminate(proc); err != nil { + return fmt.Errorf("stopping pid %d: %w", s.PID, 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 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 + } + + 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 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) + + if err := runStop(args); err != nil { + return err + } + + dir, err := runDir(home) + if 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 + } + } + + // 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, 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 { + return fmt.Errorf("no log at %s: %w", path, err) + } + defer f.Close() + + offset, err := io.Copy(os.Stdout, f) + if err != nil { + return err + } + + if !follow { + return nil + } + + for { + time.Sleep(healthInterval) + + 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 + } +} + +// 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_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 new file mode 100644 index 00000000..544648c7 --- /dev/null +++ b/cmd/cloudemu/lifecycle_test.go @@ -0,0 +1,265 @@ +//go:build unix + +package main + +import ( + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "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 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) + } +} + +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") + 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) { + // 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) + } + + // 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) + } + 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 c0df85e3..8b190a95 100644 --- a/cmd/cloudemu/main.go +++ b/cmd/cloudemu/main.go @@ -15,13 +15,29 @@ 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. ` +// 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" @@ -38,6 +54,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..eacc26ed 100644 --- a/docs/standalone-server.md +++ b/docs/standalone-server.md @@ -58,9 +58,34 @@ 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`) + +`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 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 +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 | @@ -68,7 +93,7 @@ cloudemu — standalone server | 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 @@ -156,7 +181,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" } ``` 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)