UNPKG

@atomist/sdm

Version:

Atomist Software Delivery Machine SDK

473 lines (419 loc) 17 kB
/* * Copyright © 2020 Atomist, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { configurationValue } from "@atomist/automation-client/lib/configuration"; import { RetryOptions } from "@atomist/automation-client/lib/util/retry"; import * as _ from "lodash"; import { goalData } from "../../api-helper/goal/sdmGoal"; import { LogSuppressor } from "../../api-helper/log/logInterpreters"; import { AbstractSoftwareDeliveryMachine } from "../../api-helper/machine/AbstractSoftwareDeliveryMachine"; import { InterpretLog } from "../../spi/log/InterpretedLog"; import { DefaultFacts, StatefulPushListenerInvocation, } from "../dsl/goalContribution"; import { GoalExecutionListener } from "../listener/GoalStatusListener"; import { Registerable, registerRegistrable, } from "../machine/Registerable"; import { SoftwareDeliveryMachine } from "../machine/SoftwareDeliveryMachine"; import { PushTest } from "../mapping/PushTest"; import { AnyPush } from "../mapping/support/commonPushTests"; import { ServiceRegistration, ServiceRegistrationGoalDataKey, } from "../registration/ServiceRegistration"; import { createPredicatedGoalExecutor, createRetryingGoalExecutor, WaitRules, } from "./common/createGoal"; import { Goal, GoalDefinition, GoalWithPrecondition, isGoalDefinition, } from "./Goal"; import { ExecuteGoal, GoalProjectListenerRegistration, } from "./GoalInvocation"; import { DefaultGoalNameGenerator } from "./GoalNameGenerator"; import { Goals } from "./Goals"; import { ReportProgress } from "./progress/ReportProgress"; import { GoalEnvironment, IndependentOfEnvironment, ProductionEnvironment, StagingEnvironment, } from "./support/environment"; import { GoalFulfillmentCallback } from "./support/GoalImplementationMapper"; export type Fulfillment = Implementation | SideEffect; /** * Register a fulfillment with basic details */ export interface FulfillmentRegistration { /** * Name of goal implementation */ name: string; /** * Optional push test to identify the types of projects and pushes this implementation * should get invoked on when the goal gets scheduled */ pushTest?: PushTest; } /** * Register a goal implementation with required details */ export interface ImplementationRegistration extends FulfillmentRegistration { /** * Optional log interpreter for this goal implementations log output */ logInterpreter?: InterpretLog; /** * Optional progress reporter for this goal implementation */ progressReporter?: ReportProgress; } export interface Implementation extends ImplementationRegistration { goalExecutor: ExecuteGoal; } export function isImplementation(f: Fulfillment): f is Implementation { return !!f && !!(f as Implementation).goalExecutor && true; } export interface SideEffect { name: string; registration: string; pushTest?: PushTest; } export function isSideEffect(f: Fulfillment): f is SideEffect { return !isImplementation(f); } /** * Subset of the GoalDefinition interface to use by typed Goals to allow * specifying some aspect of the Goal that are specific to the current use case. */ export interface FulfillableGoalDetails { displayName?: string; uniqueName?: string; environment?: string | GoalEnvironment; approval?: boolean; preApproval?: boolean; retry?: boolean; isolate?: boolean; descriptions?: { planned?: string; requested?: string; completed?: string; inProcess?: string; failed?: string; waitingForApproval?: string; waitingForPreApproval?: string; canceled?: string; stopped?: string; }; preCondition?: WaitRules; retryCondition?: RetryOptions; } /** * Extension to GoalDefinition that allows to specify additional WaitRules. */ export interface PredicatedGoalDefinition extends GoalDefinition { preCondition?: WaitRules; retryCondition?: RetryOptions; } export interface Parameterized { parameters?: DefaultFacts; } export interface PlannedGoal extends Parameterized { details?: Omit<FulfillableGoalDetails, "uniqueName" | "environment">; fulfillment?: { registration: string; name: string; }; } export type PlannedGoals = Record<string, { goals: PlannedGoal | Array<PlannedGoal | PlannedGoal[]>, dependsOn?: string | string[]; }>; export interface PlannableGoal { plan?(pli: StatefulPushListenerInvocation, goals: Goals): Promise<PlannedGoals | PlannedGoal>; } /** * Goal that registers goal implementations, side effects and callbacks on the * current SDM. No additional registration with the SDM is needed. */ export abstract class FulfillableGoal extends GoalWithPrecondition implements Registerable, PlannableGoal { public readonly fulfillments: Fulfillment[] = []; public readonly callbacks: GoalFulfillmentCallback[] = []; public readonly projectListeners: GoalProjectListenerRegistration[] = []; public readonly goalListeners: GoalExecutionListener[] = []; public sdm: SoftwareDeliveryMachine; constructor(public definitionOrGoal: PredicatedGoalDefinition | Goal, ...dependsOn: Goal[]) { super(isGoalDefinition(definitionOrGoal) ? definitionOrGoal : definitionOrGoal.definition, ...dependsOn); registerRegistrable(this); } public register(sdm: SoftwareDeliveryMachine): void { this.sdm = sdm; this.fulfillments.forEach(f => this.registerFulfillment(f)); this.callbacks.forEach(cb => this.registerCallback(cb)); this.goalListeners.forEach(gl => sdm.addGoalExecutionListener(gl)); } public withProjectListener(listener: GoalProjectListenerRegistration): this { this.projectListeners.push(listener); return this; } public withExecutionListener(listener: GoalExecutionListener): this { const wrappedListener = async gi => { if (gi.goalEvent.uniqueName === this.uniqueName) { return listener(gi); } }; if (this.sdm) { this.sdm.addGoalExecutionListener(wrappedListener); } this.goalListeners.push(wrappedListener); return this; } public withService(registration: ServiceRegistration<any>): this { this.addFulfillmentCallback({ goal: this, callback: async (goalEvent, repoContext) => { const service = await registration.service(goalEvent, repoContext); if (!!service) { const data = goalData(goalEvent); const servicesData = {}; _.set<any>(servicesData, `${ServiceRegistrationGoalDataKey}.${registration.name}`, service); goalEvent.data = JSON.stringify(_.merge(data, servicesData)); } return goalEvent; }, }); return this; } protected addFulfillmentCallback(cb: GoalFulfillmentCallback): this { if (this.sdm) { this.registerCallback(cb); } this.callbacks.push(cb); return this; } protected addFulfillment(fulfillment: Fulfillment): this { if (this.sdm) { this.registerFulfillment(fulfillment); } this.fulfillments.push(fulfillment); return this; } private registerFulfillment(fulfillment: Fulfillment): void { if (isImplementation(fulfillment)) { let goalExecutor = fulfillment.goalExecutor; // Wrap the ExecuteGoal instance with WaitRules if provided if (isGoalDefinition(this.definitionOrGoal) && !!this.definitionOrGoal.preCondition) { goalExecutor = createPredicatedGoalExecutor( this.definitionOrGoal.uniqueName, goalExecutor, this.definitionOrGoal.preCondition); } if (isGoalDefinition(this.definitionOrGoal) && !!this.definitionOrGoal.retryCondition) { goalExecutor = createRetryingGoalExecutor( this.definitionOrGoal.uniqueName, goalExecutor, this.definitionOrGoal.retryCondition); } (this.sdm as AbstractSoftwareDeliveryMachine).addGoalImplementation( fulfillment.name, this, goalExecutor, { pushTest: fulfillment.pushTest || AnyPush, progressReporter: fulfillment.progressReporter, logInterpreter: fulfillment.logInterpreter, projectListeners: this.projectListeners, }); } else if (isSideEffect(fulfillment)) { (this.sdm as AbstractSoftwareDeliveryMachine).addGoalSideEffect( this, fulfillment.name, fulfillment.registration, fulfillment.pushTest); } } public async plan(pli: StatefulPushListenerInvocation, goals: Goals): Promise<PlannedGoals | PlannedGoal> { return undefined; } private registerCallback(cb: GoalFulfillmentCallback): void { this.sdm.goalFulfillmentMapper.addFulfillmentCallback(cb); } } /** * Goal that accepts registrations of R. */ export abstract class FulfillableGoalWithRegistrations<R> extends FulfillableGoal { public readonly registrations: R[] = []; constructor(public definitionOrGoal: PredicatedGoalDefinition | Goal, ...dependsOn: Goal[]) { super(definitionOrGoal, ...dependsOn); } public with(registration: R): this { this.registrations.push(registration); return this; } } /** * Goal that accepts registrations of R and listeners of L. */ export abstract class FulfillableGoalWithRegistrationsAndListeners<R, L> extends FulfillableGoalWithRegistrations<R> { public readonly listeners: L[] = []; constructor(public definitionOrGoal: PredicatedGoalDefinition | Goal, ...dependsOn: Goal[]) { super(definitionOrGoal, ...dependsOn); } public withListener(listener: L): this { this.listeners.push(listener); return this; } } /** * Generic goal that can be used with a GoalDefinition. * Register goal implementations or side effects to this goal instance. */ export class GoalWithFulfillment extends FulfillableGoal { public withCallback(cb: GoalFulfillmentCallback): this { this.addFulfillmentCallback(cb); return this; } public with(fulfillment: Fulfillment): this { this.addFulfillment(fulfillment); return this; } } /** * Creates a new GoalWithFulfillment instance using conventions if overwrites aren't provided * * Call this from your machine.ts where you configure your sdm to create a custom goal. * * Caution: if you wrap this in another function, then you MUST provide details.uniqueName, * because the default is based on where in the code this `goal` function is called. * * @param details It is highly recommended that you supply at least uniqueName. * @param goalExecutor * @param options */ export function goal(details: FulfillableGoalDetails = {}, goalExecutor?: ExecuteGoal, options?: { pushTest?: PushTest, logInterpreter?: InterpretLog, progressReporter?: ReportProgress, plan?: (pli: StatefulPushListenerInvocation, goals: Goals) => Promise<PlannedGoals | PlannedGoal>, }): GoalWithFulfillment { const def = getGoalDefinitionFrom(details, DefaultGoalNameGenerator.generateName(details.displayName || "goal")); const g = new GoalWithFulfillment(def); if (!!goalExecutor) { const optsToUse = { pushTest: AnyPush, logInterpreter: LogSuppressor, ...(!!options ? options : {}), }; g.with({ name: def.uniqueName, goalExecutor, ...optsToUse, }); if (!!optsToUse.plan) { g.plan = optsToUse.plan; } } return g; } /** * Construct a PredicatedGoalDefinition from the provided goalDetails * @param goalDetails * @param uniqueName * @param definition */ // tslint:disable:cyclomatic-complexity export function getGoalDefinitionFrom(goalDetails: FulfillableGoalDetails | string, uniqueName: string, definition?: GoalDefinition): { uniqueName: string } | PredicatedGoalDefinition { if (typeof goalDetails === "string") { return { ...(definition || {}), uniqueName: goalDetails || uniqueName, }; } else { const defaultDefinition: Partial<GoalDefinition> = { ...(definition || {}), }; const goalDetailsToUse = goalDetails || {}; if (!!goalDetailsToUse.descriptions) { defaultDefinition.canceledDescription = goalDetailsToUse.descriptions.canceled || defaultDefinition.canceledDescription; defaultDefinition.completedDescription = goalDetailsToUse.descriptions.completed || defaultDefinition.completedDescription; defaultDefinition.failedDescription = goalDetailsToUse.descriptions.failed || defaultDefinition.failedDescription; defaultDefinition.plannedDescription = goalDetailsToUse.descriptions.planned || defaultDefinition.plannedDescription; defaultDefinition.requestedDescription = goalDetailsToUse.descriptions.requested || defaultDefinition.requestedDescription; defaultDefinition.stoppedDescription = goalDetailsToUse.descriptions.stopped || defaultDefinition.stoppedDescription; defaultDefinition.waitingForApprovalDescription = goalDetailsToUse.descriptions.waitingForApproval || defaultDefinition.waitingForApprovalDescription; defaultDefinition.waitingForPreApprovalDescription = goalDetailsToUse.descriptions.waitingForPreApproval || defaultDefinition.waitingForPreApprovalDescription; defaultDefinition.workingDescription = goalDetailsToUse.descriptions.inProcess || defaultDefinition.workingDescription; } return { ...defaultDefinition, displayName: goalDetailsToUse.displayName || defaultDefinition.displayName, uniqueName: goalDetailsToUse.uniqueName || uniqueName, environment: getEnvironment(goalDetailsToUse), approvalRequired: goalDetailsToUse.approval || defaultDefinition.approvalRequired, preApprovalRequired: goalDetailsToUse.preApproval || defaultDefinition.preApprovalRequired, retryFeasible: goalDetailsToUse.retry || defaultDefinition.retryFeasible, isolated: goalDetailsToUse.isolate || defaultDefinition.isolated, preCondition: goalDetailsToUse.preCondition, }; } } /** * Merge Goal configuration options into a final options object. * Starts off by merging the explicitly provided options over the provided defaults; finally merges the configuration * values at the given configuration path (prefixed with sdm.) over the previous merge. * @param defaults * @param explicit * @param configurationPath */ export function mergeOptions<OPTIONS>(defaults: OPTIONS, explicit: OPTIONS, configurationPath?: string): OPTIONS { const options: OPTIONS = _.merge(_.cloneDeep(defaults), explicit || {}); if (!!configurationPath) { const configurationOptions = configurationValue<OPTIONS>(`sdm.${configurationPath}`, {} as any); return _.merge(options, configurationOptions); } return options; } function getEnvironment(details?: { environment?: string | GoalEnvironment }): GoalEnvironment { if (details && details.environment && typeof details.environment === "string") { switch (details.environment) { case "testing": return StagingEnvironment; case "production": return ProductionEnvironment; default: return IndependentOfEnvironment; } } else if (details && typeof details.environment !== "string") { return details.environment; } else { return IndependentOfEnvironment; } }