From 6f499634551e17cf4d6be4ccc9b937acdfb16655 Mon Sep 17 00:00:00 2001 From: Cory Snider Date: Mon, 21 Sep 2026 15:27:43 -0400 Subject: [PATCH] integration: run swarm test tasks under an init The commands these tasks run - /bin/top by default, httpd in the ingress-port tests - install no SIGTERM handler, and a signal sent to PID 1 has no default action. So as PID 1 they ignore the stop signal the daemon sends them, and the daemon waits out the container's whole stop timeout before resorting to SIGKILL, on every task teardown: start clean shutdown of all containers with a 15 seconds timeout... Container failed to exit within 10s of signal 15 - using the force Daemon shutdown complete Ten seconds a task, paid by every swarm test that stops a daemon or removes a service. Ask for an init: it takes PID 1 and forwards the signal to a process the default action does apply to. Note it has to be a graceful stop to be worth anything - a container removed by force is killed outright and waits for nothing. Defaulting it in the spec helper reaches every test that builds a service through it, rather than a line per caller. The iptables-doc suite and the Init test itself build their specs by hand, so they're handled where they are - and the case in the Init test that asserts an unset Init delegates to the daemon configuration now asks for that explicitly, which is what it was relying on the helper for. The nftables-doc suite builds one too, but the only section that would use it is commented out awaiting swarm support for nftables, so there's nothing there to speed up yet. Nothing here asserts on PID 1, and nothing depends on a task lingering: the tests that care about task lifetime wait for the task count to settle before checking anything, so a quicker drain only brings that wait forward. Assisted-By: Claude Opus 5 Signed-off-by: Cory Snider --- integration/config/config_test.go | 9 +++++++-- integration/internal/swarm/service.go | 12 +++++++++++- .../bridge/iptablesdoc/iptablesdoc_linux_test.go | 2 ++ integration/secret/secret_test.go | 9 +++++++-- integration/service/create_test.go | 5 ++++- 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/integration/config/config_test.go b/integration/config/config_test.go index 275ba0dcc3..6ebfd2df8b 100644 --- a/integration/config/config_test.go +++ b/integration/config/config_test.go @@ -333,15 +333,20 @@ func TestTemplatedConfig(t *testing.T) { outBuf.Reset() errBuf.Reset() + // Read the mount table rather than running mount(8): busybox's mount stops + // parsing at an over-long entry and drops it along with everything after it, + // and the task carries one - the init binary is bind-mounted from an overlay + // whose options list every lower directory. /proc/mounts is what mount(8) + // reads anyway, and cat doesn't parse it. resp = swarm.ExecTask(ctx, t, d, tasks[0], client.ExecCreateOptions{ - Cmd: []string{"mount"}, + Cmd: []string{"cat", "/proc/mounts"}, AttachStdout: true, AttachStderr: true, }) _, err = stdcopy.StdCopy(&outBuf, &errBuf, resp.Reader) assert.NilError(t, err) - assert.Check(t, is.Contains(outBuf.String(), "tmpfs on /templated_config type tmpfs"), "expected to be mounted as tmpfs") + assert.Check(t, is.Contains(outBuf.String(), "tmpfs /templated_config tmpfs"), "expected to be mounted as tmpfs") assert.Check(t, is.Equal(errBuf.String(), "")) } diff --git a/integration/internal/swarm/service.go b/integration/internal/swarm/service.go index 5035264737..603fb5e04d 100644 --- a/integration/internal/swarm/service.go +++ b/integration/internal/swarm/service.go @@ -68,13 +68,23 @@ func CreateService(ctx context.Context, t *testing.T, d *daemon.Daemon, opts ... return resp.ID } -// CreateServiceSpec creates a default service-spec, and applies the provided options +// CreateServiceSpec creates a default service-spec, and applies the provided options. +// +// The defaults include running tasks under an init, so a test that wants the +// daemon's own default instead has to say so with ServiceWithInit(nil) - saying +// nothing gets the init. func CreateServiceSpec(t *testing.T, opts ...ServiceSpecOpt) swarmtypes.ServiceSpec { t.Helper() var spec swarmtypes.ServiceSpec ServiceWithImage("busybox:latest")(&spec) ServiceWithCommand([]string{"/bin/top"})(&spec) ServiceWithReplicas(1)(&spec) + // Run tasks under an init. None of the commands these tests run install a + // SIGTERM handler, and a signal sent to PID 1 has no default action - so as + // PID 1 they ignore the stop signal, and every task teardown waits out the + // container's whole stop timeout before the kill. An init takes PID 1 and + // forwards the signal to a process the default action applies to. + ServiceWithInit(new(true))(&spec) for _, o := range opts { o(&spec) diff --git a/integration/network/bridge/iptablesdoc/iptablesdoc_linux_test.go b/integration/network/bridge/iptablesdoc/iptablesdoc_linux_test.go index 53eaa48b7f..6849667f34 100644 --- a/integration/network/bridge/iptablesdoc/iptablesdoc_linux_test.go +++ b/integration/network/bridge/iptablesdoc/iptablesdoc_linux_test.go @@ -366,6 +366,8 @@ func createServices(ctx context.Context, t *testing.T, d *daemon.Daemon, section ContainerSpec: &swarmtypes.ContainerSpec{ Image: "busybox:latest", Command: []string{"/bin/top"}, + // top ignores SIGTERM as PID 1; an init forwards it. + Init: new(true), }, }, EndpointSpec: &swarmtypes.EndpointSpec{ diff --git a/integration/secret/secret_test.go b/integration/secret/secret_test.go index 1c2dc7a4da..91a559fa9f 100644 --- a/integration/secret/secret_test.go +++ b/integration/secret/secret_test.go @@ -355,15 +355,20 @@ func TestTemplatedSecret(t *testing.T) { outBuf.Reset() errBuf.Reset() + // Read the mount table rather than running mount(8): busybox's mount stops + // parsing at an over-long entry and drops it along with everything after it, + // and the task carries one - the init binary is bind-mounted from an overlay + // whose options list every lower directory. /proc/mounts is what mount(8) + // reads anyway, and cat doesn't parse it. resp = swarm.ExecTask(ctx, t, d, tasks[0], client.ExecCreateOptions{ - Cmd: []string{"mount"}, + Cmd: []string{"cat", "/proc/mounts"}, AttachStdout: true, AttachStderr: true, }) _, err = stdcopy.StdCopy(&outBuf, &errBuf, resp.Reader) assert.NilError(t, err) - assert.Check(t, is.Contains(outBuf.String(), "tmpfs on /run/secrets/templated_secret type tmpfs"), "expected to be mounted as tmpfs") + assert.Check(t, is.Contains(outBuf.String(), "tmpfs /run/secrets/templated_secret tmpfs"), "expected to be mounted as tmpfs") assert.Check(t, is.Equal(errBuf.String(), "")) } diff --git a/integration/service/create_test.go b/integration/service/create_test.go index 8ca6d6b8b1..f7c4018e81 100644 --- a/integration/service/create_test.go +++ b/integration/service/create_test.go @@ -44,7 +44,10 @@ func testServiceCreateInit(ctx context.Context, daemonEnabled bool) func(t *test booleanTrue := true booleanFalse := false - serviceID := swarm.CreateService(ctx, t, d) + // An unset Init delegates to the daemon configuration, which is what this + // case is about - so ask for it explicitly, rather than relying on the + // spec helper leaving it unset. + serviceID := swarm.CreateService(ctx, t, d, swarm.ServiceWithInit(nil)) poll.WaitOn(t, swarm.RunningTasksCount(ctx, apiClient, serviceID, 1), swarm.ServicePoll) i := inspectServiceContainer(ctx, t, apiClient, serviceID) // HostConfig.Init == nil means that it delegates to daemon configuration