This repository has been archived by the owner on Jun 22, 2020. It is now read-only.
/
application.ts
259 lines (248 loc) · 9.63 KB
/
application.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
/*
* Copyright © 2019 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,
GitProject,
guid,
logger,
Project,
ProjectFile,
projectUtils,
RemoteRepoRef,
} from "@atomist/automation-client";
import {
CachingProjectLoader,
execPromise,
ProjectLoader,
ProjectLoadingParameters,
} from "@atomist/sdm";
import * as k8s from "@kubernetes/client-node";
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;
}