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 424 days ago Viewed 35233 times
0
Submitted by rubenfiszel Bun
Verified 141 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
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
  // TODO: handle private SSH keys as well
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"); // needed in case the asset branch already exists in the repo
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
    // git diff returns exit-code = 1 when there's at least one staged changes
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
  // new Deno.Command(cmd, {
272
  //   args: args,
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
// Function to set up GPG signing
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
    // process.env.GIT_TRACE = 1;
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
      // Original error would contain sensitive data
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
      // This is adummy command to unlock the key
446
      // with passphrase to load it into agent
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
    // Configure Git to use the extracted key
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
}
Other submissions