From 7cc0dea2119c3e195e47a4e53339d58612284c14 Mon Sep 17 00:00:00 2001 From: Jehoszafat Zimnowoda <17126497+j-zimnowoda@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:09:45 +0200 Subject: [PATCH 1/5] feat: strip the last applied anotation --- src/cmd/apply-as-apps.test.ts | 136 ++++++++++++++++++++++++++++++++-- src/cmd/apply-as-apps.ts | 73 ++++++++++++++++-- 2 files changed, 198 insertions(+), 11 deletions(-) diff --git a/src/cmd/apply-as-apps.test.ts b/src/cmd/apply-as-apps.test.ts index a18ba3ce36..287c468e08 100644 --- a/src/cmd/apply-as-apps.test.ts +++ b/src/cmd/apply-as-apps.test.ts @@ -1,19 +1,22 @@ +import { statSync } from 'fs' +import { glob } from 'glob' +import { ARGOCD_APP_PARAMS } from '../common/constants' +import { env } from '../common/envalid' +import { getNames } from '../common/utils' import { addGitOpsApps, applyArgocdApp, applyGitOpsApps, ArgocdAppManifest, calculateGitOpsAppsSyncState, + checkArgoCdController, getApplications, + getArgocdCoreAppManifest, getArgocdGitopsManifest, - checkArgoCdController, + mergeSyncOptions, removeGitOpsApps, + stripOversizedLastAppliedAnnotations, } from './apply-as-apps' -import { glob } from 'glob' -import { env } from '../common/envalid' -import { statSync } from 'fs' -import { ARGOCD_APP_PARAMS } from '../common/constants' -import { getNames } from '../common/utils' jest.mock('glob') jest.mock('fs', () => ({ @@ -687,3 +690,124 @@ describe('checkArgoCdController', () => { expect(mockRestartStatefulSet).not.toHaveBeenCalled() }) }) + +describe('mergeSyncOptions', () => { + it('returns base options unchanged when patch provides none', () => { + expect(mergeSyncOptions(['ServerSideApply=true'])).toEqual(['ServerSideApply=true']) + }) + + it('preserves ServerSideApply=true when patch provides only different options', () => { + const result = mergeSyncOptions(['ServerSideApply=true'], ['CreateNamespace=true']) + + expect(result).toContain('ServerSideApply=true') + expect(result).toContain('CreateNamespace=true') + }) + + it('deduplicates options that appear in both base and patch', () => { + const result = mergeSyncOptions(['ServerSideApply=true'], ['ServerSideApply=true', 'CreateNamespace=true']) + + expect(result.filter((o) => o === 'ServerSideApply=true')).toHaveLength(1) + }) +}) + +describe('getArgocdCoreAppManifest', () => { + const release = { + name: 'kyverno', + namespace: 'kyverno', + enabled: true, + installed: true, + labels: '', + chart: '../charts/kyverno', + version: '1.0.0', + } + + beforeEach(() => { + jest.clearAllMocks() + ;(env as any).APPS_REPO_URL = 'https://charts.example.com' + ;(env as any).APPS_REVISION = undefined + }) + + it('should include ServerSideApply=true in syncOptions', () => { + const manifest = getArgocdCoreAppManifest(release, {}, '1.0.0') + + expect(manifest.spec.syncPolicy.syncOptions).toContain('ServerSideApply=true') + }) + + it('should preserve ServerSideApply=true when app has a patch with only ignoreDifferences', () => { + const istioBase = { ...release, name: 'istio-base', namespace: 'istio-system' } + const manifest = getArgocdCoreAppManifest(istioBase, {}, '1.0.0') + + expect(manifest.spec.syncPolicy.syncOptions).toContain('ServerSideApply=true') + }) +}) + +describe('stripOversizedLastAppliedAnnotations', () => { + const annotation = 'kubectl.kubernetes.io/last-applied-configuration' + const oversizedValue = 'x'.repeat(262145) + const undersizedValue = 'x'.repeat(100) + + const mockListCRDs = jest.fn() + const mockPatchCRD = jest.fn() + const mockListConfigMaps = jest.fn() + const mockPatchConfigMap = jest.fn() + + const mockDeps = { + getCrdApi: () => ({ listCustomResourceDefinition: mockListCRDs, patchCustomResourceDefinition: mockPatchCRD }), + getCoreApi: () => ({ + listConfigMapForAllNamespaces: mockListConfigMaps, + patchNamespacedConfigMap: mockPatchConfigMap, + }), + } + + beforeEach(() => { + jest.clearAllMocks() + mockListCRDs.mockResolvedValue({ items: [] }) + mockListConfigMaps.mockResolvedValue({ items: [] }) + mockPatchCRD.mockResolvedValue({}) + mockPatchConfigMap.mockResolvedValue({}) + }) + + it('removes last-applied-configuration from a CRD whose annotation exceeds the limit', async () => { + mockListCRDs.mockResolvedValue({ + items: [{ metadata: { name: 'clusterpolicies.kyverno.io', annotations: { [annotation]: oversizedValue } } }], + }) + + await stripOversizedLastAppliedAnnotations(mockDeps as any) + + expect(mockPatchCRD).toHaveBeenCalledWith( + expect.objectContaining({ name: 'clusterpolicies.kyverno.io' }), + expect.anything(), + ) + }) + + it('removes last-applied-configuration from a ConfigMap whose annotation exceeds the limit', async () => { + mockListConfigMaps.mockResolvedValue({ + items: [ + { + metadata: { + name: 'grafana-dashboards-k8s-admin', + namespace: 'grafana', + annotations: { [annotation]: oversizedValue }, + }, + }, + ], + }) + + await stripOversizedLastAppliedAnnotations(mockDeps as any) + + expect(mockPatchConfigMap).toHaveBeenCalledWith( + expect.objectContaining({ name: 'grafana-dashboards-k8s-admin', namespace: 'grafana' }), + expect.anything(), + ) + }) + + it('does not patch a ConfigMap without the annotation', async () => { + mockListConfigMaps.mockResolvedValue({ + items: [{ metadata: { name: 'some-config', namespace: 'default', annotations: {} } }], + }) + + await stripOversizedLastAppliedAnnotations(mockDeps as any) + + expect(mockPatchConfigMap).not.toHaveBeenCalled() + }) +}) diff --git a/src/cmd/apply-as-apps.ts b/src/cmd/apply-as-apps.ts index a9d529e982..47f6612edb 100644 --- a/src/cmd/apply-as-apps.ts +++ b/src/cmd/apply-as-apps.ts @@ -1,4 +1,11 @@ -import { ApiException, PatchStrategy, setHeaderOptions, V1ResourceRequirements } from '@kubernetes/client-node' +import { + ApiException, + ApiextensionsV1Api, + CoreV1Api, + PatchStrategy, + setHeaderOptions, + V1ResourceRequirements, +} from '@kubernetes/client-node' import { mkdirSync, rmSync, statSync } from 'fs' import { readFile } from 'fs/promises' import { glob } from 'glob' @@ -28,6 +35,52 @@ export const ARGOCD_APP_GITOPS_LABEL = 'generic-gitops' export const ARGOCD_APP_GITOPS_NS_PREFIX = 'gitops-ns' export const ARGOCD_APP_GITOPS_GLOBAL_NAME = 'gitops-global' +const LAST_APPLIED_ANNOTATION = 'kubectl.kubernetes.io/last-applied-configuration' +// JSON Patch requires '/' in key names to be escaped as '~1' +const LAST_APPLIED_PATCH_PATH = '/metadata/annotations/kubectl.kubernetes.io~1last-applied-configuration' + +export const stripOversizedLastAppliedAnnotations = async ( + deps = { + getCrdApi: (): ApiextensionsV1Api => k8s.kc().makeApiClient(ApiextensionsV1Api), + getCoreApi: (): CoreV1Api => k8s.core(), + }, +): Promise => { + const log = terminal('cmd:apply-as-apps:stripOversized') + const patchHeaders = setHeaderOptions('Content-Type', PatchStrategy.JsonPatch) + const removePatch = [{ op: 'remove', path: LAST_APPLIED_PATCH_PATH }] + + const crdApi = deps.getCrdApi() + const { items: crds } = await crdApi.listCustomResourceDefinition() + await Promise.allSettled( + crds + .filter((crd) => { + const value = crd.metadata?.annotations?.[LAST_APPLIED_ANNOTATION] + return value !== undefined + }) + .map(async (crd) => { + const name = crd.metadata!.name! + log.info(`Stripping oversized last-applied-configuration from CRD ${name}`) + await crdApi.patchCustomResourceDefinition({ name, body: removePatch }, patchHeaders) + }), + ) + + const coreApi = deps.getCoreApi() + const { items: configMaps } = await coreApi.listConfigMapForAllNamespaces() + await Promise.allSettled( + configMaps + .filter((cm) => { + const value = cm.metadata?.annotations?.[LAST_APPLIED_ANNOTATION] + return value !== undefined + }) + .map(async (cm) => { + const name = cm.metadata!.name! + const namespace = cm.metadata!.namespace! + log.info(`Stripping oversized last-applied-configuration from ConfigMap ${namespace}/${name}`) + await coreApi.patchNamespacedConfigMap({ name, namespace, body: removePatch }, patchHeaders) + }), + ) +} + const cmdName = getFilename(__filename) const dir = '/tmp/otomi' const valuesDir = '/tmp/otomi/values' @@ -94,6 +147,10 @@ const getAppName = (release: HelmRelease): string => { return `${release.namespace}-${release.name}` } +export const mergeSyncOptions = (base: string[], patch?: string[]): string[] => { + return [...new Set([...base, ...(patch ?? [])])] +} + const getArgoCdAppManifest = (name: string, appLabel: string, spec: Record): ArgocdAppManifest => { return { apiVersion: 'argoproj.io/v1alpha1', @@ -113,15 +170,20 @@ const getArgoCdAppManifest = (name: string, appLabel: string, spec: Record, otomiVersion: string, ): ArgocdAppManifest => { const name = getAppName(release) - const patch = (appPatches[name] || genericPatch) as Record + const { syncPolicy: patchSyncPolicy, ...restPatch } = (appPatches[name] || genericPatch) as Record + const syncPolicy = { + ...ARGOCD_APP_DEFAULT_SYNC_POLICY, + ...patchSyncPolicy, + syncOptions: mergeSyncOptions(ARGOCD_APP_DEFAULT_SYNC_POLICY.syncOptions, patchSyncPolicy?.syncOptions), + } return getArgoCdAppManifest(name, ARGOCD_APP_DEFAULT_LABEL, { - syncPolicy: ARGOCD_APP_DEFAULT_SYNC_POLICY, + syncPolicy, project: 'default', revisionHistoryLimit: 2, source: { @@ -137,7 +199,7 @@ const getArgocdCoreAppManifest = ( server: 'https://kubernetes.default.svc', namespace: release.namespace, }, - ...patch, + ...restPatch, }) } @@ -355,6 +417,7 @@ export const applyAsApps = async (argv: HelmArguments): Promise => { const helmfileSource = argv.file?.toString() || 'helmfile.d/' d.info(`Parsing helm releases defined in ${helmfileSource}`) setup() + await stripOversizedLastAppliedAnnotations().catch((e) => d.warn('Failed to strip oversized annotations:', e)) const otomiVersion = await getImageTagFromValues() const res = await hf({ fileOpts: argv.file, From 22e3f7ee6dac500654650b5c6a9e6a4085cce96c Mon Sep 17 00:00:00 2001 From: Jehoszafat Zimnowoda <17126497+j-zimnowoda@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:19:50 +0200 Subject: [PATCH 2/5] feat: hf to uses server-side apply --- src/common/hf.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/common/hf.ts b/src/common/hf.ts index 957bcf5c00..03e90c60b5 100644 --- a/src/common/hf.ts +++ b/src/common/hf.ts @@ -19,7 +19,7 @@ export const HF_DEFAULT_SYNC_ARGS = [ '--concurrency=1', '--sync-args', // These two need to be in same string as is passed as single argument to --sync-args - '--disable-openapi-validation --qps=20', + '--disable-openapi-validation --qps=20 --serverSide=true', ] export const HF_DEFAULT_SYNC_ON_INITIAL_INSTALL_ARGS = [ @@ -28,7 +28,7 @@ export const HF_DEFAULT_SYNC_ON_INITIAL_INSTALL_ARGS = [ '--concurrency=1', '--sync-args', // These two need to be in same string as is passed as single argument to --sync-args - '--disable-openapi-validation --qps=20', + '--disable-openapi-validation --qps=20 --serverSide=true', ] type HFParams = { From f40f12058a3ab0998410dcc393651f0f2b88a32e Mon Sep 17 00:00:00 2001 From: Jehoszafat Zimnowoda <17126497+j-zimnowoda@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:21:30 +0200 Subject: [PATCH 3/5] revert: changes to getArgoCdAppManifest --- src/cmd/apply-as-apps.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/cmd/apply-as-apps.ts b/src/cmd/apply-as-apps.ts index 47f6612edb..3038c392f1 100644 --- a/src/cmd/apply-as-apps.ts +++ b/src/cmd/apply-as-apps.ts @@ -147,10 +147,6 @@ const getAppName = (release: HelmRelease): string => { return `${release.namespace}-${release.name}` } -export const mergeSyncOptions = (base: string[], patch?: string[]): string[] => { - return [...new Set([...base, ...(patch ?? [])])] -} - const getArgoCdAppManifest = (name: string, appLabel: string, spec: Record): ArgocdAppManifest => { return { apiVersion: 'argoproj.io/v1alpha1', @@ -176,14 +172,10 @@ export const getArgocdCoreAppManifest = ( otomiVersion: string, ): ArgocdAppManifest => { const name = getAppName(release) - const { syncPolicy: patchSyncPolicy, ...restPatch } = (appPatches[name] || genericPatch) as Record - const syncPolicy = { - ...ARGOCD_APP_DEFAULT_SYNC_POLICY, - ...patchSyncPolicy, - syncOptions: mergeSyncOptions(ARGOCD_APP_DEFAULT_SYNC_POLICY.syncOptions, patchSyncPolicy?.syncOptions), - } + const patch = (appPatches[name] || genericPatch) as Record + return getArgoCdAppManifest(name, ARGOCD_APP_DEFAULT_LABEL, { - syncPolicy, + syncPolicy: ARGOCD_APP_DEFAULT_SYNC_POLICY, project: 'default', revisionHistoryLimit: 2, source: { @@ -199,7 +191,7 @@ export const getArgocdCoreAppManifest = ( server: 'https://kubernetes.default.svc', namespace: release.namespace, }, - ...restPatch, + ...patch, }) } From 9e5610acda78fc66b04dfc8a062c36cbac889cdb Mon Sep 17 00:00:00 2001 From: Jehoszafat Zimnowoda <17126497+j-zimnowoda@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:49:40 +0200 Subject: [PATCH 4/5] test: remove unused --- src/cmd/apply-as-apps.test.ts | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/src/cmd/apply-as-apps.test.ts b/src/cmd/apply-as-apps.test.ts index 287c468e08..e366e9b47b 100644 --- a/src/cmd/apply-as-apps.test.ts +++ b/src/cmd/apply-as-apps.test.ts @@ -13,7 +13,6 @@ import { getApplications, getArgocdCoreAppManifest, getArgocdGitopsManifest, - mergeSyncOptions, removeGitOpsApps, stripOversizedLastAppliedAnnotations, } from './apply-as-apps' @@ -691,25 +690,6 @@ describe('checkArgoCdController', () => { }) }) -describe('mergeSyncOptions', () => { - it('returns base options unchanged when patch provides none', () => { - expect(mergeSyncOptions(['ServerSideApply=true'])).toEqual(['ServerSideApply=true']) - }) - - it('preserves ServerSideApply=true when patch provides only different options', () => { - const result = mergeSyncOptions(['ServerSideApply=true'], ['CreateNamespace=true']) - - expect(result).toContain('ServerSideApply=true') - expect(result).toContain('CreateNamespace=true') - }) - - it('deduplicates options that appear in both base and patch', () => { - const result = mergeSyncOptions(['ServerSideApply=true'], ['ServerSideApply=true', 'CreateNamespace=true']) - - expect(result.filter((o) => o === 'ServerSideApply=true')).toHaveLength(1) - }) -}) - describe('getArgocdCoreAppManifest', () => { const release = { name: 'kyverno', From 856363008fd800d873356960dbd84be90b9d4d79 Mon Sep 17 00:00:00 2001 From: Jehoszafat Zimnowoda <17126497+j-zimnowoda@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:59:31 +0200 Subject: [PATCH 5/5] feat: remove the last applied annotation on upgrade --- src/cmd/apply-as-apps.test.ts | 72 ---------------- src/cmd/apply-as-apps.ts | 56 +------------ .../runtime-upgrades/runtime-upgrades.ts | 7 ++ src/common/runtime-upgrades/v6.2.0.test.ts | 82 +++++++++++++++++++ src/common/runtime-upgrades/v6.2.0.ts | 49 +++++++++++ 5 files changed, 139 insertions(+), 127 deletions(-) create mode 100644 src/common/runtime-upgrades/v6.2.0.test.ts create mode 100644 src/common/runtime-upgrades/v6.2.0.ts diff --git a/src/cmd/apply-as-apps.test.ts b/src/cmd/apply-as-apps.test.ts index e366e9b47b..67aac6e8e3 100644 --- a/src/cmd/apply-as-apps.test.ts +++ b/src/cmd/apply-as-apps.test.ts @@ -14,7 +14,6 @@ import { getArgocdCoreAppManifest, getArgocdGitopsManifest, removeGitOpsApps, - stripOversizedLastAppliedAnnotations, } from './apply-as-apps' jest.mock('glob') @@ -720,74 +719,3 @@ describe('getArgocdCoreAppManifest', () => { expect(manifest.spec.syncPolicy.syncOptions).toContain('ServerSideApply=true') }) }) - -describe('stripOversizedLastAppliedAnnotations', () => { - const annotation = 'kubectl.kubernetes.io/last-applied-configuration' - const oversizedValue = 'x'.repeat(262145) - const undersizedValue = 'x'.repeat(100) - - const mockListCRDs = jest.fn() - const mockPatchCRD = jest.fn() - const mockListConfigMaps = jest.fn() - const mockPatchConfigMap = jest.fn() - - const mockDeps = { - getCrdApi: () => ({ listCustomResourceDefinition: mockListCRDs, patchCustomResourceDefinition: mockPatchCRD }), - getCoreApi: () => ({ - listConfigMapForAllNamespaces: mockListConfigMaps, - patchNamespacedConfigMap: mockPatchConfigMap, - }), - } - - beforeEach(() => { - jest.clearAllMocks() - mockListCRDs.mockResolvedValue({ items: [] }) - mockListConfigMaps.mockResolvedValue({ items: [] }) - mockPatchCRD.mockResolvedValue({}) - mockPatchConfigMap.mockResolvedValue({}) - }) - - it('removes last-applied-configuration from a CRD whose annotation exceeds the limit', async () => { - mockListCRDs.mockResolvedValue({ - items: [{ metadata: { name: 'clusterpolicies.kyverno.io', annotations: { [annotation]: oversizedValue } } }], - }) - - await stripOversizedLastAppliedAnnotations(mockDeps as any) - - expect(mockPatchCRD).toHaveBeenCalledWith( - expect.objectContaining({ name: 'clusterpolicies.kyverno.io' }), - expect.anything(), - ) - }) - - it('removes last-applied-configuration from a ConfigMap whose annotation exceeds the limit', async () => { - mockListConfigMaps.mockResolvedValue({ - items: [ - { - metadata: { - name: 'grafana-dashboards-k8s-admin', - namespace: 'grafana', - annotations: { [annotation]: oversizedValue }, - }, - }, - ], - }) - - await stripOversizedLastAppliedAnnotations(mockDeps as any) - - expect(mockPatchConfigMap).toHaveBeenCalledWith( - expect.objectContaining({ name: 'grafana-dashboards-k8s-admin', namespace: 'grafana' }), - expect.anything(), - ) - }) - - it('does not patch a ConfigMap without the annotation', async () => { - mockListConfigMaps.mockResolvedValue({ - items: [{ metadata: { name: 'some-config', namespace: 'default', annotations: {} } }], - }) - - await stripOversizedLastAppliedAnnotations(mockDeps as any) - - expect(mockPatchConfigMap).not.toHaveBeenCalled() - }) -}) diff --git a/src/cmd/apply-as-apps.ts b/src/cmd/apply-as-apps.ts index 3038c392f1..d9a1954d22 100644 --- a/src/cmd/apply-as-apps.ts +++ b/src/cmd/apply-as-apps.ts @@ -1,11 +1,4 @@ -import { - ApiException, - ApiextensionsV1Api, - CoreV1Api, - PatchStrategy, - setHeaderOptions, - V1ResourceRequirements, -} from '@kubernetes/client-node' +import { ApiException, PatchStrategy, setHeaderOptions, V1ResourceRequirements } from '@kubernetes/client-node' import { mkdirSync, rmSync, statSync } from 'fs' import { readFile } from 'fs/promises' import { glob } from 'glob' @@ -35,52 +28,6 @@ export const ARGOCD_APP_GITOPS_LABEL = 'generic-gitops' export const ARGOCD_APP_GITOPS_NS_PREFIX = 'gitops-ns' export const ARGOCD_APP_GITOPS_GLOBAL_NAME = 'gitops-global' -const LAST_APPLIED_ANNOTATION = 'kubectl.kubernetes.io/last-applied-configuration' -// JSON Patch requires '/' in key names to be escaped as '~1' -const LAST_APPLIED_PATCH_PATH = '/metadata/annotations/kubectl.kubernetes.io~1last-applied-configuration' - -export const stripOversizedLastAppliedAnnotations = async ( - deps = { - getCrdApi: (): ApiextensionsV1Api => k8s.kc().makeApiClient(ApiextensionsV1Api), - getCoreApi: (): CoreV1Api => k8s.core(), - }, -): Promise => { - const log = terminal('cmd:apply-as-apps:stripOversized') - const patchHeaders = setHeaderOptions('Content-Type', PatchStrategy.JsonPatch) - const removePatch = [{ op: 'remove', path: LAST_APPLIED_PATCH_PATH }] - - const crdApi = deps.getCrdApi() - const { items: crds } = await crdApi.listCustomResourceDefinition() - await Promise.allSettled( - crds - .filter((crd) => { - const value = crd.metadata?.annotations?.[LAST_APPLIED_ANNOTATION] - return value !== undefined - }) - .map(async (crd) => { - const name = crd.metadata!.name! - log.info(`Stripping oversized last-applied-configuration from CRD ${name}`) - await crdApi.patchCustomResourceDefinition({ name, body: removePatch }, patchHeaders) - }), - ) - - const coreApi = deps.getCoreApi() - const { items: configMaps } = await coreApi.listConfigMapForAllNamespaces() - await Promise.allSettled( - configMaps - .filter((cm) => { - const value = cm.metadata?.annotations?.[LAST_APPLIED_ANNOTATION] - return value !== undefined - }) - .map(async (cm) => { - const name = cm.metadata!.name! - const namespace = cm.metadata!.namespace! - log.info(`Stripping oversized last-applied-configuration from ConfigMap ${namespace}/${name}`) - await coreApi.patchNamespacedConfigMap({ name, namespace, body: removePatch }, patchHeaders) - }), - ) -} - const cmdName = getFilename(__filename) const dir = '/tmp/otomi' const valuesDir = '/tmp/otomi/values' @@ -409,7 +356,6 @@ export const applyAsApps = async (argv: HelmArguments): Promise => { const helmfileSource = argv.file?.toString() || 'helmfile.d/' d.info(`Parsing helm releases defined in ${helmfileSource}`) setup() - await stripOversizedLastAppliedAnnotations().catch((e) => d.warn('Failed to strip oversized annotations:', e)) const otomiVersion = await getImageTagFromValues() const res = await hf({ fileOpts: argv.file, diff --git a/src/common/runtime-upgrades/runtime-upgrades.ts b/src/common/runtime-upgrades/runtime-upgrades.ts index a1703a970e..b6fd95fd04 100644 --- a/src/common/runtime-upgrades/runtime-upgrades.ts +++ b/src/common/runtime-upgrades/runtime-upgrades.ts @@ -1,6 +1,7 @@ import { OtomiDebugger } from '../debug' import { k8s } from '../k8s' import { detectAndRestartOutdatedIstioSidecars } from './restart-istio-sidecars' +import { stripOversizedLastAppliedAnnotations } from './v6.2.0' export interface RuntimeUpgradeContext { debug: OtomiDebugger @@ -35,4 +36,10 @@ export const runtimeUpgrades: RuntimeUpgrades = [ }, }, }, + { + version: '6.2.0', + pre: async ({ debug }) => { + await stripOversizedLastAppliedAnnotations().catch((e) => debug.warn('Failed to strip oversized annotations:', e)) + }, + }, ] diff --git a/src/common/runtime-upgrades/v6.2.0.test.ts b/src/common/runtime-upgrades/v6.2.0.test.ts new file mode 100644 index 0000000000..70801de428 --- /dev/null +++ b/src/common/runtime-upgrades/v6.2.0.test.ts @@ -0,0 +1,82 @@ +import { stripOversizedLastAppliedAnnotations } from './v6.2.0' + +jest.mock('../debug', () => ({ + ...jest.requireActual('../debug'), + terminal: jest.fn(() => ({ + info: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + stream: { log: process.stdout, error: process.stderr }, + })), +})) + +describe('stripOversizedLastAppliedAnnotations', () => { + const annotation = 'kubectl.kubernetes.io/last-applied-configuration' + const oversizedValue = 'x'.repeat(262145) + + const mockListCRDs = jest.fn() + const mockPatchCRD = jest.fn() + const mockListConfigMaps = jest.fn() + const mockPatchConfigMap = jest.fn() + + const mockDeps = { + getCrdApi: () => ({ listCustomResourceDefinition: mockListCRDs, patchCustomResourceDefinition: mockPatchCRD }), + getCoreApi: () => ({ + listConfigMapForAllNamespaces: mockListConfigMaps, + patchNamespacedConfigMap: mockPatchConfigMap, + }), + } + + beforeEach(() => { + jest.clearAllMocks() + mockListCRDs.mockResolvedValue({ items: [] }) + mockListConfigMaps.mockResolvedValue({ items: [] }) + mockPatchCRD.mockResolvedValue({}) + mockPatchConfigMap.mockResolvedValue({}) + }) + + it('removes last-applied-configuration from a CRD whose annotation exceeds the limit', async () => { + mockListCRDs.mockResolvedValue({ + items: [{ metadata: { name: 'clusterpolicies.kyverno.io', annotations: { [annotation]: oversizedValue } } }], + }) + + await stripOversizedLastAppliedAnnotations(mockDeps as any) + + expect(mockPatchCRD).toHaveBeenCalledWith( + expect.objectContaining({ name: 'clusterpolicies.kyverno.io' }), + expect.anything(), + ) + }) + + it('removes last-applied-configuration from a ConfigMap whose annotation exceeds the limit', async () => { + mockListConfigMaps.mockResolvedValue({ + items: [ + { + metadata: { + name: 'grafana-dashboards-k8s-admin', + namespace: 'grafana', + annotations: { [annotation]: oversizedValue }, + }, + }, + ], + }) + + await stripOversizedLastAppliedAnnotations(mockDeps as any) + + expect(mockPatchConfigMap).toHaveBeenCalledWith( + expect.objectContaining({ name: 'grafana-dashboards-k8s-admin', namespace: 'grafana' }), + expect.anything(), + ) + }) + + it('does not patch a ConfigMap without the annotation', async () => { + mockListConfigMaps.mockResolvedValue({ + items: [{ metadata: { name: 'some-config', namespace: 'default', annotations: {} } }], + }) + + await stripOversizedLastAppliedAnnotations(mockDeps as any) + + expect(mockPatchConfigMap).not.toHaveBeenCalled() + }) +}) diff --git a/src/common/runtime-upgrades/v6.2.0.ts b/src/common/runtime-upgrades/v6.2.0.ts new file mode 100644 index 0000000000..624d02cc0a --- /dev/null +++ b/src/common/runtime-upgrades/v6.2.0.ts @@ -0,0 +1,49 @@ +import { ApiextensionsV1Api, CoreV1Api, PatchStrategy, setHeaderOptions } from '@kubernetes/client-node' +import { terminal } from '../debug' +import { k8s } from '../k8s' + +const LAST_APPLIED_ANNOTATION = 'kubectl.kubernetes.io/last-applied-configuration' +// JSON Patch requires '/' in key names to be escaped as '~1' +const LAST_APPLIED_PATCH_PATH = '/metadata/annotations/kubectl.kubernetes.io~1last-applied-configuration' + +export const stripOversizedLastAppliedAnnotations = async ( + deps = { + getCrdApi: (): ApiextensionsV1Api => k8s.kc().makeApiClient(ApiextensionsV1Api), + getCoreApi: (): CoreV1Api => k8s.core(), + }, +): Promise => { + const log = terminal('common:runtime-upgrades:v6.2.0:stripOversized') + const patchHeaders = setHeaderOptions('Content-Type', PatchStrategy.JsonPatch) + const removePatch = [{ op: 'remove', path: LAST_APPLIED_PATCH_PATH }] + + const crdApi = deps.getCrdApi() + const { items: crds } = await crdApi.listCustomResourceDefinition() + await Promise.allSettled( + crds + .filter((crd) => { + const value = crd.metadata?.annotations?.[LAST_APPLIED_ANNOTATION] + return value !== undefined + }) + .map(async (crd) => { + const name = crd.metadata!.name! + log.info(`Stripping oversized last-applied-configuration from CRD ${name}`) + await crdApi.patchCustomResourceDefinition({ name, body: removePatch }, patchHeaders) + }), + ) + + const coreApi = deps.getCoreApi() + const { items: configMaps } = await coreApi.listConfigMapForAllNamespaces() + await Promise.allSettled( + configMaps + .filter((cm) => { + const value = cm.metadata?.annotations?.[LAST_APPLIED_ANNOTATION] + return value !== undefined + }) + .map(async (cm) => { + const name = cm.metadata!.name! + const namespace = cm.metadata!.namespace! + log.info(`Stripping oversized last-applied-configuration from ConfigMap ${namespace}/${name}`) + await coreApi.patchNamespacedConfigMap({ name, namespace, body: removePatch }, patchHeaders) + }), + ) +}