1 | import * as wmillclient from "windmill-client"; |
2 | import { basename, join, sep } from "node:path"; |
3 | import { existsSync, rmSync } from "fs"; |
4 | import process from "process"; |
5 | import { spawn } from 'child_process'; |
6 | import * as fs_async from 'fs/promises'; |
7 | import * as fs from 'node:fs'; |
8 | import { Readable } from 'node:stream'; |
9 | import { pipeline } from 'node:stream/promises'; |
10 |
|
11 | const UPLOAD_CONCURRENCY = 16; |
12 | const CLONE_MARKER_FILE = ".windmill_clone_complete"; |
13 | const MAX_SYMLINKED_ENTRIES = 20_000; |
14 | const MAX_SYMLINKED_BYTES = 512 * 1024 * 1024; |
15 |
|
16 | type GitRepository = { |
17 | url: string; |
18 | branch: string; |
19 | folder: string; |
20 | gpg_key: any; |
21 | is_github_app: boolean; |
22 | }; |
23 |
|
24 | export async function main( |
25 | resource_path: string, |
26 | workspace: string, |
27 | git_ssh_identity?: string[], |
28 | commit?: string |
29 | ) { |
30 | let clonedRepoPath: string | undefined; |
31 |
|
32 | try { |
33 | console.log("Starting git clone and Blob storage upload process"); |
34 |
|
35 | |
36 | const repo_resource: GitRepository = await wmillclient.getResource(resource_path); |
37 |
|
38 | const cwd = process.cwd(); |
39 |
|
40 | if (git_ssh_identity) { |
41 | process.env.GIT_SSH_COMMAND = await get_git_ssh_cmd(cwd, git_ssh_identity) |
42 | } |
43 |
|
44 | |
45 | |
46 | process.env["HOME"] = cwd; |
47 | process.env.GIT_TERMINAL_PROMPT = "0"; |
48 |
|
49 | |
50 | |
51 | |
52 | const { repo_name, commitHash } = repo_resource.is_github_app |
53 | ? await download_repo_archive(cwd, workspace, resource_path, repo_resource, commit) |
54 | : await git_clone(cwd, repo_resource, commit); |
55 | clonedRepoPath = join(cwd, repo_name); |
56 |
|
57 | |
58 | const gitDir = join(clonedRepoPath, ".git"); |
59 | if (existsSync(gitDir)) { |
60 | rmSync(gitDir, { recursive: true, force: true }); |
61 | console.log("Removed .git directory"); |
62 | } |
63 |
|
64 | |
65 | const s3Path = `gitrepos/${workspace}/${resource_path}/${commitHash}`; |
66 | const fileCount = await uploadDirectoryToS3(clonedRepoPath, s3Path, workspace); |
67 |
|
68 | return { |
69 | success: true, |
70 | message: "Repository cloned and uploaded to S3 successfully", |
71 | s3_path: s3Path, |
72 | commit_hash: commitHash, |
73 | file_count: fileCount, |
74 | }; |
75 |
|
76 | } catch (error) { |
77 | console.error("Error in git clone and upload:", error); |
78 | throw error; |
79 | } finally { |
80 | |
81 | if (clonedRepoPath && existsSync(clonedRepoPath)) { |
82 | rmSync(clonedRepoPath, { recursive: true, force: true }); |
83 | console.log("Cleaned up cloned repository"); |
84 | } |
85 | } |
86 | } |
87 |
|
88 | async function get_git_ssh_cmd(cwd: string, git_ssh_identity: string[]): Promise<string> { |
89 | const sshIdFiles = await Promise.all( |
90 | git_ssh_identity.map(async (varPath, i) => { |
91 | const filePath = join(cwd, `./ssh_id_priv_${i}`); |
92 |
|
93 | try { |
94 | |
95 | let content = await wmillclient.getVariable(varPath); |
96 | content += '\n'; |
97 |
|
98 | |
99 | await fs_async.writeFile(filePath, content, { encoding: 'utf8' }); |
100 |
|
101 | |
102 | await fs_async.chmod(filePath, 0o600); |
103 |
|
104 | |
105 | const escapedPath = filePath.replace(/'/g, "'\\''"); |
106 | return ` -i '${escapedPath}'`; |
107 | } catch (error) { |
108 | console.error( |
109 | `Variable ${varPath} not found for git ssh identity: ${error}` |
110 | ); |
111 | return ''; |
112 | } |
113 | }) |
114 | ); |
115 |
|
116 | const gitSshCmd = `ssh -o StrictHostKeyChecking=no${sshIdFiles.join('')}`; |
117 | return gitSshCmd; |
118 | } |
119 |
|
120 | async function download_repo_archive( |
121 | cwd: string, |
122 | workspace: string, |
123 | resource_path: string, |
124 | repo_resource: GitRepository, |
125 | commit?: string, |
126 | ): Promise<{ repo_name: string; commitHash: string }> { |
127 | const repo_name = basename(repo_resource.url, ".git"); |
128 | const baseUrl = |
129 | process.env["BASE_INTERNAL_URL"] ?? |
130 | process.env["BASE_URL"] ?? |
131 | "http://localhost:8000"; |
132 | const ref = commit ?? repo_resource.branch ?? ""; |
133 | const query = ref !== "" ? `?ref=${encodeURIComponent(ref)}` : ""; |
134 | const url = `${baseUrl}/api/w/${workspace}/github_app/repo_archive/${resource_path}${query}`; |
135 |
|
136 | console.log(`Downloading archive of ${resource_path}${ref !== "" ? ` at ${ref}` : ""}`); |
137 | const response = await fetch(url, { |
138 | headers: { Authorization: `Bearer ${process.env["WM_TOKEN"]}` }, |
139 | }); |
140 | if (!response.ok || response.body == null) { |
141 | const errorBody = await response.text().catch(() => ""); |
142 | throw new Error( |
143 | `Repository archive error (${response.status}): ${errorBody || response.statusText}` |
144 | ); |
145 | } |
146 |
|
147 | const commitHash = response.headers.get("x-commit-sha"); |
148 | if (!commitHash) { |
149 | throw new Error("Repository archive carried no commit sha"); |
150 | } |
151 |
|
152 | const archivePath = join(cwd, `${repo_name}.tar.gz`); |
153 | await pipeline( |
154 | Readable.fromWeb(response.body as any), |
155 | fs.createWriteStream(archivePath) |
156 | ); |
157 |
|
158 | const repoPath = join(cwd, repo_name); |
159 | await fs_async.mkdir(repoPath, { recursive: true }); |
160 | |
161 | await runCommand(undefined, "tar", "-xzf", archivePath, "-C", repoPath, "--strip-components=1"); |
162 | rmSync(archivePath, { force: true }); |
163 |
|
164 | const subfolder = repo_resource.folder ?? ""; |
165 | if (subfolder !== "") { |
166 | if (!existsSync(join(repoPath, subfolder))) { |
167 | throw new Error(`Subfolder ${subfolder} does not exist.`); |
168 | } |
169 | |
170 | |
171 | const keep = subfolder.split("/")[0]; |
172 | for (const entry of fs.readdirSync(repoPath, { withFileTypes: true })) { |
173 | if (entry.isDirectory() && entry.name !== keep) { |
174 | rmSync(join(repoPath, entry.name), { recursive: true, force: true }); |
175 | } |
176 | } |
177 | } |
178 |
|
179 | return { repo_name, commitHash }; |
180 | } |
181 |
|
182 | async function git_clone( |
183 | cwd: string, |
184 | repo_resource: GitRepository, |
185 | commit?: string, |
186 | ): Promise<{ repo_name: string; commitHash: string }> { |
187 | if (commit) { |
188 | return git_clone_at_commit(cwd, repo_resource, commit); |
189 | } else { |
190 | return git_clone_at_latest(cwd, repo_resource); |
191 | } |
192 | } |
193 |
|
194 | async function git_clone_at_commit( |
195 | cwd: string, |
196 | repo_resource: GitRepository, |
197 | commit: string, |
198 | ): Promise<{ repo_name: string; commitHash: string }> { |
199 | let repo_url = repo_resource.url; |
200 | const subfolder = repo_resource.folder ?? ""; |
201 | let branch = repo_resource.branch ?? ""; |
202 | const repo_name = basename(repo_url, ".git"); |
203 |
|
204 | const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/); |
205 | if (azureMatch) { |
206 | console.log("Fetching Azure DevOps access token..."); |
207 | const azureResource = await wmillclient.getResource(azureMatch.groups.url); |
208 | const response = await fetch( |
209 | `https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`, |
210 | { |
211 | method: "POST", |
212 | body: new URLSearchParams({ |
213 | client_id: azureResource.azureClientId, |
214 | client_secret: azureResource.azureClientSecret, |
215 | grant_type: "client_credentials", |
216 | resource: "499b84ac-1321-427f-aa17-267ca6975798/.default", |
217 | }), |
218 | } |
219 | ); |
220 | const { access_token } = await response.json(); |
221 | repo_url = repo_url.replace(azureMatch[0], access_token); |
222 | } |
223 |
|
224 | const repoPath = join(cwd, repo_name); |
225 | await fs_async.mkdir(repoPath, { recursive: true }); |
226 |
|
227 | process.chdir(repoPath); |
228 |
|
229 | let args = ['init', '--quiet'] |
230 | if (branch) { |
231 | args.push(`--initial-branch=${branch}`) |
232 | } |
233 | await runCommand(undefined, 'git', ...args); |
234 |
|
235 | await runCommand(0, 'git', 'remote', 'add', 'origin', repo_url); |
236 |
|
237 | await runCommand(undefined, 'git', 'fetch', '--depth=1', '--quiet', 'origin', commit); |
238 |
|
239 | await runCommand(undefined, 'git', 'checkout', '--quiet', 'FETCH_HEAD'); |
240 |
|
241 | const commitHash = (await runCommand(undefined, "git", "rev-parse", "HEAD")).trim(); |
242 |
|
243 | |
244 | process.chdir(cwd); |
245 |
|
246 | return { repo_name, commitHash }; |
247 | } |
248 |
|
249 | async function git_clone_at_latest( |
250 | cwd: string, |
251 | repo_resource: GitRepository |
252 | ): Promise<{ repo_name: string; commitHash: string }> { |
253 | let repo_url = repo_resource.url; |
254 | const subfolder = repo_resource.folder ?? ""; |
255 | let branch = repo_resource.branch ?? ""; |
256 | const repo_name = basename(repo_url, ".git"); |
257 |
|
258 | |
259 | const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/); |
260 | if (azureMatch) { |
261 | console.log("Fetching Azure DevOps access token..."); |
262 | const azureResource = await wmillclient.getResource(azureMatch.groups.url); |
263 | const response = await fetch( |
264 | `https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`, |
265 | { |
266 | method: "POST", |
267 | body: new URLSearchParams({ |
268 | client_id: azureResource.azureClientId, |
269 | client_secret: azureResource.azureClientSecret, |
270 | grant_type: "client_credentials", |
271 | resource: "499b84ac-1321-427f-aa17-267ca6975798/.default", |
272 | }), |
273 | } |
274 | ); |
275 | const { access_token } = await response.json(); |
276 | repo_url = repo_url.replace(azureMatch[0], access_token); |
277 | } |
278 |
|
279 | const args = ["clone", "--quiet", "--depth", "1"]; |
280 | if (subfolder !== "") args.push("--sparse"); |
281 | if (branch !== "") args.push("--branch", branch); |
282 | args.push(repo_url, repo_name); |
283 |
|
284 | await runCommand(-1, "git", ...args); |
285 |
|
286 | const fullPath = join(cwd, repo_name); |
287 | process.chdir(fullPath); |
288 |
|
289 | if (subfolder !== "") { |
290 | await runCommand(undefined, "git", "sparse-checkout", "add", subfolder); |
291 | const subfolderPath = join(fullPath, subfolder); |
292 |
|
293 | if (!existsSync(subfolderPath)) { |
294 | throw new Error(`Subfolder ${subfolder} does not exist.`); |
295 | } |
296 |
|
297 | process.chdir(subfolderPath); |
298 | } |
299 |
|
300 | |
301 | const commitHash = (await runCommand(undefined, "git", "rev-parse", "HEAD")).trim(); |
302 |
|
303 | |
304 | process.chdir(cwd); |
305 |
|
306 | return { repo_name, commitHash }; |
307 | } |
308 |
|
309 | async function uploadDirectoryToS3( |
310 | directoryPath: string, |
311 | s3BasePath: string, |
312 | workspace: string, |
313 | ): Promise<number> { |
314 | console.log(`Uploading ${directoryPath} -> ${s3BasePath}`); |
315 |
|
316 | |
317 | const tasks: { localPath: string; s3Key: string }[] = []; |
318 | const root = fs.realpathSync(directoryPath); |
319 | |
320 | |
321 | const ancestors = new Set<string>(); |
322 | |
323 | |
324 | |
325 | |
326 | let symlinkedEntries = 0; |
327 | let symlinkedBytes = 0; |
328 | let symlinkBudgetSpent = false; |
329 | function chargeSymlinkBudget(relPath: string, entries: number, bytes: number): boolean { |
330 | if (symlinkBudgetSpent) return false; |
331 | symlinkedEntries += entries; |
332 | symlinkedBytes += bytes; |
333 | if (symlinkedEntries <= MAX_SYMLINKED_ENTRIES && symlinkedBytes <= MAX_SYMLINKED_BYTES) { |
334 | return true; |
335 | } |
336 | symlinkBudgetSpent = true; |
337 | console.log( |
338 | `Skipping ${relPath} and every symlinked entry after it: symlinks reach more than ` + |
339 | `${MAX_SYMLINKED_ENTRIES} entries or ${MAX_SYMLINKED_BYTES / 2 ** 20} MiB` |
340 | ); |
341 | return false; |
342 | } |
343 | function walk(dir: string, relDir: string, viaLink: boolean) { |
344 | ancestors.add(dir); |
345 | for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { |
346 | const relPath = relDir ? `${relDir}/${entry.name}` : entry.name; |
347 | const linked = viaLink || entry.isSymbolicLink(); |
348 | if (linked && !chargeSymlinkBudget(relPath, 1, 0)) continue; |
349 | let localPath = join(dir, entry.name); |
350 | if (entry.isSymbolicLink()) { |
351 | |
352 | |
353 | |
354 | const link = fs.readlinkSync(localPath); |
355 | try { |
356 | localPath = fs.realpathSync(localPath); |
357 | } catch (e: any) { |
358 | console.log(`Skipping symlink ${relPath} -> ${link}: cannot resolve target (${e.code})`); |
359 | continue; |
360 | } |
361 | if (localPath !== root && !localPath.startsWith(root + sep)) { |
362 | console.log(`Skipping symlink ${relPath} -> ${link}: target is outside the repository`); |
363 | continue; |
364 | } |
365 | } |
366 | const stat = fs.statSync(localPath); |
367 | if (stat.isDirectory() && ancestors.has(localPath)) { |
368 | console.log(`Skipping ${relPath}: links back to a directory it is inside`); |
369 | continue; |
370 | } |
371 | if (linked && stat.isFile() && !chargeSymlinkBudget(relPath, 0, stat.size)) continue; |
372 | if (stat.isDirectory()) { |
373 | walk(localPath, relPath, linked); |
374 | } else if (stat.isFile()) { |
375 | tasks.push({ localPath, s3Key: `${s3BasePath}/${relPath}` }); |
376 | } |
377 | } |
378 | ancestors.delete(dir); |
379 | } |
380 | walk(root, "", false); |
381 | console.log(`Discovered ${tasks.length} files to upload`); |
382 |
|
383 | let nextIndex = 0; |
384 | let uploaded = 0; |
385 | let lastReport = 0; |
386 | async function worker() { |
387 | while (true) { |
388 | const idx = nextIndex++; |
389 | if (idx >= tasks.length) return; |
390 | const { localPath, s3Key } = tasks[idx]; |
391 | const fileContent = fs.readFileSync(localPath); |
392 | const blob = new Blob([fileContent], { type: 'application/octet-stream' }); |
393 | await wmillclient.HelpersService.gitRepoViewerFileUpload({ |
394 | workspace, |
395 | fileKey: s3Key, |
396 | requestBody: blob, |
397 | }); |
398 | uploaded++; |
399 | if (uploaded - lastReport >= 25 || uploaded === tasks.length) { |
400 | lastReport = uploaded; |
401 | console.log(`Uploaded ${uploaded} / ${tasks.length} files`); |
402 | } |
403 | } |
404 | } |
405 | await Promise.all( |
406 | Array.from({ length: Math.min(UPLOAD_CONCURRENCY, tasks.length) }, () => worker()) |
407 | ); |
408 |
|
409 | |
410 | const markerKey = `${s3BasePath}/${CLONE_MARKER_FILE}`; |
411 | const markerBody = JSON.stringify({ |
412 | completed_at: new Date().toISOString(), |
413 | file_count: tasks.length, |
414 | }); |
415 | await wmillclient.HelpersService.gitRepoViewerFileUpload({ |
416 | workspace, |
417 | fileKey: markerKey, |
418 | requestBody: new Blob([markerBody], { type: 'application/json' }), |
419 | }); |
420 | console.log(`Wrote completion marker: ${markerKey}`); |
421 |
|
422 | return tasks.length; |
423 | } |
424 |
|
425 | function runCommand(secret_position: number | undefined, cmd: string, ...args: string[]): Promise<string> { |
426 | const nargs = secret_position != undefined ? args.slice() : args; |
427 | if (secret_position && secret_position < 0) |
428 | secret_position = nargs.length - 1 + secret_position; |
429 |
|
430 | let secret: string | undefined = undefined; |
431 | if (secret_position != undefined) { |
432 | nargs[secret_position] = "***"; |
433 | secret = args[secret_position]; |
434 | } |
435 | console.log(`Running shell command: '${cmd} ${nargs.join(" ")} ...'`); |
436 |
|
437 | return new Promise((resolve, reject) => { |
438 | const process = spawn(cmd, args); |
439 |
|
440 | let stdout = ''; |
441 | let stderr = ''; |
442 |
|
443 | process.stdout.on('data', (data) => { |
444 | stdout += data.toString(); |
445 | }); |
446 |
|
447 | process.stderr.on('data', (data) => { |
448 | stderr += data.toString(); |
449 | }); |
450 |
|
451 | process.on('error', (error) => { |
452 | let errorString = error.toString(); |
453 | if (secret) errorString = errorString.replace(secret, "***"); |
454 | console.log(`Shell command FAILED: ${cmd}`, errorString); |
455 | const e = new Error( |
456 | `SH command '${cmd} ${nargs.join(" ")}' failed: ${errorString}` |
457 | ); |
458 | reject(e); |
459 | }); |
460 |
|
461 | process.on('close', (code) => { |
462 | if (stdout.length > 0) { |
463 | console.log("Shell stdout:", stdout); |
464 | } |
465 | if (stderr.length > 0) { |
466 | console.log("Shell stderr:", stderr); |
467 | } |
468 | if (code === 0) { |
469 | console.log(`Shell command completed successfully: ${cmd}`); |
470 | resolve(stdout); |
471 | } else { |
472 | reject(new Error(`Command failed with code ${code}: ${stderr}`)); |
473 | } |
474 | }); |
475 | }); |
476 | } |
477 |
|