UNPKG

@atomist/sdm

Version:

Atomist Software Delivery Machine SDK

408 lines (363 loc) 14.6 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 { 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; }