diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index bcd7ba6348..9f428cbd77 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -2489,6 +2489,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 change": "Previous change", diff --git a/frontend/src/components/BulkActionModal.tsx b/frontend/src/components/BulkActionModal.tsx index f983d7c571..9ab7f5a7b8 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'> @@ -65,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) @@ -72,6 +81,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 +89,7 @@ export function BulkActionModal(props: BulkActionModalProps | { setProgress(0) setProgressCount(1) setDeletePullSecret(true) + setPreserveOnDelete(false) }, [props.open]) if (props.open === false) { @@ -105,6 +116,7 @@ export function BulkActionModal(props: BulkActionModalProps | { processing, title, enableDeletePullSecret, + enablePreserveOnDelete, ...tableProps } = props @@ -129,7 +141,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 +153,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 +216,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 1474eb1306..e93fc6c475 100644 --- a/frontend/src/lib/delete-cluster.ts +++ b/frontend/src/lib/delete-cluster.ts @@ -23,16 +23,26 @@ 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, infraEnvs, deletePullSecret, + preserveOnDelete, }: { cluster: Cluster ignoreClusterDeploymentNotFound: boolean infraEnvs: InfraEnvK8sResource[] deletePullSecret: boolean + preserveOnDelete?: boolean }) { let resources: IResource[] = [] @@ -143,30 +153,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 7e7f26bf48..4161057997 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 73bcb9336c..2aac106b58 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,