Skip to content

Commit ab23aa4

Browse files
authored
fix: support for ephemeral container mutation (#3560)
Signed-off-by: Brandt Keller <[email protected]>
1 parent 6732428 commit ab23aa4

File tree

7 files changed

+184
-29
lines changed

7 files changed

+184
-29
lines changed

Diff for: packages/zarf-agent/manifests/webhook.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ webhooks:
4444
- "v1"
4545
resources:
4646
- "pods"
47+
- "pods/ephemeralcontainers"
4748
admissionReviewVersions:
4849
- "v1"
4950
- "v1beta1"

Diff for: site/src/content/docs/contribute/testing.mdx

-4
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,6 @@ When adding new tests, there are several requirements that must be followed, inc
105105
```go
106106
func TestFooBarBaz(t *testing.T) {
107107
t.Log("E2E: Enter useful description here")
108-
e2e.setup(t)
109-
defer e2e.teardown(t)
110108

111109
...
112110
}
@@ -119,8 +117,6 @@ The end-to-end tests are run sequentially and the naming convention is set inten
119117
- 00-19 tests run prior to `zarf init` (cluster not initialized).
120118

121119
:::note
122-
Tests 20+ should call `e2e.setupWithCluster(t)` instead of `e2e.setup(t)`.
123-
124120
Due to resource constraints in public GitHub runners, K8s tests are only performed on Linux.
125121
:::
126122

Diff for: src/internal/agent/hooks/pods.go

+58-8
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ func mutatePod(ctx context.Context, r *v1.AdmissionRequest, cluster *cluster.Clu
6666
return nil, fmt.Errorf(lang.AgentErrParsePod, err)
6767
}
6868

69+
if r.SubResource != "" {
70+
return mutatePodSubresource(ctx, r, cluster)
71+
}
72+
6973
if pod.Labels != nil && pod.Labels["zarf-agent"] == "patched" {
7074
// We've already played with this pod, just keep swimming 🐟
7175
return &operations.Result{
@@ -105,9 +109,9 @@ func mutatePod(ctx context.Context, r *v1.AdmissionRequest, cluster *cluster.Clu
105109
patches = append(patches, operations.ReplacePatchOperation(path, replacement))
106110
}
107111

108-
// update the image host for each ephemeral container
109-
for idx, container := range pod.Spec.EphemeralContainers {
110-
path := fmt.Sprintf("/spec/ephemeralContainers/%d/image", idx)
112+
// update the image host for each normal container
113+
for idx, container := range pod.Spec.Containers {
114+
path := fmt.Sprintf("/spec/containers/%d/image", idx)
111115
replacement, err := transform.ImageTransformHost(registryURL, container.Image)
112116
if err != nil {
113117
return nil, err
@@ -116,9 +120,55 @@ func mutatePod(ctx context.Context, r *v1.AdmissionRequest, cluster *cluster.Clu
116120
patches = append(patches, operations.ReplacePatchOperation(path, replacement))
117121
}
118122

119-
// update the image host for each normal container
120-
for idx, container := range pod.Spec.Containers {
121-
path := fmt.Sprintf("/spec/containers/%d/image", idx)
123+
// Add the "zarf-agent"="patched" label patch
124+
patches = append(patches, getLabelPatch(pod.Labels))
125+
126+
// Add the annotations label patch
127+
patches = append(patches, operations.ReplacePatchOperation("/metadata/annotations", updatedAnnotations))
128+
129+
return &operations.Result{
130+
Allowed: true,
131+
PatchOps: patches,
132+
}, nil
133+
}
134+
135+
// mutatePodSubresource handles pod subresource mutation
136+
func mutatePodSubresource(ctx context.Context, r *v1.AdmissionRequest, cluster *cluster.Cluster) (*operations.Result, error) {
137+
switch res := r.SubResource; res {
138+
case "ephemeralcontainers":
139+
return mutateEphemeralContainers(ctx, r, cluster)
140+
default:
141+
// this likely won't be hit as the MutatingWebhookConfiguration would need to be modified - but this can help ensure they stay synchronized
142+
return nil, fmt.Errorf("attempted mutation of unsupported subresource: %s", res)
143+
}
144+
}
145+
146+
func mutateEphemeralContainers(ctx context.Context, r *v1.AdmissionRequest, cluster *cluster.Cluster) (*operations.Result, error) {
147+
l := logger.From(ctx)
148+
pod, err := parsePod(r.Object.Raw)
149+
if err != nil {
150+
return nil, fmt.Errorf(lang.AgentErrParsePod, err)
151+
}
152+
153+
state, err := cluster.LoadZarfState(ctx)
154+
if err != nil {
155+
return nil, err
156+
}
157+
registryURL := state.RegistryInfo.Address
158+
159+
// Pods do not have a metadata.name at the time of admission if from a deployment so we don't log the name
160+
l.Info("using the Zarf registry URL to mutate the Pod", "registry", registryURL)
161+
162+
updatedAnnotations := pod.Annotations
163+
if updatedAnnotations == nil {
164+
updatedAnnotations = make(map[string]string)
165+
}
166+
167+
var patches []operations.PatchOperation
168+
169+
// update the image host for each ephemeral container
170+
for idx, container := range pod.Spec.EphemeralContainers {
171+
path := fmt.Sprintf("/spec/ephemeralContainers/%d/image", idx)
122172
replacement, err := transform.ImageTransformHost(registryURL, container.Image)
123173
if err != nil {
124174
return nil, err
@@ -127,10 +177,10 @@ func mutatePod(ctx context.Context, r *v1.AdmissionRequest, cluster *cluster.Clu
127177
patches = append(patches, operations.ReplacePatchOperation(path, replacement))
128178
}
129179

130-
patches = append(patches, getLabelPatch(pod.Labels))
131-
180+
// Add the annotations label patch
132181
patches = append(patches, operations.ReplacePatchOperation("/metadata/annotations", updatedAnnotations))
133182

183+
// Return the result of the subresource mutation
134184
return &operations.Result{
135185
Allowed: true,
136186
PatchOps: patches,

Diff for: src/internal/agent/hooks/pods_test.go

+43-17
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import (
2020
"k8s.io/apimachinery/pkg/runtime"
2121
)
2222

23-
func createPodAdmissionRequest(t *testing.T, op v1.Operation, pod *corev1.Pod) *v1.AdmissionRequest {
23+
func createPodAdmissionRequest(t *testing.T, op v1.Operation, pod *corev1.Pod, subResource string) *v1.AdmissionRequest {
2424
t.Helper()
2525
raw, err := json.Marshal(pod)
2626
require.NoError(t, err)
@@ -29,6 +29,7 @@ func createPodAdmissionRequest(t *testing.T, op v1.Operation, pod *corev1.Pod) *
2929
Object: runtime.RawExtension{
3030
Raw: raw,
3131
},
32+
SubResource: subResource,
3233
}
3334
}
3435

@@ -52,16 +53,8 @@ func TestPodMutationWebhook(t *testing.T) {
5253
Spec: corev1.PodSpec{
5354
Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}},
5455
InitContainers: []corev1.Container{{Name: "different", Image: "busybox"}},
55-
EphemeralContainers: []corev1.EphemeralContainer{
56-
{
57-
EphemeralContainerCommon: corev1.EphemeralContainerCommon{
58-
Name: "alpine",
59-
Image: "alpine",
60-
},
61-
},
62-
},
6356
},
64-
}),
57+
}, ""),
6558
patch: []operations.PatchOperation{
6659
operations.ReplacePatchOperation(
6760
"/spec/imagePullSecrets",
@@ -71,10 +64,6 @@ func TestPodMutationWebhook(t *testing.T) {
7164
"/spec/initContainers/0/image",
7265
"127.0.0.1:31999/library/busybox:latest-zarf-2140033595",
7366
),
74-
operations.ReplacePatchOperation(
75-
"/spec/ephemeralContainers/0/image",
76-
"127.0.0.1:31999/library/alpine:latest-zarf-1117969859",
77-
),
7867
operations.ReplacePatchOperation(
7968
"/spec/containers/0/image",
8069
"127.0.0.1:31999/library/nginx:latest-zarf-3793515731",
@@ -90,7 +79,6 @@ func TestPodMutationWebhook(t *testing.T) {
9079
"/metadata/annotations",
9180
map[string]string{
9281
"zarf.dev/original-image-nginx": "nginx",
93-
"zarf.dev/original-image-alpine": "alpine",
9482
"zarf.dev/original-image-different": "busybox",
9583
"should-be": "mutated",
9684
},
@@ -107,10 +95,48 @@ func TestPodMutationWebhook(t *testing.T) {
10795
Spec: corev1.PodSpec{
10896
Containers: []corev1.Container{{Image: "nginx"}},
10997
},
110-
}),
98+
}, ""),
11199
patch: nil,
112100
code: http.StatusOK,
113101
},
102+
{
103+
name: "ephermalcontainer update in pod with zarf-agent patched label should be mutated",
104+
admissionReq: createPodAdmissionRequest(t, v1.Create, &corev1.Pod{
105+
ObjectMeta: metav1.ObjectMeta{
106+
Labels: map[string]string{"zarf-agent": "patched"},
107+
Annotations: map[string]string{
108+
"zarf.dev/original-image-nginx": "nginx",
109+
"zarf.dev/original-image-alpine": "alpine",
110+
},
111+
},
112+
Spec: corev1.PodSpec{
113+
Containers: []corev1.Container{{Name: "nginx", Image: "127.0.0.1:31999/library/nginx:latest-zarf-3793515731"}},
114+
EphemeralContainers: []corev1.EphemeralContainer{
115+
{
116+
EphemeralContainerCommon: corev1.EphemeralContainerCommon{
117+
Name: "alpine",
118+
Image: "alpine",
119+
},
120+
},
121+
},
122+
ImagePullSecrets: []corev1.LocalObjectReference{{Name: config.ZarfImagePullSecretName}},
123+
},
124+
}, "ephemeralcontainers"),
125+
patch: []operations.PatchOperation{
126+
operations.ReplacePatchOperation(
127+
"/spec/ephemeralContainers/0/image",
128+
"127.0.0.1:31999/library/alpine:latest-zarf-1117969859",
129+
),
130+
operations.ReplacePatchOperation(
131+
"/metadata/annotations",
132+
map[string]string{
133+
"zarf.dev/original-image-nginx": "nginx",
134+
"zarf.dev/original-image-alpine": "alpine",
135+
},
136+
),
137+
},
138+
code: http.StatusOK,
139+
},
114140
{
115141
name: "pod with no labels should not error",
116142
admissionReq: createPodAdmissionRequest(t, v1.Create, &corev1.Pod{
@@ -120,7 +146,7 @@ func TestPodMutationWebhook(t *testing.T) {
120146
Spec: corev1.PodSpec{
121147
Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}},
122148
},
123-
}),
149+
}, ""),
124150
patch: []operations.PatchOperation{
125151
operations.ReplacePatchOperation(
126152
"/spec/imagePullSecrets",

Diff for: src/test/e2e/38_ephemeral_container_test.go

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: 2021-Present The Zarf Authors
3+
4+
// Package test provides e2e tests for Zarf.
5+
package test
6+
7+
import (
8+
"fmt"
9+
"path/filepath"
10+
"testing"
11+
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestEphemeralContainers(t *testing.T) {
16+
t.Log("E2E: Ephemeral Containers mutation")
17+
18+
// cleanup - should perform cleanup in the event of pass or fail
19+
t.Cleanup(func() {
20+
e2e.Zarf(t, "package", "remove", "basic-pod", "--confirm") //nolint:errcheck
21+
})
22+
23+
tmpdir := t.TempDir()
24+
25+
// we need to create a test package that contains the images we want to potentially use
26+
// this should ideally be a single pod such that naming is static
27+
stdOut, stdErr, err := e2e.Zarf(t, "package", "create", "src/test/packages/38-ephemeral-container", "-o", tmpdir, "--skip-sbom")
28+
require.NoError(t, err, stdOut, stdErr)
29+
packageName := fmt.Sprintf("zarf-package-basic-pod-%s-0.0.1.tar.zst", e2e.Arch)
30+
path := filepath.Join(tmpdir, packageName)
31+
32+
// deploy the above package
33+
stdOut, stdErr, err = e2e.Zarf(t, "package", "deploy", path, "--confirm")
34+
require.NoError(t, err, stdOut, stdErr)
35+
36+
// using a pod the package deploys - run a kubectl debug command
37+
stdOut, stdErr, err = e2e.Kubectl(t, "debug", "test-pod", "-n", "test", "--image=ghcr.io/zarf-dev/images/alpine:3.21.3", "--profile", "general")
38+
require.NoError(t, err, stdOut, stdErr)
39+
40+
// wait for the ephemeralContainer to exist - as it need to traverse mutation/admission
41+
stdOut, stdErr, err = e2e.Kubectl(t, "wait", "--namespace=test", "--for=jsonpath={.status.ephemeralContainerStatuses[*].image}=127.0.0.1:31337/zarf-dev/images/alpine:3.21.3-zarf-1792331847", "pod/test-pod", "--timeout=10s")
42+
require.NoError(t, err, stdOut, stdErr)
43+
44+
podStdOut, stdErr, err := e2e.Kubectl(t, "get", "pod", "test-pod", "-n", "test", "-o", "jsonpath={.status.ephemeralContainerStatuses[*].image}")
45+
require.NoError(t, err, podStdOut, stdErr)
46+
47+
// Ensure the image used contains the internal zarf registry (IE mutated)
48+
require.Contains(t, podStdOut, "127.0.0.1:31337/zarf-dev/images/alpine:3.21.3-zarf-1792331847")
49+
}

Diff for: src/test/packages/38-ephemeral-container/pod.yaml

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
apiVersion: v1
2+
kind: Pod
3+
metadata:
4+
creationTimestamp: null
5+
labels:
6+
run: test-pod
7+
name: test-pod
8+
spec:
9+
containers:
10+
- image: ghcr.io/zarf-dev/images/alpine:3.21.3
11+
name: test-pod
12+
command: ["sleep", "3600"]
13+
resources: {}
14+
dnsPolicy: ClusterFirst
15+
restartPolicy: Always
16+
status: {}

Diff for: src/test/packages/38-ephemeral-container/zarf.yaml

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# yaml-language-server: $schema=https://raw.githubusercontent.com/zarf-dev/zarf/v0.49.1/zarf.schema.json
2+
kind: ZarfPackageConfig
3+
4+
metadata:
5+
name: basic-pod
6+
version: 0.0.1
7+
8+
components:
9+
- name: alpine
10+
required: true
11+
manifests:
12+
- name: alpine
13+
namespace: test
14+
files:
15+
- pod.yaml
16+
images:
17+
- ghcr.io/zarf-dev/images/alpine:3.21.3

0 commit comments

Comments
 (0)