mirror of
https://github.com/hanzoai/visor.git
synced 2026-09-24 17:04:14 +00:00
redhat/ubi10-init covers a second systemd lineage: RHEL 10, systemd v257, which mandates cgroup v2. The test boots it and checks for failed units and a working transient unit. images/systemd/ built systemd from source for a past investigation and is referenced by nothing. PiperOrigin-RevId: 970125419
1149 lines
49 KiB
Go
1149 lines
49 KiB
Go
// Copyright 2026 The gVisor Authors.
|
|
//
|
|
// 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 integration
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gvisor.dev/gvisor/pkg/cleanup"
|
|
"gvisor.dev/gvisor/pkg/test/dockerutil"
|
|
"gvisor.dev/gvisor/pkg/test/testutil"
|
|
)
|
|
|
|
const (
|
|
// These timeouts bound polls whose expected wait is a few seconds at
|
|
// most. They are sized generously because on small, oversubscribed CI
|
|
// machines a single docker exec round trip has been observed to take
|
|
// tens of seconds.
|
|
systemdBootTimeout = 2 * time.Minute
|
|
daemonPollTimeout = time.Minute
|
|
)
|
|
|
|
func pollWithTimeout(ctx context.Context, timeout time.Duration, cb func(ctx context.Context) error) error {
|
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
return testutil.PollContext(ctx, func() error {
|
|
return cb(ctx)
|
|
})
|
|
}
|
|
|
|
// waitForSystemdBoot polls `systemctl status` until systemd reports "running".
|
|
func waitForSystemdBoot(ctx context.Context, d *dockerutil.Container) error {
|
|
isSystemdRunning := func(ctx context.Context) error {
|
|
out, err := d.Exec(ctx, dockerutil.ExecOpts{User: "root"}, "systemctl", "status", "--no-pager")
|
|
// systemctl status returns non-zero when degraded or starting,
|
|
// but still produces useful output. Check the output regardless.
|
|
if strings.Contains(out, "State: running") {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("systemctl status failed: %v (output: %s)", err, out)
|
|
}
|
|
return fmt.Errorf("systemd not yet running, output: %s", out)
|
|
}
|
|
|
|
return pollWithTimeout(ctx, systemdBootTimeout, isSystemdRunning)
|
|
}
|
|
|
|
// execOrFatal runs a command inside the systemd container and returns its
|
|
// output. It fatals on error.
|
|
func execOrFatal(ctx context.Context, t *testing.T, d *dockerutil.Container, args ...string) string {
|
|
t.Helper()
|
|
out, err := d.Exec(ctx, dockerutil.ExecOpts{User: "root"}, args...)
|
|
if err != nil {
|
|
t.Fatalf("exec %v failed: %v (output: %s)", args, err, out)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// spawnSystemdContainer starts a container booting systemd from the given
|
|
// image and waits until systemd reports a "running" state. The returned
|
|
// container must be cleaned up by the caller.
|
|
func spawnSystemdContainer(ctx context.Context, t *testing.T, image string) *dockerutil.Container {
|
|
t.Helper()
|
|
d := dockerutil.MakeContainerWithRuntime(ctx, t, "-cgroupv2")
|
|
cu := cleanup.Make(func() { d.CleanUp(ctx) })
|
|
defer cu.Clean()
|
|
|
|
if err := d.Spawn(ctx, dockerutil.RunOpts{
|
|
Image: image,
|
|
Privileged: true,
|
|
}); err != nil {
|
|
t.Fatalf("docker run failed: %v", err)
|
|
}
|
|
if err := waitForSystemdBoot(ctx, d); err != nil {
|
|
t.Fatalf("systemd did not boot: %v", err)
|
|
}
|
|
cu.Release()
|
|
return d
|
|
}
|
|
|
|
// waitForUnitActive polls until the given unit becomes active, dumping the
|
|
// unit's journal on failure.
|
|
func waitForUnitActive(ctx context.Context, t *testing.T, d *dockerutil.Container, unit string) {
|
|
t.Helper()
|
|
checkUnitActive := func(ctx context.Context) error {
|
|
out, err := d.Exec(ctx, dockerutil.ExecOpts{User: "root"}, "systemctl", "is-active", unit)
|
|
if err != nil || strings.TrimSpace(out) != "active" {
|
|
return fmt.Errorf("%s not active: %v (output: %s)", unit, err, out)
|
|
}
|
|
return nil
|
|
}
|
|
if err := pollWithTimeout(ctx, daemonPollTimeout, checkUnitActive); err != nil {
|
|
journal, _ := d.Exec(ctx, dockerutil.ExecOpts{User: "root"}, "journalctl", "-u", unit, "--no-pager")
|
|
t.Fatalf("%s did not become active: %v\njournal:\n%s", unit, err, journal)
|
|
}
|
|
}
|
|
|
|
// startService starts the given unit and waits for it to become active,
|
|
// dumping the unit's journal on failure.
|
|
func startService(ctx context.Context, t *testing.T, d *dockerutil.Container, unit string) {
|
|
t.Helper()
|
|
execOrFatal(ctx, t, d, "systemctl", "start", unit)
|
|
waitForUnitActive(ctx, t, d, unit)
|
|
}
|
|
|
|
// stopService stops the given unit and verifies that it becomes inactive.
|
|
func stopService(ctx context.Context, t *testing.T, d *dockerutil.Container, unit string) {
|
|
t.Helper()
|
|
wantInactiveState := "inactive"
|
|
execOrFatal(ctx, t, d, "systemctl", "stop", unit)
|
|
if state := unitState(ctx, t, d, unit); state != wantInactiveState {
|
|
t.Errorf("After stopping %s, ActiveState is wrong (got %q, want %q)", unit, state, wantInactiveState)
|
|
}
|
|
}
|
|
|
|
// unitState returns the ActiveState of the given unit.
|
|
func unitState(ctx context.Context, t *testing.T, d *dockerutil.Container, unit string) string {
|
|
t.Helper()
|
|
return strings.TrimSpace(execOrFatal(ctx, t, d, "systemctl", "show", "-p", "ActiveState", "--value", unit))
|
|
}
|
|
|
|
// unitCgroupDir returns the cgroupfs directory of the given unit's main
|
|
// process, derived from /proc rather than hardcoded: the container may share
|
|
// the host's cgroup namespace (docker's default on cgroup-v1 hosts), which
|
|
// prefixes every cgroup path with the container's own.
|
|
func unitCgroupDir(ctx context.Context, t *testing.T, d *dockerutil.Container, unit string) string {
|
|
t.Helper()
|
|
pid := strings.TrimSpace(execOrFatal(ctx, t, d, "systemctl", "show", "-p", "MainPID", "--value", unit))
|
|
cg := strings.TrimSpace(execOrFatal(ctx, t, d, "cat", "/proc/"+pid+"/cgroup"))
|
|
return "/sys/fs/cgroup" + strings.TrimPrefix(cg, "0::")
|
|
}
|
|
|
|
// TestSystemdBoot verifies that systemd boots to a "running" state and that
|
|
// core services (journald, logind) are active.
|
|
func TestSystemdBoot(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
d := spawnSystemdContainer(ctx, t, "systemd-integ")
|
|
defer d.CleanUp(ctx)
|
|
|
|
wantJournaldActive := "active (running)"
|
|
out := execOrFatal(ctx, t, d, "systemctl", "status", "systemd-journald.service", "--no-pager")
|
|
if !strings.Contains(out, wantJournaldActive) {
|
|
t.Errorf("After systemd boot, systemd-journald service is not active (output does not contain %q):\n%s", wantJournaldActive, out)
|
|
}
|
|
|
|
wantLogindActive := "active (running)"
|
|
out = execOrFatal(ctx, t, d, "systemctl", "status", "systemd-logind.service", "--no-pager")
|
|
if !strings.Contains(out, wantLogindActive) {
|
|
t.Errorf("After systemd boot, systemd-logind service is not active (output does not contain %q):\n%s", wantLogindActive, out)
|
|
}
|
|
}
|
|
|
|
// TestSystemdUBI10Init boots Red Hat's UBI 10 init image: a second systemd
|
|
// lineage (RHEL 10, systemd v257, which mandates cgroup v2).
|
|
func TestSystemdUBI10Init(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
d := spawnSystemdContainer(ctx, t, "ubi10-init")
|
|
defer d.CleanUp(ctx)
|
|
|
|
if out := strings.TrimSpace(execOrFatal(ctx, t, d, "systemctl", "--failed", "--no-legend", "--plain")); out != "" {
|
|
t.Errorf("failed units after boot (want none):\n%s", out)
|
|
}
|
|
wantEcho := "gv-ubi10-ok"
|
|
out, err := transientRun(ctx, d, nil, "/bin/echo", wantEcho)
|
|
if err != nil || !strings.Contains(out, wantEcho) {
|
|
t.Errorf("transient unit failed: %v (output does not contain %q): %s", err, wantEcho, out)
|
|
}
|
|
}
|
|
|
|
// TestSystemdSimpleDaemon verifies that a custom systemd service can be enabled, started,
|
|
// stopped, and restarted by systemd after an out-of-band kill.
|
|
func TestSystemdSimpleDaemon(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
d := spawnSystemdContainer(ctx, t, "systemd-integ")
|
|
defer d.CleanUp(ctx)
|
|
|
|
execOrFatal(ctx, t, d, "systemctl", "enable", "test-daemon.service")
|
|
startService(ctx, t, d, "test-daemon.service")
|
|
|
|
wantDaemonActive := "active (running)"
|
|
out := execOrFatal(ctx, t, d, "systemctl", "status", "test-daemon.service", "--no-pager")
|
|
if !strings.Contains(out, wantDaemonActive) {
|
|
t.Fatalf("After starting test-daemon.service, status is not active (output does not contain %q):\n%s", wantDaemonActive, out)
|
|
}
|
|
|
|
// Get the main PID.
|
|
pidStr := strings.TrimSpace(execOrFatal(ctx, t, d, "systemctl", "show", "-p", "MainPID", "--value", "test-daemon.service"))
|
|
pid1, err := strconv.Atoi(pidStr)
|
|
if err != nil || pid1 == 0 {
|
|
t.Fatalf("unexpected MainPID %q: %v", pidStr, err)
|
|
}
|
|
t.Logf("test-daemon started with PID %d", pid1)
|
|
|
|
// Verify journalctl has output from the daemon.
|
|
wantJournalOutput := "Hello from test daemon"
|
|
out = execOrFatal(ctx, t, d, "journalctl", "-u", "test-daemon.service", "--no-pager")
|
|
if !strings.Contains(out, wantJournalOutput) {
|
|
t.Errorf("After starting test-daemon.service, journalctl is missing expected output (output does not contain %q):\n%s", wantJournalOutput, out)
|
|
}
|
|
|
|
// Stop the daemon and verify it is inactive.
|
|
stopService(ctx, t, d, "test-daemon.service")
|
|
|
|
// Start the daemon again, and grab its PID.
|
|
startService(ctx, t, d, "test-daemon.service")
|
|
pidStr = strings.TrimSpace(execOrFatal(ctx, t, d, "systemctl", "show", "-p", "MainPID", "--value", "test-daemon.service"))
|
|
pid2, err := strconv.Atoi(pidStr)
|
|
if err != nil || pid2 == 0 {
|
|
t.Fatalf("unexpected MainPID after restart %q: %v", pidStr, err)
|
|
}
|
|
t.Logf("test-daemon restarted with PID %d, killing it", pid2)
|
|
|
|
// Kill the daemon out-of-band.
|
|
execOrFatal(ctx, t, d, "kill", "-9", strconv.Itoa(pid2))
|
|
|
|
checkDaemonRestartedWithNewPID := func(ctx context.Context) error {
|
|
out, err := d.Exec(ctx, dockerutil.ExecOpts{User: "root"}, "systemctl", "is-active", "test-daemon.service")
|
|
if err != nil || strings.TrimSpace(out) != "active" {
|
|
return fmt.Errorf("test-daemon not restarted yet: %v (output: %s)", err, out)
|
|
}
|
|
pidStr, err := d.Exec(ctx, dockerutil.ExecOpts{User: "root"}, "systemctl", "show", "-p", "MainPID", "--value", "test-daemon.service")
|
|
if err != nil {
|
|
return fmt.Errorf("cannot get MainPID: %v", err)
|
|
}
|
|
pid3, err := strconv.Atoi(strings.TrimSpace(pidStr))
|
|
if err != nil || pid3 == 0 {
|
|
return fmt.Errorf("unexpected MainPID %q", pidStr)
|
|
}
|
|
if pid3 == pid2 {
|
|
return fmt.Errorf("PID has not changed yet (still %d)", pid2)
|
|
}
|
|
return nil
|
|
}
|
|
if err := pollWithTimeout(ctx, daemonPollTimeout, checkDaemonRestartedWithNewPID); err != nil {
|
|
t.Fatalf("systemd did not restart the daemon after kill: %v", err)
|
|
}
|
|
|
|
// Final verification: the daemon is running with a new PID.
|
|
pidStr = strings.TrimSpace(execOrFatal(ctx, t, d, "systemctl", "show", "-p", "MainPID", "--value", "test-daemon.service"))
|
|
pid3, err := strconv.Atoi(pidStr)
|
|
if err != nil || pid3 == 0 {
|
|
t.Fatalf("unexpected MainPID after restart %q: %v", pidStr, err)
|
|
}
|
|
t.Logf("systemd restarted daemon with new PID %d (was %d)", pid3, pid2)
|
|
if pid3 == pid2 {
|
|
t.Errorf("After out-of-band kill (kill -9 %d), systemctl reports test-daemon.service restarted but did not get a new PID (got same PID %d)", pid2, pid3)
|
|
}
|
|
}
|
|
|
|
// The tests below verify that popular software installed as systemd services
|
|
// runs under systemd inside gVisor. Each test boots the "systemd-services"
|
|
// image (where all service packages are installed but disabled), starts only
|
|
// the service under test, and exercises it end-to-end.
|
|
|
|
// TestSystemdNginx verifies that nginx serves HTTP as a systemd service and
|
|
// survives a `systemctl reload` (SIGHUP to the master process).
|
|
func TestSystemdNginx(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
d := spawnSystemdContainer(ctx, t, "systemd-services")
|
|
defer d.CleanUp(ctx)
|
|
|
|
startService(ctx, t, d, "nginx.service")
|
|
// Check the Server header: nginx and apache share the /var/www/html
|
|
// docroot, so the page content does not identify the server.
|
|
wantServerHeader := "Server: nginx"
|
|
out := execOrFatal(ctx, t, d, "curl", "-fsSI", "http://127.0.0.1/")
|
|
if !strings.Contains(out, wantServerHeader) {
|
|
t.Errorf("nginx response does not contain %q:\n%s", wantServerHeader, out)
|
|
}
|
|
|
|
// Reload and verify it still serves.
|
|
execOrFatal(ctx, t, d, "systemctl", "reload", "nginx.service")
|
|
out = execOrFatal(ctx, t, d, "curl", "-fsSI", "http://127.0.0.1/")
|
|
if !strings.Contains(out, wantServerHeader) {
|
|
t.Errorf("nginx response after reload does not contain %q:\n%s", wantServerHeader, out)
|
|
}
|
|
|
|
stopService(ctx, t, d, "nginx.service")
|
|
}
|
|
|
|
// TestSystemdApache verifies that Apache httpd serves HTTP as a systemd service.
|
|
func TestSystemdApache(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
d := spawnSystemdContainer(ctx, t, "systemd-services")
|
|
defer d.CleanUp(ctx)
|
|
|
|
startService(ctx, t, d, "apache2.service")
|
|
wantServerHeader := "Server: Apache"
|
|
out := execOrFatal(ctx, t, d, "curl", "-fsSI", "http://127.0.0.1/")
|
|
if !strings.Contains(out, wantServerHeader) {
|
|
t.Errorf("apache response does not contain %q:\n%s", wantServerHeader, out)
|
|
}
|
|
|
|
// Graceful reload.
|
|
execOrFatal(ctx, t, d, "systemctl", "reload", "apache2.service")
|
|
out = execOrFatal(ctx, t, d, "curl", "-fsSI", "http://127.0.0.1/")
|
|
if !strings.Contains(out, wantServerHeader) {
|
|
t.Errorf("apache response after reload does not contain %q:\n%s", wantServerHeader, out)
|
|
}
|
|
|
|
stopService(ctx, t, d, "apache2.service")
|
|
}
|
|
|
|
// setupSSHKeys generates any missing sshd host keys, and a root keypair
|
|
// authorized for login to self.
|
|
func setupSSHKeys(ctx context.Context, t *testing.T, d *dockerutil.Container) {
|
|
t.Helper()
|
|
execOrFatal(ctx, t, d, "bash", "-c", `
|
|
ssh-keygen -A &&
|
|
mkdir -p /root/.ssh &&
|
|
rm -f /root/.ssh/id_ed25519 /root/.ssh/id_ed25519.pub &&
|
|
ssh-keygen -t ed25519 -N '' -f /root/.ssh/id_ed25519 &&
|
|
cat /root/.ssh/id_ed25519.pub >> /root/.ssh/authorized_keys
|
|
`)
|
|
}
|
|
|
|
// waitForSSH retries a key-authenticated ssh round trip to localhost until it
|
|
// succeeds: sshd can take a moment to accept connections.
|
|
func waitForSSH(ctx context.Context, t *testing.T, d *dockerutil.Container) {
|
|
t.Helper()
|
|
wantEcho := "ssh-ok"
|
|
checkSSH := func(ctx context.Context) error {
|
|
out, err := d.Exec(ctx, dockerutil.ExecOpts{User: "root"},
|
|
"ssh", "-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=yes",
|
|
"[email protected]", "echo", wantEcho)
|
|
if err != nil || !strings.Contains(out, wantEcho) {
|
|
return fmt.Errorf("ssh failed: %v (output does not contain %q): %s", err, wantEcho, out)
|
|
}
|
|
return nil
|
|
}
|
|
if err := pollWithTimeout(ctx, daemonPollTimeout, checkSSH); err != nil {
|
|
t.Fatalf("could not ssh to localhost: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestSystemdSSH verifies that sshd accepts a key-authenticated session as a
|
|
// systemd service. This also exercises PAM and the pam_systemd/logind interaction.
|
|
func TestSystemdSSH(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
d := spawnSystemdContainer(ctx, t, "systemd-services")
|
|
defer d.CleanUp(ctx)
|
|
|
|
setupSSHKeys(ctx, t, d)
|
|
startService(ctx, t, d, "ssh.service")
|
|
waitForSSH(ctx, t, d)
|
|
|
|
// Verify the login was registered with logind, i.e. that pam_systemd ran:
|
|
// from inside the ssh session, the caller's own logind session ("self")
|
|
// must exist and have class "user".
|
|
wantClass := "Class=user"
|
|
out := execOrFatal(ctx, t, d,
|
|
"ssh", "-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=yes",
|
|
"[email protected]", "loginctl", "show-session", "self", "-p", "Class")
|
|
if !strings.Contains(out, wantClass) {
|
|
t.Errorf("ssh session not registered with logind (output does not contain %q):\n%s", wantClass, out)
|
|
}
|
|
|
|
stopService(ctx, t, d, "ssh.service")
|
|
}
|
|
|
|
// TestSystemdPostgreSQL verifies that PostgreSQL runs as a systemd service
|
|
// and can execute a create/insert/select round trip.
|
|
func TestSystemdPostgreSQL(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
d := spawnSystemdContainer(ctx, t, "systemd-services")
|
|
defer d.CleanUp(ctx)
|
|
|
|
startService(ctx, t, d, "postgresql.service")
|
|
|
|
// postgresql.service is a wrapper unit; wait until the cluster actually
|
|
// accepts connections.
|
|
wantReady := "accepting connections"
|
|
checkPostgresReady := func(ctx context.Context) error {
|
|
out, err := d.Exec(ctx, dockerutil.ExecOpts{User: "root"}, "sudo", "-u", "postgres", "pg_isready")
|
|
if err != nil || !strings.Contains(out, wantReady) {
|
|
return fmt.Errorf("postgres not ready: %v (output does not contain %q): %s", err, wantReady, out)
|
|
}
|
|
return nil
|
|
}
|
|
if err := pollWithTimeout(ctx, time.Minute, checkPostgresReady); err != nil {
|
|
t.Fatalf("postgres did not accept connections: %v", err)
|
|
}
|
|
|
|
execOrFatal(ctx, t, d, "sudo", "-u", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-c", "CREATE TABLE gv (i int)")
|
|
execOrFatal(ctx, t, d, "sudo", "-u", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-c", "INSERT INTO gv VALUES (42)")
|
|
wantValue := "42"
|
|
out := execOrFatal(ctx, t, d, "sudo", "-u", "postgres", "psql", "-tA", "-c", "SELECT i FROM gv")
|
|
if !strings.Contains(out, wantValue) {
|
|
t.Errorf("select output does not contain %q:\n%s", wantValue, out)
|
|
}
|
|
|
|
stopService(ctx, t, d, "postgresql.service")
|
|
}
|
|
|
|
// TestSystemdMariaDB verifies that MariaDB runs as a systemd service and can
|
|
// execute a create/insert/select round trip.
|
|
func TestSystemdMariaDB(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
d := spawnSystemdContainer(ctx, t, "systemd-services")
|
|
defer d.CleanUp(ctx)
|
|
|
|
startService(ctx, t, d, "mariadb.service")
|
|
|
|
execOrFatal(ctx, t, d, "mysql", "-e", "CREATE DATABASE gv; CREATE TABLE gv.t (i INT); INSERT INTO gv.t VALUES (42);")
|
|
wantValue := "42"
|
|
out := execOrFatal(ctx, t, d, "mysql", "-Ne", "SELECT i FROM gv.t")
|
|
if !strings.Contains(out, wantValue) {
|
|
t.Errorf("select output does not contain %q:\n%s", wantValue, out)
|
|
}
|
|
|
|
stopService(ctx, t, d, "mariadb.service")
|
|
}
|
|
|
|
// TestSystemdRedis verifies that Redis runs as a systemd service (Type=notify)
|
|
// and that a background save (which fork()s the server) succeeds.
|
|
func TestSystemdRedis(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
d := spawnSystemdContainer(ctx, t, "systemd-services")
|
|
defer d.CleanUp(ctx)
|
|
|
|
startService(ctx, t, d, "redis-server.service")
|
|
|
|
wantPing := "PONG"
|
|
if out := execOrFatal(ctx, t, d, "redis-cli", "ping"); !strings.Contains(out, wantPing) {
|
|
t.Errorf("ping output does not contain %q: %s", wantPing, out)
|
|
}
|
|
wantValue := "42"
|
|
execOrFatal(ctx, t, d, "redis-cli", "set", "gv", wantValue)
|
|
if out := execOrFatal(ctx, t, d, "redis-cli", "get", "gv"); !strings.Contains(out, wantValue) {
|
|
t.Errorf("get output does not contain %q: %s", wantValue, out)
|
|
}
|
|
|
|
// Force a background save to exercise the fork() path.
|
|
execOrFatal(ctx, t, d, "redis-cli", "bgsave")
|
|
wantBgsaveDone := "rdb_bgsave_in_progress:0"
|
|
wantBgsaveOK := "rdb_last_bgsave_status:ok"
|
|
checkBgsave := func(ctx context.Context) error {
|
|
out, err := d.Exec(ctx, dockerutil.ExecOpts{User: "root"}, "redis-cli", "info", "persistence")
|
|
if err != nil {
|
|
return fmt.Errorf("redis-cli info failed: %v", err)
|
|
}
|
|
if !strings.Contains(out, wantBgsaveDone) {
|
|
return fmt.Errorf("bgsave still in progress (output does not contain %q)", wantBgsaveDone)
|
|
}
|
|
if !strings.Contains(out, wantBgsaveOK) {
|
|
return fmt.Errorf("bgsave did not succeed (output does not contain %q):\n%s", wantBgsaveOK, out)
|
|
}
|
|
return nil
|
|
}
|
|
if err := pollWithTimeout(ctx, daemonPollTimeout, checkBgsave); err != nil {
|
|
t.Fatalf("redis background save failed: %v", err)
|
|
}
|
|
|
|
stopService(ctx, t, d, "redis-server.service")
|
|
}
|
|
|
|
const alpineImageRef = "mirror.gcr.io/library/alpine:3.22"
|
|
|
|
// TestSystemdDocker verifies that dockerd runs as a systemd service and can
|
|
// pull images and run containers.
|
|
func TestSystemdDocker(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
d := spawnSystemdContainer(ctx, t, "systemd-services")
|
|
defer d.CleanUp(ctx)
|
|
|
|
// Overlay upper layers cannot sit on runsc's sandbox-internal overlay
|
|
// rootfs, so back the state directories with tmpfs.
|
|
execOrFatal(ctx, t, d, "mkdir", "-p", "/var/lib/docker", "/var/lib/containerd")
|
|
execOrFatal(ctx, t, d, "mount", "-t", "tmpfs", "tmpfs", "/var/lib/docker")
|
|
execOrFatal(ctx, t, d, "mount", "-t", "tmpfs", "tmpfs", "/var/lib/containerd")
|
|
|
|
startService(ctx, t, d, "docker.service")
|
|
|
|
// The daemon must be responsive.
|
|
execOrFatal(ctx, t, d, "docker", "info")
|
|
|
|
// Pull an image and verify it is stored.
|
|
execOrFatal(ctx, t, d, "docker", "pull", alpineImageRef)
|
|
wantImage := "mirror.gcr.io/library/alpine"
|
|
out := execOrFatal(ctx, t, d, "docker", "image", "ls", alpineImageRef)
|
|
if !strings.Contains(out, wantImage) {
|
|
t.Errorf("pulled image not listed (output does not contain %q):\n%s", wantImage, out)
|
|
}
|
|
|
|
// Run a command in a container from the pulled image (--network=none:
|
|
// the image disables dockerd's iptables integration).
|
|
wantRelease := "Alpine Linux"
|
|
out = execOrFatal(ctx, t, d, "docker", "run", "--network=none", "--rm", alpineImageRef, "cat", "/etc/os-release")
|
|
if !strings.Contains(out, wantRelease) {
|
|
t.Errorf("running a container did not produce its output (output does not contain %q):\n%s", wantRelease, out)
|
|
}
|
|
|
|
stopService(ctx, t, d, "docker.service")
|
|
}
|
|
|
|
// The tests below exercise systemd's event-driven activation mechanisms
|
|
// (timers, D-Bus, sockets) and the sd_notify watchdog protocol.
|
|
|
|
// TestSystemdTimer verifies that timer units fire repeatedly on both the
|
|
// monotonic clock (OnActiveSec/OnUnitActiveSec) and the realtime clock
|
|
// (OnCalendar).
|
|
func TestSystemdTimer(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
d := spawnSystemdContainer(ctx, t, "systemd-integ")
|
|
defer d.CleanUp(ctx)
|
|
|
|
// Two transient timers, each appending a line to its file on every fire.
|
|
// The default timer accuracy is 1 minute, so tighten it to keep the test
|
|
// fast.
|
|
execOrFatal(ctx, t, d, "systemd-run", "--unit=gv-timer-mono",
|
|
"--on-active=1", "--on-unit-active=1", "--timer-property=AccuracySec=100ms",
|
|
"/bin/sh", "-c", "echo fired >> /run/gv-timer-mono")
|
|
execOrFatal(ctx, t, d, "systemd-run", "--unit=gv-timer-cal",
|
|
"--on-calendar=*-*-* *:*:*", "--timer-property=AccuracySec=100ms",
|
|
"/bin/sh", "-c", "echo fired >> /run/gv-timer-cal")
|
|
|
|
// Each timer must fire at least twice: this proves both the initial fire
|
|
// and the rescheduling of a repeating timer.
|
|
for _, file := range []string{"/run/gv-timer-mono", "/run/gv-timer-cal"} {
|
|
checkFiredTwice := func(ctx context.Context) error {
|
|
out, err := d.Exec(ctx, dockerutil.ExecOpts{User: "root"}, "/bin/sh", "-c", "wc -l < "+file)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot count timer fires: %v (output: %s)", err, out)
|
|
}
|
|
fires, err := strconv.Atoi(strings.TrimSpace(out))
|
|
if err != nil {
|
|
return fmt.Errorf("unexpected wc output %q: %v", out, err)
|
|
}
|
|
if fires < 2 {
|
|
return fmt.Errorf("%s timer fired %d times, want at least 2", file, fires)
|
|
}
|
|
return nil
|
|
}
|
|
if err := pollWithTimeout(ctx, daemonPollTimeout, checkFiredTwice); err != nil {
|
|
t.Errorf("timer writing to %s did not fire repeatedly: %v", file, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSystemdDBusActivation verifies that sending a message to a well-known
|
|
// bus name starts the service that owns it. systemd-hostnamed is D-Bus
|
|
// activated: it is started on demand when org.freedesktop.hostname1 is called.
|
|
func TestSystemdDBusActivation(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
d := spawnSystemdContainer(ctx, t, "systemd-integ")
|
|
defer d.CleanUp(ctx)
|
|
|
|
wantInactive := "inactive"
|
|
if state := unitState(ctx, t, d, "systemd-hostnamed.service"); state != wantInactive {
|
|
t.Fatalf("Before the bus call, systemd-hostnamed.service ActiveState is %q, want %q", state, wantInactive)
|
|
}
|
|
|
|
hostname := strings.TrimSpace(execOrFatal(ctx, t, d, "hostname"))
|
|
out := execOrFatal(ctx, t, d, "busctl", "call", "org.freedesktop.hostname1",
|
|
"/org/freedesktop/hostname1", "org.freedesktop.DBus.Properties", "Get",
|
|
"ss", "org.freedesktop.hostname1", "Hostname")
|
|
if wantReply := fmt.Sprintf("%q", hostname); !strings.Contains(out, wantReply) {
|
|
t.Errorf("bus call reply does not contain the hostname %s:\n%s", wantReply, out)
|
|
}
|
|
|
|
wantActive := "active"
|
|
if state := unitState(ctx, t, d, "systemd-hostnamed.service"); state != wantActive {
|
|
t.Errorf("After the bus call, systemd-hostnamed.service ActiveState is %q, want %q", state, wantActive)
|
|
}
|
|
}
|
|
|
|
// TestSystemdSocketActivation verifies socket activation: with only
|
|
// ssh.socket started, systemd starts ssh.service upon the first connection,
|
|
// and sshd serves that connection on the inherited listening socket.
|
|
func TestSystemdSocketActivation(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
d := spawnSystemdContainer(ctx, t, "systemd-services")
|
|
defer d.CleanUp(ctx)
|
|
|
|
setupSSHKeys(ctx, t, d)
|
|
startService(ctx, t, d, "ssh.socket")
|
|
|
|
// The socket is listening, but the service must only start upon the
|
|
// first connection.
|
|
wantInactive := "inactive"
|
|
if state := unitState(ctx, t, d, "ssh.service"); state != wantInactive {
|
|
t.Fatalf("After starting ssh.socket, ssh.service ActiveState is %q, want %q", state, wantInactive)
|
|
}
|
|
|
|
waitForSSH(ctx, t, d)
|
|
|
|
wantActive := "active"
|
|
if state := unitState(ctx, t, d, "ssh.service"); state != wantActive {
|
|
t.Errorf("After connecting to the socket, ssh.service ActiveState is %q, want %q", state, wantActive)
|
|
}
|
|
}
|
|
|
|
// TestSystemdWatchdog verifies the sd_notify watchdog protocol: a service
|
|
// that pings the watchdog stays up, and systemd kills it with Result=watchdog
|
|
// once the pings stop.
|
|
func TestSystemdWatchdog(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
d := spawnSystemdContainer(ctx, t, "systemd-integ")
|
|
defer d.CleanUp(ctx)
|
|
|
|
// A transient notify service with a 5s watchdog: it pings the watchdog
|
|
// every 0.5s until the stop file appears, then stops pinging while
|
|
// staying alive.
|
|
const stopFile = "/run/gv-watchdog-stop"
|
|
execOrFatal(ctx, t, d, "systemd-run", "--unit=gv-watchdog",
|
|
"-p", "Type=notify", "-p", "NotifyAccess=all", "-p", "WatchdogSec=5",
|
|
"/bin/bash", "-c",
|
|
"systemd-notify --ready; while [ ! -e "+stopFile+" ]; do systemd-notify WATCHDOG=1; sleep 0.5; done; sleep infinity")
|
|
|
|
dumpJournal := func() string {
|
|
journal, _ := d.Exec(ctx, dockerutil.ExecOpts{User: "root"}, "journalctl", "-u", "gv-watchdog.service", "--no-pager")
|
|
return journal
|
|
}
|
|
checkActive := func(ctx context.Context) error {
|
|
out, err := d.Exec(ctx, dockerutil.ExecOpts{User: "root"}, "systemctl", "is-active", "gv-watchdog.service")
|
|
if err != nil || strings.TrimSpace(out) != "active" {
|
|
return fmt.Errorf("gv-watchdog not active: %v (output: %s)", err, out)
|
|
}
|
|
return nil
|
|
}
|
|
if err := pollWithTimeout(ctx, daemonPollTimeout, checkActive); err != nil {
|
|
t.Fatalf("gv-watchdog did not start: %v\njournal:\n%s", err, dumpJournal())
|
|
}
|
|
|
|
// Well past WatchdogSec, the service must still be alive: the pings are
|
|
// keeping the watchdog at bay.
|
|
time.Sleep(6 * time.Second)
|
|
if err := pollWithTimeout(ctx, daemonPollTimeout, checkActive); err != nil {
|
|
t.Fatalf("gv-watchdog died while still pinging the watchdog: %v\njournal:\n%s", err, dumpJournal())
|
|
}
|
|
|
|
// Tell the service to stop pinging. It stays alive (sleep infinity),
|
|
// so any state change from here on is the watchdog's doing.
|
|
execOrFatal(ctx, t, d, "touch", stopFile)
|
|
|
|
// With the pings stopped, systemd must kill the service and record
|
|
// the watchdog as the failure reason.
|
|
wantState := "ActiveState=failed"
|
|
wantResult := "Result=watchdog"
|
|
checkWatchdogFired := func(ctx context.Context) error {
|
|
out, err := d.Exec(ctx, dockerutil.ExecOpts{User: "root"}, "systemctl", "show", "-p", "ActiveState,Result", "gv-watchdog.service")
|
|
if err != nil {
|
|
return fmt.Errorf("systemctl show failed: %v (output: %s)", err, out)
|
|
}
|
|
if !strings.Contains(out, wantState) || !strings.Contains(out, wantResult) {
|
|
return fmt.Errorf("gv-watchdog not yet killed by the watchdog (want %s and %s): %s", wantState, wantResult, out)
|
|
}
|
|
return nil
|
|
}
|
|
if err := pollWithTimeout(ctx, daemonPollTimeout, checkWatchdogFired); err != nil {
|
|
t.Errorf("systemd did not kill gv-watchdog after pings stopped: %v\njournal:\n%s", err, dumpJournal())
|
|
}
|
|
}
|
|
|
|
// The tests below exercise kernel functionality that core PID1 machinery
|
|
// depends on. They are patterned after systemd's own integration tests
|
|
// (test/units/TEST-* in the systemd repo).
|
|
|
|
// transientRun runs a command synchronously in a transient unit via
|
|
// `systemd-run --pipe` with the given unit properties.
|
|
func transientRun(ctx context.Context, d *dockerutil.Container, props []string, argv ...string) (string, error) {
|
|
args := []string{"systemd-run", "--quiet", "--collect", "--wait", "--pipe"}
|
|
for _, p := range props {
|
|
args = append(args, "--property="+p)
|
|
}
|
|
args = append(args, "--")
|
|
args = append(args, argv...)
|
|
return d.Exec(ctx, dockerutil.ExecOpts{User: "root"}, args...)
|
|
}
|
|
|
|
// TestSystemdExecSandboxing verifies per-service sandboxing knobs:
|
|
// PrivateTmp=, ProtectSystem=strict, User=, and NoNewPrivileges=.
|
|
func TestSystemdExecSandboxing(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
d := spawnSystemdContainer(ctx, t, "systemd-integ")
|
|
defer d.CleanUp(ctx)
|
|
|
|
// A marker in the host /tmp must be invisible under PrivateTmp=yes.
|
|
hostMarker := "gv-host-marker"
|
|
execOrFatal(ctx, t, d, "touch", "/tmp/"+hostMarker)
|
|
out, err := transientRun(ctx, d, []string{"PrivateTmp=yes"}, "/bin/ls", "/tmp")
|
|
if err != nil {
|
|
t.Fatalf("systemd-run with PrivateTmp=yes failed: %v (output: %s)", err, out)
|
|
}
|
|
if strings.Contains(out, hostMarker) {
|
|
t.Errorf("PrivateTmp=yes service sees the host /tmp (output contains %q):\n%s", hostMarker, out)
|
|
}
|
|
// And a file created in the private /tmp must not leak to the host /tmp.
|
|
if out, err := transientRun(ctx, d, []string{"PrivateTmp=yes"}, "/usr/bin/touch", "/tmp/gv-private-marker"); err != nil {
|
|
t.Fatalf("touch in the private /tmp failed: %v (output: %s)", err, out)
|
|
}
|
|
if out, err := d.Exec(ctx, dockerutil.ExecOpts{User: "root"}, "test", "-e", "/tmp/gv-private-marker"); err == nil {
|
|
t.Errorf("file created under PrivateTmp=yes leaked to the host /tmp (output: %s)", out)
|
|
}
|
|
|
|
// ProtectSystem=strict must make /usr read-only.
|
|
wantEROFS := "Read-only file system"
|
|
out, _ = transientRun(ctx, d, []string{"ProtectSystem=strict"}, "/bin/bash", "-c", "touch /usr/gv-protected 2>&1")
|
|
if !strings.Contains(out, wantEROFS) {
|
|
t.Errorf("writing /usr under ProtectSystem=strict did not fail with EROFS (output does not contain %q):\n%s", wantEROFS, out)
|
|
}
|
|
if _, err := d.Exec(ctx, dockerutil.ExecOpts{User: "root"}, "test", "-e", "/usr/gv-protected"); err == nil {
|
|
t.Errorf("ProtectSystem=strict did not prevent creating /usr/gv-protected")
|
|
}
|
|
|
|
// User= must run the payload as the requested user.
|
|
wantUser := "nobody"
|
|
out, err = transientRun(ctx, d, []string{"User=nobody"}, "/usr/bin/id", "-un")
|
|
if err != nil {
|
|
t.Fatalf("systemd-run with User=nobody failed: %v (output: %s)", err, out)
|
|
}
|
|
if got := strings.TrimSpace(out); got != wantUser {
|
|
t.Errorf("User=nobody service runs as the wrong user (got %q, want %q)", got, wantUser)
|
|
}
|
|
|
|
// NoNewPrivileges= must set the no_new_privs bit.
|
|
nnpProbe := `awk '/^NoNewPrivs:/{print $2}' /proc/self/status`
|
|
wantNNP := "1"
|
|
out, err = transientRun(ctx, d, []string{"NoNewPrivileges=yes"}, "/bin/bash", "-c", nnpProbe)
|
|
if err != nil {
|
|
t.Fatalf("systemd-run with NoNewPrivileges=yes failed: %v (output: %s)", err, out)
|
|
}
|
|
if got := strings.TrimSpace(out); got != wantNNP {
|
|
t.Errorf("NoNewPrivileges=yes service has the wrong no_new_privs bit (got %q, want %q)", got, wantNNP)
|
|
}
|
|
// Control: without the property the bit must be unset, proving the
|
|
// probe does not always report 1.
|
|
wantNoNNP := "0"
|
|
out, err = transientRun(ctx, d, nil, "/bin/bash", "-c", nnpProbe)
|
|
if err != nil {
|
|
t.Fatalf("systemd-run for the no_new_privs control failed: %v (output: %s)", err, out)
|
|
}
|
|
if got := strings.TrimSpace(out); got != wantNoNNP {
|
|
t.Errorf("service without NoNewPrivileges has the wrong no_new_privs bit (got %q, want %q)", got, wantNoNNP)
|
|
}
|
|
}
|
|
|
|
// userExec runs a command as the given user, as a transient unit in the
|
|
// user's service manager (systemd-run --machine=<user>@.host --user).
|
|
// Unlike `docker exec --user`, the user is resolved inside the sandbox.
|
|
// The command line is parsed as an ExecStart= value, so "$$" escapes a
|
|
// literal "$".
|
|
func userExec(ctx context.Context, d *dockerutil.Container, user string, args ...string) (string, error) {
|
|
fullArgs := append([]string{"systemd-run", "--machine=" + user + "@.host", "--user",
|
|
"--quiet", "--collect", "--wait", "--pipe", "--"}, args...)
|
|
return d.Exec(ctx, dockerutil.ExecOpts{User: "root"}, fullArgs...)
|
|
}
|
|
|
|
// userExecOrFatal is userExec, fataling on error.
|
|
func userExecOrFatal(ctx context.Context, t *testing.T, d *dockerutil.Container, user string, args ...string) string {
|
|
t.Helper()
|
|
out, err := userExec(ctx, d, user, args...)
|
|
if err != nil {
|
|
t.Fatalf("exec %v as %s failed: %v (output: %s)", args, user, err, out)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestSystemdMultiUser proves multiuser operation: unprivileged per-user
|
|
// managers in delegated cgroup subtrees, per-user daemons and journals, and
|
|
// enforcement between users. The subtests share one container and run in
|
|
// parallel, so cleanup is registered with t.Cleanup rather than deferred.
|
|
func TestSystemdMultiUser(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
d := spawnSystemdContainer(ctx, t, "systemd-integ")
|
|
t.Cleanup(func() { d.CleanUp(context.Background()) })
|
|
|
|
const (
|
|
aliceUID = 1001
|
|
bobUID = 1002
|
|
)
|
|
aliceUnit := fmt.Sprintf("user@%d.service", aliceUID)
|
|
bobUnit := fmt.Sprintf("user@%d.service", bobUID)
|
|
|
|
// Fixture: two users, persistent journal, both managers booted via
|
|
// lingering (no login needed).
|
|
execOrFatal(ctx, t, d, "useradd", "-m", "-u", strconv.Itoa(aliceUID), "alice")
|
|
execOrFatal(ctx, t, d, "useradd", "-m", "-u", strconv.Itoa(bobUID), "bob")
|
|
// journald only splits out per-user journal files (and ACLs them) when
|
|
// /var/log/journal exists.
|
|
execOrFatal(ctx, t, d, "mkdir", "-p", "/var/log/journal")
|
|
execOrFatal(ctx, t, d, "systemctl", "restart", "systemd-journald")
|
|
execOrFatal(ctx, t, d, "journalctl", "--flush")
|
|
execOrFatal(ctx, t, d, "loginctl", "enable-linger", "alice", "bob")
|
|
waitForUnitActive(ctx, t, d, aliceUnit)
|
|
waitForUnitActive(ctx, t, d, bobUnit)
|
|
checkManagerRunning := func(ctx context.Context) error {
|
|
out, err := userExec(ctx, d, "alice", "systemctl", "--user", "is-system-running")
|
|
if err != nil || strings.TrimSpace(out) != "running" {
|
|
return fmt.Errorf("alice's manager not running: %v (output: %s)", err, out)
|
|
}
|
|
return nil
|
|
}
|
|
if err := pollWithTimeout(ctx, daemonPollTimeout, checkManagerRunning); err != nil {
|
|
t.Fatalf("alice's user manager did not reach the running state: %v", err)
|
|
}
|
|
aliceCgroup := strings.TrimSuffix(unitCgroupDir(ctx, t, d, aliceUnit), "/init.scope")
|
|
|
|
t.Run("RuntimeDir", func(t *testing.T) {
|
|
t.Parallel()
|
|
// The per-user runtime directory must be a private tmpfs owned by
|
|
// the user.
|
|
runtimeDir := fmt.Sprintf("/run/user/%d", aliceUID)
|
|
wantFstype := "tmpfs"
|
|
if got := strings.TrimSpace(execOrFatal(ctx, t, d, "findmnt", "-n", "-o", "FSTYPE", runtimeDir)); got != wantFstype {
|
|
t.Errorf("%s has the wrong filesystem type (got %q, want %q)", runtimeDir, got, wantFstype)
|
|
}
|
|
wantOwnerMode := "alice 700"
|
|
if got := strings.TrimSpace(execOrFatal(ctx, t, d, "stat", "-c", "%U %a", runtimeDir)); got != wantOwnerMode {
|
|
t.Errorf("%s has the wrong owner/mode (got %q, want %q)", runtimeDir, got, wantOwnerMode)
|
|
}
|
|
})
|
|
|
|
t.Run("MachinedShell", func(t *testing.T) {
|
|
t.Parallel()
|
|
// machinectl shell asks systemd-machined to allocate a pty and
|
|
// spawn the command as the target user.
|
|
wantUser := "alice"
|
|
out, err := d.Exec(ctx, dockerutil.ExecOpts{User: "root", UseTTY: true},
|
|
"machinectl", "--quiet", "shell", "[email protected]", "/usr/bin/id", "-un")
|
|
if err != nil {
|
|
t.Fatalf("machinectl shell failed: %v (output: %s)", err, out)
|
|
}
|
|
if !strings.Contains(out, wantUser) {
|
|
t.Errorf("machinectl shell ran as the wrong user (output does not contain %q):\n%s", wantUser, out)
|
|
}
|
|
})
|
|
|
|
t.Run("CgroupDelegation", func(t *testing.T) {
|
|
t.Parallel()
|
|
// The delegated subtree must be chowned to the user...
|
|
wantOwner := "alice"
|
|
for _, path := range []string{aliceCgroup, aliceCgroup + "/cgroup.procs", aliceCgroup + "/cgroup.subtree_control"} {
|
|
if got := strings.TrimSpace(execOrFatal(ctx, t, d, "stat", "-c", "%U", path)); got != wantOwner {
|
|
t.Errorf("%s has the wrong owner (got %q, want %q)", path, got, wantOwner)
|
|
}
|
|
}
|
|
// ...the unprivileged manager must have built its own hierarchy
|
|
// inside it...
|
|
execOrFatal(ctx, t, d, "test", "-d", aliceCgroup+"/init.scope")
|
|
// ...and controllers must be available for delegation.
|
|
out := execOrFatal(ctx, t, d, "cat", aliceCgroup+"/cgroup.controllers")
|
|
for _, want := range []string{"memory", "pids"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("delegated cgroup.controllers is missing %q: %s", want, out)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("UserUnits", func(t *testing.T) {
|
|
t.Parallel()
|
|
// A transient user unit must run as the user, inside the
|
|
// delegated subtree, with limits written by its manager.
|
|
userExecOrFatal(ctx, t, d, "alice", "systemd-run", "--user", "--quiet", "--collect",
|
|
"--unit=gv-user-svc", "--property=MemoryMax=64M", "/bin/sleep", "infinity")
|
|
checkActive := func(ctx context.Context) error {
|
|
out, err := userExec(ctx, d, "alice", "systemctl", "--user", "is-active", "gv-user-svc.service")
|
|
if err != nil || strings.TrimSpace(out) != "active" {
|
|
return fmt.Errorf("gv-user-svc not active: %v (output: %s)", err, out)
|
|
}
|
|
return nil
|
|
}
|
|
if err := pollWithTimeout(ctx, daemonPollTimeout, checkActive); err != nil {
|
|
t.Fatalf("alice's transient unit did not become active: %v", err)
|
|
}
|
|
|
|
pidStr := strings.TrimSpace(userExecOrFatal(ctx, t, d, "alice",
|
|
"systemctl", "--user", "show", "-p", "MainPID", "--value", "gv-user-svc.service"))
|
|
wantOwner := "alice"
|
|
if got := strings.TrimSpace(execOrFatal(ctx, t, d, "stat", "-c", "%U", "/proc/"+pidStr)); got != wantOwner {
|
|
t.Errorf("the user unit's main process runs as the wrong user (got %q, want %q)", got, wantOwner)
|
|
}
|
|
|
|
// The unit's cgroup must live inside the delegated subtree.
|
|
cgOut := strings.TrimSpace(execOrFatal(ctx, t, d, "cat", "/proc/"+pidStr+"/cgroup"))
|
|
cgPath := strings.TrimPrefix(cgOut, "0::")
|
|
wantSubtree := fmt.Sprintf("/user@%d.service/", aliceUID)
|
|
if !strings.Contains(cgPath, wantSubtree) || !strings.Contains(cgPath, "gv-user-svc.service") {
|
|
t.Errorf("the user unit's cgroup %q is not inside the delegated subtree (want it to contain %q and %q)", cgPath, wantSubtree, "gv-user-svc.service")
|
|
}
|
|
// And MemoryMax= must have been written through by the unprivileged
|
|
// manager.
|
|
wantMemMax := "67108864"
|
|
if got := strings.TrimSpace(execOrFatal(ctx, t, d, "cat", "/sys/fs/cgroup"+cgPath+"/memory.max")); got != wantMemMax {
|
|
t.Errorf("the user unit's memory.max is wrong (got %q, want %q)", got, wantMemMax)
|
|
}
|
|
|
|
userExecOrFatal(ctx, t, d, "alice", "systemctl", "--user", "stop", "gv-user-svc.service")
|
|
})
|
|
|
|
t.Run("UserDaemon", func(t *testing.T) {
|
|
t.Parallel()
|
|
// gpg-agent's socket unit sits in the user manager; the first
|
|
// gpg invocation must socket-activate the daemon.
|
|
userExecOrFatal(ctx, t, d, "alice", "systemctl", "--user", "start", "gpg-agent.socket")
|
|
wantInactive := "inactive"
|
|
out, _ := userExec(ctx, d, "alice", "systemctl", "--user", "is-active", "gpg-agent.service")
|
|
if got := strings.TrimSpace(out); got != wantInactive {
|
|
t.Fatalf("before first use, gpg-agent.service state is wrong (got %q, want %q)", got, wantInactive)
|
|
}
|
|
|
|
// First use: key generation connects to the listening socket.
|
|
userExecOrFatal(ctx, t, d, "alice", "gpg", "--batch", "--pinentry-mode", "loopback",
|
|
"--passphrase", "", "--quick-gen-key", "[email protected]", "default", "default")
|
|
wantActive := "active"
|
|
if out, err := userExec(ctx, d, "alice", "systemctl", "--user", "is-active", "gpg-agent.service"); err != nil || strings.TrimSpace(out) != wantActive {
|
|
t.Fatalf("after first gpg use, gpg-agent.service was not socket-activated: %v (output: %s)", err, out)
|
|
}
|
|
|
|
// The agent gpg talks to must be the systemd-supervised process...
|
|
mainPID := strings.TrimSpace(userExecOrFatal(ctx, t, d, "alice",
|
|
"systemctl", "--user", "show", "-p", "MainPID", "--value", "gpg-agent.service"))
|
|
agentPID := strings.TrimSpace(userExecOrFatal(ctx, t, d, "alice", "/bin/sh", "-c",
|
|
"gpg-connect-agent 'getinfo pid' /bye | awk '/^D/{print $2}'"))
|
|
if mainPID == "0" || mainPID != agentPID {
|
|
t.Errorf("the agent serving gpg is not the systemd-supervised one (unit MainPID %q, agent reports pid %q)", mainPID, agentPID)
|
|
}
|
|
// ...running as alice inside her delegated cgroup subtree.
|
|
wantOwner := "alice"
|
|
if got := strings.TrimSpace(execOrFatal(ctx, t, d, "stat", "-c", "%U", "/proc/"+mainPID)); got != wantOwner {
|
|
t.Errorf("gpg-agent runs as the wrong user (got %q, want %q)", got, wantOwner)
|
|
}
|
|
wantSubtree := fmt.Sprintf("/user@%d.service/", aliceUID)
|
|
if cg := strings.TrimSpace(execOrFatal(ctx, t, d, "cat", "/proc/"+mainPID+"/cgroup")); !strings.Contains(cg, wantSubtree) {
|
|
t.Errorf("gpg-agent's cgroup %q is outside the delegated subtree (want it to contain %q)", cg, wantSubtree)
|
|
}
|
|
|
|
// End-to-end: sign and verify through the daemon.
|
|
wantGood := "Good signature"
|
|
out = userExecOrFatal(ctx, t, d, "alice", "/bin/sh", "-c",
|
|
"echo gv-sign-payload > /tmp/gv-msg && gpg --batch --yes -u [email protected] --output /tmp/gv-msg.sig --sign /tmp/gv-msg && gpg --verify /tmp/gv-msg.sig 2>&1")
|
|
if !strings.Contains(out, wantGood) {
|
|
t.Errorf("sign/verify through gpg-agent failed (output does not contain %q):\n%s", wantGood, out)
|
|
}
|
|
})
|
|
|
|
t.Run("PackagedUserDaemon", func(t *testing.T) {
|
|
t.Parallel()
|
|
// The image ships test-daemon.service only as a system unit;
|
|
// enable with a full path links it into alice's own manager.
|
|
unitPath := "/etc/systemd/system/test-daemon.service"
|
|
userExecOrFatal(ctx, t, d, "alice", "systemctl", "--user", "enable", "--now", unitPath)
|
|
defer userExec(ctx, d, "alice", "systemctl", "--user", "disable", "--now", "test-daemon.service")
|
|
wantLink := "/home/alice/.config/systemd/user/default.target.wants/test-daemon.service"
|
|
if _, err := d.Exec(ctx, dockerutil.ExecOpts{User: "root"}, "test", "-L", wantLink); err != nil {
|
|
t.Errorf("enabling the linked unit did not create the wants symlink %q: %v", wantLink, err)
|
|
}
|
|
|
|
checkActive := func(ctx context.Context) error {
|
|
out, err := userExec(ctx, d, "alice", "systemctl", "--user", "is-active", "test-daemon.service")
|
|
if err != nil || strings.TrimSpace(out) != "active" {
|
|
return fmt.Errorf("test-daemon not active in alice's manager: %v (output: %s)", err, out)
|
|
}
|
|
return nil
|
|
}
|
|
if err := pollWithTimeout(ctx, daemonPollTimeout, checkActive); err != nil {
|
|
t.Fatalf("alice's test-daemon did not become active: %v", err)
|
|
}
|
|
|
|
// The daemon must run as alice inside her delegated subtree.
|
|
mainPID := strings.TrimSpace(userExecOrFatal(ctx, t, d, "alice",
|
|
"systemctl", "--user", "show", "-p", "MainPID", "--value", "test-daemon.service"))
|
|
wantOwner := "alice"
|
|
if got := strings.TrimSpace(execOrFatal(ctx, t, d, "stat", "-c", "%U", "/proc/"+mainPID)); got != wantOwner {
|
|
t.Errorf("test-daemon runs as the wrong user (got %q, want %q)", got, wantOwner)
|
|
}
|
|
wantSubtree := fmt.Sprintf("/user@%d.service/", aliceUID)
|
|
if cg := strings.TrimSpace(execOrFatal(ctx, t, d, "cat", "/proc/"+mainPID+"/cgroup")); !strings.Contains(cg, wantSubtree) || !strings.Contains(cg, "test-daemon.service") {
|
|
t.Errorf("test-daemon's cgroup %q is outside the delegated subtree (want it to contain %q and %q)", cg, wantSubtree, "test-daemon.service")
|
|
}
|
|
|
|
// Its stdout must flow into alice's own journal.
|
|
wantMsg := "Hello from test daemon"
|
|
checkJournal := func(ctx context.Context) error {
|
|
out, err := userExec(ctx, d, "alice", "journalctl", "--user", "-u", "test-daemon.service", "--no-pager")
|
|
if err != nil || !strings.Contains(out, wantMsg) {
|
|
return fmt.Errorf("alice's journal has no test-daemon output yet: %v (output does not contain %q): %s", err, wantMsg, out)
|
|
}
|
|
return nil
|
|
}
|
|
if err := pollWithTimeout(ctx, daemonPollTimeout, checkJournal); err != nil {
|
|
t.Errorf("test-daemon's stdout did not reach alice's user journal: %v", err)
|
|
}
|
|
|
|
// Restart= supervision by the unprivileged manager: an
|
|
// out-of-band SIGKILL must produce a fresh main process.
|
|
execOrFatal(ctx, t, d, "kill", "-9", mainPID)
|
|
checkRestarted := func(ctx context.Context) error {
|
|
if err := checkActive(ctx); err != nil {
|
|
return err
|
|
}
|
|
out, err := userExec(ctx, d, "alice",
|
|
"systemctl", "--user", "show", "-p", "MainPID", "--value", "test-daemon.service")
|
|
if err != nil {
|
|
return fmt.Errorf("cannot get test-daemon's MainPID: %v (output: %s)", err, out)
|
|
}
|
|
if pid := strings.TrimSpace(out); pid == "0" || pid == mainPID {
|
|
return fmt.Errorf("test-daemon not respawned yet (MainPID %q)", pid)
|
|
}
|
|
return nil
|
|
}
|
|
if err := pollWithTimeout(ctx, daemonPollTimeout, checkRestarted); err != nil {
|
|
t.Errorf("alice's manager did not restart the killed daemon: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("Sandboxing", func(t *testing.T) {
|
|
t.Parallel()
|
|
// PrivateTmp= in a user unit is built entirely without privileges
|
|
// (user namespace + mount namespace as the user).
|
|
hostMarker := "gv-user-host-marker"
|
|
execOrFatal(ctx, t, d, "touch", "/tmp/"+hostMarker)
|
|
out, err := userExec(ctx, d, "alice", "systemd-run", "--user", "--quiet", "--collect", "--wait", "--pipe",
|
|
"--property=PrivateTmp=yes", "/bin/ls", "/tmp")
|
|
if err != nil {
|
|
t.Fatalf("user unit with PrivateTmp=yes failed: %v (output: %s)", err, out)
|
|
}
|
|
if strings.Contains(out, hostMarker) {
|
|
t.Errorf("user PrivateTmp service sees the host /tmp (output contains %q):\n%s", hostMarker, out)
|
|
}
|
|
if out, err := userExec(ctx, d, "alice", "systemd-run", "--user", "--quiet", "--collect", "--wait", "--pipe",
|
|
"--property=PrivateTmp=yes", "/usr/bin/touch", "/tmp/gv-user-priv-marker"); err != nil {
|
|
t.Fatalf("touch in the user's private /tmp failed: %v (output: %s)", err, out)
|
|
}
|
|
if _, err := d.Exec(ctx, dockerutil.ExecOpts{User: "root"}, "test", "-e", "/tmp/gv-user-priv-marker"); err == nil {
|
|
t.Errorf("file created under user PrivateTmp leaked to the host /tmp")
|
|
}
|
|
})
|
|
|
|
t.Run("JournalACL", func(t *testing.T) {
|
|
t.Parallel()
|
|
// alice's entry must land in her per-user journal file, made
|
|
// readable to her alone via a POSIX ACL.
|
|
wantMsg := "gv-alice-journal-msg"
|
|
userExecOrFatal(ctx, t, d, "alice", "systemd-cat", "-t", "gv-alice-tag", "echo", wantMsg)
|
|
execOrFatal(ctx, t, d, "journalctl", "--sync")
|
|
|
|
journalGlob := fmt.Sprintf("/var/log/journal/*/user-%d.journal", aliceUID)
|
|
var journalPath string
|
|
checkJournalFile := func(ctx context.Context) error {
|
|
out, err := d.Exec(ctx, dockerutil.ExecOpts{User: "root"}, "bash", "-c", "ls "+journalGlob)
|
|
if err != nil {
|
|
return fmt.Errorf("per-user journal file not created yet: %v (output: %s)", err, out)
|
|
}
|
|
journalPath = strings.TrimSpace(out)
|
|
return nil
|
|
}
|
|
if err := pollWithTimeout(ctx, daemonPollTimeout, checkJournalFile); err != nil {
|
|
t.Fatalf("journald did not create alice's per-user journal: %v", err)
|
|
}
|
|
|
|
// Base permissions alone must NOT grant alice access...
|
|
wantPerms := "root systemd-journal 640"
|
|
if got := strings.TrimSpace(execOrFatal(ctx, t, d, "stat", "-c", "%U %G %a", journalPath)); got != wantPerms {
|
|
t.Errorf("alice's journal file has the wrong base permissions (got %q, want %q)", got, wantPerms)
|
|
}
|
|
// ...the access must come from an ACL entry for alice.
|
|
wantACL := "user:alice:r"
|
|
if out := execOrFatal(ctx, t, d, "getfacl", "-p", journalPath); !strings.Contains(out, wantACL) {
|
|
t.Errorf("alice's journal file is missing her ACL entry (output does not contain %q):\n%s", wantACL, out)
|
|
}
|
|
|
|
// Enforcement, positive: alice can read her own message back.
|
|
checkAliceReads := func(ctx context.Context) error {
|
|
out, err := userExec(ctx, d, "alice", "journalctl", "-t", "gv-alice-tag", "--no-pager")
|
|
if err != nil || !strings.Contains(out, wantMsg) {
|
|
return fmt.Errorf("alice cannot read her journal entry yet: %v (output: %s)", err, out)
|
|
}
|
|
return nil
|
|
}
|
|
if err := pollWithTimeout(ctx, daemonPollTimeout, checkAliceReads); err != nil {
|
|
t.Errorf("alice cannot read her own journal through the ACL: %v", err)
|
|
}
|
|
|
|
// Enforcement, negative: bob must not be able to open alice's
|
|
// journal file.
|
|
wantDenied := "Permission denied"
|
|
out, err := userExec(ctx, d, "bob", "cat", journalPath)
|
|
if err == nil {
|
|
t.Errorf("bob unexpectedly opened alice's journal file %s", journalPath)
|
|
} else if !strings.Contains(out, wantDenied) {
|
|
t.Errorf("bob's read of alice's journal failed for the wrong reason (output does not contain %q): %s", wantDenied, out)
|
|
}
|
|
})
|
|
|
|
t.Run("CrossUserIsolation", func(t *testing.T) {
|
|
t.Parallel()
|
|
// Both managers must be alive simultaneously.
|
|
wantActive := "active"
|
|
for _, unit := range []string{aliceUnit, bobUnit} {
|
|
if state := unitState(ctx, t, d, unit); state != wantActive {
|
|
t.Errorf("%s ActiveState is wrong (got %q, want %q)", unit, state, wantActive)
|
|
}
|
|
}
|
|
|
|
// bob must not see into alice's runtime directory...
|
|
wantDenied := "Permission denied"
|
|
out, err := userExec(ctx, d, "bob", "ls", fmt.Sprintf("/run/user/%d", aliceUID))
|
|
if err == nil {
|
|
t.Errorf("bob unexpectedly listed alice's runtime directory (output: %s)", out)
|
|
} else if !strings.Contains(out, wantDenied) {
|
|
t.Errorf("bob's access to alice's runtime dir failed for the wrong reason (output does not contain %q): %s", wantDenied, out)
|
|
}
|
|
|
|
// ...and must not be able to write into alice's delegated cgroup
|
|
// subtree (the open for writing is what must fail).
|
|
targetFile := aliceCgroup + "/init.scope/cgroup.procs"
|
|
out, err = userExec(ctx, d, "bob", "bash", "-c", `echo 0 > "$$1"`, "--", targetFile)
|
|
if err == nil {
|
|
t.Errorf("bob unexpectedly migrated a process into alice's cgroup subtree (output: %s)", out)
|
|
} else if !strings.Contains(out, wantDenied) {
|
|
t.Errorf("bob's write to alice's cgroup failed for the wrong reason (output does not contain %q): %s", wantDenied, out)
|
|
}
|
|
})
|
|
}
|