diff --git a/helm/flowfuse/README.md b/helm/flowfuse/README.md index 5a574653..b599cb06 100644 --- a/helm/flowfuse/README.md +++ b/helm/flowfuse/README.md @@ -392,6 +392,77 @@ readinessProbe: failureThreshold: 3 ``` +### Ingress Migration Tool + +To help with migration from the Ingress-Nginx controller to the Traefik Ingress controller, this chart can run an Ingress migration tool as a Kubernetes Job. + +When enabled, the chart creates a Helm hook Job (plus RBAC) that operates on Ingress resources created by FlowFuse in the following namespaces: + +- Project namespace (`forge.projectNamespace`) +- Release namespace (the namespace where you install/upgrade the chart) - only in `copy` mode + +The job behavior is controlled by values under the `ingressMigration` block: + +- `ingressMigration.enabled` Enable/disable the migration job (default `false`). +- `ingressMigration.mode` Mode to run the tool in: `copy` or `cleanup` (default `copy`). +- `ingressMigration.newIngressClassName` IngressClass name to set on migrated Ingress objects (default `traefik`). +- `ingressMigration.oldIngressClassName` Old IngressClass name to target in `cleanup` mode (required when `mode: cleanup`). +- `ingressMigration.nameSuffix` Suffix appended to migrated Ingress names (default `-tfk`). +- `ingressMigration.dryRun` If `true`, the job runs in dry-run mode (default `true`). + +Helm hook controls: + +- `ingressMigration.helmHook` Helm hook phase to run the job in (default `post-upgrade`). +- `ingressMigration.hookWeight` Hook weight used to order hook execution (default `0`). +- `ingressMigration.hookDeletePolicy` Hook delete policy applied when `dryRun: false` (default `before-hook-creation,hook-succeeded`). + +Job controls: + +- `ingressMigration.backoffLimit` Job backoff limit (default `3`). +- `ingressMigration.ttlSecondsAfterFinished` How long to keep the Job after completion when `dryRun: false` (default `300`). + +Image controls: + +- `ingressMigration.image.repository` Tool image repository (default `docker.io/devopswizard/ingress-migration-tool`). +- `ingressMigration.image.tag` Tool image tag (default `latest`). +- `ingressMigration.image.pullPolicy` Image pull policy (default `IfNotPresent`). + +Pod scheduling and security: + +- `ingressMigration.resources` Pod resources (requests/limits). +- `ingressMigration.securityContext` Container securityContext. +- `ingressMigration.nodeSelector` Node selector (default `role: management`). +- `ingressMigration.tolerations` Tolerations list (default `[]`). + +Example: run in `copy` mode first (dry-run), then re-run to apply changes: + +```yaml +ingressMigration: + enabled: true + mode: copy + newIngressClassName: traefik + nameSuffix: "-tfk" + dryRun: true +``` + +Then set `dryRun: false` and run `helm upgrade` again. + +Example: cleanup old Ingress objects after switching traffic: + +```yaml +ingressMigration: + enabled: true + mode: cleanup + oldIngressClassName: nginx + newIngressClassName: traefik + nameSuffix: "-tfk" + dryRun: true +``` + +Then set `dryRun: false` and run `helm upgrade` again. + + + ## Upgrading Chart ### Generic upgrade instructions diff --git a/helm/flowfuse/templates/_helpers.tpl b/helm/flowfuse/templates/_helpers.tpl index 4dfd9db4..de41b3a4 100644 --- a/helm/flowfuse/templates/_helpers.tpl +++ b/helm/flowfuse/templates/_helpers.tpl @@ -340,3 +340,10 @@ Get the valkey port number. {{- .Values.valkey.port }} {{- end -}} {{- end -}} + +{{/* +Get the name from the release name. +*/}} +{{- define "forge.name" -}} +{{- .Release.Name -}} +{{- end -}} \ No newline at end of file diff --git a/helm/flowfuse/templates/ingress-migration.yaml b/helm/flowfuse/templates/ingress-migration.yaml new file mode 100644 index 00000000..ca39f860 --- /dev/null +++ b/helm/flowfuse/templates/ingress-migration.yaml @@ -0,0 +1,141 @@ +{{- if .Values.ingressMigration.enabled }} +{{- $targetNamespaces := list }} +{{- if eq (.Values.ingressMigration.mode | default "copy") "cleanup" }} +{{- $targetNamespaces = list (dict "name" "project" "namespace" .Values.forge.projectNamespace "weight" "0") }} +{{- else }} +{{- $targetNamespaces = list (dict "name" "project" "namespace" .Values.forge.projectNamespace "weight" "0") (dict "name" "release" "namespace" .Release.Namespace "weight" "1") }} +{{- end }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "forge.name" . }}-ingress-migration + namespace: {{ .Release.Namespace }} + annotations: + "helm.sh/hook": {{ .Values.ingressMigration.helmHook | default "post-upgrade" }} + "helm.sh/hook-weight": {{ .Values.ingressMigration.hookWeight | default "-5" | quote }} + {{- if .Values.ingressMigration.dryRun }} + "helm.sh/hook-delete-policy": "before-hook-creation" + {{- else }} + "helm.sh/hook-delete-policy": {{ .Values.ingressMigration.hookDeletePolicy | default "before-hook-creation,hook-succeeded" }} + {{- end }} + labels: + {{- include "forge.labels" . | nindent 4 }} + app.kubernetes.io/component: ingress-migration-job + +{{- range $targetNamespaces }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "forge.name" $ }}-ingress-migration-{{ .name }} + namespace: {{ .namespace }} + annotations: + "helm.sh/hook": {{ $.Values.ingressMigration.helmHook | default "post-upgrade" }} + "helm.sh/hook-weight": {{ $.Values.ingressMigration.hookWeight | default "-5" | quote }} + {{- if $.Values.ingressMigration.dryRun }} + "helm.sh/hook-delete-policy": "before-hook-creation" + {{- else }} + "helm.sh/hook-delete-policy": {{ $.Values.ingressMigration.hookDeletePolicy | default "before-hook-creation,hook-succeeded" }} + {{- end }} + labels: + {{- include "forge.labels" $ | nindent 4 }} + app.kubernetes.io/component: ingress-migration-job +rules: + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses"] + verbs: ["get", "list", "create", "patch", "delete"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "forge.name" $ }}-ingress-migration-{{ .name }} + namespace: {{ .namespace }} + annotations: + "helm.sh/hook": {{ $.Values.ingressMigration.helmHook | default "post-upgrade" }} + "helm.sh/hook-weight": {{ $.Values.ingressMigration.hookWeight | default "-5" | quote }} + {{- if $.Values.ingressMigration.dryRun }} + "helm.sh/hook-delete-policy": "before-hook-creation" + {{- else }} + "helm.sh/hook-delete-policy": {{ $.Values.ingressMigration.hookDeletePolicy | default "before-hook-creation,hook-succeeded" }} + {{- end }} + labels: + {{- include "forge.labels" $ | nindent 4 }} + app.kubernetes.io/component: ingress-migration-job +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "forge.name" $ }}-ingress-migration-{{ .name }} +subjects: + - kind: ServiceAccount + name: {{ include "forge.name" $ }}-ingress-migration + namespace: {{ $.Release.Namespace }} + +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "forge.name" $ }}-ingress-migration-{{ .name }} + namespace: {{ $.Release.Namespace }} + annotations: + "helm.sh/hook": {{ $.Values.ingressMigration.helmHook | default "post-upgrade" }} + "helm.sh/hook-weight": {{ .weight | quote }} + {{- if $.Values.ingressMigration.dryRun }} + "helm.sh/hook-delete-policy": "before-hook-creation" + {{- else }} + "helm.sh/hook-delete-policy": {{ $.Values.ingressMigration.hookDeletePolicy | default "before-hook-creation,hook-succeeded" }} + {{- end }} + labels: + {{- include "forge.labels" $ | nindent 4 }} + app.kubernetes.io/component: ingress-migration-job +spec: + backoffLimit: {{ $.Values.ingressMigration.backoffLimit | default 3 }} + {{- if not $.Values.ingressMigration.dryRun }} + ttlSecondsAfterFinished: {{ $.Values.ingressMigration.ttlSecondsAfterFinished | default 300 }} + {{- end }} + template: + metadata: + labels: + {{- include "forge.commonSelectorLabels" $ | nindent 8 }} + app.kubernetes.io/component: ingress-migration-job + spec: + serviceAccountName: {{ include "forge.name" $ }}-ingress-migration + restartPolicy: OnFailure + {{- with $.Values.ingressMigration.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $.Values.ingressMigration.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: ingress-copy + image: {{ $.Values.ingressMigration.image.repository }}:{{ $.Values.ingressMigration.image.tag | default "latest" }} + imagePullPolicy: {{ $.Values.ingressMigration.image.pullPolicy | default "IfNotPresent" }} + env: + - name: MODE + value: {{ $.Values.ingressMigration.mode | default "copy" | quote }} + - name: TARGET_NAMESPACE + value: {{ .namespace | quote }} + {{- if eq ($.Values.ingressMigration.mode | default "copy") "cleanup" }} + - name: OLD_INGRESS_CLASS + value: {{ $.Values.ingressMigration.oldIngressClassName | quote }} + {{- end }} + - name: NEW_INGRESS_CLASS + value: {{ $.Values.ingressMigration.newIngressClassName | quote }} + - name: NAME_SUFFIX + value: {{ $.Values.ingressMigration.nameSuffix | default "-copy" | quote }} + - name: DRY_RUN + value: {{ $.Values.ingressMigration.dryRun | default false | quote }} + {{- with $.Values.ingressMigration.resources }} + resources: + {{- toYaml . | nindent 10 }} + {{- end }} + {{- with $.Values.ingressMigration.securityContext }} + securityContext: + {{- toYaml . | nindent 10 }} + {{- end }} +{{- end }} +{{- end }} diff --git a/helm/flowfuse/tests/ingress_migration_test.yaml b/helm/flowfuse/tests/ingress_migration_test.yaml new file mode 100644 index 00000000..04108fc1 --- /dev/null +++ b/helm/flowfuse/tests/ingress_migration_test.yaml @@ -0,0 +1,159 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/helm-unittest/helm-unittest/main/schema/helm-testsuite.json +suite: test ingress migration tool +templates: + - ingress-migration.yaml +release: + name: flowfuse + namespace: default +set: + forge.domain: "chart-unit-tests.com" + +tests: + - it: should not render anything when disabled + asserts: + - hasDocuments: + count: 0 + + - it: should render serviceaccount, rbac and two jobs in copy mode (dry-run) + set: + ingressMigration: + enabled: true + mode: copy + dryRun: true + newIngressClassName: "traefik" + nameSuffix: "-tfk" + asserts: + - hasDocuments: + count: 7 + - containsDocument: + apiVersion: v1 + kind: ServiceAccount + name: flowfuse-ingress-migration + any: true + - containsDocument: + apiVersion: batch/v1 + kind: Job + name: flowfuse-ingress-migration-project + any: true + - containsDocument: + apiVersion: batch/v1 + kind: Job + name: flowfuse-ingress-migration-release + any: true + + - it: should set correct env vars for project job in copy mode and omit cleanup-only env/ttl + set: + ingressMigration: + enabled: true + mode: copy + dryRun: true + newIngressClassName: "traefik" + nameSuffix: "-tfk" + documentSelector: + path: spec.template.spec.containers[0].env[?(@.name == "TARGET_NAMESPACE")].value + value: "flowforge" + asserts: + - isKind: + of: Job + - contains: + path: spec.template.spec.containers[0].env + content: + name: MODE + value: "copy" + - contains: + path: spec.template.spec.containers[0].env + content: + name: TARGET_NAMESPACE + value: "flowforge" + - contains: + path: spec.template.spec.containers[0].env + content: + name: NEW_INGRESS_CLASS + value: "traefik" + - contains: + path: spec.template.spec.containers[0].env + content: + name: NAME_SUFFIX + value: "-tfk" + - contains: + path: spec.template.spec.containers[0].env + content: + name: DRY_RUN + value: "true" + - notExists: + path: spec.template.spec.containers[0].env[?(@.name == "OLD_INGRESS_CLASS")] + - notExists: + path: spec.ttlSecondsAfterFinished + + - it: should render single job in cleanup mode and include OLD_INGRESS_CLASS + set: + ingressMigration: + enabled: true + mode: cleanup + dryRun: true + oldIngressClassName: "nginx" + newIngressClassName: "traefik" + nameSuffix: "-tfk" + asserts: + - hasDocuments: + count: 4 + - containsDocument: + apiVersion: batch/v1 + kind: Job + name: flowfuse-ingress-migration-project + any: true + + - it: should set correct env vars for project job in cleanup mode + set: + ingressMigration: + enabled: true + mode: cleanup + dryRun: true + oldIngressClassName: "nginx" + newIngressClassName: "traefik" + nameSuffix: "-tfk" + documentSelector: + path: spec.template.spec.containers[0].env[?(@.name == "TARGET_NAMESPACE")].value + value: "flowforge" + asserts: + - isKind: + of: Job + - contains: + path: spec.template.spec.containers[0].env + content: + name: MODE + value: "cleanup" + - contains: + path: spec.template.spec.containers[0].env + content: + name: OLD_INGRESS_CLASS + value: "nginx" + - contains: + path: spec.template.spec.containers[0].env + content: + name: NEW_INGRESS_CLASS + value: "traefik" + - contains: + path: spec.template.spec.containers[0].env + content: + name: TARGET_NAMESPACE + value: "flowforge" + + - it: should set ttlSecondsAfterFinished and use hookDeletePolicy when not dry-run + set: + ingressMigration: + enabled: true + mode: copy + dryRun: false + hookDeletePolicy: "hook-succeeded" + ttlSecondsAfterFinished: 123 + documentSelector: + path: spec.template.spec.containers[0].env[?(@.name == "TARGET_NAMESPACE")].value + value: "flowforge" + asserts: + - equal: + path: metadata.annotations["helm.sh/hook-delete-policy"] + value: "hook-succeeded" + - equal: + path: spec.ttlSecondsAfterFinished + value: 123 diff --git a/helm/flowfuse/values.schema.json b/helm/flowfuse/values.schema.json index 0b71348a..f6f79d74 100644 --- a/helm/flowfuse/values.schema.json +++ b/helm/flowfuse/values.schema.json @@ -1313,6 +1313,99 @@ } } }, + "ingressMigration": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "mode": { + "type": "string", + "enum": ["copy", "cleanup"], + "description": "Mode to run the tool in." + }, + "oldIngressClassName": { + "type": "string", + "description": "Old IngressClass name to target (required when mode is cleanup)." + }, + "newIngressClassName": { + "type": "string" + }, + "nameSuffix": { + "type": "string" + }, + "dryRun": { + "type": "boolean" + }, + "helmHook": { + "type": "string" + }, + "hookWeight": { + "type": "string" + }, + "hookDeletePolicy": { + "type": "string" + }, + "backoffLimit": { + "type": "integer", + "minimum": 0 + }, + "ttlSecondsAfterFinished": { + "type": "integer", + "minimum": 0 + }, + "image": { + "type": "object", + "properties": { + "repository": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "pullPolicy": { + "type": "string", + "enum": ["Always", "IfNotPresent", "Never"] + } + } + }, + "resources": { + "type": "object", + "description": "Kubernetes resources requests/limits for the migration Job container." + }, + "securityContext": { + "type": "object", + "description": "Kubernetes container securityContext for the migration Job container." + }, + "nodeSelector": { + "type": "object", + "description": "Node selector for scheduling the migration Job." + }, + "tolerations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "effect": { "type": "string" }, + "key": { "type": "string" }, + "operator": { "type": "string" }, + "value": { "type": "string" }, + "tolerationSeconds": { "type": "integer" } + } + } + } + }, + "if": { + "properties": { + "mode": { + "const": "cleanup" + } + } + }, + "then": { + "required": ["oldIngressClassName"] + } + }, "npmRegistry" :{ "type": "object", "properties": { diff --git a/helm/flowfuse/values.yaml b/helm/flowfuse/values.yaml index eef0f328..1a8ff70a 100644 --- a/helm/flowfuse/values.yaml +++ b/helm/flowfuse/values.yaml @@ -385,3 +385,44 @@ valkey: maxMemory: 800mb maxMemoryPolicy: allkeys-lru save: 3600 1 300 100 60 10000 + +ingressMigration: + enabled: false + + mode: copy # or cleanup + newIngressClassName: "traefik" + nameSuffix: "-tfk" + dryRun: true + helmHook: "post-upgrade" + hookWeight: "0" + hookDeletePolicy: "before-hook-creation,hook-succeeded" + + backoffLimit: 3 + ttlSecondsAfterFinished: 300 # Clean up job 5 minutes after completion + + image: + repository: flowfuse/ingress-migration-tool # Placeholder image, replace with actual image + tag: "1.0.0" + pullPolicy: IfNotPresent + + resources: + limits: + cpu: 200m + memory: 256Mi + requests: + cpu: 100m + memory: 128Mi + + securityContext: + runAsNonRoot: true + runAsUser: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + + nodeSelector: + role: management + + # Tolerations + tolerations: []