diff --git a/e2e/agentnetwork/chat_test.go b/e2e/agentnetwork/chat_test.go index 17ed40d5f..a928d1265 100644 --- a/e2e/agentnetwork/chat_test.go +++ b/e2e/agentnetwork/chat_test.go @@ -216,8 +216,7 @@ func TestProvidersMatrix(t *testing.T) { t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") - // Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking - // the proxy peer so WaitProxyPeer then observes it connected. + // Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts. proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) require.NoError(t, err, "resolve agent-network endpoint to proxy IP") if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { diff --git a/e2e/agentnetwork/guardrail_test.go b/e2e/agentnetwork/guardrail_test.go index d4fe98dda..1e4b222f0 100644 --- a/e2e/agentnetwork/guardrail_test.go +++ b/e2e/agentnetwork/guardrail_test.go @@ -164,8 +164,7 @@ func TestModelAllowlistEnforced(t *testing.T) { t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") - // Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking - // the proxy peer so WaitProxyPeer then observes it connected. + // Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts. proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) require.NoError(t, err, "resolve agent-network endpoint to proxy IP") if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { diff --git a/e2e/agentnetwork/skiptls_test.go b/e2e/agentnetwork/skiptls_test.go index 44e0b4dca..1f57605e3 100644 --- a/e2e/agentnetwork/skiptls_test.go +++ b/e2e/agentnetwork/skiptls_test.go @@ -104,8 +104,7 @@ func TestProviderSkipTLSVerification(t *testing.T) { t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") - // Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking - // the proxy peer so WaitProxyPeer then observes it connected. + // Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts. proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) require.NoError(t, err, "resolve endpoint to proxy IP") if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { diff --git a/e2e/agentnetwork/vllm_test.go b/e2e/agentnetwork/vllm_test.go index 53855da34..cd598f1ed 100644 --- a/e2e/agentnetwork/vllm_test.go +++ b/e2e/agentnetwork/vllm_test.go @@ -106,8 +106,7 @@ func TestVLLMProvider(t *testing.T) { t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") - // Resolve first: the DNS lookup triggers the lazy-connection warm-up, waking - // the proxy peer so WaitProxyPeer then observes it connected. + // Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts. proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) require.NoError(t, err, "resolve endpoint to proxy IP") if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { diff --git a/e2e/harness/client.go b/e2e/harness/client.go index 19210349f..4c9983e4a 100644 --- a/e2e/harness/client.go +++ b/e2e/harness/client.go @@ -4,6 +4,7 @@ package harness import ( "context" + "errors" "fmt" "io" "os/exec" @@ -167,22 +168,54 @@ func (cl *Client) pollStatus(ctx context.Context, timeout time.Duration, want st return fmt.Errorf("timed out waiting for %q; last status:\n%s", want, last) } -// ResolveProxyIP resolves the agent-network endpoint to the proxy peer's -// NetBird IP from inside the client (via magic DNS). +const ( + // curlExitCouldNotResolve is curl's exit code for a DNS resolution failure, distinct from connection-level failures. + curlExitCouldNotResolve = 6 + // dnsProbeRetryWindow bounds DNS-failure retries: the synthesized zone lands a beat after management connects, so early NXDOMAIN is propagation; a zone still absent after this window is a real failure. + dnsProbeRetryWindow = 30 * time.Second + dnsProbeRetryInterval = 2 * time.Second +) + +// ResolveProxyIP GETs https:/// from the client's netns: any HTTP status proves DNS + tunnel and wakes the lazy proxy peer; only DNS failures retry, within dnsProbeRetryWindow. Returns the connected IP for --resolve pinning. func (cl *Client) ResolveProxyIP(ctx context.Context, endpoint string) (string, error) { - code, reader, err := cl.container.Exec(ctx, []string{"getent", "hosts", endpoint}, tcexec.Multiplexed()) - if err != nil { - return "", err + args := []string{ + "run", "--rm", + "--network", "container:" + cl.container.GetContainerID(), + curlImage, + "-ksS", "-o", "/dev/null", + "--connect-timeout", "30", "--max-time", "60", + "-w", "%{remote_ip}", + "https://" + endpoint + "/", } - out, _ := io.ReadAll(reader) - if code != 0 { - return "", fmt.Errorf("getent hosts %s exited %d", endpoint, code) + deadline := time.Now().Add(dnsProbeRetryWindow) + for { + cmd := exec.CommandContext(ctx, "docker", args...) + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err == nil { + ip := strings.TrimSpace(stdout.String()) + if ip == "" { + return "", fmt.Errorf("got an HTTP response from %s but no remote IP", endpoint) + } + return ip, nil + } + + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != curlExitCouldNotResolve { + return "", fmt.Errorf("no HTTP response from %s: %w (%s)", endpoint, err, strings.TrimSpace(stderr.String())) + } + dnsErr := fmt.Errorf("DNS resolution failed for %s: %s", endpoint, strings.TrimSpace(stderr.String())) + if time.Until(deadline) < dnsProbeRetryInterval { + return "", dnsErr + } + select { + case <-ctx.Done(): + return "", fmt.Errorf("%w (%w)", dnsErr, ctx.Err()) + case <-time.After(dnsProbeRetryInterval): + } } - fields := strings.Fields(string(out)) - if len(fields) == 0 { - return "", fmt.Errorf("no address for %s", endpoint) - } - return fields[0], nil } // Wire shapes for Chat.