diff --git a/.mockery.yaml b/.mockery.yaml index b3b375c5..4ca7eead 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -5,3 +5,11 @@ packages: # place your package-specific config here config: all: true + github.com/zapier/kubechecks/pkg/generator: + # place your package-specific config here + config: + all: true + github.com/zapier/kubechecks/pkg/affected_apps: + # place your package-specific config here + config: + all: true diff --git a/Tiltfile b/Tiltfile index 90161660..fa209f09 100644 --- a/Tiltfile +++ b/Tiltfile @@ -5,6 +5,7 @@ load('ext://tests/golang', 'test_go') load('ext://namespace', 'namespace_create') load('ext://uibutton', 'cmd_button') load('ext://helm_resource', 'helm_resource') +load('ext://local_output', 'local_output') load('./.tilt/terraform/Tiltfile', 'local_terraform_resource') load('./.tilt/utils/Tiltfile', 'check_env_set') @@ -144,10 +145,6 @@ test_go( ) -# get the git commit ref -def get_git_head(): - result = local('git rev-parse --short HEAD') - return result # read .tool-versions file and return a dictionary of tools and their versions def parse_tool_versions(fn): @@ -174,7 +171,9 @@ def parse_tool_versions(fn): return tools tool_versions = parse_tool_versions(".tool-versions") -git_commit = str(get_git_head()).strip() + +# get the git commit ref +git_commit = local_output('git rev-parse --short HEAD') earthly_build( context='.', @@ -260,8 +259,8 @@ k8s_resource( load("localdev/test_apps/Tiltfile", "install_test_apps") install_test_apps(cfg) -load("localdev/test_appsets/Tiltfile", "install_test_appsets") -install_test_appsets(cfg) +load("localdev/test_appsets/Tiltfile", "copy_test_appsets") +copy_test_appsets(cfg) force_argocd_cleanup_on_tilt_down() diff --git a/charts/kubechecks-rbac/Chart.yaml b/charts/kubechecks-rbac/Chart.yaml new file mode 100644 index 00000000..42299c10 --- /dev/null +++ b/charts/kubechecks-rbac/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v2 +name: kubechecks-rbac +description: A Helm chart for kubechecks Role and RoleBinding +version: 0.4.5 +type: application +maintainers: + - name: zapier diff --git a/charts/kubechecks-rbac/README.md b/charts/kubechecks-rbac/README.md new file mode 100644 index 00000000..eb054738 --- /dev/null +++ b/charts/kubechecks-rbac/README.md @@ -0,0 +1,6 @@ +# kubechecks-rbac + +This chart deploys the Cluster Role and Cluster Role binding for the kubechecks running outside of existing cluster. + +It is not required if you're operating all within the same cluster. + diff --git a/charts/kubechecks-rbac/templates/role.yaml b/charts/kubechecks-rbac/templates/role.yaml new file mode 100644 index 00000000..754e68b7 --- /dev/null +++ b/charts/kubechecks-rbac/templates/role.yaml @@ -0,0 +1,11 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ .Values.clusterRoleName | default "kubechecks-remote-clusterrole" }} +rules: + - apiGroups: ['argoproj.io'] + resources: ['applications', 'appprojects', 'applicationsets', 'services'] + verbs: ['get', 'list', 'watch'] + - apiGroups: [''] # The core API group, which is indicated by an empty string + resources: ['secrets'] + verbs: ['get', 'list', 'watch'] \ No newline at end of file diff --git a/charts/kubechecks-rbac/templates/rolebinding.yaml b/charts/kubechecks-rbac/templates/rolebinding.yaml new file mode 100644 index 00000000..0778ff35 --- /dev/null +++ b/charts/kubechecks-rbac/templates/rolebinding.yaml @@ -0,0 +1,13 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ .Values.clusterRoleBindingName | default "kubechecks-remote-role-binding" }} + namespace: {{ .Values.namespace | default "argocd" }} +subjects: + - kind: Group + apiGroup: rbac.authorization.k8s.io + name: {{ .Values.clusterRoleBindingGroup | default "kubechecks-remote-group" }} +roleRef: + kind: ClusterRole + name: {{ .Values.clusterRoleName | default "kubechecks-remote-role" }} + apiGroup: rbac.authorization.k8s.io diff --git a/charts/kubechecks-rbac/tests/role_test.yaml b/charts/kubechecks-rbac/tests/role_test.yaml new file mode 100644 index 00000000..9a0c873d --- /dev/null +++ b/charts/kubechecks-rbac/tests/role_test.yaml @@ -0,0 +1,15 @@ +suite: role tests + +templates: + - role.yaml + +tests: + - it: should create a Role with the correct name + set: + clusterRoleName: "kubechecks-test-role" + asserts: + - isKind: + of: ClusterRole + - equal: + path: metadata.name + value: kubechecks-test-role diff --git a/charts/kubechecks-rbac/tests/rolebinding_test.yaml b/charts/kubechecks-rbac/tests/rolebinding_test.yaml new file mode 100644 index 00000000..53065bc5 --- /dev/null +++ b/charts/kubechecks-rbac/tests/rolebinding_test.yaml @@ -0,0 +1,16 @@ +suite: role binding tests + +templates: + - rolebinding.yaml + +tests: + - it: should create a RoleBinding with the correct name with EKS IAM role + set: + clusterRoleBindingName: "kubechecks-test-rolebinding-rbac" + clusterRoleBindingGroup: "kubechecks-remote-group" + asserts: + - isKind: + of: ClusterRoleBinding + - equal: + path: metadata.name + value: kubechecks-test-rolebinding-rbac diff --git a/charts/kubechecks-rbac/values.schema.json b/charts/kubechecks-rbac/values.schema.json new file mode 100644 index 00000000..43c0debe --- /dev/null +++ b/charts/kubechecks-rbac/values.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Kubechecks Values Schema", + "type": "object", + "properties": { + "clusterRoleName": { + "type": "string", + "description": "The name of the Cluster Role to be created.", + "default": "kubechecks-remote-role" + }, + "clusterRoleBindingName": { + "type": "string", + "description": "The name of the ClusterRoleBinding to be created.", + "default": "kubechecks-remote-role-binding" + }, + "clusterRoleBindingGroup": { + "type": "string", + "description": "The name of the Group to be created.", + "default": "kubechecks-remote-group" + }, + "namespace": { + "type": "string", + "description": "The namespace where the Role and RoleBinding will be created.", + "default": "argocd" + } + }, + "required": ["clusterRoleName", "clusterRoleBindingName", "clusterRoleBindingGroup", "namespace"], + "additionalProperties": false +} diff --git a/charts/kubechecks-rbac/values.yaml b/charts/kubechecks-rbac/values.yaml new file mode 100644 index 00000000..2dc123c9 --- /dev/null +++ b/charts/kubechecks-rbac/values.yaml @@ -0,0 +1,6 @@ +clusterRoleName: "kubechecks-remote-role" +clusterRoleBindingName: "kubechecks-remote-role-binding" +clusterRoleBindingGroup: "kubechecks-remote-group" + +# namespace to create the ClusterRole and RoleBinding, this has to match the argocd is operating. +namespace: "argocd" diff --git a/charts/kubechecks/Chart.yaml b/charts/kubechecks/Chart.yaml index 47779854..e7466ea6 100644 --- a/charts/kubechecks/Chart.yaml +++ b/charts/kubechecks/Chart.yaml @@ -1,7 +1,7 @@ apiVersion: v2 name: kubechecks description: A Helm chart for kubechecks -version: 0.4.4 +version: 0.4.5 type: application maintainers: - name: zapier diff --git a/charts/kubechecks/templates/clusterrole.yaml b/charts/kubechecks/templates/clusterrole.yaml index c08105bf..fb68af8f 100644 --- a/charts/kubechecks/templates/clusterrole.yaml +++ b/charts/kubechecks/templates/clusterrole.yaml @@ -4,5 +4,8 @@ metadata: name: {{ include "kubechecks.fullname" . }} rules: - apiGroups: ['argoproj.io'] - resources: ['applications', 'appprojects', 'services'] + resources: ['applications', 'appprojects', 'applicationsets', 'services'] + verbs: ['get', 'list', 'watch'] + - apiGroups: [''] # The core API group, which is indicated by an empty string + resources: ['secrets'] verbs: ['get', 'list', 'watch'] diff --git a/charts/kubechecks/values.yaml b/charts/kubechecks/values.yaml index c2eedd46..9e996252 100644 --- a/charts/kubechecks/values.yaml +++ b/charts/kubechecks/values.yaml @@ -6,6 +6,7 @@ configMap: env: {} # KUBECHECKS_ARGOCD_API_INSECURE: "false" # KUBECHECKS_ARGOCD_API_PATH_PREFIX: / + # KUBECHECKS_ARGOCD_API_NAMESPACE: argocd # KUBECHECKS_ARGOCD_WEBHOOK_URL: https://argocd./api/webhook # KUBECHECKS_FALLBACK_K8S_VERSION: "1.22.0" # KUBECHECKS_LOG_LEVEL: debug diff --git a/cmd/container.go b/cmd/container.go index 235d9654..c447838a 100644 --- a/cmd/container.go +++ b/cmd/container.go @@ -12,6 +12,7 @@ import ( "github.com/zapier/kubechecks/pkg/config" "github.com/zapier/kubechecks/pkg/container" "github.com/zapier/kubechecks/pkg/git" + client "github.com/zapier/kubechecks/pkg/kubernetes" "github.com/zapier/kubechecks/pkg/vcs/github_client" "github.com/zapier/kubechecks/pkg/vcs/gitlab_client" ) @@ -36,7 +37,30 @@ func newContainer(ctx context.Context, cfg config.ServerConfig, watchApps bool) if err != nil { return ctr, errors.Wrap(err, "failed to create vcs client") } + var kubeClient client.Interface + switch cfg.KubernetesType { + // TODO: expand with other cluster types + case client.ClusterTypeLOCAL: + kubeClient, err = client.New(&client.NewClientInput{ + KubernetesConfigPath: cfg.KubernetesConfig, + ClusterType: cfg.KubernetesType, + }) + if err != nil { + return ctr, errors.Wrap(err, "failed to create kube client") + } + case client.ClusterTypeEKS: + kubeClient, err = client.New(&client.NewClientInput{ + KubernetesConfigPath: cfg.KubernetesConfig, + ClusterType: cfg.KubernetesType, + }, + client.EKSClientOption(ctx, cfg.KubernetesClusterID), + ) + if err != nil { + return ctr, errors.Wrap(err, "failed to create kube client") + } + } + ctr.KubeClientSet = kubeClient // create argo client if ctr.ArgoClient, err = argo_client.NewArgoClient(cfg); err != nil { return ctr, errors.Wrap(err, "failed to create argo client") @@ -52,13 +76,22 @@ func newContainer(ctx context.Context, cfg config.ServerConfig, watchApps bool) return ctr, errors.Wrap(err, "failed to build apps map") } + if err = buildAppSetsMap(ctx, ctr.ArgoClient, ctr.VcsToArgoMap); err != nil { + return ctr, errors.Wrap(err, "failed to build appsets map") + } + if watchApps { - ctr.ApplicationWatcher, err = app_watcher.NewApplicationWatcher(vcsToArgoMap, cfg) + ctr.ApplicationWatcher, err = app_watcher.NewApplicationWatcher(kubeClient.Config(), vcsToArgoMap, cfg) if err != nil { return ctr, errors.Wrap(err, "failed to create watch applications") } + ctr.ApplicationSetWatcher, err = app_watcher.NewApplicationSetWatcher(kubeClient.Config(), vcsToArgoMap, cfg) + if err != nil { + return ctr, errors.Wrap(err, "failed to create watch application sets") + } go ctr.ApplicationWatcher.Run(ctx, 1) + go ctr.ApplicationSetWatcher.Run(ctx) } } else { log.Info().Msgf("not monitoring applications, MonitorAllApplications: %+v", cfg.MonitorAllApplications) @@ -75,6 +108,16 @@ func buildAppsMap(ctx context.Context, argoClient *argo_client.ArgoClient, resul for _, app := range apps.Items { result.AddApp(&app) } + return nil +} +func buildAppSetsMap(ctx context.Context, argoClient *argo_client.ArgoClient, result container.VcsToArgoMap) error { + appSets, err := argoClient.GetApplicationSets(ctx) + if err != nil { + return errors.Wrap(err, "failed to list application sets") + } + for _, appSet := range appSets.Items { + result.AddAppSet(&appSet) + } return nil } diff --git a/cmd/root.go b/cmd/root.go index 12b94ae5..7df615a2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -63,6 +63,11 @@ func init() { stringFlag(flags, "argocd-api-namespace", "ArgoCD namespace where the application watcher will read Custom Resource Definitions (CRD) for Application and ApplicationSet resources.", newStringOpts(). withDefault("argocd")) + stringFlag(flags, "kubernetes-type", "Kubernetes Type One of eks, or local. Defaults to local.", + newStringOpts(). + withChoices("eks", "local"). + withDefault("local")) + stringFlag(flags, "kubernetes-clusterid", "Kubernetes Cluster ID, must be specified if kubernetes-type is eks.") stringFlag(flags, "kubernetes-config", "Path to your kubernetes config file, used to monitor applications.") stringFlag(flags, "otel-collector-port", "The OpenTelemetry collector port.") @@ -73,10 +78,7 @@ func init() { newStringOpts(). withChoices("hide", "delete"). withDefault("hide")) - stringSliceFlag(flags, "schemas-location", "Sets schema locations to be used for every check request. Can be common paths inside the repos being checked or git urls in either git or http(s) format.", - newStringSliceOpts(). - withDefault([]string{"./schemas"})) - + stringSliceFlag(flags, "schemas-location", "Sets schema locations to be used for every check request. Can be common paths inside the repos being checked or git urls in either git or http(s) format.") boolFlag(flags, "enable-conftest", "Set to true to enable conftest policy checking of manifests.") stringSliceFlag(flags, "policies-location", "Sets rego policy locations to be used for every check request. Can be common path inside the repos being checked or git urls in either git or http(s) format.", newStringSliceOpts(). diff --git a/docs/architecture.md b/docs/architecture.md index c41252ce..0852c073 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,3 +36,10 @@ By abstracting the PR/MR in this way, `kubechecks` remains VCS provider agnostic ![Check Event and Repo type diagrams](./img/checkevent.png){: style="height:350px;display:block;margin:0 auto;"} The final piece of the puzzle is the `CheckEvent`; an internal structure that takes a `Client` and a `Repo` and begins running all configured checks. A `CheckEvent` first determines what applications within the repository have been affected by the PR/MR, and begins concurrently running the check suite against each affected application to generate a report for that app. As each application updates its report, the `CheckEvent` compiles all reports together and instructs the `Client` to update the PR/MR with a comment detailing the current progress; resulting in one comment per run of `kubechecks` with the latest information about that particular run. Whenever a new run of `kubechecks` is initiated, all previous comments are deleted to reduce clutter. + +### Event Flow Diagram + +![Event Flow Diagram](./img/eventflowdiagram.png){: style="height:350px;display:block;margin:0 auto;"} + +This diagram illustrates the flow of events from the initial webhook trigger to the final report generation and comment update process. + diff --git a/docs/img/eventflowdiagram.png b/docs/img/eventflowdiagram.png new file mode 100644 index 00000000..85974e0c Binary files /dev/null and b/docs/img/eventflowdiagram.png differ diff --git a/docs/usage.md b/docs/usage.md index b58d5e1f..67fe107a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -46,7 +46,9 @@ The full list of supported environment variables is described below: |`KUBECHECKS_ENABLE_PREUPGRADE`|Enable preupgrade checks.|`true`| |`KUBECHECKS_ENSURE_WEBHOOKS`|Ensure that webhooks are created in repositories referenced by argo.|`false`| |`KUBECHECKS_FALLBACK_K8S_VERSION`|Fallback target Kubernetes version for schema / upgrade checks.|`1.23.0`| +|`KUBECHECKS_KUBERNETES_CLUSTERID`|Kubernetes Cluster ID, must be specified if kubernetes-type is eks.|| |`KUBECHECKS_KUBERNETES_CONFIG`|Path to your kubernetes config file, used to monitor applications.|| +|`KUBECHECKS_KUBERNETES_TYPE`|Kubernetes Type One of eks, or local.|`local`| |`KUBECHECKS_LABEL_FILTER`|(Optional) If set, The label that must be set on an MR (as "kubechecks:") for kubechecks to process the merge request webhook.|| |`KUBECHECKS_LOG_LEVEL`|Set the log output level. One of error, warn, info, debug, trace.|`info`| |`KUBECHECKS_MAX_CONCURRENCT_CHECKS`|Number of concurrent checks to run.|`32`| @@ -59,7 +61,7 @@ The full list of supported environment variables is described below: |`KUBECHECKS_PERSIST_LOG_LEVEL`|Persists the set log level down to other module loggers.|`false`| |`KUBECHECKS_POLICIES_LOCATION`|Sets rego policy locations to be used for every check request. Can be common path inside the repos being checked or git urls in either git or http(s) format.|`[./policies]`| |`KUBECHECKS_REPO_REFRESH_INTERVAL`|Interval between static repo refreshes (for schemas and policies).|`5m`| -|`KUBECHECKS_SCHEMAS_LOCATION`|Sets schema locations to be used for every check request. Can be common paths inside the repos being checked or git urls in either git or http(s) format.|`[./schemas]`| +|`KUBECHECKS_SCHEMAS_LOCATION`|Sets schema locations to be used for every check request. Can be common paths inside the repos being checked or git urls in either git or http(s) format.|`[]`| |`KUBECHECKS_SHOW_DEBUG_INFO`|Set to true to print debug info to the footer of MR comments.|`false`| |`KUBECHECKS_TIDY_OUTDATED_COMMENTS_MODE`|Sets the mode to use when tidying outdated comments. One of hide, delete.|`hide`| |`KUBECHECKS_VCS_BASE_URL`|VCS base url, useful if self hosting gitlab, enterprise github, etc.|| diff --git a/go.mod b/go.mod index 855040bb..edfc29ca 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,11 @@ toolchain go1.21.6 require ( github.com/argoproj/argo-cd/v2 v2.11.6 github.com/argoproj/gitops-engine v0.7.1-0.20240715141605-18ba62e1f1fb + github.com/aws/aws-sdk-go-v2 v1.30.1 + github.com/aws/aws-sdk-go-v2/config v1.27.24 + github.com/aws/aws-sdk-go-v2/service/eks v1.46.0 + github.com/aws/aws-sdk-go-v2/service/sts v1.30.1 + github.com/aws/smithy-go v1.20.3 github.com/cenkalti/backoff/v4 v4.3.0 github.com/chainguard-dev/git-urls v1.0.2 github.com/creasty/defaults v1.7.0 @@ -16,6 +21,8 @@ require ( github.com/go-logr/zerologr v1.2.3 github.com/google/go-github/v62 v62.0.0 github.com/heptiolabs/healthcheck v0.0.0-20211123025425-613501dd5deb + github.com/imdario/mergo v0.3.16 + github.com/jeremywohl/flatten v1.0.1 github.com/labstack/echo-contrib v0.17.1 github.com/labstack/echo/v4 v4.12.0 github.com/masterminds/semver v1.5.0 @@ -50,8 +57,12 @@ require ( google.golang.org/grpc v1.64.0 gopkg.in/dealancer/validate.v2 v2.1.0 gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.26.15 + k8s.io/apiextensions-apiserver v0.26.10 k8s.io/apimachinery v0.26.15 k8s.io/client-go v0.26.15 + sigs.k8s.io/controller-runtime v0.14.7 + sigs.k8s.io/yaml v1.4.0 ) require ( @@ -66,8 +77,10 @@ require ( github.com/CycloneDX/cyclonedx-go v0.8.0 // indirect github.com/KeisukeYamashita/go-vcl v0.4.0 // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.2.1 // indirect + github.com/Masterminds/sprig/v3 v3.2.3 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect github.com/OneOfOne/xxhash v1.2.8 // indirect github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect @@ -78,6 +91,15 @@ require ( github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/argoproj/pkg v0.13.7-0.20230627120311-a4dd357b057e // indirect github.com/aws/aws-sdk-go v1.50.8 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.24 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.15 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.22.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.2 // indirect github.com/basgys/goxml2json v1.1.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect @@ -103,6 +125,7 @@ require ( github.com/emicklei/go-restful/v3 v3.10.2 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/evanphx/json-patch v5.9.0+incompatible // indirect + github.com/evanphx/json-patch/v5 v5.6.0 // indirect github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect github.com/fatih/camelcase v1.0.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -140,6 +163,8 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/gax-go/v2 v2.12.2 // indirect github.com/gorilla/mux v1.8.1 // indirect + github.com/gosimple/slug v1.13.1 // indirect + github.com/gosimple/unidecode v1.0.1 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect @@ -151,7 +176,7 @@ require ( github.com/hashicorp/go-version v1.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl/v2 v2.17.0 // indirect - github.com/imdario/mergo v0.3.16 // indirect + github.com/huandu/xstrings v1.3.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/itchyny/gojq v0.12.13 // indirect github.com/itchyny/timefmt-go v0.1.5 // indirect @@ -175,9 +200,11 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mitchellh/copystructure v1.0.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/reflectwalk v1.0.0 // indirect github.com/moby/buildkit v0.12.5 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/spdystream v0.2.0 // indirect @@ -208,6 +235,7 @@ require ( github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect github.com/sergi/go-diff v1.3.1 // indirect + github.com/shopspring/decimal v1.2.0 // indirect github.com/shteou/go-ignore v0.3.1 // indirect github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect github.com/skeema/knownhosts v1.2.2 // indirect @@ -260,8 +288,6 @@ require ( gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - k8s.io/api v0.26.15 // indirect - k8s.io/apiextensions-apiserver v0.26.10 // indirect k8s.io/apiserver v0.26.15 // indirect k8s.io/cli-runtime v0.26.15 // indirect k8s.io/component-base v0.26.15 // indirect @@ -276,12 +302,10 @@ require ( muzzammil.xyz/jsonc v1.0.0 // indirect olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect oras.land/oras-go/v2 v2.3.1 // indirect - sigs.k8s.io/controller-runtime v0.14.7 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/kustomize/api v0.12.1 // indirect sigs.k8s.io/kustomize/kyaml v0.13.9 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect ) replace ( diff --git a/go.sum b/go.sum index 12a816ab..9ca6c167 100644 --- a/go.sum +++ b/go.sum @@ -202,10 +202,15 @@ github.com/KeisukeYamashita/go-vcl v0.4.0 h1:dFxZq2yVeaCWBJAT7Oh9Z+Pp8y32i7b11QH github.com/KeisukeYamashita/go-vcl v0.4.0/go.mod h1:af2qGlXbsHDQN5abN7hyGNKtGhcFSaDdbLl4sfud+AU= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= +github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= @@ -256,6 +261,34 @@ github.com/aws/aws-sdk-go v1.44.122/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX github.com/aws/aws-sdk-go v1.44.290/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= github.com/aws/aws-sdk-go v1.50.8 h1:gY0WoOW+/Wz6XmYSgDH9ge3wnAevYDSQWPxxJvqAkP4= github.com/aws/aws-sdk-go v1.50.8/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= +github.com/aws/aws-sdk-go-v2 v1.30.1 h1:4y/5Dvfrhd1MxRDD77SrfsDaj8kUkkljU7XE83NPV+o= +github.com/aws/aws-sdk-go-v2 v1.30.1/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc= +github.com/aws/aws-sdk-go-v2/config v1.27.24 h1:NM9XicZ5o1CBU/MZaHwFtimRpWx9ohAUAqkG6AqSqPo= +github.com/aws/aws-sdk-go-v2/config v1.27.24/go.mod h1:aXzi6QJTuQRVVusAO8/NxpdTeTyr/wRcybdDtfUwJSs= +github.com/aws/aws-sdk-go-v2/credentials v1.17.24 h1:YclAsrnb1/GTQNt2nzv+756Iw4mF8AOzcDfweWwwm/M= +github.com/aws/aws-sdk-go-v2/credentials v1.17.24/go.mod h1:Hld7tmnAkoBQdTMNYZGzztzKRdA4fCdn9L83LOoigac= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.9 h1:Aznqksmd6Rfv2HQN9cpqIV/lQRMaIpJkLLaJ1ZI76no= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.9/go.mod h1:WQr3MY7AxGNxaqAtsDWn+fBxmd4XvLkzeqQ8P1VM0/w= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.13 h1:5SAoZ4jYpGH4721ZNoS1znQrhOfZinOhc4XuTXx/nVc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.13/go.mod h1:+rdA6ZLpaSeM7tSg/B0IEDinCIBJGmW8rKDFkYpP04g= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.13 h1:WIijqeaAO7TYFLbhsZmi2rgLEAtWOC1LhxCAVTJlSKw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.13/go.mod h1:i+kbfa76PQbWw/ULoWnp51EYVWH4ENln76fLQE3lXT8= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= +github.com/aws/aws-sdk-go-v2/service/eks v1.46.0 h1:ZPhHHZtAjVohIGIVjXECPfljcPOQ+hjZ1IpgvjPTJ50= +github.com/aws/aws-sdk-go-v2/service/eks v1.46.0/go.mod h1:p4Yk0zfWEoLvvQ4V6XZrTmAAPzcevNnEsbUR82NAY0w= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 h1:dT3MqvGhSoaIhRseqw2I0yH81l7wiR2vjs57O51EAm8= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.15 h1:I9zMeF107l0rJrpnHpjEiiTSCKYAIw8mALiXcPsGBiA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.15/go.mod h1:9xWJ3Q/S6Ojusz1UIkfycgD1mGirJfLLKqq3LPT7WN8= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.1 h1:p1GahKIjyMDZtiKoIn0/jAj/TkMzfzndDv5+zi2Mhgc= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.1/go.mod h1:/vWdhoIoYA5hYoPZ6fm7Sv4d8701PiG5VKe8/pPJL60= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.2 h1:ORnrOK0C4WmYV/uYt3koHEWBLYsRDwk2Np+eEoyV4Z0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.2/go.mod h1:xyFHA4zGxgYkdD73VeezHt3vSKEG9EmFnGwoKlP00u4= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.1 h1:+woJ607dllHJQtsnJLi52ycuqHMwlW+Wqm2Ppsfp4nQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.1/go.mod h1:jiNR3JqT15Dm+QWq2SRgh0x0bCNSRP2L25+CqPNpJlQ= +github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE= +github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/basgys/goxml2json v1.1.0 h1:4ln5i4rseYfXNd86lGEB+Vi652IsIXIvggKM/BhUKVw= github.com/basgys/goxml2json v1.1.0/go.mod h1:wH7a5Np/Q4QoECFIU8zTQlZwZkrilY0itPfecMw41Dw= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= @@ -449,6 +482,8 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.2.3 h1:a9vnzlIBPQBBkeaR9IuMUfmVOrQlkoC4YfPoFkX3T7A= +github.com/go-logr/zapr v1.2.3/go.mod h1:eIauM6P8qSvTw5o2ez6UEAfGjQKrxQTl5EoK+Qa2oG4= github.com/go-logr/zerologr v1.2.3 h1:up5N9vcH9Xck3jJkXzgyOxozT14R47IyDODz8LM1KSs= github.com/go-logr/zerologr v1.2.3/go.mod h1:BxwGo7y5zgSHYR1BjbnHPyF/5ZjVKfKxAZANVu6E8Ho= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= @@ -590,6 +625,7 @@ github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -614,6 +650,10 @@ github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gosimple/slug v1.13.1 h1:bQ+kpX9Qa6tHRaK+fZR0A0M2Kd7Pa5eHPPsb1JpHD+Q= +github.com/gosimple/slug v1.13.1/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= +github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= +github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= @@ -646,9 +686,12 @@ github.com/hashicorp/hcl/v2 v2.17.0/go.mod h1:gJyW2PTShkJqQBKpAmPO3yxMxIuoXkOF2T github.com/heptiolabs/healthcheck v0.0.0-20211123025425-613501dd5deb h1:tsEKRC3PU9rMw18w/uAptoijhgG4EvlA5kfJPtwrMDk= github.com/heptiolabs/healthcheck v0.0.0-20211123025425-613501dd5deb/go.mod h1:NtmN9h8vrTveVQRLHcX2HQ5wIPBDCsZ351TGbZWgg38= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4= +github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= @@ -660,6 +703,9 @@ github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jeremywohl/flatten v1.0.1 h1:LrsxmB3hfwJuE+ptGOijix1PIfOoKLJ3Uee/mzbgtrs= +github.com/jeremywohl/flatten v1.0.1/go.mod h1:4AmD/VxjWcI5SRB0n6szE2A6s2fsNHDLO0nAlMHgfLQ= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= @@ -749,6 +795,8 @@ github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0 github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.0.58/go.mod h1:NUDy4A4oXPq1l2yK6LTSvCEzAMeIcoz9lcj5dbzSrRE= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= @@ -759,6 +807,8 @@ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTS github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/buildkit v0.12.5 h1:RNHH1l3HDhYyZafr5EgstEu8aGNCwyfvMtrQDtjH9T0= github.com/moby/buildkit v0.12.5/go.mod h1:YGwjA2loqyiYfZeEo8FtI7z4x5XponAaIWsWcSjWwso= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -886,6 +936,8 @@ github.com/sashabaranov/go-openai v1.27.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adO github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shteou/go-ignore v0.3.1 h1:/DVY4w06eKliWrbkwKfBHJgUleld+QAlmlQvfRQOigA= github.com/shteou/go-ignore v0.3.1/go.mod h1:hMVyBe+qt5/Z11W/Fxxf86b5SuL8kM29xNWLYob9Vos= github.com/shurcooL/githubv4 v0.0.0-20231126234147-1cffa1f02456 h1:6dExqsYngGEiixqa1vmtlUd+zbyISilg0Cf3GWVdeYM= @@ -911,6 +963,7 @@ github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTd github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= @@ -1052,6 +1105,8 @@ go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9i go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= +go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -1062,6 +1117,7 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220517005047-85d78b3ac167/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= +golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= diff --git a/localdev/.gitignore b/localdev/.gitignore index 38a0e667..86939447 100644 --- a/localdev/.gitignore +++ b/localdev/.gitignore @@ -1,4 +1,6 @@ terraform.tfstate* .terraform.lock.hcl .terraform/** -.terraform.tfstate.lock.info \ No newline at end of file +.terraform.tfstate.lock.info +/terraform/modules/vcs_files/base_files/appsets/httpdump/httpdump.yaml +/terraform/modules/vcs_files/base_files/appsets/echo-server/echo-server.yaml diff --git a/localdev/argocd/kustomization.yaml b/localdev/argocd/kustomization.yaml index 79d08b47..3b36f495 100644 --- a/localdev/argocd/kustomization.yaml +++ b/localdev/argocd/kustomization.yaml @@ -6,7 +6,7 @@ namespace: kubechecks images: - name: quay.io/argoproj/argocd newName: quay.io/argoproj/argocd - newTag: v2.6.12 + newTag: v2.11.3 resources: - argocd-initial-admin-secret.yaml diff --git a/localdev/terraform/modules/vcs_files/base_files/apps/app-set-echo-server/in-cluster/Chart.yaml b/localdev/terraform/modules/vcs_files/base_files/apps/app-set-echo-server/in-cluster/Chart.yaml new file mode 100644 index 00000000..71edd9f0 --- /dev/null +++ b/localdev/terraform/modules/vcs_files/base_files/apps/app-set-echo-server/in-cluster/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v2 +version: 1.0.0 +name: echo-server +dependencies: + - name: echo-server + version: 0.5.0 + repository: https://ealenn.github.io/charts \ No newline at end of file diff --git a/localdev/terraform/modules/vcs_files/base_files/apps/app-set-echo-server/in-cluster/values.yaml b/localdev/terraform/modules/vcs_files/base_files/apps/app-set-echo-server/in-cluster/values.yaml new file mode 100644 index 00000000..69e19fe9 --- /dev/null +++ b/localdev/terraform/modules/vcs_files/base_files/apps/app-set-echo-server/in-cluster/values.yaml @@ -0,0 +1,2 @@ +echo-server: + replicaCount: 1 \ No newline at end of file diff --git a/localdev/terraform/modules/vcs_files/base_files/apps/echo-server/in-cluster/values.yaml b/localdev/terraform/modules/vcs_files/base_files/apps/echo-server/in-cluster/values.yaml index 0329687a..69e19fe9 100644 --- a/localdev/terraform/modules/vcs_files/base_files/apps/echo-server/in-cluster/values.yaml +++ b/localdev/terraform/modules/vcs_files/base_files/apps/echo-server/in-cluster/values.yaml @@ -1,2 +1,2 @@ echo-server: - replicaCount: 2 \ No newline at end of file + replicaCount: 1 \ No newline at end of file diff --git a/localdev/test_apps/Tiltfile b/localdev/test_apps/Tiltfile index 20626bcd..3a095362 100644 --- a/localdev/test_apps/Tiltfile +++ b/localdev/test_apps/Tiltfile @@ -13,11 +13,12 @@ def install_test_apps(cfg): projectUrl=str(read_file(vcsPath, "")).strip('\n') print("Remote Project URL: " + projectUrl) - for app in ["echo-server", "httpbin"]: + for app in ["echo-server", "httpbin", "app-root"]: print("Creating Test App: " + app) # read the application YAML and patch the repoURL objects = read_yaml_stream("localdev/test_apps/{}.yaml".format(app)) + for o in objects: o['metadata']['namespace'] = "kubechecks" o['spec']['source']['repoURL'] = projectUrl diff --git a/localdev/test_apps/app-root.yaml b/localdev/test_apps/app-root.yaml new file mode 100644 index 00000000..95c01619 --- /dev/null +++ b/localdev/test_apps/app-root.yaml @@ -0,0 +1,29 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: in-cluster-app-root + namespace: kubechecks + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + destination: + name: '' + namespace: approot + server: https://kubernetes.default.svc + source: + directory: + recurse: true + path: appsets/ + repoURL: ${REPO_URL} + targetRevision: HEAD + + sources: [] + project: default + syncPolicy: + automated: + prune: true + selfHeal: false + syncOptions: + - CreateNamespace=true + - ServerSideApply=true + - CreateProjects=true diff --git a/localdev/test_appsets/Tiltfile b/localdev/test_appsets/Tiltfile index f108dac2..b0f555c2 100644 --- a/localdev/test_appsets/Tiltfile +++ b/localdev/test_appsets/Tiltfile @@ -3,28 +3,20 @@ # Test ArgoCD Applications # ///////////////////////////////////////////////////////////////////////////// -def install_test_appsets(cfg): +def copy_test_appsets(cfg): # Load the terraform url we output, default to gitlab if cant find a vcs-type variable vcsPath = "./localdev/terraform/{}/project.url".format(cfg.get('vcs-type', 'gitlab')) print("Path to url: " + vcsPath) projectUrl=str(read_file(vcsPath, "")).strip('\n') print("Remote Project URL: " + projectUrl) - k8s_kind('ApplicationSets', api_version="apiextensions.k8s.io/v1") - if projectUrl != "": - for appset in ["httpdump"]: - print("Creating Test ApplicationSet: " + appset) + for appset in ["httpdump","echo-server"]: + source_file = "./localdev/test_appsets/{}.yaml".format(appset) + dest_file = "./localdev/terraform/modules/vcs_files/base_files/appsets/{}/{}.yaml".format(appset,appset) - # read the application YAML and patch the repoURL - objects = read_yaml_stream("localdev/test_appsets/{}.yaml".format(appset)) - for o in objects: - o['spec']['template']['spec']['source']['repoURL'] = projectUrl - k8s_yaml(encode_yaml_stream(objects)) + # Copy the file to the specific terraform directory + local("mkdir -p ./localdev/terraform/modules/vcs_files/base_files/appsets/{} && cp {} {}".format(appset, source_file, dest_file)) - k8s_resource( - new_name=appset, - objects=['{}:applicationset'.format(appset)], - labels=["test_appsets"], - resource_deps=["argocd-crds","argocd"], - ) + # Modify the copied file to replace ${REPO_URL} with projectUrl + local("sed -i '' 's#REPO_URL#{}#g' {}".format(projectUrl, dest_file)) diff --git a/localdev/test_appsets/echo-server.yaml b/localdev/test_appsets/echo-server.yaml new file mode 100644 index 00000000..22813ad6 --- /dev/null +++ b/localdev/test_appsets/echo-server.yaml @@ -0,0 +1,45 @@ +apiVersion: argoproj.io/v1alpha1 +kind: ApplicationSet +metadata: + name: app-set-echoserver + namespace: kubechecks +spec: + generators: + - clusters: + selector: + matchLabels: + environment: development + values: + cluster: 'in-cluster' + url: https://kubernetes.default.svc + template: + metadata: + finalizers: + - resources-finalizer.argocd.argoproj.io + name: "in-cluster-echo-server-{{ metadata.labels.environment }}" + namespace: kubechecks + labels: + argocd.argoproj.io/application-set-name: "echo-server" + spec: + destination: + namespace: "echo-server-{{ metadata.labels.environment }}" + server: '{{ values.url }}' + project: default + source: + repoURL: REPO_URL + targetRevision: HEAD + path: 'apps/app-set-echo-server/{{ values.cluster }}' + helm: + valueFiles: + - values.yaml + - values-{{ metadata.labels.environment }}.yaml + ignoreMissingValueFiles: true + values: |- + echo-server: + replicaCount: 2 + syncPolicy: + automated: + prune: true + syncOptions: + - CreateNamespace=true + sources: [] diff --git a/localdev/test_appsets/httpdump.yaml b/localdev/test_appsets/httpdump.yaml index 1c11ad99..d72cf2d3 100644 --- a/localdev/test_appsets/httpdump.yaml +++ b/localdev/test_appsets/httpdump.yaml @@ -5,6 +5,7 @@ metadata: namespace: kubechecks spec: generators: + # this is a simple list generator - list: elements: - name: a @@ -16,7 +17,7 @@ spec: finalizers: - resources-finalizer.argocd.argoproj.io name: "in-cluster-{{ name }}-httpdump" - namespace: argocd + namespace: kubechecks labels: argocd.argoproj.io/application-set-name: "httpdump" spec: @@ -25,11 +26,12 @@ spec: server: '{{ url }}' project: default source: - repoURL: ${REPO_URL} + repoURL: REPO_URL targetRevision: HEAD path: 'apps/httpdump/overlays/{{ name }}/' syncPolicy: automated: prune: true syncOptions: - - CreateNamespace=true \ No newline at end of file + - CreateNamespace=true + sources: [] diff --git a/mocks/affected_apps/mocks/mock_Matcher.go b/mocks/affected_apps/mocks/mock_Matcher.go new file mode 100644 index 00000000..6a0fb41c --- /dev/null +++ b/mocks/affected_apps/mocks/mock_Matcher.go @@ -0,0 +1,95 @@ +// Code generated by mockery v2.37.1. DO NOT EDIT. + +package affected_apps + +import ( + context "context" + + affected_apps "github.com/zapier/kubechecks/pkg/affected_apps" + + git "github.com/zapier/kubechecks/pkg/git" + + mock "github.com/stretchr/testify/mock" +) + +// MockMatcher is an autogenerated mock type for the Matcher type +type MockMatcher struct { + mock.Mock +} + +type MockMatcher_Expecter struct { + mock *mock.Mock +} + +func (_m *MockMatcher) EXPECT() *MockMatcher_Expecter { + return &MockMatcher_Expecter{mock: &_m.Mock} +} + +// AffectedApps provides a mock function with given fields: ctx, changeList, targetBranch, repo +func (_m *MockMatcher) AffectedApps(ctx context.Context, changeList []string, targetBranch string, repo *git.Repo) (affected_apps.AffectedItems, error) { + ret := _m.Called(ctx, changeList, targetBranch, repo) + + var r0 affected_apps.AffectedItems + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, []string, string, *git.Repo) (affected_apps.AffectedItems, error)); ok { + return rf(ctx, changeList, targetBranch, repo) + } + if rf, ok := ret.Get(0).(func(context.Context, []string, string, *git.Repo) affected_apps.AffectedItems); ok { + r0 = rf(ctx, changeList, targetBranch, repo) + } else { + r0 = ret.Get(0).(affected_apps.AffectedItems) + } + + if rf, ok := ret.Get(1).(func(context.Context, []string, string, *git.Repo) error); ok { + r1 = rf(ctx, changeList, targetBranch, repo) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockMatcher_AffectedApps_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AffectedApps' +type MockMatcher_AffectedApps_Call struct { + *mock.Call +} + +// AffectedApps is a helper method to define mock.On call +// - ctx context.Context +// - changeList []string +// - targetBranch string +// - repo *git.Repo +func (_e *MockMatcher_Expecter) AffectedApps(ctx interface{}, changeList interface{}, targetBranch interface{}, repo interface{}) *MockMatcher_AffectedApps_Call { + return &MockMatcher_AffectedApps_Call{Call: _e.mock.On("AffectedApps", ctx, changeList, targetBranch, repo)} +} + +func (_c *MockMatcher_AffectedApps_Call) Run(run func(ctx context.Context, changeList []string, targetBranch string, repo *git.Repo)) *MockMatcher_AffectedApps_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].([]string), args[2].(string), args[3].(*git.Repo)) + }) + return _c +} + +func (_c *MockMatcher_AffectedApps_Call) Return(_a0 affected_apps.AffectedItems, _a1 error) *MockMatcher_AffectedApps_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockMatcher_AffectedApps_Call) RunAndReturn(run func(context.Context, []string, string, *git.Repo) (affected_apps.AffectedItems, error)) *MockMatcher_AffectedApps_Call { + _c.Call.Return(run) + return _c +} + +// NewMockMatcher creates a new instance of MockMatcher. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockMatcher(t interface { + mock.TestingT + Cleanup(func()) +}) *MockMatcher { + mock := &MockMatcher{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/affected_apps/mocks/mock_argoClient.go b/mocks/affected_apps/mocks/mock_argoClient.go new file mode 100644 index 00000000..4b61928b --- /dev/null +++ b/mocks/affected_apps/mocks/mock_argoClient.go @@ -0,0 +1,146 @@ +// Code generated by mockery v2.37.1. DO NOT EDIT. + +package affected_apps + +import ( + context "context" + + v1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + mock "github.com/stretchr/testify/mock" +) + +// MockargoClient is an autogenerated mock type for the argoClient type +type MockargoClient struct { + mock.Mock +} + +type MockargoClient_Expecter struct { + mock *mock.Mock +} + +func (_m *MockargoClient) EXPECT() *MockargoClient_Expecter { + return &MockargoClient_Expecter{mock: &_m.Mock} +} + +// GetApplications provides a mock function with given fields: ctx +func (_m *MockargoClient) GetApplications(ctx context.Context) (*v1alpha1.ApplicationList, error) { + ret := _m.Called(ctx) + + var r0 *v1alpha1.ApplicationList + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (*v1alpha1.ApplicationList, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) *v1alpha1.ApplicationList); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1alpha1.ApplicationList) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockargoClient_GetApplications_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetApplications' +type MockargoClient_GetApplications_Call struct { + *mock.Call +} + +// GetApplications is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockargoClient_Expecter) GetApplications(ctx interface{}) *MockargoClient_GetApplications_Call { + return &MockargoClient_GetApplications_Call{Call: _e.mock.On("GetApplications", ctx)} +} + +func (_c *MockargoClient_GetApplications_Call) Run(run func(ctx context.Context)) *MockargoClient_GetApplications_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockargoClient_GetApplications_Call) Return(_a0 *v1alpha1.ApplicationList, _a1 error) *MockargoClient_GetApplications_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockargoClient_GetApplications_Call) RunAndReturn(run func(context.Context) (*v1alpha1.ApplicationList, error)) *MockargoClient_GetApplications_Call { + _c.Call.Return(run) + return _c +} + +// GetApplicationsByAppset provides a mock function with given fields: ctx, appsetName +func (_m *MockargoClient) GetApplicationsByAppset(ctx context.Context, appsetName string) (*v1alpha1.ApplicationList, error) { + ret := _m.Called(ctx, appsetName) + + var r0 *v1alpha1.ApplicationList + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*v1alpha1.ApplicationList, error)); ok { + return rf(ctx, appsetName) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *v1alpha1.ApplicationList); ok { + r0 = rf(ctx, appsetName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1alpha1.ApplicationList) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, appsetName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockargoClient_GetApplicationsByAppset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetApplicationsByAppset' +type MockargoClient_GetApplicationsByAppset_Call struct { + *mock.Call +} + +// GetApplicationsByAppset is a helper method to define mock.On call +// - ctx context.Context +// - appsetName string +func (_e *MockargoClient_Expecter) GetApplicationsByAppset(ctx interface{}, appsetName interface{}) *MockargoClient_GetApplicationsByAppset_Call { + return &MockargoClient_GetApplicationsByAppset_Call{Call: _e.mock.On("GetApplicationsByAppset", ctx, appsetName)} +} + +func (_c *MockargoClient_GetApplicationsByAppset_Call) Run(run func(ctx context.Context, appsetName string)) *MockargoClient_GetApplicationsByAppset_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *MockargoClient_GetApplicationsByAppset_Call) Return(_a0 *v1alpha1.ApplicationList, _a1 error) *MockargoClient_GetApplicationsByAppset_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockargoClient_GetApplicationsByAppset_Call) RunAndReturn(run func(context.Context, string) (*v1alpha1.ApplicationList, error)) *MockargoClient_GetApplicationsByAppset_Call { + _c.Call.Return(run) + return _c +} + +// NewMockargoClient creates a new instance of MockargoClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockargoClient(t interface { + mock.TestingT + Cleanup(func()) +}) *MockargoClient { + mock := &MockargoClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/generator/mocks/mock_AppsGenerator.go b/mocks/generator/mocks/mock_AppsGenerator.go new file mode 100644 index 00000000..8ceb12f1 --- /dev/null +++ b/mocks/generator/mocks/mock_AppsGenerator.go @@ -0,0 +1,96 @@ +// Code generated by mockery v2.37.1. DO NOT EDIT. + +package generator + +import ( + context "context" + + container "github.com/zapier/kubechecks/pkg/container" + + mock "github.com/stretchr/testify/mock" + + v1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" +) + +// MockAppsGenerator is an autogenerated mock type for the AppsGenerator type +type MockAppsGenerator struct { + mock.Mock +} + +type MockAppsGenerator_Expecter struct { + mock *mock.Mock +} + +func (_m *MockAppsGenerator) EXPECT() *MockAppsGenerator_Expecter { + return &MockAppsGenerator_Expecter{mock: &_m.Mock} +} + +// GenerateApplicationSetApps provides a mock function with given fields: ctx, appset, ctr +func (_m *MockAppsGenerator) GenerateApplicationSetApps(ctx context.Context, appset v1alpha1.ApplicationSet, ctr *container.Container) ([]v1alpha1.Application, error) { + ret := _m.Called(ctx, appset, ctr) + + var r0 []v1alpha1.Application + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, v1alpha1.ApplicationSet, *container.Container) ([]v1alpha1.Application, error)); ok { + return rf(ctx, appset, ctr) + } + if rf, ok := ret.Get(0).(func(context.Context, v1alpha1.ApplicationSet, *container.Container) []v1alpha1.Application); ok { + r0 = rf(ctx, appset, ctr) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]v1alpha1.Application) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, v1alpha1.ApplicationSet, *container.Container) error); ok { + r1 = rf(ctx, appset, ctr) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockAppsGenerator_GenerateApplicationSetApps_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GenerateApplicationSetApps' +type MockAppsGenerator_GenerateApplicationSetApps_Call struct { + *mock.Call +} + +// GenerateApplicationSetApps is a helper method to define mock.On call +// - ctx context.Context +// - appset v1alpha1.ApplicationSet +// - ctr *container.Container +func (_e *MockAppsGenerator_Expecter) GenerateApplicationSetApps(ctx interface{}, appset interface{}, ctr interface{}) *MockAppsGenerator_GenerateApplicationSetApps_Call { + return &MockAppsGenerator_GenerateApplicationSetApps_Call{Call: _e.mock.On("GenerateApplicationSetApps", ctx, appset, ctr)} +} + +func (_c *MockAppsGenerator_GenerateApplicationSetApps_Call) Run(run func(ctx context.Context, appset v1alpha1.ApplicationSet, ctr *container.Container)) *MockAppsGenerator_GenerateApplicationSetApps_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(v1alpha1.ApplicationSet), args[2].(*container.Container)) + }) + return _c +} + +func (_c *MockAppsGenerator_GenerateApplicationSetApps_Call) Return(_a0 []v1alpha1.Application, _a1 error) *MockAppsGenerator_GenerateApplicationSetApps_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockAppsGenerator_GenerateApplicationSetApps_Call) RunAndReturn(run func(context.Context, v1alpha1.ApplicationSet, *container.Container) ([]v1alpha1.Application, error)) *MockAppsGenerator_GenerateApplicationSetApps_Call { + _c.Call.Return(run) + return _c +} + +// NewMockAppsGenerator creates a new instance of MockAppsGenerator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockAppsGenerator(t interface { + mock.TestingT + Cleanup(func()) +}) *MockAppsGenerator { + mock := &MockAppsGenerator{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/generator/mocks/mock_Generator.go b/mocks/generator/mocks/mock_Generator.go new file mode 100644 index 00000000..fc7365ff --- /dev/null +++ b/mocks/generator/mocks/mock_Generator.go @@ -0,0 +1,179 @@ +// Code generated by mockery v2.37.1. DO NOT EDIT. + +package generator + +import ( + time "time" + + mock "github.com/stretchr/testify/mock" + + v1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" +) + +// MockGenerator is an autogenerated mock type for the Generator type +type MockGenerator struct { + mock.Mock +} + +type MockGenerator_Expecter struct { + mock *mock.Mock +} + +func (_m *MockGenerator) EXPECT() *MockGenerator_Expecter { + return &MockGenerator_Expecter{mock: &_m.Mock} +} + +// GenerateParams provides a mock function with given fields: appSetGenerator, applicationSetInfo +func (_m *MockGenerator) GenerateParams(appSetGenerator *v1alpha1.ApplicationSetGenerator, applicationSetInfo *v1alpha1.ApplicationSet) ([]map[string]interface{}, error) { + ret := _m.Called(appSetGenerator, applicationSetInfo) + + var r0 []map[string]interface{} + var r1 error + if rf, ok := ret.Get(0).(func(*v1alpha1.ApplicationSetGenerator, *v1alpha1.ApplicationSet) ([]map[string]interface{}, error)); ok { + return rf(appSetGenerator, applicationSetInfo) + } + if rf, ok := ret.Get(0).(func(*v1alpha1.ApplicationSetGenerator, *v1alpha1.ApplicationSet) []map[string]interface{}); ok { + r0 = rf(appSetGenerator, applicationSetInfo) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]map[string]interface{}) + } + } + + if rf, ok := ret.Get(1).(func(*v1alpha1.ApplicationSetGenerator, *v1alpha1.ApplicationSet) error); ok { + r1 = rf(appSetGenerator, applicationSetInfo) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockGenerator_GenerateParams_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GenerateParams' +type MockGenerator_GenerateParams_Call struct { + *mock.Call +} + +// GenerateParams is a helper method to define mock.On call +// - appSetGenerator *v1alpha1.ApplicationSetGenerator +// - applicationSetInfo *v1alpha1.ApplicationSet +func (_e *MockGenerator_Expecter) GenerateParams(appSetGenerator interface{}, applicationSetInfo interface{}) *MockGenerator_GenerateParams_Call { + return &MockGenerator_GenerateParams_Call{Call: _e.mock.On("GenerateParams", appSetGenerator, applicationSetInfo)} +} + +func (_c *MockGenerator_GenerateParams_Call) Run(run func(appSetGenerator *v1alpha1.ApplicationSetGenerator, applicationSetInfo *v1alpha1.ApplicationSet)) *MockGenerator_GenerateParams_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(*v1alpha1.ApplicationSetGenerator), args[1].(*v1alpha1.ApplicationSet)) + }) + return _c +} + +func (_c *MockGenerator_GenerateParams_Call) Return(_a0 []map[string]interface{}, _a1 error) *MockGenerator_GenerateParams_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockGenerator_GenerateParams_Call) RunAndReturn(run func(*v1alpha1.ApplicationSetGenerator, *v1alpha1.ApplicationSet) ([]map[string]interface{}, error)) *MockGenerator_GenerateParams_Call { + _c.Call.Return(run) + return _c +} + +// GetRequeueAfter provides a mock function with given fields: appSetGenerator +func (_m *MockGenerator) GetRequeueAfter(appSetGenerator *v1alpha1.ApplicationSetGenerator) time.Duration { + ret := _m.Called(appSetGenerator) + + var r0 time.Duration + if rf, ok := ret.Get(0).(func(*v1alpha1.ApplicationSetGenerator) time.Duration); ok { + r0 = rf(appSetGenerator) + } else { + r0 = ret.Get(0).(time.Duration) + } + + return r0 +} + +// MockGenerator_GetRequeueAfter_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRequeueAfter' +type MockGenerator_GetRequeueAfter_Call struct { + *mock.Call +} + +// GetRequeueAfter is a helper method to define mock.On call +// - appSetGenerator *v1alpha1.ApplicationSetGenerator +func (_e *MockGenerator_Expecter) GetRequeueAfter(appSetGenerator interface{}) *MockGenerator_GetRequeueAfter_Call { + return &MockGenerator_GetRequeueAfter_Call{Call: _e.mock.On("GetRequeueAfter", appSetGenerator)} +} + +func (_c *MockGenerator_GetRequeueAfter_Call) Run(run func(appSetGenerator *v1alpha1.ApplicationSetGenerator)) *MockGenerator_GetRequeueAfter_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(*v1alpha1.ApplicationSetGenerator)) + }) + return _c +} + +func (_c *MockGenerator_GetRequeueAfter_Call) Return(_a0 time.Duration) *MockGenerator_GetRequeueAfter_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGenerator_GetRequeueAfter_Call) RunAndReturn(run func(*v1alpha1.ApplicationSetGenerator) time.Duration) *MockGenerator_GetRequeueAfter_Call { + _c.Call.Return(run) + return _c +} + +// GetTemplate provides a mock function with given fields: appSetGenerator +func (_m *MockGenerator) GetTemplate(appSetGenerator *v1alpha1.ApplicationSetGenerator) *v1alpha1.ApplicationSetTemplate { + ret := _m.Called(appSetGenerator) + + var r0 *v1alpha1.ApplicationSetTemplate + if rf, ok := ret.Get(0).(func(*v1alpha1.ApplicationSetGenerator) *v1alpha1.ApplicationSetTemplate); ok { + r0 = rf(appSetGenerator) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1alpha1.ApplicationSetTemplate) + } + } + + return r0 +} + +// MockGenerator_GetTemplate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTemplate' +type MockGenerator_GetTemplate_Call struct { + *mock.Call +} + +// GetTemplate is a helper method to define mock.On call +// - appSetGenerator *v1alpha1.ApplicationSetGenerator +func (_e *MockGenerator_Expecter) GetTemplate(appSetGenerator interface{}) *MockGenerator_GetTemplate_Call { + return &MockGenerator_GetTemplate_Call{Call: _e.mock.On("GetTemplate", appSetGenerator)} +} + +func (_c *MockGenerator_GetTemplate_Call) Run(run func(appSetGenerator *v1alpha1.ApplicationSetGenerator)) *MockGenerator_GetTemplate_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(*v1alpha1.ApplicationSetGenerator)) + }) + return _c +} + +func (_c *MockGenerator_GetTemplate_Call) Return(_a0 *v1alpha1.ApplicationSetTemplate) *MockGenerator_GetTemplate_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGenerator_GetTemplate_Call) RunAndReturn(run func(*v1alpha1.ApplicationSetGenerator) *v1alpha1.ApplicationSetTemplate) *MockGenerator_GetTemplate_Call { + _c.Call.Return(run) + return _c +} + +// NewMockGenerator creates a new instance of MockGenerator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockGenerator(t interface { + mock.TestingT + Cleanup(func()) +}) *MockGenerator { + mock := &MockGenerator{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/affected_apps/argocd_matcher.go b/pkg/affected_apps/argocd_matcher.go index 679ab9a2..cb0c132f 100644 --- a/pkg/affected_apps/argocd_matcher.go +++ b/pkg/affected_apps/argocd_matcher.go @@ -5,14 +5,14 @@ import ( "os" "github.com/rs/zerolog/log" - "github.com/zapier/kubechecks/pkg/appdir" "github.com/zapier/kubechecks/pkg/container" "github.com/zapier/kubechecks/pkg/git" ) type ArgocdMatcher struct { - appsDirectory *appdir.AppDirectory + appsDirectory *appdir.AppDirectory + appSetsDirectory *appdir.AppSetDirectory } func NewArgocdMatcher(vcsToArgoMap container.VcsToArgoMap, repo *git.Repo) (*ArgocdMatcher, error) { @@ -23,8 +23,13 @@ func NewArgocdMatcher(vcsToArgoMap container.VcsToArgoMap, repo *git.Repo) (*Arg Union(repoApps). Union(kustomizeAppFiles) + repoAppSets := getArgocdAppSets(vcsToArgoMap, repo) + appSetDirectory := appdir.NewAppSetDirectory(). + Union(repoAppSets) + return &ArgocdMatcher{ - appsDirectory: appDirectory, + appsDirectory: appDirectory, + appSetsDirectory: appSetDirectory, }, nil } @@ -54,13 +59,31 @@ func getArgocdApps(vcsToArgoMap container.VcsToArgoMap, repo *git.Repo) *appdir. return repoApps } -func (a *ArgocdMatcher) AffectedApps(ctx context.Context, changeList []string, targetBranch string) (AffectedItems, error) { +func getArgocdAppSets(vcsToArgoMap container.VcsToArgoMap, repo *git.Repo) *appdir.AppSetDirectory { + log.Debug().Msgf("looking for %s repos", repo.CloneURL) + repoApps := vcsToArgoMap.GetAppSetsInRepo(repo.CloneURL) + + if repoApps == nil { + log.Debug().Msg("found no appSets") + } else { + log.Debug().Msgf("found %d appSets", repoApps.Count()) + } + return repoApps +} + +func (a *ArgocdMatcher) AffectedApps(_ context.Context, changeList []string, targetBranch string, repo *git.Repo) (AffectedItems, error) { if a.appsDirectory == nil { return AffectedItems{}, nil } appsSlice := a.appsDirectory.FindAppsBasedOnChangeList(changeList, targetBranch) - return AffectedItems{Applications: appsSlice}, nil + appSetsSlice := a.appSetsDirectory.FindAppsBasedOnChangeList(changeList, targetBranch, repo) + + // and return both apps and appSets + return AffectedItems{ + Applications: appsSlice, + ApplicationSets: appSetsSlice, + }, nil } var _ Matcher = new(ArgocdMatcher) diff --git a/pkg/affected_apps/argocd_matcher_test.go b/pkg/affected_apps/argocd_matcher_test.go index 966db648..0db01456 100644 --- a/pkg/affected_apps/argocd_matcher_test.go +++ b/pkg/affected_apps/argocd_matcher_test.go @@ -34,7 +34,7 @@ func TestFindAffectedAppsWithNilAppsDirectory(t *testing.T) { ) matcher := ArgocdMatcher{} - items, err := matcher.AffectedApps(ctx, changeList, "main") + items, err := matcher.AffectedApps(ctx, changeList, "main", nil) // verify results require.NoError(t, err) diff --git a/pkg/affected_apps/config_matcher.go b/pkg/affected_apps/config_matcher.go index 4ad331e3..de5e8b87 100644 --- a/pkg/affected_apps/config_matcher.go +++ b/pkg/affected_apps/config_matcher.go @@ -9,6 +9,8 @@ import ( "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" "github.com/pkg/errors" "github.com/rs/zerolog/log" + "github.com/zapier/kubechecks/pkg/git" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/zapier/kubechecks/pkg/container" "github.com/zapier/kubechecks/pkg/repo_config" @@ -28,9 +30,9 @@ func NewConfigMatcher(cfg *repo_config.Config, ctr container.Container) *ConfigM return &ConfigMatcher{cfg: cfg, argoClient: ctr.ArgoClient} } -func (b *ConfigMatcher) AffectedApps(ctx context.Context, changeList []string, targetBranch string) (AffectedItems, error) { +func (b *ConfigMatcher) AffectedApps(ctx context.Context, changeList []string, _ string, _ *git.Repo) (AffectedItems, error) { triggeredAppsMap := make(map[string]string) - var appSetList []ApplicationSet + var appSetList []v1alpha1.ApplicationSet triggeredApps, triggeredAppsets, err := b.triggeredApps(ctx, changeList) if err != nil { @@ -42,7 +44,11 @@ func (b *ConfigMatcher) AffectedApps(ctx context.Context, changeList []string, t } for _, appset := range triggeredAppsets { - appSetList = append(appSetList, ApplicationSet{appset.Name}) + appSetList = append(appSetList, v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: appset.Name, + }, + }) } allArgoApps, err := b.argoClient.GetApplications(ctx) diff --git a/pkg/affected_apps/matcher.go b/pkg/affected_apps/matcher.go index 4ef61d3e..a1c7a9a9 100644 --- a/pkg/affected_apps/matcher.go +++ b/pkg/affected_apps/matcher.go @@ -5,11 +5,12 @@ import ( "path" "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + "github.com/zapier/kubechecks/pkg/git" ) type AffectedItems struct { Applications []v1alpha1.Application - ApplicationSets []ApplicationSet + ApplicationSets []v1alpha1.ApplicationSet } func (ai AffectedItems) Union(other AffectedItems) AffectedItems { @@ -48,7 +49,7 @@ type ApplicationSet struct { } type Matcher interface { - AffectedApps(ctx context.Context, changeList []string, targetBranch string) (AffectedItems, error) + AffectedApps(ctx context.Context, changeList []string, targetBranch string, repo *git.Repo) (AffectedItems, error) } // modifiedDirs filters a list of changed files down to a list diff --git a/pkg/affected_apps/multi_matcher.go b/pkg/affected_apps/multi_matcher.go index 7e5192e2..5f49a99f 100644 --- a/pkg/affected_apps/multi_matcher.go +++ b/pkg/affected_apps/multi_matcher.go @@ -4,6 +4,7 @@ import ( "context" "github.com/pkg/errors" + "github.com/zapier/kubechecks/pkg/git" ) func NewMultiMatcher(matchers ...Matcher) Matcher { @@ -14,11 +15,11 @@ type MultiMatcher struct { matchers []Matcher } -func (m MultiMatcher) AffectedApps(ctx context.Context, changeList []string, targetBranch string) (AffectedItems, error) { +func (m MultiMatcher) AffectedApps(ctx context.Context, changeList []string, targetBranch string, repo *git.Repo) (AffectedItems, error) { var total AffectedItems for index, matcher := range m.matchers { - items, err := matcher.AffectedApps(ctx, changeList, targetBranch) + items, err := matcher.AffectedApps(ctx, changeList, targetBranch, repo) if err != nil { return total, errors.Wrapf(err, "failed to find items in matcher #%d", index) } diff --git a/pkg/affected_apps/multi_matcher_test.go b/pkg/affected_apps/multi_matcher_test.go index c9a287fb..16f4ee38 100644 --- a/pkg/affected_apps/multi_matcher_test.go +++ b/pkg/affected_apps/multi_matcher_test.go @@ -6,6 +6,7 @@ import ( "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" "github.com/stretchr/testify/require" + "github.com/zapier/kubechecks/pkg/git" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -13,7 +14,7 @@ type fakeMatcher struct { items AffectedItems } -func (f fakeMatcher) AffectedApps(ctx context.Context, changeList []string, targetBranch string) (AffectedItems, error) { +func (f fakeMatcher) AffectedApps(ctx context.Context, changeList []string, targetBranch string, repo *git.Repo) (AffectedItems, error) { return f.items, nil } @@ -29,7 +30,7 @@ func TestMultiMatcher(t *testing.T) { ctx := context.Background() matcher := NewMultiMatcher(matcher1, matcher2) - total, err := matcher.AffectedApps(ctx, nil, "") + total, err := matcher.AffectedApps(ctx, nil, "", nil) require.NoError(t, err) require.Len(t, total.Applications, 1) @@ -47,7 +48,7 @@ func TestMultiMatcher(t *testing.T) { ctx := context.Background() matcher := NewMultiMatcher(matcher1, matcher2) - total, err := matcher.AffectedApps(ctx, nil, "") + total, err := matcher.AffectedApps(ctx, nil, "", nil) require.NoError(t, err) require.Len(t, total.Applications, 1) @@ -69,7 +70,7 @@ func TestMultiMatcher(t *testing.T) { ctx := context.Background() matcher := NewMultiMatcher(matcher1, matcher2) - total, err := matcher.AffectedApps(ctx, nil, "") + total, err := matcher.AffectedApps(ctx, nil, "", nil) require.NoError(t, err) require.Len(t, total.Applications, 1) @@ -92,7 +93,7 @@ func TestMultiMatcher(t *testing.T) { ctx := context.Background() matcher := NewMultiMatcher(matcher1, matcher2) - total, err := matcher.AffectedApps(ctx, nil, "") + total, err := matcher.AffectedApps(ctx, nil, "", nil) require.NoError(t, err) require.Len(t, total.Applications, 2) diff --git a/pkg/app_watcher/app_watcher.go b/pkg/app_watcher/app_watcher.go index 7ca46786..adb39c26 100644 --- a/pkg/app_watcher/app_watcher.go +++ b/pkg/app_watcher/app_watcher.go @@ -2,18 +2,18 @@ package app_watcher import ( "context" + "fmt" "reflect" "strings" "time" - appclientset "github.com/argoproj/argo-cd/v2/pkg/client/clientset/versioned" - "github.com/rs/zerolog/log" - "k8s.io/client-go/tools/clientcmd" - appv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + appclientset "github.com/argoproj/argo-cd/v2/pkg/client/clientset/versioned" informers "github.com/argoproj/argo-cd/v2/pkg/client/informers/externalversions/application/v1alpha1" applisters "github.com/argoproj/argo-cd/v2/pkg/client/listers/application/v1alpha1" + "github.com/rs/zerolog/log" "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/rest" "k8s.io/client-go/tools/cache" "github.com/zapier/kubechecks/pkg/appdir" @@ -29,18 +29,17 @@ type ApplicationWatcher struct { vcsToArgoMap appdir.VcsToArgoMap } -// NewApplicationWatcher creates new instance of ApplicationWatcher. -func NewApplicationWatcher(vcsToArgoMap appdir.VcsToArgoMap, cfg config.ServerConfig) (*ApplicationWatcher, error) { - // this assumes kubechecks is running inside the cluster - kubeCfg, err := clientcmd.BuildConfigFromFlags("", cfg.KubernetesConfig) - if err != nil { - log.Fatal().Msgf("Error building kubeconfig: %s", err.Error()) +// NewApplicationWatcher creates a new instance of ApplicationWatcher. +// +// - kubeCfg is the Kubernetes configuration. +// - vcsToArgoMap is the mapping between VCS and Argo applications. +// - cfg is the server configuration. +func NewApplicationWatcher(kubeCfg *rest.Config, vcsToArgoMap appdir.VcsToArgoMap, cfg config.ServerConfig) (*ApplicationWatcher, error) { + if kubeCfg == nil { + return nil, fmt.Errorf("kubeCfg cannot be nil") } - - appClient := appclientset.NewForConfigOrDie(kubeCfg) - ctrl := ApplicationWatcher{ - applicationClientset: appClient, + applicationClientset: appclientset.NewForConfigOrDie(kubeCfg), vcsToArgoMap: vcsToArgoMap, } diff --git a/pkg/app_watcher/appset_watcher.go b/pkg/app_watcher/appset_watcher.go new file mode 100644 index 00000000..cc90fed6 --- /dev/null +++ b/pkg/app_watcher/appset_watcher.go @@ -0,0 +1,148 @@ +package app_watcher + +import ( + "context" + "fmt" + "reflect" + "time" + + appv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + appclientset "github.com/argoproj/argo-cd/v2/pkg/client/clientset/versioned" + informers "github.com/argoproj/argo-cd/v2/pkg/client/informers/externalversions/application/v1alpha1" + applisters "github.com/argoproj/argo-cd/v2/pkg/client/listers/application/v1alpha1" + "github.com/rs/zerolog/log" + "github.com/zapier/kubechecks/pkg/appdir" + "github.com/zapier/kubechecks/pkg/config" + "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/cache" +) + +// ApplicationSetWatcher is the controller that watches ArgoCD Application resources via the Kubernetes API +type ApplicationSetWatcher struct { + applicationClientset appclientset.Interface + appInformer cache.SharedIndexInformer + appLister applisters.ApplicationSetLister + + vcsToArgoMap appdir.VcsToArgoMap +} + +// NewApplicationSetWatcher creates new instance of ApplicationWatcher. +func NewApplicationSetWatcher(kubeCfg *rest.Config, vcsToArgoMap appdir.VcsToArgoMap, cfg config.ServerConfig) (*ApplicationSetWatcher, error) { + if kubeCfg == nil { + return nil, fmt.Errorf("kubeCfg cannot be nil") + } + ctrl := ApplicationSetWatcher{ + applicationClientset: appclientset.NewForConfigOrDie(kubeCfg), + vcsToArgoMap: vcsToArgoMap, + } + + appInformer, appLister := ctrl.newApplicationSetInformerAndLister(time.Second*30, cfg) + + ctrl.appInformer = appInformer + ctrl.appLister = appLister + + return &ctrl, nil +} + +// Run starts the Application CRD controller. +func (ctrl *ApplicationSetWatcher) Run(ctx context.Context) { + log.Info().Msg("starting ApplicationSet Controller") + + defer runtime.HandleCrash() + + go ctrl.appInformer.Run(ctx.Done()) + + if !cache.WaitForCacheSync(ctx.Done(), ctrl.appInformer.HasSynced) { + log.Error().Msg("Timed out waiting for caches to sync") + return + } + + <-ctx.Done() +} + +func (ctrl *ApplicationSetWatcher) newApplicationSetInformerAndLister(refreshTimeout time.Duration, cfg config.ServerConfig) (cache.SharedIndexInformer, applisters.ApplicationSetLister) { + log.Debug().Msgf("Creating ApplicationSet informer with namespace: %s", cfg.ArgoCDNamespace) + informer := informers.NewApplicationSetInformer(ctrl.applicationClientset, cfg.ArgoCDNamespace, refreshTimeout, + cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, + ) + + AppSetLister := applisters.NewApplicationSetLister(informer.GetIndexer()) + if _, err := informer.AddEventHandler( + cache.ResourceEventHandlerFuncs{ + AddFunc: ctrl.onApplicationSetAdded, + UpdateFunc: ctrl.onApplicationSetUpdated, + DeleteFunc: ctrl.onApplicationSetDeleted, + }, + ); err != nil { + log.Error().Err(err).Msg("failed to add event handler for Application Set") + } + return informer, AppSetLister +} + +// onAdd is the function executed when the informer notifies the +// presence of a new Application in the namespace +func (ctrl *ApplicationSetWatcher) onApplicationSetAdded(obj interface{}) { + appSet, ok := canProcessAppSet(obj) + if !ok { + return + } + key, err := cache.MetaNamespaceKeyFunc(obj) + if err != nil { + log.Error().Err(err).Msg("appsetwatcher: could not get key for added application") + } + log.Info().Str("key", key).Msg("appsetwatcher: onApplicationAdded") + ctrl.vcsToArgoMap.AddAppSet(appSet) +} + +func (ctrl *ApplicationSetWatcher) onApplicationSetUpdated(old, new interface{}) { + newApp, newOk := canProcessAppSet(new) + oldApp, oldOk := canProcessAppSet(old) + if !newOk || !oldOk { + return + } + + key, err := cache.MetaNamespaceKeyFunc(new) + if err != nil { + log.Warn().Err(err).Msg("appsetwatcher: could not get key for updated applicationset") + } + + // We want to update when any of Source or Sources parameters has changed + if !reflect.DeepEqual(oldApp.Spec.Template.Spec.GetSource(), newApp.Spec.Template.Spec.GetSource()) || !reflect.DeepEqual(oldApp.Spec.Template.Spec.GetSources(), newApp.Spec.Template.Spec.GetSources()) { + log.Info().Str("key", key).Msg("appsetwatcher: onApplicationSetUpdated") + ctrl.vcsToArgoMap.UpdateAppSet(old.(*appv1alpha1.ApplicationSet), new.(*appv1alpha1.ApplicationSet)) + } + +} + +func (ctrl *ApplicationSetWatcher) onApplicationSetDeleted(obj interface{}) { + app, ok := canProcessAppSet(obj) + if !ok { + return + } + key, err := cache.MetaNamespaceKeyFunc(obj) + if err != nil { + log.Warn().Err(err).Msg("appsetwatcher: could not get key for deleted applicationset") + } + + log.Info().Str("key", key).Msg("appsetwatcher: onApplicationSetDeleted") + ctrl.vcsToArgoMap.DeleteAppSet(app) +} +func canProcessAppSet(obj interface{}) (*appv1alpha1.ApplicationSet, bool) { + app, ok := obj.(*appv1alpha1.ApplicationSet) + if !ok { + return nil, false + } + + for _, src := range app.Spec.Template.Spec.GetSources() { + if isGitRepo(src.RepoURL) { + return app, true + } + } + + if isGitRepo(app.Spec.Template.Spec.GetSource().RepoURL) { + return app, true + } + + return app, false +} diff --git a/pkg/app_watcher/appset_watcher_test.go b/pkg/app_watcher/appset_watcher_test.go new file mode 100644 index 00000000..6caa6c4c --- /dev/null +++ b/pkg/app_watcher/appset_watcher_test.go @@ -0,0 +1,277 @@ +package app_watcher + +import ( + "context" + "testing" + "time" + + "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + appclientsetfake "github.com/argoproj/argo-cd/v2/pkg/client/clientset/versioned/fake" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zapier/kubechecks/pkg/appdir" + "github.com/zapier/kubechecks/pkg/config" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func initTestObjectsForAppSets(t *testing.T) *ApplicationSetWatcher { + cfg, err := config.New() + // Handle the error appropriately, e.g., log it or fail the test + require.NoError(t, err, "failed to create config") + + // set up the fake Application client set and informer. + testApp1 := &v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test-app-1", Namespace: "default"}, + Spec: v1alpha1.ApplicationSetSpec{ + Template: v1alpha1.ApplicationSetTemplate{ + Spec: v1alpha1.ApplicationSpec{ + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://gitlab.com/test/repo.git", + Path: "/apps/test-app-1", + }, + }, + }, + }, + } + testApp2 := &v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test-app-2", Namespace: "default"}, + Spec: v1alpha1.ApplicationSetSpec{ + Template: v1alpha1.ApplicationSetTemplate{ + Spec: v1alpha1.ApplicationSpec{ + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/test/repo.git", + Path: "/apps/test-app-2", + }, + }, + }, + }, + } + + clientset := appclientsetfake.NewSimpleClientset(testApp1, testApp2) + ctrl := &ApplicationSetWatcher{ + applicationClientset: clientset, + vcsToArgoMap: appdir.NewVcsToArgoMap("vcs-username"), + } + + appInformer, appLister := ctrl.newApplicationSetInformerAndLister(time.Second*1, cfg) + ctrl.appInformer = appInformer + ctrl.appLister = appLister + + return ctrl +} + +func TestApplicationSetWatcher_OnApplicationAdded(t *testing.T) { + appWatcher := initTestObjectsForAppSets(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go appWatcher.Run(ctx) + + time.Sleep(time.Second * 1) + + assert.Equal(t, 2, len(appWatcher.vcsToArgoMap.GetAppSetMap())) + + _, err := appWatcher.applicationClientset.ArgoprojV1alpha1().ApplicationSets("default").Create(ctx, &v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test-app-3", Namespace: "default"}, + Spec: v1alpha1.ApplicationSetSpec{ + Template: v1alpha1.ApplicationSetTemplate{ + Spec: v1alpha1.ApplicationSpec{ + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://gitlab.com/test/repo-3.git", + Path: "apps/test-app-3", + }, + }, + }, + }, + }, metav1.CreateOptions{}) + if err != nil { + t.Error(err) + } + + time.Sleep(time.Second * 1) + assert.Equal(t, 3, len(appWatcher.vcsToArgoMap.GetAppSetMap())) +} + +func TestApplicationSetWatcher_OnApplicationUpdated(t *testing.T) { + ctrl := initTestObjectsForAppSets(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go ctrl.Run(ctx) + + time.Sleep(time.Second * 1) + + assert.Equal(t, len(ctrl.vcsToArgoMap.GetAppSetMap()), 2) + + oldAppDirectory := ctrl.vcsToArgoMap.GetAppSetsInRepo("https://gitlab.com/test/repo.git") + newAppDirectory := ctrl.vcsToArgoMap.GetAppSetsInRepo("https://gitlab.com/test/repo-3.git") + assert.Equal(t, 1, oldAppDirectory.Count()) + assert.Equal(t, 0, newAppDirectory.Count()) + // + _, err := ctrl.applicationClientset.ArgoprojV1alpha1().ApplicationSets("default").Update(ctx, &v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test-app-1", Namespace: "default"}, + Spec: v1alpha1.ApplicationSetSpec{ + Template: v1alpha1.ApplicationSetTemplate{ + Spec: v1alpha1.ApplicationSpec{ + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://gitlab.com/test/repo-3.git", + Path: "apps/test-app-3", + }, + }, + }, + }, + }, metav1.UpdateOptions{}) + if err != nil { + t.Error(err) + } + time.Sleep(time.Second * 1) + oldAppDirectory = ctrl.vcsToArgoMap.GetAppSetsInRepo("https://gitlab.com/test/repo.git") + newAppDirectory = ctrl.vcsToArgoMap.GetAppSetsInRepo("https://gitlab.com/test/repo-3.git") + assert.Equal(t, oldAppDirectory.Count(), 0) + assert.Equal(t, newAppDirectory.Count(), 1) +} + +func TestApplicationSetWatcher_OnApplicationDEleted(t *testing.T) { + ctrl := initTestObjectsForAppSets(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go ctrl.Run(ctx) + + time.Sleep(time.Second * 1) + + assert.Equal(t, 2, len(ctrl.vcsToArgoMap.GetAppSetMap())) + + appDirectory := ctrl.vcsToArgoMap.GetAppSetsInRepo("https://gitlab.com/test/repo.git") + assert.Equal(t, 1, appDirectory.Count()) + // + err := ctrl.applicationClientset.ArgoprojV1alpha1().ApplicationSets("default").Delete(ctx, "test-app-1", metav1.DeleteOptions{}) + if err != nil { + t.Error(err) + } + time.Sleep(time.Second * 1) + + appDirectory = ctrl.vcsToArgoMap.GetAppSetsInRepo("https://gitlab.com/test/repo.git") + assert.Equal(t, 0, appDirectory.Count()) +} + +func Test_CanProcessAppSet(t *testing.T) { + tests := []struct { + name string + resource interface{} + expectedApp *v1alpha1.ApplicationSet + returnApp, canProcessApp bool + }{ + { + name: "nil resource", + resource: nil, + expectedApp: nil, + returnApp: false, + canProcessApp: false, + }, + { + name: "not an app", + resource: new(string), + expectedApp: nil, + returnApp: false, + canProcessApp: false, + }, + { + name: "empty app", + resource: new(v1alpha1.ApplicationSet), + expectedApp: nil, + returnApp: true, + canProcessApp: false, + }, + { + name: "single source without git repo", + resource: &v1alpha1.ApplicationSet{ + Spec: v1alpha1.ApplicationSetSpec{ + Template: v1alpha1.ApplicationSetTemplate{ + Spec: v1alpha1.ApplicationSpec{ + Source: &v1alpha1.ApplicationSource{ + RepoURL: "file://../../../", + }, + }, + }, + }, + }, + returnApp: true, + canProcessApp: false, + }, + { + name: "single source without git repo", + resource: &v1alpha1.ApplicationSet{ + Spec: v1alpha1.ApplicationSetSpec{ + Template: v1alpha1.ApplicationSetTemplate{ + Spec: v1alpha1.ApplicationSpec{ + Source: &v1alpha1.ApplicationSource{ + RepoURL: "git@github.com:user/repo.git", + }, + }, + }, + }, + }, + returnApp: true, + canProcessApp: true, + }, + { + name: "multi source without git repo", + resource: &v1alpha1.ApplicationSet{ + Spec: v1alpha1.ApplicationSetSpec{ + Template: v1alpha1.ApplicationSetTemplate{ + Spec: v1alpha1.ApplicationSpec{ + Sources: v1alpha1.ApplicationSources{ + v1alpha1.ApplicationSource{ + RepoURL: "file://../../../", + }, + }, + }, + }, + }, + }, + returnApp: true, + canProcessApp: false, + }, + { + name: "multi source with git repo", + resource: &v1alpha1.ApplicationSet{ + Spec: v1alpha1.ApplicationSetSpec{ + Template: v1alpha1.ApplicationSetTemplate{ + Spec: v1alpha1.ApplicationSpec{ + Sources: v1alpha1.ApplicationSources{ + { + RepoURL: "git@github.com:user/repo.git", + }, + }, + }, + }, + }, + }, + returnApp: true, + canProcessApp: true, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + app, canProcess := canProcessAppSet(tc.resource) + + if tc.canProcessApp { + assert.True(t, canProcess) + } else { + assert.False(t, canProcess) + } + + if tc.returnApp { + assert.Equal(t, tc.resource, app) + } else { + assert.Nil(t, app) + } + }) + } +} diff --git a/pkg/appdir/app_directory.go b/pkg/appdir/app_directory.go index cc5f6512..a915fe61 100644 --- a/pkg/appdir/app_directory.go +++ b/pkg/appdir/app_directory.go @@ -58,13 +58,18 @@ func (d *AppDirectory) ProcessApp(app v1alpha1.Application) { } } +// FindAppsBasedOnChangeList receives a list of modified file paths and +// returns the list of applications that are affected by the changes. +// +// changeList: a slice of strings representing the paths of modified files. +// targetBranch: the branch name to compare against the target revision of the applications. +// e.g. changeList = ["path/to/file1", "path/to/file2"] func (d *AppDirectory) FindAppsBasedOnChangeList(changeList []string, targetBranch string) []v1alpha1.Application { log.Debug().Msgf("checking %d changes", len(changeList)) appsSet := make(map[string]struct{}) for _, changePath := range changeList { log.Debug().Msgf("change: %s", changePath) - for dir, appNames := range d.appDirs { if strings.HasPrefix(changePath, dir) { log.Debug().Msg("dir match!") @@ -146,10 +151,15 @@ func (d *AppDirectory) GetApps(filter func(stub v1alpha1.Application) bool) []v1 } func (d *AppDirectory) AddApp(app v1alpha1.Application) { + if _, exists := d.appsMap[app.Name]; exists { + log.Debug().Msgf("app %s already exists", app.Name) + return + } log.Debug(). Str("appName", app.Name). Str("cluster-name", app.Spec.Destination.Name). Str("cluster-server", app.Spec.Destination.Server). + Str("source", getSourcePath(app)). Msg("add app") d.appsMap[app.Name] = app d.AddDir(app.Name, getSourcePath(app)) diff --git a/pkg/appdir/appset_directory.go b/pkg/appdir/appset_directory.go new file mode 100644 index 00000000..88eec123 --- /dev/null +++ b/pkg/appdir/appset_directory.go @@ -0,0 +1,234 @@ +package appdir + +import ( + "bufio" + "os" + "path/filepath" + "strings" + + "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + "github.com/rs/zerolog/log" + "github.com/zapier/kubechecks/pkg/git" + "sigs.k8s.io/yaml" +) + +type AppSetDirectory struct { + appSetDirs map[string][]string // directory -> array of app names + appSetFiles map[string][]string // file path -> array of app names + + appSetsMap map[string]v1alpha1.ApplicationSet // app name -> app stub +} + +func NewAppSetDirectory() *AppSetDirectory { + return &AppSetDirectory{ + appSetDirs: make(map[string][]string), + appSetFiles: make(map[string][]string), + appSetsMap: make(map[string]v1alpha1.ApplicationSet), + } +} + +func (d *AppSetDirectory) Count() int { + return len(d.appSetsMap) +} + +func (d *AppSetDirectory) Union(other *AppSetDirectory) *AppSetDirectory { + var join AppSetDirectory + join.appSetsMap = mergeMaps(d.appSetsMap, other.appSetsMap, takeFirst[v1alpha1.ApplicationSet]) + join.appSetDirs = mergeMaps(d.appSetDirs, other.appSetDirs, mergeLists[string]) + join.appSetFiles = mergeMaps(d.appSetFiles, other.appSetFiles, mergeLists[string]) + return &join +} + +func (d *AppSetDirectory) ProcessApp(app v1alpha1.ApplicationSet) { + appName := app.GetName() + + src := app.Spec.Template.Spec.GetSource() + + // common data + srcPath := src.Path + d.AddApp(&app) + + // handle extra helm paths + if helm := src.Helm; helm != nil { + for _, param := range helm.FileParameters { + path := filepath.Join(srcPath, param.Path) + d.AddFile(appName, path) + } + + for _, valueFilePath := range helm.ValueFiles { + path := filepath.Join(srcPath, valueFilePath) + d.AddFile(appName, path) + } + } +} + +// FindAppsBasedOnChangeList receives the modified file path and +// returns the list of applications that are affected by the changes. +// +// e.g. changeList = ["/appset/httpdump/httpdump.yaml", "/app/testapp/values.yaml"] +// if the changed file is application set file, return it. + +func (d *AppSetDirectory) FindAppsBasedOnChangeList(changeList []string, targetBranch string, repo *git.Repo) []v1alpha1.ApplicationSet { + log.Debug().Str("type", "applicationsets").Msgf("checking %d changes", len(changeList)) + + appsSet := make(map[string]struct{}) + var appSets []v1alpha1.ApplicationSet + + for _, changePath := range changeList { + log.Printf("change: %s", changePath) + absPath := filepath.Join(repo.Directory, changePath) + + // Check if file contains `kind: ApplicationSet` + if !containsKindApplicationSet(absPath) { + continue + } + + // Open the yaml file and parse it as v1alpha1.ApplicationSet + fileContent, err := os.ReadFile(absPath) + if err != nil { + log.Error().Msgf("failed to open file %s: %v", absPath, err) + continue + } + + appSet := &v1alpha1.ApplicationSet{} + err = yaml.Unmarshal(fileContent, appSet) + if err != nil { + log.Error().Msgf("failed to parse file %s as ApplicationSet: %v", absPath, err) + continue + } + + if !appSetShouldInclude(appSet, targetBranch) { + log.Debug().Msgf("target revision of %s is %s and does not match '%s'", appSet, appSetGetTargetRevision(appSet), targetBranch) + continue + } + + // Store the unique ApplicationSet + if _, exists := appsSet[appSet.Name]; !exists { + appsSet[appSet.Name] = struct{}{} + appSets = append(appSets, *appSet) + } + } + + log.Debug().Str("source", "appset_directory").Msgf("matched %d files into %d appset", len(changeList), len(appsSet)) + return appSets +} + +func appSetGetTargetRevision(app *v1alpha1.ApplicationSet) string { + return app.Spec.Template.Spec.GetSource().TargetRevision +} + +func appSetGetSourcePath(app *v1alpha1.ApplicationSet) string { + return app.Spec.Template.Spec.GetSource().Path +} + +func appSetShouldInclude(app *v1alpha1.ApplicationSet, targetBranch string) bool { + targetRevision := appSetGetTargetRevision(app) + if targetRevision == "" { + return true + } + + if targetRevision == targetBranch { + return true + } + + if targetRevision == "HEAD" { + if targetBranch == "main" { + return true + } + + if targetBranch == "master" { + return true + } + } + + return false +} + +func (d *AppSetDirectory) GetAppSets(filter func(stub v1alpha1.ApplicationSet) bool) []v1alpha1.ApplicationSet { + var result []v1alpha1.ApplicationSet + for _, value := range d.appSetsMap { + if filter != nil && !filter(value) { + continue + } + result = append(result, value) + } + return result +} + +func (d *AppSetDirectory) AddApp(appSet *v1alpha1.ApplicationSet) { + if _, exists := d.appSetsMap[appSet.GetName()]; exists { + log.Info().Msgf("appset %s already exists", appSet.Name) + return + } + log.Debug(). + Str("appName", appSet.GetName()). + Str("source", appSetGetSourcePath(appSet)). + Msg("add appset") + d.appSetsMap[appSet.GetName()] = *appSet + d.AddDir(appSet.GetName(), appSetGetSourcePath(appSet)) +} + +func (d *AppSetDirectory) AddDir(appName, path string) { + d.appSetDirs[path] = append(d.appSetDirs[path], appName) +} + +func (d *AppSetDirectory) AddFile(appName, path string) { + d.appSetFiles[path] = append(d.appSetFiles[path], appName) +} + +func (d *AppSetDirectory) RemoveApp(app v1alpha1.ApplicationSet) { + log.Debug(). + Str("appName", app.Name). + Msg("delete app") + + // remove app from appSetsMap + delete(d.appSetsMap, app.Name) + + // Clean up app from appSetDirs + sourcePath := appSetGetSourcePath(&app) + d.appSetDirs[sourcePath] = removeFromSlice[string](d.appSetDirs[sourcePath], app.Name, func(a, b string) bool { return a == b }) + + // Clean up app from appSetFiles + src := app.Spec.Template.Spec.GetSource() + srcPath := src.Path + if helm := src.Helm; helm != nil { + for _, param := range helm.FileParameters { + path := filepath.Join(srcPath, param.Path) + d.appSetFiles[path] = removeFromSlice[string](d.appSetFiles[path], app.Name, func(a, b string) bool { return a == b }) + } + + for _, valueFilePath := range helm.ValueFiles { + path := filepath.Join(srcPath, valueFilePath) + d.appSetFiles[path] = removeFromSlice[string](d.appSetFiles[path], app.Name, func(a, b string) bool { return a == b }) + } + } +} + +// containsKindApplicationSet checks if the file contains kind: ApplicationSet +func containsKindApplicationSet(path string) bool { + file, err := os.Open(path) + if err != nil { + log.Error().Err(err).Stack().Msgf("failed to open file %s: %v", path, err) + return false + } + defer func() { + if err := file.Close(); err != nil { + log.Warn().Err(err).Stack().Msgf("failed to close file %s: %v", path, err) + } + }() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "kind: ApplicationSet") { + log.Debug().Msgf("found kind: ApplicationSet in %s", path) + return true + } + } + + if err := scanner.Err(); err != nil { + log.Error().Err(err).Stack().Msgf("error reading file %s: %v", path, err) + } + + return false +} diff --git a/pkg/appdir/appset_directory_test.go b/pkg/appdir/appset_directory_test.go new file mode 100644 index 00000000..c2fa48c3 --- /dev/null +++ b/pkg/appdir/appset_directory_test.go @@ -0,0 +1,230 @@ +package appdir + +import ( + "os" + "path/filepath" + "testing" + + "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/zapier/kubechecks/pkg/git" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAppSetDirectory_ProcessApp(t *testing.T) { + + type args struct { + app v1alpha1.ApplicationSet + } + tests := []struct { + name string + input *AppSetDirectory + args args + expected *AppSetDirectory + }{ + { + name: "normal process, expect to get the appset stored in the map", + input: NewAppSetDirectory(), + args: args{ + app: v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-appset", + }, + Spec: v1alpha1.ApplicationSetSpec{ + Template: v1alpha1.ApplicationSetTemplate{ + Spec: v1alpha1.ApplicationSpec{ + Source: &v1alpha1.ApplicationSource{ + Path: "/test1/test2", + Helm: &v1alpha1.ApplicationSourceHelm{ + ValueFiles: []string{"one.yaml", "./two.yaml", "../three.yaml"}, + FileParameters: []v1alpha1.HelmFileParameter{ + {Name: "one", Path: "one.json"}, + {Name: "two", Path: "./two.json"}, + {Name: "three", Path: "../three.json"}, + }, + }, + }, + }, + }, + }, + }, + }, + expected: &AppSetDirectory{ + appSetDirs: map[string][]string{"/test1/test2": {"test-appset"}}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &AppSetDirectory{ + appSetDirs: tt.input.appSetDirs, + appSetFiles: tt.input.appSetFiles, + appSetsMap: tt.input.appSetsMap, + } + d.ProcessApp(tt.args.app) + assert.Equal(t, tt.expected.appSetDirs, d.appSetDirs) + }) + } +} + +func TestAppSetDirectory_FindAppsBasedOnChangeList(t *testing.T) { + + tests := []struct { + name string + changeList []string + targetBranch string + mockFiles map[string]string // Mock file content + expected []v1alpha1.ApplicationSet + }{ + { + name: "Valid ApplicationSet", + changeList: []string{ + "appsets/httpdump/valid-appset.yaml", + }, + targetBranch: "main", + mockFiles: map[string]string{ + "appsets/httpdump/valid-appset.yaml": ` +apiVersion: argoproj.io/v1alpha1 +kind: ApplicationSet +metadata: + name: httpdump + namespace: kubechecks +spec: + generators: + # this is a simple list generator + - list: + elements: + - name: a + url: https://kubernetes.default.svc + template: + metadata: + finalizers: + - resources-finalizer.argocd.argoproj.io + name: "in-cluster-{{ name }}-httpdump" + namespace: kubechecks + labels: + argocd.argoproj.io/application-set-name: "httpdump" + spec: + destination: + namespace: "httpdump-{{ name }}" + server: '{{ url }}' + project: default + source: + repoURL: REPO_URL + targetRevision: HEAD + path: 'apps/httpdump/overlays/{{ name }}/' + syncPolicy: + automated: + prune: true + syncOptions: + - CreateNamespace=true + sources: [] + `, + }, + expected: []v1alpha1.ApplicationSet{ + { + TypeMeta: metav1.TypeMeta{ + APIVersion: "argoproj.io/v1alpha1", + Kind: "ApplicationSet", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "httpdump", + Namespace: "kubechecks", + }, + Spec: v1alpha1.ApplicationSetSpec{ + Template: v1alpha1.ApplicationSetTemplate{ + ApplicationSetTemplateMeta: v1alpha1.ApplicationSetTemplateMeta{ + Name: "in-cluster-{{ name }}-httpdump", + Namespace: "kubechecks", + Labels: map[string]string{ + "argocd.argoproj.io/application-set-name": "httpdump", + }, + Finalizers: []string{"resources-finalizer.argocd.argoproj.io"}, + }, + Spec: v1alpha1.ApplicationSpec{ + Source: &v1alpha1.ApplicationSource{ + RepoURL: "REPO_URL", + Path: "apps/httpdump/overlays/{{ name }}/", + TargetRevision: "HEAD", + }, + Destination: v1alpha1.ApplicationDestination{ + Namespace: "httpdump-{{ name }}", + Server: "{{ url }}", + }, + Project: "default", + SyncPolicy: &v1alpha1.SyncPolicy{ + Automated: &v1alpha1.SyncPolicyAutomated{ + Prune: true, + }, + SyncOptions: []string{"CreateNamespace=true"}, + }, + Sources: v1alpha1.ApplicationSources{}, + }, + }, + Generators: []v1alpha1.ApplicationSetGenerator{ + { + List: &v1alpha1.ListGenerator{ + Elements: []v1.JSON{{Raw: []byte("{\"name\":\"a\",\"url\":\"https://kubernetes.default.svc\"}")}}, + Template: v1alpha1.ApplicationSetTemplate{}, + ElementsYaml: "", + }, + }, + }, + }, + }, + }, + }, + { + name: "Invalid YAML File", + changeList: []string{ + "invalid-appset.yaml", + }, + targetBranch: "main", + mockFiles: map[string]string{ + "appsets/httpdump/invalid-appset.yaml": "invalid yaml content", + }, + expected: nil, + }, + // Add more test cases as needed + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a temporary directory for the test + tempDir := os.TempDir() + var fatalErr error + var cleanUpDirs []string + for fileName, content := range tt.mockFiles { + absPath := filepath.Join(tempDir, fileName) + cleanUpDirs = append(cleanUpDirs, absPath) + err := os.MkdirAll(filepath.Dir(absPath), 0755) + if err != nil { + fatalErr = err + break + } + err = os.WriteFile(absPath, []byte(content), 0644) + if err != nil { + fatalErr = err + break + } + } + defer cleanUpTmpFiles(t, cleanUpDirs) + if fatalErr != nil { + t.Fatalf("failed to create tmp folder %s", fatalErr) + } + d := &AppSetDirectory{} + result := d.FindAppsBasedOnChangeList(tt.changeList, tt.targetBranch, &git.Repo{Directory: tempDir}) + assert.Equal(t, tt.expected, result) + }) + } +} + +// cleanUpTmpFiles removes the temporary directories created for the test +func cleanUpTmpFiles(t *testing.T, cleanUpDirs []string) { + for _, dir := range cleanUpDirs { + if err := os.RemoveAll(filepath.Dir(dir)); err != nil { + t.Fatalf("failed to remove tmp folder %s", err) + } + } +} diff --git a/pkg/appdir/vcstoargomap.go b/pkg/appdir/vcstoargomap.go index 3abad604..0becae94 100644 --- a/pkg/appdir/vcstoargomap.go +++ b/pkg/appdir/vcstoargomap.go @@ -5,19 +5,20 @@ import ( "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" "github.com/rs/zerolog/log" - "github.com/zapier/kubechecks/pkg" ) type VcsToArgoMap struct { - username string - appDirByRepo map[pkg.RepoURL]*AppDirectory + username string + appDirByRepo map[pkg.RepoURL]*AppDirectory + appSetDirByRepo map[pkg.RepoURL]*AppSetDirectory } func NewVcsToArgoMap(vcsUsername string) VcsToArgoMap { return VcsToArgoMap{ - username: vcsUsername, - appDirByRepo: make(map[pkg.RepoURL]*AppDirectory), + username: vcsUsername, + appDirByRepo: make(map[pkg.RepoURL]*AppDirectory), + appSetDirByRepo: make(map[pkg.RepoURL]*AppSetDirectory), } } @@ -25,6 +26,10 @@ func (v2a VcsToArgoMap) GetMap() map[pkg.RepoURL]*AppDirectory { return v2a.appDirByRepo } +func (v2a VcsToArgoMap) GetAppSetMap() map[pkg.RepoURL]*AppSetDirectory { + return v2a.appSetDirByRepo +} + func (v2a VcsToArgoMap) GetAppsInRepo(repoCloneUrl string) *AppDirectory { repoUrl, _, err := pkg.NormalizeRepoUrl(repoCloneUrl) if err != nil { @@ -40,6 +45,21 @@ func (v2a VcsToArgoMap) GetAppsInRepo(repoCloneUrl string) *AppDirectory { return appdir } +// GetAppSetsInRepo returns AppSetDirectory for the specified repository URL. +func (v2a VcsToArgoMap) GetAppSetsInRepo(repoCloneUrl string) *AppSetDirectory { + repoUrl, _, err := pkg.NormalizeRepoUrl(repoCloneUrl) + if err != nil { + log.Warn().Err(err).Msgf("failed to parse %s", repoCloneUrl) + } + appSetDir := v2a.appSetDirByRepo[repoUrl] + if appSetDir == nil { + appSetDir = NewAppSetDirectory() + v2a.appSetDirByRepo[repoUrl] = appSetDir + } + + return appSetDir +} + func (v2a VcsToArgoMap) WalkKustomizeApps(cloneURL string, fs fs.FS) *AppDirectory { var ( err error @@ -98,6 +118,41 @@ func (v2a VcsToArgoMap) GetVcsRepos() []string { for key := range v2a.appDirByRepo { repos = append(repos, key.CloneURL(v2a.username)) } - + for key := range v2a.appSetDirByRepo { + repos = append(repos, key.CloneURL(v2a.username)) + } return repos } + +func (v2a VcsToArgoMap) AddAppSet(app *v1alpha1.ApplicationSet) { + if app.Spec.Template.Spec.GetSource().RepoURL == "" { + log.Warn().Msgf("%s/%s: no source, skipping", app.Namespace, app.Name) + return + } + + appDirectory := v2a.GetAppSetsInRepo(app.Spec.Template.Spec.GetSource().RepoURL) + appDirectory.ProcessApp(*app) +} + +func (v2a VcsToArgoMap) UpdateAppSet(old *v1alpha1.ApplicationSet, new *v1alpha1.ApplicationSet) { + if new.Spec.Template.Spec.GetSource().RepoURL == "" { + log.Warn().Msgf("%s/%s: no source, skipping", new.Namespace, new.Name) + return + } + + oldAppDirectory := v2a.GetAppSetsInRepo(old.Spec.Template.Spec.GetSource().RepoURL) + oldAppDirectory.RemoveApp(*old) + + newAppDirectory := v2a.GetAppSetsInRepo(new.Spec.Template.Spec.GetSource().RepoURL) + newAppDirectory.ProcessApp(*new) +} + +func (v2a VcsToArgoMap) DeleteAppSet(app *v1alpha1.ApplicationSet) { + if app.Spec.Template.Spec.GetSource().RepoURL == "" { + log.Warn().Msgf("%s/%s: no source, skipping", app.Namespace, app.Name) + return + } + + oldAppDirectory := v2a.GetAppSetsInRepo(app.Spec.Template.Spec.GetSource().RepoURL) + oldAppDirectory.RemoveApp(*app) +} diff --git a/pkg/appdir/vcstoargomap_test.go b/pkg/appdir/vcstoargomap_test.go new file mode 100644 index 00000000..95a5d088 --- /dev/null +++ b/pkg/appdir/vcstoargomap_test.go @@ -0,0 +1,188 @@ +package appdir + +import ( + "testing" + + "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestAddApp tests the AddApp method from the VcsToArgoMap type. +func TestAddApp(t *testing.T) { + // Setup your mocks and expected calls here. + + v2a := NewVcsToArgoMap("vcs-username") // This would be mocked accordingly. + app1 := &v1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "test-app-1", Namespace: "default"}, + Spec: v1alpha1.ApplicationSpec{ + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argo-cd.git", + Path: "test-app-1", + }, + }, + } + + v2a.AddApp(app1) + appDir := v2a.GetAppsInRepo("https://github.com/argoproj/argo-cd.git") + + assert.Equal(t, appDir.Count(), 1) + assert.Equal(t, len(appDir.appDirs["test-app-1"]), 1) + + // Assertions to verify the behavior here. + app2 := &v1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "test-app-2", Namespace: "default"}, + Spec: v1alpha1.ApplicationSpec{ + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argo-cd.git", + Path: "test-app-2", + }, + }, + } + + v2a.AddApp(app2) + assert.Equal(t, appDir.Count(), 2) + assert.Equal(t, len(appDir.appDirs["test-app-2"]), 1) +} + +func TestDeleteApp(t *testing.T) { + // Setup your mocks and expected calls here. + + v2a := NewVcsToArgoMap("vcs-username") // This would be mocked accordingly. + app1 := &v1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "test-app-1", Namespace: "default"}, + Spec: v1alpha1.ApplicationSpec{ + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argo-cd.git", + Path: "test-app-1", + }, + }, + } + // Assertions to verify the behavior here. + app2 := &v1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "test-app-2", Namespace: "default"}, + Spec: v1alpha1.ApplicationSpec{ + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argo-cd.git", + Path: "test-app-2", + }, + }, + } + + v2a.AddApp(app1) + v2a.AddApp(app2) + appDir := v2a.GetAppsInRepo("https://github.com/argoproj/argo-cd.git") + + assert.Equal(t, appDir.Count(), 2) + assert.Equal(t, len(appDir.appDirs["test-app-1"]), 1) + assert.Equal(t, len(appDir.appDirs["test-app-2"]), 1) + + v2a.DeleteApp(app2) + assert.Equal(t, appDir.Count(), 1) + assert.Equal(t, len(appDir.appDirs["test-app-2"]), 0) +} + +func TestVcsToArgoMap_AddAppSet(t *testing.T) { + type args struct { + app *v1alpha1.ApplicationSet + } + tests := []struct { + name string + fields VcsToArgoMap + args args + expectedCount int + }{ + { + name: "normal process, expect to get the appset stored in the map", + fields: NewVcsToArgoMap("dummyuser"), + args: args{ + app: &v1alpha1.ApplicationSet{ + Spec: v1alpha1.ApplicationSetSpec{ + Template: v1alpha1.ApplicationSetTemplate{ + ApplicationSetTemplateMeta: v1alpha1.ApplicationSetTemplateMeta{}, + Spec: v1alpha1.ApplicationSpec{ + Source: &v1alpha1.ApplicationSource{ + RepoURL: "http://gitnotreal.local/unittest/", + Path: "apps/unittest/{{ values.cluster }}", + }, + }, + }, + }, + }, + }, + expectedCount: 1, + }, + { + name: "invalid appset", + fields: NewVcsToArgoMap("vcs-username"), + args: args{ + app: &v1alpha1.ApplicationSet{ + Spec: v1alpha1.ApplicationSetSpec{ + Template: v1alpha1.ApplicationSetTemplate{ + ApplicationSetTemplateMeta: v1alpha1.ApplicationSetTemplateMeta{}, + Spec: v1alpha1.ApplicationSpec{}, + }, + }, + }, + }, + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v2a := VcsToArgoMap{ + username: tt.fields.username, + appDirByRepo: tt.fields.appDirByRepo, + appSetDirByRepo: tt.fields.appSetDirByRepo, + } + v2a.AddAppSet(tt.args.app) + assert.Equal(t, tt.expectedCount, len(v2a.appSetDirByRepo)) + }) + } +} + +func TestVcsToArgoMap_DeleteAppSet(t *testing.T) { + // Set up your mocks and expected calls here. + + v2a := NewVcsToArgoMap("vcs-username") // This would be mocked accordingly. + app1 := &v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test-app-1", Namespace: "default"}, + Spec: v1alpha1.ApplicationSetSpec{ + Template: v1alpha1.ApplicationSetTemplate{ + Spec: v1alpha1.ApplicationSpec{ + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argo-cd.git", + Path: "test-app-1", + }, + }, + }, + }, + } + // Assertions to verify the behavior here. + app2 := &v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test-app-2", Namespace: "default"}, + Spec: v1alpha1.ApplicationSetSpec{ + Template: v1alpha1.ApplicationSetTemplate{ + Spec: v1alpha1.ApplicationSpec{ + Source: &v1alpha1.ApplicationSource{ + RepoURL: "https://github.com/argoproj/argo-cd.git", + Path: "test-app-2", + }, + }, + }, + }, + } + + v2a.AddAppSet(app1) + v2a.AddAppSet(app2) + appDir := v2a.GetAppSetsInRepo("https://github.com/argoproj/argo-cd.git") + + assert.Equal(t, appDir.Count(), 2) + assert.Equal(t, len(appDir.appSetDirs["test-app-1"]), 1) + assert.Equal(t, len(appDir.appSetDirs["test-app-2"]), 1) + + v2a.DeleteAppSet(app2) + assert.Equal(t, appDir.Count(), 1) + assert.Equal(t, len(appDir.appSetDirs["test-app-2"]), 0) +} diff --git a/pkg/appdir/vcstoargumap_test.go b/pkg/appdir/vcstoargumap_test.go deleted file mode 100644 index 611f8fa1..00000000 --- a/pkg/appdir/vcstoargumap_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package appdir - -import ( - "testing" - - "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" - "github.com/stretchr/testify/assert" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// TestAddApp tests the AddApp method from the VcsToArgoMap type. -func TestAddApp(t *testing.T) { - // Setup your mocks and expected calls here. - - v2a := NewVcsToArgoMap("vcs-username") // This would be mocked accordingly. - app1 := &v1alpha1.Application{ - ObjectMeta: metav1.ObjectMeta{Name: "test-app-1", Namespace: "default"}, - Spec: v1alpha1.ApplicationSpec{ - Source: &v1alpha1.ApplicationSource{ - RepoURL: "https://github.com/argoproj/argo-cd.git", - Path: "test-app-1", - }, - }, - } - - v2a.AddApp(app1) - appDir := v2a.GetAppsInRepo("https://github.com/argoproj/argo-cd.git") - - assert.Equal(t, appDir.Count(), 1) - assert.Equal(t, len(appDir.appDirs["test-app-1"]), 1) - - // Assertions to verify the behavior here. - app2 := &v1alpha1.Application{ - ObjectMeta: metav1.ObjectMeta{Name: "test-app-2", Namespace: "default"}, - Spec: v1alpha1.ApplicationSpec{ - Source: &v1alpha1.ApplicationSource{ - RepoURL: "https://github.com/argoproj/argo-cd.git", - Path: "test-app-2", - }, - }, - } - - v2a.AddApp(app2) - assert.Equal(t, appDir.Count(), 2) - assert.Equal(t, len(appDir.appDirs["test-app-2"]), 1) -} - -func TestDeleteApp(t *testing.T) { - // Setup your mocks and expected calls here. - - v2a := NewVcsToArgoMap("vcs-username") // This would be mocked accordingly. - app1 := &v1alpha1.Application{ - ObjectMeta: metav1.ObjectMeta{Name: "test-app-1", Namespace: "default"}, - Spec: v1alpha1.ApplicationSpec{ - Source: &v1alpha1.ApplicationSource{ - RepoURL: "https://github.com/argoproj/argo-cd.git", - Path: "test-app-1", - }, - }, - } - // Assertions to verify the behavior here. - app2 := &v1alpha1.Application{ - ObjectMeta: metav1.ObjectMeta{Name: "test-app-2", Namespace: "default"}, - Spec: v1alpha1.ApplicationSpec{ - Source: &v1alpha1.ApplicationSource{ - RepoURL: "https://github.com/argoproj/argo-cd.git", - Path: "test-app-2", - }, - }, - } - - v2a.AddApp(app1) - v2a.AddApp(app2) - appDir := v2a.GetAppsInRepo("https://github.com/argoproj/argo-cd.git") - - assert.Equal(t, appDir.Count(), 2) - assert.Equal(t, len(appDir.appDirs["test-app-1"]), 1) - assert.Equal(t, len(appDir.appDirs["test-app-2"]), 1) - - v2a.DeleteApp(app2) - assert.Equal(t, appDir.Count(), 1) - assert.Equal(t, len(appDir.appDirs["test-app-2"]), 0) -} diff --git a/pkg/argo_client/applications.go b/pkg/argo_client/applications.go index e2b614fb..8694a555 100644 --- a/pkg/argo_client/applications.go +++ b/pkg/argo_client/applications.go @@ -132,8 +132,8 @@ func (argo *ArgoClient) GetApplicationSets(ctx context.Context) (*v1alpha1.Appli resp, err := appClient.List(ctx, new(applicationset.ApplicationSetListQuery)) if err != nil { - telemetry.SetError(span, err, "Argo List All Applications error") - return nil, errors.Wrap(err, "failed to applications") + telemetry.SetError(span, err, "Argo List All Application Sets error") + return nil, errors.Wrap(err, "failed to list application sets") } return resp, nil } diff --git a/pkg/config/config.go b/pkg/config/config.go index 82844a1b..846419af 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -17,12 +17,14 @@ import ( type ServerConfig struct { // argocd - ArgoCDServerAddr string `mapstructure:"argocd-api-server-addr"` - ArgoCDToken string `mapstructure:"argocd-api-token"` - ArgoCDPathPrefix string `mapstructure:"argocd-api-path-prefix"` - ArgoCDInsecure bool `mapstructure:"argocd-api-insecure"` - ArgoCDNamespace string `mapstructure:"argocd-api-namespace"` - KubernetesConfig string `mapstructure:"kubernetes-config"` + ArgoCDServerAddr string `mapstructure:"argocd-api-server-addr"` + ArgoCDToken string `mapstructure:"argocd-api-token"` + ArgoCDPathPrefix string `mapstructure:"argocd-api-path-prefix"` + ArgoCDInsecure bool `mapstructure:"argocd-api-insecure"` + ArgoCDNamespace string `mapstructure:"argocd-api-namespace"` + KubernetesConfig string `mapstructure:"kubernetes-config"` + KubernetesType string `mapstructure:"kubernetes-type"` + KubernetesClusterID string `mapstructure:"kubernetes-clusterid"` // otel EnableOtel bool `mapstructure:"otel-enabled"` diff --git a/pkg/container/main.go b/pkg/container/main.go index 12234447..a330af3f 100644 --- a/pkg/container/main.go +++ b/pkg/container/main.go @@ -5,6 +5,7 @@ import ( "io/fs" "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + client "github.com/zapier/kubechecks/pkg/kubernetes" "github.com/zapier/kubechecks/pkg" "github.com/zapier/kubechecks/pkg/app_watcher" @@ -16,8 +17,9 @@ import ( ) type Container struct { - ApplicationWatcher *app_watcher.ApplicationWatcher - ArgoClient *argo_client.ArgoClient + ApplicationWatcher *app_watcher.ApplicationWatcher + ApplicationSetWatcher *app_watcher.ApplicationSetWatcher + ArgoClient *argo_client.ArgoClient Config config.ServerConfig @@ -25,14 +27,20 @@ type Container struct { VcsClient vcs.Client VcsToArgoMap VcsToArgoMap + + KubeClientSet client.Interface } type VcsToArgoMap interface { AddApp(*v1alpha1.Application) + AddAppSet(*v1alpha1.ApplicationSet) UpdateApp(old, new *v1alpha1.Application) + UpdateAppSet(old *v1alpha1.ApplicationSet, new *v1alpha1.ApplicationSet) DeleteApp(*v1alpha1.Application) + DeleteAppSet(app *v1alpha1.ApplicationSet) GetVcsRepos() []string GetAppsInRepo(string) *appdir.AppDirectory + GetAppSetsInRepo(string) *appdir.AppSetDirectory GetMap() map[pkg.RepoURL]*appdir.AppDirectory WalkKustomizeApps(cloneURL string, fs fs.FS) *appdir.AppDirectory } diff --git a/pkg/events/check.go b/pkg/events/check.go index 23bf0d9c..54b54bdf 100644 --- a/pkg/events/check.go +++ b/pkg/events/check.go @@ -13,6 +13,8 @@ import ( "github.com/pkg/errors" "github.com/rs/zerolog" "github.com/rs/zerolog/log" + "github.com/zapier/kubechecks/pkg/generator" + "github.com/zapier/kubechecks/pkg/repo_config" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" @@ -22,7 +24,6 @@ import ( "github.com/zapier/kubechecks/pkg/container" "github.com/zapier/kubechecks/pkg/git" "github.com/zapier/kubechecks/pkg/msg" - "github.com/zapier/kubechecks/pkg/repo_config" "github.com/zapier/kubechecks/pkg/vcs" "github.com/zapier/kubechecks/telemetry" ) @@ -49,13 +50,34 @@ type CheckEvent struct { appsSent int32 appChannel chan *v1alpha1.Application wg sync.WaitGroup + generator generator.AppsGenerator + matcher affected_apps.Matcher } type repoManager interface { Clone(ctx context.Context, cloneURL, branchName string) (*git.Repo, error) } +func GenerateMatcher(ce *CheckEvent, repo *git.Repo) error { + log.Debug().Msg("using the argocd matcher") + m, err := affected_apps.NewArgocdMatcher(ce.ctr.VcsToArgoMap, repo) + if err != nil { + return errors.Wrap(err, "failed to create argocd matcher") + } + ce.matcher = m + cfg, err := repo_config.LoadRepoConfig(repo.Directory) + if err != nil { + return errors.Wrap(err, "failed to load repo config") + } else if cfg != nil { + log.Debug().Msg("using the config matcher") + configMatcher := affected_apps.NewConfigMatcher(cfg, ce.ctr) + ce.matcher = affected_apps.NewMultiMatcher(ce.matcher, configMatcher) + } + return nil +} + func NewCheckEvent(pullRequest vcs.PullRequest, ctr container.Container, repoManager repoManager, processors []checks.ProcessorEntry) *CheckEvent { + ce := &CheckEvent{ addedAppsSet: make(map[string]v1alpha1.Application), appChannel: make(chan *v1alpha1.Application, ctr.Config.MaxQueueSize), @@ -64,6 +86,7 @@ func NewCheckEvent(pullRequest vcs.PullRequest, ctr container.Container, repoMan processors: processors, pullRequest: pullRequest, repoManager: repoManager, + generator: generator.New(), logger: log.Logger.With(). Str("repo", pullRequest.Name). Int("event_id", pullRequest.CheckID). @@ -87,34 +110,35 @@ func (ce *CheckEvent) UpdateListOfChangedFiles(ctx context.Context, repo *git.Re return nil } +type MatcherFn func(ce *CheckEvent, repo *git.Repo) error + // GenerateListOfAffectedApps walks the repo to find any apps or appsets impacted by the changes in the MR/PR. -func (ce *CheckEvent) GenerateListOfAffectedApps(ctx context.Context, repo *git.Repo, targetBranch string) error { +func (ce *CheckEvent) GenerateListOfAffectedApps(ctx context.Context, repo *git.Repo, targetBranch string, initMatcherFn MatcherFn) error { _, span := tracer.Start(ctx, "GenerateListOfAffectedApps") defer span.End() var err error - var matcher affected_apps.Matcher - - log.Debug().Msg("using an argocd matcher") - matcher, err = affected_apps.NewArgocdMatcher(ce.ctr.VcsToArgoMap, repo) + err = initMatcherFn(ce, repo) if err != nil { return errors.Wrap(err, "failed to create argocd matcher") } - cfg, err := repo_config.LoadRepoConfig(repo.Directory) - if err != nil { - return errors.Wrap(err, "failed to load repo config") - } else if cfg != nil { - log.Debug().Msg("using the config matcher") - configMatcher := affected_apps.NewConfigMatcher(cfg, ce.ctr) - matcher = affected_apps.NewMultiMatcher(matcher, configMatcher) - } - - ce.affectedItems, err = matcher.AffectedApps(ctx, ce.fileList, targetBranch) + // use the changed file path to get the list of affected apps + // fileList is a list of changed files in the PR/MR, e.g. ["path/to/file1", "path/to/file2"] + ce.affectedItems, err = ce.matcher.AffectedApps(ctx, ce.fileList, targetBranch, repo) if err != nil { telemetry.SetError(span, err, "Get Affected Apps") ce.logger.Error().Err(err).Msg("could not get list of affected apps and appsets") } + for _, appSet := range ce.affectedItems.ApplicationSets { + apps, err := ce.generator.GenerateApplicationSetApps(ctx, appSet, &ce.ctr) + if err != nil { + ce.logger.Error().Err(err).Msg("could not generate apps from appSet") + continue + } + ce.affectedItems.Applications = append(ce.affectedItems.Applications, apps...) + } + span.SetAttributes( attribute.Int("numAffectedApps", len(ce.affectedItems.Applications)), attribute.Int("numAffectedAppSets", len(ce.affectedItems.ApplicationSets)), @@ -149,7 +173,7 @@ func (ce *CheckEvent) getRepo(ctx context.Context, vcsClient hasUsername, cloneU err error repo *git.Repo ) - + ce.logger.Info().Stack().Str("branchName", branchName).Msg("cloning repo") ce.repoLock.Lock() defer ce.repoLock.Unlock() @@ -209,7 +233,7 @@ func (ce *CheckEvent) Process(ctx context.Context) error { _, span := tracer.Start(ctx, "GenerateListOfAffectedApps") defer span.End() - // Clone the repo's BaseRef (main etc) locally into the temp dir we just made + // Clone the repo's BaseRef (main, etc.) locally into the temp dir we just made repo, err := ce.getRepo(ctx, ce.ctr.VcsClient, ce.pullRequest.CloneURL, ce.pullRequest.BaseRef) if err != nil { return errors.Wrap(err, "failed to clone repo") @@ -226,7 +250,7 @@ func (ce *CheckEvent) Process(ctx context.Context) error { } // Generate a list of affected apps, storing them within the CheckEvent (also returns but discarded here) - if err = ce.GenerateListOfAffectedApps(ctx, repo, ce.pullRequest.BaseRef); err != nil { + if err = ce.GenerateListOfAffectedApps(ctx, repo, ce.pullRequest.BaseRef, GenerateMatcher); err != nil { return errors.Wrap(err, "failed to generate a list of affected apps") } @@ -259,7 +283,7 @@ func (ce *CheckEvent) Process(ctx context.Context) error { } go w.run(ctx) } - + ce.logger.Info().Msgf("adding %d apps to the queue", len(ce.affectedItems.Applications)) // Produce apps onto channel for _, app := range ce.affectedItems.Applications { ce.queueApp(app) @@ -269,7 +293,7 @@ func (ce *CheckEvent) Process(ctx context.Context) error { close(ce.appChannel) - ce.logger.Debug().Msg("finished an app") + ce.logger.Debug().Msg("finished an app/appsets") ce.logger.Debug(). Int("all apps", len(ce.addedAppsSet)). diff --git a/pkg/events/check_test.go b/pkg/events/check_test.go index 1dbbb5ee..ff7505b4 100644 --- a/pkg/events/check_test.go +++ b/pkg/events/check_test.go @@ -6,11 +6,22 @@ import ( "fmt" "testing" + "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + "github.com/rs/zerolog" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - + affectedappsmocks "github.com/zapier/kubechecks/mocks/affected_apps/mocks" + generatorsmocks "github.com/zapier/kubechecks/mocks/generator/mocks" + "github.com/zapier/kubechecks/pkg/affected_apps" + "github.com/zapier/kubechecks/pkg/checks" "github.com/zapier/kubechecks/pkg/config" + "github.com/zapier/kubechecks/pkg/container" + "github.com/zapier/kubechecks/pkg/generator" "github.com/zapier/kubechecks/pkg/git" + "github.com/zapier/kubechecks/pkg/msg" + "github.com/zapier/kubechecks/pkg/vcs" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // TestCleanupGetManifestsError tests the cleanupGetManifestsError function. @@ -123,3 +134,182 @@ func TestCheckEventGetRepo(t *testing.T) { assert.Contains(t, ce.clonedRepos, generateRepoKey(canonical, "gh-pages")) }) } + +func TestCheckEvent_GenerateListOfAffectedApps(t *testing.T) { + type fields struct { + fileList []string + pullRequest vcs.PullRequest + logger zerolog.Logger + vcsNote *msg.Message + affectedItems affected_apps.AffectedItems + ctr container.Container + repoManager repoManager + processors []checks.ProcessorEntry + clonedRepos map[string]*git.Repo + addedAppsSet map[string]v1alpha1.Application + appsSent int32 + appChannel chan *v1alpha1.Application + generator generator.AppsGenerator + matcher affected_apps.Matcher + } + type args struct { + ctx context.Context + repo *git.Repo + targetBranch string + initMatcherFn MatcherFn + } + tests := []struct { + name string + fields fields + args args + expectedAppCount int + wantErr assert.ErrorAssertionFunc + }{ + // TODO: Add test cases. + { + name: "no error", + fields: fields{ + fileList: nil, + pullRequest: vcs.PullRequest{}, + logger: zerolog.Logger{}, + vcsNote: nil, + affectedItems: affected_apps.AffectedItems{}, + ctr: container.Container{}, + repoManager: nil, + processors: nil, + clonedRepos: nil, + addedAppsSet: nil, + appsSent: 0, + appChannel: nil, + generator: MockGenerator("GenerateApplicationSetApps", []interface{}{[]v1alpha1.Application{}, nil}), + matcher: MockMatcher("AffectedApps", []interface{}{ + affected_apps.AffectedItems{ + ApplicationSets: []v1alpha1.ApplicationSet{ + { + TypeMeta: metav1.TypeMeta{Kind: "ApplicationSet", APIVersion: "argoproj.io/v1alpha1"}, + ObjectMeta: metav1.ObjectMeta{Name: "appset1"}, + }, + }, + }, + nil, + }), + }, + args: args{ + ctx: context.Background(), + repo: &git.Repo{Directory: "/tmp"}, + targetBranch: "HEAD", + initMatcherFn: MockInitMatcherFn(), + }, + expectedAppCount: 1, + wantErr: assert.NoError, + }, + { + name: "matcher error", + fields: fields{ + fileList: nil, + pullRequest: vcs.PullRequest{}, + logger: zerolog.Logger{}, + vcsNote: nil, + affectedItems: affected_apps.AffectedItems{}, + ctr: container.Container{}, + repoManager: nil, + processors: nil, + clonedRepos: nil, + addedAppsSet: nil, + appsSent: 0, + appChannel: nil, + generator: MockGenerator("GenerateApplicationSetApps", []interface{}{[]v1alpha1.Application{}, nil}), + matcher: MockMatcher("AffectedApps", []interface{}{ + affected_apps.AffectedItems{}, + fmt.Errorf("mock error"), + }), + }, + args: args{ + ctx: context.Background(), + repo: &git.Repo{Directory: "/tmp"}, + targetBranch: "HEAD", + initMatcherFn: MockInitMatcherFn(), + }, + expectedAppCount: 0, + wantErr: assert.Error, + }, + { + name: "generator error", + fields: fields{ + fileList: nil, + pullRequest: vcs.PullRequest{}, + logger: zerolog.Logger{}, + vcsNote: nil, + affectedItems: affected_apps.AffectedItems{}, + ctr: container.Container{}, + repoManager: nil, + processors: nil, + clonedRepos: nil, + addedAppsSet: nil, + appsSent: 0, + appChannel: nil, + generator: MockGenerator("GenerateApplicationSetApps", []interface{}{[]v1alpha1.Application{}, fmt.Errorf("mock error")}), + matcher: MockMatcher("AffectedApps", []interface{}{ + affected_apps.AffectedItems{ + ApplicationSets: []v1alpha1.ApplicationSet{ + { + TypeMeta: metav1.TypeMeta{Kind: "ApplicationSet", APIVersion: "argoproj.io/v1alpha1"}, + ObjectMeta: metav1.ObjectMeta{Name: "appset1"}, + }, + }, + }, + nil, + }), + }, + args: args{ + ctx: context.Background(), + repo: &git.Repo{Directory: "/tmp"}, + targetBranch: "HEAD", + initMatcherFn: MockInitMatcherFn(), + }, + expectedAppCount: 0, + wantErr: assert.NoError, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ce := &CheckEvent{ + fileList: tt.fields.fileList, + pullRequest: tt.fields.pullRequest, + logger: tt.fields.logger, + vcsNote: tt.fields.vcsNote, + affectedItems: tt.fields.affectedItems, + ctr: tt.fields.ctr, + repoManager: tt.fields.repoManager, + processors: tt.fields.processors, + clonedRepos: tt.fields.clonedRepos, + addedAppsSet: tt.fields.addedAppsSet, + appsSent: tt.fields.appsSent, + appChannel: tt.fields.appChannel, + generator: tt.fields.generator, + matcher: tt.fields.matcher, + } + tt.wantErr(t, ce.GenerateListOfAffectedApps(tt.args.ctx, tt.args.repo, tt.args.targetBranch, tt.args.initMatcherFn), fmt.Sprintf("GenerateListOfAffectedApps(%v, %v, %v, %v)", tt.args.ctx, tt.args.repo, tt.args.targetBranch, tt.args.initMatcherFn)) + + }) + } +} + +func MockMatcher(methodName string, returns []interface{}) affected_apps.Matcher { + mockClient := new(affectedappsmocks.MockMatcher) + mockClient.On(methodName, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(returns...) + + return mockClient +} + +func MockGenerator(methodName string, returns []interface{}) generator.AppsGenerator { + mockClient := new(generatorsmocks.MockAppsGenerator) + mockClient.On(methodName, mock.Anything, mock.Anything, mock.Anything).Return(returns...) + + return mockClient +} +func MockInitMatcherFn() MatcherFn { + return func(ce *CheckEvent, repo *git.Repo) error { + return nil + } +} diff --git a/pkg/events/worker.go b/pkg/events/worker.go index 1962b95a..58f0e47b 100644 --- a/pkg/events/worker.go +++ b/pkg/events/worker.go @@ -5,11 +5,11 @@ import ( "fmt" "sync/atomic" - "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" - "github.com/rs/zerolog" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + "github.com/rs/zerolog" "github.com/zapier/kubechecks/pkg" "github.com/zapier/kubechecks/pkg/argo_client" "github.com/zapier/kubechecks/pkg/checks" diff --git a/pkg/generator/README.md b/pkg/generator/README.md new file mode 100644 index 00000000..7f2dfc5a --- /dev/null +++ b/pkg/generator/README.md @@ -0,0 +1,19 @@ +# Argo CD Generators +This directory contains the code for Argo CD generators. Generators dynamically create Kubernetes resources from various sources of truth, such as Kustomize, Helm, Ksonnet, and others. They are a key component in Argo CD for automating resource creation and management. + +## Overview +Generators in Argo CD enable the dynamic generation of Kubernetes manifests based on the desired state defined in different configurations. By leveraging these generators, Argo CD can efficiently manage and deploy resources across different environments. + +## Why Forked? +This code is a fork of the Argo CD (v2.12) generator code. The fork was necessary due to an incompatibility between Kubechecks' use of the go-gitlab library and Argo CD's generator code. To resolve this, the generator code has been forked and adapted for compatibility with Kubechecks. + +## Supported Generators +* Lists +* Clusters + +## Unsupported Generators +* Git +* Pull Requests + +## Usage +You can use these generators to automate the creation and management of Kubernetes resources in your environment, ensuring consistency and repeatability. diff --git a/pkg/generator/applicationsets.go b/pkg/generator/applicationsets.go new file mode 100644 index 00000000..9e23c827 --- /dev/null +++ b/pkg/generator/applicationsets.go @@ -0,0 +1,182 @@ +package generator + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/argoproj/argo-cd/v2/applicationset/utils" + argov1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + "github.com/zapier/kubechecks/pkg/container" + "k8s.io/apimachinery/pkg/util/strategicpatch" + "k8s.io/client-go/kubernetes" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func New() AppsGenerator { + return &gen{} +} + +type gen struct { +} + +type AppsGenerator interface { + GenerateApplicationSetApps(ctx context.Context, appset argov1alpha1.ApplicationSet, ctr *container.Container) ([]argov1alpha1.Application, error) +} + +func (c *gen) GenerateApplicationSetApps(ctx context.Context, appset argov1alpha1.ApplicationSet, ctr *container.Container) ([]argov1alpha1.Application, error) { + + appSetGenerators := getGenerators(ctx, *ctr.KubeClientSet.ControllerClient(), ctr.KubeClientSet.ClientSet(), ctr.Config.ArgoCDNamespace) + + apps, appsetReason, err := generateApplications(appset, appSetGenerators) + if err != nil { + fmt.Printf("error generating applications: %v, appset reason: %v", err, appsetReason) + return nil, fmt.Errorf("error generating applications: %w", err) + } + return apps, nil +} + +// GetGenerators returns the generators that will be used to generate applications for the ApplicationSet +// +// only support List and Clusters generators +func getGenerators(ctx context.Context, c client.Client, k8sClient kubernetes.Interface, namespace string) map[string]Generator { + + terminalGenerators := map[string]Generator{ + "List": NewListGenerator(), + "Clusters": NewClusterGenerator(c, ctx, k8sClient, namespace), + } + + nestedGenerators := map[string]Generator{ + "List": terminalGenerators["List"], + "Clusters": terminalGenerators["Clusters"], + "Matrix": NewMatrixGenerator(terminalGenerators), + "Merge": NewMergeGenerator(terminalGenerators), + } + + topLevelGenerators := map[string]Generator{ + "List": terminalGenerators["List"], + "Clusters": terminalGenerators["Clusters"], + "Matrix": NewMatrixGenerator(nestedGenerators), + "Merge": NewMergeGenerator(nestedGenerators), + } + return topLevelGenerators +} + +// generateApplications generates applications from the ApplicationSet +func generateApplications(applicationSetInfo argov1alpha1.ApplicationSet, g map[string]Generator) ( + []argov1alpha1.Application, argov1alpha1.ApplicationSetReasonType, error, +) { + var res []argov1alpha1.Application + renderer := &utils.Render{} + var firstError error + var applicationSetReason argov1alpha1.ApplicationSetReasonType + + for _, requestedGenerator := range applicationSetInfo.Spec.Generators { + t, err := Transform(requestedGenerator, g, applicationSetInfo.Spec.Template, &applicationSetInfo, map[string]interface{}{}) + if err != nil { + if firstError == nil { + firstError = err + applicationSetReason = argov1alpha1.ApplicationSetReasonApplicationParamsGenerationError + } + continue + } + + for _, a := range t { + tmplApplication := getTempApplication(a.Template) + + for _, p := range a.Params { + app, err := renderer.RenderTemplateParams(tmplApplication, applicationSetInfo.Spec.SyncPolicy, p, applicationSetInfo.Spec.GoTemplate, applicationSetInfo.Spec.GoTemplateOptions) + if err != nil { + //logCtx.WithError(err).WithField("params", a.Params).WithField("generator", requestedGenerator). + // Error("error generating application from params") + + if firstError == nil { + firstError = err + applicationSetReason = argov1alpha1.ApplicationSetReasonRenderTemplateParamsError + } + continue + } + + if applicationSetInfo.Spec.TemplatePatch != nil { + patchedApplication, err := renderTemplatePatch(renderer, app, applicationSetInfo, p) + if err != nil { + if firstError == nil { + firstError = err + applicationSetReason = argov1alpha1.ApplicationSetReasonRenderTemplateParamsError + } + continue + } + + app = patchedApplication + } + + // The app's namespace must be the same as the AppSet's namespace to preserve the appsets-in-any-namespace + // security boundary. + app.Namespace = applicationSetInfo.Namespace + res = append(res, *app) + } + } + + //logCtx.WithField("generator", requestedGenerator).Infof("generated %d applications", len(res)) + //logCtx.WithField("generator", requestedGenerator).Debugf("apps from generator: %+v", res) + } + + return res, applicationSetReason, firstError +} + +func renderTemplatePatch(r utils.Renderer, app *argov1alpha1.Application, applicationSetInfo argov1alpha1.ApplicationSet, params map[string]interface{}) (*argov1alpha1.Application, error) { + replacedTemplate, err := r.Replace(*applicationSetInfo.Spec.TemplatePatch, params, applicationSetInfo.Spec.GoTemplate, applicationSetInfo.Spec.GoTemplateOptions) + if err != nil { + return nil, fmt.Errorf("error replacing values in templatePatch: %w", err) + } + + return applyTemplatePatch(app, replacedTemplate) +} + +func getTempApplication(applicationSetTemplate argov1alpha1.ApplicationSetTemplate) *argov1alpha1.Application { + tmplApplication := argov1alpha1.Application{} + tmplApplication.Annotations = applicationSetTemplate.Annotations + tmplApplication.Labels = applicationSetTemplate.Labels + tmplApplication.Namespace = applicationSetTemplate.Namespace + tmplApplication.Name = applicationSetTemplate.Name + tmplApplication.Spec = applicationSetTemplate.Spec + tmplApplication.Finalizers = applicationSetTemplate.Finalizers + tmplApplication.APIVersion = "argoproj.io/v1alpha1" + tmplApplication.Kind = "Application" + return &tmplApplication +} + +func applyTemplatePatch(app *argov1alpha1.Application, templatePatch string) (*argov1alpha1.Application, error) { + + appString, err := json.Marshal(app) + if err != nil { + return nil, fmt.Errorf("error while marhsalling Application %w", err) + } + + convertedTemplatePatch, err := utils.ConvertYAMLToJSON(templatePatch) + + if err != nil { + return nil, fmt.Errorf("error while converting template to json %q: %w", convertedTemplatePatch, err) + } + + if err := json.Unmarshal([]byte(convertedTemplatePatch), &argov1alpha1.Application{}); err != nil { + return nil, fmt.Errorf("invalid templatePatch %q: %w", convertedTemplatePatch, err) + } + + data, err := strategicpatch.StrategicMergePatch(appString, []byte(convertedTemplatePatch), argov1alpha1.Application{}) + + if err != nil { + return nil, fmt.Errorf("error while applying templatePatch template to json %q: %w", convertedTemplatePatch, err) + } + + finalApp := argov1alpha1.Application{} + err = json.Unmarshal(data, &finalApp) + if err != nil { + return nil, fmt.Errorf("error while unmarhsalling patched application: %w", err) + } + + // Prevent changes to the `project` field. This helps prevent malicious template patches + finalApp.Spec.Project = app.Spec.Project + + return &finalApp, nil +} diff --git a/pkg/generator/cluster.go b/pkg/generator/cluster.go new file mode 100644 index 00000000..c2b38778 --- /dev/null +++ b/pkg/generator/cluster.go @@ -0,0 +1,189 @@ +package generator + +import ( + "context" + "fmt" + "time" + + "github.com/rs/zerolog/log" + + "github.com/argoproj/argo-cd/v2/util/settings" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/argoproj/argo-cd/v2/applicationset/utils" + argoappsetv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" +) + +const ( + ArgoCDSecretTypeLabel = "argocd.argoproj.io/secret-type" + ArgoCDSecretTypeCluster = "cluster" +) + +var _ Generator = (*ClusterGenerator)(nil) + +// ClusterGenerator generates Applications for some or all clusters registered with ArgoCD. +type ClusterGenerator struct { + client.Client + ctx context.Context + clientset kubernetes.Interface + // namespace is the Argo CD namespace + namespace string + settingsManager *settings.SettingsManager +} + +var render = &utils.Render{} + +func NewClusterGenerator(c client.Client, ctx context.Context, clientset kubernetes.Interface, namespace string) Generator { + + settingsManager := settings.NewSettingsManager(ctx, clientset, namespace) + + g := &ClusterGenerator{ + Client: c, + ctx: ctx, + clientset: clientset, + namespace: namespace, + settingsManager: settingsManager, + } + return g +} + +// GetRequeueAfter never requeue the cluster generator because the `clusterSecretEventHandler` will requeue the appsets +// when the cluster secrets change +func (g *ClusterGenerator) GetRequeueAfter(_ *argoappsetv1alpha1.ApplicationSetGenerator) time.Duration { + return NoRequeueAfter +} + +func (g *ClusterGenerator) GetTemplate(appSetGenerator *argoappsetv1alpha1.ApplicationSetGenerator) *argoappsetv1alpha1.ApplicationSetTemplate { + return &appSetGenerator.Clusters.Template +} + +func (g *ClusterGenerator) GenerateParams(appSetGenerator *argoappsetv1alpha1.ApplicationSetGenerator, appSet *argoappsetv1alpha1.ApplicationSet) ([]map[string]interface{}, error) { + + if appSetGenerator == nil { + return nil, EmptyAppSetGeneratorError + } + + if appSetGenerator.Clusters == nil { + return nil, EmptyAppSetGeneratorError + } + + // Do not include the local cluster in the cluster parameters IF there is a non-empty selector + // - Since local clusters do not have secrets, they do not have labels to match against + ignoreLocalClusters := len(appSetGenerator.Clusters.Selector.MatchExpressions) > 0 || len(appSetGenerator.Clusters.Selector.MatchLabels) > 0 + + // ListCluster from Argo CD's util/db package will include the local cluster in the list of clusters + clustersFromArgoCD, err := utils.ListClusters(g.ctx, g.clientset, g.namespace) + if err != nil { + return nil, fmt.Errorf("error listing clusters: %w", err) + } + + if clustersFromArgoCD == nil { + return nil, nil + } + + clusterSecrets, err := g.getSecretsByClusterName(appSetGenerator) + if err != nil { + return nil, err + } + + var res []map[string]interface{} + + var secretsFound []corev1.Secret + + for _, cluster := range clustersFromArgoCD.Items { + + // If there is a secret for this cluster, then it's a non-local cluster, so it will be + // handled by the next step. + if secretForCluster, exists := clusterSecrets[cluster.Name]; exists { + secretsFound = append(secretsFound, secretForCluster) + + } else if !ignoreLocalClusters { + // If there is no secret for the cluster, it's the local cluster, so handle it here. + params := map[string]interface{}{} + params["name"] = cluster.Name + params["nameNormalized"] = cluster.Name + params["server"] = cluster.Server + + err = appendTemplatedValues(appSetGenerator.Clusters.Values, params, appSet.Spec.GoTemplate, appSet.Spec.GoTemplateOptions) + if err != nil { + return nil, err + } + + res = append(res, params) + + log.Info().Str("cluster", "local cluster").Msg("matched local cluster") + } + } + + // For each matching cluster secret (non-local clusters only) + for _, cluster := range secretsFound { + params := map[string]interface{}{} + + params["name"] = string(cluster.Data["name"]) + params["nameNormalized"] = utils.SanitizeName(string(cluster.Data["name"])) + params["server"] = string(cluster.Data["server"]) + + if appSet.Spec.GoTemplate { + meta := map[string]interface{}{} + + if len(cluster.ObjectMeta.Annotations) > 0 { + meta["annotations"] = cluster.ObjectMeta.Annotations + } + if len(cluster.ObjectMeta.Labels) > 0 { + meta["labels"] = cluster.ObjectMeta.Labels + } + + params["metadata"] = meta + } else { + for key, value := range cluster.ObjectMeta.Annotations { + params[fmt.Sprintf("metadata.annotations.%s", key)] = value + } + + for key, value := range cluster.ObjectMeta.Labels { + params[fmt.Sprintf("metadata.labels.%s", key)] = value + } + } + + err = appendTemplatedValues(appSetGenerator.Clusters.Values, params, appSet.Spec.GoTemplate, appSet.Spec.GoTemplateOptions) + if err != nil { + return nil, err + } + + res = append(res, params) + + log.Info().Msgf("matched cluster secret. \"cluster\": %s", cluster.Name) + } + + return res, nil +} + +func (g *ClusterGenerator) getSecretsByClusterName(appSetGenerator *argoappsetv1alpha1.ApplicationSetGenerator) (map[string]corev1.Secret, error) { + // List all Clusters: + clusterSecretList := &corev1.SecretList{} + + selector := metav1.AddLabelToSelector(&appSetGenerator.Clusters.Selector, ArgoCDSecretTypeLabel, ArgoCDSecretTypeCluster) + secretSelector, err := metav1.LabelSelectorAsSelector(selector) + if err != nil { + return nil, err + } + + if err := g.Client.List(context.Background(), clusterSecretList, client.MatchingLabelsSelector{Selector: secretSelector}); err != nil { + return nil, err + } + log.Debug().Msgf("clusters matching labels, count: %d", len(clusterSecretList.Items)) + + res := map[string]corev1.Secret{} + + for _, cluster := range clusterSecretList.Items { + clusterName := string(cluster.Data["name"]) + + res[clusterName] = cluster + } + + return res, nil + +} diff --git a/pkg/generator/cluster_test.go b/pkg/generator/cluster_test.go new file mode 100644 index 00000000..248c1d6d --- /dev/null +++ b/pkg/generator/cluster_test.go @@ -0,0 +1,645 @@ +package generator + +import ( + "context" + "fmt" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + kubefake "k8s.io/client-go/kubernetes/fake" + + "github.com/argoproj/argo-cd/v2/applicationset/utils" + argoprojiov1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + + "github.com/stretchr/testify/assert" +) + +type possiblyErroringFakeCtrlRuntimeClient struct { + client.Client + shouldError bool +} + +func (p *possiblyErroringFakeCtrlRuntimeClient) List(ctx context.Context, secretList client.ObjectList, opts ...client.ListOption) error { + if p.shouldError { + return fmt.Errorf("could not list Secrets") + } + return p.Client.List(ctx, secretList, opts...) +} + +func TestGenerateParams(t *testing.T) { + clusters := []client.Object{ + &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "staging-01", + Namespace: "namespace", + Labels: map[string]string{ + "argocd.argoproj.io/secret-type": "cluster", + "environment": "staging", + "org": "foo", + }, + Annotations: map[string]string{ + "foo.argoproj.io": "staging", + }, + }, + Data: map[string][]byte{ + "config": []byte("{}"), + "name": []byte("staging-01"), + "server": []byte("https://staging-01.example.com"), + }, + Type: corev1.SecretType("Opaque"), + }, + &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "production-01", + Namespace: "namespace", + Labels: map[string]string{ + "argocd.argoproj.io/secret-type": "cluster", + "environment": "production", + "org": "bar", + }, + Annotations: map[string]string{ + "foo.argoproj.io": "production", + }, + }, + Data: map[string][]byte{ + "config": []byte("{}"), + "name": []byte("production_01/west"), + "server": []byte("https://production-01.example.com"), + }, + Type: corev1.SecretType("Opaque"), + }, + } + testCases := []struct { + name string + selector metav1.LabelSelector + values map[string]string + expected []map[string]interface{} + // clientError is true if a k8s client error should be simulated + clientError bool + expectedError error + }{ + { + name: "no label selector", + selector: metav1.LabelSelector{}, + values: map[string]string{ + "lol1": "lol", + "lol2": "{{values.lol1}}{{values.lol1}}", + "lol3": "{{values.lol2}}{{values.lol2}}{{values.lol2}}", + "foo": "bar", + "bar": "{{ metadata.annotations.foo.argoproj.io }}", + "bat": "{{ metadata.labels.environment }}", + "aaa": "{{ server }}", + "no-op": "{{ this-does-not-exist }}", + }, expected: []map[string]interface{}{ + {"values.lol1": "lol", "values.lol2": "{{values.lol1}}{{values.lol1}}", "values.lol3": "{{values.lol2}}{{values.lol2}}{{values.lol2}}", "values.foo": "bar", "values.bar": "production", "values.no-op": "{{ this-does-not-exist }}", "values.bat": "production", "values.aaa": "https://production-01.example.com", "name": "production_01/west", "nameNormalized": "production-01-west", "server": "https://production-01.example.com", "metadata.labels.environment": "production", "metadata.labels.org": "bar", + "metadata.labels.argocd.argoproj.io/secret-type": "cluster", "metadata.annotations.foo.argoproj.io": "production"}, + + {"values.lol1": "lol", "values.lol2": "{{values.lol1}}{{values.lol1}}", "values.lol3": "{{values.lol2}}{{values.lol2}}{{values.lol2}}", "values.foo": "bar", "values.bar": "staging", "values.no-op": "{{ this-does-not-exist }}", "values.bat": "staging", "values.aaa": "https://staging-01.example.com", "name": "staging-01", "nameNormalized": "staging-01", "server": "https://staging-01.example.com", "metadata.labels.environment": "staging", "metadata.labels.org": "foo", + "metadata.labels.argocd.argoproj.io/secret-type": "cluster", "metadata.annotations.foo.argoproj.io": "staging"}, + + {"values.lol1": "lol", "values.lol2": "{{values.lol1}}{{values.lol1}}", "values.lol3": "{{values.lol2}}{{values.lol2}}{{values.lol2}}", "values.foo": "bar", "values.bar": "{{ metadata.annotations.foo.argoproj.io }}", "values.no-op": "{{ this-does-not-exist }}", "values.bat": "{{ metadata.labels.environment }}", "values.aaa": "https://kubernetes.default.svc", "nameNormalized": "in-cluster", "name": "in-cluster", "server": "https://kubernetes.default.svc"}, + }, + clientError: false, + expectedError: nil, + }, + { + name: "secret type label selector", + selector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "argocd.argoproj.io/secret-type": "cluster", + }, + }, + values: nil, + expected: []map[string]interface{}{ + {"name": "production_01/west", "nameNormalized": "production-01-west", "server": "https://production-01.example.com", "metadata.labels.environment": "production", "metadata.labels.org": "bar", + "metadata.labels.argocd.argoproj.io/secret-type": "cluster", "metadata.annotations.foo.argoproj.io": "production"}, + + {"name": "staging-01", "nameNormalized": "staging-01", "server": "https://staging-01.example.com", "metadata.labels.environment": "staging", "metadata.labels.org": "foo", + "metadata.labels.argocd.argoproj.io/secret-type": "cluster", "metadata.annotations.foo.argoproj.io": "staging"}, + }, + clientError: false, + expectedError: nil, + }, + { + name: "production-only", + selector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "environment": "production", + }, + }, + values: map[string]string{ + "foo": "bar", + }, + expected: []map[string]interface{}{ + {"values.foo": "bar", "name": "production_01/west", "nameNormalized": "production-01-west", "server": "https://production-01.example.com", "metadata.labels.environment": "production", "metadata.labels.org": "bar", + "metadata.labels.argocd.argoproj.io/secret-type": "cluster", "metadata.annotations.foo.argoproj.io": "production"}, + }, + clientError: false, + expectedError: nil, + }, + { + name: "production or staging", + selector: metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "environment", + Operator: "In", + Values: []string{ + "production", + "staging", + }, + }, + }, + }, + values: map[string]string{ + "foo": "bar", + }, + expected: []map[string]interface{}{ + {"values.foo": "bar", "name": "staging-01", "nameNormalized": "staging-01", "server": "https://staging-01.example.com", "metadata.labels.environment": "staging", "metadata.labels.org": "foo", + "metadata.labels.argocd.argoproj.io/secret-type": "cluster", "metadata.annotations.foo.argoproj.io": "staging"}, + {"values.foo": "bar", "name": "production_01/west", "nameNormalized": "production-01-west", "server": "https://production-01.example.com", "metadata.labels.environment": "production", "metadata.labels.org": "bar", + "metadata.labels.argocd.argoproj.io/secret-type": "cluster", "metadata.annotations.foo.argoproj.io": "production"}, + }, + clientError: false, + expectedError: nil, + }, + { + name: "production or staging with match labels", + selector: metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "environment", + Operator: "In", + Values: []string{ + "production", + "staging", + }, + }, + }, + MatchLabels: map[string]string{ + "org": "foo", + }, + }, + values: map[string]string{ + "name": "baz", + }, + expected: []map[string]interface{}{ + {"values.name": "baz", "name": "staging-01", "nameNormalized": "staging-01", "server": "https://staging-01.example.com", "metadata.labels.environment": "staging", "metadata.labels.org": "foo", + "metadata.labels.argocd.argoproj.io/secret-type": "cluster", "metadata.annotations.foo.argoproj.io": "staging"}, + }, + clientError: false, + expectedError: nil, + }, + { + name: "simulate client error", + selector: metav1.LabelSelector{}, + values: nil, + expected: nil, + clientError: true, + expectedError: fmt.Errorf("could not list Secrets"), + }, + } + + // convert []client.Object to []runtime.Object, for use by kubefake package + runtimeClusters := []runtime.Object{} + for _, clientCluster := range clusters { + runtimeClusters = append(runtimeClusters, clientCluster) + } + + for _, testCase := range testCases { + + t.Run(testCase.name, func(t *testing.T) { + + appClientset := kubefake.NewSimpleClientset(runtimeClusters...) + + fakeClient := fake.NewClientBuilder().WithObjects(clusters...).Build() + cl := &possiblyErroringFakeCtrlRuntimeClient{ + fakeClient, + testCase.clientError, + } + + var clusterGenerator = NewClusterGenerator(cl, context.Background(), appClientset, "namespace") + + applicationSetInfo := argoprojiov1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "set", + }, + Spec: argoprojiov1alpha1.ApplicationSetSpec{}, + } + + got, err := clusterGenerator.GenerateParams(&argoprojiov1alpha1.ApplicationSetGenerator{ + Clusters: &argoprojiov1alpha1.ClusterGenerator{ + Selector: testCase.selector, + Values: testCase.values, + }, + }, &applicationSetInfo) + + if testCase.expectedError != nil { + assert.EqualError(t, err, testCase.expectedError.Error()) + } else { + assert.NoError(t, err) + assert.ElementsMatch(t, testCase.expected, got) + } + + }) + } +} + +func TestGenerateParamsGoTemplate(t *testing.T) { + clusters := []client.Object{ + &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "staging-01", + Namespace: "namespace", + Labels: map[string]string{ + "argocd.argoproj.io/secret-type": "cluster", + "environment": "staging", + "org": "foo", + }, + Annotations: map[string]string{ + "foo.argoproj.io": "staging", + }, + }, + Data: map[string][]byte{ + "config": []byte("{}"), + "name": []byte("staging-01"), + "server": []byte("https://staging-01.example.com"), + }, + Type: corev1.SecretType("Opaque"), + }, + &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "production-01", + Namespace: "namespace", + Labels: map[string]string{ + "argocd.argoproj.io/secret-type": "cluster", + "environment": "production", + "org": "bar", + }, + Annotations: map[string]string{ + "foo.argoproj.io": "production", + }, + }, + Data: map[string][]byte{ + "config": []byte("{}"), + "name": []byte("production_01/west"), + "server": []byte("https://production-01.example.com"), + }, + Type: corev1.SecretType("Opaque"), + }, + } + testCases := []struct { + name string + selector metav1.LabelSelector + values map[string]string + expected []map[string]interface{} + // clientError is true if a k8s client error should be simulated + clientError bool + expectedError error + }{ + { + name: "no label selector", + selector: metav1.LabelSelector{}, + values: map[string]string{ + "lol1": "lol", + "lol2": "{{ .values.lol1 }}{{ .values.lol1 }}", + "lol3": "{{ .values.lol2 }}{{ .values.lol2 }}{{ .values.lol2 }}", + "foo": "bar", + "bar": "{{ if not (empty .metadata) }}{{index .metadata.annotations \"foo.argoproj.io\" }}{{ end }}", + "bat": "{{ if not (empty .metadata) }}{{.metadata.labels.environment}}{{ end }}", + "aaa": "{{ .server }}", + "no-op": "{{ .thisDoesNotExist }}", + }, expected: []map[string]interface{}{ + { + "name": "production_01/west", + "nameNormalized": "production-01-west", + "server": "https://production-01.example.com", + "metadata": map[string]interface{}{ + "labels": map[string]string{ + "argocd.argoproj.io/secret-type": "cluster", + "environment": "production", + "org": "bar", + }, + "annotations": map[string]string{ + "foo.argoproj.io": "production", + }, + }, + "values": map[string]string{ + "lol1": "lol", + "lol2": "", + "lol3": "", + "foo": "bar", + "bar": "production", + "bat": "production", + "aaa": "https://production-01.example.com", + "no-op": "", + }, + }, + { + "name": "staging-01", + "nameNormalized": "staging-01", + "server": "https://staging-01.example.com", + "metadata": map[string]interface{}{ + "labels": map[string]string{ + "argocd.argoproj.io/secret-type": "cluster", + "environment": "staging", + "org": "foo", + }, + "annotations": map[string]string{ + "foo.argoproj.io": "staging", + }, + }, + "values": map[string]string{ + "lol1": "lol", + "lol2": "", + "lol3": "", + "foo": "bar", + "bar": "staging", + "bat": "staging", + "aaa": "https://staging-01.example.com", + "no-op": "", + }, + }, + { + "nameNormalized": "in-cluster", + "name": "in-cluster", + "server": "https://kubernetes.default.svc", + "values": map[string]string{ + "lol1": "lol", + "lol2": "", + "lol3": "", + "foo": "bar", + "bar": "", + "bat": "", + "aaa": "https://kubernetes.default.svc", + "no-op": "", + }, + }, + }, + clientError: false, + expectedError: nil, + }, + { + name: "secret type label selector", + selector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "argocd.argoproj.io/secret-type": "cluster", + }, + }, + values: nil, + expected: []map[string]interface{}{ + { + "name": "production_01/west", + "nameNormalized": "production-01-west", + "server": "https://production-01.example.com", + "metadata": map[string]interface{}{ + "labels": map[string]string{ + "argocd.argoproj.io/secret-type": "cluster", + "environment": "production", + "org": "bar", + }, + "annotations": map[string]string{ + "foo.argoproj.io": "production", + }, + }, + }, + { + "name": "staging-01", + "nameNormalized": "staging-01", + "server": "https://staging-01.example.com", + "metadata": map[string]interface{}{ + "labels": map[string]string{ + "argocd.argoproj.io/secret-type": "cluster", + "environment": "staging", + "org": "foo", + }, + "annotations": map[string]string{ + "foo.argoproj.io": "staging", + }, + }, + }, + }, + clientError: false, + expectedError: nil, + }, + { + name: "production-only", + selector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "environment": "production", + }, + }, + values: map[string]string{ + "foo": "bar", + }, + expected: []map[string]interface{}{ + { + "name": "production_01/west", + "nameNormalized": "production-01-west", + "server": "https://production-01.example.com", + "metadata": map[string]interface{}{ + "labels": map[string]string{ + "argocd.argoproj.io/secret-type": "cluster", + "environment": "production", + "org": "bar", + }, + "annotations": map[string]string{ + "foo.argoproj.io": "production", + }, + }, + "values": map[string]string{ + "foo": "bar", + }, + }, + }, + clientError: false, + expectedError: nil, + }, + { + name: "production or staging", + selector: metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "environment", + Operator: "In", + Values: []string{ + "production", + "staging", + }, + }, + }, + }, + values: map[string]string{ + "foo": "bar", + }, + expected: []map[string]interface{}{ + { + "name": "production_01/west", + "nameNormalized": "production-01-west", + "server": "https://production-01.example.com", + "metadata": map[string]interface{}{ + "labels": map[string]string{ + "argocd.argoproj.io/secret-type": "cluster", + "environment": "production", + "org": "bar", + }, + "annotations": map[string]string{ + "foo.argoproj.io": "production", + }, + }, + "values": map[string]string{ + "foo": "bar", + }, + }, + { + "name": "staging-01", + "nameNormalized": "staging-01", + "server": "https://staging-01.example.com", + "metadata": map[string]interface{}{ + "labels": map[string]string{ + "argocd.argoproj.io/secret-type": "cluster", + "environment": "staging", + "org": "foo", + }, + "annotations": map[string]string{ + "foo.argoproj.io": "staging", + }, + }, + "values": map[string]string{ + "foo": "bar", + }, + }, + }, + clientError: false, + expectedError: nil, + }, + { + name: "production or staging with match labels", + selector: metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "environment", + Operator: "In", + Values: []string{ + "production", + "staging", + }, + }, + }, + MatchLabels: map[string]string{ + "org": "foo", + }, + }, + values: map[string]string{ + "name": "baz", + }, + expected: []map[string]interface{}{ + { + "name": "staging-01", + "nameNormalized": "staging-01", + "server": "https://staging-01.example.com", + "metadata": map[string]interface{}{ + "labels": map[string]string{ + "argocd.argoproj.io/secret-type": "cluster", + "environment": "staging", + "org": "foo", + }, + "annotations": map[string]string{ + "foo.argoproj.io": "staging", + }, + }, + "values": map[string]string{ + "name": "baz", + }, + }, + }, + clientError: false, + expectedError: nil, + }, + { + name: "simulate client error", + selector: metav1.LabelSelector{}, + values: nil, + expected: nil, + clientError: true, + expectedError: fmt.Errorf("could not list Secrets"), + }, + } + + // convert []client.Object to []runtime.Object, for use by kubefake package + runtimeClusters := []runtime.Object{} + for _, clientCluster := range clusters { + runtimeClusters = append(runtimeClusters, clientCluster) + } + + for _, testCase := range testCases { + + t.Run(testCase.name, func(t *testing.T) { + + appClientset := kubefake.NewSimpleClientset(runtimeClusters...) + + fakeClient := fake.NewClientBuilder().WithObjects(clusters...).Build() + cl := &possiblyErroringFakeCtrlRuntimeClient{ + fakeClient, + testCase.clientError, + } + + var clusterGenerator = NewClusterGenerator(cl, context.Background(), appClientset, "namespace") + + applicationSetInfo := argoprojiov1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "set", + }, + Spec: argoprojiov1alpha1.ApplicationSetSpec{ + GoTemplate: true, + }, + } + + got, err := clusterGenerator.GenerateParams(&argoprojiov1alpha1.ApplicationSetGenerator{ + Clusters: &argoprojiov1alpha1.ClusterGenerator{ + Selector: testCase.selector, + Values: testCase.values, + }, + }, &applicationSetInfo) + + if testCase.expectedError != nil { + assert.EqualError(t, err, testCase.expectedError.Error()) + } else { + assert.NoError(t, err) + assert.ElementsMatch(t, testCase.expected, got) + } + + }) + } +} + +func TestSanitizeClusterName(t *testing.T) { + t.Run("valid DNS-1123 subdomain name", func(t *testing.T) { + assert.Equal(t, "cluster-name", utils.SanitizeName("cluster-name")) + }) + t.Run("invalid DNS-1123 subdomain name", func(t *testing.T) { + invalidName := "-.--CLUSTER/name -./.-" + assert.Equal(t, "cluster-name", utils.SanitizeName(invalidName)) + }) +} diff --git a/pkg/generator/generator_spec_processor.go b/pkg/generator/generator_spec_processor.go new file mode 100644 index 00000000..318db380 --- /dev/null +++ b/pkg/generator/generator_spec_processor.go @@ -0,0 +1,169 @@ +package generator + +import ( + "fmt" + "reflect" + + "github.com/jeremywohl/flatten" + + "github.com/argoproj/argo-cd/v2/applicationset/utils" + + "k8s.io/apimachinery/pkg/labels" + + argoprojiov1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + + "github.com/imdario/mergo" + "github.com/rs/zerolog/log" +) + +const ( + selectorKey = "Selector" +) + +type TransformResult struct { + Params []map[string]interface{} + Template argoprojiov1alpha1.ApplicationSetTemplate +} + +// Transform a spec generator to list of paramSets and a template +func Transform(requestedGenerator argoprojiov1alpha1.ApplicationSetGenerator, allGenerators map[string]Generator, baseTemplate argoprojiov1alpha1.ApplicationSetTemplate, appSet *argoprojiov1alpha1.ApplicationSet, genParams map[string]interface{}) ([]TransformResult, error) { + // This is a custom version of the `LabelSelectorAsSelector` that is in k8s.io/apimachinery. This has been copied + // verbatim from that package, with the difference that we do not have any restrictions on label values. This is done + // so that, among other things, we can match on cluster urls. + selector, err := utils.LabelSelectorAsSelector(requestedGenerator.Selector) + if err != nil { + return nil, fmt.Errorf("error parsing label selector: %w", err) + } + + var res []TransformResult + var firstError error + interpolatedGenerator := requestedGenerator.DeepCopy() + + generators := GetRelevantGenerators(&requestedGenerator, allGenerators) + for _, g := range generators { + // we call mergeGeneratorTemplate first because GenerateParams might be more costly so we want to fail fast if there is an error + mergedTemplate, err := mergeGeneratorTemplate(g, &requestedGenerator, baseTemplate) + if err != nil { + log.Error().Err(err).Msgf("error generating params, generator: %+v", g) + if firstError == nil { + firstError = err + } + continue + } + var params []map[string]interface{} + if len(genParams) != 0 { + tempInterpolatedGenerator, err := InterpolateGenerator(&requestedGenerator, genParams, appSet.Spec.GoTemplate, appSet.Spec.GoTemplateOptions) + interpolatedGenerator = &tempInterpolatedGenerator + if err != nil { + log.Error().Err(err).Msgf("error interpolating params for generator. genParams: %+v", genParams) + if firstError == nil { + firstError = err + } + continue + } + } + params, err = g.GenerateParams(interpolatedGenerator, appSet) + if err != nil { + log.Error().Err(err).Msgf("error generating params generator. generator: %+v", g) + if firstError == nil { + firstError = err + } + continue + } + var filterParams []map[string]interface{} + for _, param := range params { + flatParam, err := flattenParameters(param) + if err != nil { + log.Error().Err(err).Msgf("error flattening params. generator: %+v", g) + if firstError == nil { + firstError = err + } + continue + } + + if requestedGenerator.Selector != nil && !selector.Matches(labels.Set(flatParam)) { + continue + } + filterParams = append(filterParams, param) + } + + res = append(res, TransformResult{ + Params: filterParams, + Template: mergedTemplate, + }) + } + + return res, firstError +} + +func GetRelevantGenerators(requestedGenerator *argoprojiov1alpha1.ApplicationSetGenerator, generators map[string]Generator) []Generator { + var res []Generator + + v := reflect.Indirect(reflect.ValueOf(requestedGenerator)) + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + if !field.CanInterface() { + continue + } + name := v.Type().Field(i).Name + if name == selectorKey { + continue + } + + if !reflect.ValueOf(field.Interface()).IsNil() { + res = append(res, generators[name]) + } + } + + return res +} + +func flattenParameters(in map[string]interface{}) (map[string]string, error) { + flat, err := flatten.Flatten(in, "", flatten.DotStyle) + if err != nil { + return nil, fmt.Errorf("error flatenning parameters: %w", err) + } + + out := make(map[string]string, len(flat)) + for k, v := range flat { + out[k] = fmt.Sprintf("%v", v) + } + + return out, nil +} + +func mergeGeneratorTemplate(g Generator, requestedGenerator *argoprojiov1alpha1.ApplicationSetGenerator, applicationSetTemplate argoprojiov1alpha1.ApplicationSetTemplate) (argoprojiov1alpha1.ApplicationSetTemplate, error) { + // Make a copy of the value from `GetTemplate()` before merge, rather than copying directly into + // the provided parameter (which will touch the original resource object returned by client-go) + dest := g.GetTemplate(requestedGenerator).DeepCopy() + + err := mergo.Merge(dest, applicationSetTemplate) + + return *dest, err +} + +// InterpolateGenerator allows interpolating the matrix's 2nd child generator with values from the 1st child generator +// "params" parameter is an array, where each index corresponds to a generator. Each index contains a map w/ that generator's parameters. +func InterpolateGenerator(requestedGenerator *argoprojiov1alpha1.ApplicationSetGenerator, params map[string]interface{}, useGoTemplate bool, goTemplateOptions []string) (argoprojiov1alpha1.ApplicationSetGenerator, error) { + render := utils.Render{} + interpolatedGenerator, err := render.RenderGeneratorParams(requestedGenerator, params, useGoTemplate, goTemplateOptions) + if err != nil { + log.Error().Err(err).Msgf("error interpolating generator with other generator's parameter. interpolatedGenerator: %+v", interpolatedGenerator) + return argoprojiov1alpha1.ApplicationSetGenerator{}, err + } + + return *interpolatedGenerator, nil +} + +// Fixes https://github.com/argoproj/argo-cd/issues/11982 while ensuring backwards compatibility. +// This is only a short-term solution and should be removed in a future major version. +func dropDisabledNestedSelectors(generators []argoprojiov1alpha1.ApplicationSetNestedGenerator) bool { + var foundSelector bool + for i := range generators { + if generators[i].Selector != nil { + foundSelector = true + generators[i].Selector = nil + } + } + return foundSelector +} diff --git a/pkg/generator/generator_spec_processor_test.go b/pkg/generator/generator_spec_processor_test.go new file mode 100644 index 00000000..54350dd9 --- /dev/null +++ b/pkg/generator/generator_spec_processor_test.go @@ -0,0 +1,507 @@ +package generator + +import ( + "context" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + argov1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + kubefake "k8s.io/client-go/kubernetes/fake" + crtclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestMatchValues(t *testing.T) { + testCases := []struct { + name string + elements []apiextensionsv1.JSON + selector *metav1.LabelSelector + expected []map[string]interface{} + }{ + { + name: "no filter", + elements: []apiextensionsv1.JSON{{Raw: []byte(`{"cluster": "cluster","url": "url"}`)}}, + selector: &metav1.LabelSelector{}, + expected: []map[string]interface{}{{"cluster": "cluster", "url": "url"}}, + }, + { + name: "nil", + elements: []apiextensionsv1.JSON{{Raw: []byte(`{"cluster": "cluster","url": "url"}`)}}, + selector: nil, + expected: []map[string]interface{}{{"cluster": "cluster", "url": "url"}}, + }, + { + name: "values.foo should be foo but is ignore element", + elements: []apiextensionsv1.JSON{{Raw: []byte(`{"cluster": "cluster","url": "url","values":{"foo":"bar"}}`)}}, + selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "values.foo": "foo", + }, + }, + expected: []map[string]interface{}{}, + }, + { + name: "values.foo should be bar", + elements: []apiextensionsv1.JSON{{Raw: []byte(`{"cluster": "cluster","url": "url","values":{"foo":"bar"}}`)}}, + selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "values.foo": "bar", + }, + }, + expected: []map[string]interface{}{{"cluster": "cluster", "url": "url", "values.foo": "bar"}}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + listGenerator := NewListGenerator() + data := map[string]Generator{ + "List": listGenerator, + } + + applicationSetInfo := argov1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "set", + }, + Spec: argov1alpha1.ApplicationSetSpec{ + GoTemplate: false, + }, + } + + results, err := Transform(argov1alpha1.ApplicationSetGenerator{ + Selector: testCase.selector, + List: &argov1alpha1.ListGenerator{ + Elements: testCase.elements, + Template: emptyTemplate(), + }, + }, + data, + emptyTemplate(), + &applicationSetInfo, nil) + + require.NoError(t, err) + assert.ElementsMatch(t, testCase.expected, results[0].Params) + }) + } +} + +func TestMatchValuesGoTemplate(t *testing.T) { + testCases := []struct { + name string + elements []apiextensionsv1.JSON + selector *metav1.LabelSelector + expected []map[string]interface{} + }{ + { + name: "no filter", + elements: []apiextensionsv1.JSON{{Raw: []byte(`{"cluster": "cluster","url": "url"}`)}}, + selector: &metav1.LabelSelector{}, + expected: []map[string]interface{}{{"cluster": "cluster", "url": "url"}}, + }, + { + name: "nil", + elements: []apiextensionsv1.JSON{{Raw: []byte(`{"cluster": "cluster","url": "url"}`)}}, + selector: nil, + expected: []map[string]interface{}{{"cluster": "cluster", "url": "url"}}, + }, + { + name: "values.foo should be foo but is ignore element", + elements: []apiextensionsv1.JSON{{Raw: []byte(`{"cluster": "cluster","url": "url","values":{"foo":"bar"}}`)}}, + selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "values.foo": "foo", + }, + }, + expected: []map[string]interface{}{}, + }, + { + name: "values.foo should be bar", + elements: []apiextensionsv1.JSON{{Raw: []byte(`{"cluster": "cluster","url": "url","values":{"foo":"bar"}}`)}}, + selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "values.foo": "bar", + }, + }, + expected: []map[string]interface{}{{"cluster": "cluster", "url": "url", "values": map[string]interface{}{"foo": "bar"}}}, + }, + { + name: "values.0 should be bar", + elements: []apiextensionsv1.JSON{{Raw: []byte(`{"cluster": "cluster","url": "url","values":["bar"]}`)}}, + selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "values.0": "bar", + }, + }, + expected: []map[string]interface{}{{"cluster": "cluster", "url": "url", "values": []interface{}{"bar"}}}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + listGenerator := NewListGenerator() + data := map[string]Generator{ + "List": listGenerator, + } + + applicationSetInfo := argov1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "set", + }, + Spec: argov1alpha1.ApplicationSetSpec{ + GoTemplate: true, + }, + } + + results, err := Transform(argov1alpha1.ApplicationSetGenerator{ + Selector: testCase.selector, + List: &argov1alpha1.ListGenerator{ + Elements: testCase.elements, + Template: emptyTemplate(), + }, + }, + data, + emptyTemplate(), + &applicationSetInfo, nil) + + require.NoError(t, err) + assert.ElementsMatch(t, testCase.expected, results[0].Params) + }) + } +} + +func TestTransForm(t *testing.T) { + testCases := []struct { + name string + selector *metav1.LabelSelector + expected []map[string]interface{} + }{ + { + name: "server filter", + selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"server": "https://production-01.example.com"}, + }, + expected: []map[string]interface{}{{ + "metadata.annotations.foo.argoproj.io": "production", + "metadata.labels.argocd.argoproj.io/secret-type": "cluster", + "metadata.labels.environment": "production", + "metadata.labels.org": "bar", + "name": "production_01/west", + "nameNormalized": "production-01-west", + "server": "https://production-01.example.com", + }}, + }, + { + name: "server filter with long url", + selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"server": "https://some-really-long-url-that-will-exceed-63-characters.com"}, + }, + expected: []map[string]interface{}{{ + "metadata.annotations.foo.argoproj.io": "production", + "metadata.labels.argocd.argoproj.io/secret-type": "cluster", + "metadata.labels.environment": "production", + "metadata.labels.org": "bar", + "name": "some-really-long-server-url", + "nameNormalized": "some-really-long-server-url", + "server": "https://some-really-long-url-that-will-exceed-63-characters.com", + }}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + testGenerators := map[string]Generator{ + "Clusters": getMockClusterGenerator(), + } + + applicationSetInfo := argov1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "set", + }, + Spec: argov1alpha1.ApplicationSetSpec{}, + } + + results, err := Transform( + argov1alpha1.ApplicationSetGenerator{ + Selector: testCase.selector, + Clusters: &argov1alpha1.ClusterGenerator{ + Selector: metav1.LabelSelector{}, + Template: argov1alpha1.ApplicationSetTemplate{}, + Values: nil, + }, + }, + testGenerators, + emptyTemplate(), + &applicationSetInfo, nil) + + require.NoError(t, err) + assert.ElementsMatch(t, testCase.expected, results[0].Params) + }) + } +} + +func emptyTemplate() argov1alpha1.ApplicationSetTemplate { + return argov1alpha1.ApplicationSetTemplate{ + Spec: argov1alpha1.ApplicationSpec{ + Project: "project", + }, + } +} + +func getMockClusterGenerator() Generator { + clusters := []crtclient.Object{ + &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "staging-01", + Namespace: "namespace", + Labels: map[string]string{ + "argocd.argoproj.io/secret-type": "cluster", + "environment": "staging", + "org": "foo", + }, + Annotations: map[string]string{ + "foo.argoproj.io": "staging", + }, + }, + Data: map[string][]byte{ + "config": []byte("{}"), + "name": []byte("staging-01"), + "server": []byte("https://staging-01.example.com"), + }, + Type: corev1.SecretType("Opaque"), + }, + &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "production-01", + Namespace: "namespace", + Labels: map[string]string{ + "argocd.argoproj.io/secret-type": "cluster", + "environment": "production", + "org": "bar", + }, + Annotations: map[string]string{ + "foo.argoproj.io": "production", + }, + }, + Data: map[string][]byte{ + "config": []byte("{}"), + "name": []byte("production_01/west"), + "server": []byte("https://production-01.example.com"), + }, + Type: corev1.SecretType("Opaque"), + }, + &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "some-really-long-server-url", + Namespace: "namespace", + Labels: map[string]string{ + "argocd.argoproj.io/secret-type": "cluster", + "environment": "production", + "org": "bar", + }, + Annotations: map[string]string{ + "foo.argoproj.io": "production", + }, + }, + Data: map[string][]byte{ + "config": []byte("{}"), + "name": []byte("some-really-long-server-url"), + "server": []byte("https://some-really-long-url-that-will-exceed-63-characters.com"), + }, + Type: corev1.SecretType("Opaque"), + }, + } + runtimeClusters := []runtime.Object{} + for _, clientCluster := range clusters { + runtimeClusters = append(runtimeClusters, clientCluster) + } + appClientset := kubefake.NewSimpleClientset(runtimeClusters...) + + fakeClient := fake.NewClientBuilder().WithObjects(clusters...).Build() + return NewClusterGenerator(fakeClient, context.Background(), appClientset, "namespace") +} + +func TestInterpolateGenerator(t *testing.T) { + requestedGenerator := &argov1alpha1.ApplicationSetGenerator{ + Clusters: &argov1alpha1.ClusterGenerator{ + Selector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "argocd.argoproj.io/secret-type": "cluster", + "path-basename": "{{path.basename}}", + "path-zero": "{{path[0]}}", + "path-full": "{{path}}", + }, + }, + }, + } + gitGeneratorParams := map[string]interface{}{ + "path": "p1/p2/app3", + "path.basename": "app3", + "path[0]": "p1", + "path[1]": "p2", + "path.basenameNormalized": "app3", + } + interpolatedGenerator, err := InterpolateGenerator(requestedGenerator, gitGeneratorParams, false, nil) + if err != nil { + log.WithError(err).WithField("requestedGenerator", requestedGenerator).Error("error interpolating Generator") + return + } + assert.Equal(t, "app3", interpolatedGenerator.Clusters.Selector.MatchLabels["path-basename"]) + assert.Equal(t, "p1", interpolatedGenerator.Clusters.Selector.MatchLabels["path-zero"]) + assert.Equal(t, "p1/p2/app3", interpolatedGenerator.Clusters.Selector.MatchLabels["path-full"]) + + fileNamePath := argov1alpha1.GitFileGeneratorItem{ + Path: "{{name}}", + } + fileServerPath := argov1alpha1.GitFileGeneratorItem{ + Path: "{{server}}", + } + + requestedGenerator = &argov1alpha1.ApplicationSetGenerator{ + Git: &argov1alpha1.GitGenerator{ + Files: append([]argov1alpha1.GitFileGeneratorItem{}, fileNamePath, fileServerPath), + Template: argov1alpha1.ApplicationSetTemplate{}, + }, + } + clusterGeneratorParams := map[string]interface{}{ + "name": "production_01/west", "server": "https://production-01.example.com", + } + interpolatedGenerator, err = InterpolateGenerator(requestedGenerator, clusterGeneratorParams, false, nil) + if err != nil { + log.WithError(err).WithField("requestedGenerator", requestedGenerator).Error("error interpolating Generator") + return + } + assert.Equal(t, "production_01/west", interpolatedGenerator.Git.Files[0].Path) + assert.Equal(t, "https://production-01.example.com", interpolatedGenerator.Git.Files[1].Path) +} + +func TestInterpolateGenerator_go(t *testing.T) { + requestedGenerator := &argov1alpha1.ApplicationSetGenerator{ + Clusters: &argov1alpha1.ClusterGenerator{ + Selector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "argocd.argoproj.io/secret-type": "cluster", + "path-basename": "{{base .path.path}}", + "path-zero": "{{index .path.segments 0}}", + "path-full": "{{.path.path}}", + "kubernetes.io/environment": `{{default "foo" .my_label}}`, + }, + }, + }, + } + gitGeneratorParams := map[string]interface{}{ + "path": map[string]interface{}{ + "path": "p1/p2/app3", + "segments": []string{"p1", "p2", "app3"}, + }, + } + interpolatedGenerator, err := InterpolateGenerator(requestedGenerator, gitGeneratorParams, true, nil) + require.NoError(t, err) + if err != nil { + log.WithError(err).WithField("requestedGenerator", requestedGenerator).Error("error interpolating Generator") + return + } + assert.Equal(t, "app3", interpolatedGenerator.Clusters.Selector.MatchLabels["path-basename"]) + assert.Equal(t, "p1", interpolatedGenerator.Clusters.Selector.MatchLabels["path-zero"]) + assert.Equal(t, "p1/p2/app3", interpolatedGenerator.Clusters.Selector.MatchLabels["path-full"]) + + fileNamePath := argov1alpha1.GitFileGeneratorItem{ + Path: "{{.name}}", + } + fileServerPath := argov1alpha1.GitFileGeneratorItem{ + Path: "{{.server}}", + } + + requestedGenerator = &argov1alpha1.ApplicationSetGenerator{ + Git: &argov1alpha1.GitGenerator{ + Files: append([]argov1alpha1.GitFileGeneratorItem{}, fileNamePath, fileServerPath), + Template: argov1alpha1.ApplicationSetTemplate{}, + }, + } + clusterGeneratorParams := map[string]interface{}{ + "name": "production_01/west", "server": "https://production-01.example.com", + } + interpolatedGenerator, err = InterpolateGenerator(requestedGenerator, clusterGeneratorParams, true, nil) + if err != nil { + log.WithError(err).WithField("requestedGenerator", requestedGenerator).Error("error interpolating Generator") + return + } + assert.Equal(t, "production_01/west", interpolatedGenerator.Git.Files[0].Path) + assert.Equal(t, "https://production-01.example.com", interpolatedGenerator.Git.Files[1].Path) +} + +func TestInterpolateGeneratorError(t *testing.T) { + type args struct { + requestedGenerator *argov1alpha1.ApplicationSetGenerator + params map[string]interface{} + useGoTemplate bool + goTemplateOptions []string + } + tests := []struct { + name string + args args + want argov1alpha1.ApplicationSetGenerator + expectedErrStr string + }{ + {name: "Empty Gen", args: args{ + requestedGenerator: nil, + params: nil, + useGoTemplate: false, + goTemplateOptions: nil, + }, want: argov1alpha1.ApplicationSetGenerator{}, expectedErrStr: "generator is empty"}, + {name: "No Params", args: args{ + requestedGenerator: &argov1alpha1.ApplicationSetGenerator{}, + params: map[string]interface{}{}, + useGoTemplate: false, + goTemplateOptions: nil, + }, want: argov1alpha1.ApplicationSetGenerator{}, expectedErrStr: ""}, + {name: "Error templating", args: args{ + requestedGenerator: &argov1alpha1.ApplicationSetGenerator{Git: &argov1alpha1.GitGenerator{ + RepoURL: "foo", + Files: []argov1alpha1.GitFileGeneratorItem{{Path: "bar/"}}, + Revision: "main", + Values: map[string]string{ + "git_test": "{{ toPrettyJson . }}", + "selection": "{{ default .override .test }}", + "resolved": "{{ index .rmap (default .override .test) }}", + }, + }}, + params: map[string]interface{}{ + "name": "in-cluster", + "override": "foo", + }, + useGoTemplate: true, + goTemplateOptions: []string{}, + }, want: argov1alpha1.ApplicationSetGenerator{}, expectedErrStr: "failed to replace parameters in generator: failed to execute go template {{ index .rmap (default .override .test) }}: template: :1:3: executing \"\" at : error calling index: index of untyped nil"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := InterpolateGenerator(tt.args.requestedGenerator, tt.args.params, tt.args.useGoTemplate, tt.args.goTemplateOptions) + if tt.expectedErrStr != "" { + require.EqualError(t, err, tt.expectedErrStr) + } else { + require.NoError(t, err) + } + assert.Equalf(t, tt.want, got, "InterpolateGenerator(%v, %v, %v, %v)", tt.args.requestedGenerator, tt.args.params, tt.args.useGoTemplate, tt.args.goTemplateOptions) + }) + } +} diff --git a/pkg/generator/interface.go b/pkg/generator/interface.go new file mode 100644 index 00000000..c08dcc93 --- /dev/null +++ b/pkg/generator/interface.go @@ -0,0 +1,27 @@ +package generator + +import ( + "time" + + argoprojiov1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + "github.com/pkg/errors" +) + +// Generator defines the interface implemented by all ApplicationSet generators. +type Generator interface { + // GenerateParams interprets the ApplicationSet and generates all relevant parameters for the application template. + // The expected / desired list of parameters is returned, it then will be render and reconciled + // against the current state of the Applications in the cluster. + GenerateParams(appSetGenerator *argoprojiov1alpha1.ApplicationSetGenerator, applicationSetInfo *argoprojiov1alpha1.ApplicationSet) ([]map[string]interface{}, error) + + // GetRequeueAfter is the generator can controller the next reconciled loop + // In case there is more then one generator the time will be the minimum of the times. + // In case NoRequeueAfter is empty, it will be ignored + GetRequeueAfter(appSetGenerator *argoprojiov1alpha1.ApplicationSetGenerator) time.Duration + + // GetTemplate returns the inline template from the spec if there is any, or an empty object otherwise + GetTemplate(appSetGenerator *argoprojiov1alpha1.ApplicationSetGenerator) *argoprojiov1alpha1.ApplicationSetTemplate +} + +var EmptyAppSetGeneratorError = errors.New("ApplicationSet is empty") +var NoRequeueAfter time.Duration diff --git a/pkg/generator/list.go b/pkg/generator/list.go new file mode 100644 index 00000000..9b9e65f2 --- /dev/null +++ b/pkg/generator/list.go @@ -0,0 +1,90 @@ +package generator + +import ( + "encoding/json" + "fmt" + "time" + + "sigs.k8s.io/yaml" + + argoprojiov1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" +) + +var _ Generator = (*ListGenerator)(nil) + +type ListGenerator struct { +} + +func NewListGenerator() Generator { + g := &ListGenerator{} + return g +} + +func (g *ListGenerator) GetRequeueAfter(appSetGenerator *argoprojiov1alpha1.ApplicationSetGenerator) time.Duration { + return NoRequeueAfter +} + +func (g *ListGenerator) GetTemplate(appSetGenerator *argoprojiov1alpha1.ApplicationSetGenerator) *argoprojiov1alpha1.ApplicationSetTemplate { + return &appSetGenerator.List.Template +} + +func (g *ListGenerator) GenerateParams(appSetGenerator *argoprojiov1alpha1.ApplicationSetGenerator, appSet *argoprojiov1alpha1.ApplicationSet) ([]map[string]interface{}, error) { + if appSetGenerator == nil { + return nil, EmptyAppSetGeneratorError + } + + if appSetGenerator.List == nil { + return nil, EmptyAppSetGeneratorError + } + + res := make([]map[string]interface{}, len(appSetGenerator.List.Elements)) + + for i, tmpItem := range appSetGenerator.List.Elements { + params := map[string]interface{}{} + var element map[string]interface{} + err := json.Unmarshal(tmpItem.Raw, &element) + if err != nil { + return nil, fmt.Errorf("error unmarshling list element %v", err) + } + + if appSet.Spec.GoTemplate { + res[i] = element + } else { + for key, value := range element { + if key == "values" { + values, ok := (value).(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("error parsing values map") + } + for k, v := range values { + value, ok := v.(string) + if !ok { + return nil, fmt.Errorf("error parsing value as string %v", err) + } + params[fmt.Sprintf("values.%s", k)] = value + } + } else { + v, ok := value.(string) + if !ok { + return nil, fmt.Errorf("error parsing value as string %v", err) + } + params[key] = v + } + res[i] = params + } + } + } + + // Append elements from ElementsYaml to the response + if len(appSetGenerator.List.ElementsYaml) > 0 { + + var yamlElements []map[string]interface{} + err := yaml.Unmarshal([]byte(appSetGenerator.List.ElementsYaml), &yamlElements) + if err != nil { + return nil, fmt.Errorf("error unmarshling decoded ElementsYaml %v", err) + } + res = append(res, yamlElements...) + } + + return res, nil +} diff --git a/pkg/generator/list_test.go b/pkg/generator/list_test.go new file mode 100644 index 00000000..272879ed --- /dev/null +++ b/pkg/generator/list_test.go @@ -0,0 +1,84 @@ +package generator + +import ( + "testing" + + "github.com/stretchr/testify/assert" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + argoprojiov1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" +) + +func TestGenerateListParams(t *testing.T) { + testCases := []struct { + elements []apiextensionsv1.JSON + expected []map[string]interface{} + }{ + { + elements: []apiextensionsv1.JSON{{Raw: []byte(`{"cluster": "cluster","url": "url"}`)}}, + expected: []map[string]interface{}{{"cluster": "cluster", "url": "url"}}, + }, { + elements: []apiextensionsv1.JSON{{Raw: []byte(`{"cluster": "cluster","url": "url","values":{"foo":"bar"}}`)}}, + expected: []map[string]interface{}{{"cluster": "cluster", "url": "url", "values.foo": "bar"}}, + }, + } + + for _, testCase := range testCases { + + var listGenerator = NewListGenerator() + + applicationSetInfo := argoprojiov1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "set", + }, + Spec: argoprojiov1alpha1.ApplicationSetSpec{}, + } + + got, err := listGenerator.GenerateParams(&argoprojiov1alpha1.ApplicationSetGenerator{ + List: &argoprojiov1alpha1.ListGenerator{ + Elements: testCase.elements, + }}, &applicationSetInfo) + + assert.NoError(t, err) + assert.ElementsMatch(t, testCase.expected, got) + + } +} + +func TestGenerateListParamsGoTemplate(t *testing.T) { + testCases := []struct { + elements []apiextensionsv1.JSON + expected []map[string]interface{} + }{ + { + elements: []apiextensionsv1.JSON{{Raw: []byte(`{"cluster": "cluster","url": "url"}`)}}, + expected: []map[string]interface{}{{"cluster": "cluster", "url": "url"}}, + }, { + elements: []apiextensionsv1.JSON{{Raw: []byte(`{"cluster": "cluster","url": "url","values":{"foo":"bar"}}`)}}, + expected: []map[string]interface{}{{"cluster": "cluster", "url": "url", "values": map[string]interface{}{"foo": "bar"}}}, + }, + } + + for _, testCase := range testCases { + + var listGenerator = NewListGenerator() + + applicationSetInfo := argoprojiov1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "set", + }, + Spec: argoprojiov1alpha1.ApplicationSetSpec{ + GoTemplate: true, + }, + } + + got, err := listGenerator.GenerateParams(&argoprojiov1alpha1.ApplicationSetGenerator{ + List: &argoprojiov1alpha1.ListGenerator{ + Elements: testCase.elements, + }}, &applicationSetInfo) + + assert.NoError(t, err) + assert.ElementsMatch(t, testCase.expected, got) + } +} diff --git a/pkg/generator/matrix.go b/pkg/generator/matrix.go new file mode 100644 index 00000000..650122d4 --- /dev/null +++ b/pkg/generator/matrix.go @@ -0,0 +1,191 @@ +package generator + +import ( + "fmt" + "time" + + "github.com/imdario/mergo" + + "github.com/argoproj/argo-cd/v2/applicationset/utils" + argoprojiov1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + + "github.com/rs/zerolog/log" +) + +var _ Generator = (*MatrixGenerator)(nil) + +var ( + ErrMoreThanTwoGenerators = fmt.Errorf("found more than two generators, Matrix support only two") + ErrLessThanTwoGenerators = fmt.Errorf("found less than two generators, Matrix support only two") + ErrMoreThenOneInnerGenerators = fmt.Errorf("found more than one generator in matrix.Generators") +) + +type MatrixGenerator struct { + // The inner generators supported by the matrix generator (cluster, git, list...) + supportedGenerators map[string]Generator +} + +func NewMatrixGenerator(supportedGenerators map[string]Generator) Generator { + m := &MatrixGenerator{ + supportedGenerators: supportedGenerators, + } + return m +} + +func (m *MatrixGenerator) GenerateParams(appSetGenerator *argoprojiov1alpha1.ApplicationSetGenerator, appSet *argoprojiov1alpha1.ApplicationSet) ([]map[string]interface{}, error) { + + if appSetGenerator.Matrix == nil { + return nil, EmptyAppSetGeneratorError + } + + if len(appSetGenerator.Matrix.Generators) < 2 { + return nil, ErrLessThanTwoGenerators + } + + if len(appSetGenerator.Matrix.Generators) > 2 { + return nil, ErrMoreThanTwoGenerators + } + + var res []map[string]interface{} + + g0, err := m.getParams(appSetGenerator.Matrix.Generators[0], appSet, nil) + if err != nil { + return nil, fmt.Errorf("error failed to get params for first generator in matrix generator: %w", err) + } + for _, a := range g0 { + g1, err := m.getParams(appSetGenerator.Matrix.Generators[1], appSet, a) + if err != nil { + return nil, fmt.Errorf("failed to get params for second generator in the matrix generator: %w", err) + } + for _, b := range g1 { + + if appSet.Spec.GoTemplate { + tmp := map[string]interface{}{} + if err := mergo.Merge(&tmp, b, mergo.WithOverride); err != nil { + return nil, fmt.Errorf("failed to merge params from the second generator in the matrix generator with temp map: %w", err) + } + if err := mergo.Merge(&tmp, a, mergo.WithOverride); err != nil { + return nil, fmt.Errorf("failed to merge params from the second generator in the matrix generator with the first: %w", err) + } + res = append(res, tmp) + } else { + val, err := utils.CombineStringMaps(a, b) + if err != nil { + return nil, fmt.Errorf("failed to combine string maps with merging params for the matrix generator: %w", err) + } + res = append(res, utils.ConvertToMapStringInterface(val)) + } + } + } + + return res, nil +} + +func (m *MatrixGenerator) getParams(appSetBaseGenerator argoprojiov1alpha1.ApplicationSetNestedGenerator, appSet *argoprojiov1alpha1.ApplicationSet, params map[string]interface{}) ([]map[string]interface{}, error) { + matrixGen, err := getMatrixGenerator(appSetBaseGenerator) + if err != nil { + return nil, err + } + if matrixGen != nil && !appSet.Spec.ApplyNestedSelectors { + foundSelector := dropDisabledNestedSelectors(matrixGen.Generators) + if foundSelector { + log.Warn().Msgf("AppSet '%v' defines selector on nested matrix generator's generator without enabling them via 'spec.applyNestedSelectors', ignoring nested selectors", appSet.Name) + } + } + mergeGen, err := getMergeGenerator(appSetBaseGenerator) + if err != nil { + return nil, fmt.Errorf("error retrieving merge generator: %w", err) + } + if mergeGen != nil && !appSet.Spec.ApplyNestedSelectors { + foundSelector := dropDisabledNestedSelectors(mergeGen.Generators) + if foundSelector { + log.Warn().Msgf("AppSet '%v' defines selector on nested merge generator's generator without enabling them via 'spec.applyNestedSelectors', ignoring nested selectors", appSet.Name) + } + } + + t, err := Transform( + argoprojiov1alpha1.ApplicationSetGenerator{ + List: appSetBaseGenerator.List, + Clusters: appSetBaseGenerator.Clusters, + Git: appSetBaseGenerator.Git, + SCMProvider: appSetBaseGenerator.SCMProvider, + ClusterDecisionResource: appSetBaseGenerator.ClusterDecisionResource, + PullRequest: appSetBaseGenerator.PullRequest, + Plugin: appSetBaseGenerator.Plugin, + Matrix: matrixGen, + Merge: mergeGen, + Selector: appSetBaseGenerator.Selector, + }, + m.supportedGenerators, + argoprojiov1alpha1.ApplicationSetTemplate{}, + appSet, + params) + + if err != nil { + return nil, fmt.Errorf("child generator returned an error on parameter generation: %v", err) + } + + if len(t) == 0 { + return nil, fmt.Errorf("child generator generated no parameters") + } + + if len(t) > 1 { + return nil, ErrMoreThenOneInnerGenerators + } + + return t[0].Params, nil +} + +const maxDuration time.Duration = 1<<63 - 1 + +func (m *MatrixGenerator) GetRequeueAfter(appSetGenerator *argoprojiov1alpha1.ApplicationSetGenerator) time.Duration { + res := maxDuration + var found bool + + for _, r := range appSetGenerator.Matrix.Generators { + matrixGen, _ := getMatrixGenerator(r) + mergeGen, _ := getMergeGenerator(r) + base := &argoprojiov1alpha1.ApplicationSetGenerator{ + List: r.List, + Clusters: r.Clusters, + Git: r.Git, + PullRequest: r.PullRequest, + Plugin: r.Plugin, + SCMProvider: r.SCMProvider, + ClusterDecisionResource: r.ClusterDecisionResource, + Matrix: matrixGen, + Merge: mergeGen, + } + generators := GetRelevantGenerators(base, m.supportedGenerators) + + for _, g := range generators { + temp := g.GetRequeueAfter(base) + if temp < res && temp != NoRequeueAfter { + found = true + res = temp + } + } + } + + if found { + return res + } else { + return NoRequeueAfter + } + +} + +func getMatrixGenerator(r argoprojiov1alpha1.ApplicationSetNestedGenerator) (*argoprojiov1alpha1.MatrixGenerator, error) { + if r.Matrix == nil { + return nil, nil + } + matrix, err := argoprojiov1alpha1.ToNestedMatrixGenerator(r.Matrix) + if err != nil { + return nil, err + } + return matrix.ToMatrixGenerator(), nil +} + +func (m *MatrixGenerator) GetTemplate(appSetGenerator *argoprojiov1alpha1.ApplicationSetGenerator) *argoprojiov1alpha1.ApplicationSetTemplate { + return &appSetGenerator.Matrix.Template +} diff --git a/pkg/generator/matrix_test.go b/pkg/generator/matrix_test.go new file mode 100644 index 00000000..31b0d1c3 --- /dev/null +++ b/pkg/generator/matrix_test.go @@ -0,0 +1,289 @@ +package generator + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + argoprojiov1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" +) + +func TestMatrixGenerate(t *testing.T) { + + listGenerator := &argoprojiov1alpha1.ListGenerator{ + Elements: []apiextensionsv1.JSON{{Raw: []byte(`{"cluster": "Cluster","url": "Url", "templated": "test-{{path.basenameNormalized}}"}`)}}, + } + + testCases := []struct { + name string + baseGenerators []argoprojiov1alpha1.ApplicationSetNestedGenerator + expectedErr error + expected []map[string]interface{} + }{ + { + name: "happy flow - generate params from two lists", + baseGenerators: []argoprojiov1alpha1.ApplicationSetNestedGenerator{ + { + List: &argoprojiov1alpha1.ListGenerator{ + Elements: []apiextensionsv1.JSON{ + {Raw: []byte(`{"a": "1"}`)}, + {Raw: []byte(`{"a": "2"}`)}, + }, + }, + }, + { + List: &argoprojiov1alpha1.ListGenerator{ + Elements: []apiextensionsv1.JSON{ + {Raw: []byte(`{"b": "1"}`)}, + {Raw: []byte(`{"b": "2"}`)}, + }, + }, + }, + }, + expected: []map[string]interface{}{ + {"a": "1", "b": "1"}, + {"a": "1", "b": "2"}, + {"a": "2", "b": "1"}, + {"a": "2", "b": "2"}, + }, + }, + { + name: "returns error if there is more than two base generators", + baseGenerators: []argoprojiov1alpha1.ApplicationSetNestedGenerator{ + { + List: listGenerator, + }, + { + List: listGenerator, + }, + { + List: listGenerator, + }, + }, + expectedErr: ErrMoreThanTwoGenerators, + }, + } + + for _, testCase := range testCases { + testCaseCopy := testCase // Since tests may run in parallel + + t.Run(testCaseCopy.name, func(t *testing.T) { + genMock := &generatorMock{} + appSet := &argoprojiov1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "set", + }, + Spec: argoprojiov1alpha1.ApplicationSetSpec{}, + } + + for _, g := range testCaseCopy.baseGenerators { + gitGeneratorSpec := argoprojiov1alpha1.ApplicationSetGenerator{ + List: g.List, + } + genMock.On("GenerateParams", mock.AnythingOfType("*v1alpha1.ApplicationSetGenerator"), appSet, mock.Anything).Return([]map[string]interface{}{ + { + "path": "app1", + "path.basename": "app1", + "path.basenameNormalized": "app1", + }, + { + "path": "app2", + "path.basename": "app2", + "path.basenameNormalized": "app2", + }, + }, nil) + + genMock.On("GetTemplate", &gitGeneratorSpec). + Return(&argoprojiov1alpha1.ApplicationSetTemplate{}) + } + + matrixGenerator := NewMatrixGenerator( + map[string]Generator{ + "List": &ListGenerator{}, + }, + ) + + got, err := matrixGenerator.GenerateParams(&argoprojiov1alpha1.ApplicationSetGenerator{ + Matrix: &argoprojiov1alpha1.MatrixGenerator{ + Generators: testCaseCopy.baseGenerators, + Template: argoprojiov1alpha1.ApplicationSetTemplate{}, + }, + }, appSet) + + if testCaseCopy.expectedErr != nil { + require.ErrorIs(t, err, testCaseCopy.expectedErr) + } else { + require.NoError(t, err) + assert.Equal(t, testCaseCopy.expected, got) + } + }) + } +} + +func TestMatrixGenerateGoTemplate(t *testing.T) { + + listGenerator := &argoprojiov1alpha1.ListGenerator{ + Elements: []apiextensionsv1.JSON{{Raw: []byte(`{"cluster": "Cluster","url": "Url"}`)}}, + } + + testCases := []struct { + name string + baseGenerators []argoprojiov1alpha1.ApplicationSetNestedGenerator + expectedErr error + expected []map[string]interface{} + }{ + { + name: "happy flow - generate params from two lists", + baseGenerators: []argoprojiov1alpha1.ApplicationSetNestedGenerator{ + { + List: &argoprojiov1alpha1.ListGenerator{ + Elements: []apiextensionsv1.JSON{ + {Raw: []byte(`{"a": "1"}`)}, + {Raw: []byte(`{"a": "2"}`)}, + }, + }, + }, + { + List: &argoprojiov1alpha1.ListGenerator{ + Elements: []apiextensionsv1.JSON{ + {Raw: []byte(`{"b": "1"}`)}, + {Raw: []byte(`{"b": "2"}`)}, + }, + }, + }, + }, + expected: []map[string]interface{}{ + {"a": "1", "b": "1"}, + {"a": "1", "b": "2"}, + {"a": "2", "b": "1"}, + {"a": "2", "b": "2"}, + }, + }, + { + name: "parameter override: first list elements take precedence", + baseGenerators: []argoprojiov1alpha1.ApplicationSetNestedGenerator{ + { + List: &argoprojiov1alpha1.ListGenerator{ + Elements: []apiextensionsv1.JSON{ + {Raw: []byte(`{"booleanFalse": false, "booleanTrue": true, "stringFalse": "false", "stringTrue": "true"}`)}, + }, + }, + }, + { + List: &argoprojiov1alpha1.ListGenerator{ + Elements: []apiextensionsv1.JSON{ + {Raw: []byte(`{"booleanFalse": true, "booleanTrue": false, "stringFalse": "true", "stringTrue": "false"}`)}, + }, + }, + }, + }, + expected: []map[string]interface{}{ + {"booleanFalse": false, "booleanTrue": true, "stringFalse": "false", "stringTrue": "true"}, + }, + }, + { + name: "returns error if there is more than two base generators", + baseGenerators: []argoprojiov1alpha1.ApplicationSetNestedGenerator{ + { + List: listGenerator, + }, + { + List: listGenerator, + }, + { + List: listGenerator, + }, + }, + expectedErr: ErrMoreThanTwoGenerators, + }, + } + + for _, testCase := range testCases { + testCaseCopy := testCase // Since tests may run in parallel + + t.Run(testCaseCopy.name, func(t *testing.T) { + genMock := &generatorMock{} + appSet := &argoprojiov1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "set", + }, + Spec: argoprojiov1alpha1.ApplicationSetSpec{ + GoTemplate: true, + }, + } + + for _, g := range testCaseCopy.baseGenerators { + gitGeneratorSpec := argoprojiov1alpha1.ApplicationSetGenerator{ + List: g.List, + } + genMock.On("GenerateParams", mock.AnythingOfType("*v1alpha1.ApplicationSetGenerator"), appSet, mock.Anything).Return([]map[string]interface{}{ + { + "path": map[string]string{ + "path": "app1", + "basename": "app1", + "basenameNormalized": "app1", + }, + }, + { + "path": map[string]string{ + "path": "app2", + "basename": "app2", + "basenameNormalized": "app2", + }, + }, + }, nil) + + genMock.On("GetTemplate", &gitGeneratorSpec). + Return(&argoprojiov1alpha1.ApplicationSetTemplate{}) + } + + matrixGenerator := NewMatrixGenerator( + map[string]Generator{ + "List": &ListGenerator{}, + }, + ) + + got, err := matrixGenerator.GenerateParams(&argoprojiov1alpha1.ApplicationSetGenerator{ + Matrix: &argoprojiov1alpha1.MatrixGenerator{ + Generators: testCaseCopy.baseGenerators, + Template: argoprojiov1alpha1.ApplicationSetTemplate{}, + }, + }, appSet) + + if testCaseCopy.expectedErr != nil { + require.ErrorIs(t, err, testCaseCopy.expectedErr) + } else { + require.NoError(t, err) + assert.Equal(t, testCaseCopy.expected, got) + } + }) + } +} + +type generatorMock struct { + mock.Mock +} + +func (g *generatorMock) GetTemplate(appSetGenerator *argoprojiov1alpha1.ApplicationSetGenerator) *argoprojiov1alpha1.ApplicationSetTemplate { + args := g.Called(appSetGenerator) + + return args.Get(0).(*argoprojiov1alpha1.ApplicationSetTemplate) +} + +func (g *generatorMock) GenerateParams(appSetGenerator *argoprojiov1alpha1.ApplicationSetGenerator, appSet *argoprojiov1alpha1.ApplicationSet, _ client.Client) ([]map[string]interface{}, error) { + args := g.Called(appSetGenerator, appSet) + + return args.Get(0).([]map[string]interface{}), args.Error(1) +} + +func (g *generatorMock) GetRequeueAfter(appSetGenerator *argoprojiov1alpha1.ApplicationSetGenerator) time.Duration { + args := g.Called(appSetGenerator) + + return args.Get(0).(time.Duration) +} diff --git a/pkg/generator/merge.go b/pkg/generator/merge.go new file mode 100644 index 00000000..965a3a8a --- /dev/null +++ b/pkg/generator/merge.go @@ -0,0 +1,247 @@ +package generator + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/imdario/mergo" + + "github.com/argoproj/argo-cd/v2/applicationset/utils" + argoprojiov1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + + "github.com/rs/zerolog/log" +) + +var _ Generator = (*MergeGenerator)(nil) + +var ( + ErrLessThanTwoGeneratorsInMerge = fmt.Errorf("found less than two generators, Merge requires two or more") + ErrNoMergeKeys = fmt.Errorf("no merge keys were specified, Merge requires at least one") + ErrNonUniqueParamSets = fmt.Errorf("the parameters from a generator were not unique by the given mergeKeys, Merge requires all param sets to be unique") +) + +type MergeGenerator struct { + // The inner generators supported by the merge generator (cluster, git, list...) + supportedGenerators map[string]Generator +} + +// NewMergeGenerator returns a MergeGenerator which allows the given supportedGenerators as child generators. +func NewMergeGenerator(supportedGenerators map[string]Generator) Generator { + m := &MergeGenerator{ + supportedGenerators: supportedGenerators, + } + return m +} + +// getParamSetsForAllGenerators generates params for each child generator in a MergeGenerator. Param sets are returned +// in slices ordered according to the order of the given generators. +func (m *MergeGenerator) getParamSetsForAllGenerators(generators []argoprojiov1alpha1.ApplicationSetNestedGenerator, appSet *argoprojiov1alpha1.ApplicationSet) ([][]map[string]interface{}, error) { + var paramSets [][]map[string]interface{} + for i, generator := range generators { + generatorParamSets, err := m.getParams(generator, appSet) + if err != nil { + return nil, fmt.Errorf("error getting params from generator %d of %d: %w", i+1, len(generators), err) + } + // concatenate param lists produced by each generator + paramSets = append(paramSets, generatorParamSets) + } + return paramSets, nil +} + +// GenerateParams gets the params produced by the MergeGenerator. +func (m *MergeGenerator) GenerateParams(appSetGenerator *argoprojiov1alpha1.ApplicationSetGenerator, appSet *argoprojiov1alpha1.ApplicationSet) ([]map[string]interface{}, error) { + if appSetGenerator.Merge == nil { + return nil, EmptyAppSetGeneratorError + } + + if len(appSetGenerator.Merge.Generators) < 2 { + return nil, ErrLessThanTwoGeneratorsInMerge + } + + paramSetsFromGenerators, err := m.getParamSetsForAllGenerators(appSetGenerator.Merge.Generators, appSet) + if err != nil { + return nil, fmt.Errorf("error getting param sets from generators: %w", err) + } + + baseParamSetsByMergeKey, err := getParamSetsByMergeKey(appSetGenerator.Merge.MergeKeys, paramSetsFromGenerators[0]) + if err != nil { + return nil, fmt.Errorf("error getting param sets by merge key: %w", err) + } + + for _, paramSets := range paramSetsFromGenerators[1:] { + paramSetsByMergeKey, err := getParamSetsByMergeKey(appSetGenerator.Merge.MergeKeys, paramSets) + if err != nil { + return nil, fmt.Errorf("error getting param sets by merge key: %w", err) + } + + for mergeKeyValue, baseParamSet := range baseParamSetsByMergeKey { + if overrideParamSet, exists := paramSetsByMergeKey[mergeKeyValue]; exists { + + if appSet.Spec.GoTemplate { + if err := mergo.Merge(&baseParamSet, overrideParamSet, mergo.WithOverride); err != nil { + return nil, fmt.Errorf("error merging base param set with override param set: %w", err) + } + baseParamSetsByMergeKey[mergeKeyValue] = baseParamSet + } else { + overriddenParamSet, err := utils.CombineStringMapsAllowDuplicates(baseParamSet, overrideParamSet) + if err != nil { + return nil, fmt.Errorf("error combining string maps: %w", err) + } + baseParamSetsByMergeKey[mergeKeyValue] = utils.ConvertToMapStringInterface(overriddenParamSet) + } + } + } + } + + mergedParamSets := make([]map[string]interface{}, len(baseParamSetsByMergeKey)) + var i = 0 + for _, mergedParamSet := range baseParamSetsByMergeKey { + mergedParamSets[i] = mergedParamSet + i += 1 + } + + return mergedParamSets, nil +} + +// getParamSetsByMergeKey converts the given list of parameter sets to a map of parameter sets where the key is the +// unique key of the parameter set as determined by the given mergeKeys. If any two parameter sets share the same merge +// key, getParamSetsByMergeKey will throw NonUniqueParamSets. +func getParamSetsByMergeKey(mergeKeys []string, paramSets []map[string]interface{}) (map[string]map[string]interface{}, error) { + if len(mergeKeys) < 1 { + return nil, ErrNoMergeKeys + } + + deDuplicatedMergeKeys := make(map[string]bool, len(mergeKeys)) + for _, mergeKey := range mergeKeys { + deDuplicatedMergeKeys[mergeKey] = false + } + + paramSetsByMergeKey := make(map[string]map[string]interface{}, len(paramSets)) + for _, paramSet := range paramSets { + paramSetKey := make(map[string]interface{}) + for mergeKey := range deDuplicatedMergeKeys { + paramSetKey[mergeKey] = paramSet[mergeKey] + } + paramSetKeyJson, err := json.Marshal(paramSetKey) + if err != nil { + return nil, fmt.Errorf("error marshalling param set key json: %w", err) + } + paramSetKeyString := string(paramSetKeyJson) + if _, exists := paramSetsByMergeKey[paramSetKeyString]; exists { + return nil, fmt.Errorf("%w. Duplicate key was %s", ErrNonUniqueParamSets, paramSetKeyString) + } + paramSetsByMergeKey[paramSetKeyString] = paramSet + } + + return paramSetsByMergeKey, nil +} + +// getParams get the parameters generated by this generator. +func (m *MergeGenerator) getParams(appSetBaseGenerator argoprojiov1alpha1.ApplicationSetNestedGenerator, appSet *argoprojiov1alpha1.ApplicationSet) ([]map[string]interface{}, error) { + matrixGen, err := getMatrixGenerator(appSetBaseGenerator) + if err != nil { + return nil, err + } + if matrixGen != nil && !appSet.Spec.ApplyNestedSelectors { + foundSelector := dropDisabledNestedSelectors(matrixGen.Generators) + if foundSelector { + log.Warn().Msgf("AppSet '%v' defines selector on nested matrix generator's generator without enabling them via 'spec.applyNestedSelectors', ignoring nested selector", appSet.Name) + } + } + mergeGen, err := getMergeGenerator(appSetBaseGenerator) + if err != nil { + return nil, err + } + if mergeGen != nil && !appSet.Spec.ApplyNestedSelectors { + foundSelector := dropDisabledNestedSelectors(mergeGen.Generators) + if foundSelector { + log.Warn().Msgf("AppSet '%v' defines selector on nested merge generator's generator without enabling them via 'spec.applyNestedSelectors', ignoring nested selector", appSet.Name) + } + } + + t, err := Transform( + argoprojiov1alpha1.ApplicationSetGenerator{ + List: appSetBaseGenerator.List, + Clusters: appSetBaseGenerator.Clusters, + Git: appSetBaseGenerator.Git, + SCMProvider: appSetBaseGenerator.SCMProvider, + ClusterDecisionResource: appSetBaseGenerator.ClusterDecisionResource, + PullRequest: appSetBaseGenerator.PullRequest, + Plugin: appSetBaseGenerator.Plugin, + Matrix: matrixGen, + Merge: mergeGen, + Selector: appSetBaseGenerator.Selector, + }, + m.supportedGenerators, + argoprojiov1alpha1.ApplicationSetTemplate{}, + appSet, + map[string]interface{}{}) + + if err != nil { + return nil, fmt.Errorf("child generator returned an error on parameter generation: %v", err) + } + + if len(t) == 0 { + return nil, fmt.Errorf("child generator generated no parameters") + } + + if len(t) > 1 { + return nil, ErrMoreThenOneInnerGenerators + } + + return t[0].Params, nil +} + +func (m *MergeGenerator) GetRequeueAfter(appSetGenerator *argoprojiov1alpha1.ApplicationSetGenerator) time.Duration { + res := maxDuration + var found bool + + for _, r := range appSetGenerator.Merge.Generators { + matrixGen, _ := getMatrixGenerator(r) + mergeGen, _ := getMergeGenerator(r) + base := &argoprojiov1alpha1.ApplicationSetGenerator{ + List: r.List, + Clusters: r.Clusters, + Git: r.Git, + PullRequest: r.PullRequest, + Plugin: r.Plugin, + SCMProvider: r.SCMProvider, + ClusterDecisionResource: r.ClusterDecisionResource, + Matrix: matrixGen, + Merge: mergeGen, + } + generators := GetRelevantGenerators(base, m.supportedGenerators) + + for _, g := range generators { + temp := g.GetRequeueAfter(base) + if temp < res && temp != NoRequeueAfter { + found = true + res = temp + } + } + } + + if found { + return res + } else { + return NoRequeueAfter + } + +} + +func getMergeGenerator(r argoprojiov1alpha1.ApplicationSetNestedGenerator) (*argoprojiov1alpha1.MergeGenerator, error) { + if r.Merge == nil { + return nil, nil + } + merge, err := argoprojiov1alpha1.ToNestedMergeGenerator(r.Merge) + if err != nil { + return nil, fmt.Errorf("error converting to nested merge generator: %w", err) + } + return merge.ToMergeGenerator(), nil +} + +// GetTemplate gets the Template field for the MergeGenerator. +func (m *MergeGenerator) GetTemplate(appSetGenerator *argoprojiov1alpha1.ApplicationSetGenerator) *argoprojiov1alpha1.ApplicationSetTemplate { + return &appSetGenerator.Merge.Template +} diff --git a/pkg/generator/merge_test.go b/pkg/generator/merge_test.go new file mode 100644 index 00000000..5bac849d --- /dev/null +++ b/pkg/generator/merge_test.go @@ -0,0 +1,351 @@ +package generator + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + + argoprojiov1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" +) + +func getNestedListGenerator(json string) *argoprojiov1alpha1.ApplicationSetNestedGenerator { + return &argoprojiov1alpha1.ApplicationSetNestedGenerator{ + List: &argoprojiov1alpha1.ListGenerator{ + Elements: []apiextensionsv1.JSON{{Raw: []byte(json)}}, + }, + } +} + +func getTerminalListGeneratorMultiple(jsons []string) argoprojiov1alpha1.ApplicationSetTerminalGenerator { + elements := make([]apiextensionsv1.JSON, len(jsons)) + + for i, json := range jsons { + elements[i] = apiextensionsv1.JSON{Raw: []byte(json)} + } + + generator := argoprojiov1alpha1.ApplicationSetTerminalGenerator{ + List: &argoprojiov1alpha1.ListGenerator{ + Elements: elements, + }, + } + + return generator +} + +func listOfMapsToSet(maps []map[string]interface{}) (map[string]bool, error) { + set := make(map[string]bool, len(maps)) + for _, paramMap := range maps { + paramMapAsJson, err := json.Marshal(paramMap) + if err != nil { + return nil, err + } + + set[string(paramMapAsJson)] = false + } + return set, nil +} + +func TestMergeGenerate(t *testing.T) { + + testCases := []struct { + name string + baseGenerators []argoprojiov1alpha1.ApplicationSetNestedGenerator + mergeKeys []string + expectedErr error + expected []map[string]interface{} + }{ + { + name: "no generators", + baseGenerators: []argoprojiov1alpha1.ApplicationSetNestedGenerator{}, + mergeKeys: []string{"b"}, + expectedErr: ErrLessThanTwoGeneratorsInMerge, + }, + { + name: "one generator", + baseGenerators: []argoprojiov1alpha1.ApplicationSetNestedGenerator{ + *getNestedListGenerator(`{"a": "1_1","b": "same","c": "1_3"}`), + }, + mergeKeys: []string{"b"}, + expectedErr: ErrLessThanTwoGeneratorsInMerge, + }, + { + name: "happy flow - generate paramSets", + baseGenerators: []argoprojiov1alpha1.ApplicationSetNestedGenerator{ + *getNestedListGenerator(`{"a": "1_1","b": "same","c": "1_3"}`), + *getNestedListGenerator(`{"a": "2_1","b": "same"}`), + *getNestedListGenerator(`{"a": "3_1","b": "different","c": "3_3"}`), // gets ignored because its merge key value isn't in the base params set + }, + mergeKeys: []string{"b"}, + expected: []map[string]interface{}{ + {"a": "2_1", "b": "same", "c": "1_3"}, + }, + }, + { + name: "merge keys absent - do not merge", + baseGenerators: []argoprojiov1alpha1.ApplicationSetNestedGenerator{ + *getNestedListGenerator(`{"a": "a"}`), + *getNestedListGenerator(`{"a": "a"}`), + }, + mergeKeys: []string{"b"}, + expected: []map[string]interface{}{ + {"a": "a"}, + }, + }, + { + name: "merge key present in first set, absent in second - do not merge", + baseGenerators: []argoprojiov1alpha1.ApplicationSetNestedGenerator{ + *getNestedListGenerator(`{"a": "a"}`), + *getNestedListGenerator(`{"b": "b"}`), + }, + mergeKeys: []string{"b"}, + expected: []map[string]interface{}{ + {"a": "a"}, + }, + }, + { + name: "merge nested matrix with some lists", + baseGenerators: []argoprojiov1alpha1.ApplicationSetNestedGenerator{ + { + Matrix: toAPIExtensionsJSON(t, &argoprojiov1alpha1.NestedMatrixGenerator{ + Generators: []argoprojiov1alpha1.ApplicationSetTerminalGenerator{ + getTerminalListGeneratorMultiple([]string{`{"a": "1"}`, `{"a": "2"}`}), + getTerminalListGeneratorMultiple([]string{`{"b": "1"}`, `{"b": "2"}`}), + }, + }), + }, + *getNestedListGenerator(`{"a": "1", "b": "1", "c": "added"}`), + }, + mergeKeys: []string{"a", "b"}, + expected: []map[string]interface{}{ + {"a": "1", "b": "1", "c": "added"}, + {"a": "1", "b": "2"}, + {"a": "2", "b": "1"}, + {"a": "2", "b": "2"}, + }, + }, + { + name: "merge nested merge with some lists", + baseGenerators: []argoprojiov1alpha1.ApplicationSetNestedGenerator{ + { + Merge: toAPIExtensionsJSON(t, &argoprojiov1alpha1.NestedMergeGenerator{ + MergeKeys: []string{"a"}, + Generators: []argoprojiov1alpha1.ApplicationSetTerminalGenerator{ + getTerminalListGeneratorMultiple([]string{`{"a": "1", "b": "1"}`, `{"a": "2", "b": "2"}`}), + getTerminalListGeneratorMultiple([]string{`{"a": "1", "b": "3", "c": "added"}`, `{"a": "3", "b": "2"}`}), // First gets merged, second gets ignored + }, + }), + }, + *getNestedListGenerator(`{"a": "1", "b": "3", "d": "added"}`), + }, + mergeKeys: []string{"a", "b"}, + expected: []map[string]interface{}{ + {"a": "1", "b": "3", "c": "added", "d": "added"}, + {"a": "2", "b": "2"}, + }, + }, + } + + for _, testCase := range testCases { + testCaseCopy := testCase // since tests may run in parallel + + t.Run(testCaseCopy.name, func(t *testing.T) { + t.Parallel() + + appSet := &argoprojiov1alpha1.ApplicationSet{} + + var mergeGenerator = NewMergeGenerator( + map[string]Generator{ + "List": &ListGenerator{}, + "Matrix": &MatrixGenerator{ + supportedGenerators: map[string]Generator{ + "List": &ListGenerator{}, + }, + }, + "Merge": &MergeGenerator{ + supportedGenerators: map[string]Generator{ + "List": &ListGenerator{}, + }, + }, + }, + ) + + got, err := mergeGenerator.GenerateParams(&argoprojiov1alpha1.ApplicationSetGenerator{ + Merge: &argoprojiov1alpha1.MergeGenerator{ + Generators: testCaseCopy.baseGenerators, + MergeKeys: testCaseCopy.mergeKeys, + Template: argoprojiov1alpha1.ApplicationSetTemplate{}, + }, + }, appSet) + + if testCaseCopy.expectedErr != nil { + assert.EqualError(t, err, testCaseCopy.expectedErr.Error()) + } else { + expectedSet, err := listOfMapsToSet(testCaseCopy.expected) + assert.NoError(t, err) + + actualSet, err := listOfMapsToSet(got) + assert.NoError(t, err) + + assert.NoError(t, err) + assert.Equal(t, expectedSet, actualSet) + } + }) + } +} + +func toAPIExtensionsJSON(t *testing.T, g interface{}) *apiextensionsv1.JSON { + + resVal, err := json.Marshal(g) + if err != nil { + t.Error("unable to unmarshal json", g) + return nil + } + + res := &apiextensionsv1.JSON{Raw: resVal} + + return res +} + +func TestParamSetsAreUniqueByMergeKeys(t *testing.T) { + testCases := []struct { + name string + mergeKeys []string + paramSets []map[string]interface{} + expectedErr error + expected map[string]map[string]interface{} + }{ + { + name: "no merge keys", + mergeKeys: []string{}, + expectedErr: ErrNoMergeKeys, + }, + { + name: "no paramSets", + mergeKeys: []string{"key"}, + expected: make(map[string]map[string]interface{}), + }, + { + name: "simple key, unique paramSets", + mergeKeys: []string{"key"}, + paramSets: []map[string]interface{}{{"key": "a"}, {"key": "b"}}, + expected: map[string]map[string]interface{}{ + `{"key":"a"}`: {"key": "a"}, + `{"key":"b"}`: {"key": "b"}, + }, + }, + { + name: "simple key object, unique paramSets", + mergeKeys: []string{"key"}, + paramSets: []map[string]interface{}{{"key": map[string]interface{}{"hello": "world"}}, {"key": "b"}}, + expected: map[string]map[string]interface{}{ + `{"key":{"hello":"world"}}`: {"key": map[string]interface{}{"hello": "world"}}, + `{"key":"b"}`: {"key": "b"}, + }, + }, + { + name: "simple key, non-unique paramSets", + mergeKeys: []string{"key"}, + paramSets: []map[string]interface{}{{"key": "a"}, {"key": "b"}, {"key": "b"}}, + expectedErr: fmt.Errorf("%w. Duplicate key was %s", ErrNonUniqueParamSets, `{"key":"b"}`), + }, + { + name: "simple key, duplicated key name, unique paramSets", + mergeKeys: []string{"key", "key"}, + paramSets: []map[string]interface{}{{"key": "a"}, {"key": "b"}}, + expected: map[string]map[string]interface{}{ + `{"key":"a"}`: {"key": "a"}, + `{"key":"b"}`: {"key": "b"}, + }, + }, + { + name: "simple key, duplicated key name, non-unique paramSets", + mergeKeys: []string{"key", "key"}, + paramSets: []map[string]interface{}{{"key": "a"}, {"key": "b"}, {"key": "b"}}, + expectedErr: fmt.Errorf("%w. Duplicate key was %s", ErrNonUniqueParamSets, `{"key":"b"}`), + }, + { + name: "compound key, unique paramSets", + mergeKeys: []string{"key1", "key2"}, + paramSets: []map[string]interface{}{ + {"key1": "a", "key2": "a"}, + {"key1": "a", "key2": "b"}, + {"key1": "b", "key2": "a"}, + }, + expected: map[string]map[string]interface{}{ + `{"key1":"a","key2":"a"}`: {"key1": "a", "key2": "a"}, + `{"key1":"a","key2":"b"}`: {"key1": "a", "key2": "b"}, + `{"key1":"b","key2":"a"}`: {"key1": "b", "key2": "a"}, + }, + }, + { + name: "compound key object, unique paramSets", + mergeKeys: []string{"key1", "key2"}, + paramSets: []map[string]interface{}{ + {"key1": "a", "key2": map[string]interface{}{"hello": "world"}}, + {"key1": "a", "key2": "b"}, + {"key1": "b", "key2": "a"}, + }, + expected: map[string]map[string]interface{}{ + `{"key1":"a","key2":{"hello":"world"}}`: {"key1": "a", "key2": map[string]interface{}{"hello": "world"}}, + `{"key1":"a","key2":"b"}`: {"key1": "a", "key2": "b"}, + `{"key1":"b","key2":"a"}`: {"key1": "b", "key2": "a"}, + }, + }, + { + name: "compound key, duplicate key names, unique paramSets", + mergeKeys: []string{"key1", "key1", "key2"}, + paramSets: []map[string]interface{}{ + {"key1": "a", "key2": "a"}, + {"key1": "a", "key2": "b"}, + {"key1": "b", "key2": "a"}, + }, + expected: map[string]map[string]interface{}{ + `{"key1":"a","key2":"a"}`: {"key1": "a", "key2": "a"}, + `{"key1":"a","key2":"b"}`: {"key1": "a", "key2": "b"}, + `{"key1":"b","key2":"a"}`: {"key1": "b", "key2": "a"}, + }, + }, + { + name: "compound key, non-unique paramSets", + mergeKeys: []string{"key1", "key2"}, + paramSets: []map[string]interface{}{ + {"key1": "a", "key2": "a"}, + {"key1": "a", "key2": "a"}, + {"key1": "b", "key2": "a"}, + }, + expectedErr: fmt.Errorf("%w. Duplicate key was %s", ErrNonUniqueParamSets, `{"key1":"a","key2":"a"}`), + }, + { + name: "compound key, duplicate key names, non-unique paramSets", + mergeKeys: []string{"key1", "key1", "key2"}, + paramSets: []map[string]interface{}{ + {"key1": "a", "key2": "a"}, + {"key1": "a", "key2": "a"}, + {"key1": "b", "key2": "a"}, + }, + expectedErr: fmt.Errorf("%w. Duplicate key was %s", ErrNonUniqueParamSets, `{"key1":"a","key2":"a"}`), + }, + } + + for _, testCase := range testCases { + testCaseCopy := testCase // since tests may run in parallel + + t.Run(testCaseCopy.name, func(t *testing.T) { + t.Parallel() + + got, err := getParamSetsByMergeKey(testCaseCopy.mergeKeys, testCaseCopy.paramSets) + + if testCaseCopy.expectedErr != nil { + assert.EqualError(t, err, testCaseCopy.expectedErr.Error()) + } else { + assert.NoError(t, err) + assert.Equal(t, testCaseCopy.expected, got) + } + + }) + + } +} diff --git a/pkg/generator/value_interpolation.go b/pkg/generator/value_interpolation.go new file mode 100644 index 00000000..2ab74782 --- /dev/null +++ b/pkg/generator/value_interpolation.go @@ -0,0 +1,43 @@ +package generator + +import ( + "fmt" +) + +func appendTemplatedValues(values map[string]string, params map[string]interface{}, useGoTemplate bool, goTemplateOptions []string) error { + // We create a local map to ensure that we do not fall victim to a billion-laughs attack. We iterate through the + // cluster values map and only replace values in said map if it has already been allowlisted in the params map. + // Once we iterate through all the cluster values we can then safely merge the `tmp` map into the main params map. + tmp := map[string]interface{}{} + + for key, value := range values { + result, err := replaceTemplatedString(value, params, useGoTemplate, goTemplateOptions) + + if err != nil { + return fmt.Errorf("failed to replace templated string: %w", err) + } + + if useGoTemplate { + if tmp["values"] == nil { + tmp["values"] = map[string]string{} + } + tmp["values"].(map[string]string)[key] = result + } else { + tmp[fmt.Sprintf("values.%s", key)] = result + } + } + + for key, value := range tmp { + params[key] = value + } + + return nil +} + +func replaceTemplatedString(value string, params map[string]interface{}, useGoTemplate bool, goTemplateOptions []string) (string, error) { + replacedTmplStr, err := render.Replace(value, params, useGoTemplate, goTemplateOptions) + if err != nil { + return "", fmt.Errorf("failed to replace templated string with rendered values: %w", err) + } + return replacedTmplStr, nil +} diff --git a/pkg/kubernetes/api_client.go b/pkg/kubernetes/api_client.go new file mode 100644 index 00000000..42add3d6 --- /dev/null +++ b/pkg/kubernetes/api_client.go @@ -0,0 +1,97 @@ +package client + +import ( + "fmt" + "log/slog" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + controllerClient "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ClusterTypes must match with the cmd/root.go kubernetes-type flag +const ( + ClusterTypeEKS = "eks" + ClusterTypeLOCAL = "local" +) + +type NewClientOption func(*NewClientInput) + +type NewClientInput struct { + // ClusterType is a type of the Kubernetes cluster (required) + ClusterType string + // KubernetesConfigPath is a path to the kubeconfig file (optional) + KubernetesConfigPath string + // restConfig is the configuration for the Kubernetes client, used as a placeholder for the client creation + restConfig *rest.Config +} + +// New creates new Kubernetes clients with the specified options. +func New(input *NewClientInput, opts ...NewClientOption) (Interface, error) { + + var ( + k8sConfig *rest.Config + err error + ) + if input.ClusterType == ClusterTypeLOCAL { + if input.KubernetesConfigPath != "" { + k8sConfig, err = clientcmd.BuildConfigFromFlags("", input.KubernetesConfigPath) + if err != nil { + // running the service outside kubernetes without specifying a kubeconfig file or ENVVAR will error. + // ignore it and continue with the opts specific configuration. + slog.Warn(err.Error(), "msg", "Error building kubeconfig using local config") + } + } else { + // kubeConfigPath not provided, try rest.InClusterConfig, going to assume this service is running in a k8s cluster + k8sConfig, err = rest.InClusterConfig() + if err != nil { + slog.Error("unable to load in-cluster config", "err", err) + return nil, err + } + } + + } + + input.restConfig = k8sConfig + + // iterate the optional clients configuration and apply, if successful config.RestConfig will be updated + for _, opt := range opts { + opt(input) + } + if input.restConfig == nil { + return nil, fmt.Errorf("failed to init kubernetes configuration") + } + + clientSet, err := kubernetes.NewForConfig(input.restConfig) + if err != nil { + return nil, err + } + contClient, err := controllerClient.New(input.restConfig, controllerClient.Options{}) + if err != nil { + return nil, err + } + return &kubernetesClientSet{ + clientSet: clientSet, + config: input.restConfig, + controllerClient: &contClient, + }, nil +} + +type kubernetesClientSet struct { + clientSet kubernetes.Interface + config *rest.Config + controllerClient *controllerClient.Client +} + +func (c *kubernetesClientSet) ClientSet() kubernetes.Interface { + return c.clientSet +} + +func (c *kubernetesClientSet) Config() *rest.Config { + return c.config +} + +func (c *kubernetesClientSet) ControllerClient() *controllerClient.Client { + return c.controllerClient +} diff --git a/pkg/kubernetes/api_eks_client.go b/pkg/kubernetes/api_eks_client.go new file mode 100644 index 00000000..1acd7a39 --- /dev/null +++ b/pkg/kubernetes/api_eks_client.go @@ -0,0 +1,178 @@ +package client + +import ( + "context" + "encoding/base64" + "fmt" + "log/slog" + "net/http" + "sync" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/eks" + smithyhttp "github.com/aws/smithy-go/transport/http" + "k8s.io/client-go/rest" + + "github.com/aws/aws-sdk-go-v2/service/sts" +) + +const ( + clusterIDHeader = "x-k8s-aws-id" + presignedURLExpiration = 10 * time.Minute + v1Prefix = "k8s-aws-v1." +) + +// getEKSToken returns a pre-signed token for accessing the EKS cluster. +func getEKSToken(ctx context.Context, cfg *aws.Config, clusterName string) (*string, error) { + stsClient := sts.NewFromConfig(*cfg) + // Create the pre-signed request + presignClient := sts.NewPresignClient(stsClient) + presignedURLRequest, err := presignClient.PresignGetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}, + func(presignOptions *sts.PresignOptions) { + presignOptions.ClientOptions = append(presignOptions.ClientOptions, appendPresignHeaderValuesFunc(clusterName)) + }) + if err != nil { + return nil, fmt.Errorf("failed to presign caller identity: %w", err) + } + + // Encode the pre-signed URL + token := v1Prefix + base64.RawURLEncoding.EncodeToString([]byte(presignedURLRequest.URL)) + return &token, nil +} + +func appendPresignHeaderValuesFunc(clusterID string) func(stsOptions *sts.Options) { + return func(stsOptions *sts.Options) { + stsOptions.APIOptions = append(stsOptions.APIOptions, + // Add clusterId Header. + smithyhttp.SetHeaderValue(clusterIDHeader, clusterID), + // Add X-Amz-Expires query param. + smithyhttp.SetHeaderValue("X-Amz-Expires", "60")) + } +} + +// EKSClientOption sets up a Kubernetes client for accessing an AWS EKS cluster. +// +// This function assumes that the AWS SDK configuration can obtain the necessary credentials +// from the environment. This is the default behavior of the AWS SDK. It is recommended to +// use IAM Roles for Service Accounts (IRSA) for EKS clusters to grant IAM roles to the pods. +// Using IRSA provides fine-grained permissions and security best practices. If IRSA is not +// used, the pod will attempt to use the instance credentials and generate an STS token for +// accessing the EKS cluster. +// +// Parameters: +// +// - ctx: the context for the AWS SDK client. +// +// - clusterID: the name of the EKS cluster. +func EKSClientOption(ctx context.Context, clusterID string) NewClientOption { + return func(c *NewClientInput) { + if c.ClusterType != ClusterTypeEKS { + slog.Error("incorrect cluster type from the ClientInput, type must be ClusterTypeEKS", "ClusterType", c.ClusterType) + return + } + var ( + cfg aws.Config + err error + ) + + cfg, err = config.LoadDefaultConfig(ctx) + if err != nil { + slog.Error("unable to load AWS SDK config", "err", err) + return + } + eksClient := eks.NewFromConfig(cfg) + // perform DescribeCluster to get endpoint address and CA data. + describeClusterOutput, err := eksClient.DescribeCluster(ctx, &eks.DescribeClusterInput{ + Name: &clusterID, + }) + if err != nil { + slog.Error("unable to describe EKS cluster", "err", err) + return + } + + token, err := getEKSToken(ctx, &cfg, *describeClusterOutput.Cluster.Name) + if err != nil { + slog.Error("unable to get EKS token", "err", err) + return + } + + caData, err := base64.StdEncoding.DecodeString(*describeClusterOutput.Cluster.CertificateAuthority.Data) + if err != nil { + slog.Error("failed to decode CA data", "err", err) + return + } + k8sConfig := &rest.Config{ + Host: *describeClusterOutput.Cluster.Endpoint, + BearerToken: *token, + TLSClientConfig: rest.TLSClientConfig{ + CAData: caData, + }, + } + // Create a token refresher and wrap the config with it + tokenRefresher := &TokenRefresher{ + awsConfig: &cfg, + clusterID: *describeClusterOutput.Cluster.Name, + restConfig: k8sConfig, + token: *token, + tokenExpiration: time.Now().Add(presignedURLExpiration), + } + + // override the WrapTransport method to refresh the token before each request + k8sConfig.WrapTransport = tokenRefresher.WrapTransport + + c.restConfig = k8sConfig + } +} + +type TokenRefresher struct { + sync.Mutex + awsConfig *aws.Config + clusterID string + restConfig *rest.Config + token string + tokenExpiration time.Time +} + +// refreshToken checks if the token is expired and refreshes it if necessary. +func (t *TokenRefresher) refreshToken(ctx context.Context) error { + t.Lock() + defer t.Unlock() + + if time.Now().After(t.tokenExpiration) { + token, err := getEKSToken(ctx, t.awsConfig, t.clusterID) + if err != nil { + return fmt.Errorf("unable to refresh EKS token, %v", err) + } + + t.token = *token + t.tokenExpiration = time.Now().Add(presignedURLExpiration) + t.restConfig.BearerToken = t.token + } + return nil +} + +func (t *TokenRefresher) WrapTransport(rt http.RoundTripper) http.RoundTripper { + return &transportWrapper{ + transport: rt, + refresher: t, + } +} + +type transportWrapper struct { + transport http.RoundTripper + refresher *TokenRefresher +} + +// RoundTrip will perform the refrsh token before each request +func (t *transportWrapper) RoundTrip(req *http.Request) (*http.Response, error) { + if err := t.refresher.refreshToken(req.Context()); err != nil { + // log the error and continue with the request + slog.Warn("failed to refresh token", "err", err) + } else { + // if only the token is refreshed, set the Authorization header + req.Header.Set("Authorization", "Bearer "+t.refresher.token) + } + return t.transport.RoundTrip(req) +} diff --git a/pkg/kubernetes/docs.go b/pkg/kubernetes/docs.go new file mode 100644 index 00000000..00278632 --- /dev/null +++ b/pkg/kubernetes/docs.go @@ -0,0 +1,22 @@ +// Package client provides utilities for creating and managing Kubernetes clients. +// +// This package includes functions and types for configuring and creating +// Kubernetes clients tailored to specific requirements, such as accessing +// AWS EKS clusters with appropriate authentication. +// +// Example usage: +// +// # specify the opts with Kubernetes Cluster type, such as EKSClientOption, otherwise the default local client will be created. +// +// * for EKS setup: +// +// client, err := client.New(&NewClientInput{ClusterType: ClusterTypeEKS}, EKSClientOption(ctx, "test-cluster-01", "us-east-1")) +// if err != nil { +// fmt.Printf("failed to create client: %v", err) +// return +// } +// +// * for localhost setup: +// +// client, err := client.New(&NewClientInput{ClusterType: ClusterTypeLOCAL, KuberKubernetesConfigPath: *configFilePath}) +package client diff --git a/pkg/kubernetes/interface.go b/pkg/kubernetes/interface.go new file mode 100644 index 00000000..a4a0ec6a --- /dev/null +++ b/pkg/kubernetes/interface.go @@ -0,0 +1,16 @@ +package client + +import ( + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + controllerClient "sigs.k8s.io/controller-runtime/pkg/client" +) + +type Interface interface { + // ClientSet returns the rest clientset to be used. + ClientSet() kubernetes.Interface + // ControllerClient returns the controller-runtime client to be used. + ControllerClient() *controllerClient.Client + // Config returns the rest.Config to be used. + Config() *rest.Config +}