UNPKG

@spotinst/spinnaker-deck

Version:

Spinnaker-Deck service, forked with support to Spotinst

561 lines (515 loc) 22.4 kB
import UIROUTER_ANGULARJS from '@uirouter/angularjs'; import { StateService } from '@uirouter/core'; import { IQService, ITimeoutService, module } from 'angular'; import { get, identity, pickBy, uniq } from 'lodash'; import { REST } from 'core/api/ApiService'; import { Application } from 'core/application/application.model'; import { ApplicationDataSource } from 'core/application/service/applicationDataSource'; import { SETTINGS } from 'core/config/settings'; import { IExecution, IExecutionStage, IExecutionStageSummary } from 'core/domain'; import { IPipeline } from 'core/domain/IPipeline'; import { FilterModelService, ISortFilter } from 'core/filterModel'; import { ReactInjector } from 'core/reactShims'; import { ExecutionState } from 'core/state'; import { JsonUtils } from 'core/utils'; import { DebugWindow } from 'core/utils/consoleDebug'; import { IRetryablePromise, retryablePromise } from 'core/utils/retryablePromise'; import { ExecutionsTransformer } from './ExecutionsTransformer'; import { PipelineConfigService } from '../config/services/PipelineConfigService'; import { ExecutionFilterService } from '../filter/executionFilter.service'; export class ExecutionService { public get activeStatuses(): string[] { return ['RUNNING', 'SUSPENDED', 'PAUSED', 'NOT_STARTED']; } private runningLimit = SETTINGS.maxRunningExecutionsToRetrieve || 30; private ignoredStringValFields = [ 'asg', 'commits', 'history', 'hydrator', 'hydrated', 'instances', 'requisiteIds', 'requisiteStageRefIds', '$$hashKey', ]; constructor(private $q: IQService, private $state: StateService, private $timeout: ITimeoutService) {} public getRunningExecutions(applicationName: string): PromiseLike<IExecution[]> { return this.getFilteredExecutions(applicationName, this.activeStatuses, this.runningLimit, null, true); } private getFilteredExecutions( applicationName: string, statuses: string[], limit: number, pipelineConfigIds: string[] = null, expand = false, ): PromiseLike<IExecution[]> { const statusString = statuses.map((status) => status.toUpperCase()).join(',') || null; const call = pipelineConfigIds ? REST('/executions').query({ limit, pipelineConfigIds, statuses }).get() : REST('/applications') .path(applicationName, 'pipelines') .query({ limit, statuses: statusString, pipelineConfigIds, expand }) .get(); return call.then((data: IExecution[]) => { if (data) { data.forEach((execution: IExecution) => { execution.hydrated = expand; return this.cleanExecutionForDiffing(execution); }); return data; } return []; }); } /** * Returns a filtered list of executions for the given application * @param {string} applicationName the name of the application * @param {Application} application: if supplied, and pipeline parameters are present on the filter model, the * application will be used to correlate and filter the retrieved executions to only include those pipelines * @param {boolean} expand: if true, the resulting executions will include fully hydrated context, outputs, and tasks * fields * @return {<IExecution[]>} */ public getExecutions( applicationName: string, application: Application = null, expand = false, ): PromiseLike<IExecution[]> { const sortFilter: ISortFilter = ExecutionState.filterModel.asFilterModel.sortFilter; const tags = FilterModelService.getCheckValues(sortFilter.tags); const pipelines = Object.keys(sortFilter.pipeline); const statuses = Object.keys(pickBy(sortFilter.status || {}, identity)); const limit = sortFilter.count; if (application && (pipelines.length || tags.length)) { return this.getConfigIdsFromFilterModel(application).then((pipelineConfigIds) => { return this.getFilteredExecutions(application.name, statuses, limit, pipelineConfigIds, expand); }); } return this.getFilteredExecutions(applicationName, statuses, limit, null, expand); } public getExecution(executionId: string): PromiseLike<IExecution> { return REST('/pipelines') .path(executionId) .get() .then((execution: IExecution) => { const { application, name } = execution; execution.hydrated = true; this.cleanExecutionForDiffing(execution); if (application && name) { return REST('/applications') .path(application, 'pipelineConfigs', name) .get() .then((pipelineConfig: IPipeline) => { execution.pipelineConfig = pipelineConfig; return execution; }) .catch(() => execution); } return execution; }); } public transformExecutions(application: Application, executions: IExecution[], currentData: IExecution[] = []): void { if (!executions || !executions.length) { return; } executions.forEach((execution) => { const stringVal = this.stringifyExecution(execution); // do not transform if it hasn't changed const match = currentData.find((test: IExecution) => test.id === execution.id); if (!match || !match.stringVal || match.stringVal !== stringVal) { execution.stringVal = stringVal; ExecutionsTransformer.transformExecution(application, execution); } }); } private getConfigIdsFromFilterModel(application: Application): PromiseLike<string[]> { const sortFilter = ExecutionState.filterModel.asFilterModel.sortFilter; const tags = FilterModelService.getCheckValues(sortFilter.tags); const pipelines = Object.keys(sortFilter.pipeline); application.pipelineConfigs.activate(); return application.pipelineConfigs.ready().then(() => { const data = application.pipelineConfigs.data.concat(application.strategyConfigs.data); const configIdsFromCheckedPipelines = pipelines .map((p) => { const match = data.find((c: IPipeline) => c.name === p); return match ? match.id : null; }) .filter((id) => !!id); const configIdsFromCheckedTags = data .filter((p: IPipeline) => ExecutionFilterService.doesPipelineMatchCheckedTags(p, tags)) .map((p: IPipeline) => p.id); return configIdsFromCheckedPipelines.concat(uniq(configIdsFromCheckedTags)); }); } private cleanExecutionForDiffing(execution: IExecution): void { (execution.stages || []).forEach((stage: IExecutionStage) => this.removeInstances(stage)); } public toggleDetails(execution: IExecution, stageIndex: number, subIndex: number): void { const standalone = this.$state.current.name.endsWith('.executionDetails.execution'); if ( execution.id === this.$state.params.executionId && this.$state.current.name.includes('.execution') && stageIndex === undefined ) { this.$state.go('^'); return; } const index = stageIndex || 0; let stageSummary = get<IExecutionStageSummary>(execution, ['stageSummaries', index]); if (stageSummary && stageSummary.type === 'group') { if (subIndex === undefined) { // Disallow clicking on a group itself return; } stageSummary = get<IExecutionStageSummary>(stageSummary, ['groupStages', subIndex]); } stageSummary = stageSummary || ({ firstActiveStage: 0 } as IExecutionStageSummary); const params = { executionId: execution.id, stage: index, subStage: subIndex, step: stageSummary.firstActiveStage, } as any; // Can't show details of a grouped stage if (subIndex === undefined && stageSummary.type === 'group') { params.stage = null; params.step = null; return; } if (this.$state.includes('**.execution', params)) { if (!standalone) { this.$state.go('^'); } } else { if (this.$state.current.name.endsWith('.execution') || standalone) { this.$state.go('.', params); } else { this.$state.go('.execution', params); } } } // these fields are never displayed in the UI, so don't retain references to them, as they consume a lot of memory // on very large deployments private removeInstances(stage: IExecutionStage): void { if (stage.context) { delete stage.context.instances; delete stage.context.asg; if (stage.context.targetReferences) { stage.context.targetReferences.forEach((targetReference: any) => { delete targetReference.instances; delete targetReference.asg; }); } } } public startAndMonitorPipeline( app: Application, pipeline: string, trigger: any, ): PromiseLike<IRetryablePromise<void>> { const { executionService } = ReactInjector; return PipelineConfigService.triggerPipeline(app.name, pipeline, trigger).then((triggerResult) => executionService.waitUntilTriggeredPipelineAppears(app, triggerResult), ); } public waitUntilTriggeredPipelineAppears( application: Application, triggeredPipelineId: string, ): IRetryablePromise<any> { const closure = () => this.getExecution(triggeredPipelineId).then(() => application.executions.refresh()); return retryablePromise(closure, 1000, 10); } private waitUntilPipelineIsCancelled(application: Application, executionId: string): PromiseLike<any> { return this.waitUntilExecutionMatches( executionId, (execution: IExecution) => execution.status === 'CANCELED', ).then(() => application.executions.refresh()); } private waitUntilPipelineIsDeleted(application: Application, executionId: string): PromiseLike<any> { const deferred = this.$q.defer(); this.getExecution(executionId).then( () => this.$timeout(() => this.waitUntilPipelineIsDeleted(application, executionId).then(deferred.resolve), 1000), () => deferred.resolve(), ); deferred.promise.then(() => application.executions.refresh()); return deferred.promise; } public cancelExecution( application: Application, executionId: string, force?: boolean, reason?: string, ): PromiseLike<any> { return REST('/pipelines') .path(executionId, 'cancel') .query({ force, reason }) .put() .then(() => this.waitUntilPipelineIsCancelled(application, executionId)) .catch((exception) => { throw exception && exception.data ? exception.message : null; }); } public pauseExecution(application: Application, executionId: string): PromiseLike<any> { return REST('/pipelines') .path(executionId, 'pause') .put() .then(() => this.waitUntilExecutionMatches(executionId, (execution) => execution.status === 'PAUSED')) .then(() => application.executions.refresh()) .catch((exception) => { throw exception && exception.data ? exception.message : null; }); } public resumeExecution(application: Application, executionId: string): PromiseLike<any> { return REST('/pipelines') .path(executionId, 'resume') .put() .then(() => this.waitUntilExecutionMatches(executionId, (execution) => execution.status === 'RUNNING')) .then(() => application.executions.refresh()) .catch((exception) => { throw exception && exception.data ? exception.message : null; }); } public deleteExecution(application: Application, executionId: string): PromiseLike<any> { const promiseLike = REST('/pipelines') .path(executionId) .delete() .then(() => this.waitUntilPipelineIsDeleted(application, executionId)) .then(() => application.executions.refresh()) .catch((exception) => { throw exception && exception.data ? exception.message : null; }); return promiseLike; } public waitUntilExecutionMatches( executionId: string, matchPredicate: (execution: IExecution) => boolean, ): PromiseLike<IExecution> { return this.getExecution(executionId).then((execution) => { if (matchPredicate(execution)) { return execution; } return this.$timeout(() => this.waitUntilExecutionMatches(executionId, matchPredicate), 1000); }); } public getSectionCacheKey(groupBy: string, application: string, heading: string): string { return ['pipeline', groupBy, application, heading].join('#'); } public getProjectExecutions(project: string, limit = 1): PromiseLike<IExecution[]> { return REST('/projects') .path(project, 'pipelines') .query({ limit }) .get() .then((executions: IExecution[]) => { if (!executions || !executions.length) { return []; } executions.forEach((execution) => ExecutionsTransformer.transformExecution({} as Application, execution)); return executions.sort((a, b) => b.startTime - (a.startTime || Date.now())); }); } public addExecutionsToApplication(application: Application, executions: IExecution[] = []): IExecution[] { // only add executions if we actually got some executions back // this will fail if there was just one execution and someone just deleted it // but that is much less likely at this point than orca falling over under load, // resulting in an empty list of executions coming back if (application.executions.data && application.executions.data.length && executions.length) { const existingData = application.executions.data; const runningData = application.runningExecutions.data; // remove any that have dropped off, update any that have changed const toRemove: number[] = []; existingData.forEach((execution: IExecution, idx: number) => { const match = executions.find((test) => test.id === execution.id); const runningMatch = runningData.find((t: IExecution) => t.id === execution.id); if (match) { if (execution.stringVal && match.stringVal && execution.stringVal !== match.stringVal) { if (execution.status !== match.status) { application.executions.data[idx] = match; } else { // don't dehydrate! if (execution.hydrated === match.hydrated) { application.executions.data[idx] = match; } } } } // if it's from the running executions, leave it alone if (!match && !runningMatch) { toRemove.push(idx); } }); toRemove.reverse().forEach((idx) => existingData.splice(idx, 1)); // add any new ones executions.forEach((execution) => { if (!existingData.filter((test: IExecution) => test.id === execution.id).length) { existingData.push(execution); } }); return [...existingData]; } else { return executions; } } // adds running execution data to the execution data source public mergeRunningExecutionsIntoExecutions(application: Application): void { let updated = false; application.runningExecutions.data.forEach((re: IExecution) => { const match = application.executions.data.findIndex((e: IExecution) => e.id === re.id); if (match !== -1) { const oldKey = application.executions.data[match].stringVal; if (re.stringVal !== oldKey) { updated = true; application.executions.data[match] = re; } } else { updated = true; application.executions.data.push(re); } }); application.executions.data.forEach((execution: IExecution) => { if (execution.isActive && application.runningExecutions.data.every((e: IExecution) => e.id !== execution.id)) { this.getExecution(execution.id).then((updatedExecution) => { this.updateExecution(application, updatedExecution); }); } }); if (updated && !application.executions.reloadingForFilters) { application.executions.dataUpdated(); } } // remove any running execution data if the execution is completed public removeCompletedExecutionsFromRunningData(application: Application): void { const data = application.executions.data; const runningData = application.runningExecutions.data; data.forEach((e: IExecution) => { const match = runningData.findIndex((re: IExecution) => e.id === re.id); if (match !== -1 && !e.isActive) { runningData.splice(match, 1); } }); application.runningExecutions.dataUpdated(); } public updateExecution( application: Application, updatedExecution: IExecution, dataSource: ApplicationDataSource<IExecution[]> = application.executions, ): void { if (dataSource.data && dataSource.data.length) { dataSource.data.forEach((currentExecution, idx) => { if (updatedExecution.id === currentExecution.id) { updatedExecution.stringVal = this.stringifyExecution(updatedExecution); if ( updatedExecution.status !== currentExecution.status || currentExecution.stringVal !== updatedExecution.stringVal ) { ExecutionsTransformer.transformExecution(application, updatedExecution); dataSource.data[idx] = updatedExecution; dataSource.dataUpdated(); } } }); } } /** * Fetches a fully hydrated execution, then assigns all its values to the supplied execution. * If the execution is already hydrated, the operation does not re-fetch the execution. * * If this method is called multiple times, only the first call performs the fetch; * subsequent calls will return the promise produced by the first call. * * This is a mutating operation - it fills the context, outputs, and tasks on the stages of the unhydrated execution. * @param application the application owning the execution; needed because the stupid * transformExecution requires it. * @param unhydrated the execution to hydrate (which may already be hydrated) * @return a Promise, which resolves with the execution itself. */ public hydrate(application: Application, unhydrated: IExecution): Promise<IExecution> { if (unhydrated.hydrator) { return unhydrated.hydrator; } if (unhydrated.hydrated) { return Promise.resolve(unhydrated); } const executionHydrator = this.getExecution(unhydrated.id).then((hydrated) => { ExecutionsTransformer.transformExecution(application, hydrated); unhydrated.stages.forEach((s, i) => { // stages *should* be in the same order, so getting the hydrated one by index should be fine. // worth verifying, though, and, if not, find the stage by id (which makes this an O(n^2) operation instead of O(n)) const hydratedStage = hydrated.stages.length === unhydrated.stages.length && hydrated.stages[i].id === s.id ? hydrated.stages[i] : hydrated.stages.find((s2) => s.id === s2.id); if (hydratedStage) { s.context = hydratedStage.context; s.outputs = hydratedStage.outputs; s.tasks = hydratedStage.tasks; } }); unhydrated.hydrated = true; unhydrated.graphStatusHash = hydrated.graphStatusHash; unhydrated.stageSummaries = hydrated.stageSummaries; return unhydrated; }); unhydrated.hydrator = Promise.resolve(executionHydrator); return unhydrated.hydrator; } public getLastExecutionForApplicationByConfigId(appName: string, configId: string): PromiseLike<IExecution> { return this.getFilteredExecutions(appName, [], 1) .then((executions: IExecution[]) => { return executions.filter((execution) => { return execution.pipelineConfigId === configId; }); }) .then((executionsByConfigId) => { return executionsByConfigId[0]; }); } /** * Returns a list of recent executions for the supplied set of IDs, optionally filtered by status * @param {string[]} pipelineConfigIds the pipeline config IDs * @param {{limit?: number; statuses?: string; transform?: boolean; application?: Application}} options: * transform: if true - and the application option is set, the execution transformer will run on each result (default: false) * application: if transform is true, the application to use when transforming the executions (default: null) * limit: the number of executions per config ID to retrieve (default: whatever Gate sets) * statuses: an optional set of execution statuses (default: all) * @return {PromiseLike<IExecution[]>} */ public getExecutionsForConfigIds( pipelineConfigIds: string[], options: { limit?: number; statuses?: string; transform?: boolean; application?: Application } = {}, ): PromiseLike<IExecution[]> { const { limit, statuses, transform, application } = options; return REST('/executions') .query({ limit, pipelineConfigIds: (pipelineConfigIds || []).join(','), statuses }) .get() .then((data: IExecution[]) => { if (data) { if (transform && application) { data.forEach((execution: IExecution) => ExecutionsTransformer.transformExecution(application, execution)); } return data.sort((a, b) => (b.buildTime || 0) - (a.buildTime || 0)); } return []; }) .catch(() => [] as IExecution[]); } public patchExecution(executionId: string, stageId: string, data: any): PromiseLike<any> { return REST('/pipelines').path(executionId, 'stages', stageId).patch(data); } private stringifyExecution(execution: IExecution): string { const transient = { ...execution }; transient.stages = transient.stages.filter((s) => s.status !== 'SUCCEEDED' && s.status !== 'NOT_STARTED'); return this.stringify(transient); } private stringify(object: IExecution | IExecutionStageSummary): string { return JsonUtils.makeSortedStringFromAngularObject({ ...object }, this.ignoredStringValFields); } } export const EXECUTION_SERVICE = 'spinnaker.core.pipeline.executions.service'; module(EXECUTION_SERVICE, [UIROUTER_ANGULARJS]).factory('executionService', [ '$q', '$state', '$timeout', ($q: IQService, $state: StateService, $timeout: ITimeoutService) => new ExecutionService($q, $state, $timeout), ]); DebugWindow.addInjectable('executionService');