Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion pkg/shim/v1/proc/BUILD
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
load("//tools:defs.bzl", "go_library")
load("//tools:defs.bzl", "go_library", "go_test")

package(
default_applicable_licenses = ["//:license"],
Expand Down Expand Up @@ -40,3 +40,10 @@ go_library(
"@org_golang_x_sys//unix:go_default_library",
],
)

go_test(
name = "proc_test",
size = "small",
srcs = ["update_test.go"],
library = ":proc",
)
16 changes: 16 additions & 0 deletions pkg/shim/v1/proc/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (

"github.com/containerd/fifo"
runc "github.com/containerd/go-runc"
google_protobuf "github.com/gogo/protobuf/types"
specs "github.com/opencontainers/runtime-spec/specs-go"
"golang.org/x/sys/unix"
"gvisor.dev/gvisor/pkg/shim/v1/extension"
Expand Down Expand Up @@ -452,6 +453,21 @@ func (p *Init) Stats(ctx context.Context, id string) (*runc.Stats, error) {
return p.initState.Stats(ctx, id)
}

// Update applies resource changes from JSON LinuxResources in the protobuf Any.
func (p *Init) Update(ctx context.Context, r *google_protobuf.Any) error {
p.mu.Lock()
defer p.mu.Unlock()

if r == nil {
return fmt.Errorf("resources are required: %w", errdefs.ErrInvalidArgument)
}
var resources specs.LinuxResources
if err := json.Unmarshal(r.Value, &resources); err != nil {
return fmt.Errorf("decoding resources: %w", err)
}
return p.runtime.Update(ctx, p.id, &resources)
}
Comment thread
a7i marked this conversation as resolved.

func (p *Init) stats(ctx context.Context, id string) (*runc.Stats, error) {
return p.Runtime().Stats(ctx, id)
}
Expand Down
45 changes: 45 additions & 0 deletions pkg/shim/v1/proc/update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright 2026 The gVisor 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
//
// https://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 proc

import (
"errors"
"strings"
"testing"

"github.com/containerd/containerd/pkg/stdio"
"github.com/containerd/errdefs"
"github.com/gogo/protobuf/types"
"gvisor.dev/gvisor/pkg/shim/v1/runsccmd"
)

func TestInitUpdateNilAny(t *testing.T) {
p := New("id", &runsccmd.Runsc{}, stdio.Stdio{})
p.initState = &runningState{p: p}
err := p.Update(t.Context(), nil)
if !errors.Is(err, errdefs.ErrInvalidArgument) {
t.Fatalf("Update(nil): %v, want ErrInvalidArgument", err)
}
}

func TestInitUpdateInvalidJSON(t *testing.T) {
p := New("id", &runsccmd.Runsc{}, stdio.Stdio{})
p.initState = &runningState{p: p}
err := p.Update(t.Context(), &types.Any{Value: []byte(`{`)})
if err == nil || !strings.Contains(err.Error(), "decoding resources") {
t.Fatalf("Update(bad JSON): %v", err)
}
}

12 changes: 12 additions & 0 deletions pkg/shim/v1/runsc/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,18 @@ func (c *Container) CloseIO(ctx context.Context, r *task.CloseIORequest) error {
return nil
}

// Update applies cgroup resource limits for the init task.
func (c *Container) Update(ctx context.Context, r *task.UpdateTaskRequest) error {
if r.Resources == nil {
return fmt.Errorf("resources are required: %w", errdefs.ErrInvalidArgument)
}
p, err := c.Process("")
if err != nil {
return err
}
return p.(*proc.Init).Update(ctx, r.Resources)
}

// Restore a process in the container.
func (c *Container) Restore(ctx context.Context, r *extension.RestoreRequest) (extension.Process, error) {
p, err := c.Process(r.Start.ExecID)
Expand Down
12 changes: 11 additions & 1 deletion pkg/shim/v1/runsc/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -765,8 +765,18 @@ func (s *runscService) getV2Stats(stats *runc.Stats, r *taskAPI.StatsRequest) (*
}

// Update updates a running container.
// CRI UpdateContainerResources sends the workload container ID, but runsc
// update only accepts the root (sandbox) container. Route through s.id which
// is the sandbox container ID assigned at shim startup.
func (s *runscService) Update(ctx context.Context, r *taskAPI.UpdateTaskRequest) (*types.Empty, error) {
return empty, errdefs.ErrNotImplemented
c, err := s.getContainer(s.id)
if err != nil {
return nil, err
}
if err := c.Update(ctx, r); err != nil {
return nil, errdefs.ToGRPC(err)
}
return empty, nil
}

// Wait waits for the container to exit.
Expand Down
11 changes: 11 additions & 0 deletions pkg/shim/v1/runsc/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,23 @@
package runsc

import (
"errors"
"testing"

"github.com/containerd/containerd/runtime/v2/task"
"github.com/containerd/errdefs"
specs "github.com/opencontainers/runtime-spec/specs-go"
"gvisor.dev/gvisor/pkg/shim/v1/utils"
)

func TestContainerUpdateNilResources(t *testing.T) {
c := &Container{}
err := c.Update(t.Context(), &task.UpdateTaskRequest{ID: "x", Resources: nil})
if !errors.Is(err, errdefs.ErrInvalidArgument) {
t.Fatalf("Update(nil Resources): %v, want ErrInvalidArgument", err)
}
}

func TestCgroupPath(t *testing.T) {
for _, tc := range []struct {
name string
Expand Down
9 changes: 8 additions & 1 deletion pkg/shim/v1/runsccmd/BUILD
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
load("//tools:defs.bzl", "go_library")
load("//tools:defs.bzl", "go_library", "go_test")

package(
default_applicable_licenses = ["//:license"],
Expand All @@ -19,3 +19,10 @@ go_library(
"@org_golang_x_sys//unix:go_default_library",
],
)

go_test(
name = "runsccmd_test",
size = "small",
srcs = ["runsc_test.go"],
library = ":runsccmd",
)
15 changes: 15 additions & 0 deletions pkg/shim/v1/runsccmd/runsc.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,21 @@ func (r *Runsc) Resume(context context.Context, id string) error {
return nil
}

// Update the current container with the provided resource spec
// The "-" path reads resources JSON from stdin.
func (r *Runsc) Update(ctx context.Context, id string, resources *specs.LinuxResources) error {
buf := getBuf()
defer putBuf(buf)

if err := json.NewEncoder(buf).Encode(resources); err != nil {
return err
}
args := []string{"update", "--resources=-", id}
cmd := r.command(ctx, args...)
cmd.Stdin = buf
return r.runOrError(cmd)
}

// Start will start an already created container.
func (r *Runsc) Start(context context.Context, id string, cio runc.IO) error {
return r.start(context, cio, r.command(context, "start", id))
Expand Down
87 changes: 87 additions & 0 deletions pkg/shim/v1/runsccmd/runsc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright 2026 The gVisor 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
//
// https://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.

//go:build linux

package runsccmd

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"

specs "github.com/opencontainers/runtime-spec/specs-go"
)

// TestRunscUpdateCLI verifies Update runs the OCI update flow: JSON resources on
// stdin and "update --resources - <id>" in argv (same contract as go-runc).
func TestRunscUpdateCLI(t *testing.T) {
ctx := t.Context()
dir := t.TempDir()
argsLog := filepath.Join(dir, "args.txt")
stdinLog := filepath.Join(dir, "stdin.txt")
script := filepath.Join(dir, "fake-runsc")
scriptBody := fmt.Sprintf(`#!/bin/sh
printf '%%s\n' "$*" >%q
cat >%q
exit 0
`, argsLog, stdinLog)
if err := os.WriteFile(script, []byte(scriptBody), 0o755); err != nil {
t.Fatal(err)
}

limit := int64(4096)
wantRes := &specs.LinuxResources{
Memory: &specs.LinuxMemory{Limit: &limit},
}

r := &Runsc{
Command: script,
Root: filepath.Join(dir, "root"),
}
if err := r.Update(ctx, "cid-1", wantRes); err != nil {
t.Fatalf("Update: %v", err)
}

rawArgs, err := os.ReadFile(argsLog)
if err != nil {
t.Fatal(err)
}
args := strings.Fields(string(rawArgs))
var joined strings.Builder
for _, a := range args {
joined.WriteString(a)
joined.WriteByte(' ')
}
s := joined.String()
// go-runc may pass "--resources -"; we use "--resources=-" (same stdin contract).
if !strings.Contains(s, "update ") || !strings.Contains(s, "--resources") || !strings.Contains(s, "cid-1") {
t.Fatalf("argv missing expected update flags: %q", string(rawArgs))
}

rawStdin, err := os.ReadFile(stdinLog)
if err != nil {
t.Fatal(err)
}
var got specs.LinuxResources
if err := json.Unmarshal(rawStdin, &got); err != nil {
t.Fatalf("stdin JSON: %v", err)
}
if got.Memory == nil || got.Memory.Limit == nil || *got.Memory.Limit != limit {
t.Fatalf("stdin resources: got %+v, want memory limit %d", got.Memory, limit)
}
}
23 changes: 22 additions & 1 deletion pkg/test/criutil/criutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ func (cc *Crictl) Create(podID, contSpecFile, sbSpecFile string) (string, error)
if err != nil {
return "", err
}
r := regexp.MustCompile(`crictl version ([0-9]+)\.([0-9]+)\.([0-9+])`)
// Newer crictl prints "version v1.2.3"; older prints "version 1.2.3".
r := regexp.MustCompile(`crictl version v?([0-9]+)\.([0-9]+)\.([0-9]+)`)
vs := r.FindStringSubmatch(out)
if len(vs) != 4 {
return "", fmt.Errorf("crictl -v had unexpected output: %s", out)
Expand Down Expand Up @@ -151,6 +152,26 @@ func (cc *Crictl) Start(contID string) (string, error) {
return output, nil
}

// UpdateContainerResources runs `crictl update` to set the Linux memory limit
// (CRI UpdateContainerResources). memoryLimitBytes must be positive.
func (cc *Crictl) UpdateContainerResources(contID string, memoryLimitBytes int64) error {
if memoryLimitBytes <= 0 {
return fmt.Errorf("memory limit must be positive")
}
_, err := cc.run("update", "--memory", strconv.FormatInt(memoryLimitBytes, 10), contID)
return err
}

// InspectGoTemplate runs `crictl inspect` with a Go template and returns the
// trimmed output.
func (cc *Crictl) InspectGoTemplate(contID, tmpl string) (string, error) {
out, err := cc.run("inspect", "-o", "go-template", "--template", tmpl, contID)
if err != nil {
return "", err
}
return strings.TrimSpace(out), nil
}

// Stop stops a container. It corresponds to `crictl stop`.
func (cc *Crictl) Stop(contID string) error {
_, err := cc.run("stop", contID)
Expand Down
67 changes: 67 additions & 0 deletions test/root/crictl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,73 @@ func TestHomeDir(t *testing.T) {
})
}

// criInspectMemoryLimitBytes reads the Linux memory limit from `crictl inspect`
// via go-template (same data as RuntimeService.ContainerStatus over gRPC).
func criInspectMemoryLimitBytes(cc *criutil.Crictl, contID string) (int64, error) {
const tmpl = `{{.status.resources.linux.memoryLimitInBytes}}`
out, err := cc.InspectGoTemplate(contID, tmpl)
if err != nil {
return 0, fmt.Errorf("inspect: %w", err)
}
n, err := strconv.ParseInt(out, 10, 64)
if err != nil {
return 0, fmt.Errorf("parse memory limit %q: %w", out, err)
}
return n, nil
}

// TestCrictlUpdateContainerResources covers the kubelet in-place pod resize path:
// CRI UpdateContainerResources targets the pod workload container (not the pause
// sandbox). runsc updates host compat cgroups and the sentry cgroupfs for that
// container id. CRI-reported memory limit must match via inspect JSON.
func TestCrictlUpdateContainerResources(t *testing.T) {
crictl, cleanup, err := setup(t, true /* enableGrouping */)
if err != nil {
t.Fatalf("failed to setup crictl: %v", err)
}
defer cleanup()

const initMem = int64(64 * 1024 * 1024)
const updatedMem = int64(96 * 1024 * 1024)

spec := SimpleSpec("resize", "basic/busybox", []string{"sleep", "1000"}, map[string]any{
"linux": map[string]any{
"resources": map[string]any{
"memory_limit_in_bytes": initMem,
},
},
})
podID, contID, err := crictl.StartPodAndContainer(containerdRuntime, "basic/busybox", Sandbox(testutil.RandomID("update-res")), spec)
if err != nil {
t.Fatalf("start failed: %v", err)
}
defer func() {
if err := crictl.StopPodAndContainer(podID, contID); err != nil {
t.Logf("cleanup stop: %v", err)
}
}()

before, err := criInspectMemoryLimitBytes(crictl, contID)
if err != nil {
t.Fatalf("inspect before update: %v", err)
}
if before != initMem {
t.Fatalf("memory limit before update: got %d want %d", before, initMem)
}

if err := crictl.UpdateContainerResources(contID, updatedMem); err != nil {
t.Fatalf("UpdateContainerResources: %v", err)
}

after, err := criInspectMemoryLimitBytes(crictl, contID)
if err != nil {
t.Fatalf("inspect after update: %v", err)
}
if after != updatedMem {
t.Fatalf("memory limit after update: got %d want %d", after, updatedMem)
}
}

const containerdRuntime = "runsc"

// containerdConfig is the containerd (1.5+) configuration file that
Expand Down
Loading