Skip to content

Commit a0143ef

Browse files
committed
sandbox: add UTS namespace sharing tests
Add MemberContainersShareUTS and its converse MemberContainersUTSNotSharedWithoutUTSPath, mirroring the existing PID/IPC namespace sharing tests: a hostname change made via the standard 'hostname <name>' command by one member container must be visible to a peer sharing its UTS namespace, and must not be visible to one that isn't. Adds a single 'hostname' testbin command matching the standard utility's CLI exactly (bare 'hostname' prints the kernel-reported name; 'hostname <name>' sets it, exiting silently on success and with a 'hostname: ...' stderr message otherwise), rather than two custom-named commands, consistent with how the other testbin commands (cat, date, echo, ls, host, nc) mirror their standard counterparts. Because 'hostname <name>' exits immediately rather than staying running, verifying a peer sees the change also proves the shared namespace outlives the container that set it, not merely that a still-running setter's own namespace is visible. sethostname(2) requires CAP_SYS_ADMIN, which shimtest's base container spec does not grant, so this adds withCapabilities (a CreateOCISpec opt to request specific Linux capabilities) and requests it explicitly on the hostname-setting container. This is a property of the requested container, not the host process running the test suite, and needs no host-level privilege — confirmed empirically against runc, which grants a container zero capabilities unless the spec asks for them, root or not. Since the base container spec's silence on capabilities means standalone (non-sandboxed) containers built the same way inherit the *caller's* UTS namespace when none is requested, this was verified in an isolated 'unshare --uts' namespace rather than through a live container, to avoid any risk of changing a real host's hostname. Whether a shim honors the capability request is a separate contract from namespace sharing; a fixed capability probe container runs first and the dependent tests are skipped, not failed, if it doesn't. Adds a containerOutputSnapshot sandbox helper to read a completed container's full captured stdout without needing to wait for a fixed substring, since the hostname a test observes isn't known until runtime. Signed-off-by: Derek McGowan <derek@mcg.dev>
1 parent f25511c commit a0143ef

7 files changed

Lines changed: 271 additions & 1 deletion

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,8 @@ These tests (all gated on the `sandbox` feature) verify the shim API contract fo
192192
| `MemberContainersShareDevShm` | sandbox | yes | Member containers sharing an IPC namespace (see `MemberContainersShareIPC`) must also get a shared `/dev/shm`, even though every member container's OCI spec carries the exact same, independent-looking `{Type: "tmpfs", Destination: "/dev/shm"}` mount with no separate "shared" signal. A POSIX-shared-memory-style write made through an `mmap(MAP_SHARED)` mapping by one member container must be visible through an independent mapping in a second, independently created member container. Implementation-neutral: does not assume any particular sharing mechanism. No special privileges required. |
193193
| `MemberContainersDevShmNotSharedWithoutIPC` | sandbox | yes | The converse of `MemberContainersShareDevShm`: a member container that does not request IPC sharing must get its own private `/dev/shm`, even though its OCI spec's `/dev/shm` mount is identical in shape to a sharing container's. Guards against a shim inferring sharing from the mount's shape rather than the IPC-sharing signal. No special privileges required. |
194194
| `MemberContainerOOMIsolation` | sandbox | yes | The OOM-specific counterpart to `ContainerLifecycleIndependence` (which only covers a peer's graceful exit): when the kernel OOM-kills one member container (a memory-limited container running a memory-hungry workload, as in the standalone `OOM` test), a sibling member container with no memory limit must be unaffected, and the sandbox itself must remain ready. No special privileges required. |
195+
| `MemberContainersShareUTS` | sandbox | yes | When a member container's OCI spec carries a host path on its UTS namespace entry (e.g. as a caller uses to express Kubernetes' default of sharing one hostname across a pod's containers), the shim must place that container in a UTS namespace shared with its sandbox peers. A hostname change made via `sethostname(2)` (the standard `hostname <name>` command) by one member container must be visible — via the kernel-reported hostname, not a file — to a second, independently created member container, even after the first container has exited, proving the shared namespace is owned by the sandbox rather than tied to the setter's lifetime. Implementation-neutral: does not assume any particular sharing mechanism. The container requests `CAP_SYS_ADMIN` explicitly since the base container spec grants no capabilities; the test is skipped, not failed, if the shim does not grant it. |
196+
| `MemberContainersUTSNotShared` | sandbox | yes | The converse of `MemberContainersShareUTS`: a member container that does not request UTS sharing must not observe a peer's hostname change, even though both belong to the same sandbox. Guards against a shim sharing UTS namespaces merely because containers are sandbox peers, rather than because sharing was requested. |
195197

196198
## Using shimtest in your shim's CI
197199

helpers.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,35 @@ func withNewNetworkNamespace() func(*specs.Spec) {
265265
}
266266
}
267267

268+
// withCapabilities returns a CreateOCISpec opt that grants the given
269+
// capabilities (e.g. "CAP_SYS_ADMIN") in the container's Bounding,
270+
// Effective, and Permitted sets, in addition to whatever the spec
271+
// already carries.
272+
//
273+
// The base spec createOCISpec builds has no Capabilities section at
274+
// all, which every runtime this repo has been tested against
275+
// (confirmed empirically with runc) treats as granting the container
276+
// *no* capabilities whatsoever — not even the small default set a
277+
// container engine like Docker or containerd/CRI would normally add —
278+
// regardless of the privilege level of the process driving the test
279+
// suite on the host. A test that needs a capability inside the
280+
// container must therefore request it explicitly here, on the
281+
// container's own spec; this is a property of the requested container,
282+
// not of the host, and needs no host-level privilege to ask for.
283+
// Whether the shim actually honors the request is exactly what a test
284+
// using this opt is checking.
285+
func withCapabilities(caps ...string) func(*specs.Spec) {
286+
return func(s *specs.Spec) {
287+
if s.Process.Capabilities == nil {
288+
s.Process.Capabilities = &specs.LinuxCapabilities{}
289+
}
290+
c := s.Process.Capabilities
291+
c.Bounding = append(c.Bounding, caps...)
292+
c.Effective = append(c.Effective, caps...)
293+
c.Permitted = append(c.Permitted, caps...)
294+
}
295+
}
296+
268297
// shimSetup resolves the shim binary, creates a bundle directory, and
269298
// builds rootfs mounts from the embedded testbin. Returns the shim
270299
// binary's absolute path, the bundle directory, and the rootfs

helpers_sandbox.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,31 @@ func readContainerOutput(tb testing.TB, env *sandboxEnv, cid, want string, timeo
448448
}
449449
}
450450

451+
// containerOutputSnapshot returns whatever stdout has been captured so
452+
// far for cid, with no waiting for particular content — unlike
453+
// readContainerOutput, which blocks for a specific substring. Use this
454+
// when the expected output isn't known in advance (e.g. it's the value
455+
// under test, not a fixed marker) and the container's completion is
456+
// already known some other way (typically a prior Task.Wait).
457+
//
458+
// Callers should allow a brief moment after Task.Wait returns before
459+
// calling this: the container's process has exited, but the FIFO
460+
// drain goroutine may not have flushed the last of its output quite
461+
// yet (see the same allowance in execInSandboxContainer).
462+
func containerOutputSnapshot(tb testing.TB, env *sandboxEnv, cid string) string {
463+
tb.Helper()
464+
env.mu.Lock()
465+
co := env.stdoutBufs[cid]
466+
env.mu.Unlock()
467+
if co == nil {
468+
tb.Fatalf("no captured stdout for container %s", cid)
469+
return ""
470+
}
471+
co.mu.Lock()
472+
defer co.mu.Unlock()
473+
return co.buf.String()
474+
}
475+
451476
// writeContainerStdin writes data to the stdin FIFO of a member container
452477
// created with withSandboxCtrStdin, then closes the write end and signals
453478
// EOF via the CloseIO RPC. The container must have been created via

rootfs.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ func testbinAssetName(goarch string) string {
125125
// testbinCommands lists the commands provided by the testbin binary.
126126
// Symlinks are created in /bin for each command in the embedded
127127
// rootfs.
128-
var testbinCommands = []string{"forever", "burstexit", "cat", "date", "echo", "echosrv", "exit", "hashverify", "host", "layercheck", "looptest", "ls", "memhog", "nc", "pidscan", "shmread", "shmwrite", "shmmapread", "shmmapwrite", "tickexit"}
128+
var testbinCommands = []string{"forever", "burstexit", "cat", "date", "echo", "echosrv", "exit", "hashverify", "host", "hostname", "layercheck", "looptest", "ls", "memhog", "nc", "pidscan", "shmread", "shmwrite", "shmmapread", "shmmapwrite", "tickexit"}
129129

130130
// bigFileSize is the size of the IO benchmark fixture file. Large
131131
// enough to swamp small per-call overheads while still building /

sandbox_suite.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ func (s *SandboxSuite) Run(t *testing.T) {
114114
t.Run("MemberContainersShareDevShm", s.testMemberContainersShareDevShm)
115115
t.Run("MemberContainersDevShmNotSharedWithoutIPC", s.testMemberContainersDevShmNotSharedWithoutIPC)
116116
t.Run("MemberContainerOOMIsolation", s.testMemberContainerOOMIsolation)
117+
t.Run("MemberContainersShareUTS", s.testMemberContainersShareUTS)
118+
t.Run("MemberContainersUTSNotShared", s.testMemberContainersUTSNotShared)
117119
}
118120

119121
// testLifecycle drives the sandbox through the full lifecycle:
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
//go:build linux
2+
3+
/*
4+
Copyright The containerd Authors.
5+
6+
Licensed under the Apache License, Version 2.0 (the "License");
7+
you may not use this file except in compliance with the License.
8+
You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing, software
13+
distributed under the License is distributed on an "AS IS" BASIS,
14+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
See the License for the specific language governing permissions and
16+
limitations under the License.
17+
*/
18+
19+
package shimtest
20+
21+
import (
22+
"strings"
23+
"testing"
24+
"time"
25+
26+
taskAPI "github.com/containerd/containerd/api/runtime/task/v3"
27+
specs "github.com/opencontainers/runtime-spec/specs-go"
28+
)
29+
30+
// requireSandboxHostnameCapability probes, independently of any
31+
// namespace-sharing behavior, whether the shim honors a member
32+
// container's request for CAP_SYS_ADMIN by running "hostname <name>"
33+
// once in an otherwise-unshared container. If the set fails, the
34+
// calling test is skipped rather than failed: granting a container's
35+
// requested Linux capabilities is a separate contract from namespace
36+
// sharing (which is what UTS tests in this file actually check), and a
37+
// shim that doesn't support capability requests at all shouldn't be
38+
// penalized on a contract it was never asked to implement here.
39+
func requireSandboxHostnameCapability(t *testing.T, env *sandboxEnv) {
40+
t.Helper()
41+
42+
cid := createContainerInSandbox(t, env, []string{"/bin/hostname", "cap-probe-" + randomSuffix()},
43+
withSandboxCtrOCIOpts(withCapabilities("CAP_SYS_ADMIN")))
44+
waitResp, err := env.tc.Wait(env.ctx, &taskAPI.WaitRequest{ID: cid})
45+
if err != nil {
46+
t.Fatalf("Task.Wait capability probe: %v", err)
47+
}
48+
env.tc.Delete(env.ctx, &taskAPI.DeleteRequest{ID: cid}) //nolint:errcheck
49+
if waitResp.GetExitStatus() != 0 {
50+
t.Skipf("shim did not grant the requested CAP_SYS_ADMIN to the container (hostname set failed); cannot verify UTS namespace behavior")
51+
}
52+
}
53+
54+
// setSandboxHostname creates a member container that sets the UTS
55+
// namespace hostname via the standard "hostname <name>" command,
56+
// applying opts (e.g. withSandboxCtrNamespace(UTSNamespace, ...) to
57+
// join a shared UTS namespace) to its spec, and waits for it to exit.
58+
//
59+
// Callers must call requireSandboxHostnameCapability first: with the
60+
// capability precondition already verified separately, a non-zero exit
61+
// here is treated as a hard test failure rather than a skip.
62+
//
63+
// The standard "hostname <name>" exits immediately and silently on a
64+
// successful set rather than holding the namespace open itself (see
65+
// cmdHostname), so a caller that later observes the change is also
66+
// proving the namespace — and its hostname — outlives the process that
67+
// set it, not merely that a still-running setter's own namespace is
68+
// visible.
69+
func setSandboxHostname(t *testing.T, env *sandboxEnv, hostname string, opts ...func(*sandboxCtrSpec)) {
70+
t.Helper()
71+
72+
opts = append(opts, withSandboxCtrOCIOpts(withCapabilities("CAP_SYS_ADMIN")))
73+
cid := createContainerInSandbox(t, env, []string{"/bin/hostname", hostname}, opts...)
74+
75+
waitResp, err := env.tc.Wait(env.ctx, &taskAPI.WaitRequest{ID: cid})
76+
if err != nil {
77+
t.Fatalf("Task.Wait hostname setter: %v", err)
78+
}
79+
if waitResp.GetExitStatus() != 0 {
80+
t.Fatalf("hostname setter exit status: got %d, want 0 (CAP_SYS_ADMIN already verified available)", waitResp.GetExitStatus())
81+
}
82+
env.tc.Delete(env.ctx, &taskAPI.DeleteRequest{ID: cid}) //nolint:errcheck
83+
}
84+
85+
// readSandboxHostname creates a member container that prints its UTS
86+
// namespace hostname via the standard, argument-less "hostname"
87+
// command, applying opts to its spec, waits for it to exit, and
88+
// returns the trimmed hostname it reported.
89+
func readSandboxHostname(t *testing.T, env *sandboxEnv, opts ...func(*sandboxCtrSpec)) string {
90+
t.Helper()
91+
92+
cid := createContainerInSandbox(t, env, []string{"/bin/hostname"}, opts...)
93+
waitResp, err := env.tc.Wait(env.ctx, &taskAPI.WaitRequest{ID: cid})
94+
if err != nil {
95+
t.Fatalf("Task.Wait hostname reader: %v", err)
96+
}
97+
if waitResp.GetExitStatus() != 0 {
98+
t.Fatalf("hostname reader exit status: got %d, want 0", waitResp.GetExitStatus())
99+
}
100+
// Allow a moment for the last of stdout to drain after exit (see
101+
// containerOutputSnapshot).
102+
time.Sleep(50 * time.Millisecond)
103+
out := strings.TrimSpace(containerOutputSnapshot(t, env, cid))
104+
env.tc.Delete(env.ctx, &taskAPI.DeleteRequest{ID: cid}) //nolint:errcheck
105+
return out
106+
}
107+
108+
// testMemberContainersShareUTS verifies that member containers of the
109+
// same sandbox can share a UTS namespace: a hostname change made by one
110+
// member container via the standard "hostname <name>" command is
111+
// visible — via the kernel's reported hostname, not a file or
112+
// environment variable — to a second, independently created member
113+
// container, even after the container that made the change has exited.
114+
//
115+
// The API contract: when a member container's OCI spec carries a host
116+
// path on its UTS namespace entry (e.g. this is how a caller expresses
117+
// Kubernetes' default of sharing one hostname across a pod's
118+
// containers), the shim must place that container in a UTS namespace
119+
// shared with its sandbox peers rather than a fresh, isolated one, and
120+
// that shared namespace must be owned by the sandbox rather than tied
121+
// to the lifetime of whichever container last changed its hostname.
122+
// This test only observes the externally visible result and does not
123+
// assume any particular mechanism a shim uses to provide it. It
124+
// intentionally uses a placeholder host path (see
125+
// withSandboxCtrNamespace) since only a live host has an actual
126+
// sandbox PID to put there.
127+
func (s *SandboxSuite) testMemberContainersShareUTS(t *testing.T) {
128+
sandboxID := containerID(t)
129+
env := startSandboxShim(t, s.cfg, sandboxID)
130+
131+
requireSandboxHostnameCapability(t, env)
132+
133+
hostname := "shared-uts-" + randomSuffix()
134+
setSandboxHostname(t, env, hostname, withSandboxCtrNamespace(specs.UTSNamespace, "/proc/1/ns/uts"))
135+
136+
got := readSandboxHostname(t, env, withSandboxCtrNamespace(specs.UTSNamespace, "/proc/1/ns/uts"))
137+
if got != hostname {
138+
t.Fatalf("reader hostname: got %q, want %q", got, hostname)
139+
}
140+
141+
t.Log("member containers share a UTS namespace: hostname change outlived the container that set it and was visible to a peer")
142+
}
143+
144+
// testMemberContainersUTSNotShared verifies the converse of
145+
// testMemberContainersShareUTS: a member container that does not
146+
// request UTS sharing must not observe a peer's hostname change, even
147+
// though both containers belong to the same sandbox.
148+
//
149+
// The API contract mirrors testMemberContainersSharePID's converse and
150+
// testMemberContainersDevShmNotSharedWithoutIPC: the shim must key UTS
151+
// namespace sharing off the container's own UTS-namespace-sharing
152+
// signal, not off simply being a member of the same sandbox.
153+
func (s *SandboxSuite) testMemberContainersUTSNotShared(t *testing.T) {
154+
sandboxID := containerID(t)
155+
env := startSandboxShim(t, s.cfg, sandboxID)
156+
157+
requireSandboxHostnameCapability(t, env)
158+
159+
hostname := "should-not-be-visible-" + randomSuffix()
160+
setSandboxHostname(t, env, hostname, withSandboxCtrNamespace(specs.UTSNamespace, "/proc/1/ns/uts"))
161+
162+
// No withSandboxCtrNamespace(UTSNamespace, ...): this container does
163+
// not request UTS sharing, so it must get its own, unaffected UTS
164+
// namespace.
165+
got := readSandboxHostname(t, env)
166+
if got == hostname {
167+
t.Fatalf("reader saw writer's hostname %q despite neither container sharing UTS", hostname)
168+
}
169+
170+
t.Log("member container not sharing UTS correctly did not observe a peer's hostname change")
171+
}

testbin/testbin.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ func Main() {
106106
cmdShmMapWrite(args)
107107
case "shmmapread":
108108
cmdShmMapRead(args)
109+
case "hostname":
110+
cmdHostname(args)
109111
default:
110112
fmt.Fprintf(os.Stderr, "testbin: unknown command: %s\n", cmd)
111113
os.Exit(127)
@@ -1094,3 +1096,42 @@ func cmdShmMapRead(args []string) {
10941096
}
10951097
fmt.Println(string(data[:end]))
10961098
}
1099+
1100+
// cmdHostname mirrors the standard "hostname" utility's CLI: with no
1101+
// argument it prints the calling process's UTS namespace hostname as
1102+
// reported by the kernel (via gethostname(2), not /etc/hostname or an
1103+
// env var); with one argument it sets the hostname (via sethostname(2))
1104+
// and, matching the standard utility, exits immediately and silently on
1105+
// success rather than staying running or printing anything.
1106+
//
1107+
// sethostname(2) requires CAP_SYS_ADMIN in the user namespace that owns
1108+
// the target UTS namespace; the container's OCI spec must request that
1109+
// capability explicitly (shimtest's base spec grants none) for a set to
1110+
// succeed at all. On failure (of either form) a "hostname: ..." message
1111+
// is printed to stderr and the process exits non-zero, matching the
1112+
// standard utility's error convention.
1113+
//
1114+
// Because this command exits immediately after a successful set rather
1115+
// than holding the UTS namespace open itself, a caller that verifies a
1116+
// hostname change is later visible to a different process is also
1117+
// proving that the namespace — and its hostname — outlives the process
1118+
// that set it, not merely that a still-running setter's own namespace
1119+
// is visible.
1120+
//
1121+
// Usage: hostname [name]
1122+
func cmdHostname(args []string) {
1123+
if len(args) < 2 {
1124+
name, err := os.Hostname()
1125+
if err != nil {
1126+
fmt.Fprintf(os.Stderr, "hostname: %v\n", err)
1127+
os.Exit(1)
1128+
}
1129+
fmt.Println(name)
1130+
return
1131+
}
1132+
1133+
if err := syscall.Sethostname([]byte(args[1])); err != nil {
1134+
fmt.Fprintf(os.Stderr, "hostname: %v\n", err)
1135+
os.Exit(1)
1136+
}
1137+
}

0 commit comments

Comments
 (0)