Watch
1
0
Fork
You've already forked RedFlag
0

processes: attribute each pid to its unit or container

/proc/[pid]/cgroup gives the join the process list never had: systemd unit,
container id, runtime. Both cgroup generations, both cgroup drivers, docker,
podman, containerd, crio and lxc. On a 321-process desktop every userland
process attributes; the 172 that do not are kernel threads.

The kernel's id is full-length and the docker inventory reports twelve
characters, so the join is a prefix, not equality. RAF carries the rule.
This commit is contained in:
Fimeg 2026-08-31 22:40:52 -04:00
commit d1670cfd6a
5 changed files with 335 additions and 3 deletions

View file

@ -47,6 +47,9 @@ type FullProcess struct {
// Elevation
ElevationStatus string `json:"elevation_status,omitempty"` // "elevated" if uid!=euid
// Ownership — cgroup, systemd unit, container. Inlined into the JSON.
ProcessOwner
// Disk I/O (from /proc/[pid]/io, may be empty for other users' processes)
DiskBytesRead uint64 `json:"disk_bytes_read,omitempty"`
DiskBytesWritten uint64 `json:"disk_bytes_written,omitempty"`

View file

@ -178,6 +178,11 @@ func readFullProc(pid int, totalCPU, memTotal uint64, passwd, group map[uint32]s
}
}
// /proc/[pid]/cgroup — the owning unit or container
if cgroupData, err := os.ReadFile(fmt.Sprintf("/proc/%d/cgroup", pid)); err == nil {
proc.ProcessOwner = parseCgroupOwner(string(cgroupData))
}
// /proc/[pid]/cmdline — full command line
cmdlineData, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
if err == nil {

View file

@ -0,0 +1,130 @@
package system
import "strings"
// ProcessOwner names what a process belongs to, read from its cgroup path.
// It is the join between the process list and the service, container, and
// package inventories — a PID answers "who owns me" without the Desktop
// having to guess from a name.
type ProcessOwner struct {
Cgroup string `json:"cgroup,omitempty"`
Unit string `json:"unit,omitempty"`
ContainerID string `json:"container_id,omitempty"`
ContainerRuntime string `json:"container_runtime,omitempty"`
}
// parseCgroupOwner reads the contents of /proc/[pid]/cgroup.
//
// v2 is one `0::<path>` line. v1 is one line per controller, and the paths can
// disagree — the systemd hierarchy is the one that carries unit and container
// scopes, so it wins when present.
func parseCgroupOwner(content string) ProcessOwner {
path := ""
for _, line := range strings.Split(content, "\n") {
fields := strings.SplitN(strings.TrimSpace(line), ":", 3)
if len(fields) != 3 || fields[2] == "" {
continue
}
hierarchy, controllers, candidate := fields[0], fields[1], fields[2]
if hierarchy == "0" && controllers == "" {
path = candidate // v2: unified, authoritative
break
}
if controllers == "name=systemd" {
path = candidate
continue
}
if path == "" && candidate != "/" {
path = candidate
}
}
if path == "" || path == "/" {
return ProcessOwner{}
}
owner := ProcessOwner{Cgroup: path}
segments := strings.Split(strings.Trim(path, "/"), "/")
containerIndex := -1
for i := len(segments) - 1; i >= 0; i-- {
runtime, id := containerSegment(segments, i)
if id == "" {
continue
}
owner.ContainerRuntime = runtime
owner.ContainerID = id
containerIndex = i
break
}
for i := len(segments) - 1; i >= 0; i-- {
if i == containerIndex {
continue
}
if strings.HasSuffix(segments[i], ".service") || strings.HasSuffix(segments[i], ".scope") {
owner.Unit = segments[i]
break
}
}
return owner
}
// containerSegment identifies a container from one cgroup path segment,
// returning its runtime and full ID. Empty ID means the segment names no
// container.
func containerSegment(segments []string, i int) (string, string) {
segment := segments[i]
// systemd cgroup drivers: docker-<id>.scope, libpod-<id>.scope,
// crio-<id>.scope, cri-containerd-<id>.scope.
if scope := strings.TrimSuffix(segment, ".scope"); scope != segment {
for prefix, runtime := range map[string]string{
"docker-": "docker",
"libpod-": "podman",
"crio-": "crio",
"cri-containerd-": "containerd",
"containerd-": "containerd",
} {
if id := strings.TrimPrefix(scope, prefix); id != scope && isContainerID(id) {
return runtime, id
}
}
}
// LXC keeps a name, not a hash: /lxc/<name>, /lxc.payload.<name>.
if payload := strings.TrimPrefix(segment, "lxc.payload."); payload != segment && payload != "" {
return "lxc", payload
}
if i > 0 && segments[i-1] == "lxc" && segment != "" {
return "lxc", segment
}
// cgroupfs drivers keep the bare ID: /docker/<id>, /kubepods/.../<id>.
if isContainerID(segment) {
if i > 0 {
switch {
case segments[i-1] == "docker":
return "docker", segment
case strings.HasPrefix(segments[i-1], "pod"), strings.HasPrefix(segments[i-1], "kubepods"):
return "containerd", segment
}
}
}
return "", ""
}
// isContainerID reports whether a segment is a container hash. Runtimes emit
// the full 64-hex ID; some emit the 12-hex short form, and the cgroup is not
// the place to decide which one a UI should show.
func isContainerID(segment string) bool {
if len(segment) != 64 && len(segment) != 12 {
return false
}
for i := 0; i < len(segment); i++ {
c := segment[i]
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
return true
}

View file

@ -0,0 +1,157 @@
package system
import "testing"
func TestParseCgroupOwner(t *testing.T) {
const dockerID = "a3f1c2d4e5b60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90"
cases := []struct {
name string
body string
want ProcessOwner
}{
{
name: "v2 system service",
body: "0::/system.slice/nginx.service\n",
want: ProcessOwner{Cgroup: "/system.slice/nginx.service", Unit: "nginx.service"},
},
{
name: "v2 user scope keeps the innermost unit",
body: "0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-firefox-4090.scope\n",
want: ProcessOwner{
Cgroup: "/user.slice/user-1000.slice/user@1000.service/app.slice/app-firefox-4090.scope",
Unit: "app-firefox-4090.scope",
},
},
{
name: "docker systemd driver",
body: "0::/system.slice/docker-" + dockerID + ".scope\n",
want: ProcessOwner{
Cgroup: "/system.slice/docker-" + dockerID + ".scope",
ContainerID: dockerID,
ContainerRuntime: "docker",
},
},
{
name: "docker cgroupfs driver",
body: "0::/docker/" + dockerID + "\n",
want: ProcessOwner{
Cgroup: "/docker/" + dockerID,
ContainerID: dockerID,
ContainerRuntime: "docker",
},
},
{
name: "podman",
body: "0::/machine.slice/libpod-" + dockerID + ".scope\n",
want: ProcessOwner{
Cgroup: "/machine.slice/libpod-" + dockerID + ".scope",
ContainerID: dockerID,
ContainerRuntime: "podman",
},
},
{
name: "cri-containerd under kubepods",
body: "0::/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podabc.slice/cri-containerd-" + dockerID + ".scope\n",
want: ProcessOwner{
Cgroup: "/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podabc.slice/cri-containerd-" + dockerID + ".scope",
ContainerID: dockerID,
ContainerRuntime: "containerd",
},
},
{
name: "kubepods bare id",
body: "0::/kubepods/burstable/pod9c4f/" + dockerID + "\n",
want: ProcessOwner{
Cgroup: "/kubepods/burstable/pod9c4f/" + dockerID,
ContainerID: dockerID,
ContainerRuntime: "containerd",
},
},
{
name: "lxc payload",
body: "0::/lxc.payload.mail/system.slice/postfix.service\n",
want: ProcessOwner{
Cgroup: "/lxc.payload.mail/system.slice/postfix.service",
Unit: "postfix.service",
ContainerID: "mail",
ContainerRuntime: "lxc",
},
},
{
name: "lxc plain",
body: "0::/lxc/mail\n",
want: ProcessOwner{Cgroup: "/lxc/mail", ContainerID: "mail", ContainerRuntime: "lxc"},
},
{
name: "v1 prefers the systemd hierarchy",
body: "12:pids:/\n" +
"6:memory:/system.slice\n" +
"1:name=systemd:/system.slice/sshd.service\n",
want: ProcessOwner{Cgroup: "/system.slice/sshd.service", Unit: "sshd.service"},
},
{
name: "v1 without systemd falls back to a named controller",
body: "6:memory:/system.slice/cron.service\n5:cpu:/\n",
want: ProcessOwner{Cgroup: "/system.slice/cron.service", Unit: "cron.service"},
},
{
name: "kernel thread at the root has no owner",
body: "0::/\n",
want: ProcessOwner{},
},
{
name: "empty",
body: "",
want: ProcessOwner{},
},
{
name: "malformed lines are skipped",
body: "garbage\n0::/system.slice/chronyd.service\n",
want: ProcessOwner{Cgroup: "/system.slice/chronyd.service", Unit: "chronyd.service"},
},
{
name: "a scope that is not a container id stays a unit",
body: "0::/system.slice/docker-notahash.scope\n",
want: ProcessOwner{
Cgroup: "/system.slice/docker-notahash.scope",
Unit: "docker-notahash.scope",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := parseCgroupOwner(tc.body)
if got != tc.want {
t.Fatalf("parseCgroupOwner(%q)\n got: %+v\nwant: %+v", tc.body, got, tc.want)
}
})
}
}
func TestIsContainerID(t *testing.T) {
valid := []string{
"a3f1c2d4e5b60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
"a3f1c2d4e5b6",
}
for _, id := range valid {
if !isContainerID(id) {
t.Errorf("isContainerID(%q) = false, want true", id)
}
}
invalid := []string{
"",
"nginx.service",
"A3F1C2D4E5B6", // runtimes emit lowercase; uppercase is something else
"a3f1c2d4e5b", // 11
"a3f1c2d4e5b60", // 13
"g3f1c2d4e5b6",
}
for _, id := range invalid {
if isContainerID(id) {
t.Errorf("isContainerID(%q) = true, want false", id)
}
}
}