9bb4b09a04
CI / Lint + build + test (push) Has been cancelled
Post-repair hardware validation pipeline for Proxmox cluster hosts. Go orchestrator + in-image agent + mkosi live image + bundled dnsmasq PXE + SQLite + HTMX/SSE UI + notify registry + janitor + full docs.
145 lines
3.8 KiB
Go
145 lines
3.8 KiB
Go
package tests
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// NetworkConfig is what the agent passes to Network: the orchestrator's
|
|
// iperf3 server address and port. We derive host from OrchestratorURL.
|
|
type NetworkConfig struct {
|
|
OrchestratorURL string
|
|
IperfPort int // 0 = 5201
|
|
Duration time.Duration
|
|
}
|
|
|
|
// Network runs iperf3 against the orchestrator's bundled server. Records
|
|
// bandwidth as a measurement; fails if iperf3 is missing, the server
|
|
// isn't reachable, or throughput is zero.
|
|
func Network(ctx context.Context, d Deps, cfg NetworkConfig) Outcome {
|
|
if _, err := exec.LookPath("iperf3"); err != nil {
|
|
d.Warn("Network: iperf3 not found — skipping stage")
|
|
return Outcome{
|
|
Passed: true,
|
|
Summary: "skipped (iperf3 missing)",
|
|
Extras: map[string]any{"skipped": true, "reason": "iperf3_missing"},
|
|
}
|
|
}
|
|
host, err := deriveHost(cfg.OrchestratorURL)
|
|
if err != nil || host == "" {
|
|
d.Warn("Network: can't derive orchestrator host from URL — skipping stage")
|
|
return Outcome{
|
|
Passed: true,
|
|
Summary: "skipped (no orchestrator host)",
|
|
Extras: map[string]any{"skipped": true, "reason": "no_host"},
|
|
}
|
|
}
|
|
port := cfg.IperfPort
|
|
if port == 0 {
|
|
port = 5201
|
|
}
|
|
duration := cfg.Duration
|
|
if duration <= 0 {
|
|
duration = 10 * time.Second
|
|
}
|
|
|
|
args := []string{
|
|
"-c", host,
|
|
"-p", strconv.Itoa(port),
|
|
"-t", strconv.Itoa(int(duration.Seconds())),
|
|
"-J", // JSON output
|
|
}
|
|
d.Info(fmt.Sprintf("Network: iperf3 -c %s -p %d -t %s", host, port, duration))
|
|
|
|
runCtx, cancel := context.WithTimeout(ctx, duration+30*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(runCtx, "iperf3", args...)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
d.Error("Network: iperf3 client failed: " + err.Error())
|
|
return Outcome{
|
|
Passed: false,
|
|
Message: "iperf3 client error: " + err.Error(),
|
|
Summary: "iperf3 failed",
|
|
Extras: map[string]any{"stderr_tail": tailLines(string(out), 20)},
|
|
}
|
|
}
|
|
mbps, parsed, err := parseIperfJSON(out)
|
|
if err != nil {
|
|
d.Error("Network: parse iperf3 output: " + err.Error())
|
|
return Outcome{
|
|
Passed: false,
|
|
Message: "parse iperf3 json: " + err.Error(),
|
|
Summary: "parse error",
|
|
Extras: map[string]any{"raw": string(out)},
|
|
}
|
|
}
|
|
if d.Sensor != nil {
|
|
_ = d.Sensor(ctx, []Sample{{Kind: "iperf", Key: "throughput_mbps", Value: mbps, Unit: "Mbps"}})
|
|
}
|
|
|
|
extras := map[string]any{
|
|
"throughput_mbps": mbps,
|
|
"iperf_end": parsed,
|
|
}
|
|
if mbps <= 0 {
|
|
return Outcome{
|
|
Passed: false,
|
|
Message: "iperf3 reported zero throughput",
|
|
Summary: "zero throughput",
|
|
Extras: extras,
|
|
}
|
|
}
|
|
d.Info(fmt.Sprintf("Network: iperf3 PASSED: %.1f Mbps", mbps))
|
|
return Outcome{
|
|
Passed: true,
|
|
Summary: fmt.Sprintf("%.1f Mbps to %s", mbps, host),
|
|
Extras: extras,
|
|
}
|
|
}
|
|
|
|
// deriveHost pulls the hostname out of an https://host:port base URL.
|
|
func deriveHost(raw string) (string, error) {
|
|
if raw == "" {
|
|
return "", fmt.Errorf("empty url")
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
h := u.Hostname()
|
|
return strings.TrimSpace(h), nil
|
|
}
|
|
|
|
// parseIperfJSON pulls end.sum_sent.bits_per_second out of iperf3 -J.
|
|
// Returns (Mbps, full-json-map, err).
|
|
func parseIperfJSON(b []byte) (float64, map[string]any, error) {
|
|
var top map[string]any
|
|
if err := json.Unmarshal(b, &top); err != nil {
|
|
return 0, nil, err
|
|
}
|
|
end, ok := top["end"].(map[string]any)
|
|
if !ok {
|
|
return 0, top, fmt.Errorf("missing end")
|
|
}
|
|
// iperf3 reports either sum_sent (when -R not set) or sum_received.
|
|
for _, key := range []string{"sum_sent", "sum_received", "sum"} {
|
|
sum, ok := end[key].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
bps, ok := sum["bits_per_second"].(float64)
|
|
if !ok {
|
|
continue
|
|
}
|
|
return bps / 1_000_000, end, nil
|
|
}
|
|
return 0, end, fmt.Errorf("no bits_per_second in end.sum_*")
|
|
}
|