diff --git a/internal/unit_tests/helm_template_neo4j_operations_test.go b/internal/unit_tests/helm_template_neo4j_operations_test.go index 80952c91..fd4198f5 100644 --- a/internal/unit_tests/helm_template_neo4j_operations_test.go +++ b/internal/unit_tests/helm_template_neo4j_operations_test.go @@ -6,6 +6,7 @@ import ( "github.com/neo4j/helm-charts/internal/model" "github.com/stretchr/testify/assert" + batchv1 "k8s.io/api/batch/v1" v1 "k8s.io/api/core/v1" v12 "k8s.io/api/rbac/v1" ) @@ -32,15 +33,16 @@ func TestNeo4jOperationsEnableServer(t *testing.T) { return } - operationsPod := manifest.OfTypeWithName( - &v1.Pod{}, + operationsJob := manifest.OfTypeWithName( + &batchv1.Job{}, fmt.Sprintf("%s-operations", model.DefaultHelmTemplateReleaseName.String()), - ).(*v1.Pod) - assert.NotNil(t, operationsPod, "operations pod not found") - assert.Equal(t, operationsPod.Spec.RestartPolicy, v1.RestartPolicyNever) - assert.Len(t, operationsPod.Spec.Containers, 1) + ).(*batchv1.Job) + assert.NotNil(t, operationsJob, "operations job not found") + podSpec := operationsJob.Spec.Template.Spec + assert.Equal(t, podSpec.RestartPolicy, v1.RestartPolicyNever) + assert.Len(t, podSpec.Containers, 1) envVarNames := make(map[string]bool) - for _, envVar := range operationsPod.Spec.Containers[0].Env { + for _, envVar := range podSpec.Containers[0].Env { envVarNames[envVar.Name] = true } @@ -50,7 +52,7 @@ func TestNeo4jOperationsEnableServer(t *testing.T) { assert.True(t, envVarNames[required], "Required environment variable %s not found", required) } - for _, envVar := range operationsPod.Spec.Containers[0].Env { + for _, envVar := range podSpec.Containers[0].Env { switch envVar.Name { case "RELEASE_NAME", "NAMESPACE", "SECRETNAME", "PROTOCOL": case "SSL_DISABLE_HOSTNAME_VERIFICATION", "SSL_INSECURE_SKIP_VERIFY": @@ -59,7 +61,7 @@ func TestNeo4jOperationsEnableServer(t *testing.T) { } } - for _, envVar := range operationsPod.Spec.Containers[0].Env { + for _, envVar := range podSpec.Containers[0].Env { switch envVar.Name { case "RELEASE_NAME": assert.Equal(t, envVar.Value, model.DefaultHelmTemplateReleaseName.String()) @@ -71,32 +73,33 @@ func TestNeo4jOperationsEnableServer(t *testing.T) { assert.Equal(t, envVar.Value, "neo4j") } } - assert.Contains(t, operationsPod.ObjectMeta.Labels, "testkey") + assert.Contains(t, operationsJob.ObjectMeta.Labels, "testkey") operationsRole := manifest.OfTypeWithName( &v12.Role{}, - fmt.Sprintf("%s-secrets-reader", model.DefaultHelmTemplateReleaseName.String()), + fmt.Sprintf("%s-operations", model.DefaultHelmTemplateReleaseName.String()), ).(*v12.Role) assert.NotNil(t, operationsRole, "operations role not found") assert.Len(t, operationsRole.Rules, 1) - assert.Equal(t, operationsRole.Rules[0].Verbs, []string{"get", "watch", "list"}) + assert.Equal(t, operationsRole.Rules[0].Verbs, []string{"get"}) assert.Equal(t, operationsRole.Rules[0].Resources, []string{"secrets"}) + assert.NotEmpty(t, operationsRole.Rules[0].ResourceNames, "operations role should have resourceNames for least-privilege") - serviceAccount := manifest.OfTypeWithName( + operationsServiceAccount := manifest.OfTypeWithName( &v1.ServiceAccount{}, - model.DefaultHelmTemplateReleaseName.String(), + fmt.Sprintf("%s-operations", model.DefaultHelmTemplateReleaseName.String()), ).(*v1.ServiceAccount) - assert.NotNil(t, serviceAccount, "serviceaccount not found") + assert.NotNil(t, operationsServiceAccount, "operations serviceaccount not found") operationsRoleBinding := manifest.OfTypeWithName( &v12.RoleBinding{}, - fmt.Sprintf("%s-secrets-binding", model.DefaultHelmTemplateReleaseName.String()), + fmt.Sprintf("%s-operations", model.DefaultHelmTemplateReleaseName.String()), ).(*v12.RoleBinding) assert.NotNil(t, operationsRoleBinding, "operations role binding not found") assert.Equal(t, operationsRoleBinding.RoleRef.Name, operationsRole.Name) assert.Len(t, operationsRoleBinding.Subjects, 1) assert.Equal(t, operationsRoleBinding.Subjects[0].Kind, "ServiceAccount") - assert.Equal(t, operationsRoleBinding.Subjects[0].Name, serviceAccount.Name) + assert.Equal(t, operationsRoleBinding.Subjects[0].Name, operationsServiceAccount.Name) } @@ -125,15 +128,15 @@ func TestNeo4jOperationsWithSSLConfiguration(t *testing.T) { return } - operationsPod := manifest.OfTypeWithName( - &v1.Pod{}, + operationsJob := manifest.OfTypeWithName( + &batchv1.Job{}, fmt.Sprintf("%s-operations", model.DefaultHelmTemplateReleaseName.String()), - ).(*v1.Pod) - assert.NotNil(t, operationsPod, "operations pod not found") + ).(*batchv1.Job) + assert.NotNil(t, operationsJob, "operations job not found") // Check for SSL environment variables envVars := make(map[string]string) - for _, envVar := range operationsPod.Spec.Containers[0].Env { + for _, envVar := range operationsJob.Spec.Template.Spec.Containers[0].Env { envVars[envVar.Name] = envVar.Value } @@ -159,15 +162,15 @@ func TestNeo4jOperationsEnableServerForStandalone(t *testing.T) { return } - operationsPod := manifest.OfTypeWithName( - &v1.Pod{}, + operationsJob := manifest.OfTypeWithName( + &batchv1.Job{}, fmt.Sprintf("%s-operations", model.DefaultHelmTemplateReleaseName.String()), ) - assert.Nil(t, operationsPod, "operations pod should not be present for standalone") + assert.Nil(t, operationsJob, "operations job should not be present for standalone") operationsRole := manifest.OfTypeWithName( &v12.Role{}, - fmt.Sprintf("%s-secrets-reader", model.DefaultHelmTemplateReleaseName.String()), + fmt.Sprintf("%s-operations", model.DefaultHelmTemplateReleaseName.String()), ) assert.Nil(t, operationsRole, "operations role should not be present for standalone") @@ -194,13 +197,13 @@ func TestNeo4jOperationsImagePullSecrets(t *testing.T) { return } - operationsPod := manifest.OfTypeWithName( - &v1.Pod{}, + operationsJob := manifest.OfTypeWithName( + &batchv1.Job{}, fmt.Sprintf("%s-operations", model.DefaultHelmTemplateReleaseName.String()), - ).(*v1.Pod) - assert.NotNil(t, operationsPod, "operations pod not found") + ).(*batchv1.Job) + assert.NotNil(t, operationsJob, "operations job not found") - pullSecrets := operationsPod.Spec.ImagePullSecrets + pullSecrets := operationsJob.Spec.Template.Spec.ImagePullSecrets assert.Len(t, pullSecrets, 2, "should have 2 imagePullSecrets") assert.Equal(t, "my-pull-secret", pullSecrets[0].Name) assert.Equal(t, "another-secret", pullSecrets[1].Name) @@ -227,12 +230,12 @@ func TestNeo4jOperationsImagePullSecretsEmpty(t *testing.T) { return } - operationsPod := manifest.OfTypeWithName( - &v1.Pod{}, + operationsJob := manifest.OfTypeWithName( + &batchv1.Job{}, fmt.Sprintf("%s-operations", model.DefaultHelmTemplateReleaseName.String()), - ).(*v1.Pod) - assert.NotNil(t, operationsPod, "operations pod not found") + ).(*batchv1.Job) + assert.NotNil(t, operationsJob, "operations job not found") - pullSecrets := operationsPod.Spec.ImagePullSecrets + pullSecrets := operationsJob.Spec.Template.Spec.ImagePullSecrets assert.Nil(t, pullSecrets, "imagePullSecrets should be nil when empty") } diff --git a/neo4j/templates/neo4j-operations.yaml b/neo4j/templates/neo4j-operations.yaml index 15770d78..7d20d548 100644 --- a/neo4j/templates/neo4j-operations.yaml +++ b/neo4j/templates/neo4j-operations.yaml @@ -1,39 +1,99 @@ {{- $clusterEnabled := eq (include "neo4j.isClusterEnabled" .) "true" }} {{- if and (not (kindIs "invalid" $.Values.neo4j.operations)) $.Values.neo4j.operations.enableServer $clusterEnabled -}} -apiVersion: v1 -kind: Pod +# Job is used instead of a standalone Pod so that the created Pod has ownerReferences. +# This satisfies Kyverno policies (e.g. require-valid-owner-reference) that require pods +# to be owned by Deployment, ReplicaSet, Job, StatefulSet, DaemonSet, etc. +# Uses a dedicated ServiceAccount with least-privilege RBAC (get on auth secret only). +apiVersion: batch/v1 +kind: Job metadata: name: {{ include "neo4j.fullname" . }}-operations labels: app: "neo4j-operations" {{- include "neo4j.labels" $.Values.neo4j.operations | indent 4 }} spec: - restartPolicy: Never - serviceAccountName: {{ include "neo4j.serviceAccountName" . }} - {{- include "neo4j.imagePullSecrets" .Values.image.imagePullSecrets | indent 2 }} - containers: - - name: {{ include "neo4j.fullname" . }}-operations - image: {{ $.Values.neo4j.operations.image }} - imagePullPolicy: "Always" - env: - - name: RELEASE_NAME - value: {{ include "neo4j.fullname" . | quote }} - {{- if and (not (kindIs "invalid" $.Values.neo4j.passwordFromSecret)) (not (empty $.Values.neo4j.passwordFromSecret)) }} - - name: SECRETNAME - value: {{ $.Values.neo4j.passwordFromSecret | quote }} - {{- else }} - - name: SECRETNAME - value: {{ include "neo4j.name" . | printf "%s-auth" | quote }} - {{- end }} - - name: NAMESPACE - value: {{ .Release.Namespace | quote }} - - name: PROTOCOL - value: {{ $.Values.neo4j.operations.protocol | default "neo4j" | quote }} - {{- if $.Values.neo4j.operations.ssl }} - - name: SSL_DISABLE_HOSTNAME_VERIFICATION - value: {{ $.Values.neo4j.operations.ssl.disableHostnameVerification | default false | toString | quote }} - - name: SSL_INSECURE_SKIP_VERIFY - value: {{ $.Values.neo4j.operations.ssl.insecureSkipVerify | default false | toString | quote }} - {{- end }} + backoffLimit: 0 + template: + metadata: + labels: + app: "neo4j-operations" + {{- include "neo4j.labels" $.Values.neo4j.operations | indent 8 }} + spec: + restartPolicy: Never + serviceAccountName: {{ include "neo4j.fullname" . }}-operations + {{- with .Values.securityContext }} + securityContext: {{ toYaml . | nindent 8 }} + {{- end }} + {{- include "neo4j.imagePullSecrets" .Values.image.imagePullSecrets | indent 6 }} + containers: + - name: {{ include "neo4j.fullname" . }}-operations + image: {{ $.Values.neo4j.operations.image }} + imagePullPolicy: {{ $.Values.neo4j.operations.imagePullPolicy | default "Always" }} + {{- with $.Values.containerSecurityContext }} + securityContext: {{ toYaml . | nindent 12 }} + {{- end }} + {{- with $.Values.neo4j.operations.resources }} + resources: {{ toYaml . | nindent 12 }} + {{- end }} + env: + - name: RELEASE_NAME + value: {{ include "neo4j.fullname" . | quote }} + {{- if and (not (kindIs "invalid" $.Values.neo4j.passwordFromSecret)) (not (empty $.Values.neo4j.passwordFromSecret)) }} + - name: SECRETNAME + value: {{ $.Values.neo4j.passwordFromSecret | quote }} + {{- else }} + - name: SECRETNAME + value: {{ include "neo4j.name" . | printf "%s-auth" | quote }} + {{- end }} + - name: NAMESPACE + value: {{ .Release.Namespace | quote }} + - name: PROTOCOL + value: {{ $.Values.neo4j.operations.protocol | default "neo4j" | quote }} + {{- if $.Values.neo4j.operations.ssl }} + - name: SSL_DISABLE_HOSTNAME_VERIFICATION + value: {{ $.Values.neo4j.operations.ssl.disableHostnameVerification | default false | toString | quote }} + - name: SSL_INSECURE_SKIP_VERIFY + value: {{ $.Values.neo4j.operations.ssl.insecureSkipVerify | default false | toString | quote }} + {{- end }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + namespace: {{ .Release.Namespace | quote }} + name: {{ include "neo4j.fullname" . }}-operations + labels: + app: "neo4j-operations" + {{- include "neo4j.labels" $.Values.neo4j.operations | indent 4 }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + namespace: {{ .Release.Namespace | quote }} + name: {{ include "neo4j.fullname" . }}-operations + labels: + app: "neo4j-operations" + {{- include "neo4j.labels" $.Values.neo4j.operations | indent 4 }} +rules: + - apiGroups: [""] + resources: ["secrets"] + resourceNames: [{{ include "neo4j.secretName" . | quote }}] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + namespace: {{ .Release.Namespace | quote }} + name: {{ include "neo4j.fullname" . }}-operations + labels: + app: "neo4j-operations" + {{- include "neo4j.labels" $.Values.neo4j.operations | indent 4 }} +subjects: + - kind: ServiceAccount + name: {{ include "neo4j.fullname" . }}-operations + namespace: {{ .Release.Namespace }} +roleRef: + kind: Role + name: {{ include "neo4j.fullname" . }}-operations + apiGroup: rbac.authorization.k8s.io {{- end -}} diff --git a/neo4j/templates/neo4j-service-account.yaml b/neo4j/templates/neo4j-service-account.yaml index 3f7fda63..dbe51594 100644 --- a/neo4j/templates/neo4j-service-account.yaml +++ b/neo4j/templates/neo4j-service-account.yaml @@ -38,36 +38,4 @@ roleRef: kind: Role # this must be Role or ClusterRole name: {{ include "neo4j.fullname" . }}-service-reader # this must match the name of the Role or ClusterRole you wish to bind to apiGroup: rbac.authorization.k8s.io ---- -{{- if and (not (kindIs "invalid" $.Values.neo4j.operations)) $.Values.neo4j.operations.enableServer }} -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - namespace: "{{ .Release.Namespace }}" - name: "{{ include "neo4j.fullname" . }}-secrets-reader" - labels: - app: "{{ template "neo4j.name" $ }}" - {{- include "neo4j.labels" $.Values.neo4j | indent 4 }} -rules: - - apiGroups: [""] # "" indicates the core API group - resources: ["secrets"] - verbs: ["get", "watch", "list"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - namespace: "{{ .Release.Namespace }}" - name: "{{ include "neo4j.fullname" . }}-secrets-binding" - labels: - app: "{{ template "neo4j.name" $ }}" - {{- include "neo4j.labels" $.Values.neo4j | indent 4 }} -subjects: - - kind: ServiceAccount - name: {{ include "neo4j.serviceAccountName" . }} -roleRef: - # "roleRef" specifies the binding to a Role / ClusterRole - kind: Role # this must be Role or ClusterRole - name: {{ include "neo4j.fullname" . }}-secrets-reader # this must match the name of the Role or ClusterRole you wish to bind to - apiGroup: rbac.authorization.k8s.io {{- end }} -{{- end }} \ No newline at end of file diff --git a/neo4j/values.yaml b/neo4j/values.yaml index 6e61199b..4b5cebbf 100644 --- a/neo4j/values.yaml +++ b/neo4j/values.yaml @@ -30,18 +30,24 @@ neo4j: # (Clustering only feature) # Neo4j operations allows you to enable servers (part of cluster) which are added outside the minimumClusterSize - # When the enableServer flag is set to true , an operations pod is created which performs the following functions + # When the enableServer flag is set to true, an operations Job (creating a Pod with ownerReferences) is created which performs the following functions # fetch neo4j creds from the k8s secret (provided by user or created via helm chart) # Use the cluster ip created as part of the respective release to connect to Neo4j via Go Driver # Execute the ENABLE SERVER query and enable the server - # The operations pod ends successfully if the server is enabled, or it was already enabled + # The operations Job's Pod ends successfully if the server is enabled, or it was already enabled operations: enableServer: false image: "neo4j/helm-charts-operations:2026.01.4" + # imagePullPolicy: Always (default) or IfNotPresent + imagePullPolicy: "Always" # protocol can be "neo4j or "neo4j+ssc" or "neo4j+s". Default set to neo4j # Note: Do not specify bolt protocol here...it will FAIL. protocol: "neo4j" labels: {} + # Resource requests and limits for the operations container + resources: + cpu: "100m" + memory: "128Mi" # SSL/TLS configuration for operations pod connections ssl: # Set to true to disable hostname verification for TLS connections