Files
openwork/scripts/prune-daytona-dev-snapshots.mjs

367 lines
10 KiB
JavaScript

import process from "node:process"
import { pathToFileURL } from "node:url"
const DEFAULT_API_URL = "https://app.daytona.io/api"
const PAGE_LIMIT = 200
const SETTLED_SNAPSHOT_STATES = new Set([
"active",
"inactive",
"error",
"build_failed",
])
const INACTIVE_SANDBOX_STATES = new Set([
"stopped",
"archived",
"destroyed",
"error",
])
function requiredValue(value, message) {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(message)
}
return value.trim()
}
function validateSnapshotName(snapshotName) {
const value = requiredValue(
snapshotName,
"Missing snapshot name. Pass --keep <name>.",
)
if (value !== snapshotName || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) {
throw new Error(
`Invalid snapshot name ${JSON.stringify(snapshotName)}. Use only letters, numbers, dots, underscores, and hyphens, with no whitespace.`,
)
}
return value
}
function validateKeepCount(keepCount) {
if (!Number.isSafeInteger(keepCount) || keepCount < 0) {
throw new Error("--keep-count must be an integer greater than or equal to 0.")
}
return keepCount
}
function authorizationHeaders(apiKey) {
const key = requiredValue(
apiKey,
"Missing Daytona API key. Set DAYTONA_API_KEY before running this command.",
)
return {
Accept: "application/json",
Authorization: `Bearer ${key}`,
}
}
async function responseBody(response) {
const body = await response.text()
if (!response.ok) {
const statusText = response.statusText ? ` ${response.statusText}` : ""
throw new Error(
`Daytona API request failed with HTTP ${response.status}${statusText}: ${body}`,
)
}
return body
}
async function responseJson(response, description) {
const body = await responseBody(response)
try {
return JSON.parse(body)
} catch {
throw new Error(`Daytona API returned invalid JSON for ${description}: ${body}`)
}
}
function normalizedState(value) {
return typeof value === "string" ? value.toLowerCase() : ""
}
function compareNewest(left, right) {
const created = right.createdAt.localeCompare(left.createdAt)
return created || left.name.localeCompare(right.name)
}
function compareOldest(left, right) {
const created = left.createdAt.localeCompare(right.createdAt)
return created || left.name.localeCompare(right.name)
}
export function selectDevSnapshotPrunes({
snapshots,
sandboxes,
nameBase,
keepNames,
keepCount,
}) {
const prefix = `${nameBase}-dev-`
const scoped = snapshots.filter((snapshot) => snapshot.name.startsWith(prefix))
const recentNames = new Set(
[...scoped].sort(compareNewest).slice(0, keepCount).map((snapshot) => snapshot.name),
)
const explicitNames = new Set(keepNames)
const activeReferences = new Set(
sandboxes
.filter((sandbox) => !INACTIVE_SANDBOX_STATES.has(normalizedState(sandbox.state)))
.map((sandbox) => sandbox.snapshot)
.filter((snapshotName) => typeof snapshotName === "string"),
)
const prune = []
const keep = []
for (const snapshot of [...scoped].sort(compareOldest)) {
let reason
if (explicitNames.has(snapshot.name)) {
reason = "explicit"
} else if (recentNames.has(snapshot.name)) {
reason = "recent"
} else if (!SETTLED_SNAPSHOT_STATES.has(normalizedState(snapshot.state))) {
reason = "in-flight"
} else if (activeReferences.has(snapshot.name)) {
reason = "active-ref"
}
if (reason) {
keep.push({ ...snapshot, reason })
} else {
prune.push(snapshot)
}
}
return { prune, keep }
}
async function readSnapshots({ apiUrl, apiKey, fetchImpl }) {
const snapshots = []
for (let page = 1; ; page += 1) {
const response = await fetchImpl(
`${apiUrl}/snapshots?limit=${PAGE_LIMIT}&page=${page}`,
{ method: "GET", headers: authorizationHeaders(apiKey) },
)
const parsed = await responseJson(response, "snapshots")
if (
!parsed ||
typeof parsed !== "object" ||
Array.isArray(parsed) ||
!Array.isArray(parsed.items) ||
typeof parsed.total !== "number"
) {
throw new Error("Daytona API snapshots response did not contain items and total.")
}
snapshots.push(...parsed.items)
if (snapshots.length >= parsed.total || parsed.items.length < PAGE_LIMIT) {
return snapshots
}
}
}
async function readSandboxes({ apiUrl, apiKey, fetchImpl }) {
const sandboxes = []
let nextCursor = ""
for (let page = 1; page <= 100; page += 1) {
const pagination = nextCursor
? `&cursor=${encodeURIComponent(nextCursor)}`
: page > 1
? `&page=${page}`
: ""
const response = await fetchImpl(
`${apiUrl}/sandbox?limit=${PAGE_LIMIT}${pagination}`,
{ method: "GET", headers: authorizationHeaders(apiKey) },
)
const parsed = await responseJson(response, "sandboxes")
if (Array.isArray(parsed)) {
sandboxes.push(...parsed)
if (parsed.length < PAGE_LIMIT) {
return sandboxes
}
continue
}
if (
!parsed ||
typeof parsed !== "object" ||
!Array.isArray(parsed.items)
) {
throw new Error("Daytona API sandbox response did not contain a sandbox list.")
}
sandboxes.push(...parsed.items)
nextCursor =
typeof parsed.nextCursor === "string" && parsed.nextCursor.length > 0
? parsed.nextCursor
: ""
if (!nextCursor) {
return sandboxes
}
}
throw new Error("Daytona API sandbox pagination exceeded 100 pages.")
}
export async function pruneDaytonaDevSnapshots({
apiUrl = DEFAULT_API_URL,
nameBase = "openwork",
keepNames,
keepCount = 5,
dryRun = false,
apiKey,
fetchImpl = globalThis.fetch,
log = console.log,
}) {
const baseUrl = requiredValue(apiUrl, "Missing Daytona API URL.").replace(/\/+$/, "")
const base = requiredValue(nameBase, "Missing snapshot name base.")
const protectedNames = keepNames.map(validateSnapshotName)
const recentCount = validateKeepCount(keepCount)
authorizationHeaders(apiKey)
const snapshots = await readSnapshots({ apiUrl: baseUrl, apiKey, fetchImpl })
const sandboxes = await readSandboxes({ apiUrl: baseUrl, apiKey, fetchImpl })
const selection = selectDevSnapshotPrunes({
snapshots,
sandboxes,
nameBase: base,
keepNames: protectedNames,
keepCount: recentCount,
})
const failures = []
let prunedCount = 0
for (const snapshot of selection.keep) {
log(`${dryRun ? "dry-run: " : ""}keep: ${snapshot.name} (${snapshot.reason})`)
}
for (const snapshot of selection.prune) {
if (dryRun) {
log(`dry-run: pruned: ${snapshot.name} (${snapshot.id})`)
prunedCount += 1
continue
}
try {
const response = await fetchImpl(
`${baseUrl}/snapshots/${encodeURIComponent(snapshot.id)}`,
{ method: "DELETE", headers: authorizationHeaders(apiKey) },
)
await responseBody(response)
log(`pruned: ${snapshot.name} (${snapshot.id})`)
prunedCount += 1
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
failures.push({ snapshot, message })
log(`failed: ${snapshot.name} (${snapshot.id}): ${message}`)
}
}
const outOfScope = snapshots.length - selection.prune.length - selection.keep.length
const summary = `summary: pruned=${prunedCount} kept=${selection.keep.length} out-of-scope=${outOfScope} failed=${failures.length}`
log(summary)
const result = { ...selection, failures, summary }
if (failures.length > 0) {
const error = new Error(`${failures.length} Daytona snapshot deletion(s) failed.`)
error.result = result
throw error
}
return result
}
function optionValue(args, index, option) {
const value = args[index + 1]
if (!value || value.startsWith("--")) {
throw new Error(`Missing value for ${option}.`)
}
return value
}
export const USAGE = `Prune old per-push Daytona dev snapshots.
Usage:
node scripts/prune-daytona-dev-snapshots.mjs [--api-url <url>] [--name-base <base>] --keep <name> [--keep <name> ...] [--keep-count <n>] [--dry-run]
Options:
--api-url <url> Daytona API base URL (default: https://app.daytona.io/api).
--name-base <base> Snapshot name base (default: openwork).
--keep <name> Snapshot name to protect; may be repeated.
--keep-count <n> Protect the newest n dev snapshots (default: 5).
--dry-run Fetch and report without deleting snapshots.
-h, --help Show this help.
Environment:
DAYTONA_API_KEY Required (except with --help).`
export function parseArgs(args) {
const options = {
apiUrl: DEFAULT_API_URL,
nameBase: "openwork",
keepNames: [],
keepCount: 5,
dryRun: false,
help: false,
}
for (let index = 0; index < args.length; index += 1) {
const argument = args[index]
if (argument === "--api-url") {
options.apiUrl = optionValue(args, index, argument)
index += 1
} else if (argument === "--name-base") {
options.nameBase = optionValue(args, index, argument)
index += 1
} else if (argument === "--keep") {
options.keepNames.push(validateSnapshotName(optionValue(args, index, argument)))
index += 1
} else if (argument === "--keep-count") {
const value = optionValue(args, index, argument)
if (!/^\d+$/.test(value)) {
throw new Error("--keep-count must be an integer greater than or equal to 0.")
}
options.keepCount = validateKeepCount(Number(value))
index += 1
} else if (argument === "--dry-run") {
options.dryRun = true
} else if (argument === "--help" || argument === "-h") {
options.help = true
} else {
throw new Error(`Unknown argument: ${argument}`)
}
}
if (options.help) {
return options
}
if (options.keepNames.length === 0) {
throw new Error("Missing snapshot name. Pass --keep <name>.")
}
return options
}
export async function main(args, environment = process.env) {
const options = parseArgs(args)
if (options.help) {
console.log(USAGE)
return { status: "help" }
}
return pruneDaytonaDevSnapshots({
...options,
apiKey: environment.DAYTONA_API_KEY,
})
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
if (isMain) {
main(process.argv.slice(2)).catch((error) => {
const message = error instanceof Error ? error.message : String(error)
console.error(`Error: ${message}`)
process.exitCode = 1
})
}