@atomist/sdm
Version:
Atomist Software Delivery Machine SDK
317 lines (294 loc) • 13.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 { 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}`;
}