0

Git repo test read write

by
Published Dec 8, 2023

This script will test the connection to the Git repo passed stored in the resources passed as an argument. It first clones the repo (checking read) and then attemps an empty push (checking write)

Script windmill Verified

The script

Submitted by rubenfiszel Bun
Verified 11 days ago
1
import * as wmill from "windmill-client";
2
import { basename } from "node:path"
3
const util = require('util');
4
const exec = util.promisify(require('child_process').exec);
5

6

7
export async function main(repo_url_resource_path: string, init: boolean = false) {
8
  const cwd = process.cwd();
9
  process.env["HOME"] = ".";
10
  console.log(`Cloning repo from resource`);
11
  let repo_name;
12
  try {
13
    repo_name = await git_clone(repo_url_resource_path);
14
    process.chdir(`${cwd}/${repo_name}`);
15
    // Add safe.directory to handle dubious ownership in cloned repo
16
    try {
17
      await sh_run(undefined, "git", "config", "--global", "--add", "safe.directory", process.cwd());
18
    } catch (e) {
19
      console.log(`Warning: Could not add safe.directory config: ${e}`);
20
    }
21
    console.log(`Attempting an empty push to repository ${repo_name}`);
22
    await git_push(init);
23
    console.log("Finished");
24
  } finally {
25
    // Cleanup: remove safe.directory config
26
    if (repo_name) {
27
      try {
28
        await sh_run(undefined, "git", "config", "--global", "--unset", "safe.directory", `${cwd}/${repo_name}`);
29
      } catch (e) {
30
        console.log(`Warning: Could not unset safe.directory config: ${e}`);
31
      }
32
    }
33
    process.chdir(`${cwd}`);
34
  }
35
}
36

37
async function git_clone(repo_resource_path: string): Promise<string> {
38
  // TODO: handle private SSH keys as well
39

40
  const repo_resource = await wmill.getResource(repo_resource_path);
41

42
  let repo_url = repo_resource.url
43

44
  if (repo_resource.is_github_app) {
45
    const token = await get_gh_app_token()
46
    const authRepoUrl = prependTokenToGitHubUrl(repo_resource.url, token);
47
    repo_url = authRepoUrl;
48
  } else if (!urlCarriesCredential(repo_resource.url)) {
49
    // A repository whose credential Windmill holds gets it from the same
50
    // endpoint the app path uses; one that carries a token in its URL, or needs
51
    // none at all, is left exactly as it is.
52
    const token = await get_stored_git_token();
53
    if (token) {
54
      console.log("Using the git credential stored for this repository");
55
      repo_url = prependTokenToGitUrl(repo_resource.url, token);
56
    }
57
  }
58

59
  const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/);
60
  if (azureMatch) {
61
    console.log(
62
      "Requires Azure DevOps service account access token, requesting..."
63
    );
64
    const azureResource = await wmill.getResource(azureMatch.groups.url);
65
    const response = await fetch(
66
      `https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`,
67
      {
68
        method: "POST",
69
        body: new URLSearchParams({
70
          client_id: azureResource.azureClientId,
71
          client_secret: azureResource.azureClientSecret,
72
          grant_type: "client_credentials",
73
          resource: "499b84ac-1321-427f-aa17-267ca6975798/.default",
74
        }),
75
      }
76
    );
77
    const { access_token } = await response.json();
78
    repo_url = repo_url.replace(azureMatch[0], access_token);
79
  }
80
  const repo_name = basename(repo_url, ".git");
81
  await sh_run(4, "git", "clone", "--quiet", "--depth", "1", repo_url, repo_name);
82
  return repo_name;
83
}
84
async function git_push(init: boolean = false) {
85
  await sh_run(undefined, "git", "config", "user.email", process.env["WM_EMAIL"])
86
  await sh_run(undefined, "git", "config", "user.name", process.env["WM_USERNAME"])
87

88
  try {
89
    await sh_run(undefined, "git", "push");
90
  } catch (error) {
91
    if (init && error.toString().includes("src refspec") && error.toString().includes("does not match any")) {
92
      console.log("Repository is empty (no commits/branches yet). This is expected for a new repository.");
93
      console.log("Push test completed - repository access verified, but no content to push.");
94
      return;
95
    }
96
    // Re-throw other errors
97
    throw error;
98
  }
99
}
100

101
async function sh_run(
102
  secret_position: number | undefined,
103
  cmd: string,
104
  ...args: string[]
105
) {
106
  const nargs = secret_position != undefined ? args.slice() : args;
107
  if (secret_position && secret_position < 0) {
108
    secret_position = nargs.length - 1 + secret_position;
109
  }
110
  let secret: string | undefined = undefined;
111
  if (secret_position != undefined) {
112
    nargs[secret_position] = "***";
113
    secret = args[secret_position];
114
  }
115

116
  console.log(`Running '${cmd} ${nargs.join(" ")} ...'`);
117
  const command = exec(`${cmd} ${args.join(" ")}`)
118
  try {
119
    const { stdout, stderr } = await command
120
    if (stdout.length > 0) {
121
      console.log(stdout);
122
    }
123
    if (stderr.length > 0) {
124
      console.log(stderr);
125
    }
126
    console.log("Command successfully executed");
127

128
  } catch (error) {
129
    let errorString = error.toString();
130
    if (secret) {
131
      errorString = errorString.replace(secret, "***");
132
    }
133
    const err = `SH command '${cmd} ${nargs.join(
134
      " "
135
    )}' returned with error ${errorString}`;
136
    throw Error(err);
137
  }
138
}
139

140
// True when the remote already authenticates itself, i.e. it has a `user@` or
141
// `user:password@` userinfo component.
142
function urlCarriesCredential(url: string | undefined): boolean {
143
  return /:\/\/[^/@]+@/.test(url ?? "");
144
}
145

146
// The credential Windmill stores for this repository, or undefined when it holds
147
// none. Absence is normal (a public remote), so it must not fail the test; the
148
// reason is logged so a real outage is still diagnosable from the job log.
149
async function get_stored_git_token() {
150
  try {
151
    return await get_gh_app_token();
152
  } catch (error) {
153
    console.log(
154
      "No stored git credential for this repository:",
155
      error?.message ?? String(error)
156
    );
157
    return undefined;
158
  }
159
}
160

161
async function get_gh_app_token() {
162
  const workspace = process.env["WM_WORKSPACE"];
163
  const jobToken = process.env["WM_TOKEN"];
164

165
  const baseUrl =
166
    process.env["BASE_INTERNAL_URL"] ??
167
    process.env["BASE_URL"] ??
168
    "http://localhost:8000";
169

170
  const url = `${baseUrl}/api/w/${workspace}/github_app/token`;
171

172
  const response = await fetch(url, {
173
    method: 'POST',
174
    headers: {
175
      'Content-Type': 'application/json',
176
      'Authorization': `Bearer ${jobToken}`,
177
    },
178
    body: JSON.stringify({
179
      job_token: jobToken,
180
    }),
181
  });
182

183
  if (!response.ok) {
184
    const errorBody = await response.text().catch(() => "");
185
    throw new Error(`GitHub App token error (${response.status}): ${errorBody || response.statusText}`);
186
  }
187

188
  const data = await response.json();
189

190
  return data.token;
191
}
192

193
// Rewrite the git remote formats `new URL` can't parse into an https URL:
194
// scp-like ssh ([user@]host:owner/repo) and scheme-less (host/owner/repo).
195
// A host:8080/... port form is kept as authority rather than read as scp.
196
function normalizeGitHubUrl(raw: string): string {
197
  const cleaned = raw.trim();
198
  if (cleaned.includes("://")) {
199
    return cleaned;
200
  }
201
  const colonIdx = cleaned.indexOf(":");
202
  if (colonIdx !== -1) {
203
    const before = cleaned.slice(0, colonIdx);
204
    const after = cleaned.slice(colonIdx + 1);
205
    const firstSeg = after.split("/")[0];
206
    // `user@host:...` is always scp form, so digits there are an owner
207
    // (GitHub allows all-numeric ones), not a port.
208
    const isPort = !before.includes("@") && firstSeg !== "" && /^\d+$/.test(firstSeg);
209
    // A `@` right after the `:` means `user:token@host/...` — an authority
210
    // (userinfo) form, not scp.
211
    const isUserinfo = firstSeg.includes("@");
212
    if (before !== "" && !before.includes("/") && after !== "" && !isPort && !isUserinfo) {
213
      const host = before.split("@").pop();
214
      return `https://${host}/${after.replace(/^\/+/, "")}`;
215
    }
216
  }
217
  return `https://${cleaned}`;
218
}
219

220
function prependTokenToGitHubUrl(gitHubUrl: string, installationToken: string) {
221
  if (!gitHubUrl || !installationToken) {
222
    throw new Error("Both GitHub URL and Installation Token are required.");
223
  }
224

225
  // `host` (not `hostname`) so a custom port on a self-hosted git server survives.
226
  const url = new URL(normalizeGitHubUrl(gitHubUrl));
227
  return `https://x-access-token:${installationToken}@${url.host}${url.pathname}`;
228
}
229

230
// Authenticate a remote with a stored token. Unlike the app-token form above,
231
// this keeps the scheme the resource chose, so a self-managed git server reached
232
// over http is not dialled as https; `oauth2` is the username GitLab documents
233
// for token auth, and the setters percent-encode the token.
234
function prependTokenToGitUrl(gitUrl: string, token: string) {
235
  const url = new URL(normalizeGitHubUrl(gitUrl));
236
  url.username = "oauth2";
237
  url.password = token;
238
  return url.toString();
239
}
240

Other submissions
  • Submitted by hugo697 Deno
    Created 772 days ago
    1
    import * as wmill from "npm:[email protected]";
    2
    import { basename } from "https://deno.land/[email protected]/path/mod.ts";
    3
    
    
    4
    export async function main(repo_url_resource_path: string) {
    5
      const cwd = Deno.cwd();
    6
      Deno.env.set("HOME", ".");
    7
      console.log(`Cloning repo from resource`);
    8
    
    
    9
      const repo_name = await git_clone(repo_url_resource_path);
    10
    
    
    11
      Deno.chdir(`${cwd}/${repo_name}`);
    12
    
    
    13
      console.log(`Attempting an empty push to repository ${repo_name}`);
    14
      await git_push();
    15
    
    
    16
      console.log("Finished");
    17
      Deno.chdir(`${cwd}`);
    18
    }
    19
    
    
    20
    async function git_clone(repo_resource_path: string): Promise<string> {
    21
      // TODO: handle private SSH keys as well
    22
      let repo_url = (await wmill.getResource(repo_resource_path)).url;
    23
    
    
    24
      const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/);
    25
    
    
    26
      if (azureMatch) {
    27
        console.log(
    28
          "Requires Azure DevOps service account access token, requesting..."
    29
        );
    30
        const azureResource = await wmill.getResource(azureMatch.groups.url);
    31
    
    
    32
        const response = await fetch(
    33
          `https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`,
    34
          {
    35
            method: "POST",
    36
            body: new URLSearchParams({
    37
              client_id: azureResource.azureClientId,
    38
              client_secret: azureResource.azureClientSecret,
    39
              grant_type: "client_credentials",
    40
              resource: "499b84ac-1321-427f-aa17-267ca6975798/.default",
    41
            }),
    42
          }
    43
        );
    44
    
    
    45
        const { access_token } = await response.json();
    46
    
    
    47
        repo_url = repo_url.replace(azureMatch[0], access_token);
    48
      }
    49
    
    
    50
      const repo_name = basename(repo_url, ".git");
    51
    
    
    52
      await sh_run("git", "clone", "--quiet", "--depth", "1", repo_url, repo_name);
    53
      return repo_name;
    54
    }
    55
    
    
    56
    async function git_push() {
    57
      await sh_run("git", "config", "user.email", Deno.env.get("WM_EMAIL"));
    58
      await sh_run("git", "config", "user.name", Deno.env.get("WM_USERNAME"));
    59
      await sh_run("git", "push");
    60
    }
    61
    
    
    62
    async function sh_run(cmd: string, ...args: string[]) {
    63
      // console.log(`Running '${cmd} ${args.join(" ")}'`)
    64
      const command = new Deno.Command(cmd, {
    65
        args: args,
    66
      });
    67
      const { code, stdout: _stdout, stderr: _stderr } = await command.output();
    68
    
    
    69
      if (code !== 0) {
    70
        throw `SH command '${cmd} ${args.join(
    71
          " "
    72
        )}' returned with a non-zero status ${code}`;
    73
      }
    74
    }
    75