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=/srv/work/tmp-gotest TMPDIR=/srv/work/tmp-gotest 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("privatae-op-nonexistent-optest", 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) } }