@atomist/sdm
Version:
Atomist Software Delivery Machine SDK
408 lines (363 loc) • 14.6 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 { HandlerContext } from "@atomist/automation-client/lib/HandlerContext";
import { Success } from "@atomist/automation-client/lib/HandlerResult";
import { GitProject } from "@atomist/automation-client/lib/project/git/GitProject";
import { QueryNoCacheOptions } from "@atomist/automation-client/lib/spi/graph/GraphClient";
import { executeAll } from "@atomist/automation-client/lib/util/pool";
import * as fs from "fs-extra";
import * as _ from "lodash";
import * as os from "os";
import * as path from "path";
import { LoggingProgressLog } from "../../../api-helper/log/LoggingProgressLog";
import { StringCapturingProgressLog } from "../../../api-helper/log/StringCapturingProgressLog";
import { spawnLog } from "../../../api-helper/misc/child_process";
import { projectConfigurationValue } from "../../../api-helper/project/configuration/projectConfiguration";
import { doWithProject, ProjectAwareGoalInvocation } from "../../../api-helper/project/withProject";
import { ExecuteGoalResult } from "../../../api/goal/ExecuteGoalResult";
import { ExecuteGoal } from "../../../api/goal/GoalInvocation";
import { mergeOptions } from "../../../api/goal/GoalWithFulfillment";
import { SdmGoalEvent } from "../../../api/goal/SdmGoalEvent";
import { readSdmVersion } from "../../../core/delivery/build/local/projectVersioner";
import { toArray } from "../../../core/util/misc/array";
import { DockerRegistryProviderAll, Password } from "../../../typings/types";
import { cleanImageName } from "../support/name";
import { DockerOptions, DockerRegistry } from "./DockerBuild";
export type DockerImageNameCreator = (
p: GitProject,
sdmGoal: SdmGoalEvent,
options: DockerOptions,
ctx: HandlerContext,
) => Promise<Array<{ registry: string; name: string; tags: string[] }>>;
/**
* Execute a Docker build for the project
*/
export function executeDockerBuild(options: DockerOptions): ExecuteGoal {
return doWithProject(
async gi => {
const { goalEvent, context, project } = gi;
const optsToUse = mergeOptions<DockerOptions>(options, {}, "docker.build");
switch (optsToUse.builder) {
case "docker":
await checkIsBuilderAvailable("docker", "help");
break;
case "kaniko":
await checkIsBuilderAvailable("/kaniko/executor", "--help");
break;
}
// Check the graph for registries if we don't have any configured
if (!optsToUse.config && toArray(optsToUse.registry || []).length === 0) {
optsToUse.registry = await readRegistries(context);
}
const imageNames = await optsToUse.dockerImageNameCreator(project, goalEvent, optsToUse, context);
const images = _.flatten(
imageNames.map(imageName =>
imageName.tags.map(
tag => `${imageName.registry ? `${imageName.registry}/` : ""}${imageName.name}:${tag}`,
),
),
);
const dockerfilePath = await (optsToUse.dockerfileFinder
? optsToUse.dockerfileFinder(project)
: "Dockerfile");
let externalUrls: ExecuteGoalResult["externalUrls"] = [];
if (await pushEnabled(gi, optsToUse)) {
externalUrls = getExternalUrls(imageNames, optsToUse);
}
// 1. run docker login
let result: ExecuteGoalResult = await dockerLogin(optsToUse, gi);
if (result.code !== 0) {
return result;
}
if (optsToUse.builder === "docker") {
result = await buildWithDocker(images, dockerfilePath, gi, optsToUse);
if (result.code !== 0) {
return result;
}
} else if (optsToUse.builder === "kaniko") {
result = await buildWithKaniko(images, imageNames, dockerfilePath, gi, optsToUse);
if (result.code !== 0) {
return result;
}
}
return {
...result,
externalUrls,
};
},
{
readOnly: true,
detachHead: false,
},
);
}
async function buildWithDocker(
images: string[],
dockerfilePath: string,
gi: ProjectAwareGoalInvocation,
optsToUse: DockerOptions,
): Promise<ExecuteGoalResult> {
// 2. run docker build
const tags = _.flatten(images.map(i => ["-t", i]));
let result: ExecuteGoalResult = await gi.spawn(
"docker",
["build", "-f", dockerfilePath, ...tags, ...optsToUse.builderArgs, optsToUse.builderPath],
{
env: {
...process.env,
DOCKER_CONFIG: dockerConfigPath(optsToUse, gi.goalEvent),
},
log: gi.progressLog,
},
);
// 3. run docker push
result = await dockerPush(images, optsToUse, gi);
if (result.code !== 0) {
return result;
}
return result;
}
async function buildWithKaniko(
images: string[],
imageNames: Array<{ registry: string; name: string; tags: string[] }>,
dockerfilePath: string,
gi: ProjectAwareGoalInvocation,
optsToUse: DockerOptions,
): Promise<ExecuteGoalResult> {
// 2. run kaniko build
const builderArgs: string[] = [];
if (await pushEnabled(gi, optsToUse)) {
builderArgs.push(
...images.map(i => `-d=${i}`),
"--cache=true",
`--cache-repo=${imageNames[0].registry ? `${imageNames[0].registry}/` : ""}${imageNames[0].name}-cache`,
);
} else {
builderArgs.push("--no-push");
}
builderArgs.push(
...(optsToUse.builderArgs.length > 0 ? optsToUse.builderArgs : ["--snapshotMode=time", "--reproducible"]),
);
// Check if base image cache dir is available
const cacheFilPath = _.get(gi, "configuration.sdm.cache.path", "/opt/data");
if (_.get(gi, "configuration.sdm.cache.enabled") === true && (await fs.pathExists(cacheFilPath))) {
const baseImageCache = path.join(cacheFilPath, "base-image-cache");
await fs.mkdirs(baseImageCache);
builderArgs.push(`--cache-dir=${baseImageCache}`, "--cache=true");
}
const kanikoContext =
`dir://${gi.project.baseDir}` + (optsToUse.builderPath === "." ? "" : `/${optsToUse.builderPath}`);
return gi.spawn(
"/kaniko/executor",
["--dockerfile", dockerfilePath, "--context", kanikoContext, ..._.uniq(builderArgs)],
{
env: {
...process.env,
DOCKER_CONFIG: dockerConfigPath(optsToUse, gi.goalEvent),
},
log: gi.progressLog,
},
);
}
async function dockerLogin(options: DockerOptions, gi: ProjectAwareGoalInvocation): Promise<ExecuteGoalResult> {
const registries = toArray(options.registry || []).filter(r => !!r.user && !!r.password);
if (registries.length > 0) {
let result;
for (const registry of registries) {
gi.progressLog.write("Running 'docker login'");
const loginArgs: string[] = ["login", "--username", registry.user, "--password", registry.password];
if (/[^A-Za-z0-9]/.test(registry.registry)) {
loginArgs.push(registry.registry);
}
// 2. run docker login
result = await gi.spawn("docker", loginArgs, {
logCommand: false,
log: gi.progressLog,
});
if (!!result && result.code !== 0) {
return result;
}
}
return result;
} else if (options.config) {
gi.progressLog.write("Authenticating with provided Docker 'config.json'");
const dockerConfig = path.join(dockerConfigPath(options, gi.goalEvent), "config.json");
await fs.ensureDir(path.dirname(dockerConfig));
await fs.writeFile(dockerConfig, options.config);
} else {
gi.progressLog.write("Skipping 'docker auth' because no credentials configured");
}
return Success;
}
async function dockerPush(
images: string[],
options: DockerOptions,
gi: ProjectAwareGoalInvocation,
): Promise<ExecuteGoalResult> {
let result = Success;
if (await pushEnabled(gi, options)) {
if (!!options.concurrentPush) {
const results = await executeAll(
images.map(image => async () => {
const log = new StringCapturingProgressLog();
const r = await gi.spawn("docker", ["push", image], {
env: {
...process.env,
DOCKER_CONFIG: dockerConfigPath(options, gi.goalEvent),
},
log,
});
gi.progressLog.write(log.log);
return r;
}),
);
return {
code: results.some(r => !!r && r.code !== 0) ? 1 : 0,
};
} else {
for (const image of images) {
result = await gi.spawn("docker", ["push", image], {
env: {
...process.env,
DOCKER_CONFIG: dockerConfigPath(options, gi.goalEvent),
},
log: gi.progressLog,
});
if (!!result && result.code !== 0) {
return result;
}
}
}
} else {
gi.progressLog.write("Skipping 'docker push'");
}
return result;
}
export const DefaultDockerImageNameCreator: DockerImageNameCreator = async (p, sdmGoal, options, context) => {
const name = cleanImageName(p.name);
const tags: string[] = [];
const version = await readSdmVersion(
sdmGoal.repo.owner,
sdmGoal.repo.name,
sdmGoal.repo.providerId,
sdmGoal.sha,
sdmGoal.branch,
context,
);
if (!!version) {
tags.push(version);
}
const latestTag = await projectConfigurationValue<boolean>("docker.tag.latest", p, false);
if ((latestTag && sdmGoal.branch === sdmGoal.push.repo.defaultBranch) || tags.length === 0) {
tags.push("latest");
}
if (!!options.registry) {
return toArray(options.registry).map(r => ({
registry: !!r.registry ? r.registry : undefined,
name,
tags,
}));
} else {
return [
{
registry: undefined,
name,
tags,
},
];
}
};
async function checkIsBuilderAvailable(cmd: string, ...args: string[]): Promise<void> {
try {
await spawnLog(cmd, args, { log: new LoggingProgressLog("docker-build-check") });
} catch (e) {
throw new Error(`Configured Docker image builder '${cmd}' is not available`);
}
}
async function pushEnabled(gi: ProjectAwareGoalInvocation, options: DockerOptions): Promise<boolean> {
let push = false;
// tslint:disable-next-line:no-boolean-literal-compare
if (options.push === true || options.push === false) {
push = options.push;
} else if (toArray(options.registry || []).some(r => !!r.user && !!r.password) || !!options.config) {
push = true;
}
return projectConfigurationValue("docker.build.push", gi.project, push);
}
function dockerConfigPath(options: DockerOptions, goalEvent: SdmGoalEvent): string {
if (!!options.config) {
return path.join(os.homedir(), `.docker-${goalEvent.goalSetId}`);
} else {
return path.join(os.homedir(), ".docker");
}
}
function getExternalUrls(
images: Array<{ registry: string; name: string; tags: string[] }>,
options: DockerOptions,
): ExecuteGoalResult["externalUrls"] {
const externalUrls = images.map(i => {
const reg = toArray(options.registry || []).find(r => r.registry === i.registry);
if (!!reg && !!reg.display) {
return i.tags.map(t => {
let url = `${!!reg.displayUrl ? reg.displayUrl : i.registry}/${i.name}`;
if (!!reg.displayBrowsePath) {
const replace = url.split(":").pop();
url = url.replace(`:${replace}`, reg.displayBrowsePath);
}
if (!!reg.label) {
return { label: reg.label, url };
} else {
return { url };
}
});
}
return undefined;
});
return _.uniqBy(
_.flatten(externalUrls).filter(u => !!u),
"url",
);
}
async function readRegistries(ctx: HandlerContext): Promise<DockerRegistry[]> {
const registries: DockerRegistry[] = [];
const dockerRegistries = await ctx.graphClient.query<
DockerRegistryProviderAll.Query,
DockerRegistryProviderAll.Variables
>({
name: "DockerRegistryProviderAll",
options: QueryNoCacheOptions,
});
if (!!dockerRegistries && !!dockerRegistries.DockerRegistryProvider) {
for (const dockerRegistry of dockerRegistries.DockerRegistryProvider) {
const credential = await ctx.graphClient.query<Password.Query, Password.Variables>({
name: "Password",
variables: {
id: dockerRegistry.credential.id,
},
});
// Strip out the protocol
const registryUrl = new URL(dockerRegistry.url);
registries.push({
registry: registryUrl.host,
user: credential.Password[0].owner.login,
password: credential.Password[0].secret,
label: dockerRegistry.name,
display: false,
});
}
}
return registries;
}