import React from 'react'; import type { IExecutionDetailsSectionProps, IStage, IStageConfigProps } from '@spinnaker/core'; import { AuthenticationService, BakeExecutionLabel, BakeryReader, ChecklistInput, ExecutionDetailsSection, ExecutionDetailsTasks, Markdown, Registry, SETTINGS, Spinner, StageConfigField, StageFailureMessage, } from '@spinnaker/core'; import { AWSProviderSettings } from '../../../aws.settings'; const storeTypes = ['ebs', 'docker']; interface IBaseOsOption { id: string; detailedDescription?: string; shortDescription?: string; displayName?: string; vmTypes?: string[]; } interface IAwsBakeStageState { baseLabelOptions: string[]; baseOsOptions: IBaseOsOption[]; extendedAttributeKey: string; extendedAttributeValue: string; loading: boolean; loadError: boolean; regions: string[]; roscoMode: boolean; showAdvancedOptions: boolean; vmTypes: string[]; } function deleteEmptyProperties(stage: IStage): IStage { return Object.keys(stage).reduce((acc, key) => { if ((stage as any)[key] !== '') { (acc as any)[key] = (stage as any)[key]; } return acc; }, {} as IStage); } function baseOsDescription(baseOsOption: IBaseOsOption): string { const baseOsName = baseOsOption?.displayName || baseOsOption?.id || ''; return baseOsOption?.shortDescription ? `${baseOsName} (${baseOsOption.shortDescription})` : baseOsName; } function roscoMode(stage: any): boolean { return ( SETTINGS.feature.roscoMode || (typeof SETTINGS.feature.roscoSelector === 'function' && SETTINGS.feature.roscoSelector(stage)) ); } function showMigrationFields(pipeline: any): boolean { return pipeline?.migrationStatus !== 'Started'; } function showDockerPreview(stage: any): boolean { return !!AWSProviderSettings.dockerBakeryDeprecated && stage.storeType === 'docker'; } export class AwsBakeStageConfig extends React.Component { private mounted = false; public state: IAwsBakeStageState = { baseLabelOptions: [], baseOsOptions: [], extendedAttributeKey: '', extendedAttributeValue: '', loading: true, loadError: false, regions: [], roscoMode: false, showAdvancedOptions: false, vmTypes: ['hvm', 'pv'], }; public componentDidMount(): void { this.mounted = true; this.initialize(); } public componentWillUnmount(): void { this.mounted = false; } private initialize(): void { const stage = this.props.stage; Promise.all([ BakeryReader.getRegions('aws'), BakeryReader.getBaseOsOptions('aws'), BakeryReader.getBaseLabelOptions(), ]) .then(([regions, baseOsOptions, baseLabelOptions]) => { if (!this.mounted) { return; } const baseOsOptionsList = baseOsOptions.baseImages || []; const nextStage = this.applyDefaults(stage, regions as string[], baseOsOptionsList, baseLabelOptions); this.replaceStage(nextStage); this.setState({ baseLabelOptions, baseOsOptions: baseOsOptionsList, loading: false, loadError: false, regions: regions as string[], roscoMode: roscoMode(nextStage), showAdvancedOptions: this.showAdvanced(nextStage), vmTypes: this.computeVmTypes(baseOsOptionsList, nextStage.baseOs), }); }) .catch(() => { if (this.mounted) { this.setState({ loading: false, loadError: true }); } }); } private applyDefaults( stage: IStage, regions: string[], baseOsOptions: IBaseOsOption[], baseLabelOptions: string[], ): IStage { const nextStage: any = deleteEmptyProperties({ ...stage }); nextStage.extendedAttributes = nextStage.extendedAttributes || {}; nextStage.regions = (nextStage.regions && [...nextStage.regions].sort()) || []; if (!nextStage.user) { nextStage.user = AuthenticationService.getAuthenticatedUser()?.name; } if (!nextStage.storeType && storeTypes.length) { nextStage.storeType = storeTypes[0]; } if (regions.length === 1) { nextStage.region = regions[0]; } else if (nextStage.region && !regions.includes(nextStage.region)) { delete nextStage.region; } if (!nextStage.regions.length && this.props.application.defaultRegions.aws) { nextStage.regions = [this.props.application.defaultRegions.aws]; } let baseOsOptionsWithCustom = baseOsOptions; if (!nextStage.baseOs && baseOsOptions.length) { nextStage.baseOs = baseOsOptions[0].id; } else if (nextStage.baseOs && !baseOsOptions.find((baseOs) => baseOs.id === nextStage.baseOs)) { baseOsOptionsWithCustom = [ ...baseOsOptions, { id: nextStage.baseOs, detailedDescription: 'Custom', vmTypes: ['hvm', 'pv'] }, ]; } if (!nextStage.baseLabel && baseLabelOptions.length) { nextStage.baseLabel = baseLabelOptions[0]; } const vmTypes = this.computeVmTypes(baseOsOptionsWithCustom, nextStage.baseOs); if (!nextStage.vmType && vmTypes.length) { nextStage.vmType = vmTypes[0]; } return nextStage; } private computeVmTypes(baseOsOptions: IBaseOsOption[], baseOs: string): string[] { if (baseOsOptions.length && baseOsOptions.every((option) => option.vmTypes)) { const match = baseOsOptions.find((option) => option.id === baseOs); return match?.vmTypes || ['hvm', 'pv']; } return ['hvm', 'pv']; } private showAdvanced(stage: any): boolean { return !!( stage.templateFileName || (stage.extendedAttributes && Object.keys(stage.extendedAttributes).length > 0) || stage.varFileName || stage.baseName || stage.baseAmi || stage.amiName || stage.amiSuffix || stage.rootVolumeSize ); } private replaceStage(nextStage: IStage): void { if (this.props.updateStage) { this.props.updateStage(nextStage); } else { this.props.updateStageField(nextStage); } } private updateStage(changes: any): void { const nextStage: any = deleteEmptyProperties({ ...this.props.stage, ...changes }); this.replaceStage(nextStage); this.setState({ roscoMode: roscoMode(nextStage) }); if (changes.baseOs !== undefined) { const vmTypes = this.computeVmTypes(this.state.baseOsOptions, changes.baseOs); this.setState({ vmTypes }); if (vmTypes.length && !vmTypes.includes(nextStage.vmType)) { this.updateStage({ vmType: vmTypes[0] }); } } } private updateExtendedAttribute(key: string, value: string): void { this.updateStage({ extendedAttributes: { ...((this.props.stage as any).extendedAttributes || {}), [key]: value } }); } private removeExtendedAttribute(key: string): void { const extendedAttributes = { ...((this.props.stage as any).extendedAttributes || {}) }; delete extendedAttributes[key]; this.updateStage({ extendedAttributes }); } private addExtendedAttribute(): void { const key = this.state.extendedAttributeKey.trim(); if (!key) { return; } this.updateExtendedAttribute(key, this.state.extendedAttributeValue); this.setState({ extendedAttributeKey: '', extendedAttributeValue: '', showAdvancedOptions: true }); } private renderDockerPreview(): React.ReactNode { const stage = this.props.stage as any; const showStoreTypeSelector = !this.state.roscoMode && showMigrationFields(this.props.pipeline); return ( <> {AWSProviderSettings.dockerBakeWarning && (
)}
{showStoreTypeSelector && ( {storeTypes.map((storeType) => ( ))} )} {(stage.regions || []).map((region: string) => ( {region} ))} {String(!!stage.skipRegionDetection)} {stage.package} {stage.baseOs} {stage.vmType} {stage.storeType} {stage.baseLabel} ); } public render() { if (this.state.loading) { return ; } if (this.state.loadError) { return
Unable to load bakery options.
; } const stage = this.props.stage as any; if (showDockerPreview(stage)) { return
{this.renderDockerPreview()}
; } const showMigration = showMigrationFields(this.props.pipeline); const showVmTypeSelector = this.state.vmTypes.length > 1 && showMigration; const showStoreType = !AWSProviderSettings.dockerBakeryDeprecated; const showStoreTypeSelector = !this.state.roscoMode && showMigration && showStoreType; const showRebake = this.state.roscoMode || stage.rebake; const showAdvancedFields = this.state.showAdvancedOptions; const extendedAttributes = stage.extendedAttributes || {}; const packageLabel = stage.storeType === 'docker' ? 'Repo Path' : 'Package'; return (
{AWSProviderSettings.bakeWarning && (
)} this.updateStage({ regions: event.target.value })} showSelectAll={true} stringOptions={this.state.regions} value={stage.regions || []} />
this.updateStage({ package: event.target.value })} /> {showVmTypeSelector && ( {this.state.vmTypes.map((vmType) => ( ))} )} {showStoreTypeSelector && ( {storeTypes.map((storeType) => ( ))} )} {this.state.baseLabelOptions.map((baseLabel) => ( ))} {showRebake && (
)}
{showAdvancedFields && ( <> {showMigration && ( this.updateStage({ templateFileName: event.target.value })} /> )} {Object.keys(extendedAttributes).map((key) => ( ))}
Key Value Actions
{key} this.updateExtendedAttribute(key, event.target.value)} /> { event.preventDefault(); this.removeExtendedAttribute(key); }} > Remove
this.setState({ extendedAttributeKey: event.target.value })} /> this.setState({ extendedAttributeValue: event.target.value })} />
{showMigration && ( this.updateStage({ varFileName: event.target.value })} /> )} {!!AWSProviderSettings.minRootVolumeSize && showMigration && ( this.updateStage({ rootVolumeSize: event.target.value })} />{' '} GB (minimum: {AWSProviderSettings.minRootVolumeSize}) )} {stage.storeType !== 'docker' && showMigration && ( this.updateStage({ baseName: event.target.value })} /> )} {stage.storeType !== 'docker' && showMigration && ( this.updateStage({ baseAmi: event.target.value })} /> )} {showMigration && ( this.updateStage({ amiName: event.target.value })} /> )} {showMigration && ( this.updateStage({ amiSuffix: event.target.value })} /> )} )}
); } } export function AwsBakeExecutionDetails({ current, execution, name, stage }: IExecutionDetailsSectionProps) { const context = (stage as any).context || {}; const isRoscoMode = roscoMode(context); const showRebake = isRoscoMode || (execution as any)?.trigger?.rebake || context.rebake; const bakeFailedNoError = context.status?.result === 'FAILURE' && !(stage as any).failureMessage; const urlTemplate = (isRoscoMode && SETTINGS.roscoDetailUrl ? SETTINGS.roscoDetailUrl : SETTINGS.bakeryDetailUrl) || ''; const bakeryDetailUrl = urlTemplate .replace(/\{\{\s*context\.region\s*\}\}/g, context.region || '') .replace(/\{\{\s*context\.status\.resourceId\s*\}\}/g, context.status?.resourceId || ''); return (
Image
{context.ami}
Region
{context.region}
Package
{context.package}
Base OS
{context.baseOs}
VM Type
{(context.vmType || '').toUpperCase()}
{!isRoscoMode && ( <>
Store Type
{(context.storeType || '').toUpperCase()}
Label
{context.baseLabel}
)} {showRebake && ( <>
Rebake
{String((execution as any)?.trigger?.rebake || context.rebake || false)}
)} {context.templateFileName && ( <>
Template
{context.templateFileName}
)} {context.varFileName && ( <>
Var File
{context.varFileName}
)}
{context.region && context.status?.resourceId && (
{context.previouslyBaked &&
No changes detected; reused existing bake
} {context.imageName && (
Image:
{context.imageName}
({context.ami})
)} {bakeFailedNoError && Bake failed. } View Bakery Details {bakeFailedNoError && for more info.}
)}
); } (AwsBakeExecutionDetails as any).title = 'bakeConfig'; export const awsBakeStage = { key: 'bake', provides: 'bake', cloudProvider: 'aws', label: 'Bake', description: 'Bakes an image', component: AwsBakeStageConfig, executionDetailsSections: [AwsBakeExecutionDetails as any, ExecutionDetailsTasks], executionLabelComponent: BakeExecutionLabel, extraLabelLines: (stage: any) => { return stage.masterStage.context.allPreviouslyBaked || stage.masterStage.context.somePreviouslyBaked ? 1 : 0; }, supportsCustomTimeout: true, validators: [ { type: 'requiredField', fieldName: 'package' }, { type: 'requiredField', fieldName: 'regions' }, { type: 'upstreamVersionProvided', checkParentTriggers: true, getMessage: (labels: string[]) => 'Bake stages should always have a stage or trigger preceding them that provides version information: ' + '
    ' + labels.map((label) => `
  • ${label}
  • `).join('') + '
' + 'Otherwise, Spinnaker will bake and deploy the most-recently built package.', }, ], restartable: true, }; export function registerAwsBakeStage() { Registry.pipeline.registerStage(awsBakeStage); } registerAwsBakeStage();