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 253260a2aa..62041c5de6 100644 --- a/src/cmd/install.ts +++ b/src/cmd/install.ts @@ -10,6 +10,7 @@ import { applyServerSide, createArgoCdRedisSecret, createUpdateConfigMap, + ensureClusterIdentity, getDeploymentState, getHelmReleases, getK8sConfigMap, @@ -303,6 +304,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 b48480b27f..66f49e2523 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 OTOMI_NAMESPACE = 'otomi' diff --git a/src/common/k8s.test.ts b/src/common/k8s.test.ts index db589371c4..b5320df90a 100644 --- a/src/common/k8s.test.ts +++ b/src/common/k8s.test.ts @@ -24,7 +24,9 @@ import { appRevisionMatches, argoCdHasUnrecoverableErrors, checkArgoCDAppStatus, + checkClusterIdentity, deleteStatefulSetPods, + ensureClusterIdentity, getSealedSecretsPEM, patchArgoCdApp, patchContainerResourcesOfSts, @@ -1117,3 +1119,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 cbd70b2a28..de6d6f3887 100644 --- a/src/common/k8s.ts +++ b/src/common/k8s.ts @@ -29,6 +29,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' @@ -466,6 +467,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