From 96d8a7c088847da388b68026ec43bf0de50c4ae3 Mon Sep 17 00:00:00 2001 From: Jehoszafat Zimnowoda <17126497+j-zimnowoda@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:26:40 +0200 Subject: [PATCH] chore: feat: add safety check --- package-lock.json | 37 ++------- src/cmd/apply.test.ts | 50 +++++++++++++ src/cmd/apply.ts | 23 +++++- src/cmd/install.ts | 7 ++ src/common/constants.ts | 1 + src/common/k8s.test.ts | 161 ++++++++++++++++++++++++++++++++++++++++ src/common/k8s.ts | 61 +++++++++++++++ 7 files changed, 306 insertions(+), 34 deletions(-) diff --git a/package-lock.json b/package-lock.json index fe10d9a87f..ea54dd42b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -200,7 +200,6 @@ "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", @@ -2492,7 +2491,6 @@ "integrity": "sha512-IQA++Idqb8fZzkCbHq3+T+9yG9WpeaBxomOrG2KcR/Pj0CgnovzuApYKL2cc35UWLePboKinMeqEPiweFpHVug==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=22.18.0" } @@ -2574,8 +2572,7 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.1.1.tgz", "integrity": "sha512-y/Vgo6qY08e1t9OqR56qjoFLBCpi4QfWMf2qzD1l9omRZwvSMQGRPz4x0bxkkkU4oocMAeztjzCsmLew//c/8w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@cspell/dict-dart": { "version": "2.3.2", @@ -2715,16 +2712,14 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.15.tgz", "integrity": "sha512-GJYnYKoD9fmo2OI0aySEGZOjThnx3upSUvV7mmqUu8oG+mGgzqm82P/f7OqsuvTaInZZwZbo+PwJQd/yHcyFIw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "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", - "peer": true + "license": "MIT" }, "node_modules/@cspell/dict-java": { "version": "5.0.12", @@ -2922,8 +2917,7 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-3.2.3.tgz", "integrity": "sha512-zXh1wYsNljQZfWWdSPYwQhpwiuW0KPW1dSd8idjMRvSD0aSvWWHoWlrMsmZeRl4qM4QCEAjua8+cjflm41cQBg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@cspell/dict-vue": { "version": "3.0.5", @@ -4929,7 +4923,6 @@ "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", @@ -6537,8 +6530,7 @@ "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", - "peer": true + "license": "MIT" }, "node_modules/@types/json5": { "version": "0.0.29", @@ -6604,7 +6596,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -6804,7 +6795,6 @@ "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", @@ -7329,7 +7319,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8211,7 +8200,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -10381,7 +10369,6 @@ "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -11775,7 +11762,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11836,7 +11822,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -11971,7 +11956,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -15383,7 +15367,6 @@ "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", @@ -17071,7 +17054,6 @@ "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" } @@ -18127,7 +18109,6 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -21206,7 +21187,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -22228,7 +22208,6 @@ "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -23230,7 +23209,6 @@ "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", @@ -25035,7 +25013,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -25239,7 +25216,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -25537,7 +25513,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -25717,7 +25692,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.2.4" }, @@ -26220,7 +26194,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/src/cmd/apply.test.ts b/src/cmd/apply.test.ts index f368d0a66d..62121a8e08 100644 --- a/src/cmd/apply.test.ts +++ b/src/cmd/apply.test.ts @@ -11,7 +11,9 @@ jest.mock('fs', () => ({ })) jest.mock('src/common/k8s', () => ({ + checkClusterIdentity: jest.fn(), deletePendingHelmReleases: jest.fn(), + ensureClusterIdentity: jest.fn(), getDeploymentState: jest.fn(), setDeploymentState: jest.fn(), restartOtomiApiDeployment: jest.fn(), @@ -47,6 +49,10 @@ jest.mock('src/common/runtime-upgrade', () => ({ runtimeUpgrade: jest.fn(), })) +jest.mock('src/common/hf', () => ({ + hfValues: jest.fn(), +})) + jest.mock('src/common/cli', () => ({ cleanupHandler: jest.fn(), prepareEnvironment: jest.fn(), @@ -73,6 +79,7 @@ jest.mock('src/common/envalid', () => ({ DISABLE_SYNC: false, ENV_DIR: '/test/env', }, + isCli: true, })) // Import the actual functions to test (after mocks are set up) @@ -91,8 +98,11 @@ describe('Apply command', () => { mockDeps = { getDeploymentState: require('src/common/k8s').getDeploymentState, setDeploymentState: require('src/common/k8s').setDeploymentState, + ensureClusterIdentity: require('src/common/k8s').ensureClusterIdentity, + checkClusterIdentity: require('src/common/k8s').checkClusterIdentity, getImageTagFromValues: require('src/common/values').getImageTagFromValues, getPackageVersion: require('src/common/values').getPackageVersion, + hfValues: require('src/common/hf').hfValues, applyAsApps: require('./apply-as-apps').applyAsApps, applyGitOpsApps: require('./apply-as-apps').applyGitOpsApps, updateOperatorApplication: require('./apply-as-apps').updateOperatorApplication, @@ -107,6 +117,9 @@ describe('Apply command', () => { mockDeps.getDeploymentState.mockResolvedValue({ status: 'deployed', deployingVersion: '1.0.0' }) mockDeps.getImageTagFromValues.mockResolvedValue('v1.0.0') mockDeps.getPackageVersion.mockReturnValue('1.0.0') + mockDeps.hfValues.mockResolvedValue({ cluster: { name: 'test-cluster' } }) + mockDeps.ensureClusterIdentity.mockResolvedValue(undefined) + mockDeps.checkClusterIdentity.mockResolvedValue(undefined) mockDeps.applyAsApps.mockResolvedValue(true) mockDeps.applyGitOpsApps.mockResolvedValue(undefined) mockDeps.updateOperatorApplication.mockResolvedValue(false) @@ -459,4 +472,41 @@ describe('Apply command', () => { process.env.DISABLE_SYNC = originalEnv }) }) + + describe('cluster identity', () => { + test('should call ensureClusterIdentity after successful applyAll when sync is enabled', async () => { + const { env } = require('src/common/envalid') + env.isDev = false + env.DISABLE_SYNC = false + + await applyAll() + + expect(mockDeps.hfValues).toHaveBeenCalled() + expect(mockDeps.ensureClusterIdentity).toHaveBeenCalledWith('test-cluster') + }) + + test('should skip ensureClusterIdentity when isDev and DISABLE_SYNC are both true', async () => { + const { env } = require('src/common/envalid') + env.isDev = true + env.DISABLE_SYNC = true + + await applyAll() + + expect(mockDeps.ensureClusterIdentity).not.toHaveBeenCalled() + + env.isDev = false + env.DISABLE_SYNC = false + }) + + test('should not call ensureClusterIdentity when hfValues returns no cluster name', async () => { + const { env } = require('src/common/envalid') + env.isDev = false + env.DISABLE_SYNC = false + mockDeps.hfValues.mockResolvedValueOnce({ cluster: {} }) + + await applyAll() + + expect(mockDeps.ensureClusterIdentity).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/cmd/apply.ts b/src/cmd/apply.ts index f54d4ed439..cfcfe03ee0 100644 --- a/src/cmd/apply.ts +++ b/src/cmd/apply.ts @@ -3,8 +3,15 @@ import { mkdirSync, rmSync } from 'fs' import { cloneDeep } from 'lodash' import { cleanupHandler, prepareEnvironment } from 'src/common/cli' import { terminal } from 'src/common/debug' -import { env } from 'src/common/envalid' -import { deletePendingHelmReleases, getDeploymentState, setDeploymentState } from 'src/common/k8s' +import { env, isCli } from 'src/common/envalid' +import { hfValues } from 'src/common/hf' +import { + checkClusterIdentity, + deletePendingHelmReleases, + ensureClusterIdentity, + getDeploymentState, + setDeploymentState, +} from 'src/common/k8s' import { getFilename, rootDir } from 'src/common/utils' import { getImageTagFromValues, getPackageVersion } from 'src/common/values' import { getParsedArgs, HelmArguments, helmOptions, setParsedArgs } from 'src/common/yargs' @@ -64,6 +71,12 @@ export const applyAll = async (): Promise => { } await applyGitOpsApps() await setDeploymentState({ status: 'deployed', version: deployingVersion }) + if (!(env.isDev && env.DISABLE_SYNC)) { + const values = await hfValues() + if (values?.cluster?.name) { + await ensureClusterIdentity(values.cluster.name) + } + } d.info('Deployment completed') } @@ -103,6 +116,12 @@ export const module: CommandModule = { setParsedArgs(argv) setup() await prepareEnvironment() + if (isCli && !argv.nonInteractive && !(env.isDev && env.DISABLE_SYNC)) { + const values = await hfValues() + if (values?.cluster?.name) { + await checkClusterIdentity(values.cluster.name) + } + } await apply() }, } diff --git a/src/cmd/install.ts b/src/cmd/install.ts index b0bd73b130..465bef4867 100644 --- a/src/cmd/install.ts +++ b/src/cmd/install.ts @@ -9,6 +9,7 @@ import { deployEssential, hf, HF_DEFAULT_SYNC_ON_INITIAL_INSTALL_ARGS, hfValues import { applyServerSide, createUpdateConfigMap, + ensureClusterIdentity, getDeploymentState, getHelmReleases, getK8sConfigMap, @@ -137,6 +138,12 @@ export const installAll = async () => { await retryInstallStep(createWelcomeConfigMap, initialData.secretName, initialData.domainSuffix) } await setDeploymentState({ status: 'deployed', version }) + if (!(env.isDev && env.DISABLE_SYNC)) { + const clusterValues = (await hfValues()) as Record + if (clusterValues?.cluster?.name) { + await ensureClusterIdentity(clusterValues.cluster.name) + } + } d.info('Installation completed') } diff --git a/src/common/constants.ts b/src/common/constants.ts index 92b3719619..81bb9c87d6 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -1,5 +1,6 @@ export const DEPLOYMENT_PASSWORDS_SECRET = 'otomi-generated-passwords' export const DEPLOYMENT_STATUS_CONFIGMAP = 'otomi-status' +export const CLUSTER_IDENTITY_CONFIGMAP = 'apl-cluster-identity' export const APL_OPERATOR_NS = 'apl-operator' export const APL_OPERATOR_STATUS_CM = 'apl-installation-status' export const ARGOCD_APP_PARAMS = { diff --git a/src/common/k8s.test.ts b/src/common/k8s.test.ts index bc35c28693..9822d555e3 100644 --- a/src/common/k8s.test.ts +++ b/src/common/k8s.test.ts @@ -23,7 +23,9 @@ import * as k8s from './k8s' import { appRevisionMatches, checkArgoCDAppStatus, + checkClusterIdentity, deleteStatefulSetPods, + ensureClusterIdentity, getSealedSecretsPEM, patchArgoCdApp, patchContainerResourcesOfSts, @@ -1007,3 +1009,162 @@ describe('getSealedSecretsPEM', () => { expect(MockX509Certificate).toHaveBeenCalledWith('single-cert') }) }) + +describe('checkClusterIdentity', () => { + const mockTerminal = terminal('test:checkClusterIdentity') + let mockGetK8sConfigMap: jest.Mock + let mockK8s: { core: jest.Mock } + + beforeEach(() => { + jest.clearAllMocks() + mockGetK8sConfigMap = jest.fn() + mockK8s = { core: jest.fn().mockReturnValue({}) } + }) + + it('should pass when configmap does not exist (first time)', async () => { + mockGetK8sConfigMap.mockResolvedValue(undefined) + + await expect( + checkClusterIdentity('my-cluster', { + getK8sConfigMap: mockGetK8sConfigMap, + k8s: mockK8s as any, + terminal: () => mockTerminal, + }), + ).resolves.not.toThrow() + + expect(mockGetK8sConfigMap).toHaveBeenCalledWith('otomi', 'apl-cluster-identity', {}) + }) + + it('should pass when cluster names match', async () => { + mockGetK8sConfigMap.mockResolvedValue({ data: { clusterName: 'my-cluster' } }) + + await expect( + checkClusterIdentity('my-cluster', { + getK8sConfigMap: mockGetK8sConfigMap, + k8s: mockK8s as any, + terminal: () => mockTerminal, + }), + ).resolves.not.toThrow() + }) + + it('should throw when cluster names do not match', async () => { + mockGetK8sConfigMap.mockResolvedValue({ data: { clusterName: 'production-cluster' } }) + + await expect( + checkClusterIdentity('staging-cluster', { + getK8sConfigMap: mockGetK8sConfigMap, + k8s: mockK8s as any, + terminal: () => mockTerminal, + }), + ).rejects.toThrow('ABORT: Cluster identity mismatch') + }) + + it('should include both cluster names in error message', async () => { + mockGetK8sConfigMap.mockResolvedValue({ data: { clusterName: 'production-cluster' } }) + + await expect( + checkClusterIdentity('staging-cluster', { + getK8sConfigMap: mockGetK8sConfigMap, + k8s: mockK8s as any, + terminal: () => mockTerminal, + }), + ).rejects.toThrow(/production-cluster.*staging-cluster/) + }) + + it('should pass when configmap exists but has no clusterName data', async () => { + mockGetK8sConfigMap.mockResolvedValue({ data: {} }) + + await expect( + checkClusterIdentity('my-cluster', { + getK8sConfigMap: mockGetK8sConfigMap, + k8s: mockK8s as any, + terminal: () => mockTerminal, + }), + ).resolves.not.toThrow() + }) + + it('should propagate k8s API errors', async () => { + mockGetK8sConfigMap.mockRejectedValue(new Error('Connection refused')) + + await expect( + checkClusterIdentity('my-cluster', { + getK8sConfigMap: mockGetK8sConfigMap, + k8s: mockK8s as any, + terminal: () => mockTerminal, + }), + ).rejects.toThrow('Connection refused') + }) +}) + +describe('ensureClusterIdentity', () => { + const mockTerminal = terminal('test:ensureClusterIdentity') + let mockGetK8sConfigMap: jest.Mock + let mockCreateK8sConfigMap: jest.Mock + let mockK8s: { core: jest.Mock } + + beforeEach(() => { + jest.clearAllMocks() + mockGetK8sConfigMap = jest.fn() + mockCreateK8sConfigMap = jest.fn() + mockK8s = { core: jest.fn().mockReturnValue({}) } + }) + + it('should create configmap when it does not exist', async () => { + mockGetK8sConfigMap.mockResolvedValue(undefined) + mockCreateK8sConfigMap.mockResolvedValue({}) + + await ensureClusterIdentity('my-cluster', { + getK8sConfigMap: mockGetK8sConfigMap, + createK8sConfigMap: mockCreateK8sConfigMap, + k8s: mockK8s as any, + terminal: () => mockTerminal, + }) + + expect(mockCreateK8sConfigMap).toHaveBeenCalledWith( + 'otomi', + 'apl-cluster-identity', + { clusterName: 'my-cluster' }, + {}, + ) + }) + + it('should not update configmap when it already exists', async () => { + mockGetK8sConfigMap.mockResolvedValue({ data: { clusterName: 'existing-cluster' } }) + + await ensureClusterIdentity('my-cluster', { + getK8sConfigMap: mockGetK8sConfigMap, + createK8sConfigMap: mockCreateK8sConfigMap, + k8s: mockK8s as any, + terminal: () => mockTerminal, + }) + + expect(mockCreateK8sConfigMap).not.toHaveBeenCalled() + }) + + it('should propagate k8s API errors on read', async () => { + mockGetK8sConfigMap.mockRejectedValue(new Error('Forbidden')) + + await expect( + ensureClusterIdentity('my-cluster', { + getK8sConfigMap: mockGetK8sConfigMap, + createK8sConfigMap: mockCreateK8sConfigMap, + k8s: mockK8s as any, + terminal: () => mockTerminal, + }), + ).rejects.toThrow('Forbidden') + }) + + it('should propagate k8s API errors on create', async () => { + mockGetK8sConfigMap.mockResolvedValue(undefined) + mockCreateK8sConfigMap.mockRejectedValue(new Error('Namespace not found')) + + await expect( + ensureClusterIdentity('my-cluster', { + getK8sConfigMap: mockGetK8sConfigMap, + createK8sConfigMap: mockCreateK8sConfigMap, + k8s: mockK8s as any, + terminal: () => mockTerminal, + }), + ).rejects.toThrow('Namespace not found') + }) +}) diff --git a/src/common/k8s.ts b/src/common/k8s.ts index e701de4f7b..5c7732567c 100644 --- a/src/common/k8s.ts +++ b/src/common/k8s.ts @@ -28,6 +28,7 @@ import { $, sleep } from 'zx' import { ARGOCD_APP_DEFAULT_SYNC_POLICY, ARGOCD_APP_PARAMS, + CLUSTER_IDENTITY_CONFIGMAP, DEPLOYMENT_PASSWORDS_SECRET, DEPLOYMENT_STATUS_CONFIGMAP, } from './constants' @@ -457,6 +458,66 @@ export const checkKubeContext = async (): Promise => { } } +const CLUSTER_IDENTITY_NS = 'otomi' + +/** + * Check whether the cluster.name from ENV_DIR matches the cluster identity stored on the cluster. + * This prevents accidentally applying configuration meant for a different cluster. + * Only runs in interactive (CLI) mode. If the configmap doesn't exist yet, the check is skipped. + */ +export const checkClusterIdentity = async ( + clusterName: string, + deps = { getK8sConfigMap, k8s, terminal }, +): Promise => { + const d = deps.terminal('common:k8s:checkClusterIdentity') + d.info('Validating cluster identity') + + const configMap = await deps.getK8sConfigMap(CLUSTER_IDENTITY_NS, CLUSTER_IDENTITY_CONFIGMAP, deps.k8s.core()) + if (!configMap) { + d.info('No cluster identity configmap found, skipping identity check (will be created after successful apply)') + return + } + + const storedClusterName = configMap.data?.clusterName + if (!storedClusterName) { + d.warn('Cluster identity configmap exists but has no clusterName data, skipping check') + return + } + + if (storedClusterName !== clusterName) { + throw new Error( + `ABORT: Cluster identity mismatch! The cluster has identity "${storedClusterName}" ` + + `but the current ENV_DIR configuration has cluster.name="${clusterName}". ` + + `This likely means you are targeting the wrong cluster. ` + + `If you intentionally renamed the cluster, delete the "${CLUSTER_IDENTITY_CONFIGMAP}" ` + + `configmap in the "${CLUSTER_IDENTITY_NS}" namespace and retry.`, + ) + } + + d.info(`Cluster identity verified: "${clusterName}"`) +} + +/** + * Ensure the cluster identity configmap exists on the cluster. + * Creates it with the current cluster.name if it doesn't exist. Never updates an existing one. + */ +export const ensureClusterIdentity = async ( + clusterName: string, + deps = { getK8sConfigMap, createK8sConfigMap, k8s, terminal }, +): Promise => { + const d = deps.terminal('common:k8s:ensureClusterIdentity') + + const existing = await deps.getK8sConfigMap(CLUSTER_IDENTITY_NS, CLUSTER_IDENTITY_CONFIGMAP, deps.k8s.core()) + if (existing) { + d.debug('Cluster identity configmap already exists, not updating') + return + } + + d.info(`Creating cluster identity configmap with clusterName="${clusterName}"`) + await deps.createK8sConfigMap(CLUSTER_IDENTITY_NS, CLUSTER_IDENTITY_CONFIGMAP, { clusterName }, deps.k8s.core()) + d.info('Cluster identity configmap created successfully') +} + type WaitTillAvailableOptions = Options & { status?: number skipSsl?: boolean