Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
96d8a7c
chore: feat: add safety check
j-zimnowoda Apr 15, 2026
76c78e8
Merge branch 'main' into APL-1796
svcAPLBot Apr 15, 2026
470115c
Merge branch 'main' into APL-1796
svcAPLBot Apr 16, 2026
0945191
Merge branch 'main' into APL-1796
svcAPLBot Apr 16, 2026
c326f1b
Merge branch 'main' into APL-1796
svcAPLBot Apr 16, 2026
7423ccc
Merge branch 'main' into APL-1796
svcAPLBot Apr 16, 2026
2e83ba2
Merge branch 'main' into APL-1796
svcAPLBot Apr 16, 2026
5aac947
Merge branch 'main' into APL-1796
svcAPLBot Apr 17, 2026
6286c4e
Merge branch 'main' into APL-1796
svcAPLBot Apr 17, 2026
24cf5a9
Merge branch 'main' into APL-1796
svcAPLBot Apr 20, 2026
08f6206
Merge branch 'main' into APL-1796
svcAPLBot Apr 20, 2026
ec1f32a
Merge branch 'main' into APL-1796
svcAPLBot Apr 20, 2026
5045474
Merge branch 'main' into APL-1796
svcAPLBot Apr 20, 2026
dc13c97
Merge branch 'main' into APL-1796
svcAPLBot Apr 21, 2026
9652a9e
Merge branch 'main' into APL-1796
svcAPLBot Apr 21, 2026
19ffa42
Merge branch 'main' into APL-1796
svcAPLBot Apr 21, 2026
7c3b13c
Merge branch 'main' into APL-1796
svcAPLBot Apr 21, 2026
47433be
Merge branch 'main' into APL-1796
svcAPLBot Apr 21, 2026
5ab39cf
Merge branch 'main' into APL-1796
svcAPLBot Apr 21, 2026
a5e0434
Merge branch 'main' into APL-1796
svcAPLBot Apr 21, 2026
d305e91
Merge branch 'main' into APL-1796
svcAPLBot Apr 22, 2026
192542c
Merge branch 'main' into APL-1796
svcAPLBot Apr 23, 2026
50a2e10
Merge branch 'main' into APL-1796
svcAPLBot Apr 23, 2026
90627a5
Merge branch 'main' into APL-1796
svcAPLBot Apr 23, 2026
5324a12
Merge branch 'main' into APL-1796
svcAPLBot Apr 23, 2026
182198b
Merge branch 'main' into APL-1796
svcAPLBot Apr 23, 2026
25d5fb4
Merge branch 'main' into APL-1796
svcAPLBot Apr 24, 2026
85dd447
Merge branch 'main' into APL-1796
svcAPLBot Apr 24, 2026
6cece54
Merge branch 'main' into APL-1796
svcAPLBot Apr 24, 2026
794d74d
Merge branch 'main' into APL-1796
svcAPLBot Apr 24, 2026
ccc8da6
Merge branch 'main' into APL-1796
svcAPLBot Apr 28, 2026
95455b6
Merge branch 'main' into APL-1796
svcAPLBot Apr 29, 2026
95d2462
Merge branch 'main' into APL-1796
svcAPLBot Apr 29, 2026
54cf4ee
Merge branch 'main' into APL-1796
svcAPLBot Apr 29, 2026
af3bd7d
Merge branch 'main' into APL-1796
svcAPLBot Apr 29, 2026
f013855
Merge branch 'main' into APL-1796
svcAPLBot Apr 30, 2026
281f58e
Merge branch 'main' into APL-1796
svcAPLBot Apr 30, 2026
515576a
Merge branch 'main' into APL-1796
svcAPLBot Apr 30, 2026
9124d7f
Merge branch 'main' into APL-1796
svcAPLBot Apr 30, 2026
808567a
Merge branch 'main' into APL-1796
svcAPLBot Apr 30, 2026
d84c8ce
Merge branch 'main' into APL-1796
svcAPLBot May 1, 2026
6689bf5
Merge branch 'main' into APL-1796
svcAPLBot May 4, 2026
2097e3d
Merge branch 'main' into APL-1796
svcAPLBot May 6, 2026
efa5d58
Merge branch 'main' into APL-1796
svcAPLBot May 6, 2026
e5612d1
Merge branch 'main' into APL-1796
svcAPLBot May 6, 2026
4681b60
Merge branch 'main' into APL-1796
svcAPLBot May 6, 2026
17efc0d
Merge branch 'main' into APL-1796
svcAPLBot May 6, 2026
afeca34
Merge branch 'main' into APL-1796
svcAPLBot May 7, 2026
8060b68
Merge branch 'main' into APL-1796
svcAPLBot May 7, 2026
02e9449
Merge branch 'main' into APL-1796
svcAPLBot May 7, 2026
2a02a3c
Merge branch 'main' into APL-1796
svcAPLBot May 7, 2026
941211f
Merge branch 'main' into APL-1796
svcAPLBot May 7, 2026
4df7180
Merge branch 'main' into APL-1796
svcAPLBot May 7, 2026
93bba1e
Merge branch 'main' into APL-1796
svcAPLBot May 7, 2026
be3ebfa
Merge branch 'main' into APL-1796
svcAPLBot May 7, 2026
ff071ce
Merge branch 'main' into APL-1796
svcAPLBot May 8, 2026
062d71b
Merge branch 'main' into APL-1796
svcAPLBot May 8, 2026
a4c9920
Merge branch 'main' into APL-1796
svcAPLBot May 8, 2026
4f3645d
Merge branch 'main' into APL-1796
svcAPLBot May 8, 2026
3269feb
Merge branch 'main' into APL-1796
svcAPLBot May 8, 2026
e1a0c98
Merge branch 'main' into APL-1796
svcAPLBot May 8, 2026
00218f7
Merge branch 'main' into APL-1796
svcAPLBot May 8, 2026
9b19d13
Merge branch 'main' into APL-1796
svcAPLBot May 8, 2026
6a404f6
Merge branch 'main' into APL-1796
svcAPLBot May 11, 2026
7328c44
Merge branch 'main' into APL-1796
svcAPLBot May 11, 2026
b7ef715
Merge branch 'main' into APL-1796
svcAPLBot May 11, 2026
1f8348e
Merge branch 'main' into APL-1796
svcAPLBot May 11, 2026
bc5d4e0
Merge branch 'main' into APL-1796
svcAPLBot May 12, 2026
c94de7c
Merge branch 'main' into APL-1796
svcAPLBot May 12, 2026
af8f3f3
Merge branch 'main' into APL-1796
svcAPLBot May 12, 2026
83b69f5
Merge branch 'main' into APL-1796
svcAPLBot May 12, 2026
9d7020f
Merge branch 'main' into APL-1796
svcAPLBot May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/cmd/apply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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()
})
})
})
23 changes: 21 additions & 2 deletions src/cmd/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -64,6 +71,12 @@ export const applyAll = async (): Promise<void> => {
}
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')
}

Expand Down Expand Up @@ -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()
},
}
7 changes: 7 additions & 0 deletions src/cmd/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
applyServerSide,
createArgoCdRedisSecret,
createUpdateConfigMap,
ensureClusterIdentity,
getDeploymentState,
getHelmReleases,
getK8sConfigMap,
Expand Down Expand Up @@ -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<string, any>
if (clusterValues?.cluster?.name) {
await ensureClusterIdentity(clusterValues.cluster.name)
}
}
d.info('Installation completed')
}

Expand Down
1 change: 1 addition & 0 deletions src/common/constants.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
161 changes: 161 additions & 0 deletions src/common/k8s.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ import {
appRevisionMatches,
argoCdHasUnrecoverableErrors,
checkArgoCDAppStatus,
checkClusterIdentity,
deleteStatefulSetPods,
ensureClusterIdentity,
getSealedSecretsPEM,
patchArgoCdApp,
patchContainerResourcesOfSts,
Expand Down Expand Up @@ -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')
})
})
Loading
Loading