Repository navigation
fix(systemtest): the Hetzner lane picks its location at run time (ankra-ews9qk) #511
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -189,10 +189,13 @@ GITOPS_REPOSITORY="${GITOPS_REPOSITORY:-}" | |
| GITOPS_BRANCH="${GITOPS_BRANCH:-master}" | ||
|
|
||
| # Regions / zones / locations. | ||
| # fsn1, not nbg1: since 2026-10 Hetzner no longer offers cpx32 in nbg1, and | ||
| # every create there was refused (ankra-ews9qk). fsn1 and hel1 still carry | ||
| # cpx22 and cpx32 at the same price. | ||
| HETZNER_LOCATION="${HETZNER_LOCATION:-fsn1}" | ||
| # Hetzner's shared-vCPU stock moves by the minute: on 2026-10-14 cpx32 left | ||
| # nbg1, then fsn1, then came back to nbg1 within the hour, and a create in a | ||
| # location without it is refused (ankra-ews9qk). So the lane picks its | ||
| # location at run time: the first of HETZNER_LOCATION_CANDIDATES that offers | ||
| # every server type the lane uses right now. Setting HETZNER_LOCATION pins it. | ||
| HETZNER_LOCATION="${HETZNER_LOCATION:-}" | ||
| HETZNER_LOCATION_CANDIDATES="${HETZNER_LOCATION_CANDIDATES:-fsn1 nbg1 hel1}" | ||
| OVH_REGION="${OVH_REGION:-GRA9}" | ||
| UPCLOUD_ZONE="${UPCLOUD_ZONE:-de-fra1}" | ||
|
|
||
|
|
@@ -433,6 +436,68 @@ ank_out() { | |
| fi | ||
| } | ||
|
|
||
| # hetzner_available_types prints the server types Hetzner can provision in a | ||
| # location right now, one per line, read from the CLI's server-types table | ||
| # (NAME CORES MEMORY GB DISK GB ARCH PRICE AVAILABLE; the last column is yes). | ||
| hetzner_available_types() { | ||
| local table | ||
| table="$(ank_out cluster hetzner server-types --credential-id "$HETZNER_CREDENTIAL_ID" \ | ||
| --location "$1" --available-only)" || return 1 | ||
| printf '%s\n' "$table" | hetzner_types_from_table | ||
| } | ||
|
|
||
| # hetzner_types_from_table reads a server-types table on stdin and prints the | ||
| # names whose AVAILABLE column says yes. It matches the row's shape (a | ||
| # type-like name first, "yes" last) rather than skipping a fixed number of | ||
| # preamble lines, so a missing banner or an extra header cannot shift it. | ||
| hetzner_types_from_table() { | ||
| awk '$1 ~ /^[a-z]+[0-9]+[a-z]*$/ && $NF == "yes" { print $1 }' | ||
| } | ||
|
|
||
| # pick_hetzner_location prints the first candidate location that offers every | ||
| # type the lane uses and succeeds; otherwise it prints why and fails. A | ||
| # location whose types could not be read (the CLI failed, or answered no rows) | ||
| # is never read as a location that lacks them: the reason names it apart, and | ||
| # when no candidate could be read at all it says so (a credential or API | ||
| # problem, not Hetzner's stock). It reads each candidate through | ||
| # hetzner_available_types, so a test can stub it. | ||
| pick_hetzner_location() { | ||
| local location needed available is_complete | ||
| local unread="" lacking="" | ||
| for needed in "$HETZNER_CP_TYPE" "$HETZNER_WORKER_TYPE" "$HETZNER_BASTION_TYPE" "$HETZNER_BIGGER_TYPE"; do | ||
| if [ -z "$needed" ]; then | ||
| echo "configuration error: HETZNER_CP_TYPE, HETZNER_WORKER_TYPE, HETZNER_BASTION_TYPE and HETZNER_BIGGER_TYPE must all be set" | ||
| return 1 | ||
| fi | ||
| done | ||
| for location in $HETZNER_LOCATION_CANDIDATES; do | ||
| if ! available="$(hetzner_available_types "$location")" || [ -z "$available" ]; then | ||
| unread="${unread:+$unread }$location" | ||
| continue | ||
| fi | ||
| is_complete=1 | ||
| for needed in "$HETZNER_CP_TYPE" "$HETZNER_WORKER_TYPE" "$HETZNER_BASTION_TYPE" "$HETZNER_BIGGER_TYPE"; do | ||
| if ! printf '%s\n' "$available" | grep -qx "$needed"; then | ||
| is_complete=0 | ||
| break | ||
| fi | ||
| done | ||
| if [ "$is_complete" = 1 ]; then | ||
| echo "$location" | ||
| return 0 | ||
| fi | ||
| lacking="${lacking:+$lacking }$location" | ||
| done | ||
| if [ -z "$lacking" ]; then | ||
| echo "could not list Hetzner server types for any of '$HETZNER_LOCATION_CANDIDATES' (a credential or API problem, not Hetzner's stock)" | ||
| elif [ -n "$unread" ]; then | ||
| echo "no readable Hetzner location offers $HETZNER_CP_TYPE, $HETZNER_WORKER_TYPE, $HETZNER_BASTION_TYPE and $HETZNER_BIGGER_TYPE right now (lacking: $lacking; could not read: $unread)" | ||
| else | ||
| echo "no Hetzner location in '$HETZNER_LOCATION_CANDIDATES' offers $HETZNER_CP_TYPE, $HETZNER_WORKER_TYPE, $HETZNER_BASTION_TYPE and $HETZNER_BIGGER_TYPE right now" | ||
| fi | ||
| return 1 | ||
| } | ||
|
|
||
| # create_failure_reason says why a create printed no "Cluster ID:" line. The | ||
| # platform refuses a create it cannot run with a 400 whose "detail" names the | ||
| # cause (a server type the location no longer offers, a provider account with | ||
|
|
@@ -1891,6 +1956,16 @@ run_provider() { | |
|
|
||
| section "$label :: $name" | ||
|
|
||
| if [ "$provider" = hetzner ] && [ -z "$HETZNER_LOCATION" ]; then | ||
| local picked | ||
| if ! picked="$(pick_hetzner_location)"; then | ||
| fail "$label create ($picked)" | ||
| return | ||
| fi | ||
| HETZNER_LOCATION="$picked" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] Picked location is pinned for the rest of the run Assigning HETZNER_LOCATION here mutates the global, so any later hetzner lane in the same script run reuses this pick without re-probing. That is probably desirable (a mid-run location switch would be surprising), but if the intent was per-lane freshness, the assignment should be scoped or re-picked per lane. Worth a one-line comment either way. Ankra AI review. Read-only and advisory. |
||
| log "hetzner location: $HETZNER_LOCATION (offers every type this lane uses right now)" | ||
| fi | ||
|
|
||
| # 1. Create (capture the printed "Cluster ID: <uuid>") | ||
| log "creating $name (distribution=$distribution) ..." | ||
| out="$(create_cluster "$provider" "$name" "$distribution")" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[low] Empty HETZNER_*_TYPE variables silently make every location incomplete
If any of HETZNER_CP_TYPE / HETZNER_WORKER_TYPE / HETZNER_BASTION_TYPE / HETZNER_BIGGER_TYPE is unset or empty,
grep -qx "$needed"never matches and every candidate is rejected, surfacing as the misleading 'no location offers the types' failure. The diff does not show where these are defaulted; if they can be empty, validate them up front and fail with a configuration error instead.Ankra AI review. Read-only and advisory.