Skip to content

Commit ef52e45

Browse files
authored
Merge pull request #7 from vimeo/import_cgroup2_and_type_changes_2024-12-06
Import cgroup2 support & use generics to improve pparser's interface
2 parents 167284a + 869f771 commit ef52e45

19 files changed

+2640
-176
lines changed

.github/workflows/go.yml

+3-3
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,17 @@ jobs:
88
strategy:
99
matrix:
1010
os: [macOS-latest, ubuntu-latest]
11-
goversion: [1.17, 1.18, 1.19]
11+
goversion: ['1.22', '1.23']
1212
steps:
1313

1414
- name: Set up Go ${{matrix.goversion}} on ${{matrix.os}}
15-
uses: actions/setup-go@v3
15+
uses: actions/setup-go@v5
1616
with:
1717
go-version: ${{matrix.goversion}}
1818
id: go
1919

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

2323
- name: gofmt
2424
run: |

.github/workflows/staticcheck.yml

+3-3
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ jobs:
66
name: "staticcheck"
77
runs-on: ubuntu-latest
88
steps:
9-
- uses: actions/checkout@v1
9+
- uses: actions/checkout@v4
1010
with:
1111
fetch-depth: 1
12-
- uses: dominikh/staticcheck-action@v1.1.0
12+
- uses: dominikh/staticcheck-action@v1.3.1
1313
with:
14-
version: "2022.1.3"
14+
version: "2024.1.1"

cgresolver/cg_path.go

+106
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// package cgresolver contains helpers and types for resolving the CGroup associated with specific subsystems
2+
// If you don't know what cgroup subsystems are, you probably want one of the higher-level interfaces in the parent package.
3+
package cgresolver
4+
5+
import (
6+
"fmt"
7+
"os"
8+
"slices"
9+
"strconv"
10+
"strings"
11+
)
12+
13+
// CGMode is an enum indicating which cgroup type is active for the returned controller
14+
type CGMode uint8
15+
16+
const (
17+
CGModeUnknown CGMode = iota
18+
// CGroup V1
19+
CGModeV1
20+
// CGroup V2
21+
CGModeV2
22+
)
23+
24+
func cgroup2Mode(iscg2 bool) CGMode {
25+
if iscg2 {
26+
return CGModeV2
27+
}
28+
return CGModeV1
29+
}
30+
31+
// CGroupPath includes information about a cgroup.
32+
type CGroupPath struct {
33+
AbsPath string
34+
MountPath string
35+
Mode CGMode
36+
}
37+
38+
// Parent returns a CGroupPath for the parent directory as long as it wouldn't pass the root of the mountpoint.
39+
// second return indicates whether a new path was returned.
40+
func (c *CGroupPath) Parent() (CGroupPath, bool) {
41+
// Remove any trailing slash
42+
path := strings.TrimSuffix(c.AbsPath, string(os.PathSeparator))
43+
mnt := strings.TrimSuffix(c.MountPath, string(os.PathSeparator))
44+
if mnt == path {
45+
return CGroupPath{
46+
AbsPath: path,
47+
MountPath: mnt,
48+
Mode: c.Mode,
49+
}, false
50+
}
51+
lastSlashIdx := strings.LastIndexByte(path, byte(os.PathSeparator))
52+
if lastSlashIdx == -1 {
53+
// This shouldn't happen
54+
panic("invalid state: path \"" + path + "\" has no slashes and doesn't match the mountpoint")
55+
}
56+
return CGroupPath{
57+
AbsPath: path[:lastSlashIdx],
58+
MountPath: mnt, // Strip any trailing slash in case one snuck in
59+
Mode: c.Mode,
60+
}, true
61+
}
62+
63+
// SelfSubsystemPath returns a CGroupPath for the cgroup associated with a specific subsystem for the current process.
64+
func SelfSubsystemPath(subsystem string) (CGroupPath, error) {
65+
return subsystemPath("self", subsystem)
66+
}
67+
68+
// PIDSubsystemPath returns a CGroupPath for the cgroup associated with a specific subsystem for the specified PID
69+
func PIDSubsystemPath(pid int, subsystem string) (CGroupPath, error) {
70+
return subsystemPath(strconv.Itoa(pid), subsystem)
71+
}
72+
73+
func subsystemPath(procSubDir string, subsystem string) (CGroupPath, error) {
74+
cgSubSyses, cgSubSysReadErr := ParseReadCGSubsystems()
75+
if cgSubSysReadErr != nil {
76+
return CGroupPath{}, fmt.Errorf("failed to resolve subsystems to hierarchies: %w", cgSubSysReadErr)
77+
}
78+
cgIdx := slices.IndexFunc(cgSubSyses, func(c CGroupSubsystem) bool {
79+
return c.Subsys == subsystem
80+
})
81+
if cgIdx == -1 {
82+
return CGroupPath{}, fmt.Errorf("no cgroup hierarchy associated with subsystem %q", subsystem)
83+
}
84+
cgHierID := cgSubSyses[cgIdx].Hierarchy
85+
86+
procCGs, procCGsErr := resolveProcCGControllers(procSubDir)
87+
if procCGsErr != nil {
88+
return CGroupPath{}, fmt.Errorf("failed to resolve cgroup controllers: %w", procCGsErr)
89+
}
90+
91+
procCGIdx := slices.IndexFunc(procCGs, func(cg CGProcHierarchy) bool { return cg.HierarchyID == cgHierID })
92+
if procCGIdx == -1 {
93+
return CGroupPath{}, fmt.Errorf("failed to resolve process cgroup controllers: %w", procCGsErr)
94+
}
95+
96+
cgMountInfo, mountInfoParseErr := CGroupMountInfo()
97+
if mountInfoParseErr != nil {
98+
return CGroupPath{}, fmt.Errorf("failed to parse mountinfo: %w", mountInfoParseErr)
99+
}
100+
101+
cgPath, cgPathErr := procCGs[procCGIdx].cgPath(cgMountInfo)
102+
if cgPathErr != nil {
103+
return CGroupPath{}, fmt.Errorf("failed to resolve filesystem path for cgroup %+v: %w", procCGs[procCGIdx], cgPathErr)
104+
}
105+
return cgPath, nil
106+
}

cgresolver/cg_path_test.go

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package cgresolver
2+
3+
import "testing"
4+
5+
func TestCGroupPathParent(t *testing.T) {
6+
for _, tbl := range []struct {
7+
name string
8+
in CGroupPath
9+
expParent CGroupPath
10+
expNewParent bool
11+
}{
12+
{
13+
name: "cgroup_mount_root",
14+
in: CGroupPath{
15+
AbsPath: "/sys/fs/cgroup",
16+
MountPath: "/sys/fs/cgroup",
17+
Mode: CGModeV2,
18+
},
19+
expParent: CGroupPath{
20+
AbsPath: "/sys/fs/cgroup",
21+
MountPath: "/sys/fs/cgroup",
22+
Mode: CGModeV2,
23+
},
24+
expNewParent: false,
25+
},
26+
{
27+
name: "cgroup_mount_root_strip_trailing_slashes",
28+
in: CGroupPath{
29+
AbsPath: "/sys/fs/cgroup/",
30+
MountPath: "/sys/fs/cgroup/",
31+
Mode: CGModeV2,
32+
},
33+
expParent: CGroupPath{
34+
AbsPath: "/sys/fs/cgroup",
35+
MountPath: "/sys/fs/cgroup",
36+
Mode: CGModeV2,
37+
},
38+
expNewParent: false,
39+
},
40+
{
41+
name: "cgroup_mount_sub_cgroup_cgv1",
42+
in: CGroupPath{
43+
AbsPath: "/sys/fs/cgroup/a/b/c",
44+
MountPath: "/sys/fs/cgroup",
45+
Mode: CGModeV1,
46+
},
47+
expParent: CGroupPath{
48+
AbsPath: "/sys/fs/cgroup/a/b",
49+
MountPath: "/sys/fs/cgroup",
50+
Mode: CGModeV1,
51+
},
52+
expNewParent: true,
53+
},
54+
{
55+
name: "cgroup_mount_sub_cgroup_cgv2",
56+
in: CGroupPath{
57+
AbsPath: "/sys/fs/cgroup/a/b/c",
58+
MountPath: "/sys/fs/cgroup",
59+
Mode: CGModeV2,
60+
},
61+
expParent: CGroupPath{
62+
AbsPath: "/sys/fs/cgroup/a/b",
63+
MountPath: "/sys/fs/cgroup",
64+
Mode: CGModeV2,
65+
},
66+
expNewParent: true,
67+
},
68+
{
69+
name: "cgroup_mount_sub_cgroup_strip_trailing_slash",
70+
in: CGroupPath{
71+
AbsPath: "/sys/fs/cgroup/a/b/c/",
72+
MountPath: "/sys/fs/cgroup",
73+
Mode: CGModeV2,
74+
},
75+
expParent: CGroupPath{
76+
AbsPath: "/sys/fs/cgroup/a/b",
77+
MountPath: "/sys/fs/cgroup",
78+
Mode: CGModeV2,
79+
},
80+
expNewParent: true,
81+
},
82+
} {
83+
t.Run(tbl.name, func(t *testing.T) {
84+
par, np := tbl.in.Parent()
85+
if np != tbl.expNewParent {
86+
t.Errorf("unexpected OK value: %t; expected %t", np, tbl.expNewParent)
87+
}
88+
if par != tbl.expParent {
89+
t.Errorf("unexpected parent CGroupPath:\n got %+v\n want %+v", par, tbl.expParent)
90+
}
91+
})
92+
}
93+
}

cgresolver/mountinfo_parse.go

+137
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package cgresolver
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"strconv"
7+
"strings"
8+
)
9+
10+
// Mount represents a cgroup or cgroup2 mount.
11+
// Subsystems will be nil if the mount is for a unified hierarchy/cgroup v2
12+
// in that case, CGroupV2 will be true.
13+
type Mount struct {
14+
Mountpoint string
15+
Root string
16+
Subsystems []string
17+
CGroupV2 bool // true if this is a cgroup2 mount
18+
}
19+
20+
const (
21+
mountinfoPath = "/proc/self/mountinfo"
22+
)
23+
24+
// CGroupMountInfo parses /proc/self/mountinfo and returns info about all cgroup and cgroup2 mounts
25+
func CGroupMountInfo() ([]Mount, error) {
26+
mountinfoContents, mntInfoReadErr := os.ReadFile(mountinfoPath)
27+
if mntInfoReadErr != nil {
28+
return nil, fmt.Errorf("failed to read contents of %s: %w",
29+
mountinfoPath, mntInfoReadErr)
30+
}
31+
32+
mounts, mntsErr := getCGroupMountsFromMountinfo(string(mountinfoContents))
33+
if mntsErr != nil {
34+
return nil, fmt.Errorf("failed to list cgroupfs mounts: %w", mntsErr)
35+
}
36+
37+
return mounts, nil
38+
}
39+
40+
func getCGroupMountsFromMountinfo(mountinfo string) ([]Mount, error) {
41+
// mountinfo is line-delimited, then space-delimited
42+
mountinfoLines := strings.Split(mountinfo, "\n")
43+
if len(mountinfoLines) == 0 {
44+
return nil, fmt.Errorf("unexpectedly empty mountinfo (one line): %q", mountinfo)
45+
}
46+
out := make([]Mount, 0, len(mountinfoLines))
47+
for _, line := range mountinfoLines {
48+
if len(line) == 0 {
49+
continue
50+
}
51+
sections := strings.SplitN(line, " - ", 2)
52+
if len(sections) < 2 {
53+
return nil, fmt.Errorf("missing section separator in line %q", line)
54+
}
55+
s2Fields := strings.SplitN(sections[1], " ", 3)
56+
if len(s2Fields) < 3 {
57+
return nil, fmt.Errorf("line %q contains %d fields in second section, expected 3",
58+
line, len(s2Fields))
59+
60+
}
61+
isCG2 := false
62+
switch s2Fields[0] {
63+
case "cgroup":
64+
isCG2 = false
65+
case "cgroup2":
66+
isCG2 = true
67+
default:
68+
// skip anything that's not a cgroup
69+
continue
70+
}
71+
s1Fields := strings.Split(sections[0], " ")
72+
if len(s1Fields) < 5 {
73+
return nil, fmt.Errorf("too few fields in line %q before optional separator: %d; expected 5",
74+
line, len(s1Fields))
75+
}
76+
mntpnt, mntPntUnescapeErr := unOctalEscape(s1Fields[4])
77+
if mntPntUnescapeErr != nil {
78+
return nil, fmt.Errorf("failed to unescape mountpoint %q: %w", s1Fields[4], mntPntUnescapeErr)
79+
}
80+
rootPath, rootUnescErr := unOctalEscape(s1Fields[3])
81+
if rootUnescErr != nil {
82+
return nil, fmt.Errorf("failed to unescape mount root %q: %w", s1Fields[3], rootUnescErr)
83+
}
84+
mnt := Mount{
85+
CGroupV2: isCG2,
86+
Mountpoint: mntpnt,
87+
Root: rootPath,
88+
Subsystems: nil,
89+
}
90+
// only bother with the mount options to find subsystems if cgroup v1
91+
if !isCG2 {
92+
for _, mntOpt := range strings.Split(s2Fields[2], ",") {
93+
switch mntOpt {
94+
case "ro", "rw":
95+
// These mount options are lies, (or at least
96+
// only reflect the original mount, without
97+
// considering the layering of later bind-mounts)
98+
continue
99+
case "":
100+
continue
101+
default:
102+
mnt.Subsystems = append(mnt.Subsystems, mntOpt)
103+
}
104+
}
105+
}
106+
107+
out = append(out, mnt)
108+
109+
}
110+
return out, nil
111+
}
112+
113+
func unOctalEscape(str string) (string, error) {
114+
b := strings.Builder{}
115+
b.Grow(len(str))
116+
for {
117+
backslashIdx := strings.IndexByte(str, byte('\\'))
118+
if backslashIdx == -1 {
119+
b.WriteString(str)
120+
return b.String(), nil
121+
}
122+
b.WriteString(str[:backslashIdx])
123+
// if the end of the escape is beyond the end of the string, abort!
124+
if backslashIdx+3 >= len(str) {
125+
return "", fmt.Errorf("invalid offset: %d+3 >= len %d", backslashIdx, len(str))
126+
}
127+
// slice out the octal 3-digit component
128+
esc := str[backslashIdx+1 : backslashIdx+4]
129+
asciiVal, parseUintErr := strconv.ParseUint(esc, 8, 8)
130+
if parseUintErr != nil {
131+
return "", fmt.Errorf("failed to parse escape value %q: %w", esc, parseUintErr)
132+
}
133+
b.WriteByte(byte(asciiVal))
134+
str = str[backslashIdx+4:]
135+
}
136+
137+
}

0 commit comments

Comments
 (0)