UNPKG

@atomist/sdm

Version:

Atomist Software Delivery Machine SDK

772 lines (723 loc) • 30.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 { sleep } from "@atomist/automation-client/lib/internal/util/poll"; import { guid } from "@atomist/automation-client/lib/internal/util/string"; import { GitCommandGitProject } from "@atomist/automation-client/lib/project/git/GitCommandGitProject"; import { GitProject } from "@atomist/automation-client/lib/project/git/GitProject"; import * as k8s from "@kubernetes/client-node"; import * as fs from "fs-extra"; import * as stringify from "json-stringify-safe"; import * as _ from "lodash"; import * as os from "os"; import * as path from "path"; import * as request from "request"; import { Writable } from "stream"; import { Merge } from "ts-essentials"; import { minimalClone } from "../../api-helper/goal/minimalClone"; import { goalData, sdmGoalTimeout } from "../../api-helper/goal/sdmGoal"; import { RepoContext } from "../../api/context/SdmContext"; import { ExecuteGoalResult } from "../../api/goal/ExecuteGoalResult"; import { ExecuteGoal, GoalProjectListenerEvent, GoalProjectListenerRegistration } from "../../api/goal/GoalInvocation"; import { GoalWithFulfillment, ImplementationRegistration } from "../../api/goal/GoalWithFulfillment"; import { SdmGoalEvent } from "../../api/goal/SdmGoalEvent"; import { GoalScheduler } from "../../api/goal/support/GoalScheduler"; import { ServiceRegistrationGoalDataKey } from "../../api/registration/ServiceRegistration"; import { CacheEntry, CacheOutputGoalDataKey, cachePut, cacheRestore } from "../../core/goal/cache/goalCaching"; import { Container, ContainerInput, ContainerOutput, ContainerProjectHome, ContainerRegistration, ContainerRegistrationGoalDataKey, ContainerScheduler, GoalContainer, GoalContainerVolume, } from "../../core/goal/container/container"; import { prepareSecrets } from "../../core/goal/container/provider"; import { containerEnvVars, prepareInputAndOutput, processResult } from "../../core/goal/container/util"; import { toArray } from "../../core/util/misc/array"; import { ProgressLog } from "../../spi/log/ProgressLog"; import { SdmGoalState } from "../../typings/types"; import { loadKubeConfig } from "./kubernetes/config"; import { k8sJobEnv, KubernetesGoalScheduler, readNamespace } from "./scheduler/KubernetesGoalScheduler"; import { K8sServiceRegistrationType, K8sServiceSpec } from "./scheduler/service"; import { k8sErrMsg } from "./support/error"; // tslint:disable:max-file-line-count /** Merge of base and Kubernetes goal container interfaces. */ export type K8sGoalContainer = Merge<GoalContainer, k8s.V1Container> & Pick<GoalContainer, "name" | "image">; /** Merge of base and Kubernetes goal container volume interfaces. */ export type K8sGoalContainerVolume = Merge<k8s.V1Volume, GoalContainerVolume>; /** * Function signature for callback that can modify and return the * [[ContainerRegistration]] object. */ export type K8sContainerSpecCallback = ( r: K8sContainerRegistration, p: GitProject, g: Container, e: SdmGoalEvent, ctx: RepoContext, ) => Promise<Omit<K8sContainerRegistration, "callback">>; /** * Additional options for Kubernetes implementation of container goals. */ export interface K8sContainerRegistration extends ContainerRegistration { /** * Replace generic containers in [[ContainerRegistration]] with * Kubernetes containers. * * Containers to run for this goal. The goal result is based on * the exit status of the first element of the `containers` array. * The other containers are considered "sidecar" containers * provided functionality that the main container needs to * function. If not set, the working directory of the first * container is set to [[ContainerProjectHome]], which contains * the project upon which the goal should operate. If * `workingDir` is set, it is not changed. If `workingDir` is set * to the empty string, the `workingDir` property is deleted from * the main container spec, meaning the container default working * directory will be used. */ containers: K8sGoalContainer[]; /** * Replace generic callback in [[ContainerRegistration]] with * Kubernetes-specific callback. */ callback?: K8sContainerSpecCallback; /** * Init containers to run for this goal. Any containers provided * here will run after the one inserted by the SDM to manage the * cloned repository. */ initContainers?: k8s.V1Container[]; /** * Replace generic volumes in [[ContainerRegistration]] with * Kubernetes volumes available to mount in containers. */ volumes?: K8sGoalContainerVolume[]; } /** * Container scheduler to use when running in Kubernetes. */ export const k8sContainerScheduler: ContainerScheduler = (goal, registration: K8sContainerRegistration) => { goal.addFulfillment({ goalExecutor: executeK8sJob(), ...(registration as ImplementationRegistration), }); goal.addFulfillmentCallback({ goal, callback: k8sFulfillmentCallback(goal, registration), }); }; /** * Container scheduler to use when running in Google Cloud Functions. */ export const k8sSkillContainerScheduler: ContainerScheduler = (goal, registration: K8sContainerRegistration) => { goal.addFulfillment({ goalExecutor: executeK8sJob(), ...(registration as ImplementationRegistration), }); }; /** * Add Kubernetes job scheduling information to SDM goal event data * for use by the [[KubernetesGoalScheduler]]. */ export function k8sFulfillmentCallback( goal: Container, registration: K8sContainerRegistration, ): (sge: SdmGoalEvent, rc: RepoContext) => Promise<SdmGoalEvent> { // tslint:disable-next-line:cyclomatic-complexity return async (goalEvent, repoContext) => { let spec: K8sContainerRegistration = _.cloneDeep(registration); if (registration.callback) { spec = await repoContext.configuration.sdm.projectLoader.doWithProject( { ...repoContext, readOnly: true, cloneOptions: minimalClone(goalEvent.push, { detachHead: true }), }, async p => { return { ...spec, ...((await registration.callback(_.cloneDeep(registration), p, goal, goalEvent, repoContext)) || {}), }; }, ); } if (!spec.containers || spec.containers.length < 1) { throw new Error("No containers defined in K8sGoalContainerSpec"); } // Preserve the container registration in the goal data before it gets munged with internals let data = goalData(goalEvent); let newData: any = {}; delete spec.callback; _.set<any>(newData, ContainerRegistrationGoalDataKey, spec); goalEvent.data = JSON.stringify(_.merge(data, newData)); if (spec.containers[0].workingDir === "") { delete spec.containers[0].workingDir; } else if (!spec.containers[0].workingDir) { spec.containers[0].workingDir = ContainerProjectHome; } const goalSchedulers: GoalScheduler[] = toArray(repoContext.configuration.sdm.goalScheduler) || []; const k8sScheduler = goalSchedulers.find( gs => gs instanceof KubernetesGoalScheduler, ) as KubernetesGoalScheduler; if (!k8sScheduler) { throw new Error("Failed to find KubernetesGoalScheduler in goal schedulers"); } if (!k8sScheduler.podSpec) { throw new Error("KubernetesGoalScheduler has no podSpec defined"); } const containerEnvs = await containerEnvVars(goalEvent, repoContext); const projectVolume = `project-${guid().split("-")[0]}`; const inputVolume = `input-${guid().split("-")[0]}`; const outputVolume = `output-${guid().split("-")[0]}`; const ioVolumes = [ { name: projectVolume, emptyDir: {}, }, { name: inputVolume, emptyDir: {}, }, { name: outputVolume, emptyDir: {}, }, ]; const ioVolumeMounts = [ { mountPath: ContainerProjectHome, name: projectVolume, }, { mountPath: ContainerInput, name: inputVolume, }, { mountPath: ContainerOutput, name: outputVolume, }, ]; const copyContainer = _.cloneDeep(k8sScheduler.podSpec.containers[0]); delete copyContainer.lifecycle; delete copyContainer.livenessProbe; delete copyContainer.readinessProbe; copyContainer.name = `container-goal-init-${guid().split("-")[0]}`; copyContainer.env = [ ...(copyContainer.env || []), ...k8sJobEnv(k8sScheduler.podSpec, goalEvent, repoContext.context as any), ...containerEnvs, { name: "ATOMIST_ISOLATED_GOAL_INIT", value: "true", }, { name: "ATOMIST_CONFIG", value: JSON.stringify({ cluster: { enabled: false, }, ws: { enabled: false, }, }), }, ]; spec.initContainers = spec.initContainers || []; const parameters = JSON.parse((goalEvent as any).parameters || "{}"); const secrets = await prepareSecrets( _.merge({}, registration.containers[0], parameters["@atomist/sdm/secrets"] || {}), repoContext, ); delete spec.containers[0].secrets; [...spec.containers, ...spec.initContainers].forEach(c => { c.env = [...(secrets.env || []), ...containerEnvs, ...(c.env || [])]; }); if (!!secrets?.files) { for (const file of secrets.files) { const fileName = path.basename(file.mountPath); const dirname = path.dirname(file.mountPath); let secretName = `secret-${guid().split("-")[0]}`; const vm = (copyContainer.volumeMounts || []).find(m => m.mountPath === dirname); if (!!vm) { secretName = vm.name; } else { copyContainer.volumeMounts = [ ...(copyContainer.volumeMounts || []), { mountPath: dirname, name: secretName, }, ]; spec.volumes = [ ...(spec.volumes || []), { name: secretName, emptyDir: {}, } as any, ]; } [...spec.containers, ...spec.initContainers].forEach((c: k8s.V1Container) => { c.volumeMounts = [ ...(c.volumeMounts || []), { mountPath: file.mountPath, name: secretName, subPath: fileName, }, ]; }); } } spec.initContainers = [copyContainer, ...spec.initContainers]; const serviceSpec: { type: string; spec: K8sServiceSpec } = { type: K8sServiceRegistrationType.K8sService, spec: { container: spec.containers, initContainer: spec.initContainers, volume: [...ioVolumes, ...(spec.volumes || [])], volumeMount: ioVolumeMounts, }, }; // Store k8s service registration in goal data data = goalData(goalEvent); newData = {}; _.set<any>(newData, `${ServiceRegistrationGoalDataKey}.${registration.name}`, serviceSpec); goalEvent.data = JSON.stringify(_.merge(data, newData)); return goalEvent; }; } /** * Get container registration from goal event data, use * [[k8sFulfillmentcallback]] to get a goal event schedulable by a * [[KubernetesGoalScheduler]], then schedule the goal using that * scheduler. */ export const scheduleK8sJob: ExecuteGoal = async gi => { const { goalEvent } = gi; const { uniqueName } = goalEvent; const data = goalData(goalEvent); const containerReg: K8sContainerRegistration = data["@atomist/sdm/container"]; if (!containerReg) { throw new Error(`Goal ${uniqueName} event data has no container spec: ${goalEvent.data}`); } const goalSchedulers: GoalScheduler[] = toArray(gi.configuration.sdm.goalScheduler) || []; const k8sScheduler = goalSchedulers.find(gs => gs instanceof KubernetesGoalScheduler) as KubernetesGoalScheduler; if (!k8sScheduler) { throw new Error(`Failed to find KubernetesGoalScheduler in goal schedulers: ${stringify(goalSchedulers)}`); } // the k8sFulfillmentCallback may already have been called, so wipe it out delete data[ServiceRegistrationGoalDataKey]; goalEvent.data = JSON.stringify(data); try { const schedulableGoalEvent = await k8sFulfillmentCallback(gi.goal as Container, containerReg)(goalEvent, gi); const scheduleResult = await k8sScheduler.schedule({ ...gi, goalEvent: schedulableGoalEvent }); if (scheduleResult.code) { return { ...scheduleResult, message: `Failed to schedule container goal ${uniqueName}: ${scheduleResult.message}`, }; } schedulableGoalEvent.state = SdmGoalState.in_process; return schedulableGoalEvent; } catch (e) { const message = `Failed to schedule container goal ${uniqueName} as Kubernetes job: ${e.message}`; gi.progressLog.write(message); return { code: 1, message }; } }; /** Container information useful the various functions. */ interface K8sContainer { /** Kubernetes configuration to use when creating API clients */ config: k8s.KubeConfig; /** Name of container in pod */ name: string; /** Pod name */ pod: string; /** Pod namespace */ ns: string; /** Log */ log: ProgressLog; } /** * Wait for first container to exit and stream its logs to the * progress log. */ export function executeK8sJob(): ExecuteGoal { // tslint:disable-next-line:cyclomatic-complexity return async gi => { const { goalEvent, progressLog, configuration, id, credentials } = gi; const projectDir = process.env.ATOMIST_PROJECT_DIR || ContainerProjectHome; const inputDir = process.env.ATOMIST_INPUT_DIR || ContainerInput; const outputDir = process.env.ATOMIST_OUTPUT_DIR || ContainerOutput; const data = goalData(goalEvent); if (!data[ContainerRegistrationGoalDataKey]) { throw new Error("Failed to read k8s ContainerRegistration from goal data"); } if (!data[ContainerRegistrationGoalDataKey]) { throw new Error( `Goal ${gi.goal.uniqueName} has no Kubernetes container registration: ${gi.goalEvent.data}`, ); } const registration: K8sContainerRegistration = data[ContainerRegistrationGoalDataKey]; if (process.env.ATOMIST_ISOLATED_GOAL_INIT === "true") { return configuration.sdm.projectLoader.doWithProject( { ...gi, readOnly: false, cloneDir: projectDir, cloneOptions: minimalClone(goalEvent.push, { detachHead: true }), }, async () => { try { await prepareInputAndOutput(inputDir, outputDir, gi); } catch (e) { const message = `Failed to prepare input and output for goal ${goalEvent.name}: ${e.message}`; progressLog.write(message); return { code: 1, message }; } const secrets = await prepareSecrets( _.merge({}, registration.containers[0], (gi.parameters || {})["@atomist/sdm/secrets"] || {}), gi, ); if (!!secrets?.files) { for (const file of secrets.files) { await fs.writeFile(file.mountPath, file.value); } } goalEvent.state = SdmGoalState.in_process; return goalEvent; }, ); } let containerName: string = _.get(registration, "containers[0].name"); if (!containerName) { const msg = `Failed to get main container name from goal registration: ${stringify(registration)}`; progressLog.write(msg); let svcSpec: K8sServiceSpec; try { svcSpec = _.get(data, `${ServiceRegistrationGoalDataKey}.${registration.name}.spec`); } catch (e) { const message = `Failed to parse Kubernetes spec from goal data '${goalEvent.data}': ${e.message}`; progressLog.write(message); return { code: 1, message }; } containerName = _.get(svcSpec, "container[1].name"); if (!containerName) { const message = `Failed to get main container name from either goal registration or data: '${goalEvent.data}'`; progressLog.write(message); return { code: 1, message }; } } const ns = await readNamespace(); const podName = os.hostname(); let kc: k8s.KubeConfig; try { kc = loadKubeConfig(); } catch (e) { const message = `Failed to load Kubernetes configuration: ${e.message}`; progressLog.write(message); return { code: 1, message }; } const container: K8sContainer = { config: kc, name: containerName, pod: podName, ns, log: progressLog, }; try { await containerStarted(container); } catch (e) { const message = `Failed to determine if container started: ${e.message}`; progressLog.write(message); return { code: 1, message }; } const status = { code: 0, message: `Container '${containerName}' completed successfully` }; try { const timeout = sdmGoalTimeout(configuration); const podStatus = await containerWatch(container, timeout); progressLog.write(`Container '${containerName}' exited: ${stringify(podStatus)}`); } catch (e) { const message = `Container '${containerName}' failed: ${e.message}`; progressLog.write(message); status.code++; status.message = message; } const outputFile = path.join(outputDir, "result.json"); let outputResult: ExecuteGoalResult; if (status.code === 0 && (await fs.pathExists(outputFile))) { try { outputResult = await processResult(await fs.readJson(outputFile), gi); } catch (e) { const message = `Failed to read output from container: ${e.message}`; progressLog.write(message); status.code++; status.message += ` but f${message.slice(1)}`; } } const cacheEntriesToPut: CacheEntry[] = [ ...(registration.output || []), ...((gi.parameters || {})[CacheOutputGoalDataKey] || []), ]; if (cacheEntriesToPut.length > 0) { try { const project = GitCommandGitProject.fromBaseDir(id, projectDir, credentials, async () => {}); const cp = cachePut({ entries: cacheEntriesToPut.map(e => { // Prevent the type on the entry to get passed along when goal actually failed if (status.code !== 0) { return { classifier: e.classifier, pattern: e.pattern, }; } else { return e; } }), }); await cp.listener(project, gi, GoalProjectListenerEvent.after); } catch (e) { const message = `Failed to put cache output from container: ${e.message}`; progressLog.write(message); status.code++; status.message += ` but f${message.slice(1)}`; } } return outputResult || status; }; } /** * If running as isolated goal, use [[executeK8sJob]] to execute the * goal. Otherwise, schedule the goal execution as a Kubernetes job * using [[scheduleK8sJob]]. */ const containerExecutor: ExecuteGoal = gi => process.env.ATOMIST_ISOLATED_GOAL ? executeK8sJob()(gi) : scheduleK8sJob(gi); /** * Restore cache input entries before fulfilling goal. */ const containerFulfillerCacheRestore: GoalProjectListenerRegistration = { name: "cache restore", events: [GoalProjectListenerEvent.before], listener: async (project, gi) => { const data = goalData(gi.goalEvent); if (!data[ContainerRegistrationGoalDataKey]) { throw new Error( `Goal ${gi.goal.uniqueName} has no Kubernetes container registration: ${gi.goalEvent.data}`, ); } const registration: K8sContainerRegistration = data[ContainerRegistrationGoalDataKey]; if (registration.input && registration.input.length > 0) { try { const cp = cacheRestore({ entries: registration.input }); return cp.listener(project, gi, GoalProjectListenerEvent.before); } catch (e) { const message = `Failed to restore cache input to container for goal ${gi.goal.uniqueName}: ${e.message}`; gi.progressLog.write(message); return { code: 1, message }; } } else { return { code: 0, message: "No container input cache entries to restore" }; } }, }; /** Deterministic name for Kubernetes container goal fulfiller. */ export const K8sContainerFulfillerName = "Kubernetes Container Goal Fulfiller"; /** * Goal that fulfills requested container goals by scheduling them as * Kubernetes jobs. */ export function k8sContainerFulfiller(): GoalWithFulfillment { return new GoalWithFulfillment({ displayName: K8sContainerFulfillerName, uniqueName: K8sContainerFulfillerName, }) .with({ goalExecutor: containerExecutor, name: `${K8sContainerFulfillerName} Executor`, }) .withProjectListener(containerFulfillerCacheRestore); } /** * Wait for container in pod to start, return when it does. * * @param container Information about container to check * @param attempts Maximum number of attempts, waiting 500 ms between */ async function containerStarted(container: K8sContainer, attempts: number = 240): Promise<void> { let core: k8s.CoreV1Api; try { core = container.config.makeApiClient(k8s.CoreV1Api); } catch (e) { e.message = `Failed to create Kubernetes core API client: ${e.message}`; container.log.write(e.message); throw e; } const sleepTime = 500; // ms for (let i = 0; i < attempts; i++) { await sleep(sleepTime); let pod: k8s.V1Pod; try { pod = (await core.readNamespacedPod(container.pod, container.ns)).body; } catch (e) { container.log.write(`Reading pod ${container.ns}/${container.pod} failed: ${k8sErrMsg(e)}`); continue; } const containerStatus = pod.status?.containerStatuses?.find(c => c.name === container.name); if (containerStatus && (!!containerStatus.state?.running?.startedAt || !!containerStatus.state?.terminated)) { const message = `Container '${container.name}' started`; container.log.write(message); return; } } const errMsg = `Container '${container.name}' failed to start within ${attempts * sleepTime} ms`; container.log.write(errMsg); throw new Error(errMsg); } /** Items used to in watching main container and its logs. */ interface ContainerDetritus { logStream?: Writable; logRequest?: request.Request; watcher?: any; timeout?: NodeJS.Timeout; } /** * Watch pod until container `container.name` exits and its log stream * is done being written to. Resolve promise with status if container * `container.name` exits with status 0. If container exits with * non-zero status, reject promise and includ pod status in the * `podStatus` property of the error. If any other error occurs, * e.g., a watch or log error or timeout exceeded, reject immediately * upon receipt of error. * * @param container Information about container to watch * @param timeout Milliseconds to allow container to run * @return Status of pod after container terminates */ function containerWatch(container: K8sContainer, timeout: number): Promise<k8s.V1PodStatus> { return new Promise(async (resolve, reject) => { const clean: ContainerDetritus = {}; const k8sLog = new k8s.Log(container.config); clean.logStream = new Writable({ write: (chunk, encoding, callback) => { container.log.write(chunk.toString()); callback(); }, }); let logDone = false; let podStatus: k8s.V1PodStatus | undefined; let podError: Error | undefined; const doneCallback = (e: any) => { logDone = true; if (e) { e.message = `Container logging error: ${k8sErrMsg(e)}`; container.log.write(e.message); containerCleanup(clean); reject(e); } if (podStatus) { containerCleanup(clean); resolve(podStatus); } else if (podError) { containerCleanup(clean); reject(podError); } }; const logOptions: k8s.LogOptions = { follow: true }; clean.logRequest = await k8sLog.log( container.ns, container.pod, container.name, clean.logStream, doneCallback, logOptions, ); let watch: k8s.Watch; try { watch = new k8s.Watch(container.config); } catch (e) { e.message = `Failed to create Kubernetes watch client: ${e.message}`; container.log.write(e.message); containerCleanup(clean); reject(e); } clean.timeout = setTimeout(() => { containerCleanup(clean); reject(new Error(`Goal timeout '${timeout}' exceeded`)); }, timeout); const watchPath = `/api/v1/watch/namespaces/${container.ns}/pods/${container.pod}`; clean.watcher = await watch.watch( watchPath, {}, async (phase, obj) => { const pod = obj as k8s.V1Pod; if (pod?.status?.containerStatuses) { const containerStatus = pod.status.containerStatuses.find(c => c.name === container.name); if (containerStatus?.state?.terminated) { const exitCode: number = containerStatus.state.terminated.exitCode; if (exitCode === 0) { podStatus = pod.status; const msg = `Container '${container.name}' exited with status 0`; container.log.write(msg); if (logDone) { containerCleanup(clean); resolve(podStatus); } } else { const msg = `Container '${container.name}' exited with status ${exitCode}`; container.log.write(msg); podError = new Error(msg); (podError as any).podStatus = pod.status; if (logDone) { containerCleanup(clean); reject(podError); } } return; } } container.log.write(`Container '${container.name}' phase: ${phase}`); }, () => containerCleanup(clean), err => { err.message = `Container watcher failed: ${err.message}`; container.log.write(err.message); containerCleanup(clean); reject(err); }, ); }); } /** Clean up resources used to watch running container. */ function containerCleanup(c: ContainerDetritus): void { if (c.timeout) { clearTimeout(c.timeout); } if (c.logRequest?.abort) { c.logRequest.abort(); } if (c.logStream?.end) { c.logStream.end(); } if (c.watcher?.abort) { c.watcher.abort(); } }