Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 39 additions & 36 deletions internal/unit_tests/helm_template_neo4j_operations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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
}

Expand All @@ -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":
Expand All @@ -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())
Expand All @@ -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)

}

Expand Down Expand Up @@ -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
}

Expand All @@ -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")

Expand All @@ -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)
Expand All @@ -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")
}
118 changes: 89 additions & 29 deletions neo4j/templates/neo4j-operations.yaml
Original file line number Diff line number Diff line change
@@ -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 -}}
32 changes: 0 additions & 32 deletions neo4j/templates/neo4j-service-account.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
10 changes: 8 additions & 2 deletions neo4j/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading