Skip to content

Commit 1f2c27b

Browse files
fkautzahmedtd
authored andcommitted
feat(atelet): expose actor identity via /run/ate/actor-id
Gives an actor a reliable way to learn its own ID without parsing the Host header. atelet populates a per-actor identity directory (currently the single file actor-id) and bind-mounts it read-only at /run/ate into each app container; the pause container gets none. - A directory rather than a single-file mount, per the discussion on agent-substrate#178, so future late-bound data can land as sibling files without changing the mount shape. - The bundle is regenerated on every resume and external bind mounts are re-attached from the restore-time config.json, so the value stays correct per-actor through a golden-snapshot restore; an env var (or a file baked into the image) would be frozen at the golden actor's ID, since it lives in the checkpointed process memory. - actor-id is written atomically (temp file, fsync, rename, parent fsync): the directory is visible to a possibly-running actor, so a reader must never observe a torn value. - The identity dir is cleared and recreated with the other per-actor dirs in resetActorDirs. - The in-rootfs mount point is created via os.Root, so a symlink planted in the image cannot redirect the write outside the extracted rootfs. Documented for actor authors in docs/api-guide.md: read it fresh on use; raw ID, no trailing newline.
1 parent 808806d commit 1f2c27b

6 files changed

Lines changed: 304 additions & 37 deletions

File tree

cmd/atelet/main.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,17 @@ func (s *AteomHerder) prepareOCIBundles(
519519
) error {
520520
netnsPath := ateompath.AteomNetNSPath(targetAteomUid)
521521

522+
// Populate the per-actor identity directory that gets bind-mounted into
523+
// the application containers. Regenerated on every resume, so it carries
524+
// the correct per-actor ID even when restoring from the golden snapshot.
525+
identityDir := ateompath.ActorIdentityDirPath(actorTemplateNamespace, actorTemplateName, actorID)
526+
if err := os.MkdirAll(identityDir, 0o755); err != nil {
527+
return fmt.Errorf("while creating actor identity dir: %w", err)
528+
}
529+
if err := writeFileAtomic(filepath.Join(identityDir, ActorIDFileName), []byte(actorID), 0o644); err != nil {
530+
return fmt.Errorf("while writing actor identity file: %w", err)
531+
}
532+
522533
g, gCtx := errgroup.WithContext(ctx)
523534

524535
// Pause container.
@@ -536,6 +547,7 @@ func (s *AteomHerder) prepareOCIBundles(
536547
"io.kubernetes.cri.container-name": "pause",
537548
},
538549
netnsPath,
550+
"", // pause is sandbox infra; it gets no actor identity mount.
539551
); err != nil {
540552
return fmt.Errorf("while creating pause OCI bundle: %w", err)
541553
}
@@ -564,6 +576,7 @@ func (s *AteomHerder) prepareOCIBundles(
564576
"io.kubernetes.cri.container-name": ctr.GetName(),
565577
},
566578
netnsPath,
579+
identityDir,
567580
); err != nil {
568581
return fmt.Errorf("while creating %q OCI bundle: %w", ctr.GetName(), err)
569582
}
@@ -681,6 +694,45 @@ func validateActorRequest(namespace, template, actorID, targetAteomUID string, s
681694
return resources.ValidateContainerNames(names)
682695
}
683696

697+
// writeFileAtomic writes data to path by writing a temp file in the same
698+
// directory, syncing, and renaming it over the target, then syncing the
699+
// parent directory so the rename is durable. The identity directory is
700+
// bind-mounted into actors, so the file must change atomically: a reader
701+
// must never observe a truncated or partially written value.
702+
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
703+
f, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*")
704+
if err != nil {
705+
return err
706+
}
707+
defer os.Remove(f.Name()) // no-op once the rename succeeds
708+
709+
if _, err := f.Write(data); err != nil {
710+
f.Close()
711+
return err
712+
}
713+
if err := f.Chmod(perm); err != nil {
714+
f.Close()
715+
return err
716+
}
717+
if err := f.Sync(); err != nil {
718+
f.Close()
719+
return err
720+
}
721+
if err := f.Close(); err != nil {
722+
return err
723+
}
724+
if err := os.Rename(f.Name(), path); err != nil {
725+
return err
726+
}
727+
728+
dir, err := os.Open(filepath.Dir(path))
729+
if err != nil {
730+
return err
731+
}
732+
defer dir.Close()
733+
return dir.Sync()
734+
}
735+
684736
func resetActorDirs(actorTemplateNamespace, actorTemplateName, actorID string) error {
685737
// Explicitly leave runsc logs dir untouched.
686738

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

779+
// World-readable (0o755): bind-mounted into the actor, whose workload
780+
// reads it through the gofer.
781+
identityDir := ateompath.ActorIdentityDirPath(actorTemplateNamespace, actorTemplateName, actorID)
782+
if err := os.RemoveAll(identityDir); err != nil {
783+
return fmt.Errorf("while deleting actor identity dir: %w", err)
784+
}
785+
if err := os.MkdirAll(identityDir, 0o755); err != nil {
786+
return fmt.Errorf("while creating actor identity dir: %w", err)
787+
}
788+
727789
return nil
728790
}

cmd/atelet/main_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package main
1717
import (
1818
"context"
1919
"os"
20+
"path/filepath"
2021
"testing"
2122

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

29+
func TestWriteFileAtomic(t *testing.T) {
30+
dir := t.TempDir()
31+
target := filepath.Join(dir, "actor-id")
32+
33+
// One shared write over an existing value, as happens on every resume;
34+
// each subtest checks one postcondition.
35+
if err := os.WriteFile(target, []byte("golden-id"), 0o600); err != nil {
36+
t.Fatalf("seeding target: %v", err)
37+
}
38+
if err := writeFileAtomic(target, []byte("counter-1"), 0o644); err != nil {
39+
t.Fatalf("writeFileAtomic: %v", err)
40+
}
41+
42+
t.Run("replaces content", func(t *testing.T) {
43+
got, err := os.ReadFile(target)
44+
if err != nil {
45+
t.Fatalf("reading target: %v", err)
46+
}
47+
if string(got) != "counter-1" {
48+
t.Errorf("content = %q, want %q", got, "counter-1")
49+
}
50+
})
51+
52+
t.Run("sets permissions", func(t *testing.T) {
53+
info, err := os.Stat(target)
54+
if err != nil {
55+
t.Fatalf("stat target: %v", err)
56+
}
57+
if perm := info.Mode().Perm(); perm != 0o644 {
58+
t.Errorf("perm = %o, want 644", perm)
59+
}
60+
})
61+
62+
t.Run("leaves no temp files", func(t *testing.T) {
63+
// The directory is visible inside the actor.
64+
entries, err := os.ReadDir(dir)
65+
if err != nil {
66+
t.Fatalf("reading dir: %v", err)
67+
}
68+
if len(entries) != 1 {
69+
names := make([]string, 0, len(entries))
70+
for _, e := range entries {
71+
names = append(names, e.Name())
72+
}
73+
t.Errorf("leftover files in identity dir: %v", names)
74+
}
75+
})
76+
}
77+
2878
func TestValidateActorRequest(t *testing.T) {
2979
const okNS, okTmpl, okID, okUID = "ate-demo", "counter", "counter-1", "422938ba-8860-4983-a25d-d6bcb0a69d4e"
3080
okSpec := &ateletpb.WorkloadSpec{Containers: []*ateletpb.Container{{Name: "worker"}}}

cmd/atelet/oci.go

Lines changed: 99 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,24 @@ import (
3434
"go.opentelemetry.io/otel/attribute"
3535
)
3636

37-
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 {
37+
const (
38+
// IdentityMountPath is the in-actor directory at which atelet bind-mounts
39+
// the actor's identity data. Workloads read the files inside it (at
40+
// request time, not cached at startup) to learn about themselves. It is
41+
// delivered as a per-actor bind mount rather than environment variables
42+
// because env lives in the checkpointed process memory and would be
43+
// frozen at the golden snapshot's values after a restore; a bind mount is
44+
// re-attached per-actor on every resume. A directory (rather than a
45+
// single-file mount) so further identity data can be added without
46+
// changing the mount shape.
47+
IdentityMountPath = "/run/ate"
48+
49+
// ActorIDFileName is the file inside IdentityMountPath holding the
50+
// actor's own ID, raw with no trailing newline.
51+
ActorIDFileName = "actor-id"
52+
)
53+
54+
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 {
3855
tracer := otel.Tracer("prepareOCIDirectory")
3956

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

82+
// Bind-mount the per-actor identity directory so the workload can read its
83+
// own ID at IdentityMountPath/ActorIDFileName. The bind target must exist
84+
// in the rootfs for the mount to attach.
85+
if identityDir != "" {
86+
if err := createMountPoint(rootPath, IdentityMountPath); err != nil {
87+
return fmt.Errorf("while creating identity mount point: %w", err)
88+
}
89+
}
90+
91+
ociSpec := buildActorOCISpec(args, env, annotations, netns, identityDir)
92+
ociSpecBytes, err := json.MarshalIndent(ociSpec, "", " ")
93+
if err != nil {
94+
return fmt.Errorf("while marshaling OCI spec: %w", err)
95+
}
96+
specPath := path.Join(bundlePath, "config.json")
97+
if err := os.WriteFile(specPath, ociSpecBytes, 0o600); err != nil {
98+
return fmt.Errorf("while writing OCI spec: %w", err)
99+
}
100+
101+
return nil
102+
}
103+
104+
// buildActorOCISpec assembles the OCI runtime spec for an actor container.
105+
// When identityDir is non-empty it adds a read-only bind mount of that host
106+
// directory at IdentityMountPath so the actor can read its own ID (see
107+
// IdentityMountPath for why this is a bind mount rather than env vars).
108+
func buildActorOCISpec(args []string, env []string, annotations map[string]string, netns string, identityDir string) *specs.Spec {
65109
envVars := []string{
66110
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
67111
}
68112
envVars = append(envVars, env...)
69113

70-
ociSpec := &specs.Spec{
114+
mounts := []specs.Mount{
115+
{
116+
Destination: "/proc",
117+
Type: "proc",
118+
Source: "proc",
119+
},
120+
{
121+
Destination: "/dev",
122+
Type: "tmpfs",
123+
Source: "tmpfs",
124+
},
125+
{
126+
Destination: "/sys",
127+
Type: "sysfs",
128+
Source: "sysfs",
129+
Options: []string{
130+
"nosuid",
131+
"noexec",
132+
"nodev",
133+
"ro",
134+
},
135+
},
136+
{
137+
Destination: "/etc/resolv.conf",
138+
Type: "bind",
139+
Source: "/etc/resolv.conf",
140+
Options: []string{"ro"},
141+
},
142+
}
143+
if identityDir != "" {
144+
mounts = append(mounts, specs.Mount{
145+
Destination: IdentityMountPath,
146+
Type: "bind",
147+
Source: identityDir,
148+
Options: []string{"ro"},
149+
})
150+
}
151+
152+
return &specs.Spec{
71153
Process: &specs.Process{
72154
User: specs.User{
73155
UID: 0,
@@ -112,35 +194,7 @@ func prepareOCIDirectory(ctx context.Context, pullCache *memorypullcache.MemoryP
112194
Readonly: false,
113195
},
114196
Hostname: "runsc",
115-
Mounts: []specs.Mount{
116-
{
117-
Destination: "/proc",
118-
Type: "proc",
119-
Source: "proc",
120-
},
121-
{
122-
Destination: "/dev",
123-
Type: "tmpfs",
124-
Source: "tmpfs",
125-
},
126-
{
127-
Destination: "/sys",
128-
Type: "sysfs",
129-
Source: "sysfs",
130-
Options: []string{
131-
"nosuid",
132-
"noexec",
133-
"nodev",
134-
"ro",
135-
},
136-
},
137-
{
138-
Destination: "/etc/resolv.conf",
139-
Type: "bind",
140-
Source: "/etc/resolv.conf",
141-
Options: []string{"ro"},
142-
},
143-
},
197+
Mounts: mounts,
144198
Linux: &specs.Linux{
145199
Namespaces: []specs.LinuxNamespace{
146200
{
@@ -163,15 +217,23 @@ func prepareOCIDirectory(ctx context.Context, pullCache *memorypullcache.MemoryP
163217
},
164218
Annotations: annotations,
165219
}
166-
ociSpecBytes, err := json.MarshalIndent(ociSpec, "", " ")
220+
}
221+
222+
// createMountPoint creates the directory mountPath (an absolute in-rootfs
223+
// path) to serve as a bind-mount target. It uses os.Root so the operation is
224+
// confined to rootPath: a symlink planted by the image cannot redirect the
225+
// write outside the extracted rootfs (same protection untar relies on).
226+
func createMountPoint(rootPath, mountPath string) error {
227+
root, err := os.OpenRoot(rootPath)
167228
if err != nil {
168-
return fmt.Errorf("while marshaling OCI spec: %w", err)
169-
}
170-
specPath := path.Join(bundlePath, "config.json")
171-
if err := os.WriteFile(specPath, ociSpecBytes, 0o600); err != nil {
172-
return fmt.Errorf("while writing OCI spec: %w", err)
229+
return fmt.Errorf("opening rootfs %q: %w", rootPath, err)
173230
}
231+
defer root.Close()
174232

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

0 commit comments

Comments
 (0)