diff --git a/go/action_kit_commons/memfill/memfill_process.go b/go/action_kit_commons/memfill/memfill_process.go index f78d8285..9a8cdc27 100644 --- a/go/action_kit_commons/memfill/memfill_process.go +++ b/go/action_kit_commons/memfill/memfill_process.go @@ -15,21 +15,52 @@ import ( "time" ) +// joinCgroupScript moves the current shell into the target's memory cgroup and then execs the +// remaining arguments. It replaces the previous `cgexec -g memory:` invocation, which +// depended on libcgroup-tools (the cgroup-tools/libcgroup-tools package). That package does not +// exist for Enterprise Linux 9 and never supported cgroup v2, so the extension could neither be +// installed nor fill memory correctly on modern hosts. +// +// The move must happen before exec: under cgroup v2 a process keeps the memory already charged to +// it when migrated, so anything memfill allocates before joining would be accounted to the wrong +// cgroup. Writing $$ and then `exec`ing keeps the same PID, so memfill starts already inside the +// target cgroup with nothing allocated yet. +// +// The first argument is the target cgroup path (as read from /proc//cgroup); the remaining +// arguments are the command to exec. cgroup v1 (memory controller) is preferred over v2 unified to +// match the historical `memory:` semantics on hybrid hosts. +const joinCgroupScript = `cg="$1"; shift +if [ -e "/sys/fs/cgroup/memory${cg}/cgroup.procs" ]; then + procs="/sys/fs/cgroup/memory${cg}/cgroup.procs" +elif [ -e "/sys/fs/cgroup${cg}/cgroup.procs" ]; then + procs="/sys/fs/cgroup${cg}/cgroup.procs" +else + echo "memfill: no cgroup.procs found for ${cg} (looked under cgroup v1 memory and v2 unified)" >&2 + exit 1 +fi +printf '%s\n' "$$" > "$procs" || { echo "memfill: failed to join cgroup $procs" >&2; exit 1; } +exec "$@"` + type memfillRunc struct { cmd *exec.Cmd state *utils.BackgroundState args []string } -func NewMemfillProcess(targetProcess ociruntime.LinuxProcessInfo, opts Opts) (Memfill, error) { - args := append([]string{ +// memfillCommandArgs builds the argument vector that runs memfill inside the target's memory +// cgroup and PID namespace. It enters the host mount namespace (nsenter -t 1 -C), joins the target +// cgroup via joinCgroupScript, then enters the target PID namespace and execs memfill. +func memfillCommandArgs(targetProcess ociruntime.LinuxProcessInfo, opts Opts) []string { + args := []string{ "nsenter", "-t", "1", "-C", "--", - //when util-linux package >= 2.39 is broadly available we could also the cgroup change using nsenter, - "cgexec", "-g", fmt.Sprintf("memory:%s", targetProcess.CGroupPath), + "sh", "-c", joinCgroupScript, "sh", targetProcess.CGroupPath, "nsenter", "-t", strconv.Itoa(targetProcess.Pid), "-p", "-F", "--", - }, - opts.processArgs()..., - ) + } + return append(args, opts.processArgs()...) +} + +func NewMemfillProcess(targetProcess ociruntime.LinuxProcessInfo, opts Opts) (Memfill, error) { + args := memfillCommandArgs(targetProcess, opts) cmd := utils.RootCommandContext(context.Background(), args[0], args[1:]...) diff --git a/go/action_kit_commons/memfill/memfill_test.go b/go/action_kit_commons/memfill/memfill_test.go index c05f3079..1d4a85a9 100644 --- a/go/action_kit_commons/memfill/memfill_test.go +++ b/go/action_kit_commons/memfill/memfill_test.go @@ -8,9 +8,35 @@ import ( "testing" "time" + "github.com/steadybit/action-kit/go/action_kit_commons/ociruntime" "github.com/stretchr/testify/assert" ) +func TestMemfillCommandArgs(t *testing.T) { + t.Setenv("STEADYBIT_EXTENSION_MEMFILL_PATH", "/usr/bin/memfill") + + target := ociruntime.LinuxProcessInfo{Pid: 4242, CGroupPath: "/system.slice/target.scope"} + args := memfillCommandArgs(target, Opts{Size: 80, Mode: ModeUsage, Unit: UnitPercent, Duration: 30 * time.Second, IgnoreCgroup: true}) + + // Enters the host mount namespace, joins the target cgroup via the sh wrapper, then enters the + // target PID namespace and execs memfill. No cgexec anywhere. + assert.Equal(t, []string{ + "nsenter", "-t", "1", "-C", "--", + "sh", "-c", joinCgroupScript, "sh", "/system.slice/target.scope", + "nsenter", "-t", "4242", "-p", "-F", "--", + "/usr/bin/memfill", "80%", "usage", "30", "--ignore-cgroup", + }, args) + + for _, arg := range args { + assert.NotContains(t, arg, "cgexec", "cgexec must no longer be used") + } + + // The cgroup path is passed as a positional argument (not interpolated into the script), + // so it cannot break out of the shell. + assert.Contains(t, joinCgroupScript, "cgroup.procs") + assert.Contains(t, joinCgroupScript, "exec \"$@\"") +} + func TestProcessArgs(t *testing.T) { t.Setenv("STEADYBIT_EXTENSION_MEMFILL_PATH", "/usr/bin/memfill")