Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

271 lines
11 KiB
TypeScript

#!/usr/bin/env bun
/**
* Provision a throwaway Hetzner server for the nightly E2E workflow.
*
* Reads:
* HCLOUD_TOKEN_CI - Hetzner Cloud API token (CI-scoped)
* CI_SSH_PUBLIC_KEY_ID - Numeric Hetzner SSH key id (one-time uploaded)
* GITHUB_RUN_ID - run id, embedded in labels
* HETZNER_E2E_LOCATION - default fsn1
* HETZNER_E2E_SERVER_TYPE - default cx22 (cpx11 was deprecated)
* HETZNER_E2E_IMAGE - default ubuntu-24.04
*
* On success: prints `{id, ip}` JSON to stdout AND writes the server id
* into the state file IMMEDIATELY after the create-call returns, before
* any further work — so a crash never leaks a server.
*/
import {
HetznerCloudClient,
HetznerCloudError,
} from "@elizaos/cloud-shared/lib/services/containers/hetzner-cloud-api";
import { appendStateAtomic } from "./state-file";
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
console.error(`[hetzner-e2e-provision] missing env: ${name}`);
process.exit(1);
}
return value;
}
// Hetzner periodically removes types / restricts new servers to specific
// locations. When the requested (serverType, location) pair returns
// `unsupported_location_for_server_type` (or the older `invalid_input`
// equivalent), retry with these fallbacks in order before giving up. Each
// fallback should be a cheap shared-cpu type available in at least one
// public location at the time this list was last reviewed.
const SERVER_TYPE_FALLBACKS: ReadonlyArray<{
serverType: string;
location: string;
}> = [
// cx22 is deprecated and cax (ARM) currently returns "error during
// placement" for the CI project; cpx22 (x86 2c/4g) is the available shared
// type across the EU datacenters, with cpx11 in hil (US-W) as a last resort.
// Verified against GET /v1/datacenters .server_types.available (2026-06).
{ serverType: "cpx22", location: "nbg1" },
{ serverType: "cpx22", location: "hel1" },
{ serverType: "cpx22", location: "fsn1" },
{ serverType: "cpx11", location: "hil" }, // US-West fallback
];
// Conditions under which we should try the next fallback combo. Covers
// Hetzner's "this server type can't be created here" and "this server
// type is going away" responses — both render the requested combo
// unusable and a different shared-cpu type / location is the natural
// remediation. We also treat project-wide quota exhaustion as retryable
// because the pre-reap pass runs immediately before the loop: if it
// freed any slots, the next attempt may now fit under the cap. The
// fallback ladder is finite (~5 combos) so a genuinely exhausted project
// will still surface as the last combo's error after the loop exits.
// Pure auth / billing failures (HTTP 401, real 403 without limit code)
// are NOT in this list — those are surfaced unchanged so the operator
// fixes the underlying account issue.
function isRetryableCombo(err: unknown): boolean {
if (err instanceof HetznerCloudError && err.code === "quota_exceeded") {
return true;
}
// "error during placement" (HTTP 412) is Hetzner's transient signal that the
// requested type can't be placed in that location right now — a different
// location usually succeeds, so keep walking the fallback ladder instead of
// aborting the whole provision on the first capacity hiccup.
if (err instanceof HetznerCloudError && err.status === 412) {
return true;
}
const message = err instanceof Error ? err.message.toLowerCase() : "";
return (
message.includes("error during placement") ||
message.includes("placement") ||
message.includes("unsupported_server_type_for_location") ||
message.includes("unsupported location for server type") ||
message.includes("unsupported_location_for_server_type") ||
message.includes("is deprecated") ||
message.includes("server_type_deprecated") ||
message.includes("resource_unavailable") ||
message.includes("not_found") || // Hetzner returns 404 when a deprecated type is fully removed
// Hetzner returns HTTP 403 with body `{ error: { code: "limit_reached",
// message: "server limit reached" } }` when the project cap is hit.
// Match both the apiCode and the human message so we stay correct even
// if mapStatusToCode is bypassed (e.g. transport layer wraps the body).
message.includes("server limit reached") ||
message.includes("limit_reached") ||
message.includes("resource_limit_exceeded")
);
}
// Pre-provision reap: delete any prior CI servers older than this. Stops a
// chain of failed runs (which leak servers because teardown only fires when
// provision succeeds) from blocking new runs with "server limit reached".
// 20min is well above the ~10min healthy E2E budget; anything older is
// guaranteed dead. The half-hourly reaper workflow handles the >60min case;
// this is the fast lane.
const PRE_REAP_AGE_MS = 20 * 60 * 1000;
async function preReapOldServers(
client: InstanceType<typeof HetznerCloudClient>,
): Promise<void> {
const servers = await client
.listServers({ ci: "true", workflow: "hetzner-e2e" })
.catch((err: unknown) => {
console.warn(
`[hetzner-e2e-provision] pre-reap listServers failed: ${err instanceof Error ? err.message : String(err)}`,
);
return [] as Awaited<ReturnType<typeof client.listServers>>;
});
const now = Date.now();
for (const server of servers) {
const created = Date.parse(server.created);
if (!Number.isFinite(created)) continue;
if (now - created < PRE_REAP_AGE_MS) continue;
console.error(
`[hetzner-e2e-provision] pre-reap deleting ${server.id} (${server.name}) age=${Math.round((now - created) / 60000)}min`,
);
try {
await client.deleteServer(server.id);
} catch (err) {
console.warn(
`[hetzner-e2e-provision] pre-reap delete ${server.id} failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
async function main(): Promise<void> {
const token = requireEnv("HCLOUD_TOKEN_CI");
const sshKeyId = Number.parseInt(requireEnv("CI_SSH_PUBLIC_KEY_ID"), 10);
if (!Number.isFinite(sshKeyId)) {
throw new Error(
"CI_SSH_PUBLIC_KEY_ID must be a numeric Hetzner SSH key id",
);
}
const runId = process.env.GITHUB_RUN_ID ?? `local-${Date.now()}`;
const requestedLocation = process.env.HETZNER_E2E_LOCATION ?? "fsn1";
// cx22 is now deprecated; cpx22 (x86 2 vCPU / 4 GB) is the current shared
// type available across fsn1/hel1/nbg1 per GET /v1/datacenters. Operators can
// still pin a specific type via env.
const requestedServerType = process.env.HETZNER_E2E_SERVER_TYPE ?? "cpx22";
const image = process.env.HETZNER_E2E_IMAGE ?? "ubuntu-24.04";
const createdAt = new Date().toISOString();
// Minimal user-data: install docker via Hetzner's cloud-init helpers.
const userData = [
"#cloud-config",
"package_update: true",
"packages:",
" - docker.io",
" - ca-certificates",
"runcmd:",
" - systemctl enable --now docker",
" - touch /var/lib/cloud/instance/e2e-ready",
"",
].join("\n");
const client = HetznerCloudClient.withToken(token);
await preReapOldServers(client);
const attempts: Array<{ serverType: string; location: string }> = [
{ serverType: requestedServerType, location: requestedLocation },
...SERVER_TYPE_FALLBACKS.filter(
(combo) =>
!(
combo.serverType === requestedServerType &&
combo.location === requestedLocation
),
),
];
let server: Awaited<ReturnType<typeof client.createServer>>["server"] | null =
null;
let lastError: unknown;
let serverType = requestedServerType;
let location = requestedLocation;
for (const attempt of attempts) {
try {
const created = await client.createServer({
name: `ci-hetzner-e2e-${runId}`,
serverType: attempt.serverType,
location: attempt.location,
image,
userData,
sshKeyIds: [sshKeyId],
labels: {
ci: "true",
workflow: "hetzner-e2e",
run: String(runId),
// Hetzner label values reject ":" — use a safe ISO variant.
created: createdAt.replace(/[:.]/g, "-"),
},
});
server = created.server;
serverType = attempt.serverType;
location = attempt.location;
if (
attempt.serverType !== requestedServerType ||
attempt.location !== requestedLocation
) {
console.error(
`[hetzner-e2e-provision] requested ${requestedServerType}@${requestedLocation} was unavailable; succeeded with ${attempt.serverType}@${attempt.location}`,
);
}
break;
} catch (err) {
lastError = err;
if (!isRetryableCombo(err)) {
// Surface a hint before propagating: a non-retryable failure on
// the first attempt is almost always an auth/account problem
// (missing or stale HCLOUD_TOKEN_CI, project disabled). Without
// this, the workflow log just shows the bare HetznerCloudError
// and the operator has to guess.
if (err instanceof HetznerCloudError && err.code === "missing_token") {
console.error(
"[hetzner-e2e-provision] Hetzner rejected the token (HTTP 401/403). " +
"Refresh HCLOUD_TOKEN_CI in the ci-hetzner-e2e GitHub environment, or verify the project is active.",
);
}
throw err;
}
const reason = err instanceof Error ? err.message : String(err);
console.error(
`[hetzner-e2e-provision] ${attempt.serverType}@${attempt.location} unavailable (${reason}); trying next fallback`,
);
}
}
if (!server) {
// Layer 2 diagnostic: when every fallback combo also failed with
// quota_exceeded, the operator needs a single actionable next step —
// not a stack of "unavailable" lines that look like a transient API
// glitch. Refresh `HCLOUD_TOKEN_CI` only if the token's project no
// longer matches the CI project; otherwise delete leaked servers in
// the Hetzner console (https://console.hetzner.cloud/) and re-run.
if (
lastError instanceof HetznerCloudError &&
lastError.code === "quota_exceeded"
) {
throw new Error(
`Hetzner project quota exhausted across all fallback combos (last error: ${lastError.message}). ` +
"Operator action required: pre-reap freed nothing in 20min window, and the workflow cannot proceed. " +
"Check https://console.hetzner.cloud/ for leaked CI servers (label ci=true, workflow=hetzner-e2e), " +
"or rotate HCLOUD_TOKEN_CI if it now points to a project with a tighter cap.",
);
}
throw lastError instanceof Error
? lastError
: new Error("Hetzner provisioning failed across all fallback combos");
}
const ip = server.public_net.ipv4?.ip ?? "";
// Persist immediately so teardown can find it even if we crash next line.
appendStateAtomic({
server_id: server.id,
ip,
created_at: createdAt,
run_id: String(runId),
});
console.log(JSON.stringify({ id: server.id, ip }));
}
await main();