diff --git a/BUILD b/BUILD index 809fee2fe1e..d469594ad1f 100644 --- a/BUILD +++ b/BUILD @@ -230,6 +230,7 @@ go_path( "//tools/checklocks", # Packages that are not dependencies of the above. + "//runsc/client", "//pkg/sentry/kernel/memevent", "//pkg/sentry/socket/plugin/stack", "//pkg/tcpip/adapters/gonet", diff --git a/runsc/client/BUILD b/runsc/client/BUILD new file mode 100644 index 00000000000..5443263f08c --- /dev/null +++ b/runsc/client/BUILD @@ -0,0 +1,32 @@ +load("//tools:defs.bzl", "go_library", "go_test") + +package( + default_applicable_licenses = ["//:license"], + licenses = ["notice"], +) + +go_library( + name = "client", + srcs = [ + "client.go", + ], + visibility = ["//visibility:public"], + deps = [ + "//runsc/config", + "//runsc/container", + "@com_github_opencontainers_runtime_spec//specs-go:go_default_library", + ], +) + +go_test( + name = "client_test", + size = "small", + srcs = [ + "client_test.go", + ], + embed = [":client"], + deps = [ + "//runsc/config", + "//runsc/container", + ], +) diff --git a/runsc/client/client.go b/runsc/client/client.go new file mode 100644 index 00000000000..1a08ec36425 --- /dev/null +++ b/runsc/client/client.go @@ -0,0 +1,123 @@ +// 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 +// +// 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 client provides programmatic access to gVisor sandboxes and +// containers without spawning runsc subprocesses. +package client + +import ( + "context" + "fmt" + + specs "github.com/opencontainers/runtime-spec/specs-go" + "gvisor.dev/gvisor/runsc/config" + "gvisor.dev/gvisor/runsc/container" +) + +// Client provides programmatic access to containers managed in a runsc root directory. +type Client struct { + conf *config.Config +} + +// New creates a new Client configured with the specified Config. +// If conf is nil or has an empty RootDir, config.DefaultRootDir() is used. +func New(conf *config.Config) *Client { + if conf == nil { + conf = &config.Config{} + } + if conf.RootDir == "" { + conf.RootDir = config.DefaultRootDir() + } + return &Client{conf: conf} +} + +// NewFromRootDir creates a new Client configured for the specified root directory. +// If rootDir is empty, config.DefaultRootDir() is used. +func NewFromRootDir(rootDir string) *Client { + return New(&config.Config{RootDir: rootDir}) +} + +// RootDir returns the root directory associated with this client. +func (c *Client) RootDir() string { + return c.conf.RootDir +} + +// Config returns the configuration associated with this client. +func (c *Client) Config() *config.Config { + return c.conf +} + +// Load loads a container by ID from the root directory. +func (c *Client) Load(ctx context.Context, id string, opts container.LoadOpts) (*container.Container, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return container.Load(c.conf.RootDir, container.FullID{ContainerID: id}, opts) +} + +// List returns the full IDs of all containers in the root directory. +func (c *Client) List(ctx context.Context) ([]container.FullID, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return container.List(c.conf.RootDir) +} + +// State returns the OCI state metadata for the container with the given ID. +func (c *Client) State(ctx context.Context, id string) (specs.State, error) { + if err := ctx.Err(); err != nil { + return specs.State{}, err + } + cont, err := c.Load(ctx, id, container.LoadOpts{ReadOnly: true}) + if err != nil { + return specs.State{}, fmt.Errorf("loading container %q: %w", id, err) + } + return cont.State(), nil +} + +// Spec returns the OCI specification for the container with the given ID. +func (c *Client) Spec(ctx context.Context, id string) (*specs.Spec, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + cont, err := c.Load(ctx, id, container.LoadOpts{ReadOnly: true}) + if err != nil { + return nil, fmt.Errorf("loading container %q: %w", id, err) + } + return cont.Spec, nil +} + +// Pause pauses the container with the given ID. +func (c *Client) Pause(ctx context.Context, id string) error { + if err := ctx.Err(); err != nil { + return err + } + cont, err := c.Load(ctx, id, container.LoadOpts{}) + if err != nil { + return fmt.Errorf("loading container %q: %w", id, err) + } + return cont.Pause() +} + +// Resume resumes the container with the given ID. +func (c *Client) Resume(ctx context.Context, id string) error { + if err := ctx.Err(); err != nil { + return err + } + cont, err := c.Load(ctx, id, container.LoadOpts{}) + if err != nil { + return fmt.Errorf("loading container %q: %w", id, err) + } + return cont.Resume() +} diff --git a/runsc/client/client_test.go b/runsc/client/client_test.go new file mode 100644 index 00000000000..620af4c39ad --- /dev/null +++ b/runsc/client/client_test.go @@ -0,0 +1,137 @@ +// 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 +// +// 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 client + +import ( + "context" + "os" + "testing" + + "gvisor.dev/gvisor/runsc/config" + "gvisor.dev/gvisor/runsc/container" +) + +func TestClientNew(t *testing.T) { + // Test New with nil config + cNil := New(nil) + if got, want := cNil.RootDir(), config.DefaultRootDir(); got != want { + t.Errorf("New(nil).RootDir() = %q, want %q", got, want) + } + + // Test New with empty RootDir + cEmpty := New(&config.Config{}) + if got, want := cEmpty.RootDir(), config.DefaultRootDir(); got != want { + t.Errorf("New(&Config{}).RootDir() = %q, want %q", got, want) + } + + // Test New with config + conf := &config.Config{ + RootDir: "/tmp/runsc-test", + Debug: true, + } + c := New(conf) + if got := c.RootDir(); got != "/tmp/runsc-test" { + t.Errorf("RootDir() = %q, want %q", got, "/tmp/runsc-test") + } + if got := c.Config(); got != conf { + t.Errorf("Config() = %v, want %v", got, conf) + } + if !c.Config().Debug { + t.Errorf("Config().Debug = false, want true") + } + + // Test NewFromRootDir + cRoot := NewFromRootDir("/tmp/custom-root") + if got := cRoot.RootDir(); got != "/tmp/custom-root" { + t.Errorf("NewFromRootDir().RootDir() = %q, want %q", got, "/tmp/custom-root") + } +} + +func TestClientLoadNonExistent(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "runsc-client-test-") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer os.RemoveAll(tmpDir) + + c := NewFromRootDir(tmpDir) + _, err = c.Load(context.Background(), "nonexistent-container-id", container.LoadOpts{}) + if err == nil { + t.Fatalf("Load() expected error for nonexistent container, got nil") + } +} + +func TestClientPauseResumeStateNonExistent(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "runsc-client-test-") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer os.RemoveAll(tmpDir) + + c := NewFromRootDir(tmpDir) + ctx := context.Background() + + if err := c.Pause(ctx, "nonexistent"); err == nil { + t.Errorf("Pause() expected error for nonexistent container, got nil") + } + if err := c.Resume(ctx, "nonexistent"); err == nil { + t.Errorf("Resume() expected error for nonexistent container, got nil") + } + if _, err := c.State(ctx, "nonexistent"); err == nil { + t.Errorf("State() expected error for nonexistent container, got nil") + } + if _, err := c.Spec(ctx, "nonexistent"); err == nil { + t.Errorf("Spec() expected error for nonexistent container, got nil") + } +} + +func TestClientListEmpty(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "runsc-client-test-") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer os.RemoveAll(tmpDir) + + c := NewFromRootDir(tmpDir) + list, err := c.List(context.Background()) + if err != nil { + t.Fatalf("List() unexpected error: %v", err) + } + if len(list) != 0 { + t.Errorf("List() returned %d items, want 0", len(list)) + } +} + +func TestClientContextCancellation(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "runsc-client-test-") + if err != nil { + t.Fatalf("MkdirTemp failed: %v", err) + } + defer os.RemoveAll(tmpDir) + + c := NewFromRootDir(tmpDir) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err = c.Load(ctx, "any-id", container.LoadOpts{}) + if err != context.Canceled { + t.Errorf("Load() with canceled context: got %v, want %v", err, context.Canceled) + } + + _, err = c.List(ctx) + if err != context.Canceled { + t.Errorf("List() with canceled context: got %v, want %v", err, context.Canceled) + } +} diff --git a/runsc/cmd/BUILD b/runsc/cmd/BUILD index 534dfa626d2..bdecb9b5bea 100644 --- a/runsc/cmd/BUILD +++ b/runsc/cmd/BUILD @@ -69,6 +69,7 @@ go_library( "//pkg/unet", "//pkg/urpc", "//runsc/boot/sentryapi", + "//runsc/client", "//runsc/cmd/metricserver/metricservercmd", "//runsc/cmd/nvproxy", "//runsc/cmd/sandboxsetup", diff --git a/runsc/cmd/pause.go b/runsc/cmd/pause.go index f67334c7cf9..33ab97cde81 100644 --- a/runsc/cmd/pause.go +++ b/runsc/cmd/pause.go @@ -20,15 +20,14 @@ import ( "github.com/google/subcommands" specs "github.com/opencontainers/runtime-spec/specs-go" + "gvisor.dev/gvisor/runsc/client" "gvisor.dev/gvisor/runsc/cmd/util" "gvisor.dev/gvisor/runsc/config" - "gvisor.dev/gvisor/runsc/container" "gvisor.dev/gvisor/runsc/flag" ) // Pause implements subcommands.Command for the "pause" command. type Pause struct { - containerLoader } // Name implements subcommands.Command.Name. @@ -52,28 +51,27 @@ func (*Pause) SetFlags(*flag.FlagSet) { // FetchSpec implements util.SubCommand.FetchSpec. func (p *Pause) FetchSpec(conf *config.Config, f *flag.FlagSet) (string, *specs.Spec, error) { - c, err := p.loadContainer(conf, f, container.LoadOpts{}) + if f.NArg() < 1 { + f.Usage() + return "", nil, fmt.Errorf("a container-id is required") + } + id := f.Arg(0) + spec, err := client.New(conf).Spec(context.Background(), id) if err != nil { - return "", nil, fmt.Errorf("loading container: %w", err) + return "", nil, err } - return c.ID, c.Spec, nil + return id, spec, nil } // Execute implements subcommands.Command.Execute. -func (p *Pause) Execute(_ context.Context, f *flag.FlagSet, args ...any) subcommands.ExitStatus { +func (p *Pause) Execute(ctx context.Context, f *flag.FlagSet, args ...any) subcommands.ExitStatus { if f.NArg() != 1 { f.Usage() return subcommands.ExitUsageError } conf := args[0].(*config.Config) - - cont, err := p.loadContainer(conf, f, container.LoadOpts{}) - if err != nil { - util.Fatalf("loading container: %v", err) - } - - if err := cont.Pause(); err != nil { + if err := client.New(conf).Pause(ctx, f.Arg(0)); err != nil { util.Fatalf("pause failed: %v", err) } diff --git a/runsc/cmd/resume.go b/runsc/cmd/resume.go index 815cc64ba08..c063fa2687e 100644 --- a/runsc/cmd/resume.go +++ b/runsc/cmd/resume.go @@ -20,15 +20,14 @@ import ( "github.com/google/subcommands" specs "github.com/opencontainers/runtime-spec/specs-go" + "gvisor.dev/gvisor/runsc/client" "gvisor.dev/gvisor/runsc/cmd/util" "gvisor.dev/gvisor/runsc/config" - "gvisor.dev/gvisor/runsc/container" "gvisor.dev/gvisor/runsc/flag" ) // Resume implements subcommands.Command for the "resume" command. type Resume struct { - containerLoader } // Name implements subcommands.Command.Name. @@ -51,28 +50,27 @@ func (*Resume) SetFlags(*flag.FlagSet) {} // FetchSpec implements util.SubCommand.FetchSpec. func (r *Resume) FetchSpec(conf *config.Config, f *flag.FlagSet) (string, *specs.Spec, error) { - c, err := r.loadContainer(conf, f, container.LoadOpts{}) + if f.NArg() < 1 { + f.Usage() + return "", nil, fmt.Errorf("a container-id is required") + } + id := f.Arg(0) + spec, err := client.New(conf).Spec(context.Background(), id) if err != nil { - return "", nil, fmt.Errorf("loading container: %w", err) + return "", nil, err } - return c.ID, c.Spec, nil + return id, spec, nil } // Execute implements subcommands.Command.Execute. -func (r *Resume) Execute(_ context.Context, f *flag.FlagSet, args ...any) subcommands.ExitStatus { +func (r *Resume) Execute(ctx context.Context, f *flag.FlagSet, args ...any) subcommands.ExitStatus { if f.NArg() != 1 { f.Usage() return subcommands.ExitUsageError } conf := args[0].(*config.Config) - - cont, err := r.loadContainer(conf, f, container.LoadOpts{}) - if err != nil { - util.Fatalf("loading container: %v", err) - } - - if err := cont.Resume(); err != nil { + if err := client.New(conf).Resume(ctx, f.Arg(0)); err != nil { util.Fatalf("resume failed: %v", err) } diff --git a/runsc/cmd/state.go b/runsc/cmd/state.go index 99f7049b756..4595ccff435 100644 --- a/runsc/cmd/state.go +++ b/runsc/cmd/state.go @@ -23,15 +23,14 @@ import ( "github.com/google/subcommands" specs "github.com/opencontainers/runtime-spec/specs-go" "gvisor.dev/gvisor/pkg/log" + "gvisor.dev/gvisor/runsc/client" "gvisor.dev/gvisor/runsc/cmd/util" "gvisor.dev/gvisor/runsc/config" - "gvisor.dev/gvisor/runsc/container" "gvisor.dev/gvisor/runsc/flag" ) // State implements subcommands.Command for the "state" command. type State struct { - containerLoader } // Name implements subcommands.Command.Name. @@ -54,29 +53,33 @@ func (*State) SetFlags(*flag.FlagSet) {} // FetchSpec implements util.SubCommand.FetchSpec. func (s *State) FetchSpec(conf *config.Config, f *flag.FlagSet) (string, *specs.Spec, error) { - c, err := s.loadContainer(conf, f, container.LoadOpts{ReadOnly: true}) + if f.NArg() < 1 { + f.Usage() + return "", nil, fmt.Errorf("a container-id is required") + } + id := f.Arg(0) + spec, err := client.New(conf).Spec(context.Background(), id) if err != nil { - return "", nil, fmt.Errorf("loading container: %w", err) + return "", nil, err } - return c.ID, c.Spec, nil + return id, spec, nil } // Execute implements subcommands.Command.Execute. -func (s *State) Execute(_ context.Context, f *flag.FlagSet, args ...any) subcommands.ExitStatus { +func (s *State) Execute(ctx context.Context, f *flag.FlagSet, args ...any) subcommands.ExitStatus { if f.NArg() != 1 { f.Usage() return subcommands.ExitUsageError } conf := args[0].(*config.Config) - - c, err := s.loadContainer(conf, f, container.LoadOpts{ReadOnly: true}) + id := f.Arg(0) + state, err := client.New(conf).State(ctx, id) if err != nil { util.Fatalf("loading container: %v", err) } - state := c.State() - log.Debugf("Returning state for container %q: %+v", c.ID, state) + log.Debugf("Returning state for container %q: %+v", id, state) // Write json-encoded state directly to stdout. encoder := json.NewEncoder(os.Stdout)