diff --git a/README.md b/README.md index 6bf3653..53e12f6 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ To explore more examples, tips, and advanced usage, check out the following guid ## Setup Script The `ENVBUILDER_SETUP_SCRIPT` environment variable dynamically configures the user and init command (PID 1) after the container build process. +Custom `ENVBUILDER_INIT_SCRIPT` and `ENVBUILDER_INIT_COMMAND` values become PID 1, so they must trap signals themselves if they need prompt `SIGTERM` or `SIGINT` handling. > **Note**: `TARGET_USER` is passed to the setup script to specify who will execute `ENVBUILDER_INIT_COMMAND` (e.g., `code`). diff --git a/docs/env-variables.md b/docs/env-variables.md index e49e2a6..30c936b 100644 --- a/docs/env-variables.md +++ b/docs/env-variables.md @@ -4,7 +4,7 @@ | Flag | Environment variable | Default | Description | | - | - | - | - | | `--setup-script` | `ENVBUILDER_SETUP_SCRIPT` | | The script to run before the init script. It runs as the root user regardless of the user specified in the devcontainer.json file. SetupScript is ran as the root user prior to the init script. It is used to configure envbuilder dynamically during the runtime. e.g. specifying whether to start systemd or tiny init for PID 1. | -| `--init-script` | `ENVBUILDER_INIT_SCRIPT` | | The script to run to initialize the workspace. Default: `sleep infinity`. | +| `--init-script` | `ENVBUILDER_INIT_SCRIPT` | | The script to run to initialize the workspace. By default, this traps `SIGTERM`/`SIGINT`, starts `sleep infinity` as a child process, and waits for it so the container exits promptly when stopped. | | `--init-command` | `ENVBUILDER_INIT_COMMAND` | | The command to run to initialize the workspace. Default: `/bin/sh`. | | `--init-args` | `ENVBUILDER_INIT_ARGS` | | The arguments to pass to the init command. They are split according to /bin/sh rules with https://github.com/kballard/go-shellquote. | | `--cache-repo` | `ENVBUILDER_CACHE_REPO` | | The name of the container registry to push the cache image to. If this is empty, the cache will not be pushed. | diff --git a/integration/integration_test.go b/integration/integration_test.go index 82f0c4c..9f134dd 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -1088,6 +1088,43 @@ func TestBuildStopStartCached(t *testing.T) { } } +func TestDefaultInitScriptRespondsToSignals(t *testing.T) { + t.Parallel() + + for _, signal := range []string{"SIGTERM", "SIGINT"} { + t.Run(signal, func(t *testing.T) { + t.Parallel() + + srv := gittest.CreateGitServer(t, gittest.Options{ + Files: map[string]string{ + "Dockerfile": "FROM " + testImageAlpine, + }, + }) + ctr, err := runEnvbuilder(t, runOpts{env: []string{ + envbuilderEnv("GIT_URL", srv.URL), + envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"), + }}) + require.NoError(t, err) + + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + require.NoError(t, err) + defer cli.Close() + + ctx := context.Background() + err = cli.ContainerKill(ctx, ctr, signal) + require.NoError(t, err) + + require.Eventually(t, func() bool { + status, err := cli.ContainerInspect(ctx, ctr) + if !assert.NoError(t, err) { + return false + } + return !status.State.Running + }, 5*time.Second, 100*time.Millisecond, "container never exited") + }) + } +} + func TestCloneFailsFallback(t *testing.T) { t.Parallel() t.Run("BadRepo", func(t *testing.T) { diff --git a/options/defaults.go b/options/defaults.go index c7aca9f..cbacdb7 100644 --- a/options/defaults.go +++ b/options/defaults.go @@ -50,7 +50,7 @@ func (o *Options) SetDefaults() { } } if o.InitScript == "" { - o.InitScript = "sleep infinity" + o.InitScript = "trap 'exit 0' TERM INT\nsleep infinity &\nwait $!" } if o.InitCommand == "" { o.InitCommand = "/bin/sh" diff --git a/options/defaults_test.go b/options/defaults_test.go index edc2bf3..b8ba50b 100644 --- a/options/defaults_test.go +++ b/options/defaults_test.go @@ -180,7 +180,7 @@ func TestOptions_SetDefaults(t *testing.T) { t.Parallel() expected := options.Options{ - InitScript: "sleep infinity", + InitScript: "trap 'exit 0' TERM INT\nsleep infinity &\nwait $!", InitCommand: "/bin/sh", IgnorePaths: []string{"/var/run", "/product_uuid", "/product_name"}, Filesystem: chmodfs.New(osfs.New("/")), diff --git a/options/options.go b/options/options.go index 69048b5..d4785d7 100644 --- a/options/options.go +++ b/options/options.go @@ -253,9 +253,11 @@ func (o *Options) CLI() serpent.OptionSet { { Flag: "init-script", Env: WithEnvPrefix("INIT_SCRIPT"), - // Default: "sleep infinity", // TODO: reinstate once legacy opts are removed. - Value: serpent.StringOf(&o.InitScript), - Description: "The script to run to initialize the workspace. Default: `sleep infinity`.", + // Default: "trap 'exit 0' TERM INT\nsleep infinity &\nwait $!", // TODO: reinstate once legacy opts are removed. + Value: serpent.StringOf(&o.InitScript), + Description: "The script to run to initialize the workspace. By default, this traps " + + "`SIGTERM`/`SIGINT`, starts `sleep infinity` as a child process, and waits for it " + + "so the container exits promptly when stopped.", }, { Flag: "init-command", diff --git a/options/testdata/options.golden b/options/testdata/options.golden index 918b3dd..38d0c1f 100644 --- a/options/testdata/options.golden +++ b/options/testdata/options.golden @@ -142,8 +142,9 @@ OPTIONS: The command to run to initialize the workspace. Default: `/bin/sh`. --init-script string, $ENVBUILDER_INIT_SCRIPT - The script to run to initialize the workspace. Default: `sleep - infinity`. + The script to run to initialize the workspace. By default, this traps + `SIGTERM`/`SIGINT`, starts `sleep infinity` as a child process, and + waits for it so the container exits promptly when stopped. --insecure bool, $ENVBUILDER_INSECURE Bypass TLS verification when cloning and pulling from container