1 | import * as wmillclient from "windmill-client"; |
2 | import wmill from "windmill-cli"; |
3 | import { basename } from "node:path" |
4 | const util = require('util'); |
5 | const exec = util.promisify(require('child_process').exec); |
6 | import process from "process"; |
7 |
|
8 | type GpgKey = { |
9 | email: string, |
10 | private_key: string, |
11 | passphrase: string |
12 | } |
13 |
|
14 | let gpgFingerprint: string | undefined = undefined; |
15 |
|
16 | export async function main( |
17 | workspace_id: string, |
18 | repo_url_resource_path: string, |
19 | path_type: |
20 | | "script" |
21 | | "flow" |
22 | | "app" |
23 | | "folder" |
24 | | "resource" |
25 | | "variable" |
26 | | "resourcetype" |
27 | | "schedule" |
28 | | "user" |
29 | | "group", |
30 | skip_secret = true, |
31 | path: string | undefined, |
32 | parent_path: string | undefined, |
33 | commit_msg: string, |
34 | use_individual_branch = false, |
35 | group_by_folder = false |
36 | ) { |
37 | const repo_resource = await wmillclient.getResource(repo_url_resource_path); |
38 | const cwd = process.cwd(); |
39 | process.env["HOME"] = "." |
40 | console.log( |
41 | `Syncing ${path_type} ${path ?? ""} with parent ${parent_path ?? ""}` |
42 | ); |
43 | const repo_name = await git_clone(cwd, repo_resource, use_individual_branch); |
44 | await move_to_git_branch( |
45 | workspace_id, |
46 | path_type, |
47 | path, |
48 | parent_path, |
49 | use_individual_branch, |
50 | group_by_folder |
51 | ); |
52 | const subfolder = repo_resource.folder ?? ""; |
53 | const branch_or_default = repo_resource.branch ?? "<DEFAULT>"; |
54 | console.log( |
55 | `Pushing to repository ${repo_name} in subfolder ${subfolder} on branch ${branch_or_default}` |
56 | ); |
57 | await wmill_sync_pull( |
58 | path_type, |
59 | workspace_id, |
60 | path, |
61 | parent_path, |
62 | skip_secret |
63 | ); |
64 | try { |
65 | await git_push(path, parent_path, commit_msg, repo_resource); |
66 | } catch (e) { |
67 | throw (e) |
68 | } finally { |
69 | await delete_pgp_keys() |
70 | } |
71 | console.log("Finished syncing"); |
72 | process.chdir(`${cwd}`); |
73 | } |
74 | async function git_clone( |
75 | cwd: string, |
76 | repo_resource: any, |
77 | use_individual_branch: boolean |
78 | ): Promise<string> { |
79 | |
80 | let repo_url = repo_resource.url; |
81 | const subfolder = repo_resource.folder ?? ""; |
82 | const branch = repo_resource.branch ?? ""; |
83 | const repo_name = basename(repo_url, ".git"); |
84 | const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/); |
85 | if (azureMatch) { |
86 | console.log( |
87 | "Requires Azure DevOps service account access token, requesting..." |
88 | ); |
89 | const azureResource = await wmillclient.getResource(azureMatch.groups.url); |
90 | const response = await fetch( |
91 | `https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`, |
92 | { |
93 | method: "POST", |
94 | body: new URLSearchParams({ |
95 | client_id: azureResource.azureClientId, |
96 | client_secret: azureResource.azureClientSecret, |
97 | grant_type: "client_credentials", |
98 | resource: "499b84ac-1321-427f-aa17-267ca6975798/.default", |
99 | }), |
100 | } |
101 | ); |
102 | const { access_token } = await response.json(); |
103 | repo_url = repo_url.replace(azureMatch[0], access_token); |
104 | } |
105 | const args = ["clone", "--quiet", "--depth", "1"]; |
106 | if (use_individual_branch) { |
107 | args.push("--no-single-branch"); |
108 | } |
109 | if (subfolder !== "") { |
110 | args.push("--sparse"); |
111 | } |
112 | if (branch !== "") { |
113 | args.push("--branch"); |
114 | args.push(branch); |
115 | } |
116 | args.push(repo_url); |
117 | args.push(repo_name); |
118 | await sh_run(-1, "git", ...args); |
119 | try { |
120 | process.chdir(`${cwd}/${repo_name}`); |
121 | } catch (err) { |
122 | console.log( |
123 | `Error changing directory to '${cwd}/${repo_name}'. Error was:\n${err}` |
124 | ); |
125 | throw err; |
126 | } |
127 | process.chdir(`${cwd}/${repo_name}`); |
128 | if (subfolder !== "") { |
129 | await sh_run(undefined, "git", "sparse-checkout", "add", subfolder); |
130 | } |
131 | try { |
132 | process.chdir(`${cwd}/${repo_name}/${subfolder}`); |
133 | } catch (err) { |
134 | console.log( |
135 | `Error changing directory to '${cwd}/${repo_name}/${subfolder}'. Error was:\n${err}` |
136 | ); |
137 | throw err; |
138 | } |
139 | return repo_name; |
140 | } |
141 | async function move_to_git_branch( |
142 | workspace_id: string, |
143 | path_type: |
144 | | "script" |
145 | | "flow" |
146 | | "app" |
147 | | "folder" |
148 | | "resource" |
149 | | "variable" |
150 | | "resourcetype" |
151 | | "schedule" |
152 | | "user" |
153 | | "group", |
154 | path: string | undefined, |
155 | parent_path: string | undefined, |
156 | use_individual_branch: boolean, |
157 | group_by_folder: boolean |
158 | ) { |
159 | if (!use_individual_branch || path_type === "user" || path_type === "group") { |
160 | return; |
161 | } |
162 | const branchName = group_by_folder |
163 | ? `wm_deploy/${workspace_id}/${(path ?? parent_path) |
164 | ?.split("/") |
165 | .slice(0, 2) |
166 | .join("__")}` |
167 | : `wm_deploy/${workspace_id}/${path_type}/${( |
168 | path ?? parent_path |
169 | )?.replaceAll("/", "__")}`; |
170 | try { |
171 | await sh_run(undefined, "git", "checkout", branchName); |
172 | } catch (err) { |
173 | console.log( |
174 | `Error checking out branch ${branchName}. It is possible it doesn't exist yet, tentatively creating it... Error was:\n${err}` |
175 | ); |
176 | try { |
177 | await sh_run(undefined, "git", "checkout", "-b", branchName); |
178 | await sh_run( |
179 | undefined, |
180 | "git", |
181 | "config", |
182 | "--add", |
183 | "--bool", |
184 | "push.autoSetupRemote", |
185 | "true" |
186 | ); |
187 | } catch (err) { |
188 | console.log( |
189 | `Error checking out branch '${branchName}'. Error was:\n${err}` |
190 | ); |
191 | throw err; |
192 | } |
193 | } |
194 | console.log(`Successfully switched to branch ${branchName}`); |
195 | } |
196 | async function git_push( |
197 | path: string | undefined, |
198 | parent_path: string | undefined, |
199 | commit_msg: string, |
200 | repo_resource: any |
201 | ) { |
202 | let user_email = process.env["WM_EMAIL"] ?? "" |
203 | let user_name = process.env["WM_USERNAME"] ?? "" |
204 |
|
205 | if (repo_resource.gpg_key) { |
206 | await set_gpg_signing_secret(repo_resource.gpg_key); |
207 | user_email = repo_resource.gpg_key.email |
208 | } |
209 |
|
210 | await sh_run( |
211 | undefined, |
212 | "git", |
213 | "config", |
214 | "user.email", |
215 | user_email |
216 | ); |
217 | await sh_run( |
218 | undefined, |
219 | "git", |
220 | "config", |
221 | "user.name", |
222 | user_name |
223 | ); |
224 | if (path !== undefined && path !== null && path !== "") { |
225 | try { |
226 | await sh_run(undefined, "git", "add", "wmill-lock.yaml", `${path}**`); |
227 | } catch (e) { |
228 | console.log(`Unable to stage files matching ${path}**, ${e}`); |
229 | } |
230 | } |
231 | if (parent_path !== undefined && parent_path !== null && parent_path !== "") { |
232 | try { |
233 | await sh_run(undefined, "git", "add", "wmill-lock.yaml", `${parent_path}**`); |
234 | } catch (e) { |
235 | console.log(`Unable to stage files matching ${parent_path}, ${e}`); |
236 | } |
237 | } |
238 | try { |
239 | await sh_run(undefined, "git", "diff", "--cached", "--quiet"); |
240 | } catch { |
241 | |
242 | await sh_run(undefined, "git", "commit", "-m", `"${commit_msg == undefined || commit_msg == "" ? "no commit msg" : commit_msg}"`); |
243 | try { |
244 | await sh_run(undefined, "git", "push", "--porcelain"); |
245 | } catch (e) { |
246 | console.log(`Could not push, trying to rebase first: ${e}`); |
247 | await sh_run(undefined, "git", "pull", "--rebase"); |
248 | await sh_run(undefined, "git", "push", "--porcelain"); |
249 | } |
250 | return; |
251 | } |
252 | console.log("No changes detected, nothing to commit. Returning..."); |
253 | } |
254 | async function sh_run( |
255 | secret_position: number | undefined, |
256 | cmd: string, |
257 | ...args: string[] |
258 | ) { |
259 | const nargs = secret_position != undefined ? args.slice() : args; |
260 | if (secret_position && secret_position < 0) { |
261 | secret_position = nargs.length - 1 + secret_position; |
262 | } |
263 | let secret: string | undefined = undefined |
264 | if (secret_position != undefined) { |
265 | nargs[secret_position] = "***"; |
266 | secret = args[secret_position] |
267 | } |
268 | |
269 | console.log(`Running '${cmd} ${nargs.join(" ")} ...'`); |
270 | const command = exec(`${cmd} ${args.join(" ")}`) |
271 | |
272 | |
273 | |
274 | try { |
275 | const { stdout, stderr } = await command |
276 | if (stdout.length > 0) { |
277 | console.log(stdout); |
278 | } |
279 | if (stderr.length > 0) { |
280 | console.log(stderr); |
281 | } |
282 | console.log("Command successfully executed"); |
283 | return stdout; |
284 | |
285 | } catch (error) { |
286 | let errorString = error.toString(); |
287 | if (secret) { |
288 | errorString = errorString.replace(secret, "***"); |
289 | } |
290 | const err = `SH command '${cmd} ${nargs.join( |
291 | " " |
292 | )}' returned with error ${errorString}`; |
293 | throw Error(err); |
294 | } |
295 | } |
296 |
|
297 | function regexFromPath( |
298 | path_type: |
299 | | "script" |
300 | | "flow" |
301 | | "app" |
302 | | "folder" |
303 | | "resource" |
304 | | "variable" |
305 | | "resourcetype" |
306 | | "schedule" |
307 | | "user" |
308 | | "group", |
309 | path: string |
310 | ) { |
311 | if (path_type == "flow") { |
312 | return `${path}.flow/*`; |
313 | } if (path_type == "app") { |
314 | return `${path}.app/*`; |
315 | } else if (path_type == "folder") { |
316 | return `${path}/folder.meta.*`; |
317 | } else if (path_type == "resourcetype") { |
318 | return `${path}.resource-type.*`; |
319 | } else if (path_type == "resource") { |
320 | return `${path}.resource.*`; |
321 | } else if (path_type == "variable") { |
322 | return `${path}.variable.*`; |
323 | } else if (path_type == "schedule") { |
324 | return `${path}.schedule.*`; |
325 | } else if (path_type == "user") { |
326 | return `${path}.user.*`; |
327 | } else if (path_type == "group") { |
328 | return `${path}.group.*`; |
329 | } else { |
330 | return `${path}.*`; |
331 | } |
332 | } |
333 | async function wmill_sync_pull( |
334 | path_type: |
335 | | "script" |
336 | | "flow" |
337 | | "app" |
338 | | "folder" |
339 | | "resource" |
340 | | "variable" |
341 | | "resourcetype" |
342 | | "schedule" |
343 | | "user" |
344 | | "group", |
345 | workspace_id: string, |
346 | path: string | undefined, |
347 | parent_path: string | undefined, |
348 | skip_secret: boolean |
349 | ) { |
350 | const includes = []; |
351 | if (path !== undefined && path !== null && path !== "") { |
352 | includes.push(regexFromPath(path_type, path)); |
353 | } |
354 | if (parent_path !== undefined && parent_path !== null && parent_path !== "") { |
355 | includes.push(regexFromPath(path_type, parent_path)); |
356 | } |
357 | await wmill_run( |
358 | 6, |
359 | "workspace", |
360 | "add", |
361 | workspace_id, |
362 | workspace_id, |
363 | process.env["BASE_URL"] + "/", |
364 | "--token", |
365 | process.env["WM_TOKEN"] ?? "" |
366 | ); |
367 | console.log("Pulling workspace into git repo"); |
368 | await wmill_run( |
369 | 3, |
370 | "sync", |
371 | "pull", |
372 | "--token", |
373 | process.env["WM_TOKEN"] ?? "", |
374 | "--workspace", |
375 | workspace_id, |
376 | "--yes", |
377 | "--raw", |
378 | skip_secret ? "--skip-secrets" : "", |
379 | "--include-schedules", |
380 | "--include-users", |
381 | "--include-groups", |
382 | "--extra-includes", |
383 | includes.join(",") |
384 | ); |
385 | } |
386 |
|
387 | async function wmill_run(secret_position: number, ...cmd: string[]) { |
388 | cmd = cmd.filter((elt) => elt !== ""); |
389 | const cmd2 = cmd.slice(); |
390 | cmd2[secret_position] = "***"; |
391 | console.log(`Running 'wmill ${cmd2.join(" ")} ...'`); |
392 | await wmill.parse(cmd); |
393 | console.log("Command successfully executed"); |
394 | } |
395 |
|
396 |
|
397 | async function set_gpg_signing_secret(gpg_key: GpgKey) { |
398 | try { |
399 | console.log("Setting GPG private key for git commits"); |
400 |
|
401 | const formattedGpgContent = gpg_key.private_key |
402 | .replace( |
403 | /(-----BEGIN PGP PRIVATE KEY BLOCK-----)([\s\S]*?)(-----END PGP PRIVATE KEY BLOCK-----)/, |
404 | (_: string, header: string, body: string, footer: string) => |
405 | header + "\n" + "\n" + body.replace(/ ([^\s])/g, "\n$1").trim() + "\n" + footer |
406 | ); |
407 |
|
408 | const gpg_path = `/tmp/gpg`; |
409 | await sh_run(undefined, "mkdir", "-p", gpg_path); |
410 | await sh_run(undefined, "chmod", "700", gpg_path) |
411 | process.env.GNUPGHOME = gpg_path; |
412 | |
413 |
|
414 |
|
415 | try { |
416 | await sh_run( |
417 | 1, |
418 | "bash", |
419 | "-c", |
420 | `cat <<EOF | gpg --batch --import \n${formattedGpgContent}\nEOF` |
421 | ); |
422 | } catch (e) { |
423 | |
424 | throw new Error('Failed to import GPG key!') |
425 | } |
426 |
|
427 | const listKeysOutput = await sh_run( |
428 | undefined, |
429 | "gpg", |
430 | "--list-secret-keys", |
431 | "--with-colons", |
432 | "--keyid-format=long" |
433 | ); |
434 |
|
435 | const keyInfoMatch = listKeysOutput.match(/sec:[^:]*:[^:]*:[^:]*:([A-F0-9]+):.*\nfpr:::::::::([A-F0-9]{40}):/); |
436 |
|
437 | if (!keyInfoMatch) { |
438 | throw new Error("Failed to extract GPG Key ID and Fingerprint"); |
439 | } |
440 |
|
441 | const keyId = keyInfoMatch[1]; |
442 | gpgFingerprint = keyInfoMatch[2]; |
443 |
|
444 | if (gpg_key.passphrase) { |
445 | |
446 | |
447 | await sh_run( |
448 | 1, |
449 | "bash", |
450 | "-c", |
451 | `echo "dummy" | gpg --batch --pinentry-mode loopback --passphrase '${gpg_key.passphrase}' --status-fd=2 -bsau ${keyId}` |
452 | ); |
453 | } |
454 |
|
455 | |
456 | await sh_run(undefined, "git", "config", "user.signingkey", keyId); |
457 | await sh_run(undefined, "git", "config", "commit.gpgsign", "true"); |
458 | console.log(`GPG signing configured with key ID: ${keyId} `); |
459 |
|
460 | } catch (e) { |
461 | console.error(`Failure while setting GPG key: ${e} `); |
462 | await delete_pgp_keys(); |
463 | } |
464 | } |
465 |
|
466 | async function delete_pgp_keys() { |
467 | console.log("deleting gpg keys") |
468 | if (gpgFingerprint) { |
469 | await sh_run(undefined, "gpg", "--batch", "--yes", "--pinentry-mode", "loopback", "--delete-secret-key", gpgFingerprint) |
470 | await sh_run(undefined, "gpg", "--batch", "--yes", "--delete-key", "--pinentry-mode", "loopback", gpgFingerprint) |
471 | } |
472 | } |