diff --git a/assets/translations/en-us.yaml b/assets/translations/en-us.yaml index 3deb54922f..caedc03cfb 100644 --- a/assets/translations/en-us.yaml +++ b/assets/translations/en-us.yaml @@ -1235,6 +1235,10 @@ validation: required: '"{key}" is required' requiredOrOverride: '"{key}" is required or must allow override' service: + clusterIp: + none: 'Service Type is Headless which requires Cluster IP to be set to "None"' + externalName: + none: 'External Name is required on an ExternalName Service.' ports: name: required: "Port Rule [{position}] - Name is required." diff --git a/models/service.js b/models/service.js index 914838a58f..f114210be6 100644 --- a/models/service.js +++ b/models/service.js @@ -58,11 +58,25 @@ export default { }, { nullable: false, - path: 'spec.ports', + path: 'spec', required: true, type: 'array', validators: ['servicePort'], - } + }, + { + nullable: true, + path: 'spec', + required: true, + type: 'string', + validators: ['clusterIp'], + }, + { + nullable: true, + path: 'spec', + required: true, + type: 'array', + validators: ['externalName'], + }, ]; }, diff --git a/utils/custom-validators.js b/utils/custom-validators.js index 7d5b32b974..7ea8852e42 100644 --- a/utils/custom-validators.js +++ b/utils/custom-validators.js @@ -1,9 +1,14 @@ import { flowOutput } from '@/utils/validators/flow-output'; -import { servicePort } from '@/utils/validators/service-port'; +import { clusterIp, externalName, servicePort } from '@/utils/validators/service'; /** * Custom validation functions beyond normal scalr types * Validator must export a function name should match the validator name on the customValidationRules rule * Exported function is used as a lookup key in resource-instance:validationErrors:customValidationRules loop */ -export default { flowOutput, servicePort }; +export default { + clusterIp, + externalName, + flowOutput, + servicePort, +}; diff --git a/utils/validators/service-port.js b/utils/validators/service-port.js deleted file mode 100644 index 0ab232f79f..0000000000 --- a/utils/validators/service-port.js +++ /dev/null @@ -1,71 +0,0 @@ -import isEmpty from 'lodash/isEmpty'; -import { validateDnsLabel } from '@/utils/validators'; - -export function servicePort(ports, getters, errors, validatorArgs) { - if (isEmpty(ports)) { - errors.push(getters['i18n/t']('validation.required', { key: 'Port Rules' })); - - return errors; - } - - ports.forEach((port, ind, ary) => { - const { - name, - nodePort, - port: pPort, - targetPort, - } = port; - const idx = ind + 1; - - if (ary.length > 1 && isEmpty(name)) { - errors.push(getters['i18n/t']('validation.service.ports.name.required', { position: idx })); - } - - if (nodePort) { - const np = parseInt(nodePort, 10); - - if (isNaN(np)) { - errors.push(getters['i18n/t']('validation.service.ports.nodePort.requiredInt', { position: idx })); - } - } - - if (pPort) { - const p = parseInt(pPort, 10); - - if (isNaN(p)) { - errors.push(getters['i18n/t']('validation.service.ports.port.requiredInt', { position: idx })); - } - } else { - errors.push(getters['i18n/t']('validation.service.ports.port.required', { position: idx })); - } - - if (targetPort) { - const tp = parseInt(targetPort, 10); - - if (isNaN(tp)) { - const tpIanaDisplayKey = getters['i18n/t']('validation.service.ports.targetPort.ianaAt', { position: idx }); - /* [rfc6335](https://tools.ietf.org/rfc/rfc6335.txt) port name (IANA_SVC_NAME) - An alphanumeric (a-z, and 0-9) string, with a maximum length of 15 characters, - with the '-' character allowed anywhere except the first or the last character or adjacent to another '-' character, - it must contain at least a(a - z) character - validateChars(str, { validChars: 'A-Za-z0-9_.-' }, displayKey, intl, errors); */ - const opts = { - ianaServiceName: true, - maxLength: 15, - validChars: 'A-Za-z0-9-', - }; - const isIanaServiceNameErrors = validateDnsLabel(targetPort, tpIanaDisplayKey, getters, opts, errors); - - if (!isEmpty(isIanaServiceNameErrors)) { - errors.push(...isIanaServiceNameErrors); - } - } else if (tp < 1 || tp > 65535) { - errors.push(getters['i18n/t']('validation.service.ports.targetPort.between', { position: idx })); - } - } else { - errors.push(getters['i18n/t']('validation.service.ports.targetPort.required', { position: idx })); - } - }); - - return errors; -} diff --git a/utils/validators/service.js b/utils/validators/service.js new file mode 100644 index 0000000000..6da9c5c53e --- /dev/null +++ b/utils/validators/service.js @@ -0,0 +1,136 @@ +import isEmpty from 'lodash/isEmpty'; +import { validateDnsLabel, validateHostname } from '@/utils/validators'; + +export function servicePort(spec, getters, errors, validatorArgs) { + const { ports, type: serviceType } = spec; + + if (serviceType === 'ExternalName') { + return errors; + } + + if (isEmpty(ports)) { + errors.push(getters['i18n/t']('validation.required', { key: 'Port Rules' })); + + return errors; + } + + ports.forEach((port, ind, ary) => { + const { + name, + nodePort, + port: pPort, + targetPort, + } = port; + const idx = ind + 1; + + if (ary.length > 1 && isEmpty(name)) { + errors.push(getters['i18n/t']('validation.service.ports.name.required', { position: idx })); + } + + if (!isEmpty(name)) { + const nameErrors = validateDnsLabel(name, 'name', getters, undefined, errors); + + if (!isEmpty(nameErrors)) { + if (errors.length && errors.length > 0) { + errors = [...errors, ...nameErrors]; + } else { + errors = nameErrors; + } + } + } + + if (nodePort) { + const np = parseInt(nodePort, 10); + + if (isNaN(np)) { + errors.push(getters['i18n/t']('validation.service.ports.nodePort.requiredInt', { position: idx })); + } + } + + if (pPort) { + const p = parseInt(pPort, 10); + + if (isNaN(p)) { + errors.push(getters['i18n/t']('validation.service.ports.port.requiredInt', { position: idx })); + } + } else { + errors.push(getters['i18n/t']('validation.service.ports.port.required', { position: idx })); + } + + if (targetPort) { + const tp = parseInt(targetPort, 10); + + if (isNaN(tp)) { + const tpIanaDisplayKey = getters['i18n/t']('validation.service.ports.targetPort.ianaAt', { position: idx }); + /* [rfc6335](https://tools.ietf.org/rfc/rfc6335.txt) port name (IANA_SVC_NAME) + An alphanumeric (a-z, and 0-9) string, with a maximum length of 15 characters, + with the '-' character allowed anywhere except the first or the last character or adjacent to another '-' character, + it must contain at least a(a - z) character + validateChars(str, { validChars: 'A-Za-z0-9_.-' }, displayKey, intl, errors); */ + const opts = { + ianaServiceName: true, + maxLength: 15, + validChars: 'A-Za-z0-9-', + }; + const isIanaServiceNameErrors = validateDnsLabel(targetPort, tpIanaDisplayKey, getters, opts, errors); + + if (!isEmpty(isIanaServiceNameErrors)) { + errors.push(...isIanaServiceNameErrors); + } + } else if (tp < 1 || tp > 65535) { + errors.push(getters['i18n/t']('validation.service.ports.targetPort.between', { position: idx })); + } + } else { + errors.push(getters['i18n/t']('validation.service.ports.targetPort.required', { position: idx })); + } + }); + + return errors; +} + +export function clusterIp(spec, getters, errors, validatorArgs) { + /* + clusterIP is the IP address of the service and is usually assigned randomly by the master. + If an address is specified manually and is not in use by others, it will be allocated to the service; otherwise, creation of the service will fail. + This field can not be changed through updates. + Valid values are \"None\", empty string (\"\"), or a valid IP address. \"None\" can be specified for headless services when proxying is not required. + Only applies to types ClusterIP, NodePort, and LoadBalancer. Ignored if type is ExternalName. + More info: https://kubernetes.io/docs/concepts/services-networking/service/#virtual-ips-and-service-proxies + */ + const typesToCheck = ['ClusterIP', 'NodePort', 'LoadBalancer']; + const serviceType = spec?.type; + + if (!typesToCheck.includes(serviceType)) { + // validation only applies to services in the types to check + return errors; + } + + if (serviceType === 'Headless' && spec?.clusterIp !== 'None') { + errors.push(getters['i18n/t']('validation.service.clusterIp.none')); + } + + return errors; +} + +export function externalName(spec, getters, errors, validatorArgs) { + /* + externalName is the external reference that kubedns or equivalent will return as a CNAME record for this service. + No proxying will be involved. + Must be a valid RFC-1123 hostname (https://tools.ietf.org/html/rfc1123) and requires Type to be ExternalName. + */ + if (spec?.type !== 'ExternalName' && isEmpty(spec?.externalName)) { + errors.push(getters['i18n/t']('validation.service.clusterIp.none')); + } else { + const hostNameErrors = validateHostname(spec.externalName, 'ExternalName', getters, undefined, errors); + + if (!isEmpty(hostNameErrors)) { + if (errors.length && errors.length > 0) { + errors = [...errors, ...hostNameErrors]; + } else { + errors = hostNameErrors; + } + } + } + + return errors; +}