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:
parent
ffa7afbb58
commit
d1670cfd6a
5 changed files with 335 additions and 3 deletions
|
|
@ -39,9 +39,9 @@ On drill-down (clicking a process row):
|
||||||
|
|
||||||
### List Scan (`GetFullProcessSnapshot`)
|
### List Scan (`GetFullProcessSnapshot`)
|
||||||
|
|
||||||
Reads `/proc/[pid]/stat`, `/proc/[pid]/status`, `/proc/[pid]/exe`, `/proc/[pid]/cmdline`, `/proc/[pid]/cwd`, `/proc/[pid]/io` for every PID. No related data collected at this stage.
|
Reads `/proc/[pid]/stat`, `/proc/[pid]/status`, `/proc/[pid]/exe`, `/proc/[pid]/cmdline`, `/proc/[pid]/cwd`, `/proc/[pid]/cgroup`, `/proc/[pid]/io` for every PID. No related data collected at this stage.
|
||||||
|
|
||||||
**Fields (25+):** PID, Name, Path, Cmdline, Cwd, State, UID, GID, EUID, EGID, User, Group, TTY, TTYName, CPUSecondsUser, CPUSecondsSystem, CPUPercent, RSSBytes, VMSBytes, MemPercent, Threads, Nice, StartTimeSeconds, ParentPID, ProcessGroupID, ElevationStatus, OnDisk, DiskBytesRead, DiskBytesWritten
|
**Fields (25+):** PID, Name, Path, Cmdline, Cwd, State, UID, GID, EUID, EGID, User, Group, TTY, TTYName, CPUSecondsUser, CPUSecondsSystem, CPUPercent, RSSBytes, VMSBytes, MemPercent, Threads, Nice, StartTimeSeconds, ParentPID, ProcessGroupID, ElevationStatus, OnDisk, DiskBytesRead, DiskBytesWritten, Cgroup, Unit, ContainerID, ContainerRuntime
|
||||||
|
|
||||||
### Drill-Down (`GetProcessDetail`)
|
### Drill-Down (`GetProcessDetail`)
|
||||||
|
|
||||||
|
|
@ -57,6 +57,42 @@ Adds related data from a single `/proc/[pid]/fd/` walk (consolidated from three
|
||||||
| Namespaces | `/proc/[pid]/ns/` symlinks | `max_namespaces` (default 50) |
|
| Namespaces | `/proc/[pid]/ns/` symlinks | `max_namespaces` (default 50) |
|
||||||
| Listening ports | Socket inode correlation with `/proc/net/tcp` | `max_listening_ports` (default 100) |
|
| Listening ports | Socket inode correlation with `/proc/net/tcp` | `max_listening_ports` (default 100) |
|
||||||
|
|
||||||
|
### Key Implementation Detail: Ownership Attribution
|
||||||
|
|
||||||
|
`/proc/[pid]/cgroup` answers who owns a process, which is the join between the process
|
||||||
|
list and the service, container, and package inventories. Without it a process list is a
|
||||||
|
task manager: a name, a number, and no way to ask what put it there.
|
||||||
|
|
||||||
|
`parseCgroupOwner` produces four fields from that one file:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `cgroup` | the path the attribution was read from, kept so a surprising answer can be checked |
|
||||||
|
| `unit` | innermost systemd unit — `nginx.service`, `app-firefox-4090.scope` |
|
||||||
|
| `container_id` | full container ID as the kernel spells it |
|
||||||
|
| `container_runtime` | `docker`, `podman`, `containerd`, `crio`, `lxc` |
|
||||||
|
|
||||||
|
Both cgroup generations are handled. v2 is the single `0::<path>` line. v1 has one line per
|
||||||
|
controller and the paths can disagree, so the `name=systemd` hierarchy wins when present —
|
||||||
|
it is the one that carries unit and container scopes.
|
||||||
|
|
||||||
|
Container detection covers both cgroup drivers, because the same runtime writes different
|
||||||
|
paths depending on how it was configured: systemd driver gives `docker-<id>.scope`,
|
||||||
|
`libpod-<id>.scope`, `cri-containerd-<id>.scope`; cgroupfs driver gives the bare ID under
|
||||||
|
`/docker/` or `/kubepods/.../pod<uid>/`. LXC carries a name rather than a hash. A scope
|
||||||
|
whose suffix is not hex of length 12 or 64 is a unit, not a container — `docker-notahash.scope`
|
||||||
|
attributes as a unit.
|
||||||
|
|
||||||
|
**The Docker join is a prefix match, not equality.** `container_id` here is the full ID
|
||||||
|
from the kernel; the Docker scanner reports `c.ID[:12]` in its inventory
|
||||||
|
(`agent/internal/orchestrator/docker_scanner.go`). A consumer joining the two compares
|
||||||
|
prefixes. Truncating the kernel's answer to match one scanner's display width would be
|
||||||
|
fabricating uniformity, which the mutation protocol forbids for the same reason.
|
||||||
|
|
||||||
|
Kernel threads have cgroup `/` and stay unattributed, correctly — they have no unit, no
|
||||||
|
container, and no package. On a 321-process Arch desktop that is 172 of them, and every
|
||||||
|
userland process attributes.
|
||||||
|
|
||||||
### Key Implementation Detail: Socket Inode Correlation
|
### Key Implementation Detail: Socket Inode Correlation
|
||||||
|
|
||||||
Listening ports are per-process, not system-wide. The scanner collects socket inodes from `/proc/[pid]/fd/` symlinks (`socket:[12345]`), then matches them against inode numbers in `/proc/net/tcp` and `/proc/net/tcp6`. Only LISTEN state (0A) entries whose inode matches a process socket are included.
|
Listening ports are per-process, not system-wide. The scanner collects socket inodes from `/proc/[pid]/fd/` symlinks (`socket:[12345]`), then matches them against inode numbers in `/proc/net/tcp` and `/proc/net/tcp6`. Only LISTEN state (0A) entries whose inode matches a process socket are included.
|
||||||
|
|
@ -98,6 +134,7 @@ Data collection limits are server-controlled via `ProcessExplorerConfig` (stored
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `agent/internal/system/process_detail.go` | Types: `FullProcess`, `ProcessOpenFile`, `ProcessOpenSocket`, `ProcessOpenPipe`, `ProcessMemoryMap`, `ProcessNamespace`, `ProcessListeningPort`, `ProcessCaps`, `FullProcessSnapshot` |
|
| `agent/internal/system/process_detail.go` | Types: `FullProcess`, `ProcessOpenFile`, `ProcessOpenSocket`, `ProcessOpenPipe`, `ProcessMemoryMap`, `ProcessNamespace`, `ProcessListeningPort`, `ProcessCaps`, `FullProcessSnapshot` |
|
||||||
|
| `agent/internal/system/process_owner.go` | `ProcessOwner` and `parseCgroupOwner` — cgroup attribution, platform-independent and fixture-tested |
|
||||||
| `agent/internal/system/process_detail_linux.go` | Linux `/proc` reader: `getFullProcessSnapshot()`, `getProcessDetail()`, `walkProcFD()`, `readListeningPorts()`, `parseHexAddr()` |
|
| `agent/internal/system/process_detail_linux.go` | Linux `/proc` reader: `getFullProcessSnapshot()`, `getProcessDetail()`, `walkProcFD()`, `readListeningPorts()`, `parseHexAddr()` |
|
||||||
| `agent/internal/system/process_detail_other.go` | Stub for non-Linux platforms |
|
| `agent/internal/system/process_detail_other.go` | Stub for non-Linux platforms |
|
||||||
| `agent/internal/handlers/processes.go` | `HandleScanProcesses` — command handler |
|
| `agent/internal/handlers/processes.go` | `HandleScanProcesses` — command handler |
|
||||||
|
|
@ -135,4 +172,4 @@ Data collection limits are server-controlled via `ProcessExplorerConfig` (stored
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Added: 2026-06-10*
|
*Added: 2026-06-10 · ownership attribution 2026-08-31*
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,9 @@ type FullProcess struct {
|
||||||
// Elevation
|
// Elevation
|
||||||
ElevationStatus string `json:"elevation_status,omitempty"` // "elevated" if uid!=euid
|
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)
|
// Disk I/O (from /proc/[pid]/io, may be empty for other users' processes)
|
||||||
DiskBytesRead uint64 `json:"disk_bytes_read,omitempty"`
|
DiskBytesRead uint64 `json:"disk_bytes_read,omitempty"`
|
||||||
DiskBytesWritten uint64 `json:"disk_bytes_written,omitempty"`
|
DiskBytesWritten uint64 `json:"disk_bytes_written,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -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
|
// /proc/[pid]/cmdline — full command line
|
||||||
cmdlineData, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
|
cmdlineData, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
|
||||||
130
agent/internal/system/process_owner.go
Normal file
130
agent/internal/system/process_owner.go
Normal 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
|
||||||
|
}
|
||||||
157
agent/internal/system/process_owner_test.go
Normal file
157
agent/internal/system/process_owner_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue