scripts/setup-hetzner.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises'
import { dirname } from 'node:path'
import { parseArgs } from 'node:util'
import { pathToFileURL } from 'node:url'
// Documented validation, authorization and capacity rejections precede creation.
// Service errors, unknown codes and unreadable responses remain ambiguous.
const creationRejectionCodes = new Set([
'json_error', 'unauthorized', 'forbidden', 'maintenance',
'resource_limit_exceeded', 'not_found', 'method_not_allowed',
'uniqueness_error', 'deprecated_api_endpoint', 'resource_unavailable',
'invalid_input', 'unsupported_error', 'protected', 'rate_limit_exceeded',
])
export function parseOptions(args) {
return parseArgs({ args: args[0] === '--' ? args.slice(1) : args, options: {
help: { type: 'boolean' }, list: { type: 'boolean' },
create: { type: 'boolean' }, name: { type: 'string' },
destroy: { type: 'boolean' }, yes: { type: 'boolean' },
state: { type: 'string', default: '.data/hetzner-test.json' },
location: { type: 'string' }, type: { type: 'string' },
image: { type: 'string', default: 'ubuntu-24.04' },
'ssh-key': { type: 'string' }, 'install-file': { type: 'string' },
} }).values
}
export function createApi(token, request = fetch) {
return async (path, body, method = body ? 'POST' : 'GET') => {
let response
try {
response = await request(`https://api.hetzner.cloud/v1${path}`, {
method,
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
...(body ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(30_000),
})
} catch {
// A failed response does not prove that a server was not created.
throw new Error(body
? 'Creation outcome unknown. Check the Hetzner Console before retrying.'
: 'Could not reach the Hetzner API.')
}
if (response.status === 204) return {}
let data
try {
data = await response.json()
} catch {
throw new Error(body
? 'Creation outcome unknown. Check the Hetzner Console before retrying.'
: `Hetzner HTTP ${response.status}: invalid response`)
}
if (!response.ok) {
const error = new Error(`Hetzner HTTP ${response.status}: ${data.error?.code || 'request_failed'}`)
error.status = response.status
error.creationRejected = response.status >= 400 && response.status < 500
&& creationRejectionCodes.has(data.error?.code)
throw error
}
return data
}
}
async function saveState(path, state) {
await writeFile(`${path}.new`, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 })
await rename(`${path}.new`, path)
}
async function getOrMissing(api, path) {
try { return await api(path) } catch (error) {
if (error.status === 404) return null
throw error
}
}
export async function destroy(options, api, output) {
const path = options.state || '.data/hetzner-test.json'
const state = JSON.parse(await readFile(path, 'utf8'))
if (state.version !== 1 || !Number.isSafeInteger(state.serverId) || !Array.isArray(state.ipIds)
|| state.ipIds.some(id => !Number.isSafeInteger(id))) {
throw new Error('State is incomplete. Check Hetzner Console; do not retry creation blindly.')
}
const existing = await getOrMissing(api, `/servers/${state.serverId}`)
if (existing && (existing.server.name !== state.name || existing.server.labels?.purpose !== 'devpush-test')) {
throw new Error('Tracked server identity changed; refusing deletion.')
}
for (const id of state.ipIds) {
const current = await getOrMissing(api, `/primary_ips/${id}`)
if (current?.primary_ip.assignee_id && current.primary_ip.assignee_id !== state.serverId) {
throw new Error(`IP ${id} is now assigned to another server; refusing deletion.`)
}
}
output(`Delete tracked server ${state.serverId} (${state.name}) and Primary IPs: ${state.ipIds.join(', ') || 'none'}.`)
if (!options.yes) { output('Preview only. Add --yes to confirm destruction.'); return }
if (existing) {
try { await api(`/servers/${state.serverId}`, undefined, 'DELETE') } catch (error) {
if (error.status !== 404) throw error
}
for (let attempt = 0; ; attempt++) {
if (!await getOrMissing(api, `/servers/${state.serverId}`)) break
if (attempt >= 30) throw new Error('Server deletion is still pending. Rerun --destroy --yes to resume.')
await new Promise(resolve => setTimeout(resolve, 1000))
}
}
for (const id of state.ipIds) {
let current = await getOrMissing(api, `/primary_ips/${id}`)
// Server disappearance can precede Primary IP detachment/auto-deletion.
for (let attempt = 0; current?.primary_ip.assignee_id === state.serverId; attempt++) {
if (attempt >= 30) throw new Error(`IP ${id} detachment is still pending. Rerun --destroy --yes to resume.`)
await new Promise(resolve => setTimeout(resolve, 1000))
current = await getOrMissing(api, `/primary_ips/${id}`)
}
if (!current) continue
if (current.primary_ip.assignee_id) throw new Error(`IP ${id} is still assigned; refusing deletion.`)
try { await api(`/primary_ips/${id}`, undefined, 'DELETE') } catch (error) {
if (error.status !== 404) throw error
}
}
await unlink(path)
output('Tracked server and IPs deleted. Local state removed. Remove the devpush record separately.')
}
async function list(api, resource, query = '') {
const rows = []
let page = 1
while (page) {
const data = await api(`/${resource}?per_page=50&page=${page}${query}`)
rows.push(...data[resource])
page = data.meta?.pagination?.next_page
}
return rows
}
export function makeServer(options, catalog, command) {
const find = (rows, value) => rows.find(row => row.name === value || String(row.id) === value)
const location = find(catalog.locations, options.location)
const type = find(catalog.server_types, options.type)
const matchingImages = catalog.images.filter(row => row.name === options.image || String(row.id) === options.image)
const image = matchingImages.find(row => row.architecture === type?.architecture) || matchingImages[0]
const key = find(catalog.ssh_keys, options['ssh-key'])
if (!location || !type || !image || !key) throw new Error('Invalid location, type, image, or SSH key. Run --list for current options.')
if (!type.locations?.some(entry => entry.id === location.id && entry.available === true)) {
throw new Error('This server type is unavailable in the selected location.')
}
if (image.architecture !== type.architecture) throw new Error('Image and server architecture do not match.')
if (image.status !== 'available' || image.deprecated) throw new Error('Image is unavailable or deprecated.')
if (!command.trim()) throw new Error('Install file is empty.')
if (Buffer.byteLength(command) > 20_000) throw new Error('Install file is too large; provide the short command from devpush.')
const script = Buffer.from(`#!/bin/bash\nset -euo pipefail\n${command}\n`).toString('base64')
return {
name: options.name, location: location.id, server_type: type.id,
image: image.id, ssh_keys: [key.id], start_after_create: true,
labels: { purpose: 'devpush-test' },
user_data: `#cloud-config\npackage_update: true\npackages: [curl, ca-certificates, git]\nwrite_files:\n - path: /root/devpush-install.sh\n permissions: '0700'\n encoding: b64\n content: ${script}\nruncmd:\n - [bash, /root/devpush-install.sh]\n`,
}
}
export async function run(options, api, output = console.log) {
if (options.destroy && (options.create || options.list)) throw new Error('Use --destroy by itself.')
if (options.destroy) return destroy(options, api, output)
const catalog = Object.fromEntries(await Promise.all([
'locations', 'server_types', 'images', 'ssh_keys',
].map(async resource => [resource, await list(api, resource, resource === 'images' ? '&type=system' : '')])))
if (options.list) {
output(JSON.stringify(catalog, null, 2))
return
}
for (const flag of ['name', 'location', 'type', 'ssh-key', 'install-file']) {
if (!options[flag]) throw new Error(`Missing --${flag}.`)
}
if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/.test(options.name)) throw new Error('Use a server name containing letters, digits, and hyphens (maximum 63 characters).')
const command = await readFile(options['install-file'], 'utf8')
const body = makeServer(options, catalog, command)
const type = catalog.server_types.find(row => row.id === body.server_type)
const location = catalog.locations.find(row => row.id === body.location)
output(JSON.stringify({ name: body.name, type: type.name, location: location.name,
image: options.image, price: type.prices?.find(price => price.location === location.name),
note: 'Server price excludes public IP charges. Install command omitted.' }, null, 2))
if (!options.create) {
output('Preview only. Add --create to create a billable server.')
return
}
// Never retry creation automatically: the first request may have succeeded.
const path = options.state || '.data/hetzner-test.json'
await mkdir(dirname(path), { recursive: true })
// Reserve before the paid request so a second invocation cannot overwrite ownership.
await writeFile(path, JSON.stringify({ version: 1, name: body.name, pending: true }), { flag: 'wx', mode: 0o600 })
let server
try {
;({ server } = await api('/servers', body))
} catch (error) {
if (error.creationRejected === true) await unlink(path)
throw error
}
output(`Created server ${server.id} (${server.name}). Startup installation is still pending.`)
const ipIds = [server.public_net?.ipv4?.id, server.public_net?.ipv6?.id].filter(id => Number.isSafeInteger(id) && id > 0)
await saveState(path, { version: 1, name: server.name, serverId: server.id, ipIds })
output(`Resource IDs saved to ${path}. Destroy with: node --env-file=.env scripts/setup-hetzner.mjs --destroy --state ${path} --yes`)
const ip = server.public_net?.ipv4?.ip
if (ip) output(`SSH: ssh root@${ip}`)
output('Check startup: cloud-init status --wait; sudo tail -n 100 /var/log/cloud-init-output.log')
output(`Delete after testing in Hetzner Console: https://console.hetzner.cloud/ (server ID ${server.id}). Powering off does not stop billing.`)
}
async function main() {
const options = parseOptions(process.argv.slice(2))
if (options.help) {
console.log('Cleanup: --destroy previews; --destroy --yes deletes the tracked server and IPs.\n--state PATH selects local tracking (default .data/hetzner-test.json).')
console.log(`Usage: pnpm setup:hetzner -- [options]\n\nRequires HETZNER_API_TOKEN (read/write for creation).\n --list List live regions, types/prices, system images and SSH keys\n --name NAME Test server name\n --location NAME Region from --list\n --type NAME Server type from --list\n --image NAME System image (default ubuntu-24.04)\n --ssh-key NAME Existing Hetzner SSH key name or ID\n --install-file PATH File containing the devpush install command\n --create Create a billable server (default: preview only)`)
return
}
const token = process.env.HETZNER_API_TOKEN
if (!token) throw new Error('Set HETZNER_API_TOKEN.')
await run(options, createApi(token))
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch(error => { console.error(error.message); process.exitCode = 1 })
}