diff --git a/helm/supabase-operator/templates/_helpers.tpl b/helm/supabase-operator/templates/_helpers.tpl index 21e4cdd..4587cde 100644 --- a/helm/supabase-operator/templates/_helpers.tpl +++ b/helm/supabase-operator/templates/_helpers.tpl @@ -60,3 +60,17 @@ Create the name of the service account to use {{- default "default" .Values.serviceAccount.name }} {{- end }} {{- end }} + +{{/* +Webhook service name +*/}} +{{- define "supabase-operator.webhookServiceName" -}} +{{- printf "%s-webhook" (include "supabase-operator.fullname" .) | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Webhook certificate secret name +*/}} +{{- define "supabase-operator.webhookCertSecret" -}} +{{- printf "%s-webhook-cert" (include "supabase-operator.fullname" .) | trunc 63 | trimSuffix "-" }} +{{- end }} diff --git a/helm/supabase-operator/templates/deployment.yaml b/helm/supabase-operator/templates/deployment.yaml index 4742031..47f1d12 100644 --- a/helm/supabase-operator/templates/deployment.yaml +++ b/helm/supabase-operator/templates/deployment.yaml @@ -56,6 +56,12 @@ spec: {{- tpl (toYaml .) $ | nindent 12 }} {{- end }} {{- end }} + {{- if .Values.webhook.enabled }} + ports: + - containerPort: {{ .Values.webhook.targetPort }} + name: webhook-server + protocol: TCP + {{- end }} livenessProbe: httpGet: path: /healthz @@ -70,14 +76,31 @@ spec: periodSeconds: 10 resources: {{- toYaml .Values.resources | nindent 12 }} - {{- with .Values.extraVolumeMounts }} + {{- $extraMounts := .Values.extraVolumeMounts }} + {{- if or .Values.webhook.enabled (and $extraMounts (not (empty $extraMounts))) }} volumeMounts: - {{- toYaml . | nindent 12 }} + {{- if .Values.webhook.enabled }} + - mountPath: /tmp/k8s-webhook-server/serving-certs + name: webhook-cert + readOnly: true + {{- end }} + {{- if $extraMounts }} + {{- toYaml $extraMounts | nindent 12 }} + {{- end }} {{- end }} terminationGracePeriodSeconds: 10 - {{- with .Values.extraVolumes }} + {{- $extraVolumes := .Values.extraVolumes }} + {{- if or .Values.webhook.enabled (and $extraVolumes (not (empty $extraVolumes))) }} volumes: - {{- toYaml . | nindent 8 }} + {{- if .Values.webhook.enabled }} + - name: webhook-cert + secret: + secretName: {{ include "supabase-operator.webhookCertSecret" . }} + defaultMode: 420 + {{- end }} + {{- if $extraVolumes }} + {{- toYaml $extraVolumes | nindent 8 }} + {{- end }} {{- end }} {{- with .Values.nodeSelector }} nodeSelector: diff --git a/helm/supabase-operator/templates/webhook-certmanager.yaml b/helm/supabase-operator/templates/webhook-certmanager.yaml new file mode 100644 index 0000000..7a60885 --- /dev/null +++ b/helm/supabase-operator/templates/webhook-certmanager.yaml @@ -0,0 +1,27 @@ +{{- if .Values.webhook.enabled }} +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: {{ include "supabase-operator.fullname" . }}-selfsigned-issuer + namespace: {{ .Release.Namespace }} + labels: + {{- include "supabase-operator.labels" . | nindent 4 }} +spec: + selfSigned: {} +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ include "supabase-operator.webhookCertSecret" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "supabase-operator.labels" . | nindent 4 }} +spec: + dnsNames: + - {{ include "supabase-operator.webhookServiceName" . }}.{{ .Release.Namespace }}.svc + - {{ include "supabase-operator.webhookServiceName" . }}.{{ .Release.Namespace }}.svc.cluster.local + issuerRef: + kind: Issuer + name: {{ include "supabase-operator.fullname" . }}-selfsigned-issuer + secretName: {{ include "supabase-operator.webhookCertSecret" . }} +{{- end }} diff --git a/helm/supabase-operator/templates/webhook-configurations.yaml b/helm/supabase-operator/templates/webhook-configurations.yaml new file mode 100644 index 0000000..35b6a5e --- /dev/null +++ b/helm/supabase-operator/templates/webhook-configurations.yaml @@ -0,0 +1,49 @@ +{{- if .Values.webhook.enabled }} +apiVersion: admissionregistration.k8s.io/v1 +kind: MutatingWebhookConfiguration +metadata: + name: {{ include "supabase-operator.fullname" . }}-mutating-webhook + labels: + {{- include "supabase-operator.labels" . | nindent 4 }} + annotations: + cert-manager.io/inject-ca-from: {{ printf "%s/%s" .Release.Namespace (include "supabase-operator.webhookCertSecret" .) }} +webhooks: + - name: msupabaseproject.kb.io + admissionReviewVersions: ["v1"] + failurePolicy: {{ .Values.webhook.failurePolicy }} + sideEffects: None + clientConfig: + service: + name: {{ include "supabase-operator.webhookServiceName" . }} + namespace: {{ .Release.Namespace }} + path: /mutate-supabase-strrl-dev-v1alpha1-supabaseproject + rules: + - apiGroups: ["supabase.strrl.dev"] + apiVersions: ["v1alpha1"] + operations: ["CREATE", "UPDATE"] + resources: ["supabaseprojects"] +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: {{ include "supabase-operator.fullname" . }}-validating-webhook + labels: + {{- include "supabase-operator.labels" . | nindent 4 }} + annotations: + cert-manager.io/inject-ca-from: {{ printf "%s/%s" .Release.Namespace (include "supabase-operator.webhookCertSecret" .) }} +webhooks: + - name: vsupabaseproject.kb.io + admissionReviewVersions: ["v1"] + failurePolicy: {{ .Values.webhook.failurePolicy }} + sideEffects: None + clientConfig: + service: + name: {{ include "supabase-operator.webhookServiceName" . }} + namespace: {{ .Release.Namespace }} + path: /validate-supabase-strrl-dev-v1alpha1-supabaseproject + rules: + - apiGroups: ["supabase.strrl.dev"] + apiVersions: ["v1alpha1"] + operations: ["CREATE", "UPDATE"] + resources: ["supabaseprojects"] +{{- end }} diff --git a/helm/supabase-operator/templates/webhook-service.yaml b/helm/supabase-operator/templates/webhook-service.yaml new file mode 100644 index 0000000..e3cf64e --- /dev/null +++ b/helm/supabase-operator/templates/webhook-service.yaml @@ -0,0 +1,17 @@ +{{- if .Values.webhook.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "supabase-operator.webhookServiceName" . }} + labels: + {{- include "supabase-operator.labels" . | nindent 4 }} +spec: + type: ClusterIP + selector: + {{- include "supabase-operator.selectorLabels" . | nindent 4 }} + ports: + - name: webhook + port: {{ .Values.webhook.servicePort }} + targetPort: {{ .Values.webhook.targetPort }} + protocol: TCP +{{- end }} diff --git a/helm/supabase-operator/values.yaml b/helm/supabase-operator/values.yaml index de6ef30..a0abec4 100644 --- a/helm/supabase-operator/values.yaml +++ b/helm/supabase-operator/values.yaml @@ -66,3 +66,9 @@ tolerations: [] affinity: {} topologySpreadConstraints: [] + +webhook: + enabled: true + servicePort: 443 + targetPort: 9443 + failurePolicy: Fail diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index 756ad13..c00fe4e 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -4,10 +4,13 @@ package e2e import ( + "bytes" "fmt" "os" "os/exec" + "strings" "testing" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -24,9 +27,11 @@ var ( // isCertManagerAlreadyInstalled will be set true when CertManager CRDs be found on the cluster isCertManagerAlreadyInstalled = false - // projectImage is the name of the image which will be build and loaded - // with the code source changes to be tested. - projectImage = "example.com/supabase-operator:v0.0.1" + // projectImage* describe the local image built for e2e; keep repo/tag split + // so we can feed them into Helm values easily. + projectImageRepository = firstNonEmpty(os.Getenv("E2E_IMG_REPO"), "example.com/supabase-operator") + projectImageTag = "" + projectImage = "" ) // TestE2E runs the end-to-end (e2e) test suite for the project. These tests execute in an isolated, @@ -40,6 +45,9 @@ func TestE2E(t *testing.T) { } var _ = BeforeSuite(func() { + projectImageTag = resolveImageTag() + projectImage = fmt.Sprintf("%s:%s", projectImageRepository, projectImageTag) + By("building the manager(Operator) image") cmd := exec.Command("make", "docker-build", fmt.Sprintf("IMG=%s", projectImage)) _, err := utils.Run(cmd) @@ -67,6 +75,61 @@ var _ = BeforeSuite(func() { } }) +// resolveImageTag picks an image tag for e2e image builds. +// Priority: explicit env E2E_IMG_TAG -> git short SHA (+ -dirty if needed) -> timestamp. +func resolveImageTag() string { + if envTag := os.Getenv("E2E_IMG_TAG"); envTag != "" { + return envTag + } + + shortSHA, err := gitRevParseShort() + if err == nil && shortSHA != "" { + if dirty, derr := gitIsDirty(); derr == nil && dirty { + return shortSHA + "-dirty" + } + return shortSHA + } + + return fmt.Sprintf("dev-%d", time.Now().Unix()) +} + +func gitRevParseShort() (string, error) { + root, err := utils.GetProjectDir() + if err != nil { + return "", err + } + cmd := exec.Command("git", "rev-parse", "--short", "HEAD") + cmd.Dir = root + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +func gitIsDirty() (bool, error) { + root, err := utils.GetProjectDir() + if err != nil { + return false, err + } + cmd := exec.Command("git", "status", "--porcelain") + cmd.Dir = root + out, err := cmd.Output() + if err != nil { + return false, err + } + return len(bytes.TrimSpace(out)) > 0, nil +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + var _ = AfterSuite(func() { // Teardown CertManager after the suite if not skipped and if it was not already installed if !skipCertManagerInstall && !isCertManagerAlreadyInstalled { diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index bf60fb7..1f8fe67 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -25,13 +25,13 @@ import ( // namespace where the project is deployed in const namespace = "supabase-operator-system" +const helmReleaseName = "supabase-operator" var _ = Describe("Manager", Ordered, func() { var controllerPodName string // Before running the tests, set up the environment by creating the namespace, - // enforce the restricted security policy to the namespace, installing CRDs, - // and deploying the controller. + // enforce the restricted security policy to the namespace, and installing the controller via Helm. BeforeAll(func() { By("creating manager namespace") cmd := exec.Command("kubectl", "create", "ns", namespace) @@ -44,30 +44,33 @@ var _ = Describe("Manager", Ordered, func() { _, err = utils.Run(cmd) Expect(err).NotTo(HaveOccurred(), "Failed to label namespace with restricted policy") - By("installing CRDs") - cmd = exec.Command("make", "install") - _, err = utils.Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to install CRDs") - - By("deploying the controller-manager") - cmd = exec.Command("make", "deploy", fmt.Sprintf("IMG=%s", projectImage)) + By("installing the operator via Helm chart") + cmd = exec.Command("helm", "upgrade", "--install", helmReleaseName, "helm/supabase-operator", + "--namespace", namespace, + "--create-namespace", + "--wait", + "--timeout", "5m", + "--set", fmt.Sprintf("image.repository=%s", projectImageRepository), + "--set", fmt.Sprintf("image.tag=%s", projectImageTag), + "--set", "image.pullPolicy=IfNotPresent", + ) _, err = utils.Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to deploy the controller-manager") + Expect(err).NotTo(HaveOccurred(), "Failed to install the operator Helm release") }) - // After all tests have been executed, clean up by undeploying the controller, uninstalling CRDs, + // After all tests have been executed, clean up by uninstalling the Helm release, removing CRDs, // and deleting the namespace. AfterAll(func() { - By("undeploying the controller-manager") - cmd := exec.Command("make", "undeploy") + By("uninstalling the operator Helm release") + cmd := exec.Command("helm", "uninstall", helmReleaseName, "-n", namespace, "--wait") _, _ = utils.Run(cmd) - By("uninstalling CRDs") - cmd = exec.Command("make", "uninstall") + By("cleaning up Supabase CRDs") + cmd = exec.Command("kubectl", "delete", "crd", "supabaseprojects.supabase.strrl.dev", "--ignore-not-found=true") _, _ = utils.Run(cmd) By("removing manager namespace") - cmd = exec.Command("kubectl", "delete", "ns", namespace) + cmd = exec.Command("kubectl", "delete", "ns", namespace, "--ignore-not-found=true") _, _ = utils.Run(cmd) }) @@ -76,6 +79,10 @@ var _ = Describe("Manager", Ordered, func() { AfterEach(func() { specReport := CurrentSpecReport() if specReport.Failed() { + if controllerPodName == "" { + _, _ = fmt.Fprintf(GinkgoWriter, "controllerPodName not set; skipping pod log/describe collection\n") + return + } By("Fetching controller manager pod logs") cmd := exec.Command("kubectl", "logs", controllerPodName, "-n", namespace) controllerLogs, err := utils.Run(cmd) @@ -114,7 +121,9 @@ var _ = Describe("Manager", Ordered, func() { verifyControllerUp := func(g Gomega) { // Get the name of the controller-manager pod cmd := exec.Command("kubectl", "get", - "pods", "-l", "control-plane=controller-manager", + "pods", + "-l", "app.kubernetes.io/name=supabase-operator", + "-l", fmt.Sprintf("app.kubernetes.io/instance=%s", helmReleaseName), "-o", "go-template={{ range .items }}"+ "{{ if not .metadata.deletionTimestamp }}"+ "{{ .metadata.name }}"+ @@ -127,7 +136,7 @@ var _ = Describe("Manager", Ordered, func() { podNames := utils.GetNonEmptyLines(podOutput) g.Expect(podNames).To(HaveLen(1), "expected 1 controller pod running") controllerPodName = podNames[0] - g.Expect(controllerPodName).To(ContainSubstring("controller-manager")) + g.Expect(controllerPodName).To(ContainSubstring(helmReleaseName)) // Validate the pod's status cmd = exec.Command("kubectl", "get",