UNPKG

@atomist/sdm

Version:

Atomist Software Delivery Machine SDK

317 lines (294 loc) 13.9 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 { Project } from "@atomist/automation-client/lib/project/Project"; import { logger } from "@atomist/automation-client/lib/util/logger"; import * as k8s from "@kubernetes/client-node"; import * as dockerfileParser from "docker-file-parser"; import * as stringify from "json-stringify-safe"; import * as _ from "lodash"; import { DeepPartial } from "ts-essentials"; import { goalData } from "../../../api-helper/goal/sdmGoal"; import { GoalInvocation } from "../../../api/goal/GoalInvocation"; import { SdmGoalEvent } from "../../../api/goal/SdmGoalEvent"; import { readSdmVersion } from "../../../core/delivery/build/local/projectVersioner"; import { validName } from "../kubernetes/name"; import { KubernetesApplication } from "../kubernetes/request"; import { K8sDefaultNamespace } from "../support/namespace"; import { KubernetesDeployFulfillerGoalName } from "./fulfiller"; import { goalEventSlug, KubernetesDeploy, KubernetesDeployDataSources, KubernetesDeployRegistration, } from "./goal"; import { loadKubernetesSpec } from "./spec"; /** * JSON propery under the goal event data where the * [[KubernetesDeployment]] goal [[KubernetesApplication]] data are * stored, i.e., the value of `goal.data[sdmPackK8s]`. */ export const sdmPackK8s = "@atomist/sdm-pack-k8s"; /** * Generate KubernetesApplication from goal, goal registration, * project, and goal invocation. The priority of the various sources * of KubernetesApplication data are, from lowest to highest: * * 1. Starting point is `{ ns: defaultNamespace }` * 2. [[KubernetesDeployDataSources.SdmConfiguration]] * 3. [[defaultKubernetesApplication]] * 4. [[KubernetesDeployDataSources.Dockerfile]] * 5. The various partial Kubernetes specs under `.atomist/kubernetes` * 6. [[KubernetesDeployDataSources.GoalEvent]] * 7. [[KubernetesDeployRegistration.applicationData]] * * Specific sources can be enabled/disabled via the * [[KubernetesDeployRegistration.dataSources]]. * * @param k8Deploy Kubernetes deployment goal * @param registration Goal registration/configuration for `k8Deploy` * @param goalInvocation The Kubernetes deployment goal currently triggered. * @return The SdmGoalEvent augmented with [[KubernetesApplication]] in the data */ export function generateKubernetesGoalEventData( k8Deploy: KubernetesDeploy, registration: KubernetesDeployRegistration, goalInvocation: GoalInvocation, ): Promise<SdmGoalEvent> { return k8Deploy.sdm.configuration.sdm.projectLoader.doWithProject({ ...goalInvocation, readOnly: true }, async p => { const { context, goalEvent } = goalInvocation; const sdmConfigAppData: Partial<KubernetesApplication> = (registration.dataSources.includes(KubernetesDeployDataSources.SdmConfiguration)) ? _.get(k8Deploy, "sdm.configuration.sdm.k8s.app", {}) : {}; const defaultAppData: Partial<KubernetesApplication> = await defaultKubernetesApplication(goalEvent, k8Deploy, context); const dockerfileAppData: Partial<KubernetesApplication> = (registration.dataSources.includes(KubernetesDeployDataSources.Dockerfile)) ? await dockerfileKubernetesApplication(p) : {}; const specAppData: Partial<KubernetesApplication> = await repoSpecsKubernetsApplication(p, registration); const mergedAppData = _.merge({ ns: K8sDefaultNamespace }, sdmConfigAppData, defaultAppData, dockerfileAppData, specAppData); const eventAppData: any = {}; eventAppData[sdmPackK8s] = mergedAppData; if (registration.dataSources.includes(KubernetesDeployDataSources.GoalEvent)) { const slug = goalEventSlug(goalEvent); let eventData: any; try { eventData = goalData(goalEvent); } catch (e) { logger.warn(`Failed to parse goal event data for ${slug} as JSON: ${e.message}`); logger.warn(`Ignoring current value of goal event data: ${goalEvent.data}`); eventData = {}; } _.merge(eventAppData, eventData); } if (registration.applicationData) { const callbackAppData = await registration.applicationData(eventAppData[sdmPackK8s], p, k8Deploy, goalEvent, context); eventAppData[sdmPackK8s] = callbackAppData; } goalEvent.data = JSON.stringify(eventAppData); goalEvent.fulfillment = { method: "sdm", name: KubernetesDeployFulfillerGoalName, registration: registration.name, }; return goalEvent; }); } /** * Fetch [[KubernetesApplication]] from goal event. If the goal event * does not contain Kubernetes application information in its data * property, `undefined` is returned. If the value of the goal event * data cannot be parsed as JSON, an error is thrown. * * @param goalEvent SDM goal event to retrieve the Kubernetes application data from * @return Parsed [[KubernetesApplication]] object */ export function getKubernetesGoalEventData(goalEvent: SdmGoalEvent): KubernetesApplication | undefined { let data: any; try { data = goalData(goalEvent); } catch (e) { logger.error(e.message); throw e; } return data[sdmPackK8s]; } /** * Given the goal event, [[KubernetesDeploy]] goal, and * handler context generate a default [[KubernetesApplication]] object. * * This function uses [[defaultImage]] to determine the image. * * @param goalEvent SDM Kubernetes deployment goal event * @param k8Deploy Kubernetes deployment goal configuration * @param context Handler context * @return a valid default KubernetesApplication for this SDM goal deployment event */ export async function defaultKubernetesApplication( goalEvent: SdmGoalEvent, k8Deploy: KubernetesDeploy, context: HandlerContext, ): Promise<Partial<KubernetesApplication>> { const workspaceId = context.workspaceId; const name = validName(goalEvent.repo.name); const image = await defaultImage(goalEvent, k8Deploy, context); return { workspaceId, name, image, }; } /** * Parse Dockerfile and return port of first argument to the first * EXPOSE command. it can suggessfully convert to an integer. If * there are no EXPOSE commands or if it cannot successfully convert * the EXPOSE arguments to an integer, `undefined` is returned. * * @param p Project to look for Dockerfile in * @return port number or `undefined` if no EXPOSE commands are found */ export async function dockerPort(p: Project): Promise<number | undefined> { const dockerFile = await p.getFile("Dockerfile"); if (dockerFile) { const dockerFileContents = await dockerFile.getContent(); const commands = dockerfileParser.parse(dockerFileContents, { includeComments: false }); const exposeCommands = commands.filter(c => c.name === "EXPOSE"); for (const exposeCommand of exposeCommands) { if (Array.isArray(exposeCommand.args)) { for (const arg of exposeCommand.args) { const port = parseInt(arg, 10); if (!isNaN(port)) { return port; } } } else if (typeof exposeCommand.args === "string") { const port = parseInt(exposeCommand.args, 10); if (!isNaN(port)) { return port; } } else { logger.warn(`Unexpected EXPOSE argument type '${typeof exposeCommand.args}': ${stringify(exposeCommand.args)}`); } } } return undefined; } /** Package return of [[dockerPort]] in [[KubernetesApplication]]. */ async function dockerfileKubernetesApplication(p: Project): Promise<Partial<KubernetesApplication>> { const port = await dockerPort(p); return (port) ? { port } : {}; } /** * Read configured Kubernetes partial specs from repository. * * @param p Project to look for specs * @param registration Configuration of KubernetesDeploy goal * @return KubernetesApplication object with requested specs populated */ export async function repoSpecsKubernetsApplication(p: Project, r: KubernetesDeployRegistration): Promise<Partial<KubernetesApplication>> { const deploymentSpec: DeepPartial<k8s.V1Deployment> = (r.dataSources.includes(KubernetesDeployDataSources.DeploymentSpec)) ? await loadKubernetesSpec(p, "deployment") : undefined; const serviceSpec: DeepPartial<k8s.V1Service> = (r.dataSources.includes(KubernetesDeployDataSources.ServiceSpec)) ? await loadKubernetesSpec(p, "service") : undefined; const ingressSpec: DeepPartial<k8s.NetworkingV1beta1Ingress> = (r.dataSources.includes(KubernetesDeployDataSources.IngressSpec)) ? await loadKubernetesSpec(p, "ingress") : undefined; const roleSpec: DeepPartial<k8s.V1Role> = (r.dataSources.includes(KubernetesDeployDataSources.RoleSpec)) ? await loadKubernetesSpec(p, "role") : undefined; const serviceAccountSpec: DeepPartial<k8s.V1ServiceAccount> = (r.dataSources.includes(KubernetesDeployDataSources.ServiceAccountSpec)) ? await loadKubernetesSpec(p, "service-account") : undefined; const roleBindingSpec: DeepPartial<k8s.V1RoleBinding> = (r.dataSources.includes(KubernetesDeployDataSources.RoleBindingSpec)) ? await loadKubernetesSpec(p, "role-binding") : undefined; return { deploymentSpec, serviceSpec, ingressSpec, roleSpec, serviceAccountSpec, roleBindingSpec, }; } /** * Remove any invalid characters from Docker image name component * `name` to make it a valid Docker image name component. If * `hubOwner` is true, it ensures the name contains only alphanumeric * characters. * * From https://docs.docker.com/engine/reference/commandline/tag/: * * > An image name is made up of slash-separated name components, * > optionally prefixed by a registry hostname. The hostname must * > comply with standard DNS rules, but may not contain * > underscores. If a hostname is present, it may optionally be * > followed by a port number in the format :8080. If not present, * > the command uses Docker’s public registry located at * > registry-1.docker.io by default. Name components may contain * > lowercase letters, digits and separators. A separator is defined * > as a period, one or two underscores, or one or more dashes. A * > name component may not start or end with a separator. * > * > A tag name must be valid ASCII and may contain lowercase and * > uppercase letters, digits, underscores, periods and dashes. A tag * > name may not start with a period or a dash and may contain a * > maximum of 128 characters. * * @param name Name component to clean up. * @param hubOwner If `true` only allow characters valid for a Docker Hub user/org * @return Valid Docker image name component. */ function dockerImageNameComponent(name: string, hubOwner: boolean = false): string { const cleanName = name.toLocaleLowerCase() .replace(/^[^a-z0-9]+/, "") .replace(/[^a-z0-9]+$/, "") .replace(/[^-_/.a-z0-9]+/g, ""); if (hubOwner) { return cleanName.replace(/[^a-z0-9]+/g, ""); } else { return cleanName; } } /** * Determine the best default value for the image property for this * Kubernetes deployment goal event. If there is no image associated * with the after commit of the push, it checks if a Docker registry * is provided at `sdm.configuration.sdm.docker.registry` and uses * that and the repo name to return an image. If neither of those * exist, a Docker Hub-like image name generated from the repository * owner and name. In the latter two cases, it tries to read a * version for this commit from the graph. If it exists it uses it at * the image tag. If it does not, it uses the tag "latest". * * @param goalEvent SDM Kubernetes deployment goal event * @param k8Deploy Kubernetes deployment goal object * @param context Handler context * @return Docker image associated with goal push after commit, or best guess */ export async function defaultImage(goalEvent: SdmGoalEvent, k8Deploy: KubernetesDeploy, context: HandlerContext): Promise<string> { if (goalEvent.push && goalEvent.push.after && goalEvent.push.after.images && goalEvent.push.after.images.length > 0) { return goalEvent.push.after.images[0].imageName; } const slug = goalEventSlug(goalEvent); let version: string; try { version = await readSdmVersion(goalEvent.repo.owner, goalEvent.repo.name, goalEvent.repo.providerId, goalEvent.sha, goalEvent.branch, context); } catch (e) { logger.warn(`Failed to read version for goal ${slug}:${goalEvent.sha}: ${e.message}`); } if (!version) { version = "latest"; } const tag = version.replace(/^[-.]/, "").replace(/[^-.\w]+/, "").substring(0, 128); const dockerName = dockerImageNameComponent(goalEvent.repo.name); const registry = _.get(k8Deploy, "sdm.configuration.sdm.build.docker.registry", dockerImageNameComponent(goalEvent.repo.owner, true)); return `${registry}/${dockerName}:${tag}`; }