Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Import cgroup2 support & use generics to improve pparser's interface #7

Merged
merged 5 commits into from
Dec 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@ jobs:
strategy:
matrix:
os: [macOS-latest, ubuntu-latest]
goversion: [1.17, 1.18, 1.19]
goversion: ['1.22', '1.23']
steps:

- name: Set up Go ${{matrix.goversion}} on ${{matrix.os}}
uses: actions/setup-go@v3
uses: actions/setup-go@v5
with:
go-version: ${{matrix.goversion}}
id: go

- name: Check out code into the Go module directory
uses: actions/checkout@v1
uses: actions/checkout@v4

- name: gofmt
run: |
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/staticcheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ jobs:
name: "staticcheck"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: dominikh/staticcheck-action@v1.1.0
- uses: dominikh/staticcheck-action@v1.3.1
with:
version: "2022.1.3"
version: "2024.1.1"
106 changes: 106 additions & 0 deletions cgresolver/cg_path.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// package cgresolver contains helpers and types for resolving the CGroup associated with specific subsystems
// If you don't know what cgroup subsystems are, you probably want one of the higher-level interfaces in the parent package.
package cgresolver

import (
"fmt"
"os"
"slices"
"strconv"
"strings"
)

// CGMode is an enum indicating which cgroup type is active for the returned controller
type CGMode uint8

const (
CGModeUnknown CGMode = iota
// CGroup V1
CGModeV1
// CGroup V2
CGModeV2
)

func cgroup2Mode(iscg2 bool) CGMode {
if iscg2 {
return CGModeV2
}
return CGModeV1
}

// CGroupPath includes information about a cgroup.
type CGroupPath struct {
AbsPath string
MountPath string
Mode CGMode
}

// Parent returns a CGroupPath for the parent directory as long as it wouldn't pass the root of the mountpoint.
// second return indicates whether a new path was returned.
func (c *CGroupPath) Parent() (CGroupPath, bool) {
// Remove any trailing slash
path := strings.TrimSuffix(c.AbsPath, string(os.PathSeparator))
mnt := strings.TrimSuffix(c.MountPath, string(os.PathSeparator))
if mnt == path {
return CGroupPath{
AbsPath: path,
MountPath: mnt,
Mode: c.Mode,
}, false
}
lastSlashIdx := strings.LastIndexByte(path, byte(os.PathSeparator))
if lastSlashIdx == -1 {
// This shouldn't happen
panic("invalid state: path \"" + path + "\" has no slashes and doesn't match the mountpoint")
}
return CGroupPath{
AbsPath: path[:lastSlashIdx],
MountPath: mnt, // Strip any trailing slash in case one snuck in
Mode: c.Mode,
}, true
}

// SelfSubsystemPath returns a CGroupPath for the cgroup associated with a specific subsystem for the current process.
func SelfSubsystemPath(subsystem string) (CGroupPath, error) {
return subsystemPath("self", subsystem)
}

// PIDSubsystemPath returns a CGroupPath for the cgroup associated with a specific subsystem for the specified PID
func PIDSubsystemPath(pid int, subsystem string) (CGroupPath, error) {
return subsystemPath(strconv.Itoa(pid), subsystem)
}

func subsystemPath(procSubDir string, subsystem string) (CGroupPath, error) {
cgSubSyses, cgSubSysReadErr := ParseReadCGSubsystems()
if cgSubSysReadErr != nil {
return CGroupPath{}, fmt.Errorf("failed to resolve subsystems to hierarchies: %w", cgSubSysReadErr)
}
cgIdx := slices.IndexFunc(cgSubSyses, func(c CGroupSubsystem) bool {
return c.Subsys == subsystem
})
if cgIdx == -1 {
return CGroupPath{}, fmt.Errorf("no cgroup hierarchy associated with subsystem %q", subsystem)
}
cgHierID := cgSubSyses[cgIdx].Hierarchy

procCGs, procCGsErr := resolveProcCGControllers(procSubDir)
if procCGsErr != nil {
return CGroupPath{}, fmt.Errorf("failed to resolve cgroup controllers: %w", procCGsErr)
}

procCGIdx := slices.IndexFunc(procCGs, func(cg CGProcHierarchy) bool { return cg.HierarchyID == cgHierID })
if procCGIdx == -1 {
return CGroupPath{}, fmt.Errorf("failed to resolve process cgroup controllers: %w", procCGsErr)
}

cgMountInfo, mountInfoParseErr := CGroupMountInfo()
if mountInfoParseErr != nil {
return CGroupPath{}, fmt.Errorf("failed to parse mountinfo: %w", mountInfoParseErr)
}

cgPath, cgPathErr := procCGs[procCGIdx].cgPath(cgMountInfo)
if cgPathErr != nil {
return CGroupPath{}, fmt.Errorf("failed to resolve filesystem path for cgroup %+v: %w", procCGs[procCGIdx], cgPathErr)
}
return cgPath, nil
}
93 changes: 93 additions & 0 deletions cgresolver/cg_path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package cgresolver

import "testing"

func TestCGroupPathParent(t *testing.T) {
for _, tbl := range []struct {
name string
in CGroupPath
expParent CGroupPath
expNewParent bool
}{
{
name: "cgroup_mount_root",
in: CGroupPath{
AbsPath: "/sys/fs/cgroup",
MountPath: "/sys/fs/cgroup",
Mode: CGModeV2,
},
expParent: CGroupPath{
AbsPath: "/sys/fs/cgroup",
MountPath: "/sys/fs/cgroup",
Mode: CGModeV2,
},
expNewParent: false,
},
{
name: "cgroup_mount_root_strip_trailing_slashes",
in: CGroupPath{
AbsPath: "/sys/fs/cgroup/",
MountPath: "/sys/fs/cgroup/",
Mode: CGModeV2,
},
expParent: CGroupPath{
AbsPath: "/sys/fs/cgroup",
MountPath: "/sys/fs/cgroup",
Mode: CGModeV2,
},
expNewParent: false,
},
{
name: "cgroup_mount_sub_cgroup_cgv1",
in: CGroupPath{
AbsPath: "/sys/fs/cgroup/a/b/c",
MountPath: "/sys/fs/cgroup",
Mode: CGModeV1,
},
expParent: CGroupPath{
AbsPath: "/sys/fs/cgroup/a/b",
MountPath: "/sys/fs/cgroup",
Mode: CGModeV1,
},
expNewParent: true,
},
{
name: "cgroup_mount_sub_cgroup_cgv2",
in: CGroupPath{
AbsPath: "/sys/fs/cgroup/a/b/c",
MountPath: "/sys/fs/cgroup",
Mode: CGModeV2,
},
expParent: CGroupPath{
AbsPath: "/sys/fs/cgroup/a/b",
MountPath: "/sys/fs/cgroup",
Mode: CGModeV2,
},
expNewParent: true,
},
{
name: "cgroup_mount_sub_cgroup_strip_trailing_slash",
in: CGroupPath{
AbsPath: "/sys/fs/cgroup/a/b/c/",
MountPath: "/sys/fs/cgroup",
Mode: CGModeV2,
},
expParent: CGroupPath{
AbsPath: "/sys/fs/cgroup/a/b",
MountPath: "/sys/fs/cgroup",
Mode: CGModeV2,
},
expNewParent: true,
},
} {
t.Run(tbl.name, func(t *testing.T) {
par, np := tbl.in.Parent()
if np != tbl.expNewParent {
t.Errorf("unexpected OK value: %t; expected %t", np, tbl.expNewParent)
}
if par != tbl.expParent {
t.Errorf("unexpected parent CGroupPath:\n got %+v\n want %+v", par, tbl.expParent)
}
})
}
}
137 changes: 137 additions & 0 deletions cgresolver/mountinfo_parse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package cgresolver

import (
"fmt"
"os"
"strconv"
"strings"
)

// Mount represents a cgroup or cgroup2 mount.
// Subsystems will be nil if the mount is for a unified hierarchy/cgroup v2
// in that case, CGroupV2 will be true.
type Mount struct {
Mountpoint string
Root string
Subsystems []string
CGroupV2 bool // true if this is a cgroup2 mount
}

const (
mountinfoPath = "/proc/self/mountinfo"
)

// CGroupMountInfo parses /proc/self/mountinfo and returns info about all cgroup and cgroup2 mounts
func CGroupMountInfo() ([]Mount, error) {
mountinfoContents, mntInfoReadErr := os.ReadFile(mountinfoPath)
if mntInfoReadErr != nil {
return nil, fmt.Errorf("failed to read contents of %s: %w",
mountinfoPath, mntInfoReadErr)
}

mounts, mntsErr := getCGroupMountsFromMountinfo(string(mountinfoContents))
if mntsErr != nil {
return nil, fmt.Errorf("failed to list cgroupfs mounts: %w", mntsErr)
}

return mounts, nil
}

func getCGroupMountsFromMountinfo(mountinfo string) ([]Mount, error) {
// mountinfo is line-delimited, then space-delimited
mountinfoLines := strings.Split(mountinfo, "\n")
if len(mountinfoLines) == 0 {
return nil, fmt.Errorf("unexpectedly empty mountinfo (one line): %q", mountinfo)
}
out := make([]Mount, 0, len(mountinfoLines))
for _, line := range mountinfoLines {
if len(line) == 0 {
continue
}
sections := strings.SplitN(line, " - ", 2)
if len(sections) < 2 {
return nil, fmt.Errorf("missing section separator in line %q", line)
}
s2Fields := strings.SplitN(sections[1], " ", 3)
if len(s2Fields) < 3 {
return nil, fmt.Errorf("line %q contains %d fields in second section, expected 3",
line, len(s2Fields))

}
isCG2 := false
switch s2Fields[0] {
case "cgroup":
isCG2 = false
case "cgroup2":
isCG2 = true
default:
// skip anything that's not a cgroup
continue
}
s1Fields := strings.Split(sections[0], " ")
if len(s1Fields) < 5 {
return nil, fmt.Errorf("too few fields in line %q before optional separator: %d; expected 5",
line, len(s1Fields))
}
mntpnt, mntPntUnescapeErr := unOctalEscape(s1Fields[4])
if mntPntUnescapeErr != nil {
return nil, fmt.Errorf("failed to unescape mountpoint %q: %w", s1Fields[4], mntPntUnescapeErr)
}
rootPath, rootUnescErr := unOctalEscape(s1Fields[3])
if rootUnescErr != nil {
return nil, fmt.Errorf("failed to unescape mount root %q: %w", s1Fields[3], rootUnescErr)
}
mnt := Mount{
CGroupV2: isCG2,
Mountpoint: mntpnt,
Root: rootPath,
Subsystems: nil,
}
// only bother with the mount options to find subsystems if cgroup v1
if !isCG2 {
for _, mntOpt := range strings.Split(s2Fields[2], ",") {
switch mntOpt {
case "ro", "rw":
// These mount options are lies, (or at least
// only reflect the original mount, without
// considering the layering of later bind-mounts)
continue
case "":
continue
default:
mnt.Subsystems = append(mnt.Subsystems, mntOpt)
}
}
}

out = append(out, mnt)

}
return out, nil
}

func unOctalEscape(str string) (string, error) {
b := strings.Builder{}
b.Grow(len(str))
for {
backslashIdx := strings.IndexByte(str, byte('\\'))
if backslashIdx == -1 {
b.WriteString(str)
return b.String(), nil
}
b.WriteString(str[:backslashIdx])
// if the end of the escape is beyond the end of the string, abort!
if backslashIdx+3 >= len(str) {
return "", fmt.Errorf("invalid offset: %d+3 >= len %d", backslashIdx, len(str))
}
// slice out the octal 3-digit component
esc := str[backslashIdx+1 : backslashIdx+4]
asciiVal, parseUintErr := strconv.ParseUint(esc, 8, 8)
if parseUintErr != nil {
return "", fmt.Errorf("failed to parse escape value %q: %w", esc, parseUintErr)
}
b.WriteByte(byte(asciiVal))
str = str[backslashIdx+4:]
}

}
Loading
Loading