Skip to content

Commit 97f21f5

Browse files
committed
Make Hetzner private network readiness explicit
1 parent 0f79485 commit 97f21f5

9 files changed

Lines changed: 179 additions & 7 deletions

File tree

.scratch/transport-layer-foundation/issues/12-add-reference-quic-benchmark-scripts.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,8 @@ from that smoke-test experience.
8282
infrastructure decision before reference-comparison runs is #25: either make
8383
Hetzner private-network readiness deterministic or scope #12 explicitly to
8484
public IPv4 paths for the first implementation.
85+
- 2026-05-21: Follow-up #25 is closed: Hetzner private-network readiness is now
86+
an explicit operator step with static guest netplan config and
87+
`just bench-transport-private-check`, validated by smoke
88+
`20260521T093427Z-private-smoke`. #12 can now design reference-comparison
89+
runtime command contracts against both public IPv4 and private-network paths.

.scratch/transport-layer-foundation/issues/25-fix-hetzner-private-network-readiness.md

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Fix Hetzner private-network readiness
22

3-
Status: needs-triage
3+
Status: closed
44
Type: AFK
55

66
## Parent
@@ -25,22 +25,52 @@ The public IPv4 path worked and produced valid benchmark JSONL.
2525

2626
## Acceptance criteria
2727

28-
- [ ] After `terraform apply`, both nodes expose their configured private IPs
28+
- [x] After `terraform apply`, both nodes expose their configured private IPs
2929
on an UP interface.
30-
- [ ] A smoke check proves client-to-server ICMP and TCP connectivity over the
30+
- [x] A smoke check proves client-to-server ICMP and TCP connectivity over the
3131
private IPs before benchmark traffic starts.
32-
- [ ] The Hetzner README documents the private-path readiness check.
33-
- [ ] If explicit OS network configuration is needed, it is handled by
32+
- [x] The Hetzner README documents the private-path readiness check.
33+
- [x] If explicit OS network configuration is needed, it is handled by
3434
Terraform/cloud-init without making cloud-init large or fragile.
35-
- [ ] If private paths are intentionally deferred, the README and outputs make
36-
that limitation clear.
35+
- [x] If private paths are intentionally deferred, the README and outputs make
36+
that limitation clear. Not applicable: private paths are supported after
37+
the readiness check passes.
3738

3839
## Blocked by
3940

4041
None.
4142

43+
## Resolution
44+
45+
Implemented private-network readiness as a first-class Hetzner operator step:
46+
47+
- Cloud-init writes a small static netplan file for the first Hetzner private
48+
NIC when private networking is enabled.
49+
- The private NIC defaults to `enp7s0`, uses the Terraform-assigned private IP
50+
as `/32`, MTU 1450, and routes the private network CIDR via the subnet
51+
gateway.
52+
- Cloud-init stops and masks `hc-net-ifup@enp7s0.service`, flushes any early
53+
global address on the private NIC, then applies netplan.
54+
- `just bench-transport-private-check` waits for cloud-init on both nodes,
55+
confirms peer routes, pings the server private IP from the client, and runs a
56+
one-second TCP `iperf3` probe over the private IP.
57+
- Terraform outputs now include `private_network_check_command` so operators
58+
can discover the required readiness probe from the applied run.
59+
4260
## Comments
4361

4462
- 2026-05-20: Created from Hetzner smoke `20260520T134420Z-smoke`. Public IPv4
4563
benchmark traffic is usable; private-network benchmark traffic is not yet a
4664
reliable operator path.
65+
- 2026-05-21: Implementation started. Direction: configure the first Hetzner
66+
private NIC explicitly in cloud-init using static netplan and add a
67+
`just bench-transport-private-check` readiness probe that proves peer route,
68+
ICMP, and TCP connectivity before private-path benchmarks.
69+
- 2026-05-21: Closed after Hetzner ARM smoke
70+
`20260521T093427Z-private-smoke`. The client and server both exposed
71+
`enp7s0` as UP with `10.88.0.11/32` and `10.88.0.12/32`, respectively, and
72+
routes to the peer private IPs via `10.88.0.1`. The readiness check proved
73+
client-to-server ICMP with 3/3 packets delivered and TCP with
74+
`iperf3 --client 10.88.0.12 --port 55209 --time 1`. The Terraform pair was
75+
destroyed afterward, and `just bench-transport-verify-clean` confirmed no
76+
Terraform state entries or labelled Hetzner resources remained.

bench/transport/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,17 @@ Terraform outputs include path metadata for public IPv4 and private-network
703703
runs. Benchmark tools should merge those outputs with live host inventory and
704704
run-specific metrics.
705705

706+
Before using private-network path metadata, run:
707+
708+
```bash
709+
just bench-transport-private-check
710+
```
711+
712+
The check proves that both nodes have their configured private IPs ready and
713+
that the client can reach the server private IP over ICMP and TCP. Treat private
714+
path benchmark results as invalid if this readiness check has not passed for
715+
the same Terraform run.
716+
706717
## Implementation Order
707718

708719
The intended issue order is:

bench/transport/infra/hetzner/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ Check cloud-init and the installed tools before running benchmarks:
4040
terraform output -json toolchain_check_commands
4141
```
4242

43+
When the private network is enabled, prove private-path readiness before using
44+
private IPs for benchmarks:
45+
46+
```bash
47+
just bench-transport-private-check
48+
```
49+
50+
The check waits for cloud-init on both nodes, verifies that each node has a
51+
route to its peer private IP, pings the server private IP from the client, and
52+
runs a one-second `iperf3` TCP probe bound to the server private IP. Do not use
53+
`path_metadata_private` for benchmark results until this check passes.
54+
4355
Destroy the pair when the run is finished:
4456

4557
```bash
@@ -84,11 +96,19 @@ allowed.
8496

8597
Cloud-init intentionally does little:
8698

99+
- writes a static netplan file for the first Hetzner private NIC when private
100+
networking is enabled;
87101
- installs build tools, `iperf3`, and small shell utilities;
88102
- installs Go from the official Linux archive at `go.dev/dl`;
89103
- installs Erlang/OTP and Elixir with the official Elixir install script;
90104
- writes a short note under `/opt/moqx-bench/`.
91105

106+
For the current CAX/CCX profiles, the first Hetzner private interface is
107+
configured as `enp7s0` with the assigned private IP as `/32`, MTU 1450, and a
108+
route to the private network CIDR via the network gateway. The Terraform
109+
variable `private_network_interface` exists so a future profile can override
110+
the guest interface name if needed.
111+
92112
The benchmark repo is not cloned automatically, and no benchmark process is
93113
started automatically. Deploy a `moqx-transport-bench` release artifact or use
94114
the installed Elixir/Mix toolchain for development-only checks after cloud-init

bench/transport/infra/hetzner/cloud-init.yaml.tftpl

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,49 @@ write_files:
1818
Provisioning installs only the base build toolchain, iperf3, Go, Erlang,
1919
and Elixir. Deploy a moqx-transport-bench release artifact when you are
2020
ready to run a benchmark.
21+
%{ if private_network_enabled }
22+
- path: /etc/netplan/60-moqx-bench-private-network.yaml
23+
permissions: "0644"
24+
content: |
25+
network:
26+
version: 2
27+
renderer: networkd
28+
ethernets:
29+
${private_network_interface}:
30+
dhcp4: false
31+
mtu: 1450
32+
addresses:
33+
- ${private_ip}/32
34+
routes:
35+
- to: ${private_network_cidr}
36+
via: ${private_gateway_ip}
37+
on-link: true
38+
%{ endif }
2139

2240
runcmd:
2341
- |
2442
set -eu
2543
export HOME=/root
2644
export DEBIAN_FRONTEND=noninteractive
45+
%{ if private_network_enabled }
46+
private_iface="${private_network_interface}"
47+
48+
for _ in $(seq 1 30); do
49+
if ip link show dev "$private_iface" >/dev/null 2>&1; then
50+
break
51+
fi
52+
sleep 1
53+
done
54+
55+
ip link show dev "$private_iface"
56+
systemctl stop "hc-net-ifup@$private_iface.service" || true
57+
systemctl mask "hc-net-ifup@$private_iface.service" || true
58+
ip address flush dev "$private_iface" scope global || true
59+
netplan generate
60+
netplan apply
61+
ip -4 address show dev "$private_iface"
62+
ip route get "${private_gateway_ip}"
63+
%{ endif }
2764
apt-get update
2865
apt-get install -y \
2966
autoconf \

bench/transport/infra/hetzner/main.tf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,12 @@ resource "hcloud_server" "node" {
139139
otp_version = var.otp_version
140140
elixir_version = var.elixir_version
141141
go_version = var.go_version
142+
143+
private_network_enabled = var.enable_private_network
144+
private_network_interface = var.private_network_interface
145+
private_network_cidr = var.private_network_cidr
146+
private_gateway_ip = cidrhost(var.private_subnet_cidr, 1)
147+
private_ip = each.value.private_ip
142148
})
143149

144150
firewall_ids = [hcloud_firewall.operator.id]

bench/transport/infra/hetzner/outputs.tf

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,8 @@ output "toolchain_check_commands" {
8181
"ssh root@${server.ipv4_address} 'cloud-init status --wait && go version && elixir --version && iperf3 --version | head -1'"
8282
}
8383
}
84+
85+
output "private_network_check_command" {
86+
description = "Read-only recipe that proves private ICMP and TCP connectivity between the benchmark endpoints."
87+
value = var.enable_private_network ? "just bench-transport-private-check ${var.run_id}" : null
88+
}

bench/transport/infra/hetzner/variables.tf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@ variable "private_network_zone" {
9494
default = "eu-central"
9595
}
9696

97+
variable "private_network_interface" {
98+
description = "Guest OS interface name for the first attached Hetzner private network on the selected server families."
99+
type = string
100+
default = "enp7s0"
101+
}
102+
97103
variable "client_private_ip" {
98104
description = "Private IP for the client endpoint when enable_private_network is true."
99105
type = string

justfile

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,58 @@ bench-transport-destroy run_id=current_run profile="arm-smoke":
129129
bench-transport-outputs:
130130
cd "{{ infra_dir }}" && terraform output
131131

132+
# Prove private-network readiness with ICMP and TCP before benchmark traffic.
133+
bench-transport-private-check run_id=current_run port="55209":
134+
#!/usr/bin/env bash
135+
set -euo pipefail
136+
137+
if [ -z "{{ run_id }}" ]; then
138+
printf '%s\n' 'Missing run_id. Run `just bench-transport-new-run` first or pass run_id explicitly.' >&2
139+
exit 2
140+
fi
141+
142+
key="{{ bench_dir }}/.keys/{{ run_id }}/id_ed25519"
143+
test -f "$key" || {
144+
printf 'Missing SSH key for run %s\n' "{{ run_id }}" >&2
145+
exit 2
146+
}
147+
148+
servers_json="$(cd "{{ infra_dir }}" && terraform output -json servers)"
149+
client_public="$(printf '%s' "$servers_json" | jq -r '.client.public_ipv4')"
150+
server_public="$(printf '%s' "$servers_json" | jq -r '.server.public_ipv4')"
151+
client_private="$(printf '%s' "$servers_json" | jq -r '.client.private_ip // empty')"
152+
server_private="$(printf '%s' "$servers_json" | jq -r '.server.private_ip // empty')"
153+
154+
if [ -z "$client_private" ] || [ -z "$server_private" ]; then
155+
printf '%s\n' 'Terraform outputs do not include private IPs. Private network is disabled or not applied.' >&2
156+
exit 2
157+
fi
158+
159+
ssh_opts=(
160+
-i "$key"
161+
-o IdentitiesOnly=yes
162+
-o StrictHostKeyChecking=accept-new
163+
-o UserKnownHostsFile="{{ bench_dir }}/.keys/{{ run_id }}/known_hosts"
164+
)
165+
166+
client="root@$client_public"
167+
server="root@$server_public"
168+
remote_log="/tmp/moqx-private-iperf3-{{ port }}.log"
169+
170+
cleanup() {
171+
ssh "${ssh_opts[@]}" "$server" "pkill -f 'iperf3 .*--port {{ port }}' >/dev/null 2>&1 || true" >/dev/null 2>&1 || true
172+
}
173+
trap cleanup EXIT
174+
175+
ssh "${ssh_opts[@]}" "$client" "cloud-init status --wait >/dev/null && ip -4 address show && ip route get '$server_private'"
176+
ssh "${ssh_opts[@]}" "$server" "cloud-init status --wait >/dev/null && ip -4 address show && ip route get '$client_private'"
177+
ssh "${ssh_opts[@]}" "$server" "nohup iperf3 --server --bind '$server_private' --port '{{ port }}' --one-off > '$remote_log' 2>&1 &"
178+
sleep 1
179+
ssh "${ssh_opts[@]}" "$client" "ping -c 3 -W 2 '$server_private'"
180+
ssh "${ssh_opts[@]}" "$client" "iperf3 --client '$server_private' --port '{{ port }}' --time 1 --json >/tmp/moqx-private-check.json"
181+
182+
printf 'Private network ready: %s -> %s over ICMP and TCP port %s\n' "$client_private" "$server_private" "{{ port }}"
183+
132184
# Verify Terraform state and Hetzner labelled resources are clean after destroy.
133185
bench-transport-verify-clean:
134186
#!/usr/bin/env bash

0 commit comments

Comments
 (0)