import * as fs from "node:fs/promises"; import { $ } from "execa"; export const PREFIX = "sandbox-agent"; export function assert(condition: any, message?: string): asserts condition { if (!condition) { throw new Error(message || "Assertion failed"); } } /** * Converts a version string or commit hash to a git ref. * If the input contains a dot, it's treated as a version (e.g., "0.1.0" -> "v0.1.0"). * Otherwise, it's treated as a git revision and returned as-is (e.g., "bb7f292"). */ export function versionOrCommitToRef(versionOrCommit: string): string { if (versionOrCommit.includes(".")) { assert( !versionOrCommit.startsWith("v"), `Version should not start with "v" (got "${versionOrCommit}", use "${versionOrCommit.slice(1)}" instead)`, ); return `v${versionOrCommit}`; } return versionOrCommit; } /** * Fetches a git ref from the remote. For tags, fetches all tags. For commits, unshallows the repo. */ export async function fetchGitRef(ref: string): Promise { if (ref.startsWith("v")) { console.log(`Fetching tags...`); await $({ stdio: "inherit" })`git fetch --tags --force`; } else { // Git doesn't allow fetching commits directly by SHA, and CI often uses // shallow clones. Unshallow the repo to ensure the commit is available. console.log(`Unshallowing repo to find commit ${ref}...`); try { await $({ stdio: "inherit" })`git fetch --unshallow origin`; } catch { // Already unshallowed, just fetch await $({ stdio: "inherit" })`git fetch origin`; } } } interface ReleasesS3Config { awsEnv: Record; endpointUrl: string; } let cachedConfig: ReleasesS3Config | null = null; async function getReleasesS3Config(): Promise { if (cachedConfig) { return cachedConfig; } let awsAccessKeyId = process.env.R2_RELEASES_ACCESS_KEY_ID; if (!awsAccessKeyId) { const result = await $`op read ${"op://Engineering/rivet-releases R2 Upload/username"}`; awsAccessKeyId = result.stdout.trim(); } let awsSecretAccessKey = process.env.R2_RELEASES_SECRET_ACCESS_KEY; if (!awsSecretAccessKey) { const result = await $`op read ${"op://Engineering/rivet-releases R2 Upload/password"}`; awsSecretAccessKey = result.stdout.trim(); } assert(awsAccessKeyId, "AWS_ACCESS_KEY_ID is required"); assert(awsSecretAccessKey, "AWS_SECRET_ACCESS_KEY is required"); cachedConfig = { awsEnv: { AWS_ACCESS_KEY_ID: awsAccessKeyId, AWS_SECRET_ACCESS_KEY: awsSecretAccessKey, AWS_DEFAULT_REGION: "auto", }, endpointUrl: "https://2a94c6a0ced8d35ea63cddc86c2681e7.r2.cloudflarestorage.com", }; return cachedConfig; } export async function uploadDirToReleases( localPath: string, remotePath: string, ): Promise { const { awsEnv, endpointUrl } = await getReleasesS3Config(); // Use --checksum-algorithm CRC32 for R2 compatibility (matches CI upload in release.yaml) await $({ env: awsEnv, shell: true, stdio: "inherit", })`aws s3 cp ${localPath} s3://rivet-releases/${remotePath} --recursive --checksum-algorithm CRC32 --endpoint-url ${endpointUrl}`; } export async function uploadContentToReleases( content: string, remotePath: string, ): Promise { const { awsEnv, endpointUrl } = await getReleasesS3Config(); await $({ env: awsEnv, input: content, shell: true, stdio: ["pipe", "inherit", "inherit"], })`aws s3 cp - s3://rivet-releases/${remotePath} --endpoint-url ${endpointUrl}`; } export interface ListReleasesResult { Contents?: { Key: string; Size: number }[]; } export async function listReleasesObjects( prefix: string, ): Promise { const { awsEnv, endpointUrl } = await getReleasesS3Config(); const result = await $({ env: awsEnv, shell: true, stdio: ["pipe", "pipe", "inherit"], })`aws s3api list-objects --bucket rivet-releases --prefix ${prefix} --endpoint-url ${endpointUrl}`; return JSON.parse(result.stdout); } export async function deleteReleasesPath(remotePath: string): Promise { const { awsEnv, endpointUrl } = await getReleasesS3Config(); await $({ env: awsEnv, shell: true, stdio: "inherit", })`aws s3 rm s3://rivet-releases/${remotePath} --recursive --endpoint-url ${endpointUrl}`; } /** * Copies objects from one S3 path to another within the releases bucket. * * NOTE: We implement our own recursive copy instead of using `aws s3 cp --recursive` * because of a Cloudflare R2 bug. R2 doesn't support the `x-amz-tagging-directive` * header, which the AWS CLI sends even with `--copy-props none` for small files. * Using `s3api copy-object` directly avoids this header. * * See: https://community.cloudflare.com/t/r2-s3-compat-doesnt-support-net-sdk-for-copy-operations-due-to-tagging-header/616867 */ export async function copyReleasesPath( sourcePath: string, targetPath: string, ): Promise { const { awsEnv, endpointUrl } = await getReleasesS3Config(); const listResult = await $({ env: awsEnv, })`aws s3api list-objects --bucket rivet-releases --prefix ${sourcePath} --endpoint-url ${endpointUrl}`; const objects = JSON.parse(listResult.stdout); if (!objects.Contents?.length) { throw new Error(`No objects found under ${sourcePath}`); } for (const obj of objects.Contents) { const sourceKey = obj.Key; const targetKey = sourceKey.replace(sourcePath, targetPath); console.log(` ${sourceKey} -> ${targetKey}`); await $({ env: awsEnv, })`aws s3api copy-object --bucket rivet-releases --key ${targetKey} --copy-source rivet-releases/${sourceKey} --endpoint-url ${endpointUrl}`; } } export function assertEquals(actual: T, expected: T, message?: string): void { if (actual !== expected) { throw new Error(message || `Expected ${expected}, got ${actual}`); } } export function assertExists( value: T | null | undefined, message?: string, ): asserts value is T { if (value === null || value === undefined) { throw new Error(message || "Value does not exist"); } } export async function assertDirExists(dirPath: string): Promise { try { const stat = await fs.stat(dirPath); if (!stat.isDirectory()) { throw new Error(`Path exists but is not a directory: ${dirPath}`); } } catch (err: any) { if (err.code === "ENOENT") { throw new Error(`Directory not found: ${dirPath}`); } throw err; } } export async function downloadFromReleases( remotePath: string, localPath: string, ): Promise { const { awsEnv, endpointUrl } = await getReleasesS3Config(); await $({ env: awsEnv, shell: true, stdio: "inherit", })`aws s3 cp s3://rivet-releases/${remotePath} ${localPath} --endpoint-url ${endpointUrl}`; }