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 { // Live image ships iperf3; absence means packaging regression. d.Error("Network: iperf3 not found — live image is missing required tool") return Outcome{ Passed: false, Message: "iperf3 binary missing from live image", Summary: "failed (iperf3 missing)", Extras: map[string]any{"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_*") }