Skip to content

Commit 86cd169

Browse files
authored
fix: ensure image oci config is included when building actor oci spec (#301)
**Summary** - Fixes a bug where image oci config is not included when building the actor oci spec by ensuring that we extract the image supplied env from the config manifest and propagate it to the `buildActorOCISpec` function. **Testing** - Followed the steps listed in issue #300 and confirmed that the image env is now captured correctly in the logs and that they are correctly overridden by the actor template env for duplicate keys. Fixes #300 - [ ] Tests pass - [x] Appropriate changes to documentation are included in the PR --------- Signed-off-by: JM Huibonhoa <jm.huibonhoa@solo.io>
1 parent 992a3b3 commit 86cd169

3 files changed

Lines changed: 110 additions & 19 deletions

File tree

cmd/atelet/internal/memorypullcache/memorypullcache.go

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,15 @@ type MemoryPullCache struct {
3737

3838
localhostRegistryReplacement string
3939

40-
// Map from hexadecimal sha256 hash of image to byte contents of composed
41-
// tarball
40+
// Map from hexadecimal sha256 hash of image to the cached image (tarball + config env).
4241
cache *lru.Cache
4342
}
4443

44+
type cachedImage struct {
45+
tar []byte
46+
cfg v1.Config
47+
}
48+
4549
func NewMemoryPullCache(ctx context.Context, gcpAuthenticator authn.Authenticator, localhostRegistryReplacement string) (*MemoryPullCache, error) {
4650
c := &MemoryPullCache{
4751
// TODO: Need a smarter cache with bounds on total consumed size, not
@@ -65,7 +69,8 @@ func NewMemoryPullCache(ctx context.Context, gcpAuthenticator authn.Authenticato
6569
return c, nil
6670
}
6771

68-
func (c *MemoryPullCache) Fetch(ctx context.Context, ref string) (io.ReadCloser, error) {
72+
// Fetch returns the image's extracted filesystem tarball and its OCI image config.
73+
func (c *MemoryPullCache) Fetch(ctx context.Context, ref string) (io.ReadCloser, *v1.Config, error) {
6974
// when running in kind we need to rewrite the registry endpoint similar to the
7075
// containerd mirror config used in https://kind.sigs.k8s.io/docs/user/local-registry/
7176
// for now we have simple opt-in support to rewrite local registries
@@ -86,7 +91,7 @@ func (c *MemoryPullCache) Fetch(ctx context.Context, ref string) (io.ReadCloser,
8691

8792
parsedRef, err := name.ParseReference(ref, nameOpts...)
8893
if err != nil {
89-
return nil, fmt.Errorf("while parsing reference: %w", err)
94+
return nil, nil, fmt.Errorf("while parsing reference: %w", err)
9095
}
9196

9297
// If the image ref included a digest, check for a hit in the pull cache.
@@ -106,7 +111,8 @@ func (c *MemoryPullCache) Fetch(ctx context.Context, ref string) (io.ReadCloser,
106111
slog.String("ref", ref),
107112
slog.String("digest", requestedDigest.DigestStr()),
108113
)
109-
return io.NopCloser(bytes.NewReader(vAny.([]byte))), nil
114+
img := vAny.(*cachedImage)
115+
return io.NopCloser(bytes.NewReader(img.tar)), &img.cfg, nil
110116
}
111117
}
112118

@@ -142,36 +148,44 @@ func (c *MemoryPullCache) Fetch(ctx context.Context, ref string) (io.ReadCloser,
142148

143149
img, err := remote.Image(parsedRef, remoteOptions...)
144150
if err != nil {
145-
return nil, fmt.Errorf("in remote.Image: %w", err)
151+
return nil, nil, fmt.Errorf("in remote.Image: %w", err)
146152
}
147153

154+
var imageCfg v1.Config
155+
cfg, cfgErr := img.ConfigFile()
156+
if cfgErr != nil {
157+
return nil, nil, fmt.Errorf("while reading image config: %w", cfgErr)
158+
}
159+
imageCfg = cfg.Config
160+
148161
size, err := img.Size()
149162
if err != nil {
150-
return nil, fmt.Errorf("in img.Size(): %w", err)
163+
return nil, nil, fmt.Errorf("in img.Size(): %w", err)
151164
}
165+
152166
if size > 100*1024*1024 {
153167
slog.InfoContext(ctx,
154168
"Image is too large to cache",
155169
slog.String("ref", ref),
156170
slog.Int64("size", size),
157171
)
158-
return mutate.Extract(img), err
172+
return mutate.Extract(img), &imageCfg, err
159173
}
160174

161175
tarData := mutate.Extract(img)
162176
defer tarData.Close()
163177

164178
memData, err := io.ReadAll(tarData)
165179
if err != nil {
166-
return nil, fmt.Errorf("while reading image: %w", err)
180+
return nil, nil, fmt.Errorf("while reading image: %w", err)
167181
}
168182

169183
if digestWasIncluded {
170184
// If the user requested multi-arch image, the digest they request will
171185
// not be the same as the digest of the image we actually downloaded
172186
// from the registry. We need to place the cache entry under the digest
173187
// they requested.
174-
c.cache.Add(requestedDigest.DigestStr(), memData)
188+
c.cache.Add(requestedDigest.DigestStr(), &cachedImage{tar: memData, cfg: imageCfg})
175189
slog.InfoContext(
176190
ctx,
177191
"Populated image cache",
@@ -180,7 +194,7 @@ func (c *MemoryPullCache) Fetch(ctx context.Context, ref string) (io.ReadCloser,
180194
)
181195
}
182196

183-
return io.NopCloser(bytes.NewReader(memData)), nil
197+
return io.NopCloser(bytes.NewReader(memData)), &imageCfg, nil
184198
}
185199

186200
func registryHost(ref string) string {

cmd/atelet/oci.go

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929

3030
"github.com/agent-substrate/substrate/cmd/atelet/internal/memorypullcache"
3131
"github.com/agent-substrate/substrate/internal/ateompath"
32+
v1 "github.com/google/go-containerregistry/pkg/v1"
3233
"github.com/opencontainers/runtime-spec/specs-go"
3334
"go.opentelemetry.io/otel"
3435
"go.opentelemetry.io/otel/attribute"
@@ -71,7 +72,7 @@ func prepareOCIDirectory(ctx context.Context, pullCache *memorypullcache.MemoryP
7172
return fmt.Errorf("in os.MkdirAll for container bundle dir: %w", err)
7273
}
7374

74-
tarData, err := pullCache.Fetch(ctx, ref)
75+
tarData, imageCfg, err := pullCache.Fetch(ctx, ref)
7576
if err != nil {
7677
return fmt.Errorf("in pullCache.Fetch: %w", err)
7778
}
@@ -90,7 +91,7 @@ func prepareOCIDirectory(ctx context.Context, pullCache *memorypullcache.MemoryP
9091
}
9192
}
9293

93-
ociSpec := buildActorOCISpec(atespace, actorID, args, env, annotations, netns, identityDir, durableDirVolumeMounts)
94+
ociSpec := buildActorOCISpec(atespace, actorID, imageCfg, args, env, annotations, netns, identityDir, durableDirVolumeMounts)
9495
ociSpecBytes, err := json.MarshalIndent(ociSpec, "", " ")
9596
if err != nil {
9697
return fmt.Errorf("while marshaling OCI spec: %w", err)
@@ -103,15 +104,42 @@ func prepareOCIDirectory(ctx context.Context, pullCache *memorypullcache.MemoryP
103104
return nil
104105
}
105106

107+
// mergeActorEnv merges the ActorTemplate env and the image's ENV, with the template taking precedence.
108+
// duplicated keys are removed in favor of the following precedence template env > image env.
109+
// default PATH stands in for an image config with no env
110+
func mergeActorEnv(imageEnv, templateEnv []string) []string {
111+
seen := make(map[string]struct{})
112+
var out []string
113+
add := func(entries ...string) {
114+
for _, e := range entries {
115+
key, _, _ := strings.Cut(e, "=")
116+
if key == "" {
117+
continue
118+
}
119+
if _, ok := seen[key]; ok {
120+
continue
121+
}
122+
seen[key] = struct{}{}
123+
out = append(out, e)
124+
}
125+
}
126+
127+
add(templateEnv...)
128+
add(imageEnv...)
129+
add("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")
130+
return out
131+
}
132+
106133
// buildActorOCISpec assembles the OCI runtime spec for an actor container.
107134
// When identityDir is non-empty it adds a read-only bind mount of that host
108135
// directory at IdentityMountPath so the actor can read its own ID (see
109136
// IdentityMountPath for why this is a bind mount rather than env vars).
110-
func buildActorOCISpec(atespace string, actorID string, args []string, env []string, annotations map[string]string, netns string, identityDir string, durableDirVolumeMounts []*ateletpb.VolumeMount) *specs.Spec {
111-
envVars := []string{
112-
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
137+
func buildActorOCISpec(atespace string, actorID string, imageCfg *v1.Config, args []string, env []string, annotations map[string]string, netns string, identityDir string, durableDirVolumeMounts []*ateletpb.VolumeMount) *specs.Spec {
138+
var imageEnv []string
139+
if imageCfg != nil {
140+
imageEnv = imageCfg.Env
113141
}
114-
envVars = append(envVars, env...)
142+
envVars := mergeActorEnv(imageEnv, env)
115143

116144
mounts := []specs.Mount{
117145
{

cmd/atelet/oci_test.go

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ func runUntar(t *testing.T, entries []tarEntry) (string, error) {
8888
func TestBuildActorOCISpec_IdentityMount(t *testing.T) {
8989
spec := buildActorOCISpec(
9090
"atespace", "id",
91+
nil,
9192
[]string{"/app"},
9293
[]string{"FOO=bar"},
9394
map[string]string{"k": "v"},
@@ -116,9 +117,57 @@ func TestBuildActorOCISpec_IdentityMount(t *testing.T) {
116117
}
117118
}
118119

120+
func TestMergeActorEnv(t *testing.T) {
121+
defaultPath := "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
122+
123+
tests := []struct {
124+
name string
125+
imageEnv []string
126+
templateEnv []string
127+
want []string
128+
}{
129+
{
130+
name: "template overrides image by key",
131+
imageEnv: []string{"FOO=image"},
132+
templateEnv: []string{"FOO=template"},
133+
want: []string{"FOO=template", defaultPath},
134+
},
135+
{
136+
name: "default PATH applies when neither sets it",
137+
imageEnv: []string{"FOO=image"},
138+
templateEnv: []string{"BAR=template"},
139+
want: []string{"BAR=template", "FOO=image", defaultPath},
140+
},
141+
{
142+
name: "image PATH overrides default",
143+
imageEnv: []string{"PATH=/image/bin"},
144+
want: []string{"PATH=/image/bin"},
145+
},
146+
{
147+
name: "template PATH overrides default",
148+
templateEnv: []string{"PATH=/template/bin"},
149+
want: []string{"PATH=/template/bin"},
150+
},
151+
{
152+
name: "blank and keyless entries are dropped",
153+
imageEnv: []string{"", "=novalue"},
154+
want: []string{defaultPath},
155+
},
156+
}
157+
158+
for _, tc := range tests {
159+
t.Run(tc.name, func(t *testing.T) {
160+
got := mergeActorEnv(tc.imageEnv, tc.templateEnv)
161+
if !slices.Equal(got, tc.want) {
162+
t.Errorf("mergeActorEnv(%v, %v) =\n %v\nwant:\n %v", tc.imageEnv, tc.templateEnv, got, tc.want)
163+
}
164+
})
165+
}
166+
}
167+
119168
// Without an identity dir (the pause container), no identity mount appears.
120169
func TestBuildActorOCISpec_NoIdentityMountForPause(t *testing.T) {
121-
bare := buildActorOCISpec("atespace", "id", []string{"/pause"}, nil, nil, "/run/netns/x", "", nil)
170+
bare := buildActorOCISpec("atespace", "id", nil, []string{"/pause"}, nil, nil, "/run/netns/x", "", nil)
122171
for _, m := range bare.Mounts {
123172
if m.Destination == IdentityMountPath {
124173
t.Errorf("identity mount must be absent when identityDir is empty")
@@ -136,7 +185,7 @@ func TestBuildActorOCISpec_DurableDirVolumeMounts(t *testing.T) {
136185
}
137186
spec := buildActorOCISpec(
138187
atespace, id,
139-
[]string{"/app"}, nil, nil,
188+
nil, []string{"/app"}, nil, nil,
140189
"/run/netns/x",
141190
"",
142191
durableDirs,

0 commit comments

Comments
 (0)