import React from 'react'; import { Modal } from 'react-bootstrap'; import type { Application, DeckRuntimeServices, IModalComponentProps, IServerGroupJob } from '@spinnaker/core'; import { DeckRuntimeContext, ModalClose, noop, PlatformHealthOverride, ReactModal, SpinFormik, TaskMonitor, TaskMonitorWrapper, TaskReason, UserVerification, ValidationMessage, } from '@spinnaker/core'; import type { IAmazonServerGroup } from '../../../domain'; export type AmazonRollbackType = 'EXPLICIT' | 'PREVIOUS_IMAGE'; export interface IAmazonPreviousImageServerGroup { buildNumber?: number | string; imageId?: string; imageName: string; name: string; } export interface IAmazonRollbackValues { delayBeforeDisableSeconds: number; interestingHealthProviderNames?: string[]; reason?: string; restoreServerGroupName?: string; targetHealthyRollbackPercentage: number; } export interface IAmazonRollbackJob extends IServerGroupJob { interestingHealthProviderNames?: string[]; platformHealthOnlyShowOverride?: boolean; reason?: string; rollbackContext: { delayBeforeDisableSeconds: number; restoreServerGroupName?: string; rollbackServerGroupName: string; targetHealthyRollbackPercentage: number; }; rollbackType: AmazonRollbackType; } export interface IAmazonRollbackErrors { delayBeforeDisableSeconds?: string; restoreServerGroupName?: string; targetHealthyRollbackPercentage?: string; } export interface IAmazonRollbackServerGroupModalProps extends IModalComponentProps { allServerGroups: IAmazonServerGroup[]; application: Application; previousServerGroup?: IAmazonServerGroup; serverGroup: IAmazonServerGroup; } interface IAmazonRollbackServerGroupModalState { initialValues: IAmazonRollbackValues; taskMonitor: TaskMonitor; verified: boolean; } export function getDefaultAmazonHealthyRollbackPercentage(desired: number): number { if (desired < 10) { return 100; } return desired < 20 ? 90 : 95; } export function getAmazonPreviousImageServerGroup( serverGroup: IAmazonServerGroup, ): IAmazonPreviousImageServerGroup | undefined { const previousServerGroup = (serverGroup.entityTags as any)?.creationMetadata?.value?.previousServerGroup; if (!previousServerGroup) { return undefined; } const buildNumber = previousServerGroup.buildInfo?.jenkins?.number; return { ...(buildNumber !== undefined ? { buildNumber } : {}), imageId: previousServerGroup.imageId && previousServerGroup.imageId !== previousServerGroup.imageName ? previousServerGroup.imageId : undefined, imageName: previousServerGroup.imageName, name: previousServerGroup.name, }; } export function getAmazonRollbackType( serverGroup: IAmazonServerGroup, allServerGroups: IAmazonServerGroup[], ): AmazonRollbackType { return allServerGroups.length === 0 && getAmazonPreviousImageServerGroup(serverGroup) ? 'PREVIOUS_IMAGE' : 'EXPLICIT'; } export function validateAmazonRollbackValues( values: IAmazonRollbackValues, rollbackType: AmazonRollbackType, eligibleTargetNames?: string[], ): IAmazonRollbackErrors { const errors: IAmazonRollbackErrors = {}; if (rollbackType === 'EXPLICIT') { if (!values.restoreServerGroupName) { errors.restoreServerGroupName = 'Select a server group to restore'; } else if (eligibleTargetNames && !eligibleTargetNames.includes(values.restoreServerGroupName)) { errors.restoreServerGroupName = 'Select an eligible server group to restore'; } } if ( !Number.isFinite(values.targetHealthyRollbackPercentage) || values.targetHealthyRollbackPercentage < 0 || values.targetHealthyRollbackPercentage > 100 ) { errors.targetHealthyRollbackPercentage = 'Healthy threshold must be between 0 and 100'; } if ( !Number.isFinite(values.delayBeforeDisableSeconds) || !Number.isInteger(values.delayBeforeDisableSeconds) || values.delayBeforeDisableSeconds < 0 ) { errors.delayBeforeDisableSeconds = 'Delay must be a non-negative whole number'; } return errors; } export function buildAmazonRollbackJob( application: Application, serverGroup: IAmazonServerGroup, rollbackType: AmazonRollbackType, values: IAmazonRollbackValues, ): IAmazonRollbackJob { return { interestingHealthProviderNames: values.interestingHealthProviderNames, platformHealthOnlyShowOverride: application.attributes?.platformHealthOnlyShowOverride, reason: values.reason, rollbackContext: { delayBeforeDisableSeconds: values.delayBeforeDisableSeconds, restoreServerGroupName: values.restoreServerGroupName, rollbackServerGroupName: serverGroup.name, targetHealthyRollbackPercentage: values.targetHealthyRollbackPercentage, }, rollbackType, }; } export class AmazonRollbackServerGroupModal extends React.Component< IAmazonRollbackServerGroupModalProps, IAmazonRollbackServerGroupModalState > { public static contextType = DeckRuntimeContext; public declare context: React.ContextType; public static defaultProps: Partial = { allServerGroups: [], closeModal: noop, dismissModal: noop, }; public static show( props: IAmazonRollbackServerGroupModalProps, runtimeServices: DeckRuntimeServices, ): Promise { return ReactModal.show(AmazonRollbackServerGroupModal, props, undefined, runtimeServices); } public constructor(props: IAmazonRollbackServerGroupModalProps) { super(props); const { application, previousServerGroup, serverGroup } = props; const desired = Number(serverGroup.capacity.desired); this.state = { initialValues: { delayBeforeDisableSeconds: 0, interestingHealthProviderNames: application.attributes?.platformHealthOnlyShowOverride && application.attributes?.platformHealthOnly ? ['Amazon'] : undefined, restoreServerGroupName: previousServerGroup?.name, targetHealthyRollbackPercentage: getDefaultAmazonHealthyRollbackPercentage(desired), }, taskMonitor: new TaskMonitor({ application, title: `Rollback ${serverGroup.name}`, onDismiss: () => this.props.dismissModal(), onTaskComplete: () => application.serverGroups.refresh(), }), verified: false, }; } private get rollbackType(): AmazonRollbackType { return getAmazonRollbackType(this.props.serverGroup, this.props.allServerGroups); } private close = (): void => this.props.dismissModal(); private label(serverGroup: IAmazonServerGroup): string { const buildNumber = serverGroup?.buildInfo?.jenkins?.number; return buildNumber ? `${serverGroup.name} (build #${buildNumber})` : serverGroup?.name || ''; } private minHealthy(percent: number): number { return Math.ceil((Number(this.props.serverGroup.capacity.desired) * percent) / 100); } private submit = (values: IAmazonRollbackValues): void => { const { allServerGroups, application, serverGroup } = this.props; const errors = validateAmazonRollbackValues( values, this.rollbackType, allServerGroups.map(({ name }) => name), ); if (!this.state.verified || Object.keys(errors).length > 0) { return; } const command = buildAmazonRollbackJob(application, serverGroup, this.rollbackType, values); this.state.taskMonitor.submit(() => this.context.services.serverGroupWriter.rollbackServerGroup(serverGroup, application, command), ); }; private renderRestoreTarget(values: IAmazonRollbackValues, setFieldValue: (name: string, value: any) => void) { if (this.rollbackType === 'PREVIOUS_IMAGE') { const previousImage = getAmazonPreviousImageServerGroup(this.props.serverGroup); return (
{previousImage.name} (no longer deployed)
Image: {previousImage.imageName} {previousImage.imageId && <> ({previousImage.imageId})} {previousImage.buildNumber !== undefined && ( <>
Build #{previousImage.buildNumber} )}
); } const enabled = this.props.allServerGroups.filter(({ isDisabled }) => !isDisabled); const disabled = this.props.allServerGroups.filter(({ isDisabled }) => isDisabled); const options = (serverGroups: IAmazonServerGroup[]) => serverGroups.map((candidate) => ( )); return ( ); } private renderOperations(values: IAmazonRollbackValues): JSX.Element { const { serverGroup } = this.props; const previousImage = getAmazonPreviousImageServerGroup(serverGroup); const restoreName = values.restoreServerGroupName || 'previous server group'; return (
    {this.rollbackType === 'EXPLICIT' ? ( <>
  1. Enable {restoreName}
  2. Resize {restoreName} to [min: {serverGroup.capacity.desired},{' '} max: {serverGroup.capacity.max}, desired:{' '} {serverGroup.capacity.desired}]
  3. ) : (
  4. Deploy {previousImage.imageId || previousImage.imageName} [min:{' '} {serverGroup.capacity.desired}, max: {serverGroup.capacity.max},{' '} desired: {serverGroup.capacity.desired}]
  5. )} {values.targetHealthyRollbackPercentage < 100 && (
  6. Wait for at least {this.minHealthy(values.targetHealthyRollbackPercentage)} instances to report as healthy
  7. )} {values.delayBeforeDisableSeconds > 0 &&
  8. Wait {values.delayBeforeDisableSeconds} seconds
  9. }
  10. Disable {serverGroup.name}
  11. Restore minimum capacity of {this.rollbackType === 'EXPLICIT' ? restoreName : 'new server group'}{' '} [min: {serverGroup.capacity.min}]

This rollback will affect server groups in {serverGroup.account} ({serverGroup.region}).

); } public render(): JSX.Element { const { application, serverGroup } = this.props; const eligibleTargetNames = this.props.allServerGroups.map(({ name }) => name); return ( <> initialValues={this.state.initialValues} onSubmit={this.submit} validate={(values) => validateAmazonRollbackValues(values, this.rollbackType, eligibleTargetNames)} render={(formik) => ( <> Rollback {serverGroup.name}
Restore to
{this.renderRestoreTarget(formik.values, formik.setFieldValue)} {formik.errors.restoreServerGroupName && ( )}
{application.attributes?.platformHealthOnlyShowOverride && (
formik.setFieldValue('interestingHealthProviderNames', names)} platformHealthType="Amazon" showHelpDetails={true} />
)} formik.setFieldValue('reason', reason)} />
Wait{' '} formik.setFieldValue('delayBeforeDisableSeconds', Number(event.target.value)) } placeholder="0" step={1} type="number" value={formik.values.delayBeforeDisableSeconds} />{' '} seconds before disabling {this.label(serverGroup)}. {formik.errors.delayBeforeDisableSeconds && ( )}
Consider rollback successful when{' '} formik.setFieldValue('targetHealthyRollbackPercentage', Number(event.target.value)) } type="number" value={formik.values.targetHealthyRollbackPercentage} />{' '} percent of instances are healthy. {formik.errors.targetHealthyRollbackPercentage && ( )}
Rollback Operations
{this.renderOperations(formik.values)}
this.setState({ verified })} /> )} /> ); } }