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 | type PathType = |
15 | | "script" |
16 | | "flow" |
17 | | "app" |
18 | | "folder" |
19 | | "resource" |
20 | | "variable" |
21 | | "resourcetype" |
22 | | "schedule" |
23 | | "user" |
24 | | "group" |
25 | | "httptrigger" |
26 | | "websockettrigger" |
27 | | "kafkatrigger" |
28 | | "natstrigger" |
29 | | "postgrestrigger" |
30 | | "mqtttrigger" |
31 | | "sqstrigger" |
32 | | "gcptrigger"; |
33 |
|
34 | let gpgFingerprint: string | undefined = undefined; |
35 |
|
36 | export async function main( |
37 | workspace_id: string, |
38 | repo_url_resource_path: string, |
39 | path_type: PathType, |
40 | skip_secret = true, |
41 | path: string | undefined, |
42 | parent_path: string | undefined, |
43 | commit_msg: string, |
44 | use_individual_branch = false, |
45 | group_by_folder = false |
46 | ) { |
47 | let safeDirectoryPath: string | undefined; |
48 | const repo_resource = await wmillclient.getResource(repo_url_resource_path); |
49 | const cwd = process.cwd(); |
50 | process.env["HOME"] = "."; |
51 | console.log( |
52 | `Syncing ${path_type} ${path ?? ""} with parent ${parent_path ?? ""}` |
53 | ); |
54 |
|
55 | if (repo_resource.is_github_app) { |
56 | const token = await get_gh_app_token(); |
57 | const authRepoUrl = prependTokenToGitHubUrl(repo_resource.url, token); |
58 | repo_resource.url = authRepoUrl; |
59 | } |
60 |
|
61 | const { repo_name, safeDirectoryPath: cloneSafeDirectoryPath } = await git_clone(cwd, repo_resource, use_individual_branch); |
62 | safeDirectoryPath = cloneSafeDirectoryPath; |
63 | await move_to_git_branch( |
64 | workspace_id, |
65 | path_type, |
66 | path, |
67 | parent_path, |
68 | use_individual_branch, |
69 | group_by_folder |
70 | ); |
71 | const subfolder = repo_resource.folder ?? ""; |
72 | const branch_or_default = repo_resource.branch ?? "<DEFAULT>"; |
73 | console.log( |
74 | `Pushing to repository ${repo_name} in subfolder ${subfolder} on branch ${branch_or_default}` |
75 | ); |
76 | await wmill_sync_pull( |
77 | path_type, |
78 | workspace_id, |
79 | path, |
80 | parent_path, |
81 | skip_secret, |
82 | repo_url_resource_path, |
83 | use_individual_branch, |
84 | repo_resource.branch |
85 | ); |
86 | try { |
87 | await git_push(path, parent_path, commit_msg, repo_resource); |
88 | } catch (e) { |
89 | throw e; |
90 | } finally { |
91 | await delete_pgp_keys(); |
92 | |
93 | if (safeDirectoryPath) { |
94 | try { |
95 | await sh_run(undefined, "git", "config", "--global", "--unset", "safe.directory", safeDirectoryPath); |
96 | } catch (e) { |
97 | console.log(`Warning: Could not unset safe.directory config: ${e}`); |
98 | } |
99 | } |
100 | } |
101 | console.log("Finished syncing"); |
102 | process.chdir(`${cwd}`); |
103 | } |
104 | async function git_clone( |
105 | cwd: string, |
106 | repo_resource: any, |
107 | use_individual_branch: boolean |
108 | ): Promise<{ repo_name: string; safeDirectoryPath: string }> { |
109 | |
110 | let repo_url = repo_resource.url; |
111 | const subfolder = repo_resource.folder ?? ""; |
112 | const branch = repo_resource.branch ?? ""; |
113 | const repo_name = basename(repo_url, ".git"); |
114 | const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/); |
115 | if (azureMatch) { |
116 | console.log( |
117 | "Requires Azure DevOps service account access token, requesting..." |
118 | ); |
119 | const azureResource = await wmillclient.getResource(azureMatch.groups.url); |
120 | const response = await fetch( |
121 | `https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`, |
122 | { |
123 | method: "POST", |
124 | body: new URLSearchParams({ |
125 | client_id: azureResource.azureClientId, |
126 | client_secret: azureResource.azureClientSecret, |
127 | grant_type: "client_credentials", |
128 | resource: "499b84ac-1321-427f-aa17-267ca6975798/.default", |
129 | }), |
130 | } |
131 | ); |
132 | const { access_token } = await response.json(); |
133 | repo_url = repo_url.replace(azureMatch[0], access_token); |
134 | } |
135 | const args = ["clone", "--quiet", "--depth", "1"]; |
136 | if (use_individual_branch) { |
137 | args.push("--no-single-branch"); |
138 | } |
139 | if (subfolder !== "") { |
140 | args.push("--sparse"); |
141 | } |
142 | if (branch !== "") { |
143 | args.push("--branch"); |
144 | args.push(branch); |
145 | } |
146 | args.push(repo_url); |
147 | args.push(repo_name); |
148 | await sh_run(-1, "git", ...args); |
149 | try { |
150 | process.chdir(`${cwd}/${repo_name}`); |
151 | const safeDirectoryPath = process.cwd(); |
152 | |
153 | try { |
154 | await sh_run(undefined, "git", "config", "--global", "--add", "safe.directory", process.cwd()); |
155 | } catch (e) { |
156 | console.log(`Warning: Could not add safe.directory config: ${e}`); |
157 | } |
158 |
|
159 | if (subfolder !== "") { |
160 | await sh_run(undefined, "git", "sparse-checkout", "add", subfolder); |
161 | try { |
162 | process.chdir(`${cwd}/${repo_name}/${subfolder}`); |
163 | } catch (err) { |
164 | console.log( |
165 | `Error changing directory to '${cwd}/${repo_name}/${subfolder}'. Error was:\n${err}` |
166 | ); |
167 | throw err; |
168 | } |
169 | } |
170 | return { repo_name, safeDirectoryPath }; |
171 | } catch (err) { |
172 | console.log( |
173 | `Error changing directory to '${cwd}/${repo_name}'. Error was:\n${err}` |
174 | ); |
175 | throw err; |
176 | } |
177 | } |
178 | async function move_to_git_branch( |
179 | workspace_id: string, |
180 | path_type: PathType, |
181 | path: string | undefined, |
182 | parent_path: string | undefined, |
183 | use_individual_branch: boolean, |
184 | group_by_folder: boolean |
185 | ) { |
186 | if (!use_individual_branch || path_type === "user" || path_type === "group") { |
187 | return; |
188 | } |
189 | const branchName = group_by_folder |
190 | ? `wm_deploy/${workspace_id}/${(path ?? parent_path) |
191 | ?.split("/") |
192 | .slice(0, 2) |
193 | .join("__")}` |
194 | : `wm_deploy/${workspace_id}/${path_type}/${( |
195 | path ?? parent_path |
196 | )?.replaceAll("/", "__")}`; |
197 | try { |
198 | await sh_run(undefined, "git", "checkout", branchName); |
199 | } catch (err) { |
200 | console.log( |
201 | `Error checking out branch ${branchName}. It is possible it doesn't exist yet, tentatively creating it... Error was:\n${err}` |
202 | ); |
203 | try { |
204 | await sh_run(undefined, "git", "checkout", "-b", branchName); |
205 | await sh_run( |
206 | undefined, |
207 | "git", |
208 | "config", |
209 | "--add", |
210 | "--bool", |
211 | "push.autoSetupRemote", |
212 | "true" |
213 | ); |
214 | } catch (err) { |
215 | console.log( |
216 | `Error checking out branch '${branchName}'. Error was:\n${err}` |
217 | ); |
218 | throw err; |
219 | } |
220 | } |
221 | console.log(`Successfully switched to branch ${branchName}`); |
222 | } |
223 | async function git_push( |
224 | path: string | undefined, |
225 | parent_path: string | undefined, |
226 | commit_msg: string, |
227 | repo_resource: any |
228 | ) { |
229 | let user_email = process.env["WM_EMAIL"] ?? ""; |
230 | let user_name = process.env["WM_USERNAME"] ?? ""; |
231 |
|
232 | if (repo_resource.gpg_key) { |
233 | await set_gpg_signing_secret(repo_resource.gpg_key); |
234 | |
235 | await sh_run( |
236 | undefined, |
237 | "git", |
238 | "config", |
239 | "user.email", |
240 | repo_resource.gpg_key.email |
241 | ); |
242 | await sh_run(undefined, "git", "config", "user.name", user_name); |
243 | } else { |
244 | await sh_run(undefined, "git", "config", "user.email", user_email); |
245 | await sh_run(undefined, "git", "config", "user.name", user_name); |
246 | } |
247 |
|
248 | if (path !== undefined && path !== null && path !== "") { |
249 | try { |
250 | await sh_run(undefined, "git", "add", "wmill-lock.yaml", `${path}**`); |
251 | } catch (e) { |
252 | console.log(`Unable to stage files matching ${path}**, ${e}`); |
253 | } |
254 | } |
255 | if (parent_path !== undefined && parent_path !== null && parent_path !== "") { |
256 | try { |
257 | await sh_run( |
258 | undefined, |
259 | "git", |
260 | "add", |
261 | "wmill-lock.yaml", |
262 | `${parent_path}**` |
263 | ); |
264 | } catch (e) { |
265 | console.log(`Unable to stage files matching ${parent_path}, ${e}`); |
266 | } |
267 | } |
268 | try { |
269 | await sh_run(undefined, "git", "diff", "--cached", "--quiet"); |
270 | } catch { |
271 | |
272 | const commitArgs = ["git", "commit"]; |
273 |
|
274 | |
275 | commitArgs.push("--author", `"${user_name} <${user_email}>"`); |
276 | commitArgs.push( |
277 | "-m", |
278 | `"${commit_msg == undefined || commit_msg == "" ? "no commit msg" : commit_msg}"` |
279 | ); |
280 |
|
281 | await sh_run(undefined, ...commitArgs); |
282 | try { |
283 | await sh_run(undefined, "git", "push", "--porcelain"); |
284 | } catch (e) { |
285 | console.log(`Could not push, trying to rebase first: ${e}`); |
286 | await sh_run(undefined, "git", "pull", "--rebase"); |
287 | await sh_run(undefined, "git", "push", "--porcelain"); |
288 | } |
289 | return; |
290 | } |
291 | console.log("No changes detected, nothing to commit. Returning..."); |
292 | } |
293 | async function sh_run( |
294 | secret_position: number | undefined, |
295 | cmd: string, |
296 | ...args: string[] |
297 | ) { |
298 | const nargs = secret_position != undefined ? args.slice() : args; |
299 | if (secret_position && secret_position < 0) { |
300 | secret_position = nargs.length - 1 + secret_position; |
301 | } |
302 | let secret: string | undefined = undefined; |
303 | if (secret_position != undefined) { |
304 | nargs[secret_position] = "***"; |
305 | secret = args[secret_position]; |
306 | } |
307 |
|
308 | console.log(`Running '${cmd} ${nargs.join(" ")} ...'`); |
309 | const command = exec(`${cmd} ${args.join(" ")}`); |
310 | |
311 | |
312 | |
313 | try { |
314 | const { stdout, stderr } = await command; |
315 | if (stdout.length > 0) { |
316 | console.log(stdout); |
317 | } |
318 | if (stderr.length > 0) { |
319 | console.log(stderr); |
320 | } |
321 | console.log("Command successfully executed"); |
322 | return stdout; |
323 | } catch (error) { |
324 | let errorString = error.toString(); |
325 | if (secret) { |
326 | errorString = errorString.replace(secret, "***"); |
327 | } |
328 | const err = `SH command '${cmd} ${nargs.join( |
329 | " " |
330 | )}' returned with error ${errorString}`; |
331 | throw Error(err); |
332 | } |
333 | } |
334 |
|
335 | function regexFromPath(path_type: PathType, path: string) { |
336 | if (path_type == "flow") { |
337 | return `${path}.flow/*`; |
338 | } |
339 | if (path_type == "app") { |
340 | return `${path}.app/*`; |
341 | } else if (path_type == "folder") { |
342 | return `${path}/folder.meta.*`; |
343 | } else if (path_type == "resourcetype") { |
344 | return `${path}.resource-type.*`; |
345 | } else if (path_type == "resource") { |
346 | return `${path}.resource.*`; |
347 | } else if (path_type == "variable") { |
348 | return `${path}.variable.*`; |
349 | } else if (path_type == "schedule") { |
350 | return `${path}.schedule.*`; |
351 | } else if (path_type == "user") { |
352 | return `${path}.user.*`; |
353 | } else if (path_type == "group") { |
354 | return `${path}.group.*`; |
355 | } else if (path_type == "httptrigger") { |
356 | return `${path}.http_trigger.*`; |
357 | } else if (path_type == "websockettrigger") { |
358 | return `${path}.websocket_trigger.*`; |
359 | } else if (path_type == "kafkatrigger") { |
360 | return `${path}.kafka_trigger.*`; |
361 | } else if (path_type == "natstrigger") { |
362 | return `${path}.nats_trigger.*`; |
363 | } else if (path_type == "postgrestrigger") { |
364 | return `${path}.postgres_trigger.*`; |
365 | } else if (path_type == "mqtttrigger") { |
366 | return `${path}.mqtt_trigger.*`; |
367 | } else if (path_type == "sqstrigger") { |
368 | return `${path}.sqs_trigger.*`; |
369 | } else if (path_type == "gcptrigger") { |
370 | return `${path}.gcp_trigger.*`; |
371 | } else { |
372 | return `${path}.*`; |
373 | } |
374 | } |
375 |
|
376 | async function wmill_sync_pull( |
377 | path_type: PathType, |
378 | workspace_id: string, |
379 | path: string | undefined, |
380 | parent_path: string | undefined, |
381 | skip_secret: boolean, |
382 | repo_url_resource_path: string, |
383 | use_individual_branch: boolean, |
384 | original_branch?: string |
385 | ) { |
386 | const includes = []; |
387 | if (path !== undefined && path !== null && path !== "") { |
388 | includes.push(regexFromPath(path_type, path)); |
389 | } |
390 | if (parent_path !== undefined && parent_path !== null && parent_path !== "") { |
391 | includes.push(regexFromPath(path_type, parent_path)); |
392 | } |
393 | await wmill_run( |
394 | 6, |
395 | "workspace", |
396 | "add", |
397 | workspace_id, |
398 | workspace_id, |
399 | process.env["BASE_URL"] + "/", |
400 | "--token", |
401 | process.env["WM_TOKEN"] ?? "" |
402 | ); |
403 | console.log("Pulling workspace into git repo"); |
404 | const args = [ |
405 | "sync", |
406 | "pull", |
407 | "--token", |
408 | process.env["WM_TOKEN"] ?? "", |
409 | "--workspace", |
410 | workspace_id, |
411 | "--repository", |
412 | repo_url_resource_path, |
413 | "--yes", |
414 | skip_secret ? "--skip-secrets" : "", |
415 | "--include-schedules", |
416 | "--include-users", |
417 | "--include-groups", |
418 | "--include-triggers", |
419 | ]; |
420 |
|
421 | |
422 | if (path_type === "settings" && !use_individual_branch) { |
423 | args.push("--include-settings"); |
424 | } |
425 |
|
426 | |
427 | if (path_type === "key" && !use_individual_branch) { |
428 | args.push("--include-key"); |
429 | } |
430 |
|
431 | args.push("--extra-includes", includes.join(",")); |
432 |
|
433 | |
434 | if (use_individual_branch && original_branch) { |
435 | console.log(`Individual branch deployment detected - using promotion settings from '${original_branch}'`); |
436 | args.push("--promotion", original_branch); |
437 | } |
438 |
|
439 | await wmill_run(3, ...args); |
440 | } |
441 |
|
442 | async function wmill_run(secret_position: number, ...cmd: string[]) { |
443 | cmd = cmd.filter((elt) => elt !== ""); |
444 | const cmd2 = cmd.slice(); |
445 | cmd2[secret_position] = "***"; |
446 | console.log(`Running 'wmill ${cmd2.join(" ")} ...'`); |
447 | await wmill.parse(cmd); |
448 | console.log("Command successfully executed"); |
449 | } |
450 |
|
451 |
|
452 | async function set_gpg_signing_secret(gpg_key: GpgKey) { |
453 | try { |
454 | console.log("Setting GPG private key for git commits"); |
455 |
|
456 | const formattedGpgContent = gpg_key.private_key.replace( |
457 | /(-----BEGIN PGP PRIVATE KEY BLOCK-----)([\s\S]*?)(-----END PGP PRIVATE KEY BLOCK-----)/, |
458 | (_: string, header: string, body: string, footer: string) => |
459 | header + |
460 | "\n" + |
461 | "\n" + |
462 | body.replace(/ ([^\s])/g, "\n$1").trim() + |
463 | "\n" + |
464 | footer |
465 | ); |
466 |
|
467 | const gpg_path = `/tmp/gpg`; |
468 | await sh_run(undefined, "mkdir", "-p", gpg_path); |
469 | await sh_run(undefined, "chmod", "700", gpg_path); |
470 | process.env.GNUPGHOME = gpg_path; |
471 | |
472 |
|
473 | try { |
474 | await sh_run( |
475 | 1, |
476 | "bash", |
477 | "-c", |
478 | `cat <<EOF | gpg --batch --import \n${formattedGpgContent}\nEOF` |
479 | ); |
480 | } catch (e) { |
481 | |
482 | throw new Error("Failed to import GPG key!"); |
483 | } |
484 |
|
485 | const listKeysOutput = await sh_run( |
486 | undefined, |
487 | "gpg", |
488 | "--list-secret-keys", |
489 | "--with-colons", |
490 | "--keyid-format=long" |
491 | ); |
492 |
|
493 | const keyInfoMatch = listKeysOutput.match( |
494 | /sec:[^:]*:[^:]*:[^:]*:([A-F0-9]+):.*\nfpr:::::::::([A-F0-9]{40}):/ |
495 | ); |
496 |
|
497 | if (!keyInfoMatch) { |
498 | throw new Error("Failed to extract GPG Key ID and Fingerprint"); |
499 | } |
500 |
|
501 | const keyId = keyInfoMatch[1]; |
502 | gpgFingerprint = keyInfoMatch[2]; |
503 |
|
504 | if (gpg_key.passphrase) { |
505 | |
506 | |
507 | await sh_run( |
508 | 1, |
509 | "bash", |
510 | "-c", |
511 | `echo "dummy" | gpg --batch --pinentry-mode loopback --passphrase '${gpg_key.passphrase}' --status-fd=2 -bsau ${keyId}` |
512 | ); |
513 | } |
514 |
|
515 | |
516 | await sh_run(undefined, "git", "config", "user.signingkey", keyId); |
517 | await sh_run(undefined, "git", "config", "commit.gpgsign", "true"); |
518 | console.log(`GPG signing configured with key ID: ${keyId} `); |
519 | } catch (e) { |
520 | console.error(`Failure while setting GPG key: ${e} `); |
521 | await delete_pgp_keys(); |
522 | } |
523 | } |
524 |
|
525 | async function delete_pgp_keys() { |
526 | console.log("deleting gpg keys"); |
527 | if (gpgFingerprint) { |
528 | await sh_run( |
529 | undefined, |
530 | "gpg", |
531 | "--batch", |
532 | "--yes", |
533 | "--pinentry-mode", |
534 | "loopback", |
535 | "--delete-secret-key", |
536 | gpgFingerprint |
537 | ); |
538 | await sh_run( |
539 | undefined, |
540 | "gpg", |
541 | "--batch", |
542 | "--yes", |
543 | "--delete-key", |
544 | "--pinentry-mode", |
545 | "loopback", |
546 | gpgFingerprint |
547 | ); |
548 | } |
549 | } |
550 |
|
551 | async function get_gh_app_token() { |
552 | const workspace = process.env["WM_WORKSPACE"]; |
553 | const jobToken = process.env["WM_TOKEN"]; |
554 |
|
555 | const baseUrl = |
556 | process.env["BASE_INTERNAL_URL"] ?? |
557 | process.env["BASE_URL"] ?? |
558 | "http://localhost:8000"; |
559 |
|
560 | const url = `${baseUrl}/api/w/${workspace}/github_app/token`; |
561 |
|
562 | const response = await fetch(url, { |
563 | method: "POST", |
564 | headers: { |
565 | "Content-Type": "application/json", |
566 | Authorization: `Bearer ${jobToken}`, |
567 | }, |
568 | body: JSON.stringify({ |
569 | job_token: jobToken, |
570 | }), |
571 | }); |
572 |
|
573 | if (!response.ok) { |
574 | throw new Error(`Error: ${response.statusText}`); |
575 | } |
576 |
|
577 | const data = await response.json(); |
578 |
|
579 | return data.token; |
580 | } |
581 |
|
582 | function prependTokenToGitHubUrl(gitHubUrl: string, installationToken: string) { |
583 | if (!gitHubUrl || !installationToken) { |
584 | throw new Error("Both GitHub URL and Installation Token are required."); |
585 | } |
586 |
|
587 | try { |
588 | const url = new URL(gitHubUrl); |
589 |
|
590 | |
591 | if (url.hostname !== "github.com") { |
592 | throw new Error( |
593 | "Invalid GitHub URL. Must be in the format 'https://github.com/owner/repo.git'." |
594 | ); |
595 | } |
596 |
|
597 | |
598 | return `https://x-access-token:${installationToken}@github.com${url.pathname}`; |
599 | } catch (e) { |
600 | const error = e as Error; |
601 | throw new Error(`Invalid URL: ${error.message}`); |
602 | } |
603 | } |
604 |
|