diff --git a/.gitignore b/.gitignore index 543d9ae..498546f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ coverage.out /deploy-* /config.yaml -/.cr-* \ No newline at end of file +/.cr-* +/patch.json \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml index 51195a5..03df7c8 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -10,4 +10,5 @@ linters: - exhaustruct - varnamelen - musttag - - depguard \ No newline at end of file + - depguard + - maligned \ No newline at end of file diff --git a/Makefile b/Makefile index 1da49ab..5c5c4be 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,9 @@ image=paskalmaksim/pod-admission-controller:$(tag) config=config.yaml testnamespace=test-pod-admission-controller +namespace= +pod= + test: ./scripts/validate-license.sh go mod tidy @@ -38,7 +41,9 @@ run: -cert=./certs/server.crt \ -key=./certs/server.key \ -listen=127.0.0.1:8443 \ - -metrics.listen=127.0.0.1:31080 + -metrics.listen=127.0.0.1:31080 \ + -test.pod=$(pod) \ + -test.namespace=$(namespace) sslInit: rm -rf ./certs diff --git a/charts/pod-admission-controller/Chart.yaml b/charts/pod-admission-controller/Chart.yaml index 97cd07d..69998b3 100644 --- a/charts/pod-admission-controller/Chart.yaml +++ b/charts/pod-admission-controller/Chart.yaml @@ -1,7 +1,7 @@ apiVersion: v2 icon: https://helm.sh/img/helm.svg name: pod-admission-controller -version: 0.0.4 +version: 0.0.5 description: pod mutating admission controller maintainers: - name: maksim-paskal # Maksim Paskal diff --git a/charts/pod-admission-controller/templates/deployment.yaml b/charts/pod-admission-controller/templates/deployment.yaml index b0697dc..e592fbb 100644 --- a/charts/pod-admission-controller/templates/deployment.yaml +++ b/charts/pod-admission-controller/templates/deployment.yaml @@ -63,8 +63,10 @@ spec: {{ toYaml .Values.args | indent 8 }} {{ end }} ports: - - containerPort: 8443 - - containerPort: 31080 + - name: https + containerPort: 8443 + - name: metrics + containerPort: 31080 volumeMounts: - name: config mountPath: /config @@ -74,13 +76,13 @@ spec: httpGet: scheme: HTTPS path: /ready - port: 8443 + port: https initialDelaySeconds: 3 periodSeconds: 5 livenessProbe: httpGet: scheme: HTTPS path: /healthz - port: 8443 + port: https initialDelaySeconds: 10 periodSeconds: 10 \ No newline at end of file diff --git a/charts/pod-admission-controller/templates/rbac.yaml b/charts/pod-admission-controller/templates/rbac.yaml index 2dd22f1..ebc608b 100644 --- a/charts/pod-admission-controller/templates/rbac.yaml +++ b/charts/pod-admission-controller/templates/rbac.yaml @@ -11,6 +11,9 @@ rules: - apiGroups: [""] resources: ["namespaces"] verbs: ["get"] +- apiGroups: [""] + resources: ["secrets"] + verbs: ["get","delete","create"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/charts/pod-admission-controller/templates/webhook.yaml b/charts/pod-admission-controller/templates/webhook.yaml index 130e774..85654fc 100644 --- a/charts/pod-admission-controller/templates/webhook.yaml +++ b/charts/pod-admission-controller/templates/webhook.yaml @@ -6,7 +6,7 @@ metadata: app: pod-admission-controller webhooks: - name: pod-admission-controller.pod-admission-controller.svc.cluster.local - failurePolicy: Ignore + failurePolicy: {{ .Values.webhook.failurePolicy }} clientConfig: caBundle: {{ tpl .Values.webhook.caBundle . | quote }} service: @@ -18,6 +18,10 @@ webhooks: apiGroups: [""] apiVersions: ["v1"] resources: ["pods"] + - operations: ["UPDATE"] + apiGroups: [""] + apiVersions: ["v1"] + resources: ["namespaces"] admissionReviewVersions: ["v1"] sideEffects: None timeoutSeconds: 5 diff --git a/charts/pod-admission-controller/values.yaml b/charts/pod-admission-controller/values.yaml index fa04aba..70f6db7 100644 --- a/charts/pod-admission-controller/values.yaml +++ b/charts/pod-admission-controller/values.yaml @@ -110,6 +110,8 @@ certificates: webhook: caBundle: "{{ b64enc .Values.certificates.caCert }}" + # Fail/Ignore + failurePolicy: Ignore namespaceSelector: - key: environment operator: In diff --git a/e2e/requirements/Pods/1/InitContainers/0/Env.json b/e2e/requirements/Pods/1/InitContainers/0/Env.json index ec747fa..db3844e 100644 --- a/e2e/requirements/Pods/1/InitContainers/0/Env.json +++ b/e2e/requirements/Pods/1/InitContainers/0/Env.json @@ -1 +1 @@ -null \ No newline at end of file +[{"name":"TEST_ENV","value":"ok"},{"name":"TEST_HOST","valueFrom":{"fieldRef":{"apiVersion":"v1","fieldPath":"status.hostIP"}}},{"name":"TEST_PORT","value":"6831"},{"name":"SERVICE_NAME","value":"alpine"}] \ No newline at end of file diff --git a/e2e/requirements/Pods/1/InitContainers/0/Resources.json b/e2e/requirements/Pods/1/InitContainers/0/Resources.json index 9e26dfe..eb635f7 100644 --- a/e2e/requirements/Pods/1/InitContainers/0/Resources.json +++ b/e2e/requirements/Pods/1/InitContainers/0/Resources.json @@ -1 +1 @@ -{} \ No newline at end of file +{"limits":{"memory":"500Mi"},"requests":{"cpu":"100m","memory":"500Mi"}} \ No newline at end of file diff --git a/e2e/requirements/Pods/1/InitContainers/0/SecurityContext.json b/e2e/requirements/Pods/1/InitContainers/0/SecurityContext.json index f0358ce..dbd4739 100644 --- a/e2e/requirements/Pods/1/InitContainers/0/SecurityContext.json +++ b/e2e/requirements/Pods/1/InitContainers/0/SecurityContext.json @@ -1 +1 @@ -{"runAsUser":1002} \ No newline at end of file +{"capabilities":{"drop":["ALL"]},"privileged":false,"runAsUser":1002,"runAsNonRoot":true,"allowPrivilegeEscalation":false} \ No newline at end of file diff --git a/e2e/requirements/Pods/1/InitContainers/1/Env.json b/e2e/requirements/Pods/1/InitContainers/1/Env.json index 01aaac1..b3b122e 100644 --- a/e2e/requirements/Pods/1/InitContainers/1/Env.json +++ b/e2e/requirements/Pods/1/InitContainers/1/Env.json @@ -1 +1 @@ -[{"name":"TEST","value":"ok"}] \ No newline at end of file +[{"name":"TEST","value":"ok"},{"name":"TEST_ENV","value":"ok"},{"name":"TEST_HOST","valueFrom":{"fieldRef":{"apiVersion":"v1","fieldPath":"status.hostIP"}}},{"name":"TEST_PORT","value":"6831"},{"name":"SERVICE_NAME","value":"alpine"}] \ No newline at end of file diff --git a/e2e/requirements/Pods/1/InitContainers/1/Resources.json b/e2e/requirements/Pods/1/InitContainers/1/Resources.json index 7d13bbd..eb635f7 100644 --- a/e2e/requirements/Pods/1/InitContainers/1/Resources.json +++ b/e2e/requirements/Pods/1/InitContainers/1/Resources.json @@ -1 +1 @@ -{"requests":{"cpu":"100m"}} \ No newline at end of file +{"limits":{"memory":"500Mi"},"requests":{"cpu":"100m","memory":"500Mi"}} \ No newline at end of file diff --git a/e2e/requirements/Pods/1/InitContainers/1/SecurityContext.json b/e2e/requirements/Pods/1/InitContainers/1/SecurityContext.json index ec747fa..b215fd6 100644 --- a/e2e/requirements/Pods/1/InitContainers/1/SecurityContext.json +++ b/e2e/requirements/Pods/1/InitContainers/1/SecurityContext.json @@ -1 +1 @@ -null \ No newline at end of file +{"capabilities":{"drop":["ALL"]},"privileged":false,"runAsUser":1001,"runAsNonRoot":true,"allowPrivilegeEscalation":false} \ No newline at end of file diff --git a/e2e/requirements/Pods/1/containers/0/Env.json b/e2e/requirements/Pods/1/containers/0/Env.json index cad9b1c..4f988f4 100644 --- a/e2e/requirements/Pods/1/containers/0/Env.json +++ b/e2e/requirements/Pods/1/containers/0/Env.json @@ -1 +1 @@ -[{"name":"TEST0","value":"test0"},{"name":"TEST_ENV","value":"ok"},{"name":"TEST_HOST","valueFrom":{"fieldRef":{"apiVersion":"v1","fieldPath":"status.hostIP"}}},{"name":"TEST_PORT","value":"6831"},{"name":"SERVICE_NAME","value":"library-alpine"}] \ No newline at end of file +[{"name":"TEST0","value":"test0"},{"name":"TEST_ENV","value":"ok"},{"name":"TEST_HOST","valueFrom":{"fieldRef":{"apiVersion":"v1","fieldPath":"status.hostIP"}}},{"name":"TEST_PORT","value":"6831"},{"name":"SERVICE_NAME","value":"alpine"}] \ No newline at end of file diff --git a/e2e/requirements/Pods/1/containers/1/Env.json b/e2e/requirements/Pods/1/containers/1/Env.json index 47d4022..db3844e 100644 --- a/e2e/requirements/Pods/1/containers/1/Env.json +++ b/e2e/requirements/Pods/1/containers/1/Env.json @@ -1 +1 @@ -[{"name":"TEST_ENV","value":"ok"},{"name":"TEST_HOST","valueFrom":{"fieldRef":{"apiVersion":"v1","fieldPath":"status.hostIP"}}},{"name":"TEST_PORT","value":"6831"},{"name":"SERVICE_NAME","value":"library-alpine"}] \ No newline at end of file +[{"name":"TEST_ENV","value":"ok"},{"name":"TEST_HOST","valueFrom":{"fieldRef":{"apiVersion":"v1","fieldPath":"status.hostIP"}}},{"name":"TEST_PORT","value":"6831"},{"name":"SERVICE_NAME","value":"alpine"}] \ No newline at end of file diff --git a/e2e/requirements/Pods/1/containers/2/Env.json b/e2e/requirements/Pods/1/containers/2/Env.json index 47d4022..db3844e 100644 --- a/e2e/requirements/Pods/1/containers/2/Env.json +++ b/e2e/requirements/Pods/1/containers/2/Env.json @@ -1 +1 @@ -[{"name":"TEST_ENV","value":"ok"},{"name":"TEST_HOST","valueFrom":{"fieldRef":{"apiVersion":"v1","fieldPath":"status.hostIP"}}},{"name":"TEST_PORT","value":"6831"},{"name":"SERVICE_NAME","value":"library-alpine"}] \ No newline at end of file +[{"name":"TEST_ENV","value":"ok"},{"name":"TEST_HOST","valueFrom":{"fieldRef":{"apiVersion":"v1","fieldPath":"status.hostIP"}}},{"name":"TEST_PORT","value":"6831"},{"name":"SERVICE_NAME","value":"alpine"}] \ No newline at end of file diff --git a/e2e/requirements/Pods/1/containers/3/Env.json b/e2e/requirements/Pods/1/containers/3/Env.json index 47d4022..db3844e 100644 --- a/e2e/requirements/Pods/1/containers/3/Env.json +++ b/e2e/requirements/Pods/1/containers/3/Env.json @@ -1 +1 @@ -[{"name":"TEST_ENV","value":"ok"},{"name":"TEST_HOST","valueFrom":{"fieldRef":{"apiVersion":"v1","fieldPath":"status.hostIP"}}},{"name":"TEST_PORT","value":"6831"},{"name":"SERVICE_NAME","value":"library-alpine"}] \ No newline at end of file +[{"name":"TEST_ENV","value":"ok"},{"name":"TEST_HOST","valueFrom":{"fieldRef":{"apiVersion":"v1","fieldPath":"status.hostIP"}}},{"name":"TEST_PORT","value":"6831"},{"name":"SERVICE_NAME","value":"alpine"}] \ No newline at end of file diff --git a/e2e/testdata/config.yaml b/e2e/testdata/config.yaml index b8e86fb..4d9a155 100644 --- a/e2e/testdata/config.yaml +++ b/e2e/testdata/config.yaml @@ -25,4 +25,19 @@ rules: - name: TEST_PORT value: "6831" - name: SERVICE_NAME - value: "{{ .Image.Slug }}" \ No newline at end of file + value: "{{ .Image.Slug }}" + +- name: "rule-replaceContainerImageHost-1" + replaceContainerImageHost: + enabled: true + to: docker.io + conditions: + - key: .Image.Domain + operator: regexp + value: ^(test-fake.test.com)$ + +- name: "rule-replaceContainerImageHost-2" + replaceContainerImageHost: + enabled: true + from: test.test.com + to: docker.io \ No newline at end of file diff --git a/e2e/testdata/pods/test-pod-1.yaml b/e2e/testdata/pods/test-pod-1.yaml index f7c632d..b2b5ed4 100644 --- a/e2e/testdata/pods/test-pod-1.yaml +++ b/e2e/testdata/pods/test-pod-1.yaml @@ -15,14 +15,14 @@ spec: initContainers: # init containers must be unchanged - name: test-init-0 - image: alpine:latest + image: test.test.com/alpine:latest securityContext: runAsUser: 1002 command: - echo - ok - name: test-init-1 - image: alpine:latest + image: test-fake.test.com/alpine:latest env: - name: TEST value: ok @@ -39,7 +39,7 @@ spec: # 2. new memory limit # 3. securitycontext - name: test-0 - image: alpine:latest + image: test.test.com/alpine:latest command: - sleep - 1d @@ -56,7 +56,7 @@ spec: # 2. do not change memory limit (pod-admission-controller/ignoreAddDefaultResources) # 3. securitycontext - name: test-1 - image: alpine:latest + image: test.test.com/alpine:latest command: - sleep - 1d @@ -72,7 +72,7 @@ spec: # 2. new resources # 3. securitycontext without change (pod-admission-controller/ignoreRunAsNonRoot) - name: test-2 - image: alpine:latest + image: test.test.com/alpine:latest command: - sleep - 1d @@ -82,7 +82,7 @@ spec: # 2. do not change memory limit (pod-admission-controller/ignoreAddDefaultResources) # 3. securitycontext without change (pod-admission-controller/ignoreRunAsNonRoot) - name: test-3 - image: alpine:latest + image: test.test.com/alpine:latest command: - sleep - 1d @@ -92,7 +92,7 @@ spec: # 2. new resources # 3. securitycontext, replace runAsUser=0 to runAsUser=82 - name: test-4 - image: alpine:latest + image: test.test.com/alpine:latest command: - sleep - 1d diff --git a/internal/internal.go b/internal/internal.go index 886e017..615f47a 100644 --- a/internal/internal.go +++ b/internal/internal.go @@ -15,7 +15,10 @@ package internal import ( "context" "crypto/tls" + "crypto/x509" + "flag" "net/http" + "os" "time" logrushooksentry "github.com/maksim-paskal/logrus-hook-sentry" @@ -23,8 +26,6 @@ import ( "github.com/maksim-paskal/pod-admission-controller/pkg/config" "github.com/maksim-paskal/pod-admission-controller/pkg/metrics" "github.com/maksim-paskal/pod-admission-controller/pkg/sentry" - "github.com/maksim-paskal/pod-admission-controller/pkg/types" - "github.com/maksim-paskal/pod-admission-controller/pkg/utils" "github.com/maksim-paskal/pod-admission-controller/pkg/web" "github.com/pkg/errors" log "github.com/sirupsen/logrus" @@ -33,6 +34,11 @@ import ( // webhook spec timeoutSeconds. const serverTimeout = 5 * time.Second +var ( + testPod = flag.String("test.pod", "", "test pod") + testNamespace = flag.String("test.namespace", "", "test namespace") +) + func Start(ctx context.Context) error { hook, err := logrushooksentry.NewHook(ctx, logrushooksentry.Options{ SentryDSN: *config.Get().SentryDSN, @@ -46,19 +52,36 @@ func Start(ctx context.Context) error { log.Infof("Starting %s...", config.GetVersion()) - if err := CheckConfigRules(); err != nil { - return errors.Wrap(err, "error in config rules") - } - if err := sentry.CreateCache(ctx); err != nil { return errors.Wrap(err, "failed to create sentry cache") } + if len(*testPod)+len(*testNamespace) > 0 { + patchBytes, err := api.TestPOD(ctx, *testNamespace, *testPod) + if err != nil { + log.WithError(err).Error() + } + + log.Info("Creating patch.json...") + + if err := os.WriteFile("patch.json", patchBytes, 0o600); err != nil { //nolint:gomnd + log.WithError(err).Error() + } + + os.Exit(0) + + return nil + } + sCert, err := tls.LoadX509KeyPair(*config.Get().CertFile, *config.Get().KeyFile) if err != nil { return errors.Wrap(err, "can not load certificates") } + if err := printCertInfo(sCert); err != nil { + return errors.Wrap(err, "can not print certificate info") + } + go startServerTLS(ctx, sCert) go startMetricsServer(ctx) @@ -89,7 +112,7 @@ func startServerTLS(ctx context.Context, sCert tls.Certificate) { log.Infof("Listening on address %s", server.Addr) - if err := server.ListenAndServeTLS("", ""); err != nil { + if err := server.ListenAndServeTLS("", ""); err != nil && ctx.Err() == nil { log.WithError(err).Fatal() } } @@ -118,27 +141,22 @@ func startMetricsServer(ctx context.Context) { log.Infof("Listening metrics on address %s", server.Addr) - if err := server.ListenAndServe(); err != nil { + if err := server.ListenAndServe(); err != nil && ctx.Err() == nil { log.WithError(err).Fatal() } } -// check config for templating errors. -func CheckConfigRules() error { - for _, containerConfig := range config.Get().Rules { - containerInfo := types.ContainerInfo{ - Image: &types.ContainerImage{}, - } - - _, err := utils.CheckConditions(containerInfo, containerConfig.Conditions) +func printCertInfo(sCert tls.Certificate) error { + for _, cert := range sCert.Certificate { + x509Cert, err := x509.ParseCertificate(cert) if err != nil { - return errors.Wrap(err, "error in conditions") + return errors.Wrap(err, "can not parse certificate") } - _, err = api.FormatEnv(containerInfo, containerConfig.Env) - if err != nil { - return errors.Wrap(err, "error in env") - } + log.Infof("Certificate valid for %s till %s", + x509Cert.Subject.CommonName, + x509Cert.NotAfter.String(), + ) } return nil diff --git a/pkg/api/api.go b/pkg/api/api.go index e7517e9..dde7819 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -15,6 +15,8 @@ package api import ( "context" "encoding/json" + "fmt" + "reflect" "regexp" "strings" @@ -23,13 +25,13 @@ import ( "github.com/maksim-paskal/pod-admission-controller/pkg/config" "github.com/maksim-paskal/pod-admission-controller/pkg/metrics" "github.com/maksim-paskal/pod-admission-controller/pkg/patch" - "github.com/maksim-paskal/pod-admission-controller/pkg/template" "github.com/maksim-paskal/pod-admission-controller/pkg/types" "github.com/maksim-paskal/pod-admission-controller/pkg/utils" "github.com/pkg/errors" log "github.com/sirupsen/logrus" admissionv1 "k8s.io/api/admission/v1" corev1 "k8s.io/api/core/v1" + k8sErrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" @@ -41,33 +43,146 @@ var ( deserializer = codecs.UniversalDeserializer() ) +type MutateInput struct { + Namespace *corev1.Namespace + AdmissionReview *admissionv1.AdmissionReview +} + +func (m *MutateInput) GetNamespace(ctx context.Context) (*corev1.Namespace, error) { + if m.Namespace != nil { + return m.Namespace, nil + } + + namespace, err := client.KubeClient().CoreV1().Namespaces().Get(ctx, m.AdmissionReview.Request.Namespace, metav1.GetOptions{}) //nolint:lll + if err != nil { + return nil, errors.Wrap(err, "namespace not found") + } + + return namespace, nil +} + +func (m *MutateInput) GetType() string { + return m.AdmissionReview.Request.Resource.Resource +} + +type Mutation struct{} + +func NewMutation() *Mutation { + return &Mutation{} +} + +func (m *Mutation) Mutate(ctx context.Context, input *MutateInput) *admissionv1.AdmissionResponse { + switch input.GetType() { + case "pods": + return m.mutatePod(ctx, input) + case "namespaces": + return m.mutateNamespace(ctx, input) + } + + return m.mutateError(string(input.AdmissionReview.Request.UID), errors.Errorf("unknown resource type %s", input.GetType())) //nolint:lll +} + +func (m *Mutation) createSecret(ctx context.Context, namespace string, secret *types.CreateSecret) error { + _, err := client.KubeClient().CoreV1().Secrets(namespace).Get(ctx, secret.Name, metav1.GetOptions{}) + // return error if operation have some errors except not found + if err != nil && !k8sErrors.IsNotFound(err) { + return errors.Wrap(err, "error getting secret") + } + + // delete secret if exists + if !k8sErrors.IsNotFound(err) { + err := client.KubeClient().CoreV1().Secrets(namespace).Delete(ctx, secret.Name, metav1.DeleteOptions{}) + if err != nil { + return errors.Wrap(err, "error deleting secret") + } + } + + newSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secret.Name, + Labels: map[string]string{ + "app": "pod-admission-controller", + }, + }, + Type: corev1.SecretType(secret.Type), + Data: secret.Data, + } + + _, err = client.KubeClient().CoreV1().Secrets(namespace).Create(ctx, newSecret, metav1.CreateOptions{}) + if err != nil { + return errors.Wrap(err, "error creating secret") + } + + return nil +} + +func (m *Mutation) mutateNamespace(ctx context.Context, input *MutateInput) *admissionv1.AdmissionResponse { //nolint:lll + req := input.AdmissionReview.Request + + namespace := corev1.Namespace{} + + if err := json.Unmarshal(req.Object.Raw, &namespace); err != nil { + return m.mutateError(namespace.Name, err) + } + + if m.checkIgnoreAnnotation(namespace.Annotations) { + metrics.MutationsIgnored.WithLabelValues(namespace.Name).Inc() + + return &admissionv1.AdmissionResponse{ + Allowed: true, + Warnings: []string{ + fmt.Sprintf("%s, namespace %s", types.WarningObjectDoedNotNeedMutation, namespace.Name), + }, + } + } + + for _, secret := range config.Get().CreateSecrets { + if err := m.createSecret(ctx, namespace.Name, secret); err != nil { + return m.mutateError(namespace.Name, err) + } + } + + return &admissionv1.AdmissionResponse{ + Allowed: true, + Result: &metav1.Status{ + Status: metav1.StatusSuccess, + }, + } +} + // mutate pod. -func Mutate(namespace *corev1.Namespace, requestedAdmissionReview *admissionv1.AdmissionReview) *admissionv1.AdmissionResponse { //nolint:funlen,cyclop,lll - req := requestedAdmissionReview.Request +func (m *Mutation) mutatePod(ctx context.Context, input *MutateInput) *admissionv1.AdmissionResponse { //nolint:funlen,cyclop,lll + namespace, err := input.GetNamespace(ctx) + if err != nil { + return m.mutateError("namespace not found", err) + } + + req := input.AdmissionReview.Request + + pod := corev1.Pod{} - var pod corev1.Pod if err := json.Unmarshal(req.Object.Raw, &pod); err != nil { - return mutateError(namespace.Name, err) + return m.mutateError(namespace.Name, err) } - // some pods does not need mutation - // pod-admission-controller/ignore=true - if ignore, ok := pod.Annotations[types.AnnotationIgnore]; ok { - if strings.EqualFold(ignore, "true") { - metrics.MutationsIgnored.WithLabelValues(namespace.Name).Inc() + if m.checkIgnoreAnnotation(pod.Annotations) { + metrics.MutationsIgnored.WithLabelValues(namespace.Name).Inc() - return &admissionv1.AdmissionResponse{ - Allowed: true, - Warnings: []string{types.WarningPodDoedNotNeedMutation}, - } + return &admissionv1.AdmissionResponse{ + Allowed: true, + Warnings: []string{ + fmt.Sprintf("%s, pod %s/%s", types.WarningObjectDoedNotNeedMutation, namespace.Name, pod.Name), + }, } } mutationPatch := make([]types.PatchOperation, 0) - for order, container := range pod.Spec.Containers { - containerInfo := types.ContainerInfo{ - ContainerName: container.Name, + for _, podContainer := range types.PodContainersFromPod(namespace, &pod) { + containerInfo := &types.ContainerInfo{ + PodContainer: podContainer, + ContainerName: podContainer.Container.Name, + ContainerType: podContainer.Type, Namespace: namespace.Name, NamespaceAnnotations: namespace.Annotations, NamespaceLabels: namespace.Labels, @@ -76,18 +191,20 @@ func Mutate(namespace *corev1.Namespace, requestedAdmissionReview *admissionv1.A SelectedRules: []*types.Rule{}, } - imageInfo, err := GetImageInfo(container.Image) + imageInfo, err := GetImageInfo(podContainer.Container.Image) if err != nil { - return mutateError(namespace.Name, err) + return m.mutateError(namespace.Name, err) } containerInfo.Image = imageInfo + log.Debugf("containerInfo.Image=%+v", containerInfo.Image) + // check rule that corresponds to container for _, rule := range config.Get().Rules { match, err := utils.CheckConditions(containerInfo, rule.Conditions) if err != nil { - return mutateError(namespace.Name, err) + return m.mutateError(namespace.Name, err) } if match { @@ -100,25 +217,17 @@ func Mutate(namespace *corev1.Namespace, requestedAdmissionReview *admissionv1.A continue } - // get all formatted envs - formattedEnv, err := FormatEnv(containerInfo, containerInfo.GetSelectedRulesEnv()) + pathOps, err := patch.NewPatch(ctx, containerInfo) if err != nil { - return mutateError(namespace.Name, err) - } - - // create env patch if env is not empty - if len(formattedEnv) > 0 { - mutationPatch = append(mutationPatch, patch.CreateEnvPatch(order, containerInfo, container.Env, formattedEnv)...) - } - - // add default resources to container if rules have adddefaultresources enabled - if selectedRule, ok := containerInfo.GetSelectedRuleEnabled(types.SelectedRuleAddDefaultResources); ok { - mutationPatch = append(mutationPatch, patch.CreateDefaultResourcesPatch(selectedRule, order, containerInfo, container.Resources)...) //nolint:lll + return m.mutateError(namespace.Name, err) } - // add default resources to container if rules have runasnonroot enabled - if selectedRule, ok := containerInfo.GetSelectedRuleEnabled(types.SelectedRuleRunAsNonRoot); ok { - mutationPatch = append(mutationPatch, patch.CreateRunAsNonRootPatch(selectedRule, order, containerInfo, pod.Spec.SecurityContext, container.SecurityContext)...) //nolint:lll + for _, pathOp := range pathOps { + if m.patchContains(mutationPatch, pathOp) { + log.Debugf("patch already exists: %s", pathOp) + } else { + mutationPatch = append(mutationPatch, pathOp) + } } } @@ -130,22 +239,11 @@ func Mutate(namespace *corev1.Namespace, requestedAdmissionReview *admissionv1.A } } - podAnnotations := make(map[string]string) - if pod.Annotations != nil { - podAnnotations = pod.Annotations - } - - podAnnotations[types.AnnotationInjected] = "true" - - mutationPatch = append(mutationPatch, types.PatchOperation{ - Op: "add", - Path: "/metadata/annotations", - Value: podAnnotations, - }) + mutationPatch = append(mutationPatch, m.injectAnnotation(pod.Annotations)) patchBytes, err := json.Marshal(mutationPatch) if err != nil { - return mutateError(namespace.Name, err) + return m.mutateError(namespace.Name, err) } log.Infof("mutate %s/%s", req.Namespace, req.UID) @@ -168,8 +266,47 @@ func Mutate(namespace *corev1.Namespace, requestedAdmissionReview *admissionv1.A } } +func (m *Mutation) patchContains(patches []types.PatchOperation, patch types.PatchOperation) bool { + for _, p := range patches { + if p.Path == patch.Path && p.Op == patch.Op { + if reflect.DeepEqual(p.Value, patch.Value) { + return true + } + } + } + + return false +} + +// some objects does not need mutation +// pod-admission-controller/ignore=true. +func (m *Mutation) checkIgnoreAnnotation(annotations map[string]string) bool { + if ignore, ok := annotations[types.AnnotationIgnore]; ok { + if strings.EqualFold(ignore, "true") { + return true + } + } + + return false +} + +func (m *Mutation) injectAnnotation(annotations map[string]string) types.PatchOperation { + objAnnotations := make(map[string]string) + if annotations != nil { + objAnnotations = annotations + } + + objAnnotations[types.AnnotationInjected] = "true" + + return types.PatchOperation{ + Op: "add", + Path: "/metadata/annotations", + Value: objAnnotations, + } +} + // throw mutaion errors. -func mutateError(namespaceName string, err error) *admissionv1.AdmissionResponse { +func (m *Mutation) mutateError(namespaceName string, err error) *admissionv1.AdmissionResponse { log.WithError(err).Error("Error mutating") metrics.MutationsError.WithLabelValues(namespaceName).Inc() @@ -196,14 +333,13 @@ func ParseRequest(ctx context.Context, body []byte) ([]byte, error) { return nil, errors.Errorf("Expected v1.AdmissionReview but got: %T", obj) } - namespace, err := client.KubeClient().CoreV1().Namespaces().Get(ctx, requestedAdmissionReview.Request.Namespace, metav1.GetOptions{}) //nolint:lll - if err != nil { - return nil, errors.Wrap(err, "Could not get namespace") + input := &MutateInput{ + AdmissionReview: requestedAdmissionReview, } responseAdmissionReview := &admissionv1.AdmissionReview{} responseAdmissionReview.SetGroupVersionKind(*gvk) - responseAdmissionReview.Response = Mutate(namespace, requestedAdmissionReview) + responseAdmissionReview.Response = NewMutation().Mutate(ctx, input) responseAdmissionReview.Response.UID = requestedAdmissionReview.Request.UID responseObj = responseAdmissionReview @@ -215,26 +351,6 @@ func ParseRequest(ctx context.Context, body []byte) ([]byte, error) { return respBytes, nil } -// template values in container environment variables. -func FormatEnv(containerInfo types.ContainerInfo, containersEnv []corev1.EnvVar) ([]corev1.EnvVar, error) { - var err error - - formattedEnv := make([]corev1.EnvVar, 0) - - for _, containerEnv := range containersEnv { - item := containerEnv - - item.Value, err = template.Get(containerInfo, item.Value) - if err != nil { - return nil, errors.Wrap(err, "error template value") - } - - formattedEnv = append(formattedEnv, item) - } - - return formattedEnv, nil -} - // parse image to repo, slug path and tag. func GetImageInfo(image string) (*types.ContainerImage, error) { // check if image is has fully qualified name @@ -250,9 +366,10 @@ func GetImageInfo(image string) (*types.ContainerImage, error) { imageName = regexp.MustCompile("[^A-Za-z0-9]+").ReplaceAllString(imageName, "-") result := types.ContainerImage{ - Name: image, - Slug: strings.Trim(imageName, "-"), - Tag: "latest", + Domain: reference.Domain(refName), + Name: image, + Slug: strings.Trim(imageName, "-"), + Tag: "latest", } if tag, ok := refName.(reference.Tagged); ok { @@ -261,3 +378,33 @@ func GetImageInfo(image string) (*types.ContainerImage, error) { return &result, nil } + +func TestPOD(ctx context.Context, namespace, podName string) ([]byte, error) { + pod, err := client.KubeClient().CoreV1().Pods(namespace).Get(ctx, podName, metav1.GetOptions{}) + if err != nil { + return nil, errors.Wrap(err, "Could not get namespace") + } + + podJSON, err := json.Marshal(pod) + if err != nil { + return nil, errors.Wrap(err, "Could not marshal pod") + } + + input := &MutateInput{ + AdmissionReview: &admissionv1.AdmissionReview{ + Request: &admissionv1.AdmissionRequest{ + Object: runtime.RawExtension{ + Raw: podJSON, + }, + }, + }, + } + + log.Infof("req=%+v", input) + + resp := NewMutation().Mutate(ctx, input) + + log.Infof("warnings=%+v", resp.Warnings) + + return resp.Patch, nil +} diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go index 97cc793..cfe7a85 100644 --- a/pkg/api/api_test.go +++ b/pkg/api/api_test.go @@ -13,6 +13,7 @@ limitations under the License. package api_test import ( + "context" "encoding/json" "flag" "fmt" @@ -26,61 +27,10 @@ import ( "github.com/maksim-paskal/pod-admission-controller/pkg/types" admissionv1 "k8s.io/api/admission/v1" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) -func TestFormatEnv(t *testing.T) { - t.Parallel() - - containerEnv := []corev1.EnvVar{ - { - Name: "TEST1", - Value: `{{ index (regexp "/(.+):(.+)$" .Image.Name) 1 }}`, - }, - { - Name: "TEST2", - Value: "test", - }, - { - Name: "TEST3", - Value: "{{ .NamespaceLabels.app }}", - }, - { - Name: "TEST4", - Value: "{{ .NamespaceLabels.unknown }}", - }, - } - - containerInfo := types.ContainerInfo{ - Image: &types.ContainerImage{Name: "/1/2/3:4"}, - NamespaceLabels: map[string]string{"app": "testapp"}, - } - - result, err := api.FormatEnv(containerInfo, containerEnv) - if err != nil { - t.Fatal(err) - } - - if len(result) != len(containerEnv) { - t.Fatal("length must be equal") - } - - returnResults := []string{ - "TEST1:1/2/3", - "TEST2:test", - "TEST3:testapp", - "TEST4:", - } - - for i, returnResult := range returnResults { - v := fmt.Sprintf("%s:%s", result[i].Name, result[i].Value) - - if v != returnResult { - t.Fatalf("must be %s, got %s", returnResult, v) - } - } -} - func TestMutation(t *testing.T) { //nolint:funlen t.Parallel() @@ -136,17 +86,22 @@ func TestMutation(t *testing.T) { //nolint:funlen t.Fatal(err) } - requestedAdmissionReview := admissionv1.AdmissionReview{ - Request: &admissionv1.AdmissionRequest{ - Namespace: "test", - Object: runtime.RawExtension{ - Raw: podJSON, + input := api.MutateInput{ + Namespace: &corev1.Namespace{}, + AdmissionReview: &admissionv1.AdmissionReview{ + Request: &admissionv1.AdmissionRequest{ + Namespace: "test", + Resource: metav1.GroupVersionResource{ + Resource: "pods", + }, + Object: runtime.RawExtension{ + Raw: podJSON, + }, }, }, } - namespace := corev1.Namespace{} - response := api.Mutate(&namespace, &requestedAdmissionReview) + response := api.NewMutation().Mutate(context.TODO(), &input) if response.Result.Status != "Success" { t.Fatalf("status must be Success, got %s, %s", response.Result.Status, response.Result.Message) @@ -173,34 +128,40 @@ func TestGetImageInfo(t *testing.T) { tests := make(map[string]*types.ContainerImage) tests["10.10.10.10:5000/product/main/backend:release-20220516-1"] = &types.ContainerImage{ - Name: "10.10.10.10:5000/product/main/backend:release-20220516-1", - Slug: "product-main-backend", - Tag: "release-20220516-1", + Domain: "10.10.10.10:5000", + Name: "10.10.10.10:5000/product/main/backend:release-20220516-1", + Slug: "product-main-backend", + Tag: "release-20220516-1", } tests["10.10.10.10:5000/product/main/front:release-20220516-1"] = &types.ContainerImage{ - Name: "10.10.10.10:5000/product/main/front:release-20220516-1", - Slug: "product-main-front", - Tag: "release-20220516-1", + Domain: "10.10.10.10:5000", + Name: "10.10.10.10:5000/product/main/front:release-20220516-1", + Slug: "product-main-front", + Tag: "release-20220516-1", } tests["domain.com/hipages/php-fpm_exporter:1"] = &types.ContainerImage{ - Name: "domain.com/hipages/php-fpm_exporter:1", - Slug: "hipages-php-fpm-exporter", - Tag: "1", + Domain: "domain.com", + Name: "domain.com/hipages/php-fpm_exporter:1", + Slug: "hipages-php-fpm-exporter", + Tag: "1", } tests["domain.com/paskalmaksim/envoy-docker-image:v0.3.8"] = &types.ContainerImage{ - Name: "domain.com/paskalmaksim/envoy-docker-image:v0.3.8", - Slug: "paskalmaksim-envoy-docker-image", - Tag: "v0.3.8", + Domain: "domain.com", + Name: "domain.com/paskalmaksim/envoy-docker-image:v0.3.8", + Slug: "paskalmaksim-envoy-docker-image", + Tag: "v0.3.8", } tests["paskalmaksim/envoy-docker-image:v0.3.8"] = &types.ContainerImage{ - Name: "paskalmaksim/envoy-docker-image:v0.3.8", - Slug: "paskalmaksim-envoy-docker-image", - Tag: "v0.3.8", + Domain: "docker.io", + Name: "paskalmaksim/envoy-docker-image:v0.3.8", + Slug: "paskalmaksim-envoy-docker-image", + Tag: "v0.3.8", } tests["paskalmaksim/envoy-docker-image"] = &types.ContainerImage{ - Name: "paskalmaksim/envoy-docker-image", - Slug: "paskalmaksim-envoy-docker-image", - Tag: "latest", + Domain: "docker.io", + Name: "paskalmaksim/envoy-docker-image", + Slug: "paskalmaksim-envoy-docker-image", + Tag: "latest", } for test, requre := range tests { diff --git a/pkg/config/config.go b/pkg/config/config.go index 3fe9b32..be717d8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -48,6 +48,7 @@ type Params struct { SentryEndpoint *string SentryToken *string SentryDSN *string + CreateSecrets []*types.CreateSecret } var param = Params{ diff --git a/pkg/patch/custompatch/README.MD b/pkg/patch/custompatch/README.MD new file mode 100644 index 0000000..887b29d --- /dev/null +++ b/pkg/patch/custompatch/README.MD @@ -0,0 +1,10 @@ +```yaml +rules: +- custompatches: + - op: "remove" + path: "/spec/affinity" + - op: "remove" + path: "/spec/nodeSelector" + - op: "remove" + path: "{{ .PodContainer.ContainerPath }}/resources" +``` diff --git a/pkg/patch/custompatch/custompatch.go b/pkg/patch/custompatch/custompatch.go new file mode 100644 index 0000000..b6f8f2f --- /dev/null +++ b/pkg/patch/custompatch/custompatch.go @@ -0,0 +1,76 @@ +/* +Copyright paskal.maksim@gmail.com +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package custompatch + +import ( + "context" + "strings" + + "github.com/maksim-paskal/pod-admission-controller/pkg/template" + "github.com/maksim-paskal/pod-admission-controller/pkg/types" + "github.com/pkg/errors" +) + +type Patch struct{} + +func (p *Patch) Create(_ context.Context, containerInfo *types.ContainerInfo) ([]types.PatchOperation, error) { //nolint:lll + patch := make([]types.PatchOperation, 0) + + for _, selectedRule := range containerInfo.SelectedRules { + for _, customPatch := range selectedRule.CustomPatches { + if p.ignorePatch(customPatch, containerInfo) { + continue + } + + newPatch := customPatch + + var err error + + newPatch.Op, err = template.Get(containerInfo, newPatch.Op) + if err != nil { + return nil, errors.Wrap(err, "error parsing template Op") + } + + newPatch.Path, err = template.Get(containerInfo, newPatch.Path) + if err != nil { + return nil, errors.Wrap(err, "error parsing template Path") + } + + patch = append(patch, newPatch) + } + } + + return patch, nil +} + +// check well known operations and ignore them. +func (p *Patch) ignorePatch(patch types.PatchOperation, containerInfo *types.ContainerInfo) bool { + if patch.Op != "remove" { + return false + } + + pod := containerInfo.PodContainer.Pod + + switch strings.ToLower(patch.Path) { + case "/spec/affinity": + if pod.Spec.Affinity == nil { + return true + } + case "/spec/nodeselector": + if pod.Spec.NodeSelector == nil || len(pod.Spec.NodeSelector) == 0 { + return true + } + } + + return false +} diff --git a/pkg/patch/custompatch/custompatch_test.go b/pkg/patch/custompatch/custompatch_test.go new file mode 100644 index 0000000..ce860e6 --- /dev/null +++ b/pkg/patch/custompatch/custompatch_test.go @@ -0,0 +1,59 @@ +/* +Copyright paskal.maksim@gmail.com +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package custompatch_test + +import ( + "context" + "testing" + + "github.com/maksim-paskal/pod-admission-controller/pkg/patch/custompatch" + "github.com/maksim-paskal/pod-admission-controller/pkg/types" +) + +func TestCustompatch(t *testing.T) { + t.Parallel() + + patch := custompatch.Patch{} + + containerInfo := &types.ContainerInfo{ + ContainerName: "test", + PodContainer: &types.PodContainer{ + Order: 123, + Type: "container", + }, + SelectedRules: []*types.Rule{ + { + CustomPatches: []types.PatchOperation{ + { + Op: "{{ .ContainerName }}", + Path: "{{ .PodContainer.ContainerPath }}/annotations", + Value: nil, + }, + }, + }, + }, + } + + patchOps, err := patch.Create(context.TODO(), containerInfo) + if err != nil { + t.Fatal(err) + } + + if len(patchOps) != 1 { + t.Fatal("1 patch must be created") + } + + if patchOps[0].Op != "test" || patchOps[0].Path != "/spec/containers/123/annotations" { + t.Fatalf("not corrected patch %s", patchOps[0].String()) + } +} diff --git a/pkg/patch/env/README.md b/pkg/patch/env/README.md new file mode 100644 index 0000000..297e6bb --- /dev/null +++ b/pkg/patch/env/README.md @@ -0,0 +1,17 @@ +```yaml +rules: +- env: + - name: SENTRY_DSN + value: '{{ GetSentryDSN .Image.Slug }}' + - name: SENTRY_ENVIRONMENT + value: '{{ .NamespaceLabels.environment }}' + - name: SENTRY_RELEASE + value: '{{ .Image.Tag }}' + conditions: + - key: 'GetSentryDSN .Image.Slug' + operator: regexp + value: .+ + - key: .Namespace + operator: regexp + value: ^(paket|romantic|cfaas)($|-main-.+) +``` \ No newline at end of file diff --git a/pkg/patch/env/env.go b/pkg/patch/env/env.go new file mode 100644 index 0000000..4c139d3 --- /dev/null +++ b/pkg/patch/env/env.go @@ -0,0 +1,96 @@ +/* +Copyright paskal.maksim@gmail.com +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package env + +import ( + "context" + "fmt" + "strings" + + "github.com/maksim-paskal/pod-admission-controller/pkg/template" + "github.com/maksim-paskal/pod-admission-controller/pkg/types" + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" +) + +type Patch struct{} + +func (p *Patch) Create(_ context.Context, containerInfo *types.ContainerInfo) ([]types.PatchOperation, error) { + // some containers don't need env + // pod-admission-controller/ignoreEnv=container1,container2 + if ignore, ok := containerInfo.GetPodAnnotation(types.AnnotationIgnoreEnv); ok { + containersNames := strings.Split(ignore, ",") + for _, containerName := range containersNames { + if containerName == containerInfo.ContainerName { + return nil, nil + } + } + } + + formattedEnv, err := p.FormatEnv(containerInfo, containerInfo.GetSelectedRulesEnv()) + if err != nil { + return nil, errors.Wrap(err, "error format env") + } + + if len(formattedEnv) == 0 { + return []types.PatchOperation{}, nil + } + + patch := make([]types.PatchOperation, 0) + + if len(containerInfo.PodContainer.Container.Env) == 0 { + patch = append(patch, types.PatchOperation{ + Op: "add", + Path: fmt.Sprintf("%s/env", containerInfo.PodContainer.ContainerPath()), + Value: formattedEnv, + }) + } else { + containerEnvName := make(map[string]bool, 0) + // get all env from container + for _, env := range containerInfo.PodContainer.Container.Env { + containerEnvName[env.Name] = true + } + + for _, env := range formattedEnv { + // add env if not exists + if _, ok := containerEnvName[env.Name]; !ok { + patch = append(patch, types.PatchOperation{ + Op: "add", + Path: fmt.Sprintf("%s/env/-", containerInfo.PodContainer.ContainerPath()), + Value: env, + }) + } + } + } + + return patch, nil +} + +func (p *Patch) FormatEnv(containerInfo *types.ContainerInfo, containersEnv []corev1.EnvVar) ([]corev1.EnvVar, error) { + var err error + + formattedEnv := make([]corev1.EnvVar, 0) + + for _, containerEnv := range containersEnv { + item := containerEnv + + item.Value, err = template.Get(containerInfo, item.Value) + if err != nil { + return nil, errors.Wrap(err, "error template value") + } + + formattedEnv = append(formattedEnv, item) + } + + return formattedEnv, nil +} diff --git a/pkg/patch/env/env_test.go b/pkg/patch/env/env_test.go new file mode 100644 index 0000000..2dad5aa --- /dev/null +++ b/pkg/patch/env/env_test.go @@ -0,0 +1,153 @@ +/* +Copyright paskal.maksim@gmail.com +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package env_test + +import ( + "context" + "fmt" + "testing" + + "github.com/maksim-paskal/pod-admission-controller/pkg/patch/env" + "github.com/maksim-paskal/pod-admission-controller/pkg/types" + corev1 "k8s.io/api/core/v1" +) + +const addOperation = "add" + +func TestCreateEnvPatchEnv(t *testing.T) { //nolint:funlen + t.Parallel() + + patch := env.Patch{} + + containerEnv := []corev1.EnvVar{ + { + Name: "TEST1", + Value: "1", + }, + { + Name: "TEST2", + Value: "2", + }, + } + + containerInfo := &types.ContainerInfo{ + PodContainer: &types.PodContainer{ + Type: "container", + Container: &corev1.Container{ + Env: containerEnv, + }, + }, + SelectedRules: []*types.Rule{ + { + Env: []corev1.EnvVar{ + { + Name: "TEST1", + Value: "3", + }, + { + Name: "TEST4", + Value: "4", + }, + }, + }, + }, + } + + envPatch, err := patch.Create(context.TODO(), containerInfo) + if err != nil { + t.Fatal(err) + } + + if len(envPatch) != 1 { + t.Fatal("1 patch must be created") + } + + if envPatch[0].Op != addOperation || envPatch[0].Path != "/spec/containers/0/env/-" { + t.Fatalf("not corrected patch %s", envPatch[0].String()) + } + + // scenario 2 (container env is empty) + containerInfo.PodContainer = &types.PodContainer{ + Type: "container", + Container: &corev1.Container{ + Env: []corev1.EnvVar{}, + }, + } + + envPatch, err = patch.Create(context.TODO(), containerInfo) + if err != nil { + t.Fatal(err) + } + + if len(envPatch) != 1 { + t.Fatal("1 patch must be created") + } + + if envPatch[0].Op != addOperation || envPatch[0].Path != "/spec/containers/0/env" { + t.Fatalf("not corrected patch %s", envPatch[0].String()) + } +} + +func TestFormatEnv(t *testing.T) { + t.Parallel() + + patch := env.Patch{} + + containerEnv := []corev1.EnvVar{ + { + Name: "TEST1", + Value: `{{ index (regexp "/(.+):(.+)$" .Image.Name) 1 }}`, + }, + { + Name: "TEST2", + Value: "test", + }, + { + Name: "TEST3", + Value: "{{ .NamespaceLabels.app }}", + }, + { + Name: "TEST4", + Value: "{{ .NamespaceLabels.unknown }}", + }, + } + + containerInfo := &types.ContainerInfo{ + Image: &types.ContainerImage{Name: "/1/2/3:4"}, + NamespaceLabels: map[string]string{"app": "testapp"}, + } + + result, err := patch.FormatEnv(containerInfo, containerEnv) + if err != nil { + t.Fatal(err) + } + + if len(result) != len(containerEnv) { + t.Fatal("length must be equal") + } + + returnResults := []string{ + "TEST1:1/2/3", + "TEST2:test", + "TEST3:testapp", + "TEST4:", + } + + for i, returnResult := range returnResults { + v := fmt.Sprintf("%s:%s", result[i].Name, result[i].Value) + + if v != returnResult { + t.Fatalf("must be %s, got %s", returnResult, v) + } + } +} diff --git a/pkg/patch/imagehost/README.md b/pkg/patch/imagehost/README.md new file mode 100644 index 0000000..b331310 --- /dev/null +++ b/pkg/patch/imagehost/README.md @@ -0,0 +1,18 @@ +```yaml +rules: +- replaceContainerImageHost: + enabled: true + to: '{{ env "CLUSTER_REGISTRY" }}' + conditions: + - key: .Image.Domain + operator: regexp + value: ^(localhost:32000|10.100.0.11:5000|registry.(.+).com)$ + +- replaceContainerImageHost: + enabled: true + to: '{{ env "CLUSTER_REGISTRY_HUB" }}' + conditions: + - key: .Image.Domain + operator: regexp + value: ^(docker-hub-proxy.+|docker.io)$ +``` \ No newline at end of file diff --git a/pkg/patch/imagehost/imagehost.go b/pkg/patch/imagehost/imagehost.go new file mode 100644 index 0000000..6cd3de5 --- /dev/null +++ b/pkg/patch/imagehost/imagehost.go @@ -0,0 +1,87 @@ +/* +Copyright paskal.maksim@gmail.com +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package imagehost + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/maksim-paskal/pod-admission-controller/pkg/template" + "github.com/maksim-paskal/pod-admission-controller/pkg/types" + "github.com/pkg/errors" +) + +type Patch struct{} + +func (p *Patch) Create(_ context.Context, containerInfo *types.ContainerInfo) ([]types.PatchOperation, error) { //nolint:lll + patch := make([]types.PatchOperation, 0) + + image := containerInfo.Image.Name + + if !strings.HasPrefix(image, containerInfo.Image.Domain) { + // for short image names like nginx - use library/nginx + if strings.Count(image, "/") == 0 { + image = "library/" + image + } + + // if image name does not contain domain - add it + image = containerInfo.Image.Domain + "/" + image + } + + for _, selectedRule := range containerInfo.SelectedRules { + if !selectedRule.ReplaceContainerImageHost.Enabled { + continue + } + + selectedRule.Logf("CreateReplaceContainerImageHost: %+v", selectedRule) + + // use image domain if from is empty + fromPattern := selectedRule.ReplaceContainerImageHost.From + if len(fromPattern) == 0 { + fromPattern = containerInfo.Image.Domain + } + + selectedRule.Logf("CreateReplaceContainerImageHost: image=%s", image) + selectedRule.Logf("CreateReplaceContainerImageHost: to=%s", selectedRule.ReplaceContainerImageHost.To) + selectedRule.Logf("CreateReplaceContainerImageHost: from=%s", fromPattern) + + fromRegexp, err := regexp.Compile(fromPattern) + if err != nil { + return nil, errors.Wrap(err, "regexp.Compile") + } + + value, err := template.Get(containerInfo, selectedRule.ReplaceContainerImageHost.To) + if err != nil { + return nil, errors.Wrap(err, "template.Get") + } + + selectedRule.Logf("CreateReplaceContainerImageHost: value=%s", value) + + result := fromRegexp.ReplaceAll([]byte(image), []byte(value)) + + selectedRule.Logf("CreateReplaceContainerImageHost: result=%s", result) + + patch = append(patch, types.PatchOperation{ + Op: "replace", + Path: fmt.Sprintf("%s/image", containerInfo.PodContainer.ContainerPath()), + Value: string(result), + }) + + // process only first rule + break + } + + return patch, nil +} diff --git a/pkg/patch/imagehost/imagehost_test.go b/pkg/patch/imagehost/imagehost_test.go new file mode 100644 index 0000000..45f908f --- /dev/null +++ b/pkg/patch/imagehost/imagehost_test.go @@ -0,0 +1,181 @@ +/* +Copyright paskal.maksim@gmail.com +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package imagehost_test + +import ( + "context" + "os" + "testing" + + "github.com/maksim-paskal/pod-admission-controller/pkg/api" + "github.com/maksim-paskal/pod-admission-controller/pkg/patch/imagehost" + "github.com/maksim-paskal/pod-admission-controller/pkg/types" + corev1 "k8s.io/api/core/v1" +) + +func TestReplaceImageHostPatch(t *testing.T) { + t.Parallel() + + patch := imagehost.Patch{} + + const requiredValue = "fromenv" + + os.Setenv("TEST_ENV", requiredValue) //nolint:tenv + + containerInfo := &types.ContainerInfo{ + PodContainer: &types.PodContainer{ + Type: "container", + Container: &corev1.Container{}, + }, + Image: &types.ContainerImage{ + Name: "test1", + }, + SelectedRules: []*types.Rule{ + { + ReplaceContainerImageHost: types.ReplaceContainerImageHost{ + Enabled: true, + From: "test1", + To: `{{ env "TEST_ENV" }}`, + }, + }, + }, + } + + patchOps, err := patch.Create(context.TODO(), containerInfo) + if err != nil { + t.Fatal(err) + } + + if len(patchOps) != 1 { + t.Fatal("1 patch must be created") + } + + if patchOps[0].Value != requiredValue { + t.Fatalf("not corrected value %s/%s", patchOps[0].Value, requiredValue) + } +} + +func TestReplaceImageHostPatchDockerIo(t *testing.T) { //nolint:funlen + t.Parallel() + + patch := imagehost.Patch{} + + type Test struct { + Image string + Required string + } + + tests := []Test{ + { + Image: "alpine:3.12", + Required: "my.docker.io/library/alpine:3.12", + }, + { + Image: "alpine", + Required: "my.docker.io/library/alpine", + }, + { + Image: "alpine:3.12@sha256:8fd21d59428507671ce0fb47f818b1d859c92d2ad07bb7c947268d433030ba98", + Required: "my.docker.io/library/alpine:3.12@sha256:8fd21d59428507671ce0fb47f818b1d859c92d2ad07bb7c947268d433030ba98", + }, + { + Image: "alpine@sha256:8fd21d59428507671ce0fb47f818b1d859c92d2ad07bb7c947268d433030ba98", + Required: "my.docker.io/library/alpine@sha256:8fd21d59428507671ce0fb47f818b1d859c92d2ad07bb7c947268d433030ba98", + }, + { + Image: "test/alpine", + Required: "my.docker.io/test/alpine", + }, + { + Image: "test/alpine:3.12", + Required: "my.docker.io/test/alpine:3.12", + }, + } + + for _, test := range tests { + imageInfo, err := api.GetImageInfo(test.Image) + if err != nil { + t.Fatal(err) + } + + containerInfo := &types.ContainerInfo{ + PodContainer: &types.PodContainer{ + Type: "container", + Container: &corev1.Container{}, + }, + Image: imageInfo, + SelectedRules: []*types.Rule{ + { + ReplaceContainerImageHost: types.ReplaceContainerImageHost{ + Enabled: true, + From: "docker.io", + To: `my.docker.io`, + }, + }, + }, + } + + patchOps, err := patch.Create(context.TODO(), containerInfo) + if err != nil { + t.Fatal(err) + } + + if len(patchOps) != 1 { + t.Fatal("1 patch must be created") + } + + if patchOps[0].Value != test.Required { + t.Fatalf("not corrected value got=%s, required=%s", patchOps[0].Value, test.Required) + } + } +} + +func TestReplaceImageHostPatchNotDockerIo(t *testing.T) { + t.Parallel() + + patch := imagehost.Patch{} + + containerInfo := &types.ContainerInfo{ + PodContainer: &types.PodContainer{ + Type: "container", + Container: &corev1.Container{}, + }, + Image: &types.ContainerImage{ + Name: "some.docker.io/test1/test2", + }, + SelectedRules: []*types.Rule{ + { + ReplaceContainerImageHost: types.ReplaceContainerImageHost{ + Enabled: true, + From: "some.docker.io", + To: `my.docker.io`, + }, + }, + }, + } + + patchOps, err := patch.Create(context.TODO(), containerInfo) + if err != nil { + t.Fatal(err) + } + + if len(patchOps) != 1 { + t.Fatal("1 patch must be created") + } + + requiredValue := "my.docker.io/test1/test2" + + if patchOps[0].Value != requiredValue { + t.Fatalf("not corrected value got=%s, required=%s", patchOps[0].Value, requiredValue) + } +} diff --git a/pkg/patch/nonroot/README.md b/pkg/patch/nonroot/README.md new file mode 100644 index 0000000..f8f219f --- /dev/null +++ b/pkg/patch/nonroot/README.md @@ -0,0 +1,16 @@ +```yaml +rules: +- runAsNonRoot: + enabled: true + replaceUser: + enabled: true + fromUser: 0 + toUser: 82 + conditions: + - key: .ContainerType + operator: equal + value: container + - key: .Namespace + operator: regexp + value: ^(prod|stage)$ +``` \ No newline at end of file diff --git a/pkg/patch/nonroot/nonroot.go b/pkg/patch/nonroot/nonroot.go new file mode 100644 index 0000000..620d5d0 --- /dev/null +++ b/pkg/patch/nonroot/nonroot.go @@ -0,0 +1,99 @@ +/* +Copyright paskal.maksim@gmail.com +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package nonroot + +import ( + "context" + "fmt" + "strings" + + "github.com/maksim-paskal/pod-admission-controller/pkg/types" + corev1 "k8s.io/api/core/v1" +) + +type Patch struct{} + +func (p *Patch) Create(_ context.Context, containerInfo *types.ContainerInfo) ([]types.PatchOperation, error) { //nolint:lll,funlen,cyclop + // some containers don't need security context check + // pod-admission-controller/ignoreRunAsNonRoot=container1,container2 + if ignore, ok := containerInfo.GetPodAnnotation(types.AnnotationIgnoreRunAsNonRoot); ok { //nolint:staticcheck + containersNames := strings.Split(ignore, ",") + for _, containerName := range containersNames { + if containerName == containerInfo.ContainerName { + return []types.PatchOperation{}, nil + } + } + } + + patch := make([]types.PatchOperation, 0) + + for _, selectedRule := range containerInfo.SelectedRules { + if !selectedRule.RunAsNonRoot.Enabled { + continue + } + + selectedRule.Logf("CreateRunAsNonRootPatch: %+v", selectedRule) + + var containerRunAsUser *int64 + + boolTrue := true + boolFalse := false + + podContainer := containerInfo.PodContainer + + if podContainer.Pod.Spec.SecurityContext != nil && podContainer.Pod.Spec.SecurityContext.RunAsUser != nil { + containerRunAsUser = podContainer.Pod.Spec.SecurityContext.RunAsUser + } + + securityContext := podContainer.Container.SecurityContext + + if securityContext == nil { + securityContext = &corev1.SecurityContext{} + } + + if securityContext.RunAsUser != nil { + containerRunAsUser = securityContext.RunAsUser + } + + if selectedRule.RunAsNonRoot.ReplaceUser.Enabled && containerRunAsUser != nil { + if *containerRunAsUser == selectedRule.RunAsNonRoot.ReplaceUser.FromUser { + containerRunAsUser = &selectedRule.RunAsNonRoot.ReplaceUser.ToUser + } + } + + if containerRunAsUser != nil { + securityContext.RunAsUser = containerRunAsUser + } + + securityContext.RunAsNonRoot = &boolTrue + securityContext.Privileged = &boolFalse + securityContext.AllowPrivilegeEscalation = &boolFalse + + if securityContext.Capabilities == nil { + securityContext.Capabilities = &corev1.Capabilities{} + } + + securityContext.Capabilities.Drop = []corev1.Capability{corev1.Capability("ALL")} + + patch = append(patch, types.PatchOperation{ + Op: "add", + Path: fmt.Sprintf("%s/securityContext", containerInfo.PodContainer.ContainerPath()), + Value: securityContext, + }) + + // process only first rule + break + } + + return patch, nil +} diff --git a/pkg/patch/nonroot/nonroot_test.go b/pkg/patch/nonroot/nonroot_test.go new file mode 100644 index 0000000..88be3bb --- /dev/null +++ b/pkg/patch/nonroot/nonroot_test.go @@ -0,0 +1,58 @@ +/* +Copyright paskal.maksim@gmail.com +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package nonroot_test + +import ( + "context" + "testing" + + "github.com/maksim-paskal/pod-admission-controller/pkg/patch/nonroot" + "github.com/maksim-paskal/pod-admission-controller/pkg/types" + corev1 "k8s.io/api/core/v1" +) + +const addOperation = "add" + +func TestCreateRunAsNonRootPatch(t *testing.T) { + t.Parallel() + + patch := nonroot.Patch{} + + containerInfo := &types.ContainerInfo{ + PodContainer: &types.PodContainer{ + Type: "container", + Container: &corev1.Container{}, + Pod: &corev1.Pod{}, + }, + SelectedRules: []*types.Rule{ + { + RunAsNonRoot: types.RunAsNonRoot{ + Enabled: true, + }, + }, + }, + } + + patchOps, err := patch.Create(context.TODO(), containerInfo) + if err != nil { + t.Fatal(err) + } + + if len(patchOps) != 1 { + t.Fatal("1 patch must be created") + } + + if patchOps[0].Op != addOperation || patchOps[0].Path != "/spec/containers/0/securityContext" { + t.Fatalf("not corrected patch %s", patchOps[0].String()) + } +} diff --git a/pkg/patch/patch.go b/pkg/patch/patch.go index 951382f..36a949d 100644 --- a/pkg/patch/patch.go +++ b/pkg/patch/patch.go @@ -13,182 +13,84 @@ limitations under the License. package patch import ( - "fmt" + "context" + "reflect" "strings" - "github.com/maksim-paskal/pod-admission-controller/pkg/config" + "github.com/maksim-paskal/pod-admission-controller/pkg/patch/custompatch" + "github.com/maksim-paskal/pod-admission-controller/pkg/patch/env" + "github.com/maksim-paskal/pod-admission-controller/pkg/patch/imagehost" + "github.com/maksim-paskal/pod-admission-controller/pkg/patch/nonroot" + "github.com/maksim-paskal/pod-admission-controller/pkg/patch/pullsecrets" + "github.com/maksim-paskal/pod-admission-controller/pkg/patch/resources" + "github.com/maksim-paskal/pod-admission-controller/pkg/patch/tolerations" "github.com/maksim-paskal/pod-admission-controller/pkg/types" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" + "github.com/pkg/errors" ) -// add env to container. -func CreateEnvPatch(containerOrder int, containerInfo types.ContainerInfo, containerEnv []corev1.EnvVar, newEnv []corev1.EnvVar) []types.PatchOperation { //nolint:lll - // some containers don't need env - // pod-admission-controller/ignoreEnv=container1,container2 - if ignore, ok := containerInfo.GetPodAnnotation(types.AnnotationIgnoreEnv); ok { - containersNames := strings.Split(ignore, ",") - for _, containerName := range containersNames { - if containerName == containerInfo.ContainerName { - return nil - } - } - } - - patch := make([]types.PatchOperation, 0) - - if len(containerEnv) == 0 { - patch = append(patch, types.PatchOperation{ - Op: "add", - Path: fmt.Sprintf("/spec/containers/%d/env", containerOrder), - Value: newEnv, - }) - } else { - containerEnvName := make(map[string]bool, 0) - // get all env from container - for _, env := range containerEnv { - containerEnvName[env.Name] = true - } - - for _, env := range newEnv { - // add env if not exists - if _, ok := containerEnvName[env.Name]; !ok { - patch = append(patch, types.PatchOperation{ - Op: "add", - Path: fmt.Sprintf("/spec/containers/%d/env/-", containerOrder), - Value: env, - }) - } - } - } - - return patch +type Patch interface { + // create patch + Create(ctx context.Context, containerInfo *types.ContainerInfo) ([]types.PatchOperation, error) } -// add default resources to container if not exists. -func CreateDefaultResourcesPatch(selectedRule *types.Rule, containerOrder int, containerInfo types.ContainerInfo, containerResources corev1.ResourceRequirements) []types.PatchOperation { //nolint:lll,funlen - // some containers don't need default resources - // pod-admission-controller/ignoreAddDefaultResources=container1,container2 - if ignore, ok := containerInfo.GetPodAnnotation(types.AnnotationIgnoreAddDefaultResources); ok { - containersNames := strings.Split(ignore, ",") - for _, containerName := range containersNames { - if containerName == containerInfo.ContainerName { - return nil - } - } - } - - // remove pod resources from all containers - if selectedRule.AddDefaultResources.RemoveResources { - return []types.PatchOperation{{ - Op: "remove", - Path: fmt.Sprintf("/spec/containers/%d/resources", containerOrder), - }} - } - - patch := make([]types.PatchOperation, 0) - - newResources := corev1.ResourceRequirements{} - - newResources.Requests = corev1.ResourceList{} - newResources.Limits = corev1.ResourceList{} +var allPatchs = []Patch{ + &env.Patch{}, + &nonroot.Patch{}, + &resources.Patch{}, + &imagehost.Patch{}, + &tolerations.Patch{}, + &pullsecrets.Patch{}, + &custompatch.Patch{}, +} - if containerResources.Requests.Cpu().IsZero() { - newResources.Requests["cpu"] = resource.MustParse(*config.Get().DefaultRequestCPU) - } else { - newResources.Requests["cpu"] = *containerResources.Requests.Cpu() - } +func NewPatch(ctx context.Context, containerInfo *types.ContainerInfo) ([]types.PatchOperation, error) { + result := make([]types.PatchOperation, 0) - if containerResources.Requests.Memory().IsZero() { - newResources.Requests["memory"] = resource.MustParse(*config.Get().DefaultRequestMemory) - } else { - newResources.Requests["memory"] = *containerResources.Requests.Memory() - } + for _, patch := range allPatchs { + if IgnoreContainerPatch(patch, containerInfo) { + continue + } - // add resource limits if exists - if containerResources.Limits.Cpu().IsZero() { - if selectedRule.AddDefaultResources.LimitCPU { - newResources.Limits["cpu"] = newResources.Requests["cpu"] + patchOps, err := patch.Create(ctx, containerInfo) + if err != nil { + return nil, errors.Wrapf(err, "error in %s", getPatchName(patch)) } - } else { - newResources.Limits["cpu"] = *containerResources.Limits.Cpu() - } - // is no memory limits set, set memory resources - if containerResources.Limits.Memory().IsZero() { - newResources.Limits["memory"] = newResources.Requests["memory"] - } else { - newResources.Limits["memory"] = *containerResources.Limits.Memory() + result = append(result, patchOps...) } - patch = append(patch, types.PatchOperation{ - Op: "add", - Path: fmt.Sprintf("/spec/containers/%d/resources", containerOrder), - Value: newResources, - }) - - return patch + return result, nil } -// add RunAsNonRoot policy to all containers (exlude InitContainers). -func CreateRunAsNonRootPatch(selectedRule *types.Rule, order int, containerInfo types.ContainerInfo, podSecurityContext *corev1.PodSecurityContext, containerSecurityContext *corev1.SecurityContext) []types.PatchOperation { //nolint:lll,cyclop - // some containers don't need security context check - // pod-admission-controller/ignoreRunAsNonRoot=container1,container2 - if ignore, ok := containerInfo.GetPodAnnotation(types.AnnotationIgnoreRunAsNonRoot); ok { - containersNames := strings.Split(ignore, ",") - for _, containerName := range containersNames { - if containerName == containerInfo.ContainerName { - return nil - } - } - } - - patch := make([]types.PatchOperation, 0) +func getPatchName(patch Patch) string { + patchName := reflect.TypeOf(patch).String() - var containerRunAsUser *int64 + patchName = strings.TrimPrefix(patchName, "*") + patchName = strings.TrimSuffix(patchName, ".Patch") - boolTrue := true - boolFalse := false - - if podSecurityContext != nil && podSecurityContext.RunAsUser != nil { - containerRunAsUser = podSecurityContext.RunAsUser - } + return patchName +} - securityContext := containerSecurityContext +const annotationIgnorePrefix = "pod-admission-controller/ignore-" - if securityContext == nil { - securityContext = &corev1.SecurityContext{} +// ignore patch if annotation exists +// pod-admission-controller/ignore-=[,]. +func IgnoreContainerPatch(patch Patch, containerInfo *types.ContainerInfo) bool { + ignore, ok := containerInfo.GetPodAnnotation(annotationIgnorePrefix + getPatchName(patch)) + if !ok { + return false } - if securityContext.RunAsUser != nil { - containerRunAsUser = securityContext.RunAsUser + if ignore == "*" { + return true } - if selectedRule.RunAsNonRoot.ReplaceUser.Enabled && containerRunAsUser != nil { - if *containerRunAsUser == selectedRule.RunAsNonRoot.ReplaceUser.FromUser { - containerRunAsUser = &selectedRule.RunAsNonRoot.ReplaceUser.ToUser + containersNames := strings.Split(ignore, ",") + for _, containerName := range containersNames { + if containerName == containerInfo.ContainerName { + return true } } - if containerRunAsUser != nil { - securityContext.RunAsUser = containerRunAsUser - } - - securityContext.RunAsNonRoot = &boolTrue - securityContext.Privileged = &boolFalse - securityContext.AllowPrivilegeEscalation = &boolFalse - - if securityContext.Capabilities == nil { - securityContext.Capabilities = &corev1.Capabilities{} - } - - securityContext.Capabilities.Drop = []corev1.Capability{corev1.Capability("ALL")} - - patch = append(patch, types.PatchOperation{ - Op: "add", - Path: fmt.Sprintf("/spec/containers/%d/securityContext", order), - Value: securityContext, - }) - - return patch + return false } diff --git a/pkg/patch/patch_test.go b/pkg/patch/patch_test.go index 6f8101c..9138acf 100644 --- a/pkg/patch/patch_test.go +++ b/pkg/patch/patch_test.go @@ -13,137 +13,113 @@ limitations under the License. package patch_test import ( + "context" + "strconv" "testing" "github.com/maksim-paskal/pod-admission-controller/pkg/patch" + "github.com/maksim-paskal/pod-admission-controller/pkg/patch/env" + "github.com/maksim-paskal/pod-admission-controller/pkg/patch/imagehost" + "github.com/maksim-paskal/pod-admission-controller/pkg/patch/nonroot" "github.com/maksim-paskal/pod-admission-controller/pkg/types" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" ) -const addOperation = "add" - -func TestCreateEnvPatchEnv(t *testing.T) { +func TestNewPatch(t *testing.T) { t.Parallel() - containerInfo := types.ContainerInfo{} - - containerEnv := []corev1.EnvVar{ - { - Name: "TEST1", - Value: "1", - }, - { - Name: "TEST2", - Value: "2", - }, + containerInfo := &types.ContainerInfo{ + Image: &types.ContainerImage{}, } - newEnv := []corev1.EnvVar{ - { - Name: "TEST1", - Value: "3", - }, - { - Name: "TEST4", - Value: "4", - }, - } - - envPatch := patch.CreateEnvPatch(0, containerInfo, containerEnv, newEnv) - - if len(envPatch) != 1 { - t.Fatal("1 patch must be created") + patchOps, err := patch.NewPatch(context.TODO(), containerInfo) + if err != nil { + t.Fatal(err) } - if envPatch[0].Op != addOperation || envPatch[0].Path != "/spec/containers/0/env/-" { - t.Fatal("not corrected patch") - } - - // scenario 2 (container env is empty) - containerEnv = []corev1.EnvVar{} - - envPatch = patch.CreateEnvPatch(0, containerInfo, containerEnv, newEnv) - - if len(envPatch) != 1 { - t.Fatal("1 patch must be created") - } - - if envPatch[0].Op != addOperation || envPatch[0].Path != "/spec/containers/0/env" { - t.Fatal("not corrected patch") + if len(patchOps) != 0 { + t.Fatal("len(patchOps) != 0") } } -func TestNullResources(t *testing.T) { +func TestIgnorePatch(t *testing.T) { //nolint:funlen t.Parallel() - resources := corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("1"), - }, + type Test struct { + Patch patch.Patch + ContainerName string + PodAnnotations map[string]string + Ignore bool } - rule := types.Rule{ - AddDefaultResources: types.AddDefaultResources{ - Enabled: true, - RemoveResources: true, + tests := []Test{ + { + Patch: &env.Patch{}, + ContainerName: "test", + PodAnnotations: map[string]string{ + "pod-admission-controller/ignore-env": "test", + }, + Ignore: true, }, - } - patch := patch.CreateDefaultResourcesPatch(&rule, 0, types.ContainerInfo{}, resources) - - if len(patch) != 1 { - t.Fatal("1 patch must be created") - } - - if patch[0].Op != "remove" || patch[0].Path != "/spec/containers/0/resources" { - t.Fatalf("not corrected op %s", patch[0].Op) - } -} - -func TestCreateDefaultResourcesPatch(t *testing.T) { - t.Parallel() - - resources := corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("1"), + { + Patch: &env.Patch{}, + ContainerName: "test1", + PodAnnotations: map[string]string{ + "pod-admission-controller/ignore-env": "test", + }, + Ignore: false, }, - } - - rule := types.Rule{ - AddDefaultResources: types.AddDefaultResources{ - Enabled: true, + { + Patch: &env.Patch{}, + ContainerName: "test", + PodAnnotations: map[string]string{ + "fake": "test", + }, + Ignore: false, }, - } - patch := patch.CreateDefaultResourcesPatch(&rule, 0, types.ContainerInfo{}, resources) - - if len(patch) != 1 { - t.Fatal("1 patch must be created") - } - - if patch[0].Op != addOperation || patch[0].Path != "/spec/containers/0/resources" { - t.Fatal("not corrected patch") - } -} - -func TestCreateRunAsNonRootPatch(t *testing.T) { - t.Parallel() - - rule := types.Rule{ - RunAsNonRoot: types.RunAsNonRoot{ - Enabled: true, + { + Patch: &nonroot.Patch{}, + ContainerName: "test", + PodAnnotations: map[string]string{ + "pod-admission-controller/ignore-nonroot": "test", + }, + Ignore: true, + }, + { + Patch: &imagehost.Patch{}, + ContainerName: "test", + PodAnnotations: map[string]string{ + "pod-admission-controller/ignore-imagehost": "test", + }, + Ignore: true, + }, + { + Patch: &imagehost.Patch{}, + ContainerName: "test", + PodAnnotations: map[string]string{ + "pod-admission-controller/ignore-imagehost": "*", + }, + Ignore: true, }, } - podSecurityContext := corev1.PodSecurityContext{} - securityContext := corev1.SecurityContext{} + for testID, test := range tests { + test := test - patch := patch.CreateRunAsNonRootPatch(&rule, 0, types.ContainerInfo{}, &podSecurityContext, &securityContext) + t.Run(strconv.Itoa(testID), func(t *testing.T) { + t.Parallel() - if len(patch) != 1 { - t.Fatal("1 patch must be created") - } + containerInfo := &types.ContainerInfo{ + PodAnnotations: map[string]string{ + "pod-admission-controller/ignore-env": "test", + }, + } + + containerInfo.ContainerName = test.ContainerName + containerInfo.PodAnnotations = test.PodAnnotations - if patch[0].Op != addOperation || patch[0].Path != "/spec/containers/0/securityContext" { - t.Fatalf("not corrected patch %s/%s", patch[0].Op, patch[0].Path) + if patch.IgnoreContainerPatch(test.Patch, containerInfo) != test.Ignore { + t.Fatal("patch.IgnorePatch(&patch.EnvPatch{}, containerInfo) != test.Ignore") + } + }) } } diff --git a/pkg/patch/pullsecrets/README.md b/pkg/patch/pullsecrets/README.md new file mode 100644 index 0000000..37c2919 --- /dev/null +++ b/pkg/patch/pullsecrets/README.md @@ -0,0 +1,9 @@ +```yaml +rules: +- imagePullSecrets: + - name: docker-registry-secret + conditions: + - key: env "SENTRY_ENVIRONMENT" + operator: equal + value: azure-dev +``` \ No newline at end of file diff --git a/pkg/patch/pullsecrets/pullsecrets.go b/pkg/patch/pullsecrets/pullsecrets.go new file mode 100644 index 0000000..026a825 --- /dev/null +++ b/pkg/patch/pullsecrets/pullsecrets.go @@ -0,0 +1,44 @@ +/* +Copyright paskal.maksim@gmail.com +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package pullsecrets + +import ( + "context" + + "github.com/maksim-paskal/pod-admission-controller/pkg/types" + corev1 "k8s.io/api/core/v1" +) + +type Patch struct{} + +func (p *Patch) Create(_ context.Context, containerInfo *types.ContainerInfo) ([]types.PatchOperation, error) { + podPullSecrets := []corev1.LocalObjectReference{} + + for _, rule := range containerInfo.SelectedRules { + if len(rule.ImagePullSecrets) > 0 { + podPullSecrets = append(podPullSecrets, rule.ImagePullSecrets...) + } + } + + if len(podPullSecrets) == 0 { + return []types.PatchOperation{}, nil + } + + return []types.PatchOperation{ + { + Op: "add", + Path: "/spec/imagePullSecrets", + Value: podPullSecrets, + }, + }, nil +} diff --git a/pkg/patch/pullsecrets/pullsecrets_test.go b/pkg/patch/pullsecrets/pullsecrets_test.go new file mode 100644 index 0000000..3639a4f --- /dev/null +++ b/pkg/patch/pullsecrets/pullsecrets_test.go @@ -0,0 +1,53 @@ +/* +Copyright paskal.maksim@gmail.com +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package pullsecrets_test + +import ( + "context" + "testing" + + "github.com/maksim-paskal/pod-admission-controller/pkg/patch/pullsecrets" + "github.com/maksim-paskal/pod-admission-controller/pkg/types" + corev1 "k8s.io/api/core/v1" +) + +func TestTolerations(t *testing.T) { + t.Parallel() + + containerInfo := &types.ContainerInfo{ + SelectedRules: []*types.Rule{ + { + ImagePullSecrets: []corev1.LocalObjectReference{ + { + Name: "test", + }, + }, + }, + }, + } + + patch := pullsecrets.Patch{} + + patchOps, err := patch.Create(context.TODO(), containerInfo) + if err != nil { + t.Fatal(err) + } + + if len(patchOps) != 1 { + t.Fatal("1 patch must be created") + } + + if patchOps[0].Op != "add" || patchOps[0].Path != "/spec/imagePullSecrets" { + t.Fatalf("not corrected patch %s", patchOps[0].String()) + } +} diff --git a/pkg/patch/resources/README.md b/pkg/patch/resources/README.md new file mode 100644 index 0000000..9167ec3 --- /dev/null +++ b/pkg/patch/resources/README.md @@ -0,0 +1,9 @@ +```yaml +rules: +- addDefaultResources: + enabled: true + conditions: + - key: .Namespace + operator: regexp + value: prod +``` \ No newline at end of file diff --git a/pkg/patch/resources/resources.go b/pkg/patch/resources/resources.go new file mode 100644 index 0000000..dff9727 --- /dev/null +++ b/pkg/patch/resources/resources.go @@ -0,0 +1,101 @@ +/* +Copyright paskal.maksim@gmail.com +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package resources + +import ( + "context" + "fmt" + "strings" + + "github.com/maksim-paskal/pod-admission-controller/pkg/config" + "github.com/maksim-paskal/pod-admission-controller/pkg/types" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +type Patch struct{} + +func (p *Patch) Create(_ context.Context, containerInfo *types.ContainerInfo) ([]types.PatchOperation, error) { //nolint:lll,funlen,cyclop + // some containers don't need default resources + // pod-admission-controller/ignoreAddDefaultResources=container1,container2 + if ignore, ok := containerInfo.GetPodAnnotation(types.AnnotationIgnoreAddDefaultResources); ok { //nolint:staticcheck + containersNames := strings.Split(ignore, ",") + for _, containerName := range containersNames { + if containerName == containerInfo.ContainerName { + return []types.PatchOperation{}, nil + } + } + } + + patch := make([]types.PatchOperation, 0) + + for _, selectedRule := range containerInfo.SelectedRules { + if !selectedRule.AddDefaultResources.Enabled { + continue + } + + // remove pod resources from all containers + if selectedRule.AddDefaultResources.RemoveResources { //nolint:staticcheck + return []types.PatchOperation{{ + Op: "remove", + Path: fmt.Sprintf("%s/resources", containerInfo.PodContainer.ContainerPath()), + }}, nil + } + + selectedRule.Logf("CreateDefaultResourcesPatch: %+v", selectedRule) + + newResources := corev1.ResourceRequirements{} + + newResources.Requests = corev1.ResourceList{} + newResources.Limits = corev1.ResourceList{} + + if containerInfo.PodContainer.Container.Resources.Requests.Cpu().IsZero() { + newResources.Requests["cpu"] = resource.MustParse(*config.Get().DefaultRequestCPU) + } else { + newResources.Requests["cpu"] = *containerInfo.PodContainer.Container.Resources.Requests.Cpu() + } + + if containerInfo.PodContainer.Container.Resources.Requests.Memory().IsZero() { + newResources.Requests["memory"] = resource.MustParse(*config.Get().DefaultRequestMemory) + } else { + newResources.Requests["memory"] = *containerInfo.PodContainer.Container.Resources.Requests.Memory() + } + + // add resource limits if exists + if containerInfo.PodContainer.Container.Resources.Limits.Cpu().IsZero() { + if selectedRule.AddDefaultResources.LimitCPU { + newResources.Limits["cpu"] = newResources.Requests["cpu"] + } + } else { + newResources.Limits["cpu"] = *containerInfo.PodContainer.Container.Resources.Limits.Cpu() + } + + // is no memory limits set, set memory resources + if containerInfo.PodContainer.Container.Resources.Limits.Memory().IsZero() { + newResources.Limits["memory"] = newResources.Requests["memory"] + } else { + newResources.Limits["memory"] = *containerInfo.PodContainer.Container.Resources.Limits.Memory() + } + + patch = append(patch, types.PatchOperation{ + Op: "add", + Path: fmt.Sprintf("%s/resources", containerInfo.PodContainer.ContainerPath()), + Value: newResources, + }) + + // process only first rule + break + } + + return patch, nil +} diff --git a/pkg/patch/resources/resources_test.go b/pkg/patch/resources/resources_test.go new file mode 100644 index 0000000..ad52f7b --- /dev/null +++ b/pkg/patch/resources/resources_test.go @@ -0,0 +1,104 @@ +/* +Copyright paskal.maksim@gmail.com +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package resources_test + +import ( + "context" + "testing" + + "github.com/maksim-paskal/pod-admission-controller/pkg/patch/resources" + "github.com/maksim-paskal/pod-admission-controller/pkg/types" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +const addOperation = "add" + +func TestNullResources(t *testing.T) { + t.Parallel() + + patch := resources.Patch{} + + containerInfo := &types.ContainerInfo{ + PodContainer: &types.PodContainer{ + Type: "container", + Container: &corev1.Container{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1"), + }, + }, + }, + }, + SelectedRules: []*types.Rule{ + { + AddDefaultResources: types.AddDefaultResources{ + Enabled: true, + RemoveResources: true, + }, + }, + }, + } + + patchOps, err := patch.Create(context.TODO(), containerInfo) + if err != nil { + t.Fatal(err) + } + + if len(patchOps) != 1 { + t.Fatal("1 patch must be created") + } + + if patchOps[0].Op != "remove" || patchOps[0].Path != "/spec/containers/0/resources" { + t.Fatalf("not corrected op %s", patchOps[0].Op) + } +} + +func TestCreateDefaultResourcesPatch(t *testing.T) { + t.Parallel() + + patch := resources.Patch{} + + containerInfo := &types.ContainerInfo{ + PodContainer: &types.PodContainer{ + Type: "container", + Container: &corev1.Container{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1"), + }, + }, + }, + }, + SelectedRules: []*types.Rule{ + { + AddDefaultResources: types.AddDefaultResources{ + Enabled: true, + }, + }, + }, + } + + patchOps, err := patch.Create(context.TODO(), containerInfo) + if err != nil { + t.Fatal(err) + } + + if len(patchOps) != 1 { + t.Fatal("1 patch must be created") + } + + if patchOps[0].Op != addOperation || patchOps[0].Path != "/spec/containers/0/resources" { + t.Fatalf("not corrected patch %s", patchOps[0].String()) + } +} diff --git a/pkg/patch/tolerations/README.md b/pkg/patch/tolerations/README.md new file mode 100644 index 0000000..c60e1ff --- /dev/null +++ b/pkg/patch/tolerations/README.md @@ -0,0 +1,12 @@ +```yaml +rules: +- tolerations: + - key: "kubernetes.azure.com/scalesetpriority" + operator: "Equal" + value: "spot" + effect: "NoSchedule" + conditions: + - key: env "SENTRY_ENVIRONMENT" + operator: equal + value: azure-dev +``` \ No newline at end of file diff --git a/pkg/patch/tolerations/tolerations.go b/pkg/patch/tolerations/tolerations.go new file mode 100644 index 0000000..aab4d75 --- /dev/null +++ b/pkg/patch/tolerations/tolerations.go @@ -0,0 +1,44 @@ +/* +Copyright paskal.maksim@gmail.com +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package tolerations + +import ( + "context" + + "github.com/maksim-paskal/pod-admission-controller/pkg/types" + corev1 "k8s.io/api/core/v1" +) + +type Patch struct{} + +func (p *Patch) Create(_ context.Context, containerInfo *types.ContainerInfo) ([]types.PatchOperation, error) { + podTolerations := make([]corev1.Toleration, 0) + + for _, rule := range containerInfo.SelectedRules { + if len(rule.Tolerations) > 0 { + podTolerations = append(podTolerations, rule.Tolerations...) + } + } + + if len(podTolerations) == 0 { + return []types.PatchOperation{}, nil + } + + return []types.PatchOperation{ + { + Op: "add", + Path: "/spec/tolerations", + Value: podTolerations, + }, + }, nil +} diff --git a/pkg/patch/tolerations/tolerations_test.go b/pkg/patch/tolerations/tolerations_test.go new file mode 100644 index 0000000..2e3fa13 --- /dev/null +++ b/pkg/patch/tolerations/tolerations_test.go @@ -0,0 +1,55 @@ +/* +Copyright paskal.maksim@gmail.com +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package tolerations_test + +import ( + "context" + "testing" + + "github.com/maksim-paskal/pod-admission-controller/pkg/patch/tolerations" + "github.com/maksim-paskal/pod-admission-controller/pkg/types" + corev1 "k8s.io/api/core/v1" +) + +func TestTolerations(t *testing.T) { + t.Parallel() + + containerInfo := &types.ContainerInfo{ + SelectedRules: []*types.Rule{ + { + Tolerations: []corev1.Toleration{ + { + Key: "key", + Operator: "Equal", + Value: "value", + }, + }, + }, + }, + } + + patch := tolerations.Patch{} + + patchOps, err := patch.Create(context.TODO(), containerInfo) + if err != nil { + t.Fatal(err) + } + + if len(patchOps) != 1 { + t.Fatal("1 patch must be created") + } + + if patchOps[0].Op != "add" || patchOps[0].Path != "/spec/tolerations" { + t.Fatalf("not corrected patch %s", patchOps[0].String()) + } +} diff --git a/pkg/template/template.go b/pkg/template/template.go index 935473d..af578c8 100644 --- a/pkg/template/template.go +++ b/pkg/template/template.go @@ -15,6 +15,7 @@ package template import ( "bytes" "net" + "os" "regexp" "text/template" @@ -23,7 +24,7 @@ import ( "github.com/pkg/errors" ) -func Get(containerInfo types.ContainerInfo, value string) (string, error) { +func Get(containerInfo *types.ContainerInfo, value string) (string, error) { tmpl, err := template.New("tmpl").Funcs(template.FuncMap{ // regexp string by pattern "regexp": func(pattern string, value string) []string { @@ -53,6 +54,7 @@ func Get(containerInfo types.ContainerInfo, value string) (string, error) { return ip.String() }, + "env": os.Getenv, }).Parse(value) if err != nil { return "", errors.Wrap(err, "error parsing template") diff --git a/pkg/template/template_test.go b/pkg/template/template_test.go index 9fde10b..db1628f 100644 --- a/pkg/template/template_test.go +++ b/pkg/template/template_test.go @@ -23,7 +23,7 @@ import ( func TestTemplateValue(t *testing.T) { t.Parallel() - containerInfo := types.ContainerInfo{ + containerInfo := &types.ContainerInfo{ Image: &types.ContainerImage{Name: "/a/b/c/d:e"}, } @@ -50,7 +50,7 @@ func TestTemplateValue(t *testing.T) { func TestResolve(t *testing.T) { t.Parallel() - value, err := template.Get(types.ContainerInfo{}, `{{ Resolve "google.com" }}`) + value, err := template.Get(&types.ContainerInfo{}, `{{ Resolve "google.com" }}`) if err != nil { t.Fatal(err) } diff --git a/pkg/types/types.go b/pkg/types/types.go index 8575c8b..c197109 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -14,7 +14,9 @@ package types import ( "encoding/json" + "fmt" + log "github.com/sirupsen/logrus" corev1 "k8s.io/api/core/v1" ) @@ -26,14 +28,14 @@ const ( AnnotationIgnore = annotationPrefix + "/ignore" // list of containers that should be skipped from RunAsNonRoot. AnnotationIgnoreEnv = annotationPrefix + "/ignoreEnv" - // list of containers that should be skipped from RunAsNonRoot. + // Deprecated: list of containers that should be skipped from RunAsNonRoot. AnnotationIgnoreRunAsNonRoot = annotationPrefix + "/ignoreRunAsNonRoot" - // list of containers that should be skipped from AddDefaultResources. + // Deprecated: list of containers that should be skipped from AddDefaultResources. AnnotationIgnoreAddDefaultResources = annotationPrefix + "/ignoreAddDefaultResources" // warning when AnnotationIgnore is enabled. - WarningPodDoedNotNeedMutation = annotationPrefix + ": POD ignore mutation by annotation " + AnnotationIgnore + WarningObjectDoedNotNeedMutation = annotationPrefix + ": ignore mutation by annotation " + AnnotationIgnore // warning when no patch is generated. - WarningNoPatchGenerated = annotationPrefix + ". No patches found for pod" + WarningNoPatchGenerated = annotationPrefix + ". No patches found" ) type RunAsNonRootReplaceUser struct { @@ -48,18 +50,38 @@ type RunAsNonRoot struct { ReplaceUser RunAsNonRootReplaceUser } +type ReplaceContainerImageHost struct { + Enabled bool + From string + To string +} + type AddDefaultResources struct { - Enabled bool - LimitCPU bool + Enabled bool + LimitCPU bool + // Deprecated: use custompatch instead RemoveResources bool } type Rule struct { - Name string - Env []corev1.EnvVar - Conditions []Conditions - AddDefaultResources AddDefaultResources - RunAsNonRoot RunAsNonRoot + Debug bool + Name string + Env []corev1.EnvVar + Conditions []Conditions + AddDefaultResources AddDefaultResources + RunAsNonRoot RunAsNonRoot + ReplaceContainerImageHost ReplaceContainerImageHost + Tolerations []corev1.Toleration + ImagePullSecrets []corev1.LocalObjectReference + CustomPatches []PatchOperation +} + +func (r *Rule) Logf(format string, args ...interface{}) { + if r.Debug || log.IsLevelEnabled(log.DebugLevel) { + log.WithFields(log.Fields{ + "name": r.Name, + }).Infof(format, args...) + } } type PatchOperation struct { @@ -68,20 +90,33 @@ type PatchOperation struct { Value interface{} `json:"value,omitempty"` } +func (p *PatchOperation) String() string { + out, err := json.Marshal(p) + if err != nil { + return err.Error() + } + + return string(out) +} + type Conditions struct { Key string Operator string Value string + Values []string } type ContainerImage struct { - Name string - Slug string - Tag string + Domain string + Name string + Slug string + Tag string } type ContainerInfo struct { + PodContainer *PodContainer ContainerName string + ContainerType string Namespace string NamespaceAnnotations map[string]string NamespaceLabels map[string]string @@ -120,26 +155,56 @@ func (c *ContainerInfo) GetSelectedRulesEnv() []corev1.EnvVar { return containerEnv } -type SelectedRuleType string +type PodContainer struct { + Pod *corev1.Pod + Namespace *corev1.Namespace + Order int + Type string + Container *corev1.Container + ContainerInfo *ContainerInfo +} + +func (c *PodContainer) String() string { + out, err := json.Marshal(c) + if err != nil { + return err.Error() + } -const ( - SelectedRuleRunAsNonRoot = SelectedRuleType("RunAsNonRoot") - SelectedRuleAddDefaultResources = SelectedRuleType("AddDefaultResources") -) + return string(out) +} -func (c *ContainerInfo) GetSelectedRuleEnabled(ruleType SelectedRuleType) (*Rule, bool) { - for _, selectedRule := range c.SelectedRules { - switch ruleType { - case SelectedRuleRunAsNonRoot: - if selectedRule.RunAsNonRoot.Enabled { - return selectedRule, true - } - case SelectedRuleAddDefaultResources: - if selectedRule.AddDefaultResources.Enabled { - return selectedRule, true - } - } +func (c *PodContainer) ContainerPath() string { + return fmt.Sprintf("/spec/%ss/%d", c.Type, c.Order) +} + +func PodContainersFromPod(namespace *corev1.Namespace, pod *corev1.Pod) []*PodContainer { + podContainers := make([]*PodContainer, 0) + + for order := range pod.Spec.InitContainers { + podContainers = append(podContainers, &PodContainer{ + Pod: pod, + Namespace: namespace, + Order: order, + Type: "initContainer", + Container: &pod.Spec.InitContainers[order], + }) + } + + for order := range pod.Spec.Containers { + podContainers = append(podContainers, &PodContainer{ + Pod: pod, + Namespace: namespace, + Order: order, + Type: "container", + Container: &pod.Spec.Containers[order], + }) } - return &Rule{}, false + return podContainers +} + +type CreateSecret struct { + Name string + Type string + Data map[string][]byte } diff --git a/pkg/types/types_test.go b/pkg/types/types_test.go index 41a033a..261e279 100644 --- a/pkg/types/types_test.go +++ b/pkg/types/types_test.go @@ -46,84 +46,6 @@ func TestGetPodAnnotation(t *testing.T) { } } -func TestGetSelectedRuleEnabled(t *testing.T) { //nolint:funlen - t.Parallel() - - containerInfo := types.ContainerInfo{ - SelectedRules: []*types.Rule{ - { - Name: "test1", - }, - { - Name: "test2", - RunAsNonRoot: types.RunAsNonRoot{ - Enabled: true, - }, - }, - { - Name: "test3", - AddDefaultResources: types.AddDefaultResources{ - Enabled: true, - }, - }, - { - Name: "test4", - RunAsNonRoot: types.RunAsNonRoot{ - Enabled: true, - }, - }, - }, - } - - selectedRule, ok := containerInfo.GetSelectedRuleEnabled(types.SelectedRuleRunAsNonRoot) - - if !ok { - t.Fatal("expected to find selected rule") - } - - if selectedRule.Name != "test2" { - t.Fatalf("expected to test2, got %s", selectedRule.Name) - } - - selectedRule, ok = containerInfo.GetSelectedRuleEnabled(types.SelectedRuleAddDefaultResources) - - if !ok { - t.Fatal("expected to find selected rule") - } - - if selectedRule.Name != "test3" { - t.Fatalf("expected to test3, got %s", selectedRule.Name) - } - - // invalid rule - _, ok = containerInfo.GetSelectedRuleEnabled("fake") - if ok { - t.Fatal("expected not to find selected rule") - } - - containerInfo = types.ContainerInfo{ - SelectedRules: []*types.Rule{ - { - Name: "test1", - }, - { - Name: "test2", - }, - { - Name: "test3", - }, - }, - } - - if _, ok = containerInfo.GetSelectedRuleEnabled(types.SelectedRuleRunAsNonRoot); ok { - t.Fatal("expected not to find selected rule") - } - - if _, ok = containerInfo.GetSelectedRuleEnabled(types.SelectedRuleAddDefaultResources); ok { - t.Fatal("expected not to find selected rule") - } -} - func TestGetSelectedRulesEnv(t *testing.T) { t.Parallel() @@ -175,3 +97,25 @@ func TestString(t *testing.T) { t.Fatal("expected to find json") } } + +func TestContainerPath(t *testing.T) { + t.Parallel() + + podContainer := types.PodContainer{ + Type: "container", + Order: 0, + } + + if got := podContainer.ContainerPath(); got != "/spec/containers/0" { + t.Fatalf("expected /spec/containers/0, got %s", got) + } + + podContainer = types.PodContainer{ + Type: "initContainer", + Order: 0, + } + + if got := podContainer.ContainerPath(); got != "/spec/initContainers/0" { + t.Fatalf("expected /spec/initContainers/0, got %s", got) + } +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index c3d5785..42f447f 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -15,6 +15,7 @@ package utils import ( "fmt" "regexp" + "slices" "strings" "github.com/maksim-paskal/pod-admission-controller/pkg/template" @@ -22,7 +23,9 @@ import ( "github.com/pkg/errors" ) -func CheckConditions(containerInfo types.ContainerInfo, conditions []types.Conditions) (bool, error) { //nolint:cyclop +const negate = "not" + +func CheckConditions(containerInfo *types.ContainerInfo, conditions []types.Conditions) (bool, error) { //nolint:cyclop if len(conditions) == 0 { return true, nil } @@ -35,18 +38,28 @@ func CheckConditions(containerInfo types.ContainerInfo, conditions []types.Condi return false, errors.Wrap(err, "error matching key") } + conditionRequired := !strings.HasPrefix(strings.ToLower(condition.Operator), negate) + switch strings.ToLower(condition.Operator) { - case "equal": - if key == condition.Value { + case "equal", negate + "equal": + if (key == condition.Value) == conditionRequired { found++ } - case "regexp": + case "regexp", negate + "regexp": match, err := regexp.MatchString(condition.Value, key) if err != nil { return false, errors.Wrap(err, "error matching regexp") } - if match { + if match == conditionRequired { + found++ + } + case "in", negate + "in": + if len(condition.Values) == 0 { + return false, errors.Errorf("empty values for operator %s", condition.Operator) + } + + if slices.Contains(condition.Values, key) == conditionRequired { found++ } default: diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index c8436cb..4644b80 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -13,176 +13,306 @@ limitations under the License. package utils_test import ( + "fmt" + "os" "testing" "github.com/maksim-paskal/pod-admission-controller/pkg/types" "github.com/maksim-paskal/pod-admission-controller/pkg/utils" ) -const namespace = "test" - -func TestCheckConditionsImageEqual(t *testing.T) { - t.Parallel() - - conditions := make([]types.Conditions, 0) - - conditions = append(conditions, types.Conditions{ - Key: ".Image.Name", - Operator: "equal", - Value: "test", - }) - - match, err := utils.CheckConditions(types.ContainerInfo{Namespace: namespace, Image: &types.ContainerImage{Name: "fake"}}, conditions) //nolint:lll - if err != nil { - t.Fatal(err) - } - - if match { - t.Fatal("must be false") - } - - match, err = utils.CheckConditions(types.ContainerInfo{Namespace: namespace, Image: &types.ContainerImage{Name: "test"}}, conditions) //nolint:lll - if err != nil { - t.Fatal(err) - } - - if !match { - t.Fatal("must be true") - } -} - -func TestCheckConditionsImageRegexp(t *testing.T) { +func TestCheckConditions(t *testing.T) { //nolint:funlen,maintidx t.Parallel() - conditions := make([]types.Conditions, 0) - - conditions = append(conditions, types.Conditions{ - Key: ".Image", - Operator: "regexp", - Value: "te(.*)st", - }) - - match, err := utils.CheckConditions(types.ContainerInfo{Namespace: namespace}, conditions) - if err != nil { - t.Fatal(err) - } - - if match { - t.Fatal("must be false") - } - - match, err = utils.CheckConditions(types.ContainerInfo{Namespace: namespace, Image: &types.ContainerImage{Name: "test"}}, conditions) //nolint:lll - if err != nil { - t.Fatal(err) - } - - if !match { - t.Fatal("must be true") - } -} - -func TestCheckConditionsNamespaceEqual(t *testing.T) { - t.Parallel() - - conditions := make([]types.Conditions, 0) - - conditions = append(conditions, types.Conditions{ - Key: ".Namespace", - Operator: "equal", - Value: "test", - }) - - match, err := utils.CheckConditions(types.ContainerInfo{Namespace: "fake"}, conditions) - if err != nil { - t.Fatal(err) - } - - if match { - t.Fatal("must be false") + os.Setenv("TEST_ENV", "test") //nolint:tenv + + containerInfo := &types.ContainerInfo{ + Namespace: "1234567890", + Image: &types.ContainerImage{Name: "alpine:3.12"}, + PodAnnotations: map[string]string{ + "asd": "erty", + "env": "test", + }, + NamespaceAnnotations: map[string]string{ + "123": "456", + "ABC": "DEF", + "qwerty": "1234567890", + "env": "asdasdq", + }, } - match, err = utils.CheckConditions(types.ContainerInfo{Namespace: namespace}, conditions) - if err != nil { - t.Fatal(err) + type testStruct struct { + Error bool + Conditions []types.Conditions + Match bool } - if !match { - t.Fatal("must be true") - } -} - -func TestCheckConditionsPodAnnotationEqual(t *testing.T) { - t.Parallel() - - conditions := make([]types.Conditions, 0) - - conditions = append(conditions, types.Conditions{ - Key: ".PodAnnotations.env", - Operator: "equal", - Value: "test", - }) - - podAnnotations := map[string]string{ - "1": "2", - "3": "4", - "5": "6", - } - - match, err := utils.CheckConditions(types.ContainerInfo{PodAnnotations: podAnnotations}, conditions) - if err != nil { - t.Fatal(err) - } - - if match { - t.Fatal("must be false") - } - - podAnnotations["env"] = "test" - - match, err = utils.CheckConditions(types.ContainerInfo{PodAnnotations: podAnnotations}, conditions) - if err != nil { - t.Fatal(err) - } - - if !match { - t.Fatal("must be true") - } -} - -func TestCheckConditionsNamespaceAnnotationEqual(t *testing.T) { - t.Parallel() - - conditions := make([]types.Conditions, 0) - - conditions = append(conditions, types.Conditions{ - Key: ".NamespaceAnnotations.env", - Operator: "equal", - Value: "test", - }) - - namespaceAnnotations := map[string]string{ - "1": "2", - "3": "4", - "5": "6", - } - - match, err := utils.CheckConditions(types.ContainerInfo{NamespaceAnnotations: namespaceAnnotations}, conditions) - if err != nil { - t.Fatal(err) - } - - if match { - t.Fatal("must be false") - } - - namespaceAnnotations["env"] = "test" - - match, err = utils.CheckConditions(types.ContainerInfo{NamespaceAnnotations: namespaceAnnotations}, conditions) - if err != nil { - t.Fatal(err) + tests := []testStruct{ + { + Match: true, + Conditions: []types.Conditions{ + { + Key: ".NamespaceAnnotations.ABC", + Operator: "in", + Values: []string{"ABC", "123", "DEF", "345"}, + }, + }, + }, + { + Match: false, + Conditions: []types.Conditions{ + { + Key: ".NamespaceAnnotations.ABC", + Operator: "NotIn", + Values: []string{"ABC", "123", "DEF", "345"}, + }, + }, + }, + { + Match: false, + Conditions: []types.Conditions{ + { + Key: ".NamespaceAnnotations.ABC", + Operator: "in", + Values: []string{"ff"}, + }, + }, + }, + { + Match: true, + Conditions: []types.Conditions{ + { + Key: ".NamespaceAnnotations.ABC", + Operator: "notin", + Values: []string{"ff"}, + }, + }, + }, + { + Match: true, + Conditions: []types.Conditions{ + { + Key: ".NamespaceAnnotations.SOMEFAKE", + Operator: "notin", + Values: []string{"ff"}, + }, + }, + }, + { + Error: true, + Conditions: []types.Conditions{ + { + Key: ".NamespaceAnnotations.ABC", + Operator: "in", + Value: "dd", + }, + }, + }, + { + Match: false, + Conditions: []types.Conditions{ + { + Key: ".Image.Name", + Operator: "equal", + Value: "test", + }, + }, + }, + { + Match: true, + Conditions: []types.Conditions{ + { + Key: ".Image.Name", + Operator: "notequal", + Value: "test", + }, + }, + }, + { + Match: true, + Conditions: []types.Conditions{ + { + Key: ".Image.Name", + Operator: "equal", + Value: "alpine:3.12", + }, + }, + }, + { + Match: false, + Conditions: []types.Conditions{ + { + Key: ".Image", + Operator: "regexp", + Value: "te(.*)st", + }, + }, + }, + { + Match: true, + Conditions: []types.Conditions{ + { + Key: ".Image", + Operator: "notRegexp", + Value: "te(.*)st", + }, + }, + }, + { + Match: true, + Conditions: []types.Conditions{ + { + Key: ".Image", + Operator: "regexp", + Value: "alpine.+", + }, + }, + }, + { + Match: true, + Conditions: []types.Conditions{ + { + Key: ".Namespace", + Operator: "equal", + Value: "1234567890", + }, + }, + }, + { + Match: false, + Conditions: []types.Conditions{ + { + Key: ".Namespace", + Operator: "equal", + Value: "dasasdsad", + }, + }, + }, + { + Match: true, + Conditions: []types.Conditions{ + { + Key: ".PodAnnotations.env", + Operator: "equal", + Value: "test", + }, + }, + }, + { + Match: false, + Conditions: []types.Conditions{ + { + Key: ".PodAnnotations.env", + Operator: "equal", + Value: "asdasdq", + }, + }, + }, + { + Match: true, + Conditions: []types.Conditions{ + { + Key: ".NamespaceAnnotations.env", + Operator: "equal", + Value: "asdasdq", + }, + }, + }, + { + Match: false, + Conditions: []types.Conditions{ + { + Key: ".NamespaceAnnotations.env", + Operator: "equal", + Value: "123123", + }, + }, + }, + { + Match: true, + Conditions: []types.Conditions{ + { + Key: ".NamespaceAnnotations.KKK", + Operator: "notequal", + Value: "dd", + }, + }, + }, + { + Match: true, + Conditions: []types.Conditions{ + { + Key: `env "TEST_ENV"`, + Operator: "equal", + Value: "test", + }, + }, + }, + { + Match: false, + Conditions: []types.Conditions{ + { + Key: `env "TEST_ENV"`, + Operator: "equal", + Value: "01lw8", + }, + }, + }, + { + Match: true, + Conditions: []types.Conditions{}, + }, + { + Error: true, + Conditions: []types.Conditions{ + { + Key: ".Namespace", + Operator: "unknownOperator", + }, + }, + }, + { + Error: true, + Conditions: []types.Conditions{ + { + Key: "unknownKey", + }, + }, + }, + { + Error: true, + Conditions: []types.Conditions{ + { + Key: ".Image", + Operator: "regexp", + Value: `\`, + }, + }, + }, } - if !match { - t.Fatal("must be true") + for _, test := range tests { + test := test + + t.Run(fmt.Sprintf("%+v", test), func(t *testing.T) { + t.Parallel() + + match, err := utils.CheckConditions(containerInfo, test.Conditions) + if test.Error { + if err == nil { + t.Fatal("must be error") + } else { + t.Skip("is error") + } + } + + if err != nil { + t.Fatal(err) + } + + if match != test.Match { + t.Fatalf("must be %v, got %v", test.Match, match) + } + }) } }