From 6765dfc29c5ced8a7161516749ae68737dfbc572 Mon Sep 17 00:00:00 2001 From: mihirlele Date: Tue, 9 Jun 2026 21:55:11 +0530 Subject: [PATCH 1/4] feat: add preserveOnDelete checkbox to cluster destroy dialog Adds a "Preserve cluster infrastructure on delete" checkbox to the destroy cluster confirmation dialog for ACM-provisioned (Hive-backed) clusters. When checked, the UI patches spec.preserveOnDelete=true on the ClusterDeployment before proceeding with deletion. If the patch fails the deletion is aborted and an error is surfaced to the user. Checkbox is not shown for Hypershift/Hosted clusters. Signed-off-by: mihirlele --- .gitignore | 2 + frontend/public/locales/en/translation.json | 1 + frontend/src/components/BulkActionModal.tsx | 19 ++++- frontend/src/lib/delete-cluster.test.ts | 53 ++++++++++++++ frontend/src/lib/delete-cluster.ts | 72 +++++++++++++------ frontend/src/resources/cluster-deployment.ts | 1 + .../components/ClusterActionDropdown.tsx | 2 + 7 files changed, 127 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index 37ae44e278b..d25f0c06ba1 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ frontend/packages/react-form-wizard/lib/ #ai related files .claude +_specs/ +_plan/ test-results playwright.config.ts .env diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 814abd9155b..66ab97c983c 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -1197,6 +1197,7 @@ "Delete placement": "Delete placement", "Delete pull-secret resource": "Delete pull-secret resource", "Delete pull-secret resource_plural": "Delete pull-secret resources", + "Preserve cluster infrastructure on delete": "Preserve cluster infrastructure on delete", "Delete resources that are no longer defined in the source repository": "Delete resources that are no longer defined in the source repository", "Delete resources that are no longer defined in the source repository at the end of a sync operation": "Delete resources that are no longer defined in the source repository at the end of a sync operation", "Delete role assignment": "Delete role assignment", diff --git a/frontend/src/components/BulkActionModal.tsx b/frontend/src/components/BulkActionModal.tsx index f983d7c5719..c2943c65aba 100644 --- a/frontend/src/components/BulkActionModal.tsx +++ b/frontend/src/components/BulkActionModal.tsx @@ -51,6 +51,7 @@ export type BulkActionModalProps = { processing: string title: string enableDeletePullSecret?: boolean + enablePreserveOnDelete?: boolean } & Required, 'items'>> & Partial, 'columns'>> & // Policy automation and cluster claim deletion modals omit columns prop to avoid showing a table Omit, 'columns'> @@ -72,6 +73,7 @@ export function BulkActionModal(props: BulkActionModalProps | { const [confirm, setConfirm] = useState('') const [errors, setErrors] = useState[] | undefined>() const [deletePullSecret, setDeletePullSecret] = useState(true) + const [preserveOnDelete, setPreserveOnDelete] = useState(false) useEffect(() => { setConfirm('') @@ -79,6 +81,7 @@ export function BulkActionModal(props: BulkActionModalProps | { setProgress(0) setProgressCount(1) setDeletePullSecret(true) + setPreserveOnDelete(false) }, [props.open]) if (props.open === false) { @@ -105,6 +108,7 @@ export function BulkActionModal(props: BulkActionModalProps | { processing, title, enableDeletePullSecret, + enablePreserveOnDelete, ...tableProps } = props @@ -129,7 +133,7 @@ export function BulkActionModal(props: BulkActionModalProps | { async function runSequential(items: T[], errors: ItemError[]) { for (const item of items) { - const { promise } = actionFn(item, { deletePullSecret }) + const { promise } = actionFn(item, { deletePullSecret, preserveOnDelete }) try { await promise } catch (err) { @@ -141,7 +145,7 @@ export function BulkActionModal(props: BulkActionModalProps | { async function runParallel(items: T[], errors: ItemError[]) { const promises = items.map((resource) => { - const r = actionFn(resource, { deletePullSecret }) + const r = actionFn(resource, { deletePullSecret, preserveOnDelete }) return { promise: r.promise.finally(incrementProgress), abort: r.abort } }) const requestResult = resultsSettled(promises) @@ -204,6 +208,17 @@ export function BulkActionModal(props: BulkActionModalProps | { /> )} + {enablePreserveOnDelete && ( + + setPreserveOnDelete(val)} + isDisabled={progress > 0} + /> + + )} {confirmText !== undefined && ( { await waitForNocks(deleteNocks) }) + + it('preserveOnDelete - patches ClusterDeployment before deleting', async () => { + const patchNock = nockPatch( + { + apiVersion: ClusterDeploymentApiVersion, + kind: ClusterDeploymentKind, + metadata: { name: mockCluster3.name, namespace: mockCluster3.namespace }, + }, + { spec: { preserveOnDelete: true } } + ) + const deleteNocks = deleteClusterNocks() + deleteCluster({ + cluster: mockCluster3, + deletePullSecret: false, + infraEnvs: [], + ignoreClusterDeploymentNotFound: false, + preserveOnDelete: true, + }) + await waitForNocks([patchNock, ...deleteNocks]) + }) + + it('preserveOnDelete - aborts deletion when patch fails', async () => { + nockPatch( + { + apiVersion: ClusterDeploymentApiVersion, + kind: ClusterDeploymentKind, + metadata: { name: mockCluster3.name, namespace: mockCluster3.namespace }, + }, + { spec: { preserveOnDelete: true } }, + undefined, + 500 + ) + const { promise } = deleteCluster({ + cluster: mockCluster3, + deletePullSecret: false, + infraEnvs: [], + ignoreClusterDeploymentNotFound: false, + preserveOnDelete: true, + }) + await expect(promise).rejects.toBeDefined() + }) + + it('preserveOnDelete false - skips patch and deletes normally', async () => { + const nocks = deleteClusterNocks() + deleteCluster({ + cluster: mockCluster3, + deletePullSecret: false, + infraEnvs: [], + ignoreClusterDeploymentNotFound: false, + preserveOnDelete: false, + }) + await waitForNocks(nocks) + }) }) const deleteHostedClusterNocks = () => [ diff --git a/frontend/src/lib/delete-cluster.ts b/frontend/src/lib/delete-cluster.ts index 1474eb13065..e17d2708e81 100644 --- a/frontend/src/lib/delete-cluster.ts +++ b/frontend/src/lib/delete-cluster.ts @@ -28,11 +28,13 @@ export function deleteCluster({ ignoreClusterDeploymentNotFound, infraEnvs, deletePullSecret, + preserveOnDelete, }: { cluster: Cluster ignoreClusterDeploymentNotFound: boolean infraEnvs: InfraEnvK8sResource[] deletePullSecret: boolean + preserveOnDelete?: boolean }) { let resources: IResource[] = [] @@ -143,30 +145,58 @@ export function deleteCluster({ } } - const deleteResourcesResult = deleteResources(resources) - return { - promise: new Promise((resolve, reject) => { - deleteResourcesResult.promise.then((promisesSettledResult) => { - if (promisesSettledResult[0]?.status === 'rejected') { - const error = promisesSettledResult[0].reason - if (error instanceof ResourceError) { - if (ignoreClusterDeploymentNotFound && error.code === ResourceErrorCode.NotFound) { - // DO NOTHING - } else { - reject(promisesSettledResult[0].reason) - return + function runDelete() { + const deleteResourcesResult = deleteResources(resources) + return { + promise: new Promise((resolve, reject) => { + deleteResourcesResult.promise.then((promisesSettledResult) => { + if (promisesSettledResult[0]?.status === 'rejected') { + const error = promisesSettledResult[0].reason + if (error instanceof ResourceError) { + if (ignoreClusterDeploymentNotFound && error.code === ResourceErrorCode.NotFound) { + // DO NOTHING + } else { + reject(promisesSettledResult[0].reason) + return + } } } - } - if (promisesSettledResult[1]?.status === 'rejected') { - reject(promisesSettledResult[1].reason) - return - } - resolve(promisesSettledResult) - }) - }), - abort: deleteResourcesResult.abort, + if (promisesSettledResult[1]?.status === 'rejected') { + reject(promisesSettledResult[1].reason) + return + } + resolve(promisesSettledResult) + }) + }), + abort: deleteResourcesResult.abort, + } + } + + if (preserveOnDelete && !cluster.isHypershift && !cluster.isHostedCluster) { + const patchResult = patchResource( + { + apiVersion: ClusterDeploymentApiVersion, + kind: ClusterDeploymentKind, + metadata: { name: cluster.name!, namespace: cluster.namespace! }, + }, + { spec: { preserveOnDelete: true } } + ) + let abortDelete = () => {} + return { + promise: patchResult.promise.then(() => { + const deleteResult = runDelete() + abortDelete = deleteResult.abort + return deleteResult.promise + }), + abort: () => { + patchResult.abort() + abortDelete() + }, + } } + + const deleteResult = runDelete() + return deleteResult } export function detachCluster(cluster: Cluster) { diff --git a/frontend/src/resources/cluster-deployment.ts b/frontend/src/resources/cluster-deployment.ts index 7e7f26bf483..41610579978 100644 --- a/frontend/src/resources/cluster-deployment.ts +++ b/frontend/src/resources/cluster-deployment.ts @@ -46,6 +46,7 @@ export interface ClusterDeployment { } } powerState?: 'Running' | 'Hibernating' + preserveOnDelete?: boolean provisioning: { imageSetRef: { name: string diff --git a/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/components/ClusterActionDropdown.tsx b/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/components/ClusterActionDropdown.tsx index 58f4db4d84e..4af048396e8 100644 --- a/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/components/ClusterActionDropdown.tsx +++ b/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/components/ClusterActionDropdown.tsx @@ -406,6 +406,7 @@ export function ClusterActionDropdown(props: { cluster: Cluster; isKebab: boolea ignoreClusterDeploymentNotFound: false, infraEnvs, deletePullSecret: !!options?.deletePullSecret, + preserveOnDelete: !!options?.preserveOnDelete, }), close: () => { setModalProps({ open: false }) @@ -415,6 +416,7 @@ export function ClusterActionDropdown(props: { cluster: Cluster; isKebab: boolea confirmText: cluster.displayName, isValidError: errorIsNot([ResourceErrorCode.NotFound]), enableDeletePullSecret: true, + enablePreserveOnDelete: true, }) }, isAriaDisabled: true, From 913cfdf19843f2a813944780c48866630cedfbc6 Mon Sep 17 00:00:00 2001 From: mihirlele Date: Wed, 10 Jun 2026 11:09:15 +0530 Subject: [PATCH 2/4] fix: rename preserveOnDelete checkbox label to "Preserve cluster resources on delete" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit More accurate phrasing — the checkbox prevents deletion of provisioned cluster resources (VMs, VPCs, instances, etc.) rather than generic infrastructure. Also removes _specs/ and _plan/ from .gitignore. Signed-off-by: mihirlele --- .gitignore | 2 -- frontend/public/locales/en/translation.json | 2 +- frontend/src/components/BulkActionModal.tsx | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index d25f0c06ba1..37ae44e278b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,8 +24,6 @@ frontend/packages/react-form-wizard/lib/ #ai related files .claude -_specs/ -_plan/ test-results playwright.config.ts .env diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 66ab97c983c..00bce06c264 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -1197,7 +1197,7 @@ "Delete placement": "Delete placement", "Delete pull-secret resource": "Delete pull-secret resource", "Delete pull-secret resource_plural": "Delete pull-secret resources", - "Preserve cluster infrastructure on delete": "Preserve cluster infrastructure on delete", + "Preserve cluster resources on delete": "Preserve cluster resources on delete", "Delete resources that are no longer defined in the source repository": "Delete resources that are no longer defined in the source repository", "Delete resources that are no longer defined in the source repository at the end of a sync operation": "Delete resources that are no longer defined in the source repository at the end of a sync operation", "Delete role assignment": "Delete role assignment", diff --git a/frontend/src/components/BulkActionModal.tsx b/frontend/src/components/BulkActionModal.tsx index c2943c65aba..a168994e735 100644 --- a/frontend/src/components/BulkActionModal.tsx +++ b/frontend/src/components/BulkActionModal.tsx @@ -212,7 +212,7 @@ export function BulkActionModal(props: BulkActionModalProps | { setPreserveOnDelete(val)} isDisabled={progress > 0} From 933b0421f41baa95ab50a475bb0a4750c1cb9696 Mon Sep 17 00:00:00 2001 From: mihirlele Date: Wed, 10 Jun 2026 11:29:49 +0530 Subject: [PATCH 3/4] docs: add JSDoc to deleteCluster and BulkActionModal Satisfies docstring coverage threshold for the functions modified in this PR. Signed-off-by: mihirlele --- frontend/src/components/BulkActionModal.tsx | 8 ++++++++ frontend/src/lib/delete-cluster.ts | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/frontend/src/components/BulkActionModal.tsx b/frontend/src/components/BulkActionModal.tsx index a168994e735..9ab7f5a7b8b 100644 --- a/frontend/src/components/BulkActionModal.tsx +++ b/frontend/src/components/BulkActionModal.tsx @@ -66,6 +66,14 @@ export interface ItemError { const COMPLETE_DELAY_MS = 200 const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) +/** + * Generic confirmation modal for bulk actions (delete, destroy, detach, etc.). + * + * Supports optional checkboxes via `enableDeletePullSecret` and + * `enablePreserveOnDelete` props. Checkbox state is forwarded to `actionFn` + * through the `options` argument so callers can adjust their behaviour without + * needing separate modal variants. + */ export function BulkActionModal(props: BulkActionModalProps | { open: false }) { const { t } = useTranslation() const [progress, setProgress] = useState(0) diff --git a/frontend/src/lib/delete-cluster.ts b/frontend/src/lib/delete-cluster.ts index e17d2708e81..e93fc6c4753 100644 --- a/frontend/src/lib/delete-cluster.ts +++ b/frontend/src/lib/delete-cluster.ts @@ -23,6 +23,14 @@ import { clusterDestroyable } from '../routes/Infrastructure/Clusters/ManagedClu import { deleteResources } from './delete-resources' import { Provider } from '../ui-components' +/** + * Deletes an ACM-provisioned cluster and its associated resources. + * + * When `preserveOnDelete` is true, patches the ClusterDeployment with + * `spec.preserveOnDelete: true` before issuing any deletes. If the patch + * fails the deletion is aborted and the returned promise rejects — no + * cluster resources are removed. + */ export function deleteCluster({ cluster, ignoreClusterDeploymentNotFound, From 26a1c8e16b4123b899e4ac8bcf8a364baa6bdb59 Mon Sep 17 00:00:00 2001 From: mihirlele Date: Wed, 10 Jun 2026 11:42:21 +0530 Subject: [PATCH 4/4] fix: move i18n key to correct alphabetical position in translation.json Signed-off-by: mihirlele --- frontend/public/locales/en/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 00bce06c264..f23394bafb9 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -1197,7 +1197,6 @@ "Delete placement": "Delete placement", "Delete pull-secret resource": "Delete pull-secret resource", "Delete pull-secret resource_plural": "Delete pull-secret resources", - "Preserve cluster resources on delete": "Preserve cluster resources on delete", "Delete resources that are no longer defined in the source repository": "Delete resources that are no longer defined in the source repository", "Delete resources that are no longer defined in the source repository at the end of a sync operation": "Delete resources that are no longer defined in the source repository at the end of a sync operation", "Delete role assignment": "Delete role assignment", @@ -2486,6 +2485,7 @@ "Pre-authorized user created": "Pre-authorized user created", "PreferNoSelect": "PreferNoSelect", "Prerequisites and Configuration": "Prerequisites and Configuration", + "Preserve cluster resources on delete": "Preserve cluster resources on delete", "Press space or enter to begin dragging, and use the arrow keys to navigate up or down. Press enter to confirm the drag, or any other key to cancel the drag operation.": "Press space or enter to begin dragging, and use the arrow keys to navigate up or down. Press enter to confirm the drag, or any other key to cancel the drag operation.", "preview.clusterPools": "Technology Preview: For more information, view documentation on Cluster pools", "Previous example": "Previous example",