Skip to content

Commit

Permalink
test: pod agent unit tests (defenseunicorns#2526)
Browse files Browse the repository at this point in the history
## Description

This also changes how we the agent gets secrets from the cluster.
Instead of mounting secrets in the deployment and reading from a file
the agent will instead read the secret directly from the cluster. This
is done for simplicity, and easier testing. Additionally, the agent will
need to read from the cluster in defenseunicorns#1974 as well so this helps prepare for
that change.

This only changes and tests pods to keep the scope of the PR slim. This
means temporarily the agent will both read from mounted file secrets and
regular kubernetes secrets depending on the hook, but this will be
changed shortly in future PRs.

## Related Issue

Relates to defenseunicorns#2512 

## Checklist before merging

- [ ] Test, docs, adr added or updated as needed
- [ ] [Contributor Guide
Steps](https://github.com/defenseunicorns/zarf/blob/main/.github/CONTRIBUTING.md#developer-workflow)
followed

---------

Co-authored-by: Lucas Rodriguez <[email protected]>
  • Loading branch information
2 people authored and schristoff committed May 22, 2024
1 parent 9f9d352 commit fd43ac8
Show file tree
Hide file tree
Showing 12 changed files with 305 additions and 36 deletions.
1 change: 1 addition & 0 deletions packages/zarf-agent/manifests/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ spec:
imagePullSecrets:
- name: private-registry
priorityClassName: system-node-critical
serviceAccountName: zarf
containers:
- name: server
image: "###ZARF_REGISTRY###/###ZARF_CONST_AGENT_IMAGE###:###ZARF_CONST_AGENT_IMAGE_TAG###"
Expand Down
12 changes: 12 additions & 0 deletions packages/zarf-agent/manifests/role.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: zarf-agent
namespace: zarf
rules:
- apiGroups:
- ""
resources:
- secrets
verbs:
- get
13 changes: 13 additions & 0 deletions packages/zarf-agent/manifests/rolebinding.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: zarf-agent-binding
namespace: zarf
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: zarf-agent
subjects:
- kind: ServiceAccount
name: zarf
namespace: zarf
5 changes: 5 additions & 0 deletions packages/zarf-agent/manifests/serviceaccount.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: zarf
namespace: zarf
3 changes: 3 additions & 0 deletions packages/zarf-agent/zarf.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ components:
- manifests/secret.yaml
- manifests/deployment.yaml
- manifests/webhook.yaml
- manifests/role.yaml
- manifests/rolebinding.yaml
- manifests/serviceaccount.yaml
actions:
onCreate:
before:
Expand Down
2 changes: 1 addition & 1 deletion src/config/lang/english.go
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,7 @@ const (
AgentErrBadRequest = "could not read request body: %s"
AgentErrBindHandler = "Unable to bind the webhook handler"
AgentErrCouldNotDeserializeReq = "could not deserialize request: %s"
AgentErrGetState = "failed to load zarf state from file: %w"
AgentErrGetState = "failed to load zarf state: %w"
AgentErrHostnameMatch = "failed to complete hostname matching: %w"
AgentErrImageSwap = "Unable to swap the host for (%s)"
AgentErrInvalidMethod = "invalid method only POST requests are allowed"
Expand Down
39 changes: 22 additions & 17 deletions src/internal/agent/hooks/pods.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
package hooks

import (
"context"
"encoding/json"
"fmt"

"github.com/defenseunicorns/zarf/src/config"
"github.com/defenseunicorns/zarf/src/config/lang"
"github.com/defenseunicorns/zarf/src/internal/agent/operations"
"github.com/defenseunicorns/zarf/src/internal/agent/state"
"github.com/defenseunicorns/zarf/src/pkg/cluster"
"github.com/defenseunicorns/zarf/src/pkg/message"
"github.com/defenseunicorns/zarf/src/pkg/transform"
v1 "k8s.io/api/admission/v1"
Expand All @@ -20,11 +21,15 @@ import (
)

// NewPodMutationHook creates a new instance of pods mutation hook.
func NewPodMutationHook() operations.Hook {
func NewPodMutationHook(ctx context.Context, cluster *cluster.Cluster) operations.Hook {
message.Debug("hooks.NewMutationHook()")
return operations.Hook{
Create: mutatePod,
Update: mutatePod,
Create: func(r *v1.AdmissionRequest) (*operations.Result, error) {
return mutatePod(ctx, r, cluster)
},
Update: func(r *v1.AdmissionRequest) (*operations.Result, error) {
return mutatePod(ctx, r, cluster)
},
}
}

Expand All @@ -34,14 +39,12 @@ func parsePod(object []byte) (*corev1.Pod, error) {
if err := json.Unmarshal(object, &pod); err != nil {
return nil, err
}

return &pod, nil
}

func mutatePod(r *v1.AdmissionRequest) (*operations.Result, error) {
func mutatePod(ctx context.Context, r *v1.AdmissionRequest, cluster *cluster.Cluster) (*operations.Result, error) {
message.Debugf("hooks.mutatePod()(*v1.AdmissionRequest) - %#v , %s/%s: %#v", r.Kind, r.Namespace, r.Name, r.Operation)

var patchOperations []operations.PatchOperation
pod, err := parsePod(r.Object.Raw)
if err != nil {
return &operations.Result{Msg: err.Error()}, nil
Expand All @@ -51,24 +54,26 @@ func mutatePod(r *v1.AdmissionRequest) (*operations.Result, error) {
// We've already played with this pod, just keep swimming 🐟
return &operations.Result{
Allowed: true,
PatchOps: patchOperations,
PatchOps: []operations.PatchOperation{},
}, nil
}

// Add the zarf secret to the podspec
zarfSecret := []corev1.LocalObjectReference{{Name: config.ZarfImagePullSecretName}}
patchOperations = append(patchOperations, operations.ReplacePatchOperation("/spec/imagePullSecrets", zarfSecret))

zarfState, err := state.GetZarfStateFromAgentPod()
state, err := cluster.LoadZarfState(ctx)
if err != nil {
return nil, fmt.Errorf(lang.AgentErrGetState, err)
}
containerRegistryURL := zarfState.RegistryInfo.Address
registryURL := state.RegistryInfo.Address

var patchOperations []operations.PatchOperation

// Add the zarf secret to the podspec
zarfSecret := []corev1.LocalObjectReference{{Name: config.ZarfImagePullSecretName}}
patchOperations = append(patchOperations, operations.ReplacePatchOperation("/spec/imagePullSecrets", zarfSecret))

// update the image host for each init container
for idx, container := range pod.Spec.InitContainers {
path := fmt.Sprintf("/spec/initContainers/%d/image", idx)
replacement, err := transform.ImageTransformHost(containerRegistryURL, container.Image)
replacement, err := transform.ImageTransformHost(registryURL, container.Image)
if err != nil {
message.Warnf(lang.AgentErrImageSwap, container.Image)
continue // Continue, because we might as well attempt to mutate the other containers for this pod
Expand All @@ -79,7 +84,7 @@ func mutatePod(r *v1.AdmissionRequest) (*operations.Result, error) {
// update the image host for each ephemeral container
for idx, container := range pod.Spec.EphemeralContainers {
path := fmt.Sprintf("/spec/ephemeralContainers/%d/image", idx)
replacement, err := transform.ImageTransformHost(containerRegistryURL, container.Image)
replacement, err := transform.ImageTransformHost(registryURL, container.Image)
if err != nil {
message.Warnf(lang.AgentErrImageSwap, container.Image)
continue // Continue, because we might as well attempt to mutate the other containers for this pod
Expand All @@ -90,7 +95,7 @@ func mutatePod(r *v1.AdmissionRequest) (*operations.Result, error) {
// update the image host for each normal container
for idx, container := range pod.Spec.Containers {
path := fmt.Sprintf("/spec/containers/%d/image", idx)
replacement, err := transform.ImageTransformHost(containerRegistryURL, container.Image)
replacement, err := transform.ImageTransformHost(registryURL, container.Image)
if err != nil {
message.Warnf(lang.AgentErrImageSwap, container.Image)
continue // Continue, because we might as well attempt to mutate the other containers for this pod
Expand Down
149 changes: 149 additions & 0 deletions src/internal/agent/hooks/pods_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2021-Present The Zarf Authors

package hooks

import (
"context"
"encoding/json"
"net/http"
"testing"

"github.com/defenseunicorns/zarf/src/config"
"github.com/defenseunicorns/zarf/src/internal/agent/http/admission"
"github.com/defenseunicorns/zarf/src/internal/agent/operations"
"github.com/defenseunicorns/zarf/src/types"
"github.com/stretchr/testify/require"
v1 "k8s.io/api/admission/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)

func createPodAdmissionRequest(t *testing.T, op v1.Operation, pod *corev1.Pod) *v1.AdmissionRequest {
t.Helper()
raw, err := json.Marshal(pod)
require.NoError(t, err)
return &v1.AdmissionRequest{
Operation: op,
Object: runtime.RawExtension{
Raw: raw,
},
}
}

func TestPodMutationWebhook(t *testing.T) {
t.Parallel()

ctx := context.Background()

state := &types.ZarfState{RegistryInfo: types.RegistryInfo{Address: "127.0.0.1:31999"}}
c := createTestClientWithZarfState(ctx, t, state)
handler := admission.NewHandler().Serve(NewPodMutationHook(ctx, c))

tests := []struct {
name string
admissionReq *v1.AdmissionRequest
expectedPatch []operations.PatchOperation
code int
}{
{
name: "pod with label should be mutated",
admissionReq: createPodAdmissionRequest(t, v1.Create, &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"should-be": "mutated"},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Image: "nginx"}},
InitContainers: []corev1.Container{{Image: "busybox"}},
EphemeralContainers: []corev1.EphemeralContainer{
{
EphemeralContainerCommon: corev1.EphemeralContainerCommon{
Image: "alpine",
},
},
},
},
}),
expectedPatch: []operations.PatchOperation{
operations.ReplacePatchOperation(
"/spec/imagePullSecrets",
[]corev1.LocalObjectReference{{Name: config.ZarfImagePullSecretName}},
),
operations.ReplacePatchOperation(
"/spec/initContainers/0/image",
"127.0.0.1:31999/library/busybox:latest-zarf-2140033595",
),
operations.ReplacePatchOperation(
"/spec/ephemeralContainers/0/image",
"127.0.0.1:31999/library/alpine:latest-zarf-1117969859",
),
operations.ReplacePatchOperation(
"/spec/containers/0/image",
"127.0.0.1:31999/library/nginx:latest-zarf-3793515731",
),
operations.ReplacePatchOperation(
"/metadata/labels/zarf-agent",
"patched",
),
},
code: http.StatusOK,
},
{
name: "pod with zarf-agent patched label should not be mutated",
admissionReq: createPodAdmissionRequest(t, v1.Create, &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"zarf-agent": "patched"},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Image: "nginx"}},
},
}),
expectedPatch: nil,
code: http.StatusOK,
},
{
name: "pod with no labels should not error",
admissionReq: createPodAdmissionRequest(t, v1.Create, &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Labels: nil,
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Image: "nginx"}},
},
}),
expectedPatch: []operations.PatchOperation{
operations.ReplacePatchOperation(
"/spec/imagePullSecrets",
[]corev1.LocalObjectReference{{Name: config.ZarfImagePullSecretName}},
),
operations.ReplacePatchOperation(
"/spec/containers/0/image",
"127.0.0.1:31999/library/nginx:latest-zarf-3793515731",
),
operations.AddPatchOperation(
"/metadata/labels",
map[string]string{"zarf-agent": "patched"},
),
},
code: http.StatusOK,
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
resp := sendAdmissionRequest(t, tt.admissionReq, handler, tt.code)
if tt.expectedPatch == nil {
require.Empty(t, string(resp.Patch))
} else {
expectedPatchJSON, err := json.Marshal(tt.expectedPatch)
require.NoError(t, err)
require.NotNil(t, resp)
require.True(t, resp.Allowed)
require.JSONEq(t, string(expectedPatchJSON), string(resp.Patch))
}
})
}
}
70 changes: 70 additions & 0 deletions src/internal/agent/hooks/utils_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2021-Present The Zarf Authors

package hooks

import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/defenseunicorns/zarf/src/pkg/cluster"
"github.com/defenseunicorns/zarf/src/pkg/k8s"
"github.com/defenseunicorns/zarf/src/types"
"github.com/stretchr/testify/require"
v1 "k8s.io/api/admission/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
)

func createTestClientWithZarfState(ctx context.Context, t *testing.T, state *types.ZarfState) *cluster.Cluster {
t.Helper()
c := &cluster.Cluster{K8s: &k8s.K8s{Clientset: fake.NewSimpleClientset()}}
stateData, err := json.Marshal(state)
require.NoError(t, err)

secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: cluster.ZarfStateSecretName,
Namespace: cluster.ZarfNamespaceName,
},
Data: map[string][]byte{
cluster.ZarfStateDataKey: stateData,
},
}
_, err = c.Clientset.CoreV1().Secrets(cluster.ZarfNamespaceName).Create(ctx, secret, metav1.CreateOptions{})
require.NoError(t, err)
return c
}

// sendAdmissionRequest sends an admission request to the handler and returns the response.
func sendAdmissionRequest(t *testing.T, admissionReq *v1.AdmissionRequest, handler http.HandlerFunc, code int) *v1.AdmissionResponse {
t.Helper()

b, err := json.Marshal(&v1.AdmissionReview{
Request: admissionReq,
})
require.NoError(t, err)

// Note: The URL ("/test") doesn't matter here because we are directly invoking the handler.
// The handler processes the request based on the HTTP method and body content, not the URL path.
req := httptest.NewRequest(http.MethodPost, "/test", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")

rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

require.Equal(t, code, rr.Code)

var admissionReview v1.AdmissionReview
if rr.Code == http.StatusOK {
err = json.NewDecoder(rr.Body).Decode(&admissionReview)
require.NoError(t, err)
}

return admissionReview.Response
}

0 comments on commit fd43ac8

Please sign in to comment.