package main
// The action-op gate, run for real (ops_action_linux.go): a program in a
// systemd transient unit, as a fresh uid, held to its destinations, seeing
// only the closed view, leaving nothing behind. These need root and systemd,
// and start real units on the machine; they skip anywhere else.
//
// Run them in a private PID namespace but NOT a private mount namespace: the
// unit is started by the machine's systemd, in the machine's mounts, and must
// see the run's tmpfs the test mounted. GOTMPDIR and TMPDIR name a folder
// on a disk, not under /tmp (a run gets its own /tmp):
//
// GOTMPDIR=
TMPDIR= unshare --pid --fork go test -run Action .
import (
"bytes"
"context"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
"golang.org/x/sys/unix"
)
func requireActionSandbox(t *testing.T) {
t.Helper()
if ok, why := opActionAvailable(); !ok {
t.Skip("no action sandbox here: " + why)
}
if err := os.MkdirAll(opTestRoot, 0o755); err != nil {
t.Fatal(err)
}
}
// actionOp writes an action op (a manifest with extra lines, a shell program)
// to a fresh directory and parses it.
func actionOp(t *testing.T, extra, program string) (string, *opManifest) {
t.Helper()
man := `name: optest-action
version: 1.0.0
summary: An action op under test.
kind: action
target: machine
privilege: user
interpreter: /bin/sh
time_limit: 30
params:
name: {type: string, max_len: 32}
` + extra + `modes: [dry_run, apply, verify]
undo: none
`
m, err := parseOpManifest([]byte(man))
if err != nil {
t.Fatalf("manifest: %v\n%s", err, man)
}
dir := filepath.Join(opTestRoot, fmt.Sprintf("aop%d", time.Now().UnixNano()))
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
_ = os.WriteFile(filepath.Join(dir, "op.yaml"), []byte(man), 0o644)
_ = os.WriteFile(filepath.Join(dir, "run"), []byte("#!/bin/sh\n"+program+"\n"), 0o755)
return dir, m
}
func actionTerms(m *opManifest) *opTerms {
return &opTerms{Kind: opKindRun, Op: m.Name, Version: m.Version, SHA256: strings.Repeat("a", 64),
Privilege: m.Privilege, Params: `{"name":"x"}`, Nonce: fmt.Sprintf("n-%d-0123456789", time.Now().UnixNano()),
Expires: time.Now().Add(time.Hour).UTC().Format(time.RFC3339), Uses: 1}
}
func runAction(t *testing.T, dir string, m *opManifest, tm *opTerms, mode string, blobs []string) (*opReport, error) {
t.Helper()
id, _ := newID()
actionRunsMu.Lock()
actionRuns[t.Name()] = append(actionRuns[t.Name()], id)
actionRunsMu.Unlock()
t.Cleanup(func() {
actionRunsMu.Lock()
delete(actionRuns, t.Name())
actionRunsMu.Unlock()
})
return opRunAction(dir, m, tm, mode, id, blobs, nil)
}
var (
actionRunsMu sync.Mutex
actionRuns = map[string][]string{} // test name -> the run ids it started
)
// leftUnits is the op units still on the machine from the runs this test
// started. The machine's list is not asked alone: another test run on the
// same machine has units of its own, and those are not this test's leftovers.
func leftUnits(t *testing.T) []string {
actionRunsMu.Lock()
ids := append([]string(nil), actionRuns[t.Name()]...)
actionRunsMu.Unlock()
var out []string
for _, u := range opActionUnits() {
for _, id := range ids {
if strings.HasPrefix(u, opActionUnitPrefix+truncate(id, 16)+"-") {
out = append(out, u)
}
}
}
return out
}
// localAddr is an address of this machine that is not loopback, so a test
// can listen on it and name it as a destination.
func localAddr(t *testing.T) net.IP {
addrs, _ := net.InterfaceAddrs()
for _, a := range addrs {
if n, ok := a.(*net.IPNet); ok && n.IP.To4() != nil && !n.IP.IsLoopback() && n.IP.IsGlobalUnicast() {
return n.IP
}
}
t.Skip("no non-loopback IPv4 address here")
return nil
}
func listen(t *testing.T, addr string) (net.Listener, int) {
t.Helper()
ln, err := net.Listen("tcp", addr)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
_, _ = c.Write([]byte("hello\n"))
_ = c.Close()
}
}()
return ln, ln.Addr().(*net.TCPAddr).Port
}
// A destination the op did not declare is out of reach: another port on the
// declared address (Landlock), another address (the unit's allowlist), and
// this machine's loopback.
func TestActionProgramReachesOnlyItsDestinations(t *testing.T) {
requireActionSandbox(t)
setupBox(t)
ip := localAddr(t)
_, declared := listen(t, net.JoinHostPort(ip.String(), "0"))
_, otherPort := listen(t, net.JoinHostPort(ip.String(), "0"))
_, loop := listen(t, "127.0.0.1:0")
// The declared port on another address: only the unit's address
// allowlist stands between the program and it (Landlock allows the port).
listen(t, net.JoinHostPort("127.0.0.1", strconv.Itoa(declared)))
// This machine's own address is refused as a destination outside tests;
// here it stands in for a remote one, reached by a name.
prevOwn, prevRes := opActionOwnAddrs, opActionResolve
opActionOwnAddrs = func() []net.IP { return nil }
opActionResolve = func(_ context.Context, h string) ([]net.IP, error) {
switch h {
case "dest.optest.invalid":
return []net.IP{ip}, nil
case "meta.optest.invalid":
return []net.IP{net.ParseIP("169.254.169.254")}, nil
}
return nil, fmt.Errorf("no")
}
t.Cleanup(func() { opActionOwnAddrs, opActionResolve = prevOwn, prevRes })
probe := func(host string, port int) string {
return fmt.Sprintf(`python3 -c 'import socket,sys
try:
s=socket.create_connection((%q,%d),timeout=3); print("REACHED %s:%d", s.recv(6).decode().strip())
except Exception as e:
print("BLOCKED %s:%d", type(e).__name__)'`, host, port, host, port, host, port)
}
prog := strings.Join([]string{
probe("dest.optest.invalid", declared),
probe(ip.String(), otherPort),
probe("127.0.0.1", loop),
probe("127.0.0.1", declared),
probe("1.1.1.1", 443),
}, "\n")
dir, m := actionOp(t, fmt.Sprintf("destinations: [\"dest.optest.invalid:%d\"]\n", declared), prog)
rep, err := runAction(t, dir, m, actionTerms(m), opModeApply, nil)
if err != nil {
t.Fatalf("%v\n%s", err, repOut(rep))
}
for _, want := range []string{
fmt.Sprintf("REACHED dest.optest.invalid:%d hello", declared),
fmt.Sprintf("BLOCKED %s:%d", ip, otherPort),
fmt.Sprintf("BLOCKED 127.0.0.1:%d", loop),
fmt.Sprintf("BLOCKED 127.0.0.1:%d", declared),
"BLOCKED 1.1.1.1:443",
} {
if !strings.Contains(rep.Output, want) {
t.Errorf("want %q in:\n%s", want, rep.Output)
}
}
// A dry run, which nobody approved, reaches nothing at all, its declared
// destination included.
rep, err = runAction(t, dir, m, actionTerms(m), opModeDryRun, nil)
if err != nil {
t.Fatalf("%v\n%s", err, repOut(rep))
}
if strings.Contains(rep.Output, "REACHED") || !strings.Contains(rep.Output, fmt.Sprintf("BLOCKED dest.optest.invalid:%d", declared)) {
t.Fatalf("a dry run has no network:\n%s", rep.Output)
}
// A name that resolves to the metadata service is refused at run start.
if _, err := opResolveDests(context.Background(), []string{"meta.optest.invalid:80"}); err == nil || !strings.Contains(err.Error(), "link-local") {
t.Fatalf("a destination that resolves to the metadata service: %v", err)
}
}
func repOut(r *opReport) string {
if r == nil {
return "(no report)"
}
return r.Output
}
// The program is a uid no one else is, with no capability, and it sees only
// the closed view: not /root, not the docker socket, not /etc/shadow, not an
// abstract socket outside it, and it writes nowhere but /tmp.
func TestActionProgramSeesNothingItWasNotGiven(t *testing.T) {
requireActionSandbox(t)
setupBox(t)
abs := fmt.Sprintf("privatae-optest-%d", time.Now().UnixNano())
aln, err := net.Listen("unix", "@"+abs)
if err != nil {
t.Fatal(err)
}
defer aln.Close()
go func() {
for {
c, err := aln.Accept()
if err != nil {
return
}
_ = c.Close()
}
}()
readable := filepath.Join(opTestRoot, fmt.Sprintf("r%d.txt", time.Now().UnixNano()))
_ = os.WriteFile(readable, []byte("DECLARED-READ\n"), 0o644)
t.Cleanup(func() { _ = os.RemoveAll(readable) })
// A folder anyone may write, declared as a read: only the read-only
// machine keeps the program from writing into it.
open := filepath.Join(opTestRoot, fmt.Sprintf("w%d", time.Now().UnixNano()))
_ = os.Mkdir(open, 0o777)
_ = os.Chmod(open, 0o777)
t.Cleanup(func() { _ = os.RemoveAll(open) })
prog := `echo "UID=$(id -u)"
grep -E '^(CapEff|CapBnd|NoNewPrivs)' /proc/self/status
echo "ROOT=[$(ls -A /root 2>&1 | tr '\n' ' ')]"
echo "SHADOW=[$(cat /etc/shadow 2>&1 | head -c 40)]"
for s in /var/run/docker.sock /run/docker.sock /run/containerd/containerd.sock /run/systemd/private; do
[ -e "$s" ] && echo "SEES $s" || echo "NO $s"
done
python3 -c 'import socket
s=socket.socket(socket.AF_UNIX)
try:
s.connect("\0` + abs + `"); print("ABSTRACT REACHED")
except Exception as e:
print("ABSTRACT BLOCKED", type(e).__name__)'
cat ` + readable + `
touch /usr/local/optest-probe 2>/dev/null && echo "WROTE /usr/local" || echo "RO /usr/local"
touch ` + opTestRoot + `/optest-probe 2>/dev/null && echo "WROTE optest" || echo "RO optest"
touch ` + open + `/probe 2>/dev/null && echo "WROTE open" || echo "RO open"
touch /tmp/ok && echo "WROTE /tmp"
echo x > /dev/null && echo "DEVNULL-OK"
echo "HOSTNAME=$(hostname)"
echo "CGROUP=$(cat /proc/self/cgroup)"`
dir, m := actionOp(t, "reads: ["+strconv.Quote(readable)+", "+strconv.Quote(open+"/")+"]\n", prog)
rep, err := runAction(t, dir, m, actionTerms(m), opModeApply, nil)
if err != nil {
t.Fatalf("%v\n%s", err, repOut(rep))
}
out := rep.Output
var uid uint64
for _, l := range strings.Split(out, "\n") {
if strings.HasPrefix(l, "UID=") {
uid, _ = strconv.ParseUint(strings.TrimPrefix(l, "UID="), 10, 64)
}
}
if uid < opUIDBase || uid >= opUIDBase+opUIDSpan {
t.Errorf("the program runs as a uid set aside for ops, got %d", uid)
}
for _, want := range []string{"CapEff:\t0000000000000000", "CapBnd:\t0000000000000000", "NoNewPrivs:\t1",
"ROOT=[]", "SHADOW=[]", "NO /var/run/docker.sock", "NO /run/docker.sock", "NO /run/systemd/private",
"CGROUP=0::/" + opActionSlice + "/" + opActionUnitPrefix, "ABSTRACT BLOCKED", "DECLARED-READ", "RO /usr/local", "RO optest", "RO open", "WROTE /tmp", "DEVNULL-OK", "HOSTNAME=op-sandbox"} {
if !strings.Contains(out, want) {
t.Errorf("want %q in:\n%s", want, out)
}
}
if _, err := os.Stat("/usr/local/optest-probe"); err == nil {
_ = os.Remove("/usr/local/optest-probe")
t.Error("the program wrote to the machine")
}
}
// A credential reaches the program, never its output, and nothing of the run
// is left afterwards: not the value on disk, not the tmpfs it sat on, not the
// work folder, not the unit, not a process the program left running.
func TestActionCredentialIsHandedAndNothingIsLeft(t *testing.T) {
requireActionSandbox(t)
setupBox(t)
secret := fmt.Sprintf("optest-secret-%d", time.Now().UnixNano())
// Every process of the program carries this mark, so a leftover is told
// apart from a process of another test run that was given the same uid.
mark := fmt.Sprintf("OPTEST_MARK=optest-mark-%d", time.Now().UnixNano())
prog := `export ` + mark + `
echo "TOKEN=$PRIVATAE_CRED_TOKEN"
echo "$PRIVATAE_CRED_TOKEN" > /tmp/leak
echo "UID=$(id -u)"
setsid sleep 300 >/dev/null 2>&1 < /dev/null &
echo started`
dir, m := actionOp(t, "credentials: [token]\n", prog)
tm := actionTerms(m)
pub, _, err := opEncKey()
if err != nil {
t.Fatal(err)
}
blob, _ := opSealCredFor(pub, opSealedCred{Name: "token", Value: secret, Nonce: tm.Nonce, Expires: tm.Expires})
// A dry run is handed nothing.
if _, err := runAction(t, dir, m, tm, opModeDryRun, []string{blob}); err == nil {
t.Fatal("a dry run handed a credential is refused")
}
rep, err := runAction(t, dir, m, tm, opModeApply, []string{blob})
if err != nil {
t.Fatalf("%v\n%s", err, repOut(rep))
}
if strings.Contains(rep.Output, secret) || !strings.Contains(rep.Output, "TOKEN=***") {
t.Fatalf("the program had it and its output does not: %s", rep.Output)
}
var uid string
for _, l := range strings.Split(rep.Output, "\n") {
if strings.HasPrefix(l, "UID=") {
uid = strings.TrimPrefix(l, "UID=")
}
}
root, _ := opRunsRoot()
entries, _ := os.ReadDir(root)
if len(entries) != 0 {
t.Errorf("the run left %d entries in %s", len(entries), root)
}
mi, _ := os.ReadFile("/proc/self/mountinfo")
if strings.Contains(string(mi), root) {
t.Error("the run's tmpfs is still mounted")
}
d, _ := configDir()
_ = filepath.Walk(d, func(p string, info os.FileInfo, err error) error {
if err == nil && info.Mode().IsRegular() {
if b, _ := os.ReadFile(p); strings.Contains(string(b), secret) {
t.Errorf("the credential is on disk at %s", p)
}
}
return nil
})
// The machine's systemd starts the unit outside this test's pid
// namespace, so the check reads every process it can see: any with the
// run's uid and this run's mark.
time.Sleep(500 * time.Millisecond)
procs, _ := filepath.Glob("/proc/[0-9]*/status")
for _, p := range procs {
b, _ := os.ReadFile(p)
for _, l := range strings.Split(string(b), "\n") {
if f := strings.Fields(l); len(f) >= 2 && f[0] == "Uid:" && f[1] == uid {
env, _ := os.ReadFile(filepath.Join(filepath.Dir(p), "environ"))
if strings.Contains(string(env), mark) {
t.Errorf("a process of the run's uid %s is still running: %s", uid, p)
}
}
}
}
if u := leftUnits(t); len(u) != 0 {
t.Errorf("op units remain: %v", u)
}
}
// An apply whose machine moved since the dry run lands nothing, and runs
// nothing: the program is never started.
func TestActionApplyOfAnotherChangeListRunsNothing(t *testing.T) {
requireActionSandbox(t)
setupBox(t)
marker := filepath.Join(opTestRoot, fmt.Sprintf("ran%d", time.Now().UnixNano()))
dir, m := actionOp(t, "", "echo ran")
tm := actionTerms(m)
tm.Changes = strings.Repeat("0", 64)
d, _ := configDir()
before, _ := os.ReadFile(filepath.Join(d, opUIDFile))
rep, err := runAction(t, dir, m, tm, opModeApply, nil)
if err == nil || !strings.Contains(err.Error(), "refused before anything ran") || rep != nil {
t.Fatalf("refused, with no run: %v %+v", err, rep)
}
after, _ := os.ReadFile(filepath.Join(d, opUIDFile))
if string(before) != string(after) {
t.Fatal("a uid was spent, so a program started")
}
_ = marker
}
// What the program reports it pushed becomes a receipt signed here; a result
// file that is a link is refused, never followed as root.
func TestActionResultBecomesAReceiptAndALinkIsRefused(t *testing.T) {
requireActionSandbox(t)
setupBox(t)
digest := "sha256:" + strings.Repeat("7", 64)
res := `{"images":[{"image":"forge.example.com/acme/web","digest":"` + digest +
`","source":"https://forge.example.com/acme/app.git","commit":"` + strings.Repeat("6", 40) + `"}]}`
dir, m := actionOp(t, "", "[ \"$PRIVATAE_OP_MODE\" = apply ] && printf '%s' '"+res+"' > \"$PRIVATAE_OP_RESULT\"; true")
rep, err := runAction(t, dir, m, actionTerms(m), opModeApply, nil)
if err != nil {
t.Fatalf("%v\n%s", err, repOut(rep))
}
if len(rep.Receipts) != 1 {
t.Fatalf("one receipt: %+v", rep.Receipts)
}
if r, err := opReceiptFor(digest); err != nil || r == nil || r.Commit != strings.Repeat("6", 40) || r.Op != "optest-action" {
t.Fatalf("held and verified: %+v %v", r, err)
}
dir2, m2 := actionOp(t, "", "[ \"$PRIVATAE_OP_MODE\" = apply ] && ln -s /etc/passwd \"$PRIVATAE_OP_RESULT\"; true")
if _, err := runAction(t, dir2, m2, actionTerms(m2), opModeApply, nil); err == nil || !strings.Contains(err.Error(), "not a plain file") {
t.Fatalf("a link is refused: %v", err)
}
}
// A bastion that died mid-run leaves a mounted run dir and a work folder;
// the next start removes both.
func TestStartupSweepRemovesWhatADeadRunLeft(t *testing.T) {
requireActionSandbox(t)
setupBox(t)
root, _ := opRunsRoot()
run := filepath.Join(root, "run-dead")
_ = os.Mkdir(run, 0o700)
if err := unix.Mount("tmpfs", run, "tmpfs", 0, "size=1m"); err != nil {
t.Fatal(err)
}
_ = os.WriteFile(filepath.Join(run, "cred-0"), []byte("x"), 0o400)
_ = os.Mkdir(run+"-work", 0o700)
opStartupSweep()
if entries, _ := os.ReadDir(root); len(entries) != 0 {
t.Fatalf("left: %v", entries)
}
var st syscall.Statfs_t
if err := syscall.Statfs(run, &st); err == nil {
t.Fatal("the dead run's tmpfs is still there")
}
}
// The builder's socket reaches the program in an apply, through the socket's
// group, and never in a dry run.
func TestActionBuilderSocketOnlyInApply(t *testing.T) {
requireActionSandbox(t)
setupBox(t)
sock := filepath.Join(t.TempDir(), "buildkitd.sock")
ln, err := net.Listen("unix", sock)
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
_, _ = c.Write([]byte("builder\n"))
_ = c.Close()
}
}()
// root:nogroup 0660: only the socket's group reaches it.
_ = os.Chown(sock, 0, 65534)
_ = os.Chmod(sock, 0o660)
prev := opBuilderSocket
opBuilderSocket = sock
t.Cleanup(func() { opBuilderSocket = prev })
prog := `echo "HOST=$BUILDKIT_HOST"
python3 -c 'import socket
s=socket.socket(socket.AF_UNIX)
try:
s.connect("/run/privatae-builder/buildkitd.sock"); print("BUILDER", s.recv(8).decode().strip())
except Exception as e:
print("NO BUILDER", type(e).__name__)'`
dir, m := actionOp(t, "builder: true\n", prog)
rep, err := runAction(t, dir, m, actionTerms(m), opModeApply, nil)
if err != nil {
t.Fatalf("%v\n%s", err, repOut(rep))
}
if !strings.Contains(rep.Output, "HOST=unix:///run/privatae-builder/buildkitd.sock") || !strings.Contains(rep.Output, "BUILDER builder") {
t.Fatalf("the apply reaches the builder: %s", rep.Output)
}
rep, err = runAction(t, dir, m, actionTerms(m), opModeDryRun, nil)
if err != nil {
t.Fatalf("%v\n%s", err, repOut(rep))
}
if !strings.Contains(rep.Output, "HOST=\n") || !strings.Contains(rep.Output, "NO BUILDER") {
t.Fatalf("a dry run never does: %s", rep.Output)
}
// A socket in root's group would hand the program that group: refused.
_ = os.Chown(sock, 0, 0)
if _, err := runAction(t, dir, m, actionTerms(m), opModeApply, nil); err == nil || !strings.Contains(err.Error(), "privileged group") {
t.Fatalf("a builder socket in root's group: %v", err)
}
// A machine with no builder refuses an apply that builds.
opBuilderSocket = filepath.Join(t.TempDir(), "absent.sock")
if _, err := runAction(t, dir, m, actionTerms(m), opModeApply, nil); err == nil || !strings.Contains(err.Error(), "no builder") {
t.Fatalf("no builder: %v", err)
}
}
// Through a real unit: a credential a verb names never reaches the program,
// and the program's own does (scrubbed from what it prints).
func TestActionVerbCredentialNeverReachesTheRealProgram(t *testing.T) {
requireActionSandbox(t)
setupBox(t)
useFileRepos(t)
origin, tree, _, c2 := twoCommitRepo(t, func(string) {}, func(o string) {
_ = os.WriteFile(filepath.Join(o, "a.txt"), []byte("two\n"), 0o644)
})
extra := fmt.Sprintf(`params:
commit: {type: string, max_len: 64}
credentials: [forge-read, registry]
verbs:
- {verb: tree.checkout, path: %q, repo: %q, commit: "{{commit}}", credential: forge-read}
`, tree, "file://"+origin)
prog := `echo "FORGE=[$PRIVATAE_CRED_FORGE_READ]"; echo "REG=[$PRIVATAE_CRED_REGISTRY]"`
man := strings.Replace(strings.Replace(`name: optest-action
version: 1.0.0
summary: An action op under test.
kind: action
target: machine
privilege: root
interpreter: /bin/sh
time_limit: 30
EXTRA
modes: [dry_run, apply, verify]
undo: none
`, "EXTRA\n", extra, 1), "\n\n", "\n", -1)
m, err := parseOpManifest([]byte(man))
if err != nil {
t.Fatalf("%v\n%s", err, man)
}
dir := filepath.Join(opTestRoot, fmt.Sprintf("aopv%d", time.Now().UnixNano()))
_ = os.MkdirAll(dir, 0o755)
t.Cleanup(func() { _ = os.RemoveAll(dir) })
_ = os.WriteFile(filepath.Join(dir, "op.yaml"), []byte(man), 0o644)
_ = os.WriteFile(filepath.Join(dir, "run"), []byte("#!/bin/sh\n"+prog+"\n"), 0o755)
tm := actionTerms(m)
tm.Params = fmt.Sprintf(`{"commit":%q}`, c2)
pub, _, _ := opEncKey()
var blobs []string
for name, val := range map[string]string{"forge-read": "forge-secret-value", "registry": "registry-secret-value"} {
b, _ := opSealCredFor(pub, opSealedCred{Name: name, Value: val, Nonce: tm.Nonce, Expires: tm.Expires})
blobs = append(blobs, b)
}
rep, err := runAction(t, dir, m, tm, opModeApply, blobs)
if err != nil {
t.Fatalf("%v\n%s", err, repOut(rep))
}
if !strings.Contains(rep.Output, "FORGE=[]") || !strings.Contains(rep.Output, "REG=[***]") {
t.Fatalf("the verb's credential is withheld and the program's handed:\n%s", rep.Output)
}
if gitT(t, tree, "rev-parse", "HEAD") != c2 {
t.Fatal("the verb ran with its credential")
}
}
// allocProg holds `mib` MiB, touching every page, in 50 MiB steps.
func allocProg(mib int) string {
return fmt.Sprintf(`python3 -c '
import time
print("ADJ", open("/proc/self/oom_score_adj").read().strip(), flush=True)
held = []
for i in range(%d // 50):
b = bytearray(50 << 20)
b[::4096] = b"\x01" * len(b[::4096])
held.append(b)
time.sleep(0.05)
print("HELD", len(held) * 50, flush=True)
rss = [l for l in open("/proc/self/status") if l.startswith("VmRSS:")][0].split()[1]
print("RSS", int(rss) // 1024, flush=True)
'`, mib)
}
// No ceiling: a run holds what it needs while the machine has it to spare.
// When the machine runs down to its floor, the op is stopped first and says
// why; a machine already under the floor starts no op; and the kernel, if it
// must kill, prefers the op.
func TestActionMemoryIsNotCappedAndTheOpYieldsFirst(t *testing.T) {
requireActionSandbox(t)
setupBox(t)
_, avail, ok := opMemInfo()
if !ok || avail < 10<<30 {
t.Skip("needs 10 GiB free to show a run is not capped and yields")
}
dir, m := actionOp(t, "", allocProg(1500))
rep, err := runAction(t, dir, m, actionTerms(m), opModeApply, nil)
if err != nil {
t.Fatalf("a run with memory to spare is not limited: %v\n%s", err, repOut(rep))
}
if !strings.Contains(rep.Output, "HELD 1500") || !strings.Contains(rep.Output, "ADJ 1000") {
t.Fatalf("it held 1500 MiB, and the kernel would kill it first:\n%s", rep.Output)
}
// In memory, not pushed to swap by a limit on the unit.
var rss int
for _, l := range strings.Split(rep.Output, "\n") {
if strings.HasPrefix(l, "RSS ") {
rss, _ = strconv.Atoi(strings.TrimPrefix(l, "RSS "))
}
}
if rss < 1400 {
t.Fatalf("it held its 1500 MiB in memory, got %d MiB resident:\n%s", rss, rep.Output)
}
// The machine runs short: the floor is put 300 MiB under what is free now.
prev := opMemoryFloor
t.Cleanup(func() { opMemoryFloor = prev })
_, avail, _ = opMemInfo()
opMemoryFloor = func() uint64 { return avail - 300<<20 }
bigDir, big := actionOp(t, "", allocProg(8000))
rep, err = runAction(t, bigDir, big, actionTerms(big), opModeApply, nil)
if err == nil || !strings.Contains(err.Error(), "stopped first") || strings.Contains(repOut(rep), "HELD") {
t.Fatalf("the op yields when the machine runs short: %v\n%s", err, repOut(rep))
}
if !strings.Contains(err.Error(), "at its peak") || strings.Contains(err.Error(), "used 0 MiB") {
t.Fatalf("the refusal says what the op had used: %v", err)
}
// Already under the floor: nothing starts, no uid is spent.
_, avail, _ = opMemInfo()
opMemoryFloor = func() uint64 { return avail + 1<<30 }
d, _ := configDir()
before, _ := os.ReadFile(filepath.Join(d, opUIDFile))
if _, err := runAction(t, dir, m, actionTerms(m), opModeDryRun, nil); err == nil || !strings.Contains(err.Error(), "did not start") {
t.Fatalf("a machine under its floor starts no op: %v", err)
}
if after, _ := os.ReadFile(filepath.Join(d, opUIDFile)); string(before) != string(after) {
t.Fatal("a uid was spent")
}
if u := leftUnits(t); len(u) != 0 {
t.Fatalf("op units remain: %v", u)
}
}
func TestMemoryFloorSitsAboveTheKubeletsEviction(t *testing.T) {
total, _, _ := opMemInfo()
gib := uint64(1 << 30)
for _, c := range []struct {
what string
files map[string]string // relative path -> content
want uint64
unreadable bool
}{
{"the config's setting, not its comment", map[string]string{"config.yaml": "# eviction-hard 9Gi = a comment\nkubelet-arg:\n - \"eviction-hard=memory.available<3Gi,nodefs.available<10%\"\n"}, 3 * gib, false},
{"a percentage of the machine", map[string]string{"config.yaml.d/10-evict.yaml": "kubelet-arg:\n - eviction-hard=memory.available<10%\n"}, total / 10, false},
{"a decimal", map[string]string{"config.yaml": "kubelet-arg: [\"eviction-hard=memory.available<1.5Gi\"]\n"}, 3 * gib / 2, false},
{"a decimal unit", map[string]string{"config.yaml": "kubelet-arg: [\"eviction-hard=memory.available<2G\"]\n"}, 2e9, false},
{"soft eviction counts too", map[string]string{"config.yaml": "kubelet-arg:\n - eviction-hard=memory.available<512Mi\n - eviction-soft=memory.available<4Gi\n"}, 4 * gib, false},
{"a flag on the k3s unit", map[string]string{"k3s.service": "ExecStart=/usr/local/bin/k3s server --kubelet-arg=eviction-hard=memory.available<2Gi\n"}, 2 * gib, false},
{"a kubelet config file's form", map[string]string{"config.yaml.d/kubelet.yaml": "evictionHard:\n memory.available: \"3Gi\"\n"}, 3 * gib, false},
{"a value that cannot be read", map[string]string{"config.yaml": "kubelet-arg: [\"eviction-hard=memory.available 0 && floor < c.want+opMemoryKubeletPad {
t.Errorf("%s: the floor %d is not above the kubelet's line %d", c.what, floor, c.want)
}
if c.unreadable && floor < total/10+opMemoryKubeletPad {
t.Errorf("%s: an unreadable setting makes the floor cautious, got %d", c.what, floor)
}
opK3sConfigGlobs = []string{filepath.Join(dir, "config.yaml"), filepath.Join(dir, "config.yaml.d/*.yaml"), filepath.Join(dir, "kubelet.conf.d/*.conf")}
opK3sUnitGlobs = []string{filepath.Join(dir, "k3s*.service")}
note := opFloorNote()
opK3sConfigGlobs, opK3sUnitGlobs = prevC, prevU
if (note != "") != c.unreadable {
t.Errorf("%s: a stop says when the floor was raised for an unreadable setting, and only then: %q", c.what, note)
}
}
// The product reads where k3s v1.35 writes the kubelet's own config.
found := false
for _, g := range opK3sConfigGlobs {
found = found || g == "/var/lib/rancher/k3s/agent/etc/kubelet.conf.d/*.conf"
}
if !found {
t.Fatal("the kubelet config k3s writes is not read")
}
// No kubelet: the largest of 1 GiB and 5%.
prevC, prevU := opK3sConfigGlobs, opK3sUnitGlobs
opK3sConfigGlobs, opK3sUnitGlobs = []string{filepath.Join(t.TempDir(), "absent")}, nil
defer func() { opK3sConfigGlobs, opK3sUnitGlobs = prevC, prevU }()
want := gib
if total/20 > want {
want = total / 20
}
if got := opMemoryFloorForMachine(); got != want {
t.Fatalf("with no kubelet: %d, want %d", got, want)
}
}
// A kill that does not land (no such unit, no cgroup yet) is never reported
// as the op stopped.
func TestTheMemoryWatchReportsOnlyAKillThatLanded(t *testing.T) {
stop := opMemoryWatch(opActionUnitPrefix+"nonexistent", 1<<62)
time.Sleep(4 * opMemoryWatchPeriod)
v := stop()
if v.Stopped {
t.Fatalf("a kill that found nothing is not a stop: %+v", v)
}
}
// The second signal: the machine's own services waiting on memory. The op's
// own stalls (its slice) never count; another cgroup's do, and stop it.
func TestActionYieldsWhenTheServicesWaitOnMemoryNotForItsOwnStalls(t *testing.T) {
requireActionSandbox(t)
setupBox(t)
fake := t.TempDir()
write := func(cg string, some float64) {
_ = os.MkdirAll(filepath.Join(fake, cg), 0o755)
_ = os.WriteFile(filepath.Join(fake, cg, "memory.pressure"),
[]byte(fmt.Sprintf("some avg10=%.2f avg60=0.00 avg300=0.00 total=1\nfull avg10=0.00 avg60=0.00 avg300=0.00 total=1\n", some)), 0o644)
}
write("system.slice", 0.10)
write("kubepods.slice", 40) // a pod reclaiming against its own limit, already
write(opActionSlice, 0.10)
prev := opCgroupRoot
opCgroupRoot = fake
t.Cleanup(func() { opCgroupRoot = prev })
// The op's own slice rises to 90 during the run: its own stalls.
dir, m := actionOp(t, "", "sleep 3; echo DONE")
go func() { time.Sleep(time.Second); write(opActionSlice, 90) }()
rep, err := runAction(t, dir, m, actionTerms(m), opModeApply, nil)
if err != nil || !strings.Contains(rep.Output, "DONE") {
t.Fatalf("neither the op's own stalls nor a group already stalling stop it: %v\n%s", err, repOut(rep))
}
// The services start waiting one second into a longer run.
dir2, m2 := actionOp(t, "", "sleep 20; echo DONE")
go func() { time.Sleep(time.Second); write("system.slice", 12.5) }()
start := time.Now()
rep, err = runAction(t, dir2, m2, actionTerms(m2), opModeApply, nil)
if err == nil || !strings.Contains(err.Error(), "system.slice began waiting on memory 12.4% more of the time than it had during the run") || strings.Contains(repOut(rep), "DONE") {
t.Fatalf("the services waiting stops the op: %v\n%s", err, repOut(rep))
}
if time.Since(start) > 10*time.Second {
t.Fatal("it was not stopped promptly")
}
// A group that keeps stalling at a level does not stop the next run: only
// a rise during the run does.
rep, err = runAction(t, dir, m, actionTerms(m), opModeDryRun, nil)
if err != nil || !strings.Contains(rep.Output, "DONE") {
t.Fatalf("a steady level is not a rise: %v\n%s", err, repOut(rep))
}
// A group that spiked as the run began and fell back does not hide a
// rise the run causes later: the rise is from its lowest reading.
write("system.slice", 30)
dir3, m3 := actionOp(t, "", "sleep 20; echo DONE")
go func() {
time.Sleep(time.Second)
write("system.slice", 0)
time.Sleep(time.Second)
write("system.slice", 12)
}()
rep, err = runAction(t, dir3, m3, actionTerms(m3), opModeApply, nil)
if err == nil || !strings.Contains(err.Error(), "system.slice began waiting on memory 12.0% more") || strings.Contains(repOut(rep), "DONE") {
t.Fatalf("a rise after a spike at the start: %v\n%s", err, repOut(rep))
}
}
// The watch keeps trying: the machine is short before the op's unit exists
// (the first kill finds nothing), and the unit that starts a second later is
// still stopped.
func TestTheMemoryWatchKeepsTryingUntilTheKillLands(t *testing.T) {
requireActionSandbox(t)
unit := fmt.Sprintf("%swatchtest-%d", opActionUnitPrefix, time.Now().UnixNano())
stop := opMemoryWatch(unit, 1<<62)
defer func() { opKillUnit(unit) }()
time.Sleep(time.Second)
// Started the way a run starts its unit (--wait, so it holds until the
// unit ends), in the background. Without --quiet, systemd-run reports how
// the unit ended: that is the proof it ran and was killed. Polling for its
// cgroup is not: the watch kills within one period of the unit starting,
// so the cgroup can come and go between two polls.
var report bytes.Buffer
cmd := exec.Command("systemd-run", "--wait", "--collect", "--unit="+unit, "--slice="+opActionSlice,
"-p", "RuntimeMaxSec=60", "/bin/sleep", "30")
cmd.Env = []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin"}
cmd.Stdout, cmd.Stderr = &report, &report
if err := cmd.Start(); err != nil {
t.Fatalf("start the unit: %v", err)
}
ended := make(chan struct{})
go func() { _ = cmd.Wait(); close(ended) }()
select {
case <-ended:
case <-time.After(10 * time.Second):
t.Fatal("the unit was not stopped: its 30-second sleep is still running")
}
v := stop()
gone := false
for i := 0; i < 100 && !gone; i++ {
if _, err := os.Stat(opUnitCgroupDir(unit)); err != nil {
gone = true
} else {
time.Sleep(50 * time.Millisecond)
}
}
out := report.String()
killed := strings.Contains(out, "Finished with result: signal") && regexp.MustCompile(`code=killed.*KILL`).MatchString(out)
if !killed || !gone || !v.Stopped {
t.Fatalf("the unit that came after the first try was stopped: killed=%v gone=%v %+v\n%s", killed, gone, v, out)
}
}