diff --git a/packages/zarf-agent/chart/templates/webhook.yaml b/packages/zarf-agent/chart/templates/webhook.yaml index c2e1c908a1..5e7c7051d7 100644 --- a/packages/zarf-agent/chart/templates/webhook.yaml +++ b/packages/zarf-agent/chart/templates/webhook.yaml @@ -222,6 +222,48 @@ webhooks: - "v1" - "v1beta1" sideEffects: None + - name: agent-argocd-applicationset.zarf.dev + namespaceSelector: + matchExpressions: + # Ensure we don't mess with kube-system + - key: "kubernetes.io/metadata.name" + operator: NotIn + values: + - "kube-system" + # Allow ignoring whole namespaces + - key: zarf.dev/agent + operator: NotIn + values: + - "skip" + - "ignore" + objectSelector: + matchExpressions: + # Always ignore specific resources if requested by annotation/label + - key: zarf.dev/agent + operator: NotIn + values: + - "skip" + - "ignore" + clientConfig: + service: + name: {{ .Values.service.name }} + namespace: {{ .Release.Namespace }} + path: "/mutate/argocd-applicationset" + caBundle: "###ZARF_AGENT_CA###" + rules: + - operations: + - "CREATE" + - "UPDATE" + apiGroups: + - "argoproj.io" + apiVersions: + - "v1alpha1" + resources: + - "applicationsets" + admissionReviewVersions: + - "v1" + - "v1beta1" + sideEffects: None - name: agent-argocd-repository.zarf.dev namespaceSelector: matchExpressions: diff --git a/src/internal/agent/hooks/argocd-applicationset.go b/src/internal/agent/hooks/argocd-applicationset.go new file mode 100644 index 0000000000..c9d1705332 --- /dev/null +++ b/src/internal/agent/hooks/argocd-applicationset.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +// Package hooks contains the mutation hooks for the Zarf agent. +package hooks + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/zarf-dev/zarf/src/config/lang" + "github.com/zarf-dev/zarf/src/internal/agent/operations" + "github.com/zarf-dev/zarf/src/pkg/cluster" + "github.com/zarf-dev/zarf/src/pkg/logger" + v1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ApplicationSet is a definition of an ArgoCD ApplicationSet resource. +// The ArgoCD ApplicationSet structs in this file have been partially copied from upstream. +// +// https://github.com/argoproj/argo-cd/blob/v2.11.0/pkg/apis/application/v1alpha1/applicationset_types.go +// +// There were errors encountered when trying to import argocd as a Go package. +// +// For more information: https://argo-cd.readthedocs.io/en/stable/user-guide/import/ +type ApplicationSet struct { + Spec ApplicationSetSpec `json:"spec"` + metav1.ObjectMeta `json:"metadata,omitempty"` +} + +// ApplicationSetSpec represents a class of application set state. +type ApplicationSetSpec struct { + Generators []ApplicationSetGenerator `json:"generators,omitempty"` +} + +// ApplicationSetGenerator represents a generator at the top level of an ApplicationSet. +type ApplicationSetGenerator struct { + Git *GitGenerator `json:"git,omitempty"` +} + +// GitGenerator represents a class of git generator. +type GitGenerator struct { + RepoURL string `json:"repoURL"` +} + +// NewApplicationSetMutationHook creates a new instance of the ArgoCD ApplicationSet mutation hook. +func NewApplicationSetMutationHook(ctx context.Context, cluster *cluster.Cluster) operations.Hook { + return operations.Hook{ + Create: func(r *v1.AdmissionRequest) (*operations.Result, error) { + return mutateApplicationSet(ctx, r, cluster) + }, + Update: func(r *v1.AdmissionRequest) (*operations.Result, error) { + return mutateApplicationSet(ctx, r, cluster) + }, + } +} + +// mutateApplication mutates the git repository urls to point to the repository URL defined in the ZarfState. +func mutateApplicationSet(ctx context.Context, r *v1.AdmissionRequest, cluster *cluster.Cluster) (*operations.Result, error) { + l := logger.From(ctx) + s, err := cluster.LoadState(ctx) + if err != nil { + return nil, err + } + + appSet := ApplicationSet{} + if err = json.Unmarshal(r.Object.Raw, &appSet); err != nil { + return nil, fmt.Errorf(lang.ErrUnmarshal, err) + } + + l.Info("using the Zarf git server URL to mutate the ArgoCD ApplicationSet", + "name", appSet.Name, + "git-server", s.GitServer.Address) + + patches := make([]operations.PatchOperation, 0) + + for genIdx, generator := range appSet.Spec.Generators { + if generator.Git != nil && generator.Git.RepoURL != "" { + patchedURL, err := getPatchedRepoURL(ctx, generator.Git.RepoURL, s.GitServer, r) + if err != nil { + return nil, err + } + patches = append(patches, operations.ReplacePatchOperation(fmt.Sprintf("/spec/generators/%d/git/repoURL", genIdx), patchedURL)) + } + } + + patches = append(patches, getLabelPatch(appSet.Labels)) + + return &operations.Result{ + Allowed: true, + PatchOps: patches, + }, nil +} diff --git a/src/internal/agent/hooks/argocd-applicationset_test.go b/src/internal/agent/hooks/argocd-applicationset_test.go new file mode 100644 index 0000000000..080c2d2749 --- /dev/null +++ b/src/internal/agent/hooks/argocd-applicationset_test.go @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package hooks + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zarf-dev/zarf/src/internal/agent/http/admission" + "github.com/zarf-dev/zarf/src/internal/agent/operations" + "github.com/zarf-dev/zarf/src/pkg/state" + v1 "k8s.io/api/admission/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func createArgoAppSetAdmissionRequest(t *testing.T, op v1.Operation, argoAppSet *ApplicationSet) *v1.AdmissionRequest { + t.Helper() + raw, err := json.Marshal(argoAppSet) + require.NoError(t, err) + return &v1.AdmissionRequest{ + Operation: op, + Object: runtime.RawExtension{ + Raw: raw, + }, + } +} + +func TestArgoAppSetWebhook(t *testing.T) { + t.Parallel() + + ctx := context.Background() + s := &state.State{GitServer: state.GitServerInfo{ + Address: "https://git-server.com", + PushUsername: "a-push-user", + }} + c := createTestClientWithZarfState(ctx, t, s) + handler := admission.NewHandler().Serve(ctx, NewApplicationSetMutationHook(ctx, c)) + + tests := []admissionTest{ + { + name: "should mutate git generators and template sources", + admissionReq: createArgoAppSetAdmissionRequest(t, v1.Create, &ApplicationSet{ + Spec: ApplicationSetSpec{ + Generators: []ApplicationSetGenerator{ + { + Git: &GitGenerator{ + RepoURL: "https://diff-git-server.com/walnuts", + }, + }, + { + Git: &GitGenerator{ + RepoURL: "https://diff-git-server.com/pecans", + }, + }, + }, + }, + }), + patch: []operations.PatchOperation{ + operations.ReplacePatchOperation( + "/spec/generators/0/git/repoURL", + "https://git-server.com/a-push-user/walnuts-1104520479", + ), + operations.ReplacePatchOperation( + "/spec/generators/1/git/repoURL", + "https://git-server.com/a-push-user/pecans-1381863636", + ), + operations.ReplacePatchOperation( + "/metadata/labels", + map[string]string{ + "zarf-agent": "patched", + }, + ), + }, + code: http.StatusOK, + }, + { + name: "should return internal server error on bad git URL", + admissionReq: createArgoAppSetAdmissionRequest(t, v1.Create, &ApplicationSet{ + Spec: ApplicationSetSpec{ + Generators: []ApplicationSetGenerator{ + { + Git: &GitGenerator{ + RepoURL: "https://bad-url", + }, + }, + }, + }, + }), + code: http.StatusInternalServerError, + errContains: AgentErrTransformGitURL, + }, + } + + 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/start.go b/src/internal/agent/start.go index 16c1fe9e2d..e6b88d3f60 100644 --- a/src/internal/agent/start.go +++ b/src/internal/agent/start.go @@ -38,6 +38,7 @@ func StartWebhook(ctx context.Context, cluster *cluster.Cluster) error { podsMutation := hooks.NewPodMutationHook(ctx, cluster) fluxGitRepositoryMutation := hooks.NewGitRepositoryMutationHook(ctx, cluster) argocdApplicationMutation := hooks.NewApplicationMutationHook(ctx, cluster) + argocdApplicationSetMutation := hooks.NewApplicationSetMutationHook(ctx, cluster) argocdAppProjectMutation := hooks.NewAppProjectMutationHook(ctx, cluster) argocdRepositoryMutation := hooks.NewRepositorySecretMutationHook(ctx, cluster) fluxHelmRepositoryMutation := hooks.NewHelmRepositoryMutationHook(ctx, cluster) @@ -50,6 +51,7 @@ func StartWebhook(ctx context.Context, cluster *cluster.Cluster) error { mux.Handle("/mutate/flux-helmrepository", admissionHandler.Serve(ctx, fluxHelmRepositoryMutation)) mux.Handle("/mutate/flux-ocirepository", admissionHandler.Serve(ctx, fluxOCIRepositoryMutation)) mux.Handle("/mutate/argocd-application", admissionHandler.Serve(ctx, argocdApplicationMutation)) + mux.Handle("/mutate/argocd-applicationset", admissionHandler.Serve(ctx, argocdApplicationSetMutation)) mux.Handle("/mutate/argocd-appproject", admissionHandler.Serve(ctx, argocdAppProjectMutation)) mux.Handle("/mutate/argocd-repository", admissionHandler.Serve(ctx, argocdRepositoryMutation))