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
62 changes: 62 additions & 0 deletions cmd/atelet/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,17 @@ func (s *AteomHerder) prepareOCIBundles(
) error {
netnsPath := ateompath.AteomNetNSPath(targetAteomUid)

// Populate the per-actor identity directory that gets bind-mounted into
// the application containers. Regenerated on every resume, so it carries
// the correct per-actor ID even when restoring from the golden snapshot.
identityDir := ateompath.ActorIdentityDirPath(actorTemplateNamespace, actorTemplateName, actorID)
if err := os.MkdirAll(identityDir, 0o755); err != nil {
return fmt.Errorf("while creating actor identity dir: %w", err)
}
if err := writeFileAtomic(filepath.Join(identityDir, ActorIDFileName), []byte(actorID), 0o644); err != nil {
return fmt.Errorf("while writing actor identity file: %w", err)
}

g, gCtx := errgroup.WithContext(ctx)

// Pause container.
Expand All @@ -536,6 +547,7 @@ func (s *AteomHerder) prepareOCIBundles(
"io.kubernetes.cri.container-name": "pause",
},
netnsPath,
"", // pause is sandbox infra; it gets no actor identity mount.
); err != nil {
return fmt.Errorf("while creating pause OCI bundle: %w", err)
}
Expand Down Expand Up @@ -564,6 +576,7 @@ func (s *AteomHerder) prepareOCIBundles(
"io.kubernetes.cri.container-name": ctr.GetName(),
},
netnsPath,
identityDir,
); err != nil {
return fmt.Errorf("while creating %q OCI bundle: %w", ctr.GetName(), err)
}
Expand Down Expand Up @@ -681,6 +694,45 @@ func validateActorRequest(namespace, template, actorID, targetAteomUID string, s
return resources.ValidateContainerNames(names)
}

// writeFileAtomic writes data to path by writing a temp file in the same
// directory, syncing, and renaming it over the target, then syncing the
// parent directory so the rename is durable. The identity directory is
// bind-mounted into actors, so the file must change atomically: a reader
// must never observe a truncated or partially written value.
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
f, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*")
if err != nil {
return err
}
defer os.Remove(f.Name()) // no-op once the rename succeeds

if _, err := f.Write(data); err != nil {
f.Close()
return err
}
if err := f.Chmod(perm); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
if err := os.Rename(f.Name(), path); err != nil {
return err
}

dir, err := os.Open(filepath.Dir(path))
if err != nil {
return err
}
defer dir.Close()
return dir.Sync()
}

func resetActorDirs(actorTemplateNamespace, actorTemplateName, actorID string) error {
// Explicitly leave runsc logs dir untouched.

Expand Down Expand Up @@ -724,5 +776,15 @@ func resetActorDirs(actorTemplateNamespace, actorTemplateName, actorID string) e
return fmt.Errorf("while creating restore-state dir: %w", err)
}

// World-readable (0o755): bind-mounted into the actor, whose workload
// reads it through the gofer.
identityDir := ateompath.ActorIdentityDirPath(actorTemplateNamespace, actorTemplateName, actorID)
if err := os.RemoveAll(identityDir); err != nil {
return fmt.Errorf("while deleting actor identity dir: %w", err)
}
if err := os.MkdirAll(identityDir, 0o755); err != nil {
return fmt.Errorf("while creating actor identity dir: %w", err)
}

return nil
}
50 changes: 50 additions & 0 deletions cmd/atelet/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package main
import (
"context"
"os"
"path/filepath"
"testing"

"github.com/agent-substrate/substrate/internal/ateompath"
Expand All @@ -25,6 +26,55 @@ import (
"google.golang.org/grpc/status"
)

func TestWriteFileAtomic(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "actor-id")

// One shared write over an existing value, as happens on every resume;
// each subtest checks one postcondition.
if err := os.WriteFile(target, []byte("golden-id"), 0o600); err != nil {
t.Fatalf("seeding target: %v", err)
}
if err := writeFileAtomic(target, []byte("counter-1"), 0o644); err != nil {
t.Fatalf("writeFileAtomic: %v", err)
}

t.Run("replaces content", func(t *testing.T) {
got, err := os.ReadFile(target)
if err != nil {
t.Fatalf("reading target: %v", err)
}
if string(got) != "counter-1" {
t.Errorf("content = %q, want %q", got, "counter-1")
}
})

t.Run("sets permissions", func(t *testing.T) {
info, err := os.Stat(target)
if err != nil {
t.Fatalf("stat target: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o644 {
t.Errorf("perm = %o, want 644", perm)
}
})

t.Run("leaves no temp files", func(t *testing.T) {
// The directory is visible inside the actor.
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("reading dir: %v", err)
}
if len(entries) != 1 {
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, e.Name())
}
t.Errorf("leftover files in identity dir: %v", names)
}
})
}

func TestValidateActorRequest(t *testing.T) {
const okNS, okTmpl, okID, okUID = "ate-demo", "counter", "counter-1", "422938ba-8860-4983-a25d-d6bcb0a69d4e"
okSpec := &ateletpb.WorkloadSpec{Containers: []*ateletpb.Container{{Name: "worker"}}}
Expand Down
136 changes: 99 additions & 37 deletions cmd/atelet/oci.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,24 @@ import (
"go.opentelemetry.io/otel/attribute"
)

func prepareOCIDirectory(ctx context.Context, pullCache *memorypullcache.MemoryPullCache, actorTemplateNamespace, actorTemplateName, actorID, containerName, ref string, args []string, env []string, annotations map[string]string, netns string) error {
const (
// IdentityMountPath is the in-actor directory at which atelet bind-mounts
// the actor's identity data. Workloads read the files inside it (at
// request time, not cached at startup) to learn about themselves. It is
// delivered as a per-actor bind mount rather than environment variables
// because env lives in the checkpointed process memory and would be
// frozen at the golden snapshot's values after a restore; a bind mount is
// re-attached per-actor on every resume. A directory (rather than a
// single-file mount) so further identity data can be added without
// changing the mount shape.
IdentityMountPath = "/run/ate"

// ActorIDFileName is the file inside IdentityMountPath holding the
// actor's own ID, raw with no trailing newline.
ActorIDFileName = "actor-id"
)

func prepareOCIDirectory(ctx context.Context, pullCache *memorypullcache.MemoryPullCache, actorTemplateNamespace, actorTemplateName, actorID, containerName, ref string, args []string, env []string, annotations map[string]string, netns string, identityDir string) error {
tracer := otel.Tracer("prepareOCIDirectory")

ctx, span := tracer.Start(ctx, "prepareOCIDirectory")
Expand Down Expand Up @@ -62,12 +79,77 @@ func prepareOCIDirectory(ctx context.Context, pullCache *memorypullcache.MemoryP
return fmt.Errorf("in untar: %w", err)
}

// Bind-mount the per-actor identity directory so the workload can read its
// own ID at IdentityMountPath/ActorIDFileName. The bind target must exist
// in the rootfs for the mount to attach.
if identityDir != "" {
if err := createMountPoint(rootPath, IdentityMountPath); err != nil {
return fmt.Errorf("while creating identity mount point: %w", err)
}
}

ociSpec := buildActorOCISpec(args, env, annotations, netns, identityDir)
ociSpecBytes, err := json.MarshalIndent(ociSpec, "", " ")
if err != nil {
return fmt.Errorf("while marshaling OCI spec: %w", err)
}
specPath := path.Join(bundlePath, "config.json")
if err := os.WriteFile(specPath, ociSpecBytes, 0o600); err != nil {
return fmt.Errorf("while writing OCI spec: %w", err)
}

return nil
}

// buildActorOCISpec assembles the OCI runtime spec for an actor container.
// When identityDir is non-empty it adds a read-only bind mount of that host
// directory at IdentityMountPath so the actor can read its own ID (see
// IdentityMountPath for why this is a bind mount rather than env vars).
func buildActorOCISpec(args []string, env []string, annotations map[string]string, netns string, identityDir string) *specs.Spec {
envVars := []string{
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
}
envVars = append(envVars, env...)

ociSpec := &specs.Spec{
mounts := []specs.Mount{
{
Destination: "/proc",
Type: "proc",
Source: "proc",
},
{
Destination: "/dev",
Type: "tmpfs",
Source: "tmpfs",
},
{
Destination: "/sys",
Type: "sysfs",
Source: "sysfs",
Options: []string{
"nosuid",
"noexec",
"nodev",
"ro",
},
},
{
Destination: "/etc/resolv.conf",
Type: "bind",
Source: "/etc/resolv.conf",
Options: []string{"ro"},
},
}
if identityDir != "" {
mounts = append(mounts, specs.Mount{
Destination: IdentityMountPath,
Type: "bind",
Source: identityDir,
Options: []string{"ro"},
})
}

return &specs.Spec{
Process: &specs.Process{
User: specs.User{
UID: 0,
Expand Down Expand Up @@ -112,35 +194,7 @@ func prepareOCIDirectory(ctx context.Context, pullCache *memorypullcache.MemoryP
Readonly: false,
Comment thread
MushuEE marked this conversation as resolved.
},
Hostname: "runsc",
Mounts: []specs.Mount{
{
Destination: "/proc",
Type: "proc",
Source: "proc",
},
{
Destination: "/dev",
Type: "tmpfs",
Source: "tmpfs",
},
{
Destination: "/sys",
Type: "sysfs",
Source: "sysfs",
Options: []string{
"nosuid",
"noexec",
"nodev",
"ro",
},
},
{
Destination: "/etc/resolv.conf",
Type: "bind",
Source: "/etc/resolv.conf",
Options: []string{"ro"},
},
},
Mounts: mounts,
Linux: &specs.Linux{
Namespaces: []specs.LinuxNamespace{
{
Expand All @@ -163,15 +217,23 @@ func prepareOCIDirectory(ctx context.Context, pullCache *memorypullcache.MemoryP
},
Annotations: annotations,
}
ociSpecBytes, err := json.MarshalIndent(ociSpec, "", " ")
}

// createMountPoint creates the directory mountPath (an absolute in-rootfs
// path) to serve as a bind-mount target. It uses os.Root so the operation is
// confined to rootPath: a symlink planted by the image cannot redirect the
// write outside the extracted rootfs (same protection untar relies on).
func createMountPoint(rootPath, mountPath string) error {
root, err := os.OpenRoot(rootPath)
if err != nil {
return fmt.Errorf("while marshaling OCI spec: %w", err)
}
specPath := path.Join(bundlePath, "config.json")
if err := os.WriteFile(specPath, ociSpecBytes, 0o600); err != nil {
return fmt.Errorf("while writing OCI spec: %w", err)
return fmt.Errorf("opening rootfs %q: %w", rootPath, err)
}
defer root.Close()

rel := strings.TrimPrefix(mountPath, "/")
if err := root.MkdirAll(rel, 0o755); err != nil {
return fmt.Errorf("creating mount dir %q: %w", rel, err)
}
return nil
}

Expand Down
Loading
Loading