UNPKG

@atomist/sdm

Version:

Atomist Software Delivery Machine SDK

258 lines (246 loc) 10.5 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 { configurationValue } from "@atomist/automation-client/lib/configuration"; import { guid } from "@atomist/automation-client/lib/internal/util/string"; import { RemoteRepoRef } from "@atomist/automation-client/lib/operations/common/RepoId"; import { File as ProjectFile } from "@atomist/automation-client/lib/project/File"; import { GitProject } from "@atomist/automation-client/lib/project/git/GitProject"; import { Project } from "@atomist/automation-client/lib/project/Project"; import * as projectUtils from "@atomist/automation-client/lib/project/util/projectUtils"; import { execPromise } from "@atomist/automation-client/lib/util/child_process"; import { logger } from "@atomist/automation-client/lib/util/logger"; import * as k8s from "@kubernetes/client-node"; import { CachingProjectLoader } from "../../../api-helper/project/CachingProjectLoader"; import { ProjectLoader, ProjectLoadingParameters, } from "../../../spi/project/ProjectLoader"; import { KubernetesSyncOptions, validSyncOptions, } from "../config"; import { parseKubernetesSpecFile } from "../deploy/spec"; import { appName, isKubernetesApplication, KubernetesDelete, } from "../kubernetes/request"; import { kubernetesSpecFileBasename, kubernetesSpecStringify, KubernetesSpecStringifyOptions, } from "../kubernetes/spec"; import { logRetry } from "../support/retry"; import { defaultCloneOptions } from "./clone"; import { k8sSpecGlob } from "./diff"; import { commitTag } from "./tag"; export type SyncAction = "upsert" | "delete"; /** * Synchronize changes from deploying app to the configured syncRepo. * If no syncRepo is configured, do nothing. * * @param app Kubernetes application change that triggered the sync * @param resources Kubernetes resource objects to synchronize * @param action Action performed, "upsert" or "delete" */ export async function syncApplication(app: KubernetesDelete, resources: k8s.KubernetesObject[], action: SyncAction = "upsert"): Promise<void> { const slug = appName(app); const syncOpts = configurationValue<Partial<KubernetesSyncOptions>>("sdm.k8s.options.sync", {}); if (!validSyncOptions(syncOpts)) { return; } const syncRepo = syncOpts.repo as RemoteRepoRef; if (resources.length < 1) { return; } const projectLoadingParameters: ProjectLoadingParameters = { credentials: syncOpts.credentials, cloneOptions: defaultCloneOptions, id: syncRepo, readOnly: false, }; const projectLoader = configurationValue<ProjectLoader>("sdm.projectLoader", new CachingProjectLoader()); try { await projectLoader.doWithProject(projectLoadingParameters, syncResources(app, resources, action, syncOpts)); } catch (e) { e.message = `Failed to perform sync resources from ${slug} to sync repo ${syncRepo.owner}/${syncRepo.repo}: ${e.message}`; logger.error(e.message); throw e; } return; } export interface ProjectFileSpec { file: ProjectFile; spec: k8s.KubernetesObject; } /** * Update the sync repo with the changed resources from a * KubernetesApplication. For each changed resource in `resources`, * loop through all the existing Kubernetes spec files, i.e., those * that match [[k8sSpecGlob]], to see if the apiVersion, kind, name, * and namespace, which may be undefined, match. If a match is found, * update that spec file. If no match is found, create a unique file * name and store the resource spec in it. If changes are made, * commit and push the changes. * * @param app Kubernetes application object * @param resources Resources that were upserted as part of this application * @param action Action performed, "upsert" or "delete" * @param opts Repo sync options, passed to the sync action * @return Function that updates the sync repo with the resource specs */ export function syncResources( app: KubernetesDelete, resources: k8s.KubernetesObject[], action: SyncAction, opts: KubernetesSyncOptions, ): (p: GitProject) => Promise<void> { return async syncProject => { const slug = `${syncProject.id.owner}/${syncProject.id.repo}`; const aName = appName(app); const specs: ProjectFileSpec[] = []; await projectUtils.doWithFiles(syncProject, k8sSpecGlob, async file => { try { const spec = await parseKubernetesSpecFile(file); specs.push({ file, spec }); } catch (e) { logger.warn(`Failed to process sync repo ${slug} spec ${file.path}, ignoring: ${e.message}`); } }); const [syncAction, syncVerb] = (action === "delete") ? [resourceDeleted, "Delete"] : [resourceUpserted, "Update"]; for (const resource of resources) { const fileSpec = matchSpec(resource, specs); await syncAction(resource, syncProject, fileSpec, opts); } if (await syncProject.isClean()) { return; } try { const v = isKubernetesApplication(app) ? app.image.replace(/^.*:/, ":") : ""; await syncProject.commit(`${syncVerb} ${aName}${v}\n\n[atomist:generated] ${commitTag()}\n`); } catch (e) { e.message = `Failed to commit resource changes for ${aName} to sync repo ${slug}: ${e.message}`; logger.error(e.message); throw e; } try { await syncProject.push(); } catch (e) { logger.warn(`Failed on initial sync repo ${slug} push attempt: ${e.message}`); try { await logRetry(async () => { const pullResult = await execPromise("git", ["pull", "--rebase"], { cwd: syncProject.baseDir }); logger.debug(`Sync project 'git pull --rebase': ${pullResult.stdout}; ${pullResult.stderr}`); await syncProject.push(); }, `sync project ${slug} git pull and push`); } catch (e) { e.message = `Failed sync repo ${slug} pull and rebase retries: ${e.message}`; logger.error(e.message); throw e; } } }; } /** * Persist the creation of or update to a resource to the sync repo * project. * * @param resource Kubernetes resource that was upserted * @param p Sync repo project * @param fs File and spec object that matches resource, may be undefined */ async function resourceUpserted(resource: k8s.KubernetesObject, p: Project, fs: ProjectFileSpec, opts: KubernetesSyncOptions): Promise<void> { let format: KubernetesSyncOptions["specFormat"] = "yaml"; if (fs && fs.file) { format = (/\.ya?ml$/.test(fs.file.path)) ? "yaml" : "json"; } else if (opts.specFormat) { format = opts.specFormat; } const stringifyOptions: KubernetesSpecStringifyOptions = { format, secretKey: opts.secretKey, }; const resourceString = await kubernetesSpecStringify(resource, stringifyOptions); if (fs) { await fs.file.setContent(resourceString); } else { const specPath = await uniqueSpecFile(resource, p, format); await p.addFile(specPath, resourceString); } } /** * Safely persist the deletion of a resource to the sync repo project. * If `fs` is `undefined`, do nothing. * * @param resource Kubernetes resource that was upserted * @param p Sync repo project * @param fs File and spec object that matches resource, may be `undefined` */ async function resourceDeleted(resource: k8s.KubernetesObject, p: Project, fs: ProjectFileSpec): Promise<void> { if (fs) { await p.deleteFile(fs.file.path); } } /** * Determine if two Kubernetes resource specifications represent the * same object. When determining if they are the same, only the kind, * name, and namespace, which may be `undefined`, must match. The * apiVersion is not considered when matching because the same * resource can appear under different API versions. Other object * properties are not considered. * * @param a First Kubernetes object spec to match * @param b Second Kubernetes object spec to match * @return `true` if specs match, `false` otherwise */ export function sameObject(a: k8s.KubernetesObject, b: k8s.KubernetesObject): boolean { return a && b && a.metadata && b.metadata && a.kind === b.kind && a.metadata.name === b.metadata.name && a.metadata.namespace === b.metadata.namespace; } /** * Search `fileSpecs` for a spec that matches `spec`. To be * considered a match, the kind, name, and namespace, which may be * undefined, must match. The apiVersion is not considered when * matching because the same resource can appear under different API * versions. * * @param spec Kubernetes object spec to match * @param fileSpecs Array of spec and file objects to search * @return First file and spec object to match spec or `undefined` if no match is found */ export function matchSpec(spec: k8s.KubernetesObject, fileSpecs: ProjectFileSpec[]): ProjectFileSpec | undefined { return fileSpecs.find(fs => sameObject(spec, fs.spec)); } /** * Return a unique name for a resource spec that lexically sorts so * resources that should be created earlier than others sort earlier * than others. * * @param resource Kubernetes object spec * @param p Kubernetes spec project * @return Unique spec file name that sorts properly */ export async function uniqueSpecFile(resource: k8s.KubernetesObject, p: Project, format: KubernetesSyncOptions["specFormat"]): Promise<string> { const specRoot = kubernetesSpecFileBasename(resource); const specExt = `.${format}`; let specPath = specRoot + specExt; while (await p.getFile(specPath)) { specPath = specRoot + "_" + guid().split("-")[0] + specExt; } return specPath; }