Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions frontend/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<bold>Technology Preview</bold>: For more information, <a>view documentation</a> on Cluster pools",
"Previous change": "Previous change",
Expand Down
27 changes: 25 additions & 2 deletions frontend/src/components/BulkActionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export type BulkActionModalProps<T = undefined> = {
processing: string
title: string
enableDeletePullSecret?: boolean
enablePreserveOnDelete?: boolean
} & Required<Pick<AcmTableProps<T>, 'items'>> &
Partial<Pick<AcmTableProps<T>, 'columns'>> & // Policy automation and cluster claim deletion modals omit columns prop to avoid showing a table
Omit<AcmTableProps<T>, 'columns'>
Expand All @@ -65,20 +66,30 @@ export interface ItemError<T> {
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<T = unknown>(props: BulkActionModalProps<T> | { open: false }) {
const { t } = useTranslation()
const [progress, setProgress] = useState(0)
const [progressCount, setProgressCount] = useState(0)
const [confirm, setConfirm] = useState('')
const [errors, setErrors] = useState<ItemError<T>[] | undefined>()
const [deletePullSecret, setDeletePullSecret] = useState(true)
const [preserveOnDelete, setPreserveOnDelete] = useState(false)

useEffect(() => {
setConfirm('')
setErrors(undefined)
setProgress(0)
setProgressCount(1)
setDeletePullSecret(true)
setPreserveOnDelete(false)
}, [props.open])

if (props.open === false) {
Expand All @@ -105,6 +116,7 @@ export function BulkActionModal<T = unknown>(props: BulkActionModalProps<T> | {
processing,
title,
enableDeletePullSecret,
enablePreserveOnDelete,
...tableProps
} = props

Expand All @@ -129,7 +141,7 @@ export function BulkActionModal<T = unknown>(props: BulkActionModalProps<T> | {

async function runSequential(items: T[], errors: ItemError<T>[]) {
for (const item of items) {
const { promise } = actionFn(item, { deletePullSecret })
const { promise } = actionFn(item, { deletePullSecret, preserveOnDelete })
try {
await promise
} catch (err) {
Expand All @@ -141,7 +153,7 @@ export function BulkActionModal<T = unknown>(props: BulkActionModalProps<T> | {

async function runParallel(items: T[], errors: ItemError<T>[]) {
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)
Expand Down Expand Up @@ -204,6 +216,17 @@ export function BulkActionModal<T = unknown>(props: BulkActionModalProps<T> | {
/>
</StackItem>
)}
{enablePreserveOnDelete && (
<StackItem>
<Checkbox
id="preserve-on-delete"
label={t('Preserve cluster resources on delete')}
isChecked={preserveOnDelete}
onChange={(_event, val) => setPreserveOnDelete(val)}
isDisabled={progress > 0}
/>
</StackItem>
)}
{confirmText !== undefined && (
<StackItem>
<AcmTextInput
Expand Down
53 changes: 53 additions & 0 deletions frontend/src/lib/delete-cluster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,59 @@ describe('deleteCluster', () => {

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 = () => [
Expand Down
80 changes: 59 additions & 21 deletions frontend/src/lib/delete-cluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []

Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/resources/cluster-deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface ClusterDeployment {
}
}
powerState?: 'Running' | 'Hibernating'
preserveOnDelete?: boolean
provisioning: {
imageSetRef: {
name: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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,
Expand Down