scripts/setup-hetzner.test.mjs
Rama main · réplica de lectura del repositorio original.
import assert from 'node:assert/strict'
import test from 'node:test'
import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createApi, makeServer, parseOptions, run } from './setup-hetzner.mjs'
const catalog = {
locations: [{ id: 1, name: 'fsn1' }],
server_types: [{ id: 2, name: 'test-type', architecture: 'x86',
locations: [{ id: 1, name: 'fsn1', available: true }], prices: [] }],
images: [{ id: 3, name: 'ubuntu-24.04', architecture: 'x86', status: 'available', deprecated: null }],
ssh_keys: [{ id: 4, name: 'test-key' }],
}
const options = { name: 'devpush-test', location: 'fsn1', type: 'test-type',
image: 'ubuntu-24.04', 'ssh-key': 'test-key' }
test('rejects unavailable capacity, mismatched images, and missing keys', () => {
const changed = structuredClone(catalog)
changed.server_types[0].locations[0].available = false
assert.throws(() => makeServer(options, changed, 'echo ok'), /unavailable/)
changed.server_types[0].locations[0].available = true
changed.images[0].architecture = 'arm'
assert.throws(() => makeServer(options, changed, 'echo ok'), /architecture/)
assert.throws(() => makeServer({ ...options, 'ssh-key': 'missing' }, catalog, 'echo ok'), /Invalid/)
})
test('previews without mutation; explicit create sends command only as cloud-init', async () => {
const dir = await mkdtemp(join(tmpdir(), 'devpush-hetzner-'))
try {
const file = join(dir, 'install.sh')
const command = 'curl https://staging.example/install?token=private-token | bash'
await writeFile(file, command)
const calls = [], output = []
const api = async (path, body) => {
calls.push({ path, body })
if (body) return { server: { id: 99, name: options.name } }
const resource = path.slice(1).split('?')[0]
return { [resource]: catalog[resource], meta: { pagination: { next_page: null } } }
}
await run({ ...options, 'install-file': file }, api, text => output.push(text))
assert.equal(calls.filter(call => call.body).length, 0)
await run({ ...options, 'install-file': file, create: true, state: join(dir, 'state.json') }, api, text => output.push(text))
const mutations = calls.filter(call => call.body)
assert.equal(mutations.length, 1)
assert.equal(mutations[0].path, '/servers')
assert.deepEqual(mutations[0].body.ssh_keys, [4])
const encoded = mutations[0].body.user_data.match(/content: (.+)/)[1]
assert.ok(Buffer.from(encoded, 'base64').toString().includes(command))
assert.ok(!output.join('\n').includes('private-token'))
assert.ok(output.join('\n').includes('still pending'))
} finally { await rm(dir, { recursive: true, force: true }) }
})
test('does not retry ambiguous creation failures or expose provider error text', async () => {
let count = 0
const api = createApi('secret', async () => { count++; throw Error('network') })
await assert.rejects(api('/servers', { name: 'test' }), /outcome unknown/)
assert.equal(count, 1)
const rejected = createApi('secret', async () => ({ ok: false, status: 400,
json: async () => ({ error: { code: 'invalid_input', message: 'secret command' } }) }))
await assert.rejects(rejected('/servers', {}), /Hetzner HTTP 400: invalid_input$/)
})
test('accepts the pnpm argument separator and rejects unknown flags', () => {
assert.equal(parseOptions(['--', '--list']).list, true)
assert.throws(() => parseOptions(['--typo']))
})
test('destruction previews, resumes missing resources, and removes only recorded IPs', async () => {
const dir = await mkdtemp(join(tmpdir(), 'devpush-destroy-'))
const state = join(dir, 'state.json')
try {
await writeFile(state, JSON.stringify({ version: 1, name: 'devpush-test', serverId: 99, ipIds: [10, 11] }))
const calls = []
let deleted = false
const missing = () => { throw Object.assign(new Error('not found'), { status: 404 }) }
const api = async (path, body, method) => {
calls.push({ path, method })
if (method === 'DELETE') { if (path === '/servers/99') deleted = true; return {} }
if (path === '/servers/99') return deleted ? missing() : { server: { name: 'devpush-test', labels: { purpose: 'devpush-test' } } }
if (path === '/primary_ips/11') return missing()
return { primary_ip: { assignee_id: deleted ? null : 99 } }
}
await run({ destroy: true, state }, api, () => {})
assert.equal(calls.filter(c => c.method === 'DELETE').length, 0)
await run({ destroy: true, yes: true, state }, api, () => {})
assert.deepEqual(calls.filter(c => c.method === 'DELETE').map(c => c.path), ['/servers/99', '/primary_ips/10'])
await assert.rejects(run({ destroy: true, state }, api, () => {}), /ENOENT/)
} finally { await rm(dir, { recursive: true, force: true }) }
})
test('refuses deletion of an IP reassigned to another server', async () => {
const dir = await mkdtemp(join(tmpdir(), 'devpush-destroy-'))
try {
const state = join(dir, 'state.json')
await writeFile(state, JSON.stringify({ version: 1, name: 'devpush-test', serverId: 99, ipIds: [10] }))
const api = async path => path === '/servers/99'
? { server: { name: 'devpush-test', labels: { purpose: 'devpush-test' } } }
: { primary_ip: { assignee_id: 88 } }
await assert.rejects(run({ destroy: true, yes: true, state }, api, () => {}), /another server/)
} finally { await rm(dir, { recursive: true, force: true }) }
})
test('destruction waits for delayed Primary IP auto-deletion after the server disappears', async () => {
const dir = await mkdtemp(join(tmpdir(), 'devpush-destroy-'))
const state = join(dir, 'state.json')
try {
await writeFile(state, JSON.stringify({ version: 1, name: 'devpush-test', serverId: 99, ipIds: [10] }))
let reads = 0
const missing = () => { throw Object.assign(new Error('not found'), { status: 404 }) }
const api = async path => {
if (path === '/servers/99') return missing()
if (++reads < 3) return { primary_ip: { assignee_id: 99 } }
return missing()
}
await run({ destroy: true, yes: true, state }, api, () => {})
assert.equal(reads, 3)
await assert.rejects(readFile(state), /ENOENT/)
} finally { await rm(dir, { recursive: true, force: true }) }
})
test('destruction refuses an IP reassigned while waiting and preserves recovery state', async () => {
const dir = await mkdtemp(join(tmpdir(), 'devpush-destroy-'))
const state = join(dir, 'state.json')
try {
await writeFile(state, JSON.stringify({ version: 1, name: 'devpush-test', serverId: 99, ipIds: [10] }))
let reads = 0, deletes = 0
const api = async (path, _body, method) => {
if (method === 'DELETE') { deletes++; return {} }
if (path === '/servers/99') throw Object.assign(new Error('not found'), { status: 404 })
return { primary_ip: { assignee_id: ++reads < 3 ? 99 : 88 } }
}
await assert.rejects(run({ destroy: true, yes: true, state }, api, () => {}), /still assigned/)
assert.equal(deletes, 0)
assert.equal(JSON.parse(await readFile(state, 'utf8')).serverId, 99)
} finally { await rm(dir, { recursive: true, force: true }) }
})
for (const [status, code, released] of [[422, 'invalid_input', true], [401, 'unauthorized', true], [503, 'server_error', false], [422, 'service_error', false], [400, 'unknown_error', false]]) {
test(`creation ${status}/${code} ${released ? 'releases' : 'retains'} its reservation without retrying`, async () => {
const dir = await mkdtemp(join(tmpdir(), 'devpush-reservation-'))
try {
const state = join(dir, 'state.json')
const file = join(dir, 'install.sh')
await writeFile(file, 'echo fixture')
let creates = 0
const api = createApi('fixture', async (url, request) => {
if (request.method === 'POST') { creates++; return Response.json({ error: { code } }, { status }) }
const resource = new URL(url).pathname.split('/').at(-1)
return Response.json({ [resource]: catalog[resource], meta: { pagination: { next_page: null } } })
})
const input = { ...options, create: true, state, 'install-file': file }
await assert.rejects(run(input, api, () => {}), /Hetzner HTTP/)
assert.equal(creates, 1)
if (released) await assert.rejects(readFile(state), { code: 'ENOENT' })
else {
assert.equal(JSON.parse(await readFile(state, 'utf8')).pending, true)
await assert.rejects(run(input, api, () => {}), { code: 'EEXIST' })
assert.equal(creates, 1)
}
} finally { await rm(dir, { recursive: true, force: true }) }
})
}