diff --git a/README.md b/README.md index 7ff8f5e..0bb9e85 100644 --- a/README.md +++ b/README.md @@ -84,11 +84,11 @@ Each command has full help via `imposter --help`. | Command | What it does | | --- | --- | -| `imposter up [DIR]` | Start a live mock from Imposter config in `DIR` (defaults to current directory). Add `-s` to scaffold first. | +| `imposter up [DIR]` | Start a live mock from Imposter config in `DIR` (defaults to current directory). Add `-s` to scaffold first, or `-d` to background it. | | `imposter scaffold [DIR]` | Generate Imposter config from any OpenAPI/Swagger or WSDL files in `DIR`. | | `imposter proxy URL` | Forward traffic to `URL` and record each exchange to disk as a replayable mock. Add `--insecure` to skip TLS verification. | -| `imposter down` | Stop running mocks for the current engine type. Add `-a` to stop them all. | -| `imposter list` | List running mocks and their health. `-qx` makes a tidy healthcheck. | +| `imposter down ID` | Stop the mock with the given ID (see `imposter ls`). `-a` / `--all` stops every managed mock across all engine types. | +| `imposter list` | List running mocks and their health across all engine types. `-t` filters by engine type; `-qx` makes a tidy healthcheck. | | `imposter bundle [DIR]` | Bundle config and engine into a Docker image or Lambda zip. | | `imposter doctor` | Check that you have at least one engine ready to run. | | `imposter engine pull` / `engine list` | Manage cached engine binaries and images. | diff --git a/cmd/down.go b/cmd/down.go index d300b81..e75df27 100644 --- a/cmd/down.go +++ b/cmd/down.go @@ -17,36 +17,45 @@ limitations under the License. package cmd import ( - "github.com/imposter-project/imposter-cli/internal/engine" - "github.com/spf13/cobra" + "fmt" "os" "path/filepath" + "strings" + + "github.com/imposter-project/imposter-cli/internal/engine" + "github.com/spf13/cobra" ) var downFlags = struct { - engineType string - all bool + all bool }{} // downCmd represents the down command var downCmd = &cobra.Command{ - Use: "down", - Short: "Stop running mocks", - Long: `Stops running Imposter mocks for the current engine type.`, + Use: "down [ID]", + Short: "Stop a running mock by ID, or all mocks with --all", + Long: `Stops a single running Imposter mock identified by ID, or all +managed mocks across every engine type with --all. + +Use 'imposter ls' to discover the IDs of running mocks.`, + Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { if downFlags.all { + if len(args) > 0 { + logger.Fatal("cannot specify both --all and a mock ID") + } stopAllEngines() - } else { - stopAll(engine.GetConfiguredType(downFlags.engineType)) + return + } + if len(args) == 0 { + logger.Fatal("a mock ID is required (or use --all to stop all mocks); see 'imposter ls' for IDs") } + stopMockByID(args[0]) }, } func init() { - downCmd.Flags().StringVarP(&downFlags.engineType, "engine-type", "t", "", "Imposter engine type (valid: docker,native,jvm - default \"docker\")") - downCmd.Flags().BoolVarP(&downFlags.all, "all", "a", false, "Stop mocks for all engine types") - downCmd.MarkFlagsMutuallyExclusive("engine-type", "all") - registerEngineTypeCompletions(downCmd) + downCmd.Flags().BoolVarP(&downFlags.all, "all", "a", false, "Stop all managed mocks across all engine types") rootCmd.AddCommand(downCmd) } @@ -76,17 +85,34 @@ func stopAllEngines() { } } -func stopAll(engineType engine.EngineType) { - logger.Info("stopping all managed mocks...") - stopped, err := stopEngine(engineType) - if err != nil { - logger.Fatalf("failed to stop mocks: %s", err) +// stopMockByID searches every engine type for a managed mock with the +// given ID and stops it. +func stopMockByID(id string) { + var engineErrors []string + for _, engineType := range allEngineTypes { + var stopped bool + var stopErr error + err := runWithRecovery(func() { + mockEngine := engine.BuildEngine(engineType, filepath.Join(os.TempDir(), "imposter-down"), engine.StartOptions{}) + stopped, stopErr = mockEngine.StopManaged(id) + }) + if err != nil { + engineErrors = append(engineErrors, fmt.Sprintf("%s: %v", engineType, err)) + continue + } + if stopErr != nil { + engineErrors = append(engineErrors, fmt.Sprintf("%s: %v", engineType, stopErr)) + continue + } + if stopped { + logger.Infof("stopped mock %s (%s engine)", id, engineType) + return + } } - if stopped > 0 { - logger.Infof("stopped %d managed mock(s)", stopped) - } else { - logger.Info("no managed mocks were found") + if len(engineErrors) == len(allEngineTypes) { + logger.Fatalf("failed to query any engine: %s", strings.Join(engineErrors, "; ")) } + logger.Fatalf("no managed mock found with ID %q (run 'imposter ls' to see running mocks)", id) } func stopEngine(engineType engine.EngineType) (int, error) { diff --git a/cmd/down_test.go b/cmd/down_test.go index a3204cd..a32a37d 100644 --- a/cmd/down_test.go +++ b/cmd/down_test.go @@ -49,8 +49,26 @@ func Test_stopEngine(t *testing.T) { } } -func Test_downCmd_mutual_exclusivity(t *testing.T) { - rootCmd.SetArgs([]string{"down", "-a", "-t", "docker"}) - err := rootCmd.Execute() - require.Error(t, err, "should reject --all with --engine-type") +func Test_downCmd_requires_id_or_all(t *testing.T) { + // bare `imposter down` is fatal — capture via runWithRecovery + rootCmd.SetArgs([]string{"down"}) + err := runWithRecovery(func() { + _ = rootCmd.Execute() + }) + require.Error(t, err, "should fail without an ID or --all") +} + +func Test_downCmd_rejects_id_with_all(t *testing.T) { + rootCmd.SetArgs([]string{"down", "--all", "abc123"}) + err := runWithRecovery(func() { + _ = rootCmd.Execute() + }) + require.Error(t, err, "should reject ID together with --all") +} + +func Test_stopMockByID_unknown(t *testing.T) { + err := runWithRecovery(func() { + stopMockByID("definitely-not-a-real-mock-id") + }) + require.Error(t, err, "should fail when no engine has a mock with the given id") } diff --git a/cmd/list.go b/cmd/list.go index fe7c76e..4609d3d 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -27,7 +27,6 @@ import ( var listFlags = struct { engineType string - all bool healthExitCode bool quiet bool }{} @@ -37,23 +36,23 @@ var listCmd = &cobra.Command{ Use: "list", Aliases: []string{"ls"}, Short: "List running mocks", - Long: `Lists running Imposter mocks for the current engine type -and reports their health.`, + Long: `Lists running Imposter mocks and reports their health. + +By default, mocks across all engine types are listed. Use --engine-type / -t +to filter to a single engine type.`, Run: func(cmd *cobra.Command, args []string) { - if listFlags.all { - listAllMocks(listFlags.quiet) - } else { + if listFlags.engineType != "" { listMocks(engine.GetConfiguredType(listFlags.engineType), listFlags.quiet, false) + } else { + listAllMocks(listFlags.quiet) } }, } func init() { - listCmd.Flags().StringVarP(&listFlags.engineType, "engine-type", "t", "", "Imposter engine type (valid: docker,native,jvm - default \"docker\")") - listCmd.Flags().BoolVarP(&listFlags.all, "all", "a", false, "List mocks for all engine types") + listCmd.Flags().StringVarP(&listFlags.engineType, "engine-type", "t", "", "Filter mocks to this engine type (valid: docker,native,jvm)") listCmd.Flags().BoolVarP(&listFlags.healthExitCode, "exit-code-health", "x", false, "Set exit code based on mock health") listCmd.Flags().BoolVarP(&listFlags.quiet, "quiet", "q", false, "Quieten output; only print ID") - listCmd.MarkFlagsMutuallyExclusive("engine-type", "all") registerEngineTypeCompletions(listCmd) rootCmd.AddCommand(listCmd) } diff --git a/cmd/list_test.go b/cmd/list_test.go index 10a7b98..b23594f 100644 --- a/cmd/list_test.go +++ b/cmd/list_test.go @@ -146,8 +146,8 @@ func Test_listMocksForEngine(t *testing.T) { } } -func Test_listCmd_mutual_exclusivity(t *testing.T) { - rootCmd.SetArgs([]string{"list", "-a", "-t", "docker"}) +func Test_listCmd_rejects_unknown_flag(t *testing.T) { + rootCmd.SetArgs([]string{"list", "--all"}) err := rootCmd.Execute() - require.Error(t, err, "should reject --all with --engine-type") + require.Error(t, err, "--all is no longer a flag; listing across engines is the default") } diff --git a/cmd/up.go b/cmd/up.go index 380be2a..4dcf60a 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -48,6 +48,8 @@ var upFlags = struct { dirMounts []string recursiveConfigScan bool debugMode bool + detach string + logFile string }{} // upCmd represents the up command @@ -111,10 +113,49 @@ If CONFIG_DIR is not specified, the current working directory is used.`, DirMounts: upFlags.dirMounts, DebugMode: upFlags.debugMode, } - start(&lib, startOptions, configDir, upFlags.restartOnChange) + restartOnChange := applyDetachOptions(&startOptions, engineType, upFlags.detach, upFlags.logFile, upFlags.restartOnChange) + + start(&lib, startOptions, configDir, restartOnChange) }, } +// applyDetachOptions resolves the detach-related flags onto startOptions +// and returns the (possibly disabled) auto-restart setting. Auto-restart +// is incompatible with detaching because the config-dir watcher lives in +// the foreground CLI process, which exits once the mock is backgrounded. +func applyDetachOptions(startOptions *engine.StartOptions, engineType engine.EngineType, detach string, logFile string, restartOnChange bool) bool { + switch detach { + case "": + return restartOnChange + case "healthy": + startOptions.Detach = engine.DetachHealthy + case "now": + startOptions.Detach = engine.DetachNow + default: + logger.Fatalf("invalid --detach mode %q (valid: healthy, now)", detach) + } + + if restartOnChange { + logger.Warn("--auto-restart is not supported with --detach; auto-restart disabled") + restartOnChange = false + } + + if engine.IsDockerEngine(engineType) { + if logFile != "" { + logger.Warn("--log-file is ignored for the docker engine; use 'docker logs' instead") + } + } else if logFile != "" { + startOptions.DetachLog, _ = filepath.Abs(logFile) + } else { + detachLog, err := engine.DefaultDetachLogPath(startOptions.Port) + if err != nil { + logger.Fatal(err) + } + startOptions.DetachLog = detachLog + } + return restartOnChange +} + func init() { upCmd.Flags().StringVarP(&upFlags.engineType, "engine-type", "t", "", "Imposter engine type (valid: docker,native,jvm - default \"docker\")") upCmd.Flags().StringVarP(&upFlags.engineVersion, "version", "v", "", "Imposter engine version (default \"latest\")") @@ -130,6 +171,9 @@ func init() { upCmd.Flags().StringArrayVar(&upFlags.dirMounts, "mount-dir", []string{}, "(Docker engine type only) Extra directory bind-mounts in the form HOST_PATH:CONTAINER_PATH (e.g. $HOME/somedir:/opt/imposter/somedir) or simply HOST_PATH, which will mount the directory at /opt/imposter/") upCmd.Flags().BoolVarP(&upFlags.recursiveConfigScan, "recursive-config-scan", "r", false, "Scan for config files in subdirectories") upCmd.Flags().BoolVar(&upFlags.debugMode, "debug-mode", false, fmt.Sprintf("Enable JVM debug mode and listen on port %v", engine.DefaultDebugPort)) + upCmd.Flags().StringVarP(&upFlags.detach, "detach", "d", "", "Run the mock in the background and return control to the terminal. Optional mode: 'healthy' (default, wait for the healthcheck before detaching) or 'now' (detach immediately)") + upCmd.Flags().Lookup("detach").NoOptDefVal = "healthy" + upCmd.Flags().StringVar(&upFlags.logFile, "log-file", "", "(Process engine types only) File to write detached mock logs to (default ~/.imposter/logs/imposter-.log)") registerEngineTypeCompletions(upCmd) rootCmd.AddCommand(upCmd) } @@ -172,6 +216,23 @@ func start(lib *engine.EngineLibrary, startOptions engine.StartOptions, configDi mockEngine := provider.Build(configDir, startOptions) wg := &sync.WaitGroup{} + + if startOptions.IsDetached() { + // DetachHealthy still traps Ctrl+C so an abort during the + // healthcheck wait stops the mock; DetachNow returns immediately + // so there is nothing to interrupt. + if startOptions.Detach == engine.DetachHealthy { + trapExit(mockEngine, wg) + } + if !mockEngine.Start(wg) { + // healthcheck timeout already calls logger.Fatalf; reaching + // here means the wait was aborted (e.g. Ctrl+C) + return + } + printDetachSummary(mockEngine, startOptions) + return + } + trapExit(mockEngine, wg) success := mockEngine.Start(wg) @@ -190,6 +251,14 @@ func start(lib *engine.EngineLibrary, startOptions engine.StartOptions, configDi logger.Debug("shutting down") } +func printDetachSummary(mockEngine engine.MockEngine, startOptions engine.StartOptions) { + logger.Infof("mock running in the background (id: %s, port: %d)", mockEngine.GetID(), startOptions.Port) + if startOptions.DetachLog != "" { + logger.Infof("logs: %s", startOptions.DetachLog) + } + logger.Info("use 'imposter ls' to list running mocks, or 'imposter down' to stop them") +} + // listen for an interrupt from the OS, then attempt engine cleanup func trapExit(mockEngine engine.MockEngine, wg *sync.WaitGroup) { c := make(chan os.Signal) diff --git a/cmd/up_test.go b/cmd/up_test.go new file mode 100644 index 0000000..4899fc5 --- /dev/null +++ b/cmd/up_test.go @@ -0,0 +1,75 @@ +/* +Copyright © 2021 Pete Cornish + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "path/filepath" + "testing" + + "github.com/imposter-project/imposter-cli/internal/config" + "github.com/imposter-project/imposter-cli/internal/engine" + "github.com/stretchr/testify/assert" +) + +func Test_applyDetachOptions(t *testing.T) { + tmpHome := t.TempDir() + config.DirPath = tmpHome + defer func() { config.DirPath = "" }() + + t.Run("no detach leaves foreground mode and keeps auto-restart", func(t *testing.T) { + opts := engine.StartOptions{Port: 8080} + restart := applyDetachOptions(&opts, engine.EngineTypeJvmSingleJar, "", "", true) + assert.Equal(t, engine.DetachNone, opts.Detach) + assert.False(t, opts.IsDetached()) + assert.True(t, restart) + assert.Empty(t, opts.DetachLog) + }) + + t.Run("detach=healthy waits for the healthcheck", func(t *testing.T) { + opts := engine.StartOptions{Port: 8080} + restart := applyDetachOptions(&opts, engine.EngineTypeJvmSingleJar, "healthy", "", true) + assert.Equal(t, engine.DetachHealthy, opts.Detach) + assert.False(t, restart, "auto-restart must be disabled when detached") + }) + + t.Run("detach=now returns immediately", func(t *testing.T) { + opts := engine.StartOptions{Port: 8080} + applyDetachOptions(&opts, engine.EngineTypeNative, "now", "", false) + assert.Equal(t, engine.DetachNow, opts.Detach) + }) + + t.Run("process engine resolves default log path", func(t *testing.T) { + opts := engine.StartOptions{Port: 1234} + applyDetachOptions(&opts, engine.EngineTypeJvmSingleJar, "healthy", "", false) + expected := filepath.Join(tmpHome, "logs", "imposter-1234.log") + assert.Equal(t, expected, opts.DetachLog) + }) + + t.Run("explicit log file is honoured and made absolute", func(t *testing.T) { + opts := engine.StartOptions{Port: 8080} + applyDetachOptions(&opts, engine.EngineTypeNative, "healthy", "relative/mock.log", false) + assert.True(t, filepath.IsAbs(opts.DetachLog)) + assert.Equal(t, "mock.log", filepath.Base(opts.DetachLog)) + }) + + t.Run("docker engine does not set a detach log", func(t *testing.T) { + opts := engine.StartOptions{Port: 8080} + applyDetachOptions(&opts, engine.EngineTypeDockerCore, "healthy", "", false) + assert.Equal(t, engine.DetachHealthy, opts.Detach) + assert.Empty(t, opts.DetachLog) + }) +} diff --git a/go.mod b/go.mod index 331dccb..4fb026e 100644 --- a/go.mod +++ b/go.mod @@ -119,7 +119,7 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/crypto v0.49.0 // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/sys v0.42.0 golang.org/x/text v0.35.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/engine/api.go b/internal/engine/api.go index c0d7f14..75741d9 100644 --- a/internal/engine/api.go +++ b/internal/engine/api.go @@ -16,7 +16,27 @@ limitations under the License. package engine -import "sync" +import ( + "fmt" + "path/filepath" + "sync" + + "github.com/imposter-project/imposter-cli/internal/config" +) + +// DetachMode controls whether and how `up` backgrounds the mock. +type DetachMode int + +const ( + // DetachNone runs the mock in the foreground (default behaviour). + DetachNone DetachMode = iota + // DetachNow starts the mock and returns immediately without waiting + // for it to become healthy. + DetachNow + // DetachHealthy starts the mock, waits for the healthcheck to pass, + // then returns control to the caller. + DetachHealthy +) type StartOptions struct { Port int @@ -30,6 +50,25 @@ type StartOptions struct { Environment []string DirMounts []string DebugMode bool + Detach DetachMode + // DetachLog is the resolved absolute path that a detached process + // engine writes stdout/stderr to. Unused by the docker engine. + DetachLog string +} + +// IsDetached reports whether the mock should be run in the background. +func (o StartOptions) IsDetached() bool { + return o.Detach != DetachNone +} + +// DefaultDetachLogPath returns the default log file path for a detached +// process-engine mock listening on the given port. +func DefaultDetachLogPath(port int) (string, error) { + globalDir, err := config.GetGlobalConfigDir() + if err != nil { + return "", err + } + return filepath.Join(globalDir, "logs", fmt.Sprintf("imposter-%d.log", port)), nil } type EnvOptions struct { @@ -52,7 +91,21 @@ type MockEngine interface { Restart(wg *sync.WaitGroup) ListAllManaged() ([]ManagedMock, error) StopAllManaged() int + + // StopManaged stops the single managed mock identified by id (the same + // value reported in ManagedMock.ID, i.e. the short container ID for + // docker or the PID for process engines). Returns (true, nil) if the + // mock was found and stopped; (false, nil) if no managed mock with + // that id exists in this engine; (false, err) if the engine could + // not be queried. + StopManaged(id string) (bool, error) + GetVersionString() (string, error) + + // GetID returns an identifier for the running mock: the container ID + // for the docker engine, or the process PID for process engines. + // Returns an empty string if the mock has not been started. + GetID() string } type EngineMetadata struct { diff --git a/internal/engine/builder.go b/internal/engine/builder.go index b8faf54..df727c6 100644 --- a/internal/engine/builder.go +++ b/internal/engine/builder.go @@ -123,6 +123,16 @@ func validateEngineType(engineType EngineType) error { return fmt.Errorf("unsupported engine type: %v", engineType) } +// IsDockerEngine reports whether the engine type is one of the +// container-based docker variants. +func IsDockerEngine(engineType EngineType) bool { + switch engineType { + case EngineTypeDockerCore, EngineTypeDockerAll, EngineTypeDockerDistroless: + return true + } + return false +} + func GetConfiguredType(override string) EngineType { return GetConfiguredTypeWithDefault(override, defaultEngineType) } diff --git a/internal/engine/detach_test.go b/internal/engine/detach_test.go new file mode 100644 index 0000000..593c61b --- /dev/null +++ b/internal/engine/detach_test.go @@ -0,0 +1,26 @@ +package engine + +import ( + "path/filepath" + "testing" + + "github.com/imposter-project/imposter-cli/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_DefaultDetachLogPath(t *testing.T) { + tmpHome := t.TempDir() + config.DirPath = tmpHome + defer func() { config.DirPath = "" }() + + path, err := DefaultDetachLogPath(8081) + require.NoError(t, err) + assert.Equal(t, filepath.Join(tmpHome, "logs", "imposter-8081.log"), path) +} + +func Test_StartOptions_IsDetached(t *testing.T) { + assert.False(t, StartOptions{Detach: DetachNone}.IsDetached()) + assert.True(t, StartOptions{Detach: DetachNow}.IsDetached()) + assert.True(t, StartOptions{Detach: DetachHealthy}.IsDetached()) +} diff --git a/internal/engine/docker/engine.go b/internal/engine/docker/engine.go index 0274a7c..3f4b44c 100644 --- a/internal/engine/docker/engine.go +++ b/internal/engine/docker/engine.go @@ -99,15 +99,33 @@ func (d *DockerMockEngine) startWithOptions(wg *sync.WaitGroup, options engine.S logger.Trace("starting Docker mock engine") d.containerId = containerId - if err = streamLogsToStdIo(cli, ctx, containerId); err != nil { - logger.Warn(err) - } - up := engine.WaitUntilUp(options.Port, d.shutDownC) - // watch in case container stops - go notifyOnStopBlocking(d, wg, containerId, cli, ctx) + switch options.Detach { + case engine.DetachNow: + // container runs in dockerd independently of the CLI + return true + case engine.DetachHealthy: + // wait for health but don't stream logs or reap - the container + // keeps running in dockerd after the CLI exits + return engine.WaitUntilUp(options.Port, d.shutDownC) + default: + if err = streamLogsToStdIo(cli, ctx, containerId); err != nil { + logger.Warn(err) + } + up := engine.WaitUntilUp(options.Port, d.shutDownC) + + // watch in case container stops + go notifyOnStopBlocking(d, wg, containerId, cli, ctx) + + return up + } +} - return up +func (d *DockerMockEngine) GetID() string { + if len(d.containerId) > 12 { + return d.containerId[:12] + } + return d.containerId } func buildPorts(options engine.StartOptions) (nat.PortSet, nat.PortMap) { @@ -325,6 +343,25 @@ func (d *DockerMockEngine) ListAllManaged() ([]engine.ManagedMock, error) { return containers, nil } +func (d *DockerMockEngine) StopManaged(id string) (bool, error) { + ctx, cli, err := buildCliClient() + if err != nil { + return false, err + } + info, err := cli.ContainerInspect(ctx, id) + if err != nil { + if client.IsErrNotFound(err) { + return false, nil + } + return false, err + } + if info.Config == nil || info.Config.Labels[labelKeyManaged] != "true" { + return false, nil + } + removeContainers(d, []string{info.ID}) + return true, nil +} + func (d *DockerMockEngine) StopAllManaged() int { cli, ctx, err := buildCliClient() if err != nil { diff --git a/internal/engine/enginetests/common.go b/internal/engine/enginetests/common.go index 88258f9..32419fc 100644 --- a/internal/engine/enginetests/common.go +++ b/internal/engine/enginetests/common.go @@ -23,8 +23,10 @@ import ( "io" "net" "net/http" + "os" "sync" "testing" + "time" ) type EngineTestFields struct { @@ -127,6 +129,67 @@ func List(t *testing.T, tests []EngineTestScenario, builder func(scenario Engine } } +// StartDetached verifies the detach flow for process engines: Start +// returns once healthy without the harness ever calling wg.Wait() (the +// CLI exits in real usage), the mock keeps serving, its log file is +// written, and it remains discoverable/stoppable via the managed-process +// helpers. +func StartDetached(t *testing.T, tests []EngineTestScenario, builder func(scenario EngineTestScenario) engine.MockEngine) { + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + wg := &sync.WaitGroup{} + mockEngine := builder(tt) + + success := mockEngine.Start(wg) + if !success { + t.Fatalf("detached engine did not become healthy") + } + + stopped := false + defer func() { + if !stopped { + mockEngine.StopAllManaged() + } + }() + + // deliberately do NOT call wg.Wait() - in detach mode the CLI + // returns immediately and the OS reparents the child + checkUp(t, tt.Fields.Options.Port) + + require.NotEmpty(t, mockEngine.GetID(), "detached mock should expose an id") + + info, err := os.Stat(tt.Fields.Options.DetachLog) + require.NoErrorf(t, err, "detach log %s should exist", tt.Fields.Options.DetachLog) + require.NotZerof(t, info.Size(), "detach log %s should be non-empty", tt.Fields.Options.DetachLog) + + mocks, err := mockEngine.ListAllManaged() + require.NoError(t, err, "failed to list managed mocks") + // Don't assert exact count — shared CI runners can have other + // imposter processes the matchers also pick up. We only care + // that the mock we just started is in the list. + require.True(t, containsMockOnPort(mocks, tt.Fields.Options.Port), + "expected detached mock on port %d to be in the managed list (got %d mocks)", + tt.Fields.Options.Port, len(mocks)) + + require.Positive(t, mockEngine.StopAllManaged(), "expected StopAllManaged to stop at least the detached mock") + stopped = true + + require.Eventually(t, func() bool { + return engine.CheckMockStatus(tt.Fields.Options.Port) != nil + }, 10*time.Second, 200*time.Millisecond, "mock should stop serving after StopAllManaged") + }) + } +} + +func containsMockOnPort(mocks []engine.ManagedMock, port int) bool { + for _, m := range mocks { + if m.Port == port { + return true + } + } + return false +} + func GetFreePort() int { if addr, err := net.ResolveTCPAddr("tcp", "localhost:0"); err == nil { var l *net.TCPListener diff --git a/internal/engine/jvm/engine.go b/internal/engine/jvm/engine.go index 7619b49..3bc96dd 100644 --- a/internal/engine/jvm/engine.go +++ b/internal/engine/jvm/engine.go @@ -48,8 +48,18 @@ func (j *JvmMockEngine) startWithOptions(wg *sync.WaitGroup, options engine.Star } env := buildEnv(options) command := (*j.provider).GetStartCommand(args, env) - command.Stdout = os.Stdout - command.Stderr = os.Stderr + if options.IsDetached() { + f, err := procutil.OpenDetachLog(options.DetachLog) + if err != nil { + logger.Fatal(err) + } + command.Stdout = f + command.Stderr = f + command.SysProcAttr = procutil.DetachSysProcAttr() + } else { + command.Stdout = os.Stdout + command.Stderr = os.Stderr + } err := command.Start() if err != nil { logger.Fatalf("failed to exec: %v %v: %v", command.Path, command.Args, err) @@ -58,12 +68,28 @@ func (j *JvmMockEngine) startWithOptions(wg *sync.WaitGroup, options engine.Star logger.Trace("starting JVM mock engine") j.command = command - up := engine.WaitUntilUp(options.Port, j.shutDownC) + switch options.Detach { + case engine.DetachNow: + // do not wait for health, do not reap - the OS reparents the child + return true + case engine.DetachHealthy: + // wait for health but do not reap - the OS reparents the child + return engine.WaitUntilUp(options.Port, j.shutDownC) + default: + up := engine.WaitUntilUp(options.Port, j.shutDownC) + + // watch in case process stops + go j.notifyOnStopBlocking(wg) - // watch in case process stops - go j.notifyOnStopBlocking(wg) + return up + } +} - return up +func (j *JvmMockEngine) GetID() string { + if j.command == nil || j.command.Process == nil { + return "" + } + return strconv.Itoa(j.command.Process.Pid) } func buildEnv(options engine.StartOptions) []string { @@ -161,6 +187,10 @@ func (j *JvmMockEngine) StopAllManaged() int { return count } +func (j *JvmMockEngine) StopManaged(id string) (bool, error) { + return procutil.StopManagedProcess(matcher, id) +} + func (j *JvmMockEngine) GetVersionString() (string, error) { if !(*j.provider).Satisfied() { if err := (*j.provider).Provide(engine.PullSkip); err != nil { diff --git a/internal/engine/jvm/engine_test.go b/internal/engine/jvm/engine_test.go index fae55ec..6eeb2a4 100644 --- a/internal/engine/jvm/engine_test.go +++ b/internal/engine/jvm/engine_test.go @@ -84,6 +84,32 @@ func TestEngine_Restart(t *testing.T) { enginetests.Restart(t, tests, engineBuilder) } +func TestEngine_StartDetached(t *testing.T) { + workingDir, err := os.Getwd() + if err != nil { + panic(err) + } + testConfigPath := filepath.Join(workingDir, "../enginetests/testdata") + + tests := []enginetests.EngineTestScenario{ + { + Name: "start jvm engine detached", + Fields: enginetests.EngineTestFields{ + ConfigDir: testConfigPath, + Options: engine.StartOptions{ + Port: enginetests.GetFreePort(), + Version: "4.9.1", + PullPolicy: engine.PullIfNotPresent, + LogLevel: "DEBUG", + Detach: engine.DetachHealthy, + DetachLog: filepath.Join(t.TempDir(), "mock.log"), + }, + }, + }, + } + enginetests.StartDetached(t, tests, engineBuilder) +} + // disabled flaky test func xTestEngine_List(t *testing.T) { logger.SetLevel(logrus.TraceLevel) diff --git a/internal/engine/native/engine.go b/internal/engine/native/engine.go index 702d276..17c44e7 100644 --- a/internal/engine/native/engine.go +++ b/internal/engine/native/engine.go @@ -47,8 +47,18 @@ func (g *NativeMockEngine) startWithOptions(wg *sync.WaitGroup, options engine.S } env := g.buildEnv(options) command := (*g.provider).GetStartCommand([]string{}, env) - command.Stdout = os.Stdout - command.Stderr = os.Stderr + if options.IsDetached() { + f, err := procutil.OpenDetachLog(options.DetachLog) + if err != nil { + logger.Fatal(err) + } + command.Stdout = f + command.Stderr = f + command.SysProcAttr = procutil.DetachSysProcAttr() + } else { + command.Stdout = os.Stdout + command.Stderr = os.Stderr + } if err := command.Start(); err != nil { logger.Errorf("failed to start native mock engine: %v", err) @@ -58,11 +68,27 @@ func (g *NativeMockEngine) startWithOptions(wg *sync.WaitGroup, options engine.S logger.Trace("starting native mock engine") g.cmd = command - // watch in case process stops - up := engine.WaitUntilUp(options.Port, g.shutDownC) + switch options.Detach { + case engine.DetachNow: + // do not wait for health, do not reap - the OS reparents the child + return true + case engine.DetachHealthy: + // wait for health but do not reap - the OS reparents the child + return engine.WaitUntilUp(options.Port, g.shutDownC) + default: + // watch in case process stops + up := engine.WaitUntilUp(options.Port, g.shutDownC) + + go g.notifyOnStopBlocking(wg) + return up + } +} - go g.notifyOnStopBlocking(wg) - return up +func (g *NativeMockEngine) GetID() string { + if g.cmd == nil || g.cmd.Process == nil { + return "" + } + return strconv.Itoa(g.cmd.Process.Pid) } func (g *NativeMockEngine) buildEnv(options engine.StartOptions) []string { @@ -161,6 +187,10 @@ func (g *NativeMockEngine) StopAllManaged() int { return count } +func (g *NativeMockEngine) StopManaged(id string) (bool, error) { + return procutil.StopManagedProcess(matcher, id) +} + func (g *NativeMockEngine) GetVersionString() (string, error) { // TODO get from binary return g.options.Version, nil diff --git a/internal/engine/native/engine_test.go b/internal/engine/native/engine_test.go index 9b74ded..a092e4c 100644 --- a/internal/engine/native/engine_test.go +++ b/internal/engine/native/engine_test.go @@ -83,6 +83,33 @@ func TestEngine_Restart(t *testing.T) { enginetests.Restart(t, tests, engineBuilder) } +func TestEngine_StartDetached(t *testing.T) { + logger.SetLevel(logrus.TraceLevel) + workingDir, err := os.Getwd() + if err != nil { + panic(err) + } + testConfigPath := filepath.Join(workingDir, "../enginetests/testdata") + + tests := []enginetests.EngineTestScenario{ + { + Name: "start golang engine detached", + Fields: enginetests.EngineTestFields{ + ConfigDir: testConfigPath, + Options: engine.StartOptions{ + Port: enginetests.GetFreePort(), + Version: "1.2.3", + PullPolicy: engine.PullIfNotPresent, + LogLevel: "DEBUG", + Detach: engine.DetachHealthy, + DetachLog: filepath.Join(t.TempDir(), "mock.log"), + }, + }, + }, + } + enginetests.StartDetached(t, tests, engineBuilder) +} + func TestEngine_List(t *testing.T) { logger.SetLevel(logrus.TraceLevel) workingDir, err := os.Getwd() diff --git a/internal/engine/procutil/detach_unix.go b/internal/engine/procutil/detach_unix.go new file mode 100644 index 0000000..fb63ce9 --- /dev/null +++ b/internal/engine/procutil/detach_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package procutil + +import "syscall" + +// DetachSysProcAttr returns the SysProcAttr needed to start a child +// process in its own session so it survives the parent CLI exiting and +// the controlling terminal closing. +func DetachSysProcAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{Setsid: true} +} diff --git a/internal/engine/procutil/detach_windows.go b/internal/engine/procutil/detach_windows.go new file mode 100644 index 0000000..52a5937 --- /dev/null +++ b/internal/engine/procutil/detach_windows.go @@ -0,0 +1,18 @@ +//go:build windows + +package procutil + +import ( + "syscall" + + "golang.org/x/sys/windows" +) + +// DetachSysProcAttr returns the SysProcAttr needed to start a child +// process detached from the parent CLI's process group and console so it +// survives the parent exiting and the console closing. +func DetachSysProcAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{ + CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS, + } +} diff --git a/internal/engine/procutil/log.go b/internal/engine/procutil/log.go new file mode 100644 index 0000000..2a951cb --- /dev/null +++ b/internal/engine/procutil/log.go @@ -0,0 +1,21 @@ +package procutil + +import ( + "fmt" + "os" + "path/filepath" +) + +// OpenDetachLog opens (creating directories and file as needed) the log +// file a detached process engine writes its stdout/stderr to. The file is +// opened in append mode so restarts do not truncate prior output. +func OpenDetachLog(path string) (*os.File, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, fmt.Errorf("failed to create log directory for %s: %w", path, err) + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return nil, fmt.Errorf("failed to open detach log %s: %w", path, err) + } + return f, nil +} diff --git a/internal/engine/procutil/log_test.go b/internal/engine/procutil/log_test.go new file mode 100644 index 0000000..c0fefea --- /dev/null +++ b/internal/engine/procutil/log_test.go @@ -0,0 +1,32 @@ +package procutil + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_OpenDetachLog(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "nested", "imposter-8080.log") + + f, err := OpenDetachLog(path) + require.NoError(t, err) + _, err = f.WriteString("first\n") + require.NoError(t, err) + require.NoError(t, f.Close()) + + // reopening must append, not truncate + f2, err := OpenDetachLog(path) + require.NoError(t, err) + _, err = f2.WriteString("second\n") + require.NoError(t, err) + require.NoError(t, f2.Close()) + + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "first\nsecond\n", string(content)) +} diff --git a/internal/engine/procutil/processes.go b/internal/engine/procutil/processes.go index 12e1cbd..2689058 100644 --- a/internal/engine/procutil/processes.go +++ b/internal/engine/procutil/processes.go @@ -104,6 +104,35 @@ func StopManagedProcesses(matcher ProcessMatcher) (int, error) { return len(processes), nil } +// StopManagedProcess kills the single managed process whose PID matches id, +// provided it satisfies matcher. Returns (true, nil) if killed, (false, nil) +// if no matching process exists, (false, err) on lookup/kill errors. +func StopManagedProcess(matcher ProcessMatcher, id string) (bool, error) { + pid, err := strconv.Atoi(id) + if err != nil { + return false, nil + } + processes, err := FindImposterProcesses(matcher) + if err != nil { + return false, err + } + for _, mock := range processes { + if mock.ID != id { + continue + } + logger.Debugf("killing %s process with PID: %d", matcher.ProcessName, pid) + p, err := os.FindProcess(pid) + if err != nil { + return false, fmt.Errorf("failed to find process %d: %v", pid, err) + } + if err := p.Kill(); err != nil { + return false, fmt.Errorf("failed to kill process %d: %v", pid, err) + } + return true, nil + } + return false, nil +} + // ReadArg parses the command line arguments to find the value of a given argument func ReadArg(cmdline []string, longArg string, shortArg string) string { for i := range cmdline {