@atomist/sdm
Version:
Atomist Software Delivery Machine SDK
497 lines (455 loc) • 17.9 kB
text/typescript
/*
* 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 { HandlerContext } from "@atomist/automation-client/lib/HandlerContext";
import { failure, HandlerResult, Success } from "@atomist/automation-client/lib/HandlerResult";
import { RemoteRepoRef } from "@atomist/automation-client/lib/operations/common/RepoId";
import { GitProject } from "@atomist/automation-client/lib/project/git/GitProject";
import { logger } from "@atomist/automation-client/lib/util/logger";
import * as _ from "lodash";
import * as path from "path";
import { AddressChannels } from "../../api/context/addressChannels";
import { createSkillContext } from "../../api/context/skillContext";
import { ExecuteGoalResult, isFailure } from "../../api/goal/ExecuteGoalResult";
import { Goal } from "../../api/goal/Goal";
import {
ExecuteGoal,
GoalInvocation,
GoalProjectListenerEvent,
GoalProjectListenerRegistration,
} from "../../api/goal/GoalInvocation";
import { ReportProgress } from "../../api/goal/progress/ReportProgress";
import { SdmGoalEvent } from "../../api/goal/SdmGoalEvent";
import { GoalImplementation } from "../../api/goal/support/GoalImplementationMapper";
import { GoalExecutionListener, GoalExecutionListenerInvocation } from "../../api/listener/GoalStatusListener";
import { SoftwareDeliveryMachineConfiguration } from "../../api/machine/SoftwareDeliveryMachineOptions";
import { AnyPush } from "../../api/mapping/support/commonPushTests";
import { InterpretLog } from "../../spi/log/InterpretedLog";
import { ProgressLog } from "../../spi/log/ProgressLog";
import { isLazyProjectLoader, LazyProject } from "../../spi/project/LazyProjectLoader";
import { ProjectLoader } from "../../spi/project/ProjectLoader";
import { SdmGoalState } from "../../typings/types";
import { format } from "../log/format";
import { WriteToAllProgressLog } from "../log/WriteToAllProgressLog";
import { spawnLog } from "../misc/child_process";
import { toToken } from "../misc/credentials/toToken";
import { reportFailureInterpretation } from "../misc/reportFailureInterpretation";
import { serializeResult } from "../misc/result";
import { ProjectListenerInvokingProjectLoader } from "../project/ProjectListenerInvokingProjectLoader";
import { mockGoalExecutor } from "./mock";
import { descriptionFromState, updateGoal } from "./storeGoals";
class GoalExecutionError extends Error {
public readonly where: string;
public readonly result?: ExecuteGoalResult;
public readonly cause?: Error;
constructor(params: { where: string; result?: ExecuteGoalResult; cause?: Error }) {
super("Failure in " + params.where);
Object.setPrototypeOf(this, new.target.prototype);
this.where = params.where;
this.result = params.result;
this.cause = params.cause;
}
get description(): string {
const resultDescription = this.result ? ` Result code ${this.result.code} ${this.result.message}` : "";
const causeDescription = this.cause ? ` Caused by: ${this.cause.message}` : "";
return `Failure in ${this.where}:${resultDescription}${causeDescription}`;
}
}
/**
* Central function to execute a goal with progress logging
*/
export async function executeGoal(
rules: { projectLoader: ProjectLoader; goalExecutionListeners: GoalExecutionListener[] },
implementation: GoalImplementation,
gi: GoalInvocation,
): Promise<ExecuteGoalResult> {
const { goal, goalEvent, addressChannels, progressLog, id, context, credentials, configuration, preferences } = gi;
const { progressReporter, logInterpreter, projectListeners } = implementation;
const implementationName = goalEvent.fulfillment.name;
if (!!progressReporter) {
gi.progressLog = new WriteToAllProgressLog(
goalEvent.name,
gi.progressLog,
new ProgressReportingProgressLog(progressReporter, goalEvent, gi.context),
);
}
const push = goalEvent.push;
logger.info(`Starting goal '%s' on '%s/%s/%s'`, goalEvent.uniqueName, push.repo.owner, push.repo.name, push.branch);
async function notifyGoalExecutionListeners(
sge: SdmGoalEvent,
result?: ExecuteGoalResult,
error?: Error,
): Promise<void> {
const inProcessGoalExecutionListenerInvocation: GoalExecutionListenerInvocation = {
id,
context,
addressChannels,
configuration,
preferences,
credentials,
goal,
goalEvent: sge,
error,
result,
skill: createSkillContext(context),
};
await Promise.all(
rules.goalExecutionListeners.map(gel => {
try {
return gel(inProcessGoalExecutionListenerInvocation);
} catch (e) {
logger.warn(`GoalExecutionListener failed: ${e.message}`);
logger.debug(e);
return undefined;
}
}),
);
}
const inProcessGoalEvent = await markGoalInProcess({
ctx: context,
goalEvent,
goal,
progressLogUrl: progressLog.url,
});
await notifyGoalExecutionListeners(inProcessGoalEvent);
try {
const goalInvocation = prepareGoalInvocation(gi, projectListeners);
// execute pre hook
const preHookResult: ExecuteGoalResult =
(await executeHook(rules, goalInvocation, inProcessGoalEvent, "pre").catch(async err => {
throw new GoalExecutionError({ where: "executing pre-goal hook", cause: err });
})) || Success;
if (isFailure(preHookResult)) {
throw new GoalExecutionError({ where: "executing pre-goal hook", result: preHookResult });
}
// execute the actual goal
const goalResult: ExecuteGoalResult =
(await prepareGoalExecutor(
implementation,
inProcessGoalEvent,
configuration,
)(goalInvocation).catch(async err => {
throw new GoalExecutionError({ where: "executing goal", cause: err });
})) || Success;
if (isFailure(goalResult)) {
throw new GoalExecutionError({ where: "executing goal", result: goalResult });
}
// execute post hook
const postHookResult: ExecuteGoalResult =
(await executeHook(rules, goalInvocation, inProcessGoalEvent, "post").catch(async err => {
throw new GoalExecutionError({ where: "executing post-goal hook", cause: err });
})) || Success;
if (isFailure(postHookResult)) {
throw new GoalExecutionError({ where: "executing post-goal hooks", result: postHookResult });
}
const result = {
...preHookResult,
...goalResult,
...postHookResult,
};
await notifyGoalExecutionListeners(
{
...inProcessGoalEvent,
state: SdmGoalState.success,
},
result,
);
logger.info("Goal '%s' completed with: %j", goalEvent.uniqueName, result);
await markStatus({ context, goalEvent, goal, result, progressLogUrl: progressLog.url });
return { ...result, code: 0 };
} catch (err) {
logger.warn("Error executing goal '%s': %s", goalEvent.uniqueName, err.message);
const result = handleGitRefErrors({ code: 1, ...(err.result || {}) }, err);
await notifyGoalExecutionListeners(
{
...inProcessGoalEvent,
state: result.state || SdmGoalState.failure,
},
result,
err,
);
await reportGoalError(
{
goal,
implementationName,
addressChannels,
progressLog,
id,
logInterpreter,
},
err,
);
await markStatus({
context,
goalEvent,
goal,
result,
error: err,
progressLogUrl: progressLog.url,
});
return failure(err);
}
}
export async function executeHook(
rules: { projectLoader: ProjectLoader },
goalInvocation: GoalInvocation,
sdmGoal: SdmGoalEvent,
stage: "post" | "pre",
): Promise<HandlerResult> {
const hook = goalToHookFile(sdmGoal, stage);
// Check configuration to see if hooks should be skipped
if (!configurationValue<boolean>("sdm.goal.hooks", false)) {
return Success;
}
const { projectLoader } = rules;
const { credentials, id, context, progressLog } = goalInvocation;
return projectLoader.doWithProject(
{
credentials,
id,
context,
readOnly: true,
cloneOptions: { detachHead: true },
},
async p => {
if (await p.hasFile(path.join(".atomist", "hooks", hook))) {
progressLog.write("/--");
progressLog.write(`Invoking goal hook: ${hook}`);
const opts = {
cwd: path.join(p.baseDir, ".atomist", "hooks"),
env: {
...process.env,
GITHUB_TOKEN: toToken(credentials),
ATOMIST_WORKSPACE: context.workspaceId,
ATOMIST_CORRELATION_ID: context.correlationId,
ATOMIST_REPO: sdmGoal.push.repo.name,
ATOMIST_OWNER: sdmGoal.push.repo.owner,
},
log: progressLog,
};
const cmd = path.join(p.baseDir, ".atomist", "hooks", hook);
let result: HandlerResult = await spawnLog(cmd, [], opts);
if (!result) {
result = Success;
}
progressLog.write(`Result: ${serializeResult(result)}`);
progressLog.write("\\--");
await progressLog.flush();
return result;
} else {
return Success;
}
},
);
}
function goalToHookFile(sdmGoal: SdmGoalEvent, prefix: string): string {
return `${prefix}-${sdmGoal.environment.toLocaleLowerCase().slice(2)}-${sdmGoal.name
.toLocaleLowerCase()
.replace(" ", "_")}`;
}
export function markStatus(parameters: {
context: HandlerContext;
goalEvent: SdmGoalEvent;
goal: Goal;
result: ExecuteGoalResult;
error?: Error;
progressLogUrl: string;
}): Promise<void> {
const { context, goalEvent, goal, result, error, progressLogUrl } = parameters;
let newState = SdmGoalState.success;
if (result.state) {
newState = result.state;
} else if (result.code !== 0) {
newState = SdmGoalState.failure;
} else if (goal.definition.approvalRequired) {
newState = SdmGoalState.waiting_for_approval;
}
return updateGoal(context, goalEvent, {
url: progressLogUrl,
externalUrls: result.externalUrls || [],
state: newState,
phase: result.phase ? result.phase : goalEvent.phase,
description: result.description ? result.description : descriptionFromState(goal, newState, goalEvent),
error,
data: result.data ? result.data : goalEvent.data,
});
}
function handleGitRefErrors(result: ExecuteGoalResult, error: Error & any): ExecuteGoalResult {
if (!!error?.cause?.stderr) {
const err = error?.cause?.stderr;
if (/Remote branch .* not found/.test(err)) {
result.code = 0;
result.state = SdmGoalState.canceled;
result.phase = "branch not found";
} else if (/reference is not a tree/.test(err)) {
result.code = 0;
result.state = SdmGoalState.canceled;
result.phase = "sha not found";
}
}
return result;
}
async function markGoalInProcess(parameters: {
ctx: HandlerContext;
goalEvent: SdmGoalEvent;
goal: Goal;
progressLogUrl: string;
}): Promise<SdmGoalEvent> {
const { ctx, goalEvent, goal, progressLogUrl } = parameters;
goalEvent.state = SdmGoalState.in_process;
goalEvent.description = descriptionFromState(goal, SdmGoalState.in_process, goalEvent);
goalEvent.url = progressLogUrl;
await updateGoal(ctx, goalEvent, {
url: progressLogUrl,
description: descriptionFromState(goal, SdmGoalState.in_process, goalEvent),
state: SdmGoalState.in_process,
});
return goalEvent;
}
/**
* Report an error executing a goal and present a retry button
* @return {Promise<void>}
*/
async function reportGoalError(
parameters: {
goal: Goal;
implementationName: string;
addressChannels: AddressChannels;
progressLog: ProgressLog;
id: RemoteRepoRef;
logInterpreter: InterpretLog;
},
err: GoalExecutionError,
): Promise<void> {
const { implementationName, addressChannels, progressLog, id, logInterpreter } = parameters;
if (err.cause) {
logger.warn(err.cause.stack);
progressLog.write(err.cause.stack);
} else if (err.result && (err.result as any).error) {
logger.warn((err.result as any).error.stack);
progressLog.write((err.result as any).error.stack);
} else {
logger.warn(err.stack);
}
progressLog.write("Error: " + (err.description || err.message) + "\n");
const interpretation = logInterpreter(progressLog.log);
// The executor might have information about the failure; report it in the channels
if (interpretation) {
if (!interpretation.doNotReportToUser) {
await reportFailureInterpretation(
implementationName,
interpretation,
{ url: progressLog.url, log: progressLog.log },
id,
addressChannels,
);
}
}
}
export function prepareGoalExecutor(
gi: GoalImplementation,
sdmGoal: SdmGoalEvent,
configuration: SoftwareDeliveryMachineConfiguration,
): ExecuteGoal {
const mge = mockGoalExecutor(gi.goal, sdmGoal, configuration);
if (mge) {
return mge;
} else {
return gi.goalExecutor;
}
}
export function prepareGoalInvocation(
gi: GoalInvocation,
listeners: GoalProjectListenerRegistration | GoalProjectListenerRegistration[],
): GoalInvocation {
let hs: GoalProjectListenerRegistration[] = listeners
? Array.isArray(listeners)
? listeners
: [listeners]
: ([] as GoalProjectListenerRegistration[]);
if (isLazyProjectLoader(gi.configuration.sdm.projectLoader)) {
// Register the materializing listener for LazyProject instances of those need to
// get materialized before using in goal implementations
const projectMaterializer = {
name: "clone project",
pushTest: AnyPush,
events: [GoalProjectListenerEvent.before],
listener: async (p: GitProject & LazyProject) => {
if (!p.materialized()) {
// Trigger project materialization
await p.materialize();
}
return { code: 0 };
},
};
hs = [projectMaterializer, ...hs];
}
if (hs.length === 0) {
return gi;
}
const configuration = _.cloneDeep(gi.configuration);
configuration.sdm.projectLoader = new ProjectListenerInvokingProjectLoader(gi, hs);
const newGi: GoalInvocation = {
...gi,
configuration,
};
return newGi;
}
/**
* ProgressLog implementation that uses the configured ReportProgress
* instance to report goal execution updates.
*/
class ProgressReportingProgressLog implements ProgressLog {
public log: string;
public readonly name: string;
public url: string;
constructor(
private readonly progressReporter: ReportProgress,
private readonly sdmGoal: SdmGoalEvent,
private readonly context: HandlerContext,
) {
this.name = sdmGoal.name;
}
public async close(): Promise<void> {
return;
}
public async flush(): Promise<void> {
return;
}
public async isAvailable(): Promise<boolean> {
return true;
}
public write(msg: string, ...args: string[]): void {
const progress = this.progressReporter(format(msg, ...args), this.sdmGoal);
if (progress && progress.phase) {
if (this.sdmGoal.phase !== progress.phase) {
this.sdmGoal.phase = progress.phase;
updateGoal(this.context, this.sdmGoal, {
state: this.sdmGoal.state,
phase: progress.phase,
description: this.sdmGoal.description,
url: this.sdmGoal.url,
})
.then(() => {
// Intentionally empty
})
.catch(err => {
logger.debug(`Error occurred reporting progress: %s`, err.message);
});
}
}
}
}