From 09144d07eedceab2cb561c73a01eab7d586dd652 Mon Sep 17 00:00:00 2001 From: Cody Jackson Date: Wed, 13 May 2026 01:00:09 +0000 Subject: [PATCH 1/5] Fix Ingress form editor to handle backend.service.port.name The form editor only read/wrote backend.service.port.number, leaving the port field blank when an Ingress used port.name instead. Now both port.name and port.number are supported for reading, writing, validation, and port dropdown options. Fixes #17105 --- .../DefaultBackend.vue | 12 ++++++---- .../networking.k8s.io.ingress/RulePath.vue | 9 ++++--- .../edit/networking.k8s.io.ingress/index.vue | 22 ++++++++++------- shell/models/networking.k8s.io.ingress.js | 9 ++++++- shell/utils/__tests__/ingress.test.ts | 24 +++++++++++++++++++ shell/utils/ingress.ts | 10 +++++++- 6 files changed, 69 insertions(+), 17 deletions(-) diff --git a/shell/edit/networking.k8s.io.ingress/DefaultBackend.vue b/shell/edit/networking.k8s.io.ingress/DefaultBackend.vue index 7793dc29041..5ef50990bf0 100644 --- a/shell/edit/networking.k8s.io.ingress/DefaultBackend.vue +++ b/shell/edit/networking.k8s.io.ingress/DefaultBackend.vue @@ -43,7 +43,9 @@ export default { const backend = get(this.value.spec, this.value.defaultBackendPath); this.serviceName = get(backend, this.value.serviceNamePath) || ''; - this.servicePort = get(backend, this.value.servicePortPath) || ''; + this.servicePort = get(backend, this.value.servicePortPath) || + get(backend, this.value.servicePortNamePath) || + ''; }, computed: { isView() { @@ -75,10 +77,12 @@ export default { }, methods: { update() { - const backend = get(this.value.spec, this.value.defaultBackendPath) || {}; + const backend = {}; + const servicePort = Number.parseInt(this.servicePort) || this.servicePort; + const portPath = typeof servicePort === 'number' ? this.value.servicePortPath : this.value.servicePortNamePath; set(backend, this.value.serviceNamePath, this.serviceName); - set(backend, this.value.servicePortPath, this.servicePort); + set(backend, portPath, servicePort); set(this.value.spec, this.value.defaultBackendPath, backend); this.$emit('update:value', this.value); @@ -121,7 +125,7 @@ export default { > { + if (!port || (!port.number && !port.name)) { + return this.t('validation.required', { key: this.t('ingress.rules.port.label') }); + } + }; + + return { backEndOrRules, portNumberOrName }; }, tabErrors() { return { - rules: this.fvGetPathErrors(['spec.rules.host', 'spec.rules.http.paths.path', 'spec.rules.http.paths.backend.service.port.number', 'spec.rules.http.paths.backend.service.name'])?.length > 0, - defaultBackend: this.fvGetPathErrors(['spec.defaultBackend.service.name', 'spec.defaultBackend.service.port.number'])?.length > 0 + rules: this.fvGetPathErrors(['spec.rules.host', 'spec.rules.http.paths.path', 'spec.rules.http.paths.backend.service.port', 'spec.rules.http.paths.backend.service.name'])?.length > 0, + defaultBackend: this.fvGetPathErrors(['spec.defaultBackend.service.name', 'spec.defaultBackend.service.port'])?.length > 0 }; }, rulesPathRules() { return { requestHost: this.fvGetAndReportPathRules('spec.rules.host'), path: this.fvGetAndReportPathRules('spec.rules.http.paths.path'), - port: this.fvGetAndReportPathRules('spec.rules.http.paths.backend.service.port.number'), + port: this.fvGetAndReportPathRules('spec.rules.http.paths.backend.service.port'), target: this.fvGetAndReportPathRules('spec.rules.http.paths.backend.service.name'), }; @@ -149,7 +155,7 @@ export default { if (!rulesExist || defaultBackendExist) { return { name: this.fvGetAndReportPathRules('spec.defaultBackend.service.name'), - port: this.fvGetAndReportPathRules('spec.defaultBackend.service.port.number'), + port: this.fvGetAndReportPathRules('spec.defaultBackend.service.port'), }; } diff --git a/shell/models/networking.k8s.io.ingress.js b/shell/models/networking.k8s.io.ingress.js index f66e4182bd0..3ae9ab07db7 100644 --- a/shell/models/networking.k8s.io.ingress.js +++ b/shell/models/networking.k8s.io.ingress.js @@ -84,7 +84,7 @@ export default class Ingress extends SteveModel { serviceTargetTo: this.targetTo(workloads, serviceName), certs: this.certLinks(rule, certificates), targetLink: this.targetLink(workloads, serviceName), - port: get(path?.backend, this.servicePortPath) + port: get(path?.backend, this.servicePortPath) || get(path?.backend, this.servicePortNamePath) }; } @@ -184,6 +184,13 @@ export default class Ingress extends SteveModel { return this.useNestedBackendField ? nestedPath : flatPath; } + get servicePortNamePath() { + const nestedPath = 'service.port.name'; + const flatPath = 'servicePort'; + + return this.useNestedBackendField ? nestedPath : flatPath; + } + get defaultBackendPath() { const defaultBackend = this.$rootGetters['cluster/pathExistsInSchema'](this.type, 'spec.defaultBackend'); diff --git a/shell/utils/__tests__/ingress.test.ts b/shell/utils/__tests__/ingress.test.ts index 06eb05dd2dd..815cf7477b8 100644 --- a/shell/utils/__tests__/ingress.test.ts +++ b/shell/utils/__tests__/ingress.test.ts @@ -72,6 +72,18 @@ describe('ingress', () => { ports: [80, 443], }], }, + { + desc: 'includes port names alongside port numbers when available', + services: [{ + metadata: { name: 'named-ports-service' }, + spec: { ports: [{ port: 80, name: 'http' }, { port: 443, name: 'https' }] }, + }], + expected: [{ + label: 'named-ports-service', + value: 'named-ports-service', + ports: [80, 'http', 443, 'https'], + }], + }, { desc: 'returns undefined ports when service has no spec.ports', services: [{ @@ -121,6 +133,18 @@ describe('ingress', () => { }, ], }, + { + desc: 'includes only port numbers when ports have no names', + services: [{ + metadata: { name: 'unnamed-service' }, + spec: { ports: [{ port: 3000 }] }, + }], + expected: [{ + label: 'unnamed-service', + value: 'unnamed-service', + ports: [3000], + }], + }, ])('$desc', ({ services, expected }) => { const helper = makeHelper(); diff --git a/shell/utils/ingress.ts b/shell/utils/ingress.ts index 048490e4151..0c866e5551a 100644 --- a/shell/utils/ingress.ts +++ b/shell/utils/ingress.ts @@ -56,7 +56,15 @@ class IngressDetailEditHelper { return services.map((service) => ({ label: service.metadata.name, value: service.metadata.name, - ports: service.spec.ports?.map((p: any) => p.port) + ports: service.spec.ports?.flatMap((p: any) => { + const options = [p.port]; + + if (p.name) { + options.push(p.name); + } + + return options; + }) })); } } From 1e7381dc71f94f5bf3c7727897aa80201481d5a3 Mon Sep 17 00:00:00 2001 From: Cody Jackson Date: Fri, 15 May 2026 17:01:35 +0000 Subject: [PATCH 2/5] Harden port validation and fix parseInt edge case - Fix Number.parseInt fallthrough when port is 0 (falsy but valid parse) - Split portNumberOrName into portRequired and portRange validators - Handle both scalar (component-level) and object (mixin-level) inputs - Restore required asterisk on DefaultBackend port fields - Add unit tests for port parsing, portRequired, and portRange --- .../DefaultBackend.vue | 5 +- .../networking.k8s.io.ingress/RulePath.vue | 3 +- .../edit/networking.k8s.io.ingress/index.vue | 39 ++- shell/utils/__tests__/ingress.test.ts | 285 ++++++++++++++++++ 4 files changed, 324 insertions(+), 8 deletions(-) diff --git a/shell/edit/networking.k8s.io.ingress/DefaultBackend.vue b/shell/edit/networking.k8s.io.ingress/DefaultBackend.vue index 5ef50990bf0..efd515b5171 100644 --- a/shell/edit/networking.k8s.io.ingress/DefaultBackend.vue +++ b/shell/edit/networking.k8s.io.ingress/DefaultBackend.vue @@ -78,7 +78,8 @@ export default { methods: { update() { const backend = {}; - const servicePort = Number.parseInt(this.servicePort) || this.servicePort; + const parsed = Number.parseInt(this.servicePort); + const servicePort = Number.isNaN(parsed) ? this.servicePort : parsed; const portPath = typeof servicePort === 'number' ? this.value.servicePortPath : this.value.servicePortNamePath; set(backend, this.value.serviceNamePath, this.serviceName); @@ -127,6 +128,7 @@ export default { v-if="portOptions.length === 0 || isView" v-model:value="servicePort" :mode="mode" + :required="true" :label="t('ingress.defaultBackend.port.label')" :placeholder="t('ingress.defaultBackend.port.placeholder')" :rules="rules.port" @@ -136,6 +138,7 @@ export default { v-else v-model:value="servicePort" :mode="mode" + :required="true" :options="portOptions" :label="t('ingress.defaultBackend.port.label')" :placeholder="t('ingress.defaultBackend.port.placeholder')" diff --git a/shell/edit/networking.k8s.io.ingress/RulePath.vue b/shell/edit/networking.k8s.io.ingress/RulePath.vue index f3f49a38c39..755ff1b7d51 100644 --- a/shell/edit/networking.k8s.io.ingress/RulePath.vue +++ b/shell/edit/networking.k8s.io.ingress/RulePath.vue @@ -87,7 +87,8 @@ export default { }, methods: { update() { - const servicePort = Number.parseInt(this.servicePort) || this.servicePort; + const parsed = Number.parseInt(this.servicePort); + const servicePort = Number.isNaN(parsed) ? this.servicePort : parsed; const serviceName = this.serviceName.label || this.serviceName; const out = { id: this.value.id, backend: {}, path: this.path, pathType: this.pathType diff --git a/shell/edit/networking.k8s.io.ingress/index.vue b/shell/edit/networking.k8s.io.ingress/index.vue index 1ace641e4c5..bf642fe81d5 100644 --- a/shell/edit/networking.k8s.io.ingress/index.vue +++ b/shell/edit/networking.k8s.io.ingress/index.vue @@ -87,7 +87,7 @@ export default { path: 'spec.rules.http.paths.path', rules: ['absolutePath'], translationKey: 'ingress.rules.path.label' }, { - path: 'spec.rules.http.paths.backend.service.port', rules: ['portNumberOrName'], translationKey: 'ingress.rules.port.label' + path: 'spec.rules.http.paths.backend.service.port', rules: ['portRequired', 'portRange'], translationKey: 'ingress.rules.port.label' }, { path: 'spec.rules.http.paths.backend.service.name', rules: ['required'], translationKey: 'ingress.rules.target.label' @@ -97,7 +97,7 @@ export default { path: 'spec.defaultBackend.service.name', rules: ['required'], translationKey: 'ingress.defaultBackend.targetService.label' }, { - path: 'spec.defaultBackend.service.port', rules: ['portNumberOrName'], translationKey: 'ingress.defaultBackend.port.label' + path: 'spec.defaultBackend.service.port', rules: ['portRequired', 'portRange'], translationKey: 'ingress.defaultBackend.port.label' }, { path: 'spec.tls.hosts', rules: ['required', 'wildcardHostname'] } ], @@ -125,13 +125,40 @@ export default { } }; - const portNumberOrName = (port) => { - if (!port || (!port.number && !port.name)) { - return this.t('validation.required', { key: this.t('ingress.rules.port.label') }); + const portLabel = this.t('ingress.rules.port.label'); + + // Built-in `required` won't work: it passes for empty objects like {} or { name: '' }. + const portRequired = (port) => { + if (typeof port === 'string' || typeof port === 'number') { + if (!port) { + return this.t('validation.required', { key: portLabel }); + } + } else if (!port || (!port.number && !port.name)) { + return this.t('validation.required', { key: portLabel }); } }; - return { backEndOrRules, portNumberOrName }; + const portRange = (port) => { + let num; + + if (typeof port === 'number') { + num = port; + } else if (typeof port === 'string') { + num = Number.parseInt(port); + } else if (port?.number) { + num = port.number; + } + + if (num !== undefined && !Number.isNaN(num) && (num < 1 || num > 65535)) { + return this.t('validation.number.between', { + key: portLabel, min: '1', max: '65535' + }); + } + }; + + return { + backEndOrRules, portRequired, portRange + }; }, tabErrors() { return { diff --git a/shell/utils/__tests__/ingress.test.ts b/shell/utils/__tests__/ingress.test.ts index 815cf7477b8..8fa292a053a 100644 --- a/shell/utils/__tests__/ingress.test.ts +++ b/shell/utils/__tests__/ingress.test.ts @@ -1,6 +1,55 @@ import IngressDetailEditHelper from '@shell/utils/ingress'; import { SECRET_TYPES as TYPES } from '@shell/config/secret'; +/** + * Mirrors the port parsing logic used in RulePath.vue and DefaultBackend.vue + * to decide whether to write port.number or port.name + */ +function parseServicePort(rawPort: string | number): string | number { + const parsed = Number.parseInt(String(rawPort)); + + return Number.isNaN(parsed) ? rawPort : parsed; +} + +/** + * Mirrors the portRequired validation rule from index.vue. + * Handles both scalar values (from component-level validation) + * and port objects (from form-validation mixin). + */ +function portRequired(port: any): string | undefined { + if (typeof port === 'string' || typeof port === 'number') { + if (!port) { + return 'Port is required'; + } + } else if (!port || (!port.number && !port.name)) { + return 'Port is required'; + } + + return undefined; +} + +/** + * Mirrors the portRange validation rule from index.vue. + * Checks that a numeric port is within 1-65535 (handles both scalar and object inputs). + */ +function portRange(port: any): string | undefined { + let num; + + if (typeof port === 'number') { + num = port; + } else if (typeof port === 'string') { + num = Number.parseInt(port); + } else if (port?.number) { + num = port.number; + } + + if (num !== undefined && !Number.isNaN(num) && (num < 1 || num > 65535)) { + return 'Port number must be between 1 and 65535'; + } + + return undefined; +} + const makeHelper = () => new IngressDetailEditHelper({ $store: {} as any, namespace: 'default', @@ -145,10 +194,246 @@ describe('ingress', () => { ports: [3000], }], }, + { + desc: 'skips empty-string port names', + services: [{ + metadata: { name: 'empty-name-svc' }, + spec: { ports: [{ port: 8080, name: '' }] }, + }], + expected: [{ + label: 'empty-name-svc', + value: 'empty-name-svc', + ports: [8080], + }], + }, + { + desc: 'handles mix of named and unnamed ports on the same service', + services: [{ + metadata: { name: 'mixed-svc' }, + spec: { ports: [{ port: 80, name: 'http' }, { port: 9090 }] }, + }], + expected: [{ + label: 'mixed-svc', + value: 'mixed-svc', + ports: [80, 'http', 9090], + }], + }, ])('$desc', ({ services, expected }) => { const helper = makeHelper(); expect(helper.findAndMapServiceTargets(services)).toStrictEqual(expected); }); }); + + describe('parseServicePort', () => { + it.each([ + { + desc: 'parses numeric string to number', + input: '80', + expected: 80, + }, + { + desc: 'parses large port number', + input: '65535', + expected: 65535, + }, + { + desc: 'keeps string port name as-is', + input: 'http', + expected: 'http', + }, + { + desc: 'keeps string port name with hyphens', + input: 'my-port', + expected: 'my-port', + }, + { + desc: 'handles zero as a number (not as falsy)', + input: '0', + expected: 0, + }, + { + desc: 'handles already-numeric input', + input: 443 as any, + expected: 443, + }, + { + desc: 'keeps empty string as empty string', + input: '', + expected: '', + }, + ])('$desc', ({ input, expected }) => { + expect(parseServicePort(input)).toStrictEqual(expected); + }); + + it('routes numeric result to port.number path', () => { + const servicePort = parseServicePort('80'); + + expect(typeof servicePort).toBe('number'); + }); + + it('routes string result to port.name path', () => { + const servicePort = parseServicePort('http'); + + expect(typeof servicePort).toBe('string'); + }); + }); + + describe('portRequired validation', () => { + describe('object input (form mixin)', () => { + it.each([ + { + desc: 'passes when port.number is set', + port: { number: 80 }, + }, + { + desc: 'passes when port.name is set', + port: { name: 'http' }, + }, + { + desc: 'passes when both are set', + port: { + number: 80, + name: 'http', + }, + }, + { + desc: 'passes when port.number is 0 but name is set', + port: { + number: 0, + name: 'http', + }, + }, + ])('valid: $desc', ({ port }) => { + expect(portRequired(port)).toBeUndefined(); + }); + + it.each([ + { + desc: 'port is undefined', + port: undefined, + }, + { + desc: 'port is null', + port: null, + }, + { + desc: 'port is empty object', + port: {}, + }, + { + desc: 'port.number is 0 and no name', + port: { number: 0 }, + }, + { + desc: 'port.name is empty string and no number', + port: { name: '' }, + }, + ])('invalid: $desc', ({ port }) => { + expect(portRequired(port)).toBeDefined(); + }); + }); + + describe('scalar input (component)', () => { + it.each([ + { + desc: 'passes for numeric port', + port: 80, + }, + { + desc: 'passes for string port name', + port: 'http', + }, + ])('valid: $desc', ({ port }) => { + expect(portRequired(port)).toBeUndefined(); + }); + + it.each([ + { + desc: 'empty string', + port: '', + }, + { + desc: 'zero', + port: 0, + }, + ])('invalid: $desc', ({ port }) => { + expect(portRequired(port)).toBeDefined(); + }); + }); + }); + + describe('portRange validation', () => { + describe('object input (form mixin)', () => { + it.each([ + { + desc: 'passes for port.number 1 (minimum)', + port: { number: 1 }, + }, + { + desc: 'passes for port.number 65535 (maximum)', + port: { number: 65535 }, + }, + { + desc: 'passes when only port.name is set', + port: { name: 'http' }, + }, + ])('valid: $desc', ({ port }) => { + expect(portRange(port)).toBeUndefined(); + }); + + it.each([ + { + desc: 'port.number exceeds 65535', + port: { number: 70000 }, + }, + { + desc: 'port.number is negative', + port: { number: -1 }, + }, + ])('invalid: $desc', ({ port }) => { + expect(portRange(port)).toBeDefined(); + }); + }); + + describe('scalar input (component)', () => { + it.each([ + { + desc: 'passes for port 1 (minimum)', + port: 1, + }, + { + desc: 'passes for port 65535 (maximum)', + port: 65535, + }, + { + desc: 'passes for numeric string "443"', + port: '443', + }, + { + desc: 'passes for string port name', + port: 'http', + }, + ])('valid: $desc', ({ port }) => { + expect(portRange(port)).toBeUndefined(); + }); + + it.each([ + { + desc: 'number exceeds 65535', + port: 70000, + }, + { + desc: 'negative number', + port: -1, + }, + { + desc: 'string "70000" exceeds range', + port: '70000', + }, + ])('invalid: $desc', ({ port }) => { + expect(portRange(port)).toBeDefined(); + }); + }); + }); }); From e39b4b7d1b03066244efbfca2bf3ae0ceaf0dbd5 Mon Sep 17 00:00:00 2001 From: Cody Jackson Date: Fri, 15 May 2026 17:07:17 +0000 Subject: [PATCH 3/5] Add reviewer-facing comments on non-obvious changes --- shell/edit/networking.k8s.io.ingress/DefaultBackend.vue | 2 ++ shell/models/networking.k8s.io.ingress.js | 1 + 2 files changed, 3 insertions(+) diff --git a/shell/edit/networking.k8s.io.ingress/DefaultBackend.vue b/shell/edit/networking.k8s.io.ingress/DefaultBackend.vue index efd515b5171..ffe79285eeb 100644 --- a/shell/edit/networking.k8s.io.ingress/DefaultBackend.vue +++ b/shell/edit/networking.k8s.io.ingress/DefaultBackend.vue @@ -77,6 +77,7 @@ export default { }, methods: { update() { + // Fresh object so the old port path (name vs number) doesn't linger. const backend = {}; const parsed = Number.parseInt(this.servicePort); const servicePort = Number.isNaN(parsed) ? this.servicePort : parsed; @@ -124,6 +125,7 @@ export default { class="col span-3" :style="{'margin-right': '0px'}" > + Date: Mon, 18 May 2026 14:59:05 +0000 Subject: [PATCH 4/5] Fix willSave() deleting default backend when port uses a name willSave() only checked servicePortPath (port.number), missing backends that store the port under servicePortNamePath (port.name). --- shell/edit/networking.k8s.io.ingress/index.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shell/edit/networking.k8s.io.ingress/index.vue b/shell/edit/networking.k8s.io.ingress/index.vue index bf642fe81d5..6a4389c1e86 100644 --- a/shell/edit/networking.k8s.io.ingress/index.vue +++ b/shell/edit/networking.k8s.io.ingress/index.vue @@ -221,7 +221,8 @@ export default { willSave() { const backend = get(this.value.spec, this.value.defaultBackendPath); const serviceName = get(backend, this.value.serviceNamePath); - const servicePort = get(backend, this.value.servicePortPath); + const servicePort = get(backend, this.value.servicePortPath) || + get(backend, this.value.servicePortNamePath); if (backend && (!serviceName || !servicePort)) { const path = this.value.defaultBackendPath; From 70e0c4577120b7b96bd920d1cdd13e271972b0a8 Mon Sep 17 00:00:00 2001 From: Cody Jackson Date: Mon, 18 May 2026 16:43:47 +0000 Subject: [PATCH 5/5] Allow removing default backend by selecting None Default backend validation rules now only enforce required checks when a service is actually selected. Selecting "None" skips validation so the user can save, and willSave() cleans up the empty backend object. --- .../edit/networking.k8s.io.ingress/index.vue | 49 ++++-- shell/utils/__tests__/ingress.test.ts | 140 ++++++++++++++++-- 2 files changed, 162 insertions(+), 27 deletions(-) diff --git a/shell/edit/networking.k8s.io.ingress/index.vue b/shell/edit/networking.k8s.io.ingress/index.vue index 6a4389c1e86..17bda5881c8 100644 --- a/shell/edit/networking.k8s.io.ingress/index.vue +++ b/shell/edit/networking.k8s.io.ingress/index.vue @@ -94,10 +94,10 @@ export default { }, { path: 'spec', rules: ['backEndOrRules'] }, { - path: 'spec.defaultBackend.service.name', rules: ['required'], translationKey: 'ingress.defaultBackend.targetService.label' + path: 'spec.defaultBackend.service.name', rules: ['defaultBackendNameRequired'], translationKey: 'ingress.defaultBackend.targetService.label' }, { - path: 'spec.defaultBackend.service.port', rules: ['portRequired', 'portRange'], translationKey: 'ingress.defaultBackend.port.label' + path: 'spec.defaultBackend.service.port', rules: ['defaultBackendPortRequired', 'portRange'], translationKey: 'ingress.defaultBackend.port.label' }, { path: 'spec.tls.hosts', rules: ['required', 'wildcardHostname'] } ], @@ -156,8 +156,36 @@ export default { } }; + const hasDefaultBackendService = () => { + const backend = get(this.value?.spec, this.value.defaultBackendPath); + + return !!get(backend, this.value.serviceNamePath); + }; + + const nameLabel = this.t('ingress.defaultBackend.targetService.label'); + + // Only enforce required on the default backend when a service is selected. + // Selecting "None" means the user wants to remove the backend; willSave() handles cleanup. + const defaultBackendNameRequired = (name) => { + if (hasDefaultBackendService() && !name) { + return this.t('validation.required', { key: nameLabel }); + } + }; + + const defaultBackendPortRequired = (port) => { + if (!hasDefaultBackendService()) { + return; + } + + return portRequired(port); + }; + return { - backEndOrRules, portRequired, portRange + backEndOrRules, + portRequired, + portRange, + defaultBackendNameRequired, + defaultBackendPortRequired, }; }, tabErrors() { @@ -176,17 +204,10 @@ export default { }; }, defaultBackendPathRules() { - const rulesExist = (this.value?.spec?.rules || []).length > 0; - const defaultBackendExist = !!this.value?.spec?.defaultBackend?.service; - - if (!rulesExist || defaultBackendExist) { - return { - name: this.fvGetAndReportPathRules('spec.defaultBackend.service.name'), - port: this.fvGetAndReportPathRules('spec.defaultBackend.service.port'), - }; - } - - return { name: [], port: [] }; + return { + name: this.fvGetAndReportPathRules('spec.defaultBackend.service.name'), + port: this.fvGetAndReportPathRules('spec.defaultBackend.service.port'), + }; }, serviceTargets() { return this.ingressHelper.findAndMapServiceTargets(this.services); diff --git a/shell/utils/__tests__/ingress.test.ts b/shell/utils/__tests__/ingress.test.ts index 8fa292a053a..080ee0dcf77 100644 --- a/shell/utils/__tests__/ingress.test.ts +++ b/shell/utils/__tests__/ingress.test.ts @@ -1,21 +1,13 @@ import IngressDetailEditHelper from '@shell/utils/ingress'; import { SECRET_TYPES as TYPES } from '@shell/config/secret'; +import { get, set } from '@shell/utils/object'; -/** - * Mirrors the port parsing logic used in RulePath.vue and DefaultBackend.vue - * to decide whether to write port.number or port.name - */ function parseServicePort(rawPort: string | number): string | number { const parsed = Number.parseInt(String(rawPort)); return Number.isNaN(parsed) ? rawPort : parsed; } -/** - * Mirrors the portRequired validation rule from index.vue. - * Handles both scalar values (from component-level validation) - * and port objects (from form-validation mixin). - */ function portRequired(port: any): string | undefined { if (typeof port === 'string' || typeof port === 'number') { if (!port) { @@ -28,10 +20,6 @@ function portRequired(port: any): string | undefined { return undefined; } -/** - * Mirrors the portRange validation rule from index.vue. - * Checks that a numeric port is within 1-65535 (handles both scalar and object inputs). - */ function portRange(port: any): string | undefined { let num; @@ -50,6 +38,45 @@ function portRange(port: any): string | undefined { return undefined; } +/** + * Mirrors willSave() from index.vue. + * Clears the default backend when the service name or port is missing. + */ +function willSave(spec: any, paths: { defaultBackendPath: string; serviceNamePath: string; servicePortPath: string; servicePortNamePath: string }): void { + const backend = get(spec, paths.defaultBackendPath); + const serviceName = get(backend, paths.serviceNamePath); + const servicePort = get(backend, paths.servicePortPath) || + get(backend, paths.servicePortNamePath); + + if (backend && (!serviceName || !servicePort)) { + set(spec, paths.defaultBackendPath, null); + } +} + +/** + * Mirrors defaultBackendNameRequired from index.vue. + * Only enforces required when a service is actually selected. + */ +function defaultBackendNameRequired(name: any, hasService: boolean): string | undefined { + if (hasService && !name) { + return 'Target Service is required'; + } + + return undefined; +} + +/** + * Mirrors defaultBackendPortRequired from index.vue. + * Delegates to portRequired only when a service is selected. + */ +function defaultBackendPortRequired(port: any, hasService: boolean): string | undefined { + if (!hasService) { + return undefined; + } + + return portRequired(port); +} + const makeHelper = () => new IngressDetailEditHelper({ $store: {} as any, namespace: 'default', @@ -436,4 +463,91 @@ describe('ingress', () => { }); }); }); + + describe('willSave', () => { + const nestedPaths = { + defaultBackendPath: 'defaultBackend', + serviceNamePath: 'service.name', + servicePortPath: 'service.port.number', + servicePortNamePath: 'service.port.name', + }; + + it('preserves backend when port.number is set', () => { + const spec = { defaultBackend: { service: { name: 'my-svc', port: { number: 80 } } } }; + + willSave(spec, nestedPaths); + expect(spec.defaultBackend).toStrictEqual({ service: { name: 'my-svc', port: { number: 80 } } }); + }); + + it('preserves backend when port.name is set', () => { + const spec = { defaultBackend: { service: { name: 'my-svc', port: { name: 'http' } } } }; + + willSave(spec, nestedPaths); + expect(spec.defaultBackend).toStrictEqual({ service: { name: 'my-svc', port: { name: 'http' } } }); + }); + + it('clears backend when service name is empty', () => { + const spec = { defaultBackend: { service: { name: '', port: { number: 80 } } } }; + + willSave(spec, nestedPaths); + expect(spec.defaultBackend).toBeNull(); + }); + + it('clears backend when both port paths are empty', () => { + const spec = { defaultBackend: { service: { name: 'my-svc', port: {} } } }; + + willSave(spec, nestedPaths); + expect(spec.defaultBackend).toBeNull(); + }); + + it('clears backend when port is undefined', () => { + const spec = { defaultBackend: { service: { name: 'my-svc' } } }; + + willSave(spec, nestedPaths); + expect(spec.defaultBackend).toBeNull(); + }); + + it('does nothing when there is no backend', () => { + const spec = { rules: [{}] } as any; + + willSave(spec, nestedPaths); + expect(spec).toStrictEqual({ rules: [{}] }); + }); + }); + + describe('defaultBackendNameRequired', () => { + it('returns error when service is selected but name is empty', () => { + expect(defaultBackendNameRequired('', true)).toBeDefined(); + }); + + it('passes when service is selected and name is set', () => { + expect(defaultBackendNameRequired('my-svc', true)).toBeUndefined(); + }); + + it('passes when no service is selected and name is empty', () => { + expect(defaultBackendNameRequired('', false)).toBeUndefined(); + }); + + it('passes when no service is selected and name is undefined', () => { + expect(defaultBackendNameRequired(undefined, false)).toBeUndefined(); + }); + }); + + describe('defaultBackendPortRequired', () => { + it('delegates to portRequired when service is selected', () => { + expect(defaultBackendPortRequired('', true)).toBeDefined(); + expect(defaultBackendPortRequired(80, true)).toBeUndefined(); + expect(defaultBackendPortRequired('http', true)).toBeUndefined(); + expect(defaultBackendPortRequired({ number: 80 }, true)).toBeUndefined(); + expect(defaultBackendPortRequired({ name: 'http' }, true)).toBeUndefined(); + expect(defaultBackendPortRequired({}, true)).toBeDefined(); + }); + + it('skips validation when no service is selected', () => { + expect(defaultBackendPortRequired('', false)).toBeUndefined(); + expect(defaultBackendPortRequired(undefined, false)).toBeUndefined(); + expect(defaultBackendPortRequired(null, false)).toBeUndefined(); + expect(defaultBackendPortRequired({}, false)).toBeUndefined(); + }); + }); });