diff --git a/go.mod b/go.mod index 9fe6c7a968..1275140145 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/fairwindsops/pluto/v5 v5.18.4 github.com/fatih/color v1.16.0 github.com/fluxcd/helm-controller/api v0.37.4 + github.com/fluxcd/pkg/apis/meta v1.3.0 github.com/fluxcd/source-controller/api v1.2.4 github.com/go-git/go-git/v5 v5.11.0 github.com/go-logr/logr v1.4.1 @@ -227,7 +228,6 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fluxcd/pkg/apis/acl v0.1.0 // indirect github.com/fluxcd/pkg/apis/kustomize v1.3.0 // indirect - github.com/fluxcd/pkg/apis/meta v1.3.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fvbommel/sortorder v1.1.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect diff --git a/src/config/lang/english.go b/src/config/lang/english.go index c5deeffc0c..adae1f1a41 100644 --- a/src/config/lang/english.go +++ b/src/config/lang/english.go @@ -665,6 +665,7 @@ const ( AgentErrBindHandler = "Unable to bind the webhook handler" AgentErrCouldNotDeserializeReq = "could not deserialize request: %s" AgentErrGetState = "failed to load zarf state: %w" + AgentErrParsePod = "failed to parse pod: %w" AgentErrHostnameMatch = "failed to complete hostname matching: %w" AgentErrImageSwap = "Unable to swap the host for (%s)" AgentErrInvalidMethod = "invalid method only POST requests are allowed" diff --git a/src/internal/agent/hooks/flux.go b/src/internal/agent/hooks/flux.go index c4efce1846..617b91901c 100644 --- a/src/internal/agent/hooks/flux.go +++ b/src/internal/agent/hooks/flux.go @@ -5,6 +5,7 @@ package hooks import ( + "context" "encoding/json" "fmt" @@ -12,40 +13,34 @@ import ( "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" - "github.com/defenseunicorns/zarf/src/types" + fluxmeta "github.com/fluxcd/pkg/apis/meta" + flux "github.com/fluxcd/source-controller/api/v1" v1 "k8s.io/api/admission/v1" ) -// SecretRef contains the name used to reference a git repository secret. -type SecretRef struct { - Name string `json:"name"` -} - -// GenericGitRepo contains the URL of a git repo and the secret that corresponds to it for use with Flux. -type GenericGitRepo struct { - Spec struct { - URL string `json:"url"` - SecretRef SecretRef `json:"secretRef,omitempty"` - } `json:"spec"` -} +// AgentErrTransformGitURL is thrown when the agent fails to make the git url a Zarf compatible url +const AgentErrTransformGitURL = "unable to transform the git url" // NewGitRepositoryMutationHook creates a new instance of the git repo mutation hook. -func NewGitRepositoryMutationHook() operations.Hook { +func NewGitRepositoryMutationHook(ctx context.Context, cluster *cluster.Cluster) operations.Hook { message.Debug("hooks.NewGitRepositoryMutationHook()") return operations.Hook{ - Create: mutateGitRepo, - Update: mutateGitRepo, + Create: func(r *v1.AdmissionRequest) (*operations.Result, error) { + return mutateGitRepo(ctx, r, cluster) + }, + Update: func(r *v1.AdmissionRequest) (*operations.Result, error) { + return mutateGitRepo(ctx, r, cluster) + }, } } // mutateGitRepoCreate mutates the git repository url to point to the repository URL defined in the ZarfState. -func mutateGitRepo(r *v1.AdmissionRequest) (result *operations.Result, err error) { +func mutateGitRepo(ctx context.Context, r *v1.AdmissionRequest, cluster *cluster.Cluster) (result *operations.Result, err error) { var ( - zarfState *types.ZarfState patches []operations.PatchOperation isPatched bool @@ -53,43 +48,43 @@ func mutateGitRepo(r *v1.AdmissionRequest) (result *operations.Result, err error isUpdate = r.Operation == v1.Update ) - // Form the zarfState.GitServer.Address from the zarfState - if zarfState, err = state.GetZarfStateFromAgentPod(); err != nil { + state, err := cluster.LoadZarfState(ctx) + if err != nil { return nil, fmt.Errorf(lang.AgentErrGetState, err) } - message.Debugf("Using the url of (%s) to mutate the flux repository", zarfState.GitServer.Address) + message.Debugf("Using the url of (%s) to mutate the flux repository", state.GitServer.Address) - // parse to simple struct to read the git url - src := &GenericGitRepo{} - if err = json.Unmarshal(r.Object.Raw, &src); err != nil { + repo := flux.GitRepository{} + if err = json.Unmarshal(r.Object.Raw, &repo); err != nil { return nil, fmt.Errorf(lang.ErrUnmarshal, err) } - patchedURL := src.Spec.URL // Check if this is an update operation and the hostname is different from what we have in the zarfState // NOTE: We mutate on updates IF AND ONLY IF the hostname in the request is different than the hostname in the zarfState // NOTE: We are checking if the hostname is different before because we do not want to potentially mutate a URL that has already been mutated. if isUpdate { - isPatched, err = helpers.DoHostnamesMatch(zarfState.GitServer.Address, src.Spec.URL) + isPatched, err = helpers.DoHostnamesMatch(state.GitServer.Address, repo.Spec.URL) if err != nil { return nil, fmt.Errorf(lang.AgentErrHostnameMatch, err) } } + patchedURL := repo.Spec.URL + // Mutate the git URL if necessary if isCreate || (isUpdate && !isPatched) { // Mutate the git URL so that the hostname matches the hostname in the Zarf state - transformedURL, err := transform.GitURL(zarfState.GitServer.Address, patchedURL, zarfState.GitServer.PushUsername) + transformedURL, err := transform.GitURL(state.GitServer.Address, patchedURL, state.GitServer.PushUsername) if err != nil { - message.Warnf("Unable to transform the git url, using the original url we have: %s", patchedURL) + return nil, fmt.Errorf("%s: %w", AgentErrTransformGitURL, err) } patchedURL = transformedURL.String() - message.Debugf("original git URL of (%s) got mutated to (%s)", src.Spec.URL, patchedURL) + message.Debugf("original git URL of (%s) got mutated to (%s)", repo.Spec.URL, patchedURL) } // Patch updates of the repo spec - patches = populatePatchOperations(patchedURL, src.Spec.SecretRef.Name) + patches = populatePatchOperations(patchedURL) return &operations.Result{ Allowed: true, @@ -98,17 +93,12 @@ func mutateGitRepo(r *v1.AdmissionRequest) (result *operations.Result, err error } // Patch updates of the repo spec. -func populatePatchOperations(repoURL string, secretName string) []operations.PatchOperation { +func populatePatchOperations(repoURL string) []operations.PatchOperation { var patches []operations.PatchOperation patches = append(patches, operations.ReplacePatchOperation("/spec/url", repoURL)) - // If a prior secret exists, replace it - if secretName != "" { - patches = append(patches, operations.ReplacePatchOperation("/spec/secretRef/name", config.ZarfGitServerSecretName)) - } else { - // Otherwise, add the new secret - patches = append(patches, operations.AddPatchOperation("/spec/secretRef", SecretRef{Name: config.ZarfGitServerSecretName})) - } + newSecretRef := fluxmeta.LocalObjectReference{Name: config.ZarfGitServerSecretName} + patches = append(patches, operations.AddPatchOperation("/spec/secretRef", newSecretRef)) return patches } diff --git a/src/internal/agent/hooks/flux_test.go b/src/internal/agent/hooks/flux_test.go new file mode 100644 index 0000000000..cf56ac844a --- /dev/null +++ b/src/internal/agent/hooks/flux_test.go @@ -0,0 +1,116 @@ +// 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" + fluxmeta "github.com/fluxcd/pkg/apis/meta" + flux "github.com/fluxcd/source-controller/api/v1" + "github.com/stretchr/testify/require" + v1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func createFluxGitRepoAdmissionRequest(t *testing.T, op v1.Operation, fluxGitRepo *flux.GitRepository) *v1.AdmissionRequest { + t.Helper() + raw, err := json.Marshal(fluxGitRepo) + require.NoError(t, err) + return &v1.AdmissionRequest{ + Operation: op, + Object: runtime.RawExtension{ + Raw: raw, + }, + } +} + +func TestFluxMutationWebhook(t *testing.T) { + t.Parallel() + + ctx := context.Background() + state := &types.ZarfState{GitServer: types.GitServerInfo{ + Address: "https://git-server.com", + PushUsername: "a-push-user", + }} + c := createTestClientWithZarfState(ctx, t, state) + handler := admission.NewHandler().Serve(NewGitRepositoryMutationHook(ctx, c)) + + tests := []admissionTest{ + { + name: "should be mutated", + admissionReq: createFluxGitRepoAdmissionRequest(t, v1.Create, &flux.GitRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mutate-this", + }, + Spec: flux.GitRepositorySpec{ + URL: "https://github.com/stefanprodan/podinfo.git", + }, + }), + patch: []operations.PatchOperation{ + operations.ReplacePatchOperation( + "/spec/url", + "https://git-server.com/a-push-user/podinfo-1646971829.git", + ), + operations.AddPatchOperation( + "/spec/secretRef", + fluxmeta.LocalObjectReference{Name: config.ZarfGitServerSecretName}, + ), + }, + code: http.StatusOK, + }, + { + name: "should not mutate invalid git url", + admissionReq: createFluxGitRepoAdmissionRequest(t, v1.Update, &flux.GitRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mutate-this", + }, + Spec: flux.GitRepositorySpec{ + URL: "not-a-git-url", + }, + }), + patch: nil, + code: http.StatusInternalServerError, + errContains: AgentErrTransformGitURL, + }, + { + name: "should patch to same url and update secret if hostname matches", + admissionReq: createFluxGitRepoAdmissionRequest(t, v1.Update, &flux.GitRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "no-mutate", + }, + Spec: flux.GitRepositorySpec{ + URL: "https://git-server.com/a-push-user/podinfo.git", + }, + }), + patch: []operations.PatchOperation{ + operations.ReplacePatchOperation( + "/spec/url", + "https://git-server.com/a-push-user/podinfo.git", + ), + operations.AddPatchOperation( + "/spec/secretRef", + fluxmeta.LocalObjectReference{Name: config.ZarfGitServerSecretName}, + ), + }, + code: http.StatusOK, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rr := sendAdmissionRequest(t, tt.admissionReq, handler) + verifyAdmission(t, rr, tt) + }) + } +} diff --git a/src/internal/agent/hooks/pods.go b/src/internal/agent/hooks/pods.go index cefb024df0..658c585b12 100644 --- a/src/internal/agent/hooks/pods.go +++ b/src/internal/agent/hooks/pods.go @@ -47,7 +47,7 @@ func mutatePod(ctx context.Context, r *v1.AdmissionRequest, cluster *cluster.Clu pod, err := parsePod(r.Object.Raw) if err != nil { - return &operations.Result{Msg: err.Error()}, nil + return nil, fmt.Errorf(lang.AgentErrParsePod, err) } if pod.Labels != nil && pod.Labels["zarf-agent"] == "patched" { diff --git a/src/internal/agent/hooks/pods_test.go b/src/internal/agent/hooks/pods_test.go index eb3480b415..8eca704f59 100644 --- a/src/internal/agent/hooks/pods_test.go +++ b/src/internal/agent/hooks/pods_test.go @@ -41,12 +41,7 @@ func TestPodMutationWebhook(t *testing.T) { c := createTestClientWithZarfState(ctx, t, state) handler := admission.NewHandler().Serve(NewPodMutationHook(ctx, c)) - tests := []struct { - name string - admissionReq *v1.AdmissionRequest - expectedPatch []operations.PatchOperation - code int - }{ + tests := []admissionTest{ { name: "pod with label should be mutated", admissionReq: createPodAdmissionRequest(t, v1.Create, &corev1.Pod{ @@ -65,7 +60,7 @@ func TestPodMutationWebhook(t *testing.T) { }, }, }), - expectedPatch: []operations.PatchOperation{ + patch: []operations.PatchOperation{ operations.ReplacePatchOperation( "/spec/imagePullSecrets", []corev1.LocalObjectReference{{Name: config.ZarfImagePullSecretName}}, @@ -99,8 +94,8 @@ func TestPodMutationWebhook(t *testing.T) { Containers: []corev1.Container{{Image: "nginx"}}, }, }), - expectedPatch: nil, - code: http.StatusOK, + patch: nil, + code: http.StatusOK, }, { name: "pod with no labels should not error", @@ -112,7 +107,7 @@ func TestPodMutationWebhook(t *testing.T) { Containers: []corev1.Container{{Image: "nginx"}}, }, }), - expectedPatch: []operations.PatchOperation{ + patch: []operations.PatchOperation{ operations.ReplacePatchOperation( "/spec/imagePullSecrets", []corev1.LocalObjectReference{{Name: config.ZarfImagePullSecretName}}, @@ -134,16 +129,8 @@ func TestPodMutationWebhook(t *testing.T) { 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)) - } + rr := sendAdmissionRequest(t, tt.admissionReq, handler) + verifyAdmission(t, rr, tt) }) } } diff --git a/src/internal/agent/hooks/utils_test.go b/src/internal/agent/hooks/utils_test.go index 909cd1f301..7cc1c912a6 100644 --- a/src/internal/agent/hooks/utils_test.go +++ b/src/internal/agent/hooks/utils_test.go @@ -11,6 +11,7 @@ import ( "net/http/httptest" "testing" + "github.com/defenseunicorns/zarf/src/internal/agent/operations" "github.com/defenseunicorns/zarf/src/pkg/cluster" "github.com/defenseunicorns/zarf/src/pkg/k8s" "github.com/defenseunicorns/zarf/src/types" @@ -21,6 +22,14 @@ import ( "k8s.io/client-go/kubernetes/fake" ) +type admissionTest struct { + name string + admissionReq *v1.AdmissionRequest + patch []operations.PatchOperation + code int + errContains string +} + func createTestClientWithZarfState(ctx context.Context, t *testing.T, state *types.ZarfState) *cluster.Cluster { t.Helper() c := &cluster.Cluster{K8s: &k8s.K8s{Clientset: fake.NewSimpleClientset()}} @@ -42,7 +51,7 @@ func createTestClientWithZarfState(ctx context.Context, t *testing.T, state *typ } // 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 { +func sendAdmissionRequest(t *testing.T, admissionReq *v1.AdmissionRequest, handler http.HandlerFunc) *httptest.ResponseRecorder { t.Helper() b, err := json.Marshal(&v1.AdmissionReview{ @@ -58,13 +67,32 @@ func sendAdmissionRequest(t *testing.T, admissionReq *v1.AdmissionRequest, handl rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) - require.Equal(t, code, rr.Code) + return rr +} + +func verifyAdmission(t *testing.T, rr *httptest.ResponseRecorder, expected admissionTest) { + t.Helper() + + require.Equal(t, expected.code, rr.Code) var admissionReview v1.AdmissionReview - if rr.Code == http.StatusOK { - err = json.NewDecoder(rr.Body).Decode(&admissionReview) - require.NoError(t, err) + + err := json.NewDecoder(rr.Body).Decode(&admissionReview) + + if expected.errContains != "" { + require.Contains(t, admissionReview.Response.Result.Message, expected.errContains) + return } - return admissionReview.Response + resp := admissionReview.Response + require.NoError(t, err) + if expected.patch == nil { + require.Empty(t, string(resp.Patch)) + } else { + expectedPatchJSON, err := json.Marshal(expected.patch) + require.NoError(t, err) + require.NotNil(t, resp) + require.True(t, resp.Allowed) + require.JSONEq(t, string(expectedPatchJSON), string(resp.Patch)) + } } diff --git a/src/internal/agent/http/admission/handler.go b/src/internal/agent/http/admission/handler.go index b4e3109af7..4839073038 100644 --- a/src/internal/agent/http/admission/handler.go +++ b/src/internal/agent/http/admission/handler.go @@ -15,8 +15,8 @@ import ( "github.com/defenseunicorns/zarf/src/config/lang" "github.com/defenseunicorns/zarf/src/internal/agent/operations" "github.com/defenseunicorns/zarf/src/pkg/message" - v1 "k8s.io/api/admission/v1" - meta "k8s.io/apimachinery/pkg/apis/meta/v1" + corev1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" ) @@ -56,7 +56,7 @@ func (h *Handler) Serve(hook operations.Hook) http.HandlerFunc { return } - var review v1.AdmissionReview + var review corev1.AdmissionReview if _, _, err := h.decoder.Decode(body, nil, &review); err != nil { http.Error(w, fmt.Sprintf(lang.AgentErrCouldNotDeserializeReq, err), http.StatusBadRequest) return @@ -68,27 +68,41 @@ func (h *Handler) Serve(hook operations.Hook) http.HandlerFunc { } result, err := hook.Execute(review.Request) + admissionMeta := metav1.TypeMeta{ + APIVersion: corev1.SchemeGroupVersion.String(), + Kind: "AdmissionReview", + } if err != nil { message.Warnf("%s: %s", lang.AgentErrBindHandler, err.Error()) + admissionResponse := corev1.AdmissionReview{ + TypeMeta: admissionMeta, + Response: &corev1.AdmissionResponse{ + Result: &metav1.Status{Message: err.Error(), Status: string(metav1.StatusReasonInternalError)}, + }, + } + jsonResponse, err := json.Marshal(admissionResponse) + if err != nil { + message.WarnErr(err, lang.AgentErrMarshalResponse) + http.Error(w, lang.AgentErrMarshalResponse, http.StatusInternalServerError) + return + } w.WriteHeader(http.StatusInternalServerError) + w.Write(jsonResponse) return } - admissionResponse := v1.AdmissionReview{ - TypeMeta: meta.TypeMeta{ - APIVersion: v1.SchemeGroupVersion.String(), - Kind: "AdmissionReview", - }, - Response: &v1.AdmissionResponse{ + admissionResponse := corev1.AdmissionReview{ + TypeMeta: admissionMeta, + Response: &corev1.AdmissionResponse{ UID: review.Request.UID, Allowed: result.Allowed, - Result: &meta.Status{Message: result.Msg}, + Result: &metav1.Status{Message: result.Msg}, }, } // Set the patch operations for mutating admission if len(result.PatchOps) > 0 { - jsonPatchType := v1.PatchTypeJSONPatch + jsonPatchType := corev1.PatchTypeJSONPatch patchBytes, err := json.Marshal(result.PatchOps) if err != nil { message.WarnErr(err, lang.AgentErrMarshallJSONPatch) diff --git a/src/internal/agent/http/server.go b/src/internal/agent/http/server.go index 7b07b1445d..2c56428fd7 100644 --- a/src/internal/agent/http/server.go +++ b/src/internal/agent/http/server.go @@ -30,7 +30,7 @@ func NewAdmissionServer(port string) *http.Server { // Instances hooks podsMutation := hooks.NewPodMutationHook(ctx, c) - fluxGitRepositoryMutation := hooks.NewGitRepositoryMutationHook() + fluxGitRepositoryMutation := hooks.NewGitRepositoryMutationHook(ctx, c) argocdApplicationMutation := hooks.NewApplicationMutationHook() argocdRepositoryMutation := hooks.NewRepositoryMutationHook()