diff --git a/package-lock.json b/package-lock.json index 163a77aaf..50e76e84a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "lightship": "6.7.2", "lodash": "4.17.23", "morgan": "1.10.1", + "semver": "^7.7.4", "simple-git": "3.33.0", "socket.io": "4.8.3", "swagger-ui-express": "5.0.1", @@ -63,6 +64,7 @@ "@types/jsonpath": "0.2.4", "@types/lodash": "4.17.24", "@types/node": "24.12.0", + "@types/semver": "^7.7.1", "@types/supertest": "7.2.0", "@typescript-eslint/eslint-plugin": "8.57.1", "@typescript-eslint/parser": "8.57.1", @@ -196,6 +198,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2655,6 +2658,7 @@ "integrity": "sha512-Tdfx4eH2uS+gv9V9NCr3Rz+c7RSS6ntXp3Blliud18ibRUlRxO9dTaOjG4iv4x0nAmMeedP1ORkEpeXSkh2QiQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=20" } @@ -2736,7 +2740,8 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.0.19.tgz", "integrity": "sha512-VYHtPnZt/Zd/ATbW3rtexWpBnHUohUrQOHff/2JBhsVgxOrksAxJnLAO43Q1ayLJBJUUwNVo+RU0sx0aaysZfg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-dart": { "version": "2.3.2", @@ -2876,14 +2881,16 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.14.tgz", "integrity": "sha512-2bf7n+kS92g+cMKV0wr9o/Oq9n8JzU7CcrB96gIh2GHgnF+0xDOqO2W/1KeFAqOfqosoOVE48t+4dnEMkkoJ2Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-html-symbol-entities": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@cspell/dict-html-symbol-entities/-/dict-html-symbol-entities-4.0.5.tgz", "integrity": "sha512-429alTD4cE0FIwpMucvSN35Ld87HCyuM8mF731KU5Rm4Je2SG6hmVx7nkBsLyrmH3sQukTcr1GaiZsiEg8svPA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-java": { "version": "5.0.12", @@ -3081,7 +3088,8 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-3.2.3.tgz", "integrity": "sha512-zXh1wYsNljQZfWWdSPYwQhpwiuW0KPW1dSd8idjMRvSD0aSvWWHoWlrMsmZeRl4qM4QCEAjua8+cjflm41cQBg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-vue": { "version": "3.0.5", @@ -4292,6 +4300,7 @@ "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "30.3.0", "@jest/expect": "30.3.0", @@ -4800,6 +4809,7 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -6283,7 +6293,8 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/json5": { "version": "0.0.29", @@ -6365,6 +6376,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -6411,6 +6423,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -6531,6 +6550,7 @@ "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/types": "8.57.1", @@ -7099,6 +7119,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8128,6 +8149,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -9370,6 +9392,7 @@ "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -10848,6 +10871,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10908,6 +10932,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -11034,6 +11059,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -11568,6 +11594,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -11654,6 +11681,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -14330,6 +14358,7 @@ "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", @@ -15208,6 +15237,7 @@ "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 10.16.0" } @@ -16581,6 +16611,7 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -19840,6 +19871,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -21097,6 +21129,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -22216,6 +22249,7 @@ "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -24361,6 +24395,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -24583,6 +24618,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -24847,6 +24883,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -25026,6 +25063,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -25458,6 +25496,7 @@ "version": "7.5.7", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", "integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==", + "peer": true, "engines": { "node": ">=8.3.0" }, diff --git a/package.json b/package.json index 40ae67756..0bf2a0df2 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,8 @@ "@linode/api-v4": "0.158.0", "@types/json-schema": "7.0.15", "@types/jsonwebtoken": "9.0.10", - "axios": "1.13.6", - "jose": "^6.2.2", "async-retry": "^1.3.3", + "axios": "1.13.6", "clean-deep": "3.4.0", "cors": "2.8.6", "debug": "4.4.3", @@ -29,12 +28,14 @@ "fs-extra": "11.3.4", "generate-password": "1.7.1", "glob": "13.0.6", + "jose": "^6.2.2", "jsonpath": "1.3.0", "jsonwebtoken": "9.0.3", "jwt-decode": "4.0.0", "lightship": "6.7.2", "lodash": "4.17.23", "morgan": "1.10.1", + "semver": "^7.7.4", "simple-git": "3.33.0", "socket.io": "4.8.3", "swagger-ui-express": "5.0.1", @@ -53,8 +54,8 @@ "@eslint/compat": "2.0.3", "@redocly/openapi-cli": "1.0.0-beta.95", "@semantic-release/changelog": "6.0.3", - "@types/async-retry": "^1.4.8", "@semantic-release/git": "10.0.1", + "@types/async-retry": "^1.4.8", "@types/debug": "^4.1.13", "@types/expect": "24.3.2", "@types/express": "^5.0.6", @@ -63,6 +64,7 @@ "@types/jsonpath": "0.2.4", "@types/lodash": "4.17.24", "@types/node": "24.12.0", + "@types/semver": "^7.7.1", "@types/supertest": "7.2.0", "@typescript-eslint/eslint-plugin": "8.57.1", "@typescript-eslint/parser": "8.57.1", diff --git a/src/api/v1/apps.ts b/src/api/v1/apps.ts index 0685f562f..85604d6a0 100644 --- a/src/api/v1/apps.ts +++ b/src/api/v1/apps.ts @@ -9,7 +9,7 @@ const debug = Debug('otomi:api:v1:apps') * Get all apps across all teams * Returns list of all apps with their ids and enabled status */ -export const getApps = (req: OpenApiRequestExt, res: Response): void => { +export const getApps = async (req: OpenApiRequestExt, res: Response): Promise => { debug('getAllApps') - res.json(req.otomi.getApps()) + res.json(await req.otomi.getApps()) } diff --git a/src/api/v1/apps/{teamId}.ts b/src/api/v1/apps/{teamId}.ts index 1fd7d3f2d..ba268d12f 100644 --- a/src/api/v1/apps/{teamId}.ts +++ b/src/api/v1/apps/{teamId}.ts @@ -9,10 +9,10 @@ const debug = Debug('otomi:api:v1:apps') * Get apps for a team * Returns list of team apps with their ids and enabled status */ -export const getTeamApps = (req: OpenApiRequestExt, res: Response): void => { +export const getTeamApps = async (req: OpenApiRequestExt, res: Response): Promise => { const { teamId } = req.params debug('getTeamApps', teamId) - res.json(req.otomi.getTeamApps(teamId)) + res.json(await req.otomi.getTeamApps(teamId)) } /** diff --git a/src/api/v1/apps/{teamId}/{appId}.ts b/src/api/v1/apps/{teamId}/{appId}.ts index c8c52b582..5487dab9f 100644 --- a/src/api/v1/apps/{teamId}/{appId}.ts +++ b/src/api/v1/apps/{teamId}/{appId}.ts @@ -5,9 +5,9 @@ import { App, OpenApiRequestExt } from 'src/otomi-models' * GET /v1/apps/{teamId}/{appId} * Get a specific team app */ -export const getTeamApp = (req: OpenApiRequestExt, res: Response): void => { +export const getTeamApp = async (req: OpenApiRequestExt, res: Response): Promise => { const { teamId, appId } = req.params - res.json(req.otomi.getTeamApp(teamId, appId)) + res.json(await req.otomi.getTeamApp(teamId, appId)) } /** diff --git a/src/k8s-operations.ts b/src/k8s-operations.ts index 3bc9ce49b..1df48c62a 100644 --- a/src/k8s-operations.ts +++ b/src/k8s-operations.ts @@ -51,8 +51,11 @@ export async function checkPodExists(namespace: string, podName: string): Promis } } +let cachedKubernetesVersion: string | undefined + export async function getKubernetesVersion() { if (process.env.NODE_ENV === 'development') return 'x.x.x' + if (cachedKubernetesVersion) return cachedKubernetesVersion const kc = new KubeConfig() kc.loadFromDefault() @@ -62,7 +65,8 @@ export async function getKubernetesVersion() { try { const response = await k8sApi.getCode() console.log('Kubernetes Server Version:', response.gitVersion) - return response.gitVersion + cachedKubernetesVersion = response.gitVersion + return cachedKubernetesVersion } catch (error) { debug(`Failed to get Kubernetes version.`) } diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index b968f27dd..2d280306d 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -135,6 +135,7 @@ import { testPublicRepoConnect, } from './utils/codeRepoUtils' import { getAplObjectFromV1, getV1MergeObject, getV1ObjectFromApl } from './utils/manifests' +import { isKnativeSupported } from './utils/k8sUtils' import { ensureSealedSecretMetadata, getSealedSecretsPEM, sealedSecretManifest } from './utils/sealedSecretUtils' import { getKeycloakUsers, isValidUsername } from './utils/userUtils' import { defineClusterId, ObjectStorageClient } from './utils/wizardUtils' @@ -561,30 +562,37 @@ export default class OtomiStack { return settings } - filterExcludedApp(apps: App | App[]) { + filterExcludedApp(apps: App | App[], k8sVersion?: string) { const preInstalledExcludedApps = env.PREINSTALLED_EXCLUDED_APPS.apps const hiddenApps = env.HIDDEN_APPS.apps const excludedApps = preInstalledExcludedApps.concat(hiddenApps) const settingsInfo = this.getSettingsInfo() if (!Array.isArray(apps)) { + if (k8sVersion && !isKnativeSupported(k8sVersion) && apps.id === 'knative') { + // eslint-disable-next-line no-param-reassign + ;(apps as ExcludedApp).managed = true + return apps as ExcludedApp + } if (settingsInfo.otomi && settingsInfo.otomi.isPreInstalled && excludedApps.includes(apps.id)) { // eslint-disable-next-line no-param-reassign ;(apps as ExcludedApp).managed = true return apps as ExcludedApp } } else if (Array.isArray(apps)) { + let filtered = k8sVersion && !isKnativeSupported(k8sVersion) ? apps.filter((app) => app.id !== 'knative') : apps if (settingsInfo.otomi && settingsInfo.otomi.isPreInstalled) { - return apps.filter((app) => !excludedApps.includes(app.id)) + return filtered.filter((app) => !excludedApps.includes(app.id)) } else { - return apps + return filtered } } return apps } - getTeamApp(teamId: string, id: string): App | ExcludedApp { + async getTeamApp(teamId: string, id: string): Promise { + const k8sVersion = (await getKubernetesVersion()) as string | undefined const app = this.getApp(id) - this.filterExcludedApp(app) + this.filterExcludedApp(app, k8sVersion) if (teamId === 'admin') return app return { id: app.id, enabled: app.enabled } @@ -601,14 +609,15 @@ export default class OtomiStack { return { values: content.spec, id: content.metadata.name } as App } - getApps(): Array { + async getApps(): Promise> { const appList = this.getAppList() + const k8sVersion = (await getKubernetesVersion()) as string | undefined const allApps = appList.map((id) => { return this.getApp(id) }) - const providerSpecificApps = this.filterExcludedApp(allApps) as App[] + const providerSpecificApps = this.filterExcludedApp(allApps, k8sVersion) as App[] return providerSpecificApps.map((app) => { return { @@ -618,8 +627,8 @@ export default class OtomiStack { }) } - getTeamApps(teamId: string): Array { - const allApps = this.getApps() + async getTeamApps(teamId: string): Promise> { + const allApps = await this.getApps() if (teamId === 'admin') return allApps diff --git a/src/utils/k8sUtils.test.ts b/src/utils/k8sUtils.test.ts new file mode 100644 index 000000000..4c3046529 --- /dev/null +++ b/src/utils/k8sUtils.test.ts @@ -0,0 +1,37 @@ +import { isK8sVersionAtLeast, isKnativeSupported } from './k8sUtils' + +describe('isK8sVersionAtLeast', () => { + it('returns true when version meets the minimum', () => { + expect(isK8sVersionAtLeast('v1.33.0', '1.33.0')).toBe(true) + }) + + it('returns true when version exceeds the minimum', () => { + expect(isK8sVersionAtLeast('v1.35.3', '1.33.0')).toBe(true) + }) + + it('returns false when version is below the minimum', () => { + expect(isK8sVersionAtLeast('v1.32.9', '1.33.0')).toBe(false) + }) + + it('handles versions without the v prefix', () => { + expect(isK8sVersionAtLeast('1.33.0', '1.33.0')).toBe(true) + }) + + it('returns false for unparseable version strings', () => { + expect(isK8sVersionAtLeast('x.x.x', '1.33.0')).toBe(false) + }) +}) + +describe('isKnativeSupported', () => { + it('returns true for Kubernetes 1.33', () => { + expect(isKnativeSupported('v1.33.0')).toBe(true) + }) + + it('returns true for Kubernetes above 1.33', () => { + expect(isKnativeSupported('v1.35.3')).toBe(true) + }) + + it('returns false for Kubernetes below 1.33', () => { + expect(isKnativeSupported('v1.32.0')).toBe(false) + }) +}) diff --git a/src/utils/k8sUtils.ts b/src/utils/k8sUtils.ts new file mode 100644 index 000000000..e576474e7 --- /dev/null +++ b/src/utils/k8sUtils.ts @@ -0,0 +1,16 @@ +import semver from 'semver' +import { cleanEnv, MIN_KNATIVE_K8S_VERSION } from '../validators' + +const env = cleanEnv({ + MIN_KNATIVE_K8S_VERSION, +}) + +export function isK8sVersionAtLeast(k8sVersion: string, minVersion: string): boolean { + const coerced = semver.coerce(k8sVersion) + if (!coerced) return false + return semver.gte(coerced, minVersion) +} + +export function isKnativeSupported(k8sVersion: string): boolean { + return isK8sVersionAtLeast(k8sVersion, env.MIN_KNATIVE_K8S_VERSION) +} diff --git a/src/validators.ts b/src/validators.ts index 35b90a654..e35ef0edf 100644 --- a/src/validators.ts +++ b/src/validators.ts @@ -185,6 +185,10 @@ export const OBJECT_STORAGE_UI_EXCLUSIONS = json({ desc: 'Object Storage regions hidden in the UI', default: ['fr-par-2', 'in-bom-2'], }) +export const MIN_KNATIVE_K8S_VERSION = str({ + desc: 'Minimum Kubernetes version required for Knative support', + default: '1.33.0', +}) const { env } = process export function cleanEnv(validators: { [K in keyof T]: ValidatorSpec }, options: CleanOptions = {}) { if (env.NODE_ENV === 'test') {