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
14 changes: 14 additions & 0 deletions cmd/atelet/imagevolume_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,25 @@ func singleFileLayer(t *testing.T, path, body string) v1.Layer {
}

func pushTestImage(t *testing.T, ref string, layers ...v1.Layer) {
t.Helper()
pushTestImageWithConfig(t, ref, v1.Config{}, layers...)
}

func pushTestImageWithConfig(t *testing.T, ref string, cfg v1.Config, layers ...v1.Layer) {
t.Helper()
img, err := mutate.AppendLayers(empty.Image, layers...)
if err != nil {
t.Fatalf("mutate.AppendLayers: %v", err)
}
cf, err := img.ConfigFile()
if err != nil {
t.Fatalf("img.ConfigFile: %v", err)
}
cf = cf.DeepCopy()
cf.Config = cfg
if img, err = mutate.ConfigFile(img, cf); err != nil {
t.Fatalf("mutate.ConfigFile: %v", err)
}
tag, err := name.ParseReference(ref, name.Insecure)
if err != nil {
t.Fatalf("name.ParseReference(%q): %v", ref, err)
Expand Down
2 changes: 2 additions & 0 deletions cmd/atelet/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1571,6 +1571,7 @@ func (s *AteomHerder) prepareOCIBundles(
if err := prepareOCIDirectory(
gCtx,
s.imageCache,
ateletpath.OCIBundlePath(actorUID, ocispec.PauseContainer),
actorUID,
ocispec.PauseContainer,
pauseImage,
Expand Down Expand Up @@ -1599,6 +1600,7 @@ func (s *AteomHerder) prepareOCIBundles(
if err := prepareOCIDirectory(
gCtx,
s.imageCache,
ateletpath.OCIBundlePath(actorUID, ctr.GetName()),
actorUID,
ctr.GetName(),
ctr.GetImage(),
Expand Down
96 changes: 88 additions & 8 deletions cmd/atelet/oci.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@
package main

import (
"bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"sort"
Expand All @@ -27,6 +31,8 @@ import (
"github.com/agent-substrate/substrate/internal/ocispec"
"github.com/agent-substrate/substrate/internal/proto/ateletpb"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/moby/sys/user"
"github.com/opencontainers/runtime-spec/specs-go"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"golang.org/x/sync/errgroup"
Expand Down Expand Up @@ -75,15 +81,16 @@ func resolveCapabilities(caps *ateletpb.Capabilities) []string {
return out
}

func prepareOCIDirectory(ctx context.Context, imageCache *imagecache.Store, actorUID, containerName, ref string, command, args []string, env []string, netns string, volumes []*ateletpb.Volume, volumeMounts []*ateletpb.VolumeMount, capabilities []string, resources *ateletpb.ResourceLimits) error {
// prepareOCIDirectory assembles the OCI bundle at bundlePath for one of the
// actor's containers: it pulls the image, writes the overlay spec ateom
// composes the rootfs from, and writes the runtime-neutral OCI spec.
func prepareOCIDirectory(ctx context.Context, imageCache *imagecache.Store, bundlePath, actorUID, containerName, ref string, command, args []string, env []string, netns string, volumes []*ateletpb.Volume, volumeMounts []*ateletpb.VolumeMount, capabilities []string, resources *ateletpb.ResourceLimits) error {
tracer := otel.Tracer("prepareOCIDirectory")

ctx, span := tracer.Start(ctx, "prepareOCIDirectory")
span.SetAttributes(attribute.String("image", ref))
defer span.End()

bundlePath := ateletpath.OCIBundlePath(actorUID, containerName)

// Clear any previous bundle contents (belt and suspenders: resetActorDirs
// already wiped the bundle dir on the Run/Restore path).
if err := imagecache.RemoveAllWritable(bundlePath); err != nil {
Expand Down Expand Up @@ -123,21 +130,32 @@ func prepareOCIDirectory(ctx context.Context, imageCache *imagecache.Store, acto
return err
}

// Argv and env need only the image config; resolve them before writing
// any spec so an invalid container config fails fast.
// Resolve argv, env and user before writing any spec so an invalid
// container config fails fast.
resolvedArgs, err := resolveProcessArgs(&img.Config, command, args)
if err != nil {
return fmt.Errorf("while resolving process args for container %q: %w", containerName, err)
}
resolvedEnv := resolveActorEnv(&img.Config, env)
identity, err := resolveImageUser(img)
if err != nil {
return fmt.Errorf("while resolving user for container %q: %w", containerName, err)
}
cwd, err := resolveCwd(&img.Config)
if err != nil {
return fmt.Errorf("while resolving working directory for container %q: %w", containerName, err)
}

// Every bind target must exist in the rootfs for the mount to attach;
// ateom creates them through the mounted overlay (they land in the
// actor's upper).
// Every bind target must exist in the rootfs for the mount to attach, and
// the working directory for the process to start; ateom creates them
// through the mounted overlay (they land in the actor's upper).
var extraDirs []string
for _, vm := range volumeMounts {
extraDirs = append(extraDirs, vm.GetMountPath())
}
if cwd != "/" {
extraDirs = append(extraDirs, cwd)
}
if err := imagecache.WriteSpec(bundlePath, &imagecache.OverlaySpec{
ImageDigest: img.Digest.String(),
Layers: img.LayerDirs,
Expand All @@ -160,6 +178,8 @@ func prepareOCIDirectory(ctx context.Context, imageCache *imagecache.Store, acto
VolumesDir: ateletpath.VolumesDir(actorUID),
SystemInfoVolumeRootsDir: ateletpath.SystemInfoVolumeRootsDir(actorUID),
BundlePath: bundlePath,
User: identity,
Cwd: cwd,
})); err != nil {
return fmt.Errorf("while writing OCI spec: %w", err)
}
Expand Down Expand Up @@ -265,3 +285,63 @@ func resolveProcessArgs(imageCfg *v1.Config, command, args []string) ([]string,
}
return argv, nil
}

// resolveImageUser resolves the image's USER against the image's own
// /etc/passwd and /etc/group, either of which may be absent. No USER is root.
func resolveImageUser(img *imagecache.Image) (specs.User, error) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this get one live run on each class before merge, with a common image that sets both, for example a node-based image with USER 1000 and WORKDIR /app? Including a durable dir and a suspend/resume would cover the case most existing templates will hit, which the unit tests can't show.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do later today! node image with USER 1000 + WORKDIR /app, durable dir, suspend/resume, both classes, on a branch stacked on #1906.

if img.Config.User == "" {
return specs.User{}, nil
}
passwd, err := readOptionalImageFile(img, "etc/passwd")
if err != nil {
return specs.User{}, err
}
group, err := readOptionalImageFile(img, "etc/group")
if err != nil {
return specs.User{}, err
}
return resolveUser(img.Config.User, passwd, group)
}

// readOptionalImageFile returns a nil reader for a file the image lacks.
func readOptionalImageFile(img *imagecache.Image, name string) (io.Reader, error) {
b, err := img.ReadFile(name)
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
return bytes.NewReader(b), nil
}

// resolveUser follows Docker's rules for USER "user[:group]": numeric ids as
// is, a bare uid with its login group from passwd (gid 0 without an entry),
// names looked up or an error, group memberships as supplementary groups.
// Ids are bounded to int32. A nil reader means the image lacks the file.
func resolveUser(userSpec string, passwd, group io.Reader) (specs.User, error) {
if strings.Count(userSpec, ":") > 1 {
return specs.User{}, fmt.Errorf("image User %q: want user[:group]", userSpec)
}
u, err := user.GetExecUser(userSpec, nil, passwd, group)
if err != nil {
return specs.User{}, fmt.Errorf("image User %q: %w", userSpec, err)
}
identity := specs.User{UID: uint32(u.Uid), GID: uint32(u.Gid)}
for _, gid := range u.Sgids {
identity.AdditionalGids = append(identity.AdditionalGids, uint32(gid))
}
return identity, nil
}

// resolveCwd reads the image's WORKDIR; none means "/". The OCI spec requires
// an absolute path.
func resolveCwd(imageCfg *v1.Config) (string, error) {
if imageCfg == nil || imageCfg.WorkingDir == "" {
return "/", nil
}
if !path.IsAbs(imageCfg.WorkingDir) {
return "", fmt.Errorf("image WorkingDir %q is not absolute", imageCfg.WorkingDir)
}
return path.Clean(imageCfg.WorkingDir), nil
}
131 changes: 131 additions & 0 deletions cmd/atelet/oci_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,18 @@
package main

import (
"fmt"
"io"
"reflect"
"slices"
"strings"
"testing"

"github.com/agent-substrate/substrate/internal/imagecache"
"github.com/agent-substrate/substrate/internal/ocispec"
"github.com/agent-substrate/substrate/internal/proto/ateletpb"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/opencontainers/runtime-spec/specs-go"
)

func TestResolveActorEnv(t *testing.T) {
Expand Down Expand Up @@ -153,6 +160,130 @@ func TestResolveProcessArgs(t *testing.T) {
}
}

func TestResolveUser(t *testing.T) {
Comment thread
dims marked this conversation as resolved.
const passwd = "root:x:0:0:root:/root:/bin/sh\n" +
"jovyan:x:1000:100::/home/jovyan:/bin/bash\n" +
"nonroot:x:65532:65532::/home/nonroot:/sbin/nologin\n"
const group = "root:x:0:\n" +
"users:x:100:\n" +
"nonroot:x:65532:\n" +
"video:x:44:nonroot,jovyan\n" +
"audio:x:29:nonroot\n"
tests := []struct {
name string
user string
passwd, group string // empty: the image lacks the file
want specs.User
wantErr bool
}{
{name: "bare uid, no passwd", user: "1000", want: specs.User{UID: 1000}},
{name: "bare uid takes its login group and memberships", user: "1000", passwd: passwd, group: group, want: specs.User{UID: 1000, GID: 100, AdditionalGids: []uint32{44}}},
{name: "bare uid without a passwd entry", user: "1001", passwd: passwd, group: group, want: specs.User{UID: 1001}},
{name: "uid:gid passes through", user: "1000:2000", passwd: passwd, group: group, want: specs.User{UID: 1000, GID: 2000}},
{name: "named user", user: "nonroot", passwd: passwd, group: group, want: specs.User{UID: 65532, GID: 65532, AdditionalGids: []uint32{44, 29}}},
{name: "named user and group", user: "jovyan:video", passwd: passwd, group: group, want: specs.User{UID: 1000, GID: 44}},
{name: "named user missing from passwd", user: "nobody", passwd: passwd, wantErr: true},
{name: "named group missing from group", user: "1000:staff", passwd: passwd, group: group, wantErr: true},
{name: "above int32", user: "2147483648", wantErr: true},
{name: "third field", user: "1000:1000:1", wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var passwdR, groupR io.Reader
if tc.passwd != "" {
passwdR = strings.NewReader(tc.passwd)
}
if tc.group != "" {
groupR = strings.NewReader(tc.group)
}
got, err := resolveUser(tc.user, passwdR, groupR)
if (err != nil) != tc.wantErr {
t.Fatalf("resolveUser(%q) = %+v, %v; wantErr %v", tc.user, got, err, tc.wantErr)
}
if err == nil && !reflect.DeepEqual(got, tc.want) {
t.Errorf("resolveUser(%q) = %+v, want %+v", tc.user, got, tc.want)
}
})
}
}

func TestResolveCwd(t *testing.T) {
for _, tc := range []struct {
image *v1.Config
want string
wantErr bool
}{
{image: nil, want: "/"},
{image: &v1.Config{}, want: "/"},
{image: &v1.Config{WorkingDir: "/home/jovyan"}, want: "/home/jovyan"},
{image: &v1.Config{WorkingDir: "/app/"}, want: "/app"},
{image: &v1.Config{WorkingDir: "app"}, wantErr: true},
} {
got, err := resolveCwd(tc.image)
if (err != nil) != tc.wantErr || got != tc.want {
t.Errorf("resolveCwd(%+v) = %q, %v; want %q, wantErr %v", tc.image, got, err, tc.want, tc.wantErr)
}
}
}

// User and cwd reach the bundle's config.json, for the pause container too;
// the cwd is created through ExtraDirs.
func TestPrepareOCIDirectory(t *testing.T) {
host := imageVolumeTestRegistry(t)
etc := []v1.Layer{
singleFileLayer(t, "etc/passwd", "root:x:0:0:root:/root:/bin/sh\nnonroot:x:65532:65532::/home/nonroot:/sbin/nologin\n"),
singleFileLayer(t, "etc/group", "root:x:0:\nnonroot:x:65532:\nvideo:x:44:nonroot\n"),
}
bin := []v1.Layer{singleFileLayer(t, "bin/app", "x")}
tests := []struct {
name string
container string
user string
layers []v1.Layer
want specs.User
wantErr bool
}{
{name: "no USER is root", container: "app", layers: etc},
{name: "bare uid resolves against the image's files", container: "app", user: "65532", layers: etc, want: specs.User{UID: 65532, GID: 65532, AdditionalGids: []uint32{44}}},
{name: "named user without passwd fails", container: "app", user: "nonroot", layers: bin, wantErr: true},
{name: "pause container", container: ocispec.PauseContainer, user: "65532", layers: etc, want: specs.User{UID: 65532, GID: 65532, AdditionalGids: []uint32{44}}},
}
for i, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ref := fmt.Sprintf("%s/user%d:v1", host, i)
pushTestImageWithConfig(t, ref, v1.Config{User: tc.user, Cmd: []string{"/app"}, WorkingDir: "/home/nonroot"}, tc.layers...)
bundle := t.TempDir()
err := prepareOCIDirectory(t.Context(), newImageVolumeStore(t), bundle, "actor-uid", tc.container, ref, nil, nil, nil, "", nil, nil, nil, nil)
if tc.wantErr {
if err == nil {
t.Fatal("prepareOCIDirectory succeeded, want an error")
}
return
}
if err != nil {
t.Fatalf("prepareOCIDirectory: %v", err)
}
spec, err := ocispec.Load(bundle)
if err != nil {
t.Fatalf("ocispec.Load: %v", err)
}
if !reflect.DeepEqual(spec.Process.User, tc.want) {
t.Errorf("Process.User = %+v, want %+v", spec.Process.User, tc.want)
}
if spec.Process.Cwd != "/home/nonroot" {
t.Errorf("Process.Cwd = %q, want /home/nonroot", spec.Process.Cwd)
}
overlay, err := imagecache.ReadSpec(bundle)
if err != nil {
t.Fatalf("imagecache.ReadSpec: %v", err)
}
if !slices.Contains(overlay.ExtraDirs, "/home/nonroot") {
t.Errorf("ExtraDirs = %v, want the working directory", overlay.ExtraDirs)
}
})
}
}

// wantDefaultCapabilities is the set a container gets when it asks for no
// adjustment. It is spelled out rather than derived from defaultCapabilities so
// that widening or narrowing the default is a deliberate test change.
Expand Down
20 changes: 20 additions & 0 deletions cmd/ateom-microvm/internal/kata/specconv_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package kata

import (
"slices"
"testing"

specs "github.com/opencontainers/runtime-spec/specs-go"
Expand Down Expand Up @@ -162,3 +163,22 @@ func TestSpecToAgentPB_NonPositiveCPUValuesAreDropped(t *testing.T) {
})
}
}

// The agent starts the process as the user it is sent; a dropped identity
// would run the workload as root in the guest.
func TestSpecToAgentPB_ForwardsProcessUser(t *testing.T) {
got := SpecToAgentPB(&specs.Spec{
Process: &specs.Process{User: specs.User{UID: 65532, GID: 65534, AdditionalGids: []uint32{44}}},
})
u := got.GetProcess().GetUser()
if u == nil || u.UID != 65532 || u.GID != 65534 || !slices.Equal(u.AdditionalGids, []uint32{44}) {
t.Fatalf("Process.User = %v, want 65532:65534 +44", u)
}
}

func TestSpecToAgentPB_ForwardsCwd(t *testing.T) {
got := SpecToAgentPB(&specs.Spec{Process: &specs.Process{Cwd: "/work"}})
if got.GetProcess().GetCwd() != "/work" {
t.Fatalf("Process.Cwd = %q, want /work", got.GetProcess().GetCwd())
}
}
4 changes: 4 additions & 0 deletions docs/api-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@ Each entry in `containers` describes one process to run in the actor's sandbox.

`command` and `args` resolve against the container image's `ENTRYPOINT`/`CMD` the same way [Kubernetes Pod `command`/`args`](https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/) resolve against `ENTRYPOINT`/`CMD`. If the resolved argv is empty — the image sets neither `ENTRYPOINT` nor `CMD`, and the container sets neither `command` nor `args` — `Run`/`Restore` fails.

The process runs as the image's `USER`, resolved as Docker does against the image's own `/etc/passwd` and `/etc/group`: a numeric `uid:gid` as is; a bare uid with its login group, or gid 0 when it has no entry; a name must have an entry or `Run`/`Restore` fails. Group memberships become supplementary groups. Ids are at most 2147483647. No `USER` means root. There is no `runAsUser` field.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could the docs also say what operators need to do after upgrading? On gVisor, golden snapshots of templates whose image sets USER or WORKDIR have to be re-taken, and data an earlier run wrote as root stays root-owned, so a process that's now non-root can read it but not change it. A short paragraph here or in the release notes would save people from debugging a failed restore.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added to docs/upgrade.md, step 4. Wider than this: the pause image sets USER 65535 and runsc checks every container, so every gVisor snapshot from before this release breaks, not just USER/WORKDIR templates. Micro-VM snapshots resume as they were.


The process starts in the image's `WORKDIR`, or in `/` when the image sets none. A `WORKDIR` the image lacks is created in the actor's writable layer. There is no `workingDir` field.

### Container Capabilities (`securityContext.capabilities`)

Each container runs with a default set of Linux capabilities — `AUDIT_WRITE`, `KILL` and `NET_BIND_SERVICE`. `securityContext.capabilities` adjusts that set, mirroring `securityContext.capabilities` on a Kubernetes Pod container.
Expand Down
Loading
Loading