Bump opencontainers/cgroups to v0.1.0, moby/sys/userns to v0.2.1, containerd/log to v0.2.0, CDI specs-go to v1.1.1

The only sig-node runtime libraries behind their latest tags. No CVEs.
cgroups: hardening (device-filter kernel OOB workaround, O_CLOEXEC,
GetAllPids skips removed cgroups). userns: RunningInUserNS compares the
/proc/self/ns/user inode instead of parsing uid_map. containerd/log:
slog output support (vendors logrus/hooks/slog). specs-go: drops the
golang.org/x/mod requirement.

Signed-off-by: Davanum Srinivas <davanum@gmail.com>
This commit is contained in:
Davanum Srinivas committed 2026-09-25 17:29:32 -04:00
1 parent 4581f65cb3
commit cfa400da50
20 files changed
+799 -144

No files matched your search

+4 -4
View File
@@ -40,11 +40,11 @@ require (
github.com/ishidawataru/sctp v0.0.0-20250521072954-ae8eb7fa7995
github.com/lithammer/dedent v1.1.0
github.com/moby/ipvs v1.1.0
github.com/moby/sys/userns v0.1.0
github.com/moby/sys/userns v0.2.1
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822
github.com/onsi/ginkgo/v2 v2.33.0
github.com/onsi/gomega v1.43.1
github.com/opencontainers/cgroups v0.0.7
github.com/opencontainers/cgroups v0.1.0
github.com/opencontainers/selinux v1.15.1
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
github.com/prometheus/client_model v0.6.2
@@ -123,7 +123,7 @@ require (
sigs.k8s.io/randfill v1.0.0
sigs.k8s.io/structured-merge-diff/v7 v7.0.0
sigs.k8s.io/yaml v1.6.0
tags.cncf.io/container-device-interface/specs-go v1.1.0
tags.cncf.io/container-device-interface/specs-go v1.1.1
)
require (
@@ -139,7 +139,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chai2010/gettext-go v1.0.2 // indirect
github.com/containerd/containerd/api v1.12.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/log v0.2.0 // indirect
github.com/containerd/ttrpc v1.2.10 // indirect
github.com/coredns/caddy v1.1.1 // indirect
github.com/coreos/go-semver v0.3.1 // indirect
+8 -8
View File
@@ -51,8 +51,8 @@ github.com/container-storage-interface/spec v1.13.0 h1:6ja3nYoACisewTPAAZkJ1pbf4
github.com/container-storage-interface/spec v1.13.0/go.mod h1:fPZ7EFHYJwIwc9CMcoaT/yIhFTdToocYVtyafMG7EDM=
github.com/containerd/containerd/api v1.12.0 h1:kuQm82SbDrCuO4n7hf2L8zsBtZLuympyq5X/VotfX2A=
github.com/containerd/containerd/api v1.12.0/go.mod h1:EBcSzoi9Vl18cdODaXUCskf3D2NT8lsSXeZJnU5jIUc=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/log v0.2.0 h1:BewD/umNgVnoczglOpX8eRMyEy5t5iPlu5AIpnWDONc=
github.com/containerd/log v0.2.0/go.mod h1:/M7L7CXKcPTfNC74XzaK+5H5KbO5+4lJVpuVI6vRLoM=
github.com/containerd/ttrpc v1.2.10 h1:qQvy3mokhoXON7FXiOOc05O7Up6nrNj6amJF8urNvDc=
github.com/containerd/ttrpc v1.2.10/go.mod h1:YrmEQKkMbBA4EM26AzVAIwHGIYgptkL4CJ/yVTaDyBE=
github.com/containerd/typeurl/v2 v2.3.0 h1:HZHPhRWo5XMy3QGQoPrUzbW/2ckwjfweHmOwlkIrPAQ=
@@ -235,8 +235,8 @@ github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y
github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/sys/userns v0.2.1 h1:4OvdM7BcPkASbuouHsbW3aeMJSFlYDldBRnXVZhaRk8=
github.com/moby/sys/userns v0.2.1/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -252,8 +252,8 @@ github.com/onsi/ginkgo/v2 v2.33.0 h1:C8gBA6Uc2ZEubiV+SXiu5tZnMTwEmXHgkJwGozKtZf8
github.com/onsi/ginkgo/v2 v2.33.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/gomega v1.43.1 h1:vGIPFuYrIO6/0Z09s0I0QQQgFchiX4+tb1re3MScJYo=
github.com/onsi/gomega v1.43.1/go.mod h1:e/C2HwaZ1DhvjzXXuFhcR7hY7Sh9pl7MmoWKEjzwcdA=
github.com/opencontainers/cgroups v0.0.7 h1:FqhggFhigAMgKKwy39jweWX3h7Ha6VBF/qNR6Sso3oc=
github.com/opencontainers/cgroups v0.0.7/go.mod h1:hPBRvnBhLZueEN0eJyozMeM3HeFGYlZW9KnO//px6G4=
github.com/opencontainers/cgroups v0.1.0 h1:6W05KiDvgj1yi/6D13SJCvYx8TAESz67szvHPxhfcrs=
github.com/opencontainers/cgroups v0.1.0/go.mod h1:hPBRvnBhLZueEN0eJyozMeM3HeFGYlZW9KnO//px6G4=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -502,5 +502,5 @@ sigs.k8s.io/structured-merge-diff/v7 v7.0.0 h1:LnKTE1vO6tDITDMVOJ5oCsWauLIAPJnV9
sigs.k8s.io/structured-merge-diff/v7 v7.0.0/go.mod h1:xKoMSgtnJnkfRR+CTNSQ1dBVhkKJR5OQl5RxX/OqHbI=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
tags.cncf.io/container-device-interface/specs-go v1.1.0 h1:QRZVeAceQM+zTZe12eyfuJuuzp524EKYwhmvLd+h+yQ=
tags.cncf.io/container-device-interface/specs-go v1.1.0/go.mod h1:u86hoFWqnh3hWz3esofRFKbI261bUlvUfLKGrDhJkgQ=
tags.cncf.io/container-device-interface/specs-go v1.1.1 h1:3xjaytilFeCBVFJsJTaT9uOFahoqJMVuYG7gYqi2+NY=
tags.cncf.io/container-device-interface/specs-go v1.1.1/go.mod h1:BhJIkjjPh4qpys+qm4DAYtUyryaTDg9zris+AczXyws=
@@ -25,7 +25,7 @@ require (
k8s.io/kubelet v0.0.0
k8s.io/utils v0.0.0-20260626114624-be93311217bd
sigs.k8s.io/randfill v1.0.0
tags.cncf.io/container-device-interface/specs-go v1.1.0
tags.cncf.io/container-device-interface/specs-go v1.1.1
)
require (
@@ -69,7 +69,6 @@ require (
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/oauth2 v0.37.0 // indirect
golang.org/x/sync v0.23.0 // indirect
+2 -3
View File
@@ -183,7 +183,6 @@ go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU=
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
@@ -237,5 +236,5 @@ sigs.k8s.io/structured-merge-diff/v7 v7.0.0 h1:LnKTE1vO6tDITDMVOJ5oCsWauLIAPJnV9
sigs.k8s.io/structured-merge-diff/v7 v7.0.0/go.mod h1:xKoMSgtnJnkfRR+CTNSQ1dBVhkKJR5OQl5RxX/OqHbI=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
tags.cncf.io/container-device-interface/specs-go v1.1.0 h1:QRZVeAceQM+zTZe12eyfuJuuzp524EKYwhmvLd+h+yQ=
tags.cncf.io/container-device-interface/specs-go v1.1.0/go.mod h1:u86hoFWqnh3hWz3esofRFKbI261bUlvUfLKGrDhJkgQ=
tags.cncf.io/container-device-interface/specs-go v1.1.1 h1:3xjaytilFeCBVFJsJTaT9uOFahoqJMVuYG7gYqi2+NY=
tags.cncf.io/container-device-interface/specs-go v1.1.1/go.mod h1:BhJIkjjPh4qpys+qm4DAYtUyryaTDg9zris+AczXyws=
+1 -1
View File
@@ -209,4 +209,4 @@ sigs.k8s.io/structured-merge-diff/v7 v7.0.0 h1:LnKTE1vO6tDITDMVOJ5oCsWauLIAPJnV9
sigs.k8s.io/structured-merge-diff/v7 v7.0.0/go.mod h1:xKoMSgtnJnkfRR+CTNSQ1dBVhkKJR5OQl5RxX/OqHbI=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
tags.cncf.io/container-device-interface/specs-go v1.1.0/go.mod h1:u86hoFWqnh3hWz3esofRFKbI261bUlvUfLKGrDhJkgQ=
tags.cncf.io/container-device-interface/specs-go v1.1.1/go.mod h1:BhJIkjjPh4qpys+qm4DAYtUyryaTDg9zris+AczXyws=
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+35 -15
View File
@@ -1,30 +1,50 @@
version: "2"
linters:
enable:
- exportloopref # Checks for pointers to enclosing loop variables
- gofmt
- goimports
- copyloopvar
- dupword
- gosec
- govet
- ineffassign
- misspell
- nolintlint
- revive
- staticcheck
- tenv # Detects using os.Setenv instead of t.Setenv since Go 1.17
- unconvert
- unused
- vet
- dupword # Checks for duplicate words in the source code
disable:
- errcheck
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- api
- cluster
- design
- docs
- docs/man
- releases
- reports
- test
formatters:
enable:
- gofmt
- goimports
exclusions:
generated: lax
paths:
- api
- cluster
- design
- docs
- docs/man
- releases
- reports
- test
run:
timeout: 5m
skip-dirs:
- api
- cluster
- design
- docs
- docs/man
- releases
- reports
- test # e2e scripts
+61 -9
View File
@@ -40,8 +40,11 @@ package log
import (
"context"
"fmt"
"io"
"log/slog"
"github.com/sirupsen/logrus"
lslog "github.com/sirupsen/logrus/hooks/slog"
)
// G is a shorthand for [GetLogger].
@@ -108,10 +111,17 @@ const (
PanicLevel Level = logrus.PanicLevel
)
// SetLevel sets log level globally. It returns an error if the given
// level is not supported.
// levelValue is a log level accepted by [SetLevel].
type levelValue interface {
string | Level | slog.Level | lslog.Level | lslog.SlogLevel
}
// SetLevel sets the global log level.
//
// level can be one of:
// The level may be specified as a string, a [Level], a [slog.Level], an
// [lslog.Level], or an [lslog.SlogLevel].
//
// String levels are parsed using [logrus.ParseLevel] and may be one of:
//
// - "trace" ([TraceLevel])
// - "debug" ([DebugLevel])
@@ -120,10 +130,34 @@ const (
// - "error" ([ErrorLevel])
// - "fatal" ([FatalLevel])
// - "panic" ([PanicLevel])
func SetLevel(level string) error {
lvl, err := logrus.ParseLevel(level)
if err != nil {
return err
//
// slog levels are mapped to Logrus levels using the mapping defined by
// [lslog.SlogLevel]. [lslog.Level] values are accepted directly as their
// underlying Logrus level.
//
// SetLevel returns an error if a string level is not supported.
func SetLevel[T levelValue](level T) error {
var lvl Level
switch l := any(level).(type) {
case string:
var err error
lvl, err = logrus.ParseLevel(l)
if err != nil {
return err
}
case Level:
lvl = l
case slog.Level:
lvl = lslog.SlogLevel(l).Level()
case lslog.Level:
lvl = Level(l)
case lslog.SlogLevel:
lvl = l.Level()
}
L.Logger.SetLevel(lvl)
@@ -149,21 +183,39 @@ const (
// SetFormat sets the log output format ([TextFormat] or [JSONFormat]).
func SetFormat(format OutputFormat) error {
if slogOut != nil {
var handler slog.Handler
switch format {
case TextFormat:
handler = slog.NewTextHandler(slogOut, &slog.HandlerOptions{Level: loggerLevel{}})
case JSONFormat:
handler = slog.NewJSONHandler(slogOut, &slog.HandlerOptions{Level: loggerLevel{}})
default:
return fmt.Errorf("unknown log format: %s", format)
}
slog.SetDefault(slog.New(handler))
// Keep logrus formatting and output disabled, as both are handled by slog.
L.Logger.SetFormatter(discardFormatter{})
L.Logger.SetOutput(io.Discard)
return nil
}
switch format {
case TextFormat:
L.Logger.SetFormatter(&logrus.TextFormatter{
TimestampFormat: RFC3339NanoFixed,
FullTimestamp: true,
})
return nil
case JSONFormat:
L.Logger.SetFormatter(&logrus.JSONFormatter{
TimestampFormat: RFC3339NanoFixed,
})
return nil
default:
return fmt.Errorf("unknown log format: %s", format)
}
return nil
}
// WithLogger returns a new context with the provided logger. Use in
+66
View File
@@ -0,0 +1,66 @@
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package log
import (
"io"
"log/slog"
"sync"
"github.com/sirupsen/logrus"
lslog "github.com/sirupsen/logrus/hooks/slog"
)
// slogOut is used to set the slog logger when setting output format.
var slogOut io.Writer
// slogOnce guards UseSlog so repeated calls do not stack up hooks or
// reset slogOut to the discard writer installed on the first call.
var slogOnce sync.Once
func UseSlog() {
slogOnce.Do(func() {
L.Logger.SetNoLock()
L.Logger.AddHook(slogHook{})
slogOut = L.Logger.Out
// Disable logrus formatting and output, as both are handled by slog.
L.Logger.SetFormatter(discardFormatter{})
L.Logger.SetOutput(io.Discard)
})
}
type slogHook struct{}
func (slogHook) Levels() []logrus.Level {
return logrus.AllLevels
}
func (slogHook) Fire(entry *logrus.Entry) error {
return lslog.NewHook(slog.Default(), nil).Fire(entry)
}
// loggerLevel exposes the current Logrus logger level as a slog.Leveler.
type loggerLevel struct{}
func (loggerLevel) Level() slog.Level {
return lslog.Level(L.Logger.GetLevel()).Level()
}
type discardFormatter struct{}
func (discardFormatter) Format(*logrus.Entry) ([]byte, error) { return nil, nil }
+48
View File
@@ -2,11 +2,16 @@ package userns
import (
"bufio"
"errors"
"fmt"
"os"
"sync"
"syscall"
)
// See PROC_USER_INIT_INO in https://github.com/torvalds/linux/blob/v7.1/include/uapi/linux/nsfs.h#L50.
const procUserInitIno = 0xEFFFFFFD
var inUserNS = sync.OnceValue(runningInUserNS)
// runningInUserNS detects whether we are currently running in a user namespace.
@@ -17,6 +22,33 @@ var inUserNS = sync.OnceValue(runningInUserNS)
// [libcontainer/runc]: https://github.com/opencontainers/runc/blob/3778ae603c706494fd1e2c2faf83b406e38d687d/libcontainer/userns/userns_linux.go#L12-L49
// [lcx/incus]: https://github.com/lxc/incus/blob/e45085dd42f826b3c8c3228e9733c0b6f998eafe/shared/util.go#L678-L700
func runningInUserNS() bool {
var st syscall.Stat_t
if err := syscall.Stat("/proc/self/ns/user", &st); err == nil {
// The kernel's initial user namespace inode is definitive when it
// matches. OpenVZ virtualizes namespace inode numbers, so a mismatch
// must fall back to uid_map-based detection there.
if st.Ino == procUserInitIno {
return false
}
if runningInOpenVZ() {
return runningInUserNSFromUIDMap()
}
return true
} else if !os.IsNotExist(err) {
// As long as /proc/self/ns/user exists, we are on a modern kernel.
// Other errors indicate an unexpected procfs state, where assuming the
// init namespace would be unsafe.
return false
}
// Only fall back for older kernels that do not expose the user namespace
// through procfs at /proc/self/ns/user.
// TODO: Remove this fallback once Linux kernels older than 3.8 are no
// longer supported.
return runningInUserNSFromUIDMap()
}
func runningInUserNSFromUIDMap() bool {
file, err := os.Open("/proc/self/uid_map")
if err != nil {
// This kernel-provided file only exists if user namespaces are supported.
@@ -51,3 +83,19 @@ func uidMapInUserNS(uidMap string) bool {
initNS := a == 0 && b == 0 && c == 4294967295
return !initNS
}
// runningInOpenVZ reports whether the process is running inside an OpenVZ
// container.
//
// OpenVZ exposes /proc/vz both on the host and inside containers, while
// /proc/bc is only exposed on the host. This follows systemd's OpenVZ
// detection:
// https://github.com/systemd/systemd/blob/v261.2/src/basic/virt.c#L642-L653
func runningInOpenVZ() bool {
var st syscall.Stat_t
if err := syscall.Stat("/proc/vz", &st); err != nil {
return false
}
err := syscall.Stat("/proc/bc", &st)
return errors.Is(err, syscall.ENOENT)
}
+2
View File
@@ -10,6 +10,8 @@ formatters:
linters:
enable:
- errorlint
- govet
- modernize
- nolintlint
- unconvert
- unparam
+4 -3
View File
@@ -4,7 +4,7 @@ import (
"errors"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"sync"
@@ -153,8 +153,9 @@ func (m *Manager) AddPid(subcgroup string, pid int) (retErr error) {
c := m.cgroups
for _, dir := range m.paths {
path := path.Join(dir, subcgroup)
if !strings.HasPrefix(path, dir) {
path := filepath.Join(dir, subcgroup)
// Make sure path is either dir itself or below it.
if path != dir && !strings.HasPrefix(path, dir+"/") {
return fmt.Errorf("bad sub cgroup path: %s", subcgroup)
}
+2 -1
View File
@@ -89,7 +89,8 @@ func (m *Manager) Apply(pid int) error {
// a cgroup under under the manager's cgroup.
func (m *Manager) AddPid(subcgroup string, pid int) error {
path := filepath.Join(m.dirPath, subcgroup)
if !strings.HasPrefix(path, m.dirPath) {
// Make sure path is either m.dirPath itself or below it.
if path != m.dirPath && !strings.HasPrefix(path, m.dirPath+"/") {
return fmt.Errorf("bad sub cgroup path: %s", subcgroup)
}
+17
View File
@@ -1,8 +1,12 @@
package cgroups
import (
"errors"
"io/fs"
"os"
"path/filepath"
"golang.org/x/sys/unix"
)
// GetAllPids returns all pids from the cgroup identified by path, and all its
@@ -11,6 +15,11 @@ func GetAllPids(path string) ([]int, error) {
var pids []int
err := filepath.WalkDir(path, func(p string, d fs.DirEntry, iErr error) error {
if iErr != nil {
// A descendant cgroup can be removed while we walk, ignore
// any such error unless it's on the root (path) cgroup here
if p != path && ignoreCgroupRemoved(iErr) {
return nil
}
return iErr
}
if !d.IsDir() {
@@ -18,6 +27,9 @@ func GetAllPids(path string) ([]int, error) {
}
cPids, err := readProcsFile(p)
if err != nil {
if p != path && ignoreCgroupRemoved(err) {
return nil
}
return err
}
pids = append(pids, cPids...)
@@ -25,3 +37,8 @@ func GetAllPids(path string) ([]int, error) {
})
return pids, err
}
// ignoreCgroupRemoved reports whether err indicates the cgroup was removed.
func ignoreCgroupRemoved(err error) bool {
return os.IsNotExist(err) || errors.Is(err, unix.ENODEV)
}
+27 -8
View File
@@ -20,7 +20,8 @@ import (
)
const (
cpuIdleSupportedVersion = 252
cpuIdleSupportedVersion = 252
oomPolicySupportedVersion = 253
)
type UnifiedManager struct {
@@ -183,13 +184,8 @@ func unifiedResToSystemdProps(cm *dbusConnManager, res map[string]string) (props
newProp("TasksMax", num))
case "memory.oom.group":
// Setting this to 1 is roughly equivalent to OOMPolicy=kill
// (as per systemd.service(5) and
// https://www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html),
// but it's not clear what to do if it is unset or set
// to 0 in runc update, as there are two other possible
// values for OOMPolicy (continue/stop).
fallthrough
// This was set before the unit started, so no need to
// warn about it here.
default:
// Ignore the unknown resource here -- will still be
@@ -338,6 +334,29 @@ func (m *UnifiedManager) Apply(pid int) error {
properties = append(properties, c.SystemdProps...)
// OOMPolicy must be set at unit creation time, systemd does not allow
// changing it on a running scope for some reason despite the kernel allowing so
// so we do this here in Apply() instead of Set()
if c.Resources != nil {
if v, ok := c.Resources.Unified["memory.oom.group"]; ok {
if systemdVersion(m.dbus) >= oomPolicySupportedVersion {
value, err := strconv.ParseUint(v, 10, 64)
if err != nil {
return fmt.Errorf("unified resource %q value conversion error: %w", "memory.oom.group", err)
}
switch value {
case 0:
properties = append(properties, newProp("OOMPolicy", "continue"))
case 1:
properties = append(properties, newProp("OOMPolicy", "kill"))
default:
logrus.Debugf("don't know how to convert memory.oom.group=%d; skipping (will still be applied to cgroupfs)", value)
}
}
}
}
if err := startUnit(m.dbus, unitName, properties, pid == -1); err != nil {
return fmt.Errorf("unable to start unit %q (properties %+v): %w", unitName, properties, err)
}
+201
View File
@@ -0,0 +1,201 @@
package slog
import (
"context"
"log/slog"
"maps"
"runtime"
"slices"
"strings"
"github.com/sirupsen/logrus"
)
// HandlerOptions are options for a [Handler].
// A zero HandlerOptions consists entirely of default values.
type HandlerOptions struct {
// AddSource causes the handler to include the source code position
// of the log statement in the Logrus entry.
AddSource bool
// LevelMapper maps slog levels to Logrus levels. If nil, the default
// mapping is used. Set it to customize level mapping, for example to map
// custom slog levels to specific Logrus levels.
LevelMapper func(slog.Level) logrus.Level
}
// Handler is a [slog.Handler] that writes records to a [logrus.Logger].
//
// It is intended for bridging libraries or application code that log via slog
// into an existing Logrus logger, for example during a gradual migration.
//
// By default, slog levels are mapped using [SlogLevel].
// Set [HandlerOptions.LevelMapper] to customize the mapping.
//
// Mapping to [logrus.FatalLevel] or [logrus.PanicLevel] preserves the level
// only; handling a record does not exit or panic.
type Handler struct {
logger *logrus.Logger
opts HandlerOptions
// fields holds attributes from prior WithAttrs calls, already resolved
// under the group prefix that applied when they were added.
fields logrus.Fields
// groups is the current group name stack for attributes added later.
groups []string
}
var _ slog.Handler = (*Handler)(nil)
// NewHandler creates a [slog.Handler] that writes to the provided
// [logrus.Logger].
//
// The provided logger must not be nil. NewHandler panics if logger is nil.
// If opts is nil, the default options are used.
func NewHandler(logger *logrus.Logger, opts *HandlerOptions) *Handler {
if logger == nil {
panic("cannot create handler from nil logger")
}
if opts == nil {
opts = &HandlerOptions{}
}
return &Handler{
logger: logger,
opts: *opts,
}
}
// toLogrusLevel maps a slog level using LevelMapper or the default mapping.
func (h *Handler) toLogrusLevel(level slog.Level) logrus.Level {
if h.opts.LevelMapper != nil {
return h.opts.LevelMapper(level)
}
return SlogLevel(level).Level()
}
// Enabled reports whether the handler handles records at the given level.
// It maps the slog level to a logrus level and consults the underlying logger.
func (h *Handler) Enabled(_ context.Context, level slog.Level) bool {
return h.logger.IsLevelEnabled(h.toLogrusLevel(level))
}
// Handle converts the slog record into a logrus entry and logs it.
// Record time, context, message, and attributes (including those from
// [Handler.WithAttrs]/[Handler.WithGroup]) are preserved. Attributes are
// attached as logrus fields; group names are joined with "." as a key prefix
// (similar to [slog.TextHandler]).
func (h *Handler) Handle(ctx context.Context, record slog.Record) error {
// Stop recursive forwarding before it can cycle back through this logger.
if hasLogrusLogger(ctx, h.logger) {
return nil
}
level := h.toLogrusLevel(record.Level)
if !h.logger.IsLevelEnabled(level) {
return nil
}
entry := &logrus.Entry{
Logger: h.logger,
Data: h.fields,
Time: record.Time,
Context: ctx,
}
if h.opts.AddSource && record.PC != 0 {
// Preserve the caller selected by slog instead of rediscovering it
// through Logrus's caller stack filtering.
//
// Record.PC is a return PC; resolve it with CallersFrames.
frame, _ := runtime.CallersFrames([]uintptr{record.PC}).Next()
entry.Caller = &frame
}
if n := record.NumAttrs(); n > 0 {
// Clone before mutating the handler's fields.
entry.Data = maps.Clone(h.fields)
if entry.Data == nil {
entry.Data = make(logrus.Fields, n)
}
record.Attrs(func(a slog.Attr) bool {
appendAttr(entry.Data, h.groups, a)
return true
})
}
entry.Log(level, record.Message)
return nil
}
// WithAttrs returns a new Handler whose attributes consist of h's attributes
// followed by attrs.
func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return h
}
h2 := h.clone()
h2.fields = maps.Clone(h.fields)
if h2.fields == nil {
h2.fields = make(logrus.Fields, len(attrs))
}
for _, a := range attrs {
appendAttr(h2.fields, h.groups, a)
}
return h2
}
// WithGroup returns a new Handler with a group appended to the receiver's
// existing groups. All attributes added to the returned handler (via WithAttrs
// or Handle) are nested under the combined group names.
// If name is empty, WithGroup returns h unchanged.
func (h *Handler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
h2 := h.clone()
h2.groups = append(slices.Clip(h.groups), name)
return h2
}
// clone returns a shallow copy of h. Callers must clone fields or groups
// before modifying them.
func (h *Handler) clone() *Handler {
return &Handler{
logger: h.logger,
opts: h.opts,
fields: h.fields,
groups: h.groups,
}
}
// appendAttr adds attr to fields, resolving it and applying group prefixes.
// Group attributes are flattened with "."-separated keys.
func appendAttr(fields logrus.Fields, groups []string, attr slog.Attr) {
attr.Value = attr.Value.Resolve()
if attr.Equal(slog.Attr{}) {
return
}
if attr.Value.Kind() == slog.KindGroup {
gs := attr.Value.Group()
if len(gs) == 0 {
return
}
g := groups
if attr.Key != "" {
g = append(slices.Clip(groups), attr.Key)
}
for _, a := range gs {
appendAttr(fields, g, a)
}
return
}
key := attr.Key
if len(groups) > 0 {
key = strings.Join(groups, ".")
if attr.Key != "" {
key += "." + attr.Key
}
}
fields[key] = attr.Value.Any()
}
+119
View File
@@ -0,0 +1,119 @@
package slog
import (
"log/slog"
"github.com/sirupsen/logrus"
)
// slog levels corresponding to Logrus levels.
const (
slogLevelTrace = slog.LevelDebug - 4
slogLevelDebug = slog.LevelDebug
slogLevelInfo = slog.LevelInfo
slogLevelWarn = slog.LevelWarn
slogLevelError = slog.LevelError
slogLevelFatal = slog.LevelError + 2
slogLevelPanic = slog.LevelError + 4
)
// A Leveler provides a [logrus.Level] value.
//
// It is the Logrus counterpart of [slog.Leveler].
//
// As [SlogLevel] itself implements Leveler, clients typically supply
// a SlogLevel value wherever a Leveler is needed.
// Clients who need to vary the level dynamically can provide a more complex
// Leveler implementation.
type Leveler interface {
Level() logrus.Level
}
// Level adapts a [logrus.Level] to a [slog.Leveler] using the default
// Logrus-to-slog level mapping:
//
// - [logrus.TraceLevel] -> [slog.LevelDebug] - 4
// - [logrus.DebugLevel] -> [slog.LevelDebug]
// - [logrus.InfoLevel] -> [slog.LevelInfo]
// - [logrus.WarnLevel] -> [slog.LevelWarn]
// - [logrus.ErrorLevel] -> [slog.LevelError]
// - [logrus.FatalLevel] -> [slog.LevelError] + 2
// - [logrus.PanicLevel] -> [slog.LevelError] + 4
//
// Unknown Logrus levels map to [slog.LevelError].
type Level logrus.Level
// Level returns l mapped to the corresponding slog level.
func (l Level) Level() slog.Level {
return toSlogLevel(logrus.Level(l))
}
var _ slog.Leveler = Level(0)
// SlogLevel adapts a [slog.Level] to a [Leveler] using the default
// slog-to-Logrus level mapping:
//
// - [slog.LevelDebug] - 4 -> [logrus.TraceLevel]
// - [slog.LevelDebug] -> [logrus.DebugLevel]
// - [slog.LevelInfo] -> [logrus.InfoLevel]
// - [slog.LevelWarn] -> [logrus.WarnLevel]
// - [slog.LevelError] -> [logrus.ErrorLevel]
// - [slog.LevelError] + 2 -> [logrus.FatalLevel]
// - [slog.LevelError] + 4 -> [logrus.PanicLevel]
//
// Levels between these boundaries map to the next lower Logrus severity.
// Levels below [slog.LevelDebug] map to [logrus.TraceLevel], and levels at or
// above the Panic boundary map to [logrus.PanicLevel].
type SlogLevel slog.Level
// Level returns l mapped to the corresponding Logrus level.
func (l SlogLevel) Level() logrus.Level {
return toLogrusLevel(slog.Level(l))
}
var _ Leveler = SlogLevel(0)
// toLogrusLevel maps a slog level using the default mapping.
func toLogrusLevel(level slog.Level) logrus.Level {
switch {
case level >= slogLevelPanic:
return logrus.PanicLevel
case level >= slogLevelFatal:
return logrus.FatalLevel
case level >= slogLevelError:
return logrus.ErrorLevel
case level >= slogLevelWarn:
return logrus.WarnLevel
case level >= slogLevelInfo:
return logrus.InfoLevel
case level >= slogLevelDebug:
return logrus.DebugLevel
case level >= slogLevelTrace:
return logrus.TraceLevel
default:
return logrus.TraceLevel
}
}
// toSlogLevel maps a Logrus level using the default mapping.
func toSlogLevel(level logrus.Level) slog.Level {
switch level {
case logrus.PanicLevel:
return slogLevelPanic
case logrus.FatalLevel:
return slogLevelFatal
case logrus.ErrorLevel:
return slogLevelError
case logrus.WarnLevel:
return slogLevelWarn
case logrus.InfoLevel:
return slogLevelInfo
case logrus.DebugLevel:
return slogLevelDebug
case logrus.TraceLevel:
return slogLevelTrace
default:
// Treat all unknown levels as errors
return slogLevelError
}
}
+126
View File
@@ -0,0 +1,126 @@
// Package slog provides adapters between Logrus and log/slog.
//
// [Handler] forwards slog records to a Logrus logger, while [Hook] forwards
// Logrus entries to a slog logger. They can be used independently to support
// incremental migration between the two logging APIs.
package slog
import (
"context"
"log/slog"
"maps"
"github.com/sirupsen/logrus"
)
// logrusLoggerContextKey is the context key used to track Logrus loggers a
// record has already traversed while being forwarded through slog adapters.
type logrusLoggerContextKey struct{}
// withLogrusLogger returns a context that records logger as having already
// handled the current log record.
func withLogrusLogger(ctx context.Context, logger *logrus.Logger) context.Context {
seen, _ := ctx.Value(logrusLoggerContextKey{}).(map[*logrus.Logger]struct{})
seen = maps.Clone(seen)
if seen == nil {
seen = make(map[*logrus.Logger]struct{}, 1)
}
seen[logger] = struct{}{}
return context.WithValue(ctx, logrusLoggerContextKey{}, seen)
}
// hasLogrusLogger reports whether logger has already handled the current log
// record while it was forwarded through slog adapters.
func hasLogrusLogger(ctx context.Context, logger *logrus.Logger) bool {
seen, _ := ctx.Value(logrusLoggerContextKey{}).(map[*logrus.Logger]struct{})
_, ok := seen[logger]
return ok
}
// HookOptions are options for a [Hook].
// A zero HookOptions consists entirely of default values.
type HookOptions struct {
// LevelMapper maps Logrus levels to slog levels. If nil, the default
// mapping is used. Set it to customize level mapping, for example to map
// custom Logrus levels to specific slog levels.
LevelMapper func(logrus.Level) slog.Level
}
// Hook sends Logrus entries to slog.
//
// It is intended for bridging libraries or application code that log via
// Logrus into an existing slog logger, for example during a gradual migration.
//
// By default, Logrus levels are mapped using [Level]. Set
// [HookOptions.LevelMapper] to customize the mapping.
type Hook struct {
logger *slog.Logger
opts HookOptions
}
var _ logrus.Hook = (*Hook)(nil)
// NewHook creates a [logrus.Hook] that sends logs to the provided [slog.Logger].
//
// This hook is intended to be used during transition from Logrus to slog,
// or as a shim between different parts of your application or different
// libraries that depend on different loggers.
//
// The provided logger must not be nil. NewHook panics if logger is nil.
// If opts is nil, the default options are used.
func NewHook(logger *slog.Logger, opts *HookOptions) *Hook {
if logger == nil {
panic("cannot create hook from nil logger")
}
if opts == nil {
opts = &HookOptions{}
}
return &Hook{
logger: logger,
opts: *opts,
}
}
// toSlogLevel maps a Logrus level using LevelMapper or the default mapping.
func (h *Hook) toSlogLevel(level logrus.Level) slog.Level {
if h.opts.LevelMapper != nil {
return h.opts.LevelMapper(level)
}
return Level(level).Level()
}
// Levels always returns all levels, since slog allows controlling level
// enabling based on context.
func (h *Hook) Levels() []logrus.Level {
return logrus.AllLevels
}
// Fire forwards the provided logrus Entry to the underlying slog.Logger's
// Handler, mapping it to a slog.Record. Time and caller information are
// preserved when available, and Entry.Data is converted to attributes.
// If Entry.Context is nil, context.Background() is used.
func (h *Hook) Fire(entry *logrus.Entry) error {
ctx := entry.Context
if ctx == nil {
ctx = context.Background()
}
// Track this logger to guard against recursive forwarding.
ctx = withLogrusLogger(ctx, entry.Logger)
level := h.toSlogLevel(entry.Level)
handler := h.logger.Handler()
if !handler.Enabled(ctx, level) {
return nil
}
attrs := make([]slog.Attr, 0, len(entry.Data))
for k, v := range entry.Data {
attrs = append(attrs, slog.Any(k, v))
}
var pc uintptr
if entry.Caller != nil {
pc = entry.Caller.PC
}
r := slog.NewRecord(entry.Time, level, entry.Message, pc)
r.AddAttrs(attrs...)
return handler.Handle(ctx, r)
}
+6 -5
View File
@@ -80,8 +80,8 @@ github.com/containerd/containerd/api/services/tasks/v1
github.com/containerd/containerd/api/services/version/v1
github.com/containerd/containerd/api/types
github.com/containerd/containerd/api/types/task
# github.com/containerd/log v0.1.0
## explicit; go 1.20
# github.com/containerd/log v0.2.0
## explicit; go 1.23
github.com/containerd/log
# github.com/containerd/ttrpc v1.2.10
## explicit; go 1.25.0
@@ -416,7 +416,7 @@ github.com/moby/spdystream/spdy
# github.com/moby/sys/mountinfo v0.7.2
## explicit; go 1.17
github.com/moby/sys/mountinfo
# github.com/moby/sys/userns v0.1.0
# github.com/moby/sys/userns v0.2.1
## explicit; go 1.21
github.com/moby/sys/userns
# github.com/moby/term v0.5.2
@@ -472,7 +472,7 @@ github.com/onsi/gomega/matchers/support/goraph/edge
github.com/onsi/gomega/matchers/support/goraph/node
github.com/onsi/gomega/matchers/support/goraph/util
github.com/onsi/gomega/types
# github.com/opencontainers/cgroups v0.0.7
# github.com/opencontainers/cgroups v0.1.0
## explicit; go 1.24.0
github.com/opencontainers/cgroups
github.com/opencontainers/cgroups/devices/config
@@ -536,6 +536,7 @@ github.com/russross/blackfriday/v2
# github.com/sirupsen/logrus v1.10.2
## explicit; go 1.23
github.com/sirupsen/logrus
github.com/sirupsen/logrus/hooks/slog
# github.com/soheilhy/cmux v0.1.5
## explicit; go 1.11
github.com/soheilhy/cmux
@@ -1302,6 +1303,6 @@ sigs.k8s.io/structured-merge-diff/v7/value
## explicit; go 1.22
sigs.k8s.io/yaml
sigs.k8s.io/yaml/kyaml
# tags.cncf.io/container-device-interface/specs-go v1.1.0
# tags.cncf.io/container-device-interface/specs-go v1.1.1
## explicit; go 1.19
tags.cncf.io/container-device-interface/specs-go
+68 -85
View File
@@ -19,47 +19,46 @@ package specs
import (
"fmt"
"strings"
"golang.org/x/mod/semver"
)
// CurrentVersion is the current version of the Spec.
const CurrentVersion = "1.1.0"
const (
// CurrentVersion is the current version of the Spec.
CurrentVersion = "1.1.0"
// vCurrent is the current version as a semver-comparable type
vCurrent version = "v" + CurrentVersion
// These represent the released versions of the CDI specification
v010 version = "v0.1.0"
v020 version = "v0.2.0"
v030 version = "v0.3.0"
v040 version = "v0.4.0"
v050 version = "v0.5.0"
v060 version = "v0.6.0"
v070 version = "v0.7.0"
v080 version = "v0.8.0"
v100 version = "v1.0.0"
v110 version = "v1.1.0"
v010 version = "0.1.0"
v020 version = "0.2.0"
v030 version = "0.3.0"
v040 version = "0.4.0"
v050 version = "0.5.0"
v060 version = "0.6.0"
v070 version = "0.7.0"
v080 version = "0.8.0"
v100 version = "1.0.0"
v110 version = "1.1.0"
// vEarliest is the earliest supported version of the CDI specification
vEarliest version = v030
)
// validSpecVersions stores a map of spec versions to functions to check the required versions.
// validSpecVersions stores the known spec versions in newest-to-oldest order,
// together with functions to check whether a version is required.
// Adding new fields / spec versions requires that a `requiredFunc` be implemented and
// this map be updated.
var validSpecVersions = requiredVersionMap{
v010: nil,
v020: nil,
v030: nil,
v040: requiresV040,
v050: requiresV050,
v060: requiresV060,
v070: requiresV070,
v080: requiresV080,
v100: requiresV100,
v110: requiresV110,
// this list be updated.
var validSpecVersions = []struct {
version version
isRequired requiredFunc
}{
{v110, requiresV110},
{v100, requiresV100},
{v080, requiresV080},
{v070, requiresV070},
{v060, requiresV060},
{v050, requiresV050},
{v040, requiresV040},
{v030, nil},
{v020, nil},
{v010, nil},
}
// ValidateVersion checks whether the specified spec version is valid.
@@ -67,14 +66,15 @@ var validSpecVersions = requiredVersionMap{
// the spec is inspected to determine whether the features used are available in specified
// version.
func ValidateVersion(spec *Spec) error {
if !validSpecVersions.isValidVersion(spec.Version) {
specVersion := newVersion(spec.Version)
if !isValidVersion(specVersion) {
return fmt.Errorf("invalid version %q", spec.Version)
}
minVersion, err := MinimumRequiredVersion(spec)
if err != nil {
return fmt.Errorf("could not determine minimum required version: %w", err)
}
if newVersion(minVersion).isGreaterThan(newVersion(spec.Version)) {
if versionIndex(version(minVersion)) < versionIndex(specVersion) {
return fmt.Errorf("the spec version must be at least v%v", minVersion)
}
return nil
@@ -82,64 +82,50 @@ func ValidateVersion(spec *Spec) error {
// MinimumRequiredVersion determines the minimum spec version for the input spec.
func MinimumRequiredVersion(spec *Spec) (string, error) {
minVersion := validSpecVersions.requiredVersion(spec)
return minVersion.String(), nil
minVersion := requiredVersion(spec)
return string(minVersion), nil
}
// version represents a semantic version string
// version represents a CDI specification version.
type version string
// newVersion creates a version that can be used for semantic version comparisons.
// newVersion normalizes a specification version by removing an optional leading v.
func newVersion(v string) version {
return version("v" + strings.TrimPrefix(v, "v"))
}
// String returns the string representation of the version.
// This trims a leading v if present.
func (v version) String() string {
return strings.TrimPrefix(string(v), "v")
}
// isGreaterThan checks with a version is greater than the specified version.
func (v version) isGreaterThan(o version) bool {
return semver.Compare(string(v), string(o)) > 0
}
// isLatest checks whether the version is the latest supported version
func (v version) isLatest() bool {
return v == vCurrent
return version(strings.TrimPrefix(v, "v"))
}
type requiredFunc func(*Spec) bool
type requiredVersionMap map[version]requiredFunc
// isValidVersion checks whether the specified version is valid.
// A version is valid if it is contained in the required version map.
func (r requiredVersionMap) isValidVersion(specVersion string) bool {
_, ok := validSpecVersions[newVersion(specVersion)]
return ok
}
// requiredVersion returns the minimum version required for the given spec
func (r requiredVersionMap) requiredVersion(spec *Spec) version {
minVersion := vEarliest
for v, isRequired := range validSpecVersions {
if isRequired == nil {
continue
}
if isRequired(spec) && v.isGreaterThan(minVersion) {
minVersion = v
}
// If we have already detected the latest version then no later version could be detected
if minVersion.isLatest() {
break
// A version is valid if it is contained in the list of known spec versions.
func isValidVersion(specVersion version) bool {
for _, known := range validSpecVersions {
if known.version == specVersion {
return true
}
}
return false
}
return minVersion
// requiredVersion returns the minimum version required for the given spec.
func requiredVersion(spec *Spec) version {
for _, known := range validSpecVersions {
if known.isRequired != nil && known.isRequired(spec) {
return known.version
}
}
return vEarliest
}
// versionIndex returns the index of v in validSpecVersions, which is ordered
// newest-to-oldest. It returns -1 for an unknown version.
func versionIndex(v version) int {
for i, known := range validSpecVersions {
if known.version == v {
return i
}
}
return -1
}
// requiresV110 returns true if the spec uses v1.1.0 features.
@@ -209,23 +195,20 @@ func requiresV070(spec *Spec) bool {
// requiresV060 returns true if the spec uses v0.6.0 features
func requiresV060(spec *Spec) bool {
// The v0.6.0 spec allows annotations to be specified at a spec level
for range spec.Annotations {
if len(spec.Annotations) > 0 {
return true
}
// The v0.6.0 spec allows annotations to be specified at a device level
for _, d := range spec.Devices {
for range d.Annotations {
if len(d.Annotations) > 0 {
return true
}
}
// The v0.6.0 spec allows dots "." in Kind name label (class)
if !strings.Contains(spec.Kind, "/") {
return false
}
class := strings.SplitN(spec.Kind, "/", 2)[1]
return strings.Contains(class, ".")
_, class, ok := strings.Cut(spec.Kind, "/")
return ok && strings.Contains(class, ".")
}
// requiresV050 returns true if the spec uses v0.5.0 features