From dc7d3d24f08438e97d641347254c726410f380ca Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 17:57:12 +0000 Subject: [PATCH 1/6] feat: add --detach option to `imposter up` Adds `--detach`/`-d` to background a running mock and return the terminal. By default it waits for the healthcheck to pass before detaching; `--no-await` returns immediately. Detached process-engine logs go to ~/.imposter/logs/imposter-.log (overridable with `--log-file`). Auto-restart is disabled when detaching since the config-dir watcher lives in the foreground process. Process engines are started in their own session so they survive the CLI exiting; docker containers already run independently in dockerd. Existing `imposter ls` / `imposter down` rediscovery is unchanged. https://claude.ai/code/session_0176yoDmpbo3wwm7i4z4BfPY --- cmd/up.go | 74 ++++++++++++++++++++- cmd/up_test.go | 75 ++++++++++++++++++++++ go.mod | 2 +- internal/engine/api.go | 46 ++++++++++++- internal/engine/builder.go | 10 +++ internal/engine/detach_test.go | 26 ++++++++ internal/engine/docker/engine.go | 29 +++++++-- internal/engine/enginetests/common.go | 49 ++++++++++++++ internal/engine/jvm/engine.go | 38 +++++++++-- internal/engine/jvm/engine_test.go | 26 ++++++++ internal/engine/native/engine.go | 38 +++++++++-- internal/engine/native/engine_test.go | 27 ++++++++ internal/engine/procutil/detach_unix.go | 12 ++++ internal/engine/procutil/detach_windows.go | 18 ++++++ internal/engine/procutil/log.go | 21 ++++++ internal/engine/procutil/log_test.go | 32 +++++++++ 16 files changed, 501 insertions(+), 22 deletions(-) create mode 100644 cmd/up_test.go create mode 100644 internal/engine/detach_test.go create mode 100644 internal/engine/procutil/detach_unix.go create mode 100644 internal/engine/procutil/detach_windows.go create mode 100644 internal/engine/procutil/log.go create mode 100644 internal/engine/procutil/log_test.go diff --git a/cmd/up.go b/cmd/up.go index 380be2a..3c3027b 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -48,6 +48,9 @@ var upFlags = struct { dirMounts []string recursiveConfigScan bool debugMode bool + detach bool + noAwait bool + logFile string }{} // upCmd represents the up command @@ -111,10 +114,51 @@ 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.noAwait, 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 bool, noAwait bool, logFile string, restartOnChange bool) bool { + if !detach { + if noAwait { + logger.Warn("--no-await has no effect without --detach") + } + return restartOnChange + } + + if noAwait { + startOptions.Detach = engine.DetachNow + } else { + startOptions.Detach = engine.DetachHealthy + } + + 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 +174,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().BoolVarP(&upFlags.detach, "detach", "d", false, "Run the mock in the background and return control to the terminal once it is healthy") + upCmd.Flags().BoolVar(&upFlags.noAwait, "no-await", false, "With --detach, return immediately without waiting for the mock to become 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 +219,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 +254,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..37e1f7c --- /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, false, false, "", 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 defaults to await-healthy", func(t *testing.T) { + opts := engine.StartOptions{Port: 8080} + restart := applyDetachOptions(&opts, engine.EngineTypeJvmSingleJar, true, false, "", true) + assert.Equal(t, engine.DetachHealthy, opts.Detach) + assert.False(t, restart, "auto-restart must be disabled when detached") + }) + + t.Run("detach with no-await returns immediately", func(t *testing.T) { + opts := engine.StartOptions{Port: 8080} + applyDetachOptions(&opts, engine.EngineTypeNative, true, true, "", 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, true, false, "", 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, true, false, "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, true, false, "", 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..6e4a951 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 { @@ -53,6 +92,11 @@ type MockEngine interface { ListAllManaged() ([]ManagedMock, error) StopAllManaged() int 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..87355e2 100644 --- a/internal/engine/docker/engine.go +++ b/internal/engine/docker/engine.go @@ -99,15 +99,30 @@ 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 { + return d.containerId } func buildPorts(options engine.StartOptions) (nat.PortSet, nat.PortMap) { diff --git a/internal/engine/enginetests/common.go b/internal/engine/enginetests/common.go index 88258f9..053da5a 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,53 @@ 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") + require.Equal(t, 1, len(mocks), "expected the detached mock to be discoverable") + + require.Equal(t, 1, mockEngine.StopAllManaged(), "expected to stop 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 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..6e1a092 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 { 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..fed2d6e 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 { 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)) +} From 7b6a39d4c0d877f243fc4fc213123105a6f05c8a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 19:44:32 +0000 Subject: [PATCH 2/6] refactor: make --detach a mode flag (healthy|now) Replace the separate --detach/--no-await booleans with a single --detach=MODE string flag (NoOptDefVal "healthy"), accepting 'healthy' (default) and 'now'. https://claude.ai/code/session_0176yoDmpbo3wwm7i4z4BfPY --- cmd/up.go | 27 ++++++++++++--------------- cmd/up_test.go | 16 ++++++++-------- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/cmd/up.go b/cmd/up.go index 3c3027b..4dcf60a 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -48,8 +48,7 @@ var upFlags = struct { dirMounts []string recursiveConfigScan bool debugMode bool - detach bool - noAwait bool + detach string logFile string }{} @@ -114,7 +113,7 @@ If CONFIG_DIR is not specified, the current working directory is used.`, DirMounts: upFlags.dirMounts, DebugMode: upFlags.debugMode, } - restartOnChange := applyDetachOptions(&startOptions, engineType, upFlags.detach, upFlags.noAwait, upFlags.logFile, upFlags.restartOnChange) + restartOnChange := applyDetachOptions(&startOptions, engineType, upFlags.detach, upFlags.logFile, upFlags.restartOnChange) start(&lib, startOptions, configDir, restartOnChange) }, @@ -124,18 +123,16 @@ If CONFIG_DIR is not specified, the current working directory is used.`, // 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 bool, noAwait bool, logFile string, restartOnChange bool) bool { - if !detach { - if noAwait { - logger.Warn("--no-await has no effect without --detach") - } +func applyDetachOptions(startOptions *engine.StartOptions, engineType engine.EngineType, detach string, logFile string, restartOnChange bool) bool { + switch detach { + case "": return restartOnChange - } - - if noAwait { - startOptions.Detach = engine.DetachNow - } else { + 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 { @@ -174,8 +171,8 @@ 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().BoolVarP(&upFlags.detach, "detach", "d", false, "Run the mock in the background and return control to the terminal once it is healthy") - upCmd.Flags().BoolVar(&upFlags.noAwait, "no-await", false, "With --detach, return immediately without waiting for the mock to become healthy") + 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) diff --git a/cmd/up_test.go b/cmd/up_test.go index 37e1f7c..4899fc5 100644 --- a/cmd/up_test.go +++ b/cmd/up_test.go @@ -32,43 +32,43 @@ func Test_applyDetachOptions(t *testing.T) { 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, false, false, "", true) + 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 defaults to await-healthy", func(t *testing.T) { + t.Run("detach=healthy waits for the healthcheck", func(t *testing.T) { opts := engine.StartOptions{Port: 8080} - restart := applyDetachOptions(&opts, engine.EngineTypeJvmSingleJar, true, false, "", true) + 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 with no-await returns immediately", func(t *testing.T) { + t.Run("detach=now returns immediately", func(t *testing.T) { opts := engine.StartOptions{Port: 8080} - applyDetachOptions(&opts, engine.EngineTypeNative, true, true, "", false) + 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, true, false, "", false) + 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, true, false, "relative/mock.log", false) + 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, true, false, "", false) + applyDetachOptions(&opts, engine.EngineTypeDockerCore, "healthy", "", false) assert.Equal(t, engine.DetachHealthy, opts.Detach) assert.Empty(t, opts.DetachLog) }) From beca4bbfe89f25e5a6b96fcf121c5933199e3b60 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 12:49:46 +0000 Subject: [PATCH 3/6] test: tolerate other imposter processes on the host The detach test asserted ListAllManaged returned exactly one mock and StopAllManaged returned exactly one. On CI runners with a system imposter installed at /opt/imposter, the JVM matcher also picks up that process, so the count is 2. Verify instead that our mock (by port) is in the list and that at least one mock was stopped. https://claude.ai/code/session_0176yoDmpbo3wwm7i4z4BfPY --- internal/engine/enginetests/common.go | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/internal/engine/enginetests/common.go b/internal/engine/enginetests/common.go index 053da5a..32419fc 100644 --- a/internal/engine/enginetests/common.go +++ b/internal/engine/enginetests/common.go @@ -164,9 +164,14 @@ func StartDetached(t *testing.T, tests []EngineTestScenario, builder func(scenar mocks, err := mockEngine.ListAllManaged() require.NoError(t, err, "failed to list managed mocks") - require.Equal(t, 1, len(mocks), "expected the detached mock to be discoverable") - - require.Equal(t, 1, mockEngine.StopAllManaged(), "expected to stop the detached mock") + // 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 { @@ -176,6 +181,15 @@ func StartDetached(t *testing.T, tests []EngineTestScenario, builder func(scenar } } +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 From da14205527be465416d316af3db87af9e816c7cf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 13:13:20 +0000 Subject: [PATCH 4/6] fix: shorten docker container id in detach summary GetID() returned the full 64-char container ID, which made the backgrounded-mock log line in `imposter up --detach` unwieldy. Match the 12-char short ID that `imposter ls` already uses for docker containers; docker accepts the short form for rm/logs/etc. https://claude.ai/code/session_0176yoDmpbo3wwm7i4z4BfPY --- internal/engine/docker/engine.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/engine/docker/engine.go b/internal/engine/docker/engine.go index 87355e2..77345a1 100644 --- a/internal/engine/docker/engine.go +++ b/internal/engine/docker/engine.go @@ -122,6 +122,9 @@ func (d *DockerMockEngine) startWithOptions(wg *sync.WaitGroup, options engine.S } func (d *DockerMockEngine) GetID() string { + if len(d.containerId) > 12 { + return d.containerId[:12] + } return d.containerId } From 1351e40a7b1bf935e7bd84cf4aa363b99d25651d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 13:23:11 +0000 Subject: [PATCH 5/6] feat: require mock ID for `down`; default `ls` to all engine types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `imposter ls`: - list mocks across all engine types by default (was opt-in via -a/--all) - `-t/--engine-type` filters to a single engine - drop the `-a/--all` flag and its mutual exclusivity rule `imposter down`: - accept a single mock ID as a positional argument (matching the ID shown by `imposter ls`); an unknown ID is fatal - `-a/--all` stops every managed mock across all engine types - the bare `imposter down` form is now an error - drop `-t/--engine-type` — IDs identify the engine implicitly To support per-ID stopping, add `StopManaged(id) (bool, error)` to the MockEngine interface. Docker resolves via ContainerInspect and checks the imposter-managed label before removing. Process engines (jvm, native) use a new `procutil.StopManagedProcess` helper that kills only the PID matching both the id and the engine's matcher. https://claude.ai/code/session_0176yoDmpbo3wwm7i4z4BfPY --- README.md | 4 +- cmd/down.go | 70 ++++++++++++++++++--------- cmd/down_test.go | 26 ++++++++-- cmd/list.go | 17 +++---- cmd/list_test.go | 6 +-- internal/engine/api.go | 9 ++++ internal/engine/docker/engine.go | 19 ++++++++ internal/engine/jvm/engine.go | 4 ++ internal/engine/native/engine.go | 4 ++ internal/engine/procutil/processes.go | 29 +++++++++++ 10 files changed, 148 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 7ff8f5e..ab43281 100644 --- a/README.md +++ b/README.md @@ -87,8 +87,8 @@ Each command has full help via `imposter --help`. | `imposter up [DIR]` | Start a live mock from Imposter config in `DIR` (defaults to current directory). Add `-s` to scaffold first. | | `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/internal/engine/api.go b/internal/engine/api.go index 6e4a951..75741d9 100644 --- a/internal/engine/api.go +++ b/internal/engine/api.go @@ -91,6 +91,15 @@ 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 diff --git a/internal/engine/docker/engine.go b/internal/engine/docker/engine.go index 77345a1..3f4b44c 100644 --- a/internal/engine/docker/engine.go +++ b/internal/engine/docker/engine.go @@ -343,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/jvm/engine.go b/internal/engine/jvm/engine.go index 6e1a092..3bc96dd 100644 --- a/internal/engine/jvm/engine.go +++ b/internal/engine/jvm/engine.go @@ -187,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/native/engine.go b/internal/engine/native/engine.go index fed2d6e..17c44e7 100644 --- a/internal/engine/native/engine.go +++ b/internal/engine/native/engine.go @@ -187,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/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 { From a679834d48556bed25bbfb8a991d34727579a1f5 Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Thu, 21 May 2026 15:20:23 +0100 Subject: [PATCH 6/6] docs: mention -d in the up command summary The previous down/ls rewrite touched the table for those rows; flag the new background mode in the `imposter up` row to keep the front page in line with the branch's user-visible changes. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ab43281..0bb9e85 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ 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 ID` | Stop the mock with the given ID (see `imposter ls`). `-a` / `--all` stops every managed mock across all engine types. |