// Copyright 2025 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// Package cgrouptest provides best-effort helpers for running tests inside a
// cgroup.
package cgrouptest

import (
	"fmt"
	"internal/runtime/cgroup"
	"os"
	"path/filepath"
	"slices"
	"strconv"
	"strings"
	"syscall"
	"testing"
)

type CgroupV2 struct {
	orig string
	path string
}

func (c *CgroupV2) Path() string {
	return c.path
}

// Path to cpu.max.
func (c *CgroupV2) CPUMaxPath() string {
	return filepath.Join(c.path, "cpu.max")
}

// Set cpu.max. Pass -1 for quota to disable the limit.
func (c *CgroupV2) SetCPUMax(quota, period int64) error {
	q := "max"
	if quota >= 0 {
		q = strconv.FormatInt(quota, 10)
	}
	buf := fmt.Sprintf("%s %d", q, period)
	return os.WriteFile(c.CPUMaxPath(), []byte(buf), 0)
}

// InCgroupV2 creates a new v2 cgroup, migrates the current process into it,
// and then calls fn. When fn returns, the current process is migrated back to
// the original cgroup and the new cgroup is destroyed.
//
// If a new cgroup cannot be created, the test is skipped.
//
// This must not be used in parallel tests, as it affects the entire process.
func InCgroupV2(t *testing.T, fn func(*CgroupV2)) {
	orig := findCurrent(t)
	parent := findOwnedParent(t, orig)

	// Make sure the parent allows children to control cpu.
	b, err := os.ReadFile(filepath.Join(parent, "cgroup.subtree_control"))
	if err != nil {
		t.Skipf("unable to read cgroup.subtree_control: %v", err)
	}
	if !slices.Contains(strings.Fields(string(b)), "cpu") {
		// N.B. We should have permission to add cpu to
		// subtree_control, but it seems like a bad idea to change this
		// on a high-level cgroup that probably has lots of existing
		// children.
		t.Skipf("Parent cgroup %s does not allow children to control cpu, only %q", parent, string(b))
	}

	path, err := os.MkdirTemp(parent, "go-cgrouptest")
	if err != nil {
		t.Skipf("unable to create cgroup directory: %v", err)
	}
	// Important: defer cleanups so they run even in the event of panic.
	//
	// TODO(prattmic): Consider running everything in a subprocess just so
	// we can clean up if it throws or otherwise doesn't run the defers.
	defer func() {
		if err := os.Remove(path); err != nil {
			// Not much we can do, but at least inform of the
			// problem.
			t.Errorf("Error removing cgroup directory: %v", err)
		}
	}()

	migrateTo(t, path)
	defer migrateTo(t, orig)

	c := &CgroupV2{
		orig: orig,
		path: path,
	}
	fn(c)
}

// Returns the filesystem path to the current cgroup the process is in.
func findCurrent(t *testing.T) string {
	// Find the path to our current CPU cgroup. Currently this package is
	// only used for CPU cgroup testing, so the distinction of different
	// controllers doesn't matter.
	var scratch [cgroup.ParseSize]byte
	buf := make([]byte, cgroup.PathSize)
	n, ver, err := cgroup.FindCPU(buf, scratch[:])
	if err != nil {
		t.Skipf("cgroup: unable to find current cgroup mount: %v", err)
	}
	if ver != cgroup.V2 {
		t.Skipf("cgroup: running on cgroup v%d want v2", ver)
	}
	return string(buf[:n])
}

// Returns a parent directory in which we can create our own cgroup subdirectory.
func findOwnedParent(t *testing.T, orig string) string {
	// There are many ways cgroups may be set up on a system. We don't try
	// to cover all of them, just common ones.
	//
	// To start with, systemd:
	//
	// Our test process is likely running inside a user session, in which
	// case we are likely inside a cgroup that looks something like:
	//
	//   /sys/fs/cgroup/user.slice/user-1234.slice/user@1234.service/vte-spawn-1.scope/
	//
	// Possibly with additional slice layers between user@1234.service and
	// the leaf scope.
	//
	// On new enough kernel and systemd versions (exact versions unknown),
	// full unprivileged control of the user's cgroups is permitted
	// directly via the cgroup filesystem. Specifically, the
	// user@1234.service directory is owned by the user, as are all
	// subdirectories.

	// We want to create our own subdirectory that we can migrate into and
	// then manipulate at will. It is tempting to create a new subdirectory
	// inside the current cgroup we are already in, however that will likely
	// not work. cgroup v2 only allows processes to be in leaf cgroups. Our
	// current cgroup likely contains multiple processes (at least this one
	// and the cmd/go test runner). If we make a subdirectory and try to
	// move our process into that cgroup, then the subdirectory and parent
	// would both contain processes. Linux won't allow us to do that [1].
	//
	// Instead, we will simply walk up to the highest directory that our
	// user owns and create our new subdirectory. Since that directory
	// already has a bunch of subdirectories, it must not directly contain
	// and processes.
	//
	// (This would fall apart if we already in the highest directory we
	// own, such as if there was simply a single cgroup for the entire
	// user. Luckily systemd at least does not do this.)
	//
	// [1] Minor technicality: By default a new subdirectory has no cgroup
	// controller (they must be explicitly enabled in the parent's
	// cgroup.subtree_control). Linux will allow moving processes into a
	// subdirectory that has no controllers while there are still processes
	// in the parent, but it won't allow adding controller until the parent
	// is empty. As far as I tell, the only purpose of this is to allow
	// reorganizing processes into a new set of subdirectories and then
	// adding controllers once done.
	var stat syscall.Stat_t
	err := syscall.Stat(orig, &stat)
	if err != nil {
		t.Fatalf("error stating orig cgroup: %v", err)
	}

	uid := os.Getuid()
	var prev string
	cur := filepath.Dir(orig)
	for cur != "/" {
		var curStat syscall.Stat_t
		err = syscall.Stat(cur, &curStat)
		if err != nil {
			t.Fatalf("error stating cgroup path: %v", err)
		}

		if int(curStat.Uid) != uid || curStat.Dev != stat.Dev {
			// Stop at first directory we don't own or filesystem boundary.
			break
		}

		prev = cur
		cur = filepath.Dir(cur)
	}

	if prev == "" {
		t.Skipf("No parent cgroup owned by UID %d", uid)
	}

	// We actually want the last directory where we were the owner.
	return prev
}

// Migrate the current process to the cgroup directory dst.
func migrateTo(t *testing.T, dst string) {
	pid := []byte(strconv.FormatInt(int64(os.Getpid()), 10))
	if err := os.WriteFile(filepath.Join(dst, "cgroup.procs"), pid, 0); err != nil {
		t.Skipf("Unable to migrate into %s: %v", dst, err)
	}
}
