| 1 | import { randomUUID } from "node:crypto"; |
| 2 | import { mkdir, rm, stat, writeFile } from "node:fs/promises"; |
| 3 | import path from "node:path"; |
| 4 | import { errMsg, failure, StepFailure } from "../job.js"; |
| 5 | import { globPaths, inputList, truthyInput } from "../util.js"; |
| 6 | import { downloadTo } from "./cache.js"; |
| 7 | import type { Builtin } from "./index.js"; |
| 8 | |
| 9 | function lca(dirs: string[]): string { |
| 10 | if (dirs.length === 0) return "/"; |
| 11 | let parts = dirs[0]!.split("/"); |
| 12 | for (const d of dirs.slice(1)) { |
| 13 | const p = d.split("/"); |
| 14 | let i = 0; |
| 15 | while (i < parts.length && i < p.length && parts[i] === p[i]) i++; |
| 16 | parts = parts.slice(0, i); |
| 17 | } |
| 18 | return parts.join("/") || "/"; |
| 19 | } |
| 20 | |
| 21 | export const artifactUpload: Builtin = async (c) => { |
| 22 | const name = c.input("name", "artifact"); |
| 23 | if (/[":<>|*?\\/\r\n]/.test(name)) throw failure(`The artifact name is not valid: ${name}. Contains invalid characters.`); |
| 24 | const patterns = inputList(c.input("path")); |
| 25 | if (!patterns.length) throw failure("Input required and not supplied: path"); |
| 26 | const ifNone = c.input("if-no-files-found", "warn").toLowerCase(); |
| 27 | const includeHidden = truthyInput(c.input("include-hidden-files"), false); |
| 28 | let files = globPaths(c.job.workspace, patterns, { filesOnly: true }); |
| 29 | if (!includeHidden) files = files.filter((f) => !path.relative(c.job.workspace, f).split("/").some((s) => s.startsWith(".") && s !== "..")); |
| 30 | if (files.length === 0) { |
| 31 | const msg = `No files were found with the provided path: ${patterns.join(", ")}. No artifacts will be uploaded.`; |
| 32 | if (ifNone === "error") throw failure(msg); |
| 33 | if (ifNone === "warn") c.warn(msg); |
| 34 | else c.log(msg); |
| 35 | return; |
| 36 | } |
| 37 | if (!c.job.api.online) { |
| 38 | c.warn("Artifact service is not available in offline mode; skipping upload."); |
| 39 | return; |
| 40 | } |
| 41 | const root = lca(files.map((f) => path.dirname(f))); |
| 42 | const rel = files.map((f) => path.relative(root, f)); |
| 43 | c.log(`With the provided path, there will be ${files.length} file(s) uploaded (root: ${root})`); |
| 44 | const list = path.join(c.job.tempDir, `artifact-${randomUUID()}.list`); |
| 45 | const archive = path.join(c.job.tempDir, `artifact-${randomUUID()}.tgz`); |
| 46 | try { |
| 47 | await writeFile(list, rel.join("\0") + "\0"); |
| 48 | await c.run("tar", ["-czf", archive, "-C", root, "--null", "-T", list], { silent: true }); |
| 49 | const size = (await stat(archive)).size; |
| 50 | const q = new URLSearchParams(); |
| 51 | const retention = c.input("retention-days"); |
| 52 | if (retention) q.set("retention-days", retention); |
| 53 | if (truthyInput(c.input("overwrite"), false)) q.set("overwrite", "true"); |
| 54 | const qs = q.toString(); |
| 55 | const res = await c.job.api.putFile(`/artifacts/${encodeURIComponent(name)}${qs ? `?${qs}` : ""}`, archive); |
| 56 | if (res.status === 409) throw failure(`An artifact with the name '${name}' already exists. Set 'overwrite: true' to replace it.`); |
| 57 | let body: { id?: string | number; url?: string } = {}; |
| 58 | try { |
| 59 | body = (await res.json()) as typeof body; |
| 60 | } catch { |
| 61 | /* no body */ |
| 62 | } |
| 63 | c.log(`Artifact '${name}' uploaded (${size} bytes compressed).`); |
| 64 | if (body.id !== undefined) c.setOutput("artifact-id", String(body.id)); |
| 65 | if (body.url) c.setOutput("artifact-url", body.url); |
| 66 | } catch (e) { |
| 67 | if (e instanceof StepFailure) throw e; |
| 68 | throw failure(`Failed to upload artifact '${name}': ${errMsg(e)}`); |
| 69 | } finally { |
| 70 | await rm(list, { force: true }); |
| 71 | await rm(archive, { force: true }); |
| 72 | } |
| 73 | }; |
| 74 | |
| 75 | export const artifactDownload: Builtin = async (c) => { |
| 76 | const name = c.input("name"); |
| 77 | if (!name) { |
| 78 | throw failure("download-artifact without `name` (download all / `pattern`) is not supported on Diffinery runners yet; specify `name`."); |
| 79 | } |
| 80 | if (!c.job.api.online) throw failure("Artifact service is not available in offline mode."); |
| 81 | const dest = path.resolve(c.job.workspace, c.input("path", ".")); |
| 82 | await mkdir(dest, { recursive: true }); |
| 83 | const archive = path.join(c.job.tempDir, `artifact-${randomUUID()}.tgz`); |
| 84 | try { |
| 85 | try { |
| 86 | await downloadTo(c, c.job.api.url(`/artifacts/${encodeURIComponent(name)}`), archive); |
| 87 | } catch (e) { |
| 88 | throw failure(`Unable to download artifact '${name}': ${errMsg(e)}`); |
| 89 | } |
| 90 | await c.run("tar", ["-xzf", archive, "-C", dest], { silent: true }); |
| 91 | c.log(`Artifact '${name}' downloaded to ${dest}`); |
| 92 | c.setOutput("download-path", dest); |
| 93 | } finally { |
| 94 | await rm(archive, { force: true }); |
| 95 | } |
| 96 | }; |