diff --git a/core/runtime/v2/bundle.go b/core/runtime/v2/bundle.go index 3d301b25f1..0987df61e8 100644 --- a/core/runtime/v2/bundle.go +++ b/core/runtime/v2/bundle.go @@ -18,16 +18,19 @@ package v2 import ( "context" + "encoding/json" "fmt" "os" "path/filepath" + "github.com/containerd/log" + "github.com/containerd/typeurl/v2" + "github.com/opencontainers/runtime-spec/specs-go" + "github.com/containerd/containerd/v2/core/mount" "github.com/containerd/containerd/v2/pkg/identifiers" "github.com/containerd/containerd/v2/pkg/namespaces" "github.com/containerd/containerd/v2/pkg/oci" - "github.com/containerd/typeurl/v2" - "github.com/opencontainers/runtime-spec/specs-go" ) // LoadBundle loads an existing bundle from disk @@ -106,10 +109,21 @@ func NewBundle(ctx context.Context, root, state, id string, spec typeurl.Any) (b // Spec may be nil for some sandboxers that do not initialize the spec, for // example the shim sandboxer with a hostNetwork container. if spec != nil { - if spec := spec.GetValue(); spec != nil { + if specVal := spec.GetValue(); specVal != nil { + logger := log.G(ctx) + if logger.Logger.IsLevelEnabled(log.TraceLevel) && typeurl.Is(spec, &specs.Spec{}) { + var s specs.Spec + if err := json.Unmarshal(specVal, &s); err == nil && s.Linux != nil { + logger.WithFields(log.Fields{ + "bundle": b.Path, + "maskedPaths": s.Linux.MaskedPaths, + "readonlyPaths": s.Linux.ReadonlyPaths, + }).Trace("configured bundle paths") + } + } // write the spec to the bundle specPath := filepath.Join(b.Path, oci.ConfigFilename) - err = os.WriteFile(specPath, spec, 0666) + err = os.WriteFile(specPath, specVal, 0666) if err != nil { return nil, fmt.Errorf("failed to write bundle spec: %w", err) } diff --git a/pkg/oci/spec.go b/pkg/oci/spec.go index b629aa52bc..3b3a19bb9f 100644 --- a/pkg/oci/spec.go +++ b/pkg/oci/spec.go @@ -19,9 +19,15 @@ package oci import ( "context" "encoding/json" + "errors" + "fmt" "os" "path" "runtime" + "slices" + "strconv" + "strings" + "sync" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -186,19 +192,7 @@ func populateDefaultUnixSpec(ctx context.Context, s *Spec, id string) error { }, }, Linux: &specs.Linux{ - MaskedPaths: []string{ - "/proc/acpi", - "/proc/asound", - "/proc/kcore", - "/proc/keys", - "/proc/latency_stats", - "/proc/timer_list", - "/proc/timer_stats", - "/proc/sched_debug", - "/sys/firmware", - "/sys/devices/virtual/powercap", - "/proc/scsi", - }, + MaskedPaths: defaultLinuxMaskedPaths(), ReadonlyPaths: []string{ "/proc/bus", "/proc/fs", @@ -264,3 +258,77 @@ func DescriptorToProto(d ocispec.Descriptor) *types.Descriptor { Annotations: d.Annotations, } } + +var cachedDefaultLinuxMaskedPaths = sync.OnceValue(func() []string { + maskedPaths := []string{ + "/proc/acpi", + "/proc/asound", + "/proc/interrupts", + "/proc/kcore", + "/proc/keys", + "/proc/latency_stats", + "/proc/timer_list", + "/proc/timer_stats", + "/proc/sched_debug", + "/sys/firmware", + "/sys/devices/virtual/powercap", + "/proc/scsi", + } + + return appendCPUThrottlePaths(maskedPaths, possibleCPUs()) +}) + +func defaultLinuxMaskedPaths() []string { + return slices.Clone(cachedDefaultLinuxMaskedPaths()) +} + +func possibleCPUs() []int { + if cpus, err := possibleCPUsParsed(); err == nil && len(cpus) > 0 { + return cpus + } + + var cpus []int + for i := range runtime.NumCPU() { + cpus = append(cpus, i) + } + return cpus +} + +func parsePossibleCPUs(content string) ([]int, error) { + if content == "" { + return nil, errors.New("empty possible cpu string") + } + ranges := strings.Split(content, ",") + var cpus []int + for _, r := range ranges { + if rStart, rEnd, ok := strings.Cut(r, "-"); ok { + start, err := strconv.Atoi(rStart) + if err != nil { + return nil, fmt.Errorf("invalid cpu range start %q: %w", rStart, err) + } + if start < 0 { + return nil, fmt.Errorf("negative cpu range start %d", start) + } + end, err := strconv.Atoi(rEnd) + if err != nil { + return nil, fmt.Errorf("invalid cpu range end %q: %w", rEnd, err) + } + if start > end { + return nil, fmt.Errorf("invalid cpu range %d-%d: start greater than end", start, end) + } + for i := start; i <= end; i++ { + cpus = append(cpus, i) + } + } else { + cpu, err := strconv.Atoi(rStart) + if err != nil { + return nil, fmt.Errorf("invalid cpu number %q: %w", rStart, err) + } + if cpu < 0 { + return nil, fmt.Errorf("negative cpu number %d", cpu) + } + cpus = append(cpus, cpu) + } + } + return cpus, nil +} diff --git a/pkg/oci/spec_linux.go b/pkg/oci/spec_linux.go new file mode 100644 index 0000000000..9a282f3814 --- /dev/null +++ b/pkg/oci/spec_linux.go @@ -0,0 +1,42 @@ +/* + 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 oci + +import ( + "fmt" + "os" + "strings" + "sync" +) + +var possibleCPUsParsed = sync.OnceValues(func() ([]int, error) { + data, err := os.ReadFile("/sys/devices/system/cpu/possible") + if err != nil { + return nil, err + } + return parsePossibleCPUs(strings.TrimSpace(string(data))) +}) + +func appendCPUThrottlePaths(paths []string, cpus []int) []string { + for _, cpu := range cpus { + path := fmt.Sprintf("/sys/devices/system/cpu/cpu%d/thermal_throttle", cpu) + if _, err := os.Stat(path); err == nil { + paths = append(paths, path) + } + } + return paths +} diff --git a/pkg/oci/spec_linux_test.go b/pkg/oci/spec_linux_test.go new file mode 100644 index 0000000000..ad3881a104 --- /dev/null +++ b/pkg/oci/spec_linux_test.go @@ -0,0 +1,97 @@ +/* + 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 oci + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPossibleCPUs_HostSysfs(t *testing.T) { + const possiblePath = "/sys/devices/system/cpu/possible" + data, err := os.ReadFile(possiblePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + t.Skipf("%s not present on host", possiblePath) + } + t.Fatalf("failed to read %s: %v", possiblePath, err) + } + + content := strings.TrimSpace(string(data)) + parsed, err := parsePossibleCPUs(content) + require.NoError(t, err, "host %s content %q failed to parse", possiblePath, content) + require.NotEmpty(t, parsed, "parsed CPU list should not be empty") + + cachedParsed, err := possibleCPUsParsed() + require.NoError(t, err, "possibleCPUsParsed() returned error") + assert.Equal(t, parsed, cachedParsed, "possibleCPUsParsed() should match parsePossibleCPUs") + + cpus := possibleCPUs() + assert.Equal(t, parsed, cpus, "possibleCPUs() should return parsed possible CPUs without falling back to NumCPU counting") + assert.GreaterOrEqual(t, len(cpus), runtime.NumCPU(), "possible CPUs count should be >= runtime.NumCPU()") +} + +func TestDefaultLinuxMaskedPaths(t *testing.T) { + paths := defaultLinuxMaskedPaths() + assert.Contains(t, paths, "/proc/interrupts") + + root := "/sys/devices/system/cpu" + entries, err := os.ReadDir(root) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + t.Skipf("%s not present on host", root) + } + t.Fatalf("failed to read %s: %v", root, err) + } + + for _, entry := range entries { + name := entry.Name() + if !strings.HasPrefix(name, "cpu") { + continue + } + if _, err := strconv.Atoi(strings.TrimPrefix(name, "cpu")); err != nil { + continue + } + thermalPath := filepath.Join(root, name, "thermal_throttle") + if _, err := os.Stat(thermalPath); err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + t.Fatalf("unexpected error stating %s: %v", thermalPath, err) + } + assert.Contains(t, paths, thermalPath) + } +} + +func TestDefaultLinuxMaskedPaths_Clone(t *testing.T) { + paths1 := defaultLinuxMaskedPaths() + paths2 := defaultLinuxMaskedPaths() + require.Equal(t, paths1, paths2) + require.NotEmpty(t, paths1) + + paths1[0] = "/mutated" + paths3 := defaultLinuxMaskedPaths() + assert.NotEqual(t, paths1[0], paths3[0]) +} diff --git a/pkg/oci/spec_nonlinux.go b/pkg/oci/spec_nonlinux.go new file mode 100644 index 0000000000..1922e613cb --- /dev/null +++ b/pkg/oci/spec_nonlinux.go @@ -0,0 +1,32 @@ +//go:build !linux + +/* + 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 oci + +import ( + "errors" + "sync" +) + +var possibleCPUsParsed = sync.OnceValues(func() ([]int, error) { + return nil, errors.New("not supported") +}) + +func appendCPUThrottlePaths(paths []string, _ []int) []string { + return paths +} diff --git a/pkg/oci/spec_test.go b/pkg/oci/spec_test.go index 602055c4b2..0cdc4b7547 100644 --- a/pkg/oci/spec_test.go +++ b/pkg/oci/spec_test.go @@ -30,6 +30,7 @@ import ( "github.com/containerd/continuity/fs/fstest" "github.com/moby/sys/user" "github.com/opencontainers/runtime-spec/specs-go" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -479,3 +480,76 @@ func TestGroupLookup_Symlink(t *testing.T) { }) } } + +func TestParsePossibleCPUs(t *testing.T) { + tests := []struct { + name string + input string + expected []int + expectError bool + }{ + { + name: "continuous range", + input: "0-3", + expected: []int{0, 1, 2, 3}, + }, + { + name: "large range", + input: "0-127", + expected: func() []int { + res := make([]int, 128) + for i := range res { + res[i] = i + } + return res + }(), + }, + { + name: "non-continuous range", + input: "0-2,4,6-7", + expected: []int{0, 1, 2, 4, 6, 7}, + }, + { + name: "single cpu", + input: "5", + expected: []int{5}, + }, + { + name: "empty input", + input: "", + expectError: true, + }, + { + name: "invalid range", + input: "0-2,invalid", + expectError: true, + }, + { + name: "malformed range with multiple dashes", + input: "0-2-3", + expectError: true, + }, + { + name: "reversed range", + input: "3-0", + expectError: true, + }, + { + name: "negative start", + input: "-1-3", + expectError: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := parsePossibleCPUs(test.input) + if test.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.expected, result) + } + }) + } +}