1
Sync script to Git repo
One script reply has been approved by the moderators Verified

This script will pull the script from the current workspace in a temporary folder, then commit it to the remote Git repository and push it. Only the script-related file will be pushed, nothing else. It takes as input the git_repository resource containing the repository URL, the script path, and the commit message to use for the commit. All params are mandatory.

Created by hugo697 608 days ago Viewed 91774 times
0
Submitted by rubenfiszel Bun
Verified 325 days ago
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
  const repo_resource = await wmillclient.getResource(repo_url_resource_path);
48
  const cwd = process.cwd();
49
  process.env["HOME"] = ".";
50
  console.log(
51
    `Syncing ${path_type} ${path ?? ""} with parent ${parent_path ?? ""}`
52
  );
53

54
  if (repo_resource.is_github_app) {
55
    const token = await get_gh_app_token();
56
    const authRepoUrl = prependTokenToGitHubUrl(repo_resource.url, token);
57
    repo_resource.url = authRepoUrl;
58
  }
59

60
  const repo_name = await git_clone(cwd, repo_resource, use_individual_branch);
61
  await move_to_git_branch(
62
    workspace_id,
63
    path_type,
64
    path,
65
    parent_path,
66
    use_individual_branch,
67
    group_by_folder
68
  );
69
  const subfolder = repo_resource.folder ?? "";
70
  const branch_or_default = repo_resource.branch ?? "<DEFAULT>";
71
  console.log(
72
    `Pushing to repository ${repo_name} in subfolder ${subfolder} on branch ${branch_or_default}`
73
  );
74
  await wmill_sync_pull(
75
    path_type,
76
    workspace_id,
77
    path,
78
    parent_path,
79
    skip_secret,
80
    repo_url_resource_path
81
  );
82
  try {
83
    await git_push(path, parent_path, commit_msg, repo_resource);
84
  } catch (e) {
85
    throw e;
86
  } finally {
87
    await delete_pgp_keys();
88
  }
89
  console.log("Finished syncing");
90
  process.chdir(`${cwd}`);
91
}
92
async function git_clone(
93
  cwd: string,
94
  repo_resource: any,
95
  use_individual_branch: boolean
96
): Promise<string> {
97
  // TODO: handle private SSH keys as well
98
  let repo_url = repo_resource.url;
99
  const subfolder = repo_resource.folder ?? "";
100
  const branch = repo_resource.branch ?? "";
101
  const repo_name = basename(repo_url, ".git");
102
  const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/);
103
  if (azureMatch) {
104
    console.log(
105
      "Requires Azure DevOps service account access token, requesting..."
106
    );
107
    const azureResource = await wmillclient.getResource(azureMatch.groups.url);
108
    const response = await fetch(
109
      `https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`,
110
      {
111
        method: "POST",
112
        body: new URLSearchParams({
113
          client_id: azureResource.azureClientId,
114
          client_secret: azureResource.azureClientSecret,
115
          grant_type: "client_credentials",
116
          resource: "499b84ac-1321-427f-aa17-267ca6975798/.default",
117
        }),
118
      }
119
    );
120
    const { access_token } = await response.json();
121
    repo_url = repo_url.replace(azureMatch[0], access_token);
122
  }
123
  const args = ["clone", "--quiet", "--depth", "1"];
124
  if (use_individual_branch) {
125
    args.push("--no-single-branch"); // needed in case the asset branch already exists in the repo
126
  }
127
  if (subfolder !== "") {
128
    args.push("--sparse");
129
  }
130
  if (branch !== "") {
131
    args.push("--branch");
132
    args.push(branch);
133
  }
134
  args.push(repo_url);
135
  args.push(repo_name);
136
  await sh_run(-1, "git", ...args);
137
  try {
138
    process.chdir(`${cwd}/${repo_name}`);
139
  } catch (err) {
140
    console.log(
141
      `Error changing directory to '${cwd}/${repo_name}'. Error was:\n${err}`
142
    );
143
    throw err;
144
  }
145
  process.chdir(`${cwd}/${repo_name}`);
146
  if (subfolder !== "") {
147
    await sh_run(undefined, "git", "sparse-checkout", "add", subfolder);
148
  }
149
  try {
150
    process.chdir(`${cwd}/${repo_name}/${subfolder}`);
151
  } catch (err) {
152
    console.log(
153
      `Error changing directory to '${cwd}/${repo_name}/${subfolder}'. Error was:\n${err}`
154
    );
155
    throw err;
156
  }
157
  return repo_name;
158
}
159
async function move_to_git_branch(
160
  workspace_id: string,
161
  path_type: PathType,
162
  path: string | undefined,
163
  parent_path: string | undefined,
164
  use_individual_branch: boolean,
165
  group_by_folder: boolean
166
) {
167
  if (!use_individual_branch || path_type === "user" || path_type === "group") {
168
    return;
169
  }
170
  const branchName = group_by_folder
171
    ? `wm_deploy/${workspace_id}/${(path ?? parent_path)
172
        ?.split("/")
173
        .slice(0, 2)
174
        .join("__")}`
175
    : `wm_deploy/${workspace_id}/${path_type}/${(
176
        path ?? parent_path
177
      )?.replaceAll("/", "__")}`;
178
  try {
179
    await sh_run(undefined, "git", "checkout", branchName);
180
  } catch (err) {
181
    console.log(
182
      `Error checking out branch ${branchName}. It is possible it doesn't exist yet, tentatively creating it... Error was:\n${err}`
183
    );
184
    try {
185
      await sh_run(undefined, "git", "checkout", "-b", branchName);
186
      await sh_run(
187
        undefined,
188
        "git",
189
        "config",
190
        "--add",
191
        "--bool",
192
        "push.autoSetupRemote",
193
        "true"
194
      );
195
    } catch (err) {
196
      console.log(
197
        `Error checking out branch '${branchName}'. Error was:\n${err}`
198
      );
199
      throw err;
200
    }
201
  }
202
  console.log(`Successfully switched to branch ${branchName}`);
203
}
204
async function git_push(
205
  path: string | undefined,
206
  parent_path: string | undefined,
207
  commit_msg: string,
208
  repo_resource: any
209
) {
210
  let user_email = process.env["WM_EMAIL"] ?? "";
211
  let user_name = process.env["WM_USERNAME"] ?? "";
212

213
  if (repo_resource.gpg_key) {
214
    await set_gpg_signing_secret(repo_resource.gpg_key);
215
    // Configure git with GPG key email for signing
216
    await sh_run(
217
      undefined,
218
      "git",
219
      "config",
220
      "user.email",
221
      repo_resource.gpg_key.email
222
    );
223
    await sh_run(undefined, "git", "config", "user.name", user_name);
224
  } else {
225
    await sh_run(undefined, "git", "config", "user.email", user_email);
226
    await sh_run(undefined, "git", "config", "user.name", user_name);
227
  }
228

229
  if (path !== undefined && path !== null && path !== "") {
230
    try {
231
      await sh_run(undefined, "git", "add", "wmill-lock.yaml", `${path}**`);
232
    } catch (e) {
233
      console.log(`Unable to stage files matching ${path}**, ${e}`);
234
    }
235
  }
236
  if (parent_path !== undefined && parent_path !== null && parent_path !== "") {
237
    try {
238
      await sh_run(
239
        undefined,
240
        "git",
241
        "add",
242
        "wmill-lock.yaml",
243
        `${parent_path}**`
244
      );
245
    } catch (e) {
246
      console.log(`Unable to stage files matching ${parent_path}, ${e}`);
247
    }
248
  }
249
  try {
250
    await sh_run(undefined, "git", "diff", "--cached", "--quiet");
251
  } catch {
252
    // git diff returns exit-code = 1 when there's at least one staged changes
253
    const commitArgs = ["git", "commit"];
254

255
    // Always use --author to set consistent authorship
256
    commitArgs.push("--author", `"${user_name} <${user_email}>"`);
257
    commitArgs.push(
258
      "-m",
259
      `"${commit_msg == undefined || commit_msg == "" ? "no commit msg" : commit_msg}"`
260
    );
261

262
    await sh_run(undefined, ...commitArgs);
263
    try {
264
      await sh_run(undefined, "git", "push", "--porcelain");
265
    } catch (e) {
266
      console.log(`Could not push, trying to rebase first: ${e}`);
267
      await sh_run(undefined, "git", "pull", "--rebase");
268
      await sh_run(undefined, "git", "push", "--porcelain");
269
    }
270
    return;
271
  }
272
  console.log("No changes detected, nothing to commit. Returning...");
273
}
274
async function sh_run(
275
  secret_position: number | undefined,
276
  cmd: string,
277
  ...args: string[]
278
) {
279
  const nargs = secret_position != undefined ? args.slice() : args;
280
  if (secret_position && secret_position < 0) {
281
    secret_position = nargs.length - 1 + secret_position;
282
  }
283
  let secret: string | undefined = undefined;
284
  if (secret_position != undefined) {
285
    nargs[secret_position] = "***";
286
    secret = args[secret_position];
287
  }
288

289
  console.log(`Running '${cmd} ${nargs.join(" ")} ...'`);
290
  const command = exec(`${cmd} ${args.join(" ")}`);
291
  // new Deno.Command(cmd, {
292
  //   args: args,
293
  // });
294
  try {
295
    const { stdout, stderr } = await command;
296
    if (stdout.length > 0) {
297
      console.log(stdout);
298
    }
299
    if (stderr.length > 0) {
300
      console.log(stderr);
301
    }
302
    console.log("Command successfully executed");
303
    return stdout;
304
  } catch (error) {
305
    let errorString = error.toString();
306
    if (secret) {
307
      errorString = errorString.replace(secret, "***");
308
    }
309
    const err = `SH command '${cmd} ${nargs.join(
310
      " "
311
    )}' returned with error ${errorString}`;
312
    throw Error(err);
313
  }
314
}
315

316
function regexFromPath(path_type: PathType, path: string) {
317
  if (path_type == "flow") {
318
    return `${path}.flow/*`;
319
  }
320
  if (path_type == "app") {
321
    return `${path}.app/*`;
322
  } else if (path_type == "folder") {
323
    return `${path}/folder.meta.*`;
324
  } else if (path_type == "resourcetype") {
325
    return `${path}.resource-type.*`;
326
  } else if (path_type == "resource") {
327
    return `${path}.resource.*`;
328
  } else if (path_type == "variable") {
329
    return `${path}.variable.*`;
330
  } else if (path_type == "schedule") {
331
    return `${path}.schedule.*`;
332
  } else if (path_type == "user") {
333
    return `${path}.user.*`;
334
  } else if (path_type == "group") {
335
    return `${path}.group.*`;
336
  } else if (path_type == "httptrigger") {
337
    return `${path}.http_trigger.*`;
338
  } else if (path_type == "websockettrigger") {
339
    return `${path}.websocket_trigger.*`;
340
  } else if (path_type == "kafkatrigger") {
341
    return `${path}.kafka_trigger.*`;
342
  } else if (path_type == "natstrigger") {
343
    return `${path}.nats_trigger.*`;
344
  } else if (path_type == "postgrestrigger") {
345
    return `${path}.postgres_trigger.*`;
346
  } else if (path_type == "mqtttrigger") {
347
    return `${path}.mqtt_trigger.*`;
348
  } else if (path_type == "sqstrigger") {
349
    return `${path}.sqs_trigger.*`;
350
  } else if (path_type == "gcptrigger") {
351
    return `${path}.gcp_trigger.*`;
352
  } else {
353
    return `${path}.*`;
354
  }
355
}
356

357
async function wmill_sync_pull(
358
  path_type: PathType,
359
  workspace_id: string,
360
  path: string | undefined,
361
  parent_path: string | undefined,
362
  skip_secret: boolean,
363
  repo_url_resource_path: string
364
) {
365
  const includes = [];
366
  if (path !== undefined && path !== null && path !== "") {
367
    includes.push(regexFromPath(path_type, path));
368
  }
369
  if (parent_path !== undefined && parent_path !== null && parent_path !== "") {
370
    includes.push(regexFromPath(path_type, parent_path));
371
  }
372
  await wmill_run(
373
    6,
374
    "workspace",
375
    "add",
376
    workspace_id,
377
    workspace_id,
378
    process.env["BASE_URL"] + "/",
379
    "--token",
380
    process.env["WM_TOKEN"] ?? ""
381
  );
382
  console.log("Pulling workspace into git repo");
383
  const args = [
384
    "sync",
385
    "pull",
386
    "--token",
387
    process.env["WM_TOKEN"] ?? "",
388
    "--workspace",
389
    workspace_id,
390
    "--repository",
391
    repo_url_resource_path,
392
    "--yes",
393
    skip_secret ? "--skip-secrets" : "",
394
    "--include-schedules",
395
    "--include-users",
396
    "--include-groups",
397
    "--include-triggers",
398
  ];
399

400
  // Only include settings when specifically deploying settings
401
  if (path_type === "settings") {
402
    args.push("--include-settings");
403
  }
404

405
  // Only include key when specifically deploying keys
406
  if (path_type === "key") {
407
    args.push("--include-key");
408
  }
409

410
  args.push("--extra-includes", includes.join(","));
411

412
  await wmill_run(3, ...args);
413
}
414

415
async function wmill_run(secret_position: number, ...cmd: string[]) {
416
  cmd = cmd.filter((elt) => elt !== "");
417
  const cmd2 = cmd.slice();
418
  cmd2[secret_position] = "***";
419
  console.log(`Running 'wmill ${cmd2.join(" ")} ...'`);
420
  await wmill.parse(cmd);
421
  console.log("Command successfully executed");
422
}
423

424
// Function to set up GPG signing
425
async function set_gpg_signing_secret(gpg_key: GpgKey) {
426
  try {
427
    console.log("Setting GPG private key for git commits");
428

429
    const formattedGpgContent = gpg_key.private_key.replace(
430
      /(-----BEGIN PGP PRIVATE KEY BLOCK-----)([\s\S]*?)(-----END PGP PRIVATE KEY BLOCK-----)/,
431
      (_: string, header: string, body: string, footer: string) =>
432
        header +
433
        "\n" +
434
        "\n" +
435
        body.replace(/ ([^\s])/g, "\n$1").trim() +
436
        "\n" +
437
        footer
438
    );
439

440
    const gpg_path = `/tmp/gpg`;
441
    await sh_run(undefined, "mkdir", "-p", gpg_path);
442
    await sh_run(undefined, "chmod", "700", gpg_path);
443
    process.env.GNUPGHOME = gpg_path;
444
    // process.env.GIT_TRACE = 1;
445

446
    try {
447
      await sh_run(
448
        1,
449
        "bash",
450
        "-c",
451
        `cat <<EOF | gpg --batch --import \n${formattedGpgContent}\nEOF`
452
      );
453
    } catch (e) {
454
      // Original error would contain sensitive data
455
      throw new Error("Failed to import GPG key!");
456
    }
457

458
    const listKeysOutput = await sh_run(
459
      undefined,
460
      "gpg",
461
      "--list-secret-keys",
462
      "--with-colons",
463
      "--keyid-format=long"
464
    );
465

466
    const keyInfoMatch = listKeysOutput.match(
467
      /sec:[^:]*:[^:]*:[^:]*:([A-F0-9]+):.*\nfpr:::::::::([A-F0-9]{40}):/
468
    );
469

470
    if (!keyInfoMatch) {
471
      throw new Error("Failed to extract GPG Key ID and Fingerprint");
472
    }
473

474
    const keyId = keyInfoMatch[1];
475
    gpgFingerprint = keyInfoMatch[2];
476

477
    if (gpg_key.passphrase) {
478
      // This is adummy command to unlock the key
479
      // with passphrase to load it into agent
480
      await sh_run(
481
        1,
482
        "bash",
483
        "-c",
484
        `echo "dummy" | gpg --batch --pinentry-mode loopback --passphrase '${gpg_key.passphrase}' --status-fd=2 -bsau ${keyId}`
485
      );
486
    }
487

488
    // Configure Git to use the extracted key
489
    await sh_run(undefined, "git", "config", "user.signingkey", keyId);
490
    await sh_run(undefined, "git", "config", "commit.gpgsign", "true");
491
    console.log(`GPG signing configured with key ID: ${keyId} `);
492
  } catch (e) {
493
    console.error(`Failure while setting GPG key: ${e} `);
494
    await delete_pgp_keys();
495
  }
496
}
497

498
async function delete_pgp_keys() {
499
  console.log("deleting gpg keys");
500
  if (gpgFingerprint) {
501
    await sh_run(
502
      undefined,
503
      "gpg",
504
      "--batch",
505
      "--yes",
506
      "--pinentry-mode",
507
      "loopback",
508
      "--delete-secret-key",
509
      gpgFingerprint
510
    );
511
    await sh_run(
512
      undefined,
513
      "gpg",
514
      "--batch",
515
      "--yes",
516
      "--delete-key",
517
      "--pinentry-mode",
518
      "loopback",
519
      gpgFingerprint
520
    );
521
  }
522
}
523

524
async function get_gh_app_token() {
525
  const workspace = process.env["WM_WORKSPACE"];
526
  const jobToken = process.env["WM_TOKEN"];
527

528
  const baseUrl =
529
    process.env["BASE_INTERNAL_URL"] ??
530
    process.env["BASE_URL"] ??
531
    "http://localhost:8000";
532

533
  const url = `${baseUrl}/api/w/${workspace}/github_app/token`;
534

535
  const response = await fetch(url, {
536
    method: "POST",
537
    headers: {
538
      "Content-Type": "application/json",
539
      Authorization: `Bearer ${jobToken}`,
540
    },
541
    body: JSON.stringify({
542
      job_token: jobToken,
543
    }),
544
  });
545

546
  if (!response.ok) {
547
    throw new Error(`Error: ${response.statusText}`);
548
  }
549

550
  const data = await response.json();
551

552
  return data.token;
553
}
554

555
function prependTokenToGitHubUrl(gitHubUrl: string, installationToken: string) {
556
  if (!gitHubUrl || !installationToken) {
557
    throw new Error("Both GitHub URL and Installation Token are required.");
558
  }
559

560
  try {
561
    const url = new URL(gitHubUrl);
562

563
    // GitHub repository URL should be in the format: https://github.com/owner/repo.git
564
    if (url.hostname !== "github.com") {
565
      throw new Error(
566
        "Invalid GitHub URL. Must be in the format 'https://github.com/owner/repo.git'."
567
      );
568
    }
569

570
    // Convert URL to include the installation token
571
    return `https://x-access-token:${installationToken}@github.com${url.pathname}`;
572
  } catch (e) {
573
    const error = e as Error;
574
    throw new Error(`Invalid URL: ${error.message}`);
575
  }
576
}
577

Other submissions