From 217a5d8c3c2bc6d408a53694348c398c3783db43 Mon Sep 17 00:00:00 2001 From: k402xxxcenxxx Date: Sat, 29 Aug 2026 16:39:10 +0800 Subject: [PATCH 1/2] test(bdd): add DNS resolution assertion Add a strict DNS assertion that validates and interpolates inputs before delegating host-resolver polling to the existing wait-for-dns.sh script. Use the assertion across the single- and multi-cluster EKS workflows, with coverage for command construction and step-handler behavior. Refs: #1086 Signed-off-by: k402xxxcenxxx --- tests/bdd/PLAN.md | 1 + tests/bdd/dsl/dns.go | 51 +++++++++++ tests/bdd/dsl/dns_test.go | 84 +++++++++++++++++++ .../multi-cluster-eks-helmfile.feature | 12 +-- .../single-cluster-eks-helmfile.feature | 3 +- tests/bdd/steps/assertion_steps.go | 17 ++++ tests/bdd/steps/steps_test.go | 69 +++++++++++++++ 7 files changed, 227 insertions(+), 10 deletions(-) create mode 100644 tests/bdd/dsl/dns.go create mode 100644 tests/bdd/dsl/dns_test.go diff --git a/tests/bdd/PLAN.md b/tests/bdd/PLAN.md index e53c0aecf..1a98dd584 100644 --- a/tests/bdd/PLAN.md +++ b/tests/bdd/PLAN.md @@ -179,6 +179,7 @@ original order. Repeated options and empty values are preserved. | `Then deployment {string} in namespace {string} using context {string} should complete rollout within {string}` | Runs `kubectl rollout status` for the named deployment with the explicit namespace, context, and timeout. Failure messages name the deployment without printing command output. | | `Then NVCFBackend {string} in namespace {string} using context {string} should report agent status {string} within {string}` | Waits for the named backend's `status.agentStatus` to equal the visible value using the explicit namespace, context, and timeout. Failure messages name the backend without printing resource output. | | `Then these Gateway API routes should be accepted and resolved using context {string} within {string}:` (table) | Requires `kind`, `name`, `namespace`, and `parent` headers. Waits for every named route to report both `Accepted=True` and `ResolvedRefs=True` for the named Gateway parent using the explicit context and timeout. The route kind is passed through without an allowlist. Failures name the table row, route, namespace, parent, and unmet condition without printing resource output. | +| `Then DNS name {string} should resolve within {string} seconds` | Waits for the explicit DNS name to resolve through the host resolver within the explicit timeout. `${VAR}` interpolation applies to the name and timeout. Resolution must remain successful for three consecutive checks. Failures report the unresolved name and timeout without printing resolver output. | #### YAML comparison semantics diff --git a/tests/bdd/dsl/dns.go b/tests/bdd/dsl/dns.go new file mode 100644 index 000000000..ccee4ea05 --- /dev/null +++ b/tests/bdd/dsl/dns.go @@ -0,0 +1,51 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dsl + +import ( + "fmt" + "strconv" + "strings" +) + +const waitForDNSScript = "tests/bdd/scripts/wait-for-dns.sh" + +// DNSResolutionCommand builds the existing host-resolver polling command after +// resolving interpolation and validating its visible inputs. +func DNSResolutionCommand(hostname, timeout string) (string, error) { + hostname = strings.TrimSpace(Interpolate(hostname)) + timeout = strings.TrimSpace(Interpolate(timeout)) + if hostname == "" { + return "", fmt.Errorf("DNS name is empty") + } + if timeout == "" { + return "", fmt.Errorf("DNS resolution timeout is empty") + } + for _, char := range timeout { + if char < '0' || char > '9' { + return "", fmt.Errorf("DNS resolution timeout %q is not a non-negative integer", timeout) + } + } + timeoutSeconds, err := strconv.ParseInt(timeout, 10, 64) + if err != nil { + return "", fmt.Errorf("DNS resolution timeout %q is invalid: %w", timeout, err) + } + timeout = strconv.FormatInt(timeoutSeconds, 10) + + return BuildCommand(waitForDNSScript, hostname, timeout), nil +} diff --git a/tests/bdd/dsl/dns_test.go b/tests/bdd/dsl/dns_test.go new file mode 100644 index 000000000..99d3e2f23 --- /dev/null +++ b/tests/bdd/dsl/dns_test.go @@ -0,0 +1,84 @@ +/* +SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dsl + +import ( + "strings" + "testing" +) + +func TestDNSResolutionCommandInterpolatesAndBuildsScriptCommand(t *testing.T) { + t.Setenv("BDD_DNS_NAME", "api.192-0-2-10.nip.io") + t.Setenv("BDD_DNS_TIMEOUT", "180") + + got, err := DNSResolutionCommand(" ${BDD_DNS_NAME} ", " ${BDD_DNS_TIMEOUT} ") + if err != nil { + t.Fatalf("build DNS resolution command: %v", err) + } + want := "tests/bdd/scripts/wait-for-dns.sh api.192-0-2-10.nip.io 180" + if got != want { + t.Fatalf("command = %q, want %q", got, want) + } +} + +func TestDNSResolutionCommandNormalizesAndQuotesArguments(t *testing.T) { + got, err := DNSResolutionCommand("gateway name", "0180") + if err != nil { + t.Fatalf("build DNS resolution command: %v", err) + } + want := "tests/bdd/scripts/wait-for-dns.sh 'gateway name' 180" + if got != want { + t.Fatalf("command = %q, want %q", got, want) + } +} + +func TestDNSResolutionCommandRejectsInvalidInputs(t *testing.T) { + tests := []struct { + name string + hostname string + timeout string + want string + }{ + {name: "empty hostname", hostname: " ", timeout: "180", want: "DNS name is empty"}, + {name: "missing hostname variable", hostname: "${BDD_DNS_NAME_MISSING}", timeout: "180", want: "DNS name is empty"}, + {name: "empty timeout", hostname: "gateway.example.com", timeout: " ", want: "timeout is empty"}, + {name: "negative timeout", hostname: "gateway.example.com", timeout: "-1", want: "not a non-negative integer"}, + {name: "duration timeout", hostname: "gateway.example.com", timeout: "3m", want: "not a non-negative integer"}, + {name: "overflowing timeout", hostname: "gateway.example.com", timeout: "9223372036854775808", want: "timeout \"9223372036854775808\" is invalid"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := DNSResolutionCommand(test.hostname, test.timeout) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want substring %q", err, test.want) + } + }) + } +} + +func TestDNSResolutionCommandAllowsImmediateTimeout(t *testing.T) { + got, err := DNSResolutionCommand("gateway.example.com", "0") + if err != nil { + t.Fatalf("build DNS resolution command: %v", err) + } + want := "tests/bdd/scripts/wait-for-dns.sh gateway.example.com 0" + if got != want { + t.Fatalf("command = %q, want %q", got, want) + } +} diff --git a/tests/bdd/features/multi-cluster-eks-helmfile.feature b/tests/bdd/features/multi-cluster-eks-helmfile.feature index f649be4c1..e3435ea3d 100644 --- a/tests/bdd/features/multi-cluster-eks-helmfile.feature +++ b/tests/bdd/features/multi-cluster-eks-helmfile.feature @@ -104,8 +104,7 @@ Feature: Install a multi-cluster NVCF stack across two pre-provisioned EKS clust # 5. Wait for the ELB hostname to be resolvable from the host's # DNS resolver before installing. - When I run command "tests/bdd/scripts/wait-for-dns.sh ${EKS_GATEWAY_ADDR} 180" - Then the command exit code should be 0 + Then DNS name "${EKS_GATEWAY_ADDR}" should resolve within "180" seconds # Route hostnames such as api. must resolve independently. Derive # a nip.io wildcard domain from one NLB address so worker pods on the @@ -113,8 +112,7 @@ Feature: Install a multi-cluster NVCF stack across two pre-provisioned EKS clust When I run command "tests/bdd/scripts/resolve-gateway-domain.sh ${EKS_GATEWAY_ADDR}" Then the command exit code should be 0 When I export command output to environment variable "EKS_GATEWAY_DOMAIN" - When I run command "tests/bdd/scripts/wait-for-dns.sh api.${EKS_GATEWAY_DOMAIN} 180" - Then the command exit code should be 0 + Then DNS name "api.${EKS_GATEWAY_DOMAIN}" should resolve within "180" seconds # 6. Copy base.yaml -> eks-bdd-multi.yaml and patch with the EKS # knobs, including the resolvable Gateway domain. @@ -316,8 +314,7 @@ Feature: Install a multi-cluster NVCF stack across two pre-provisioned EKS clust # Register the compute cluster and write the returned Helm values under # registration/. - When I run command "tests/bdd/scripts/wait-for-dns.sh ${EKS_GATEWAY_ADDR} 180" - Then the command exit code should be 0 + Then DNS name "${EKS_GATEWAY_ADDR}" should resolve within "180" seconds When I run command: """ @@ -406,8 +403,7 @@ Feature: Install a multi-cluster NVCF stack across two pre-provisioned EKS clust # AWS can briefly return NXDOMAIN for a newly provisioned ELB even after # earlier successful lookups. Reconfirm system-resolver stability before # the CLI performs its function-details lookup and invocation. - When I run command "tests/bdd/scripts/wait-for-dns.sh ${EKS_GATEWAY_ADDR} 180" - Then the command exit code should be 0 + Then DNS name "${EKS_GATEWAY_ADDR}" should resolve within "180" seconds When I successfully invoke the function selected by NVCF CLI over HTTP with timeout "120" seconds and poll duration "5" seconds: """ diff --git a/tests/bdd/features/single-cluster-eks-helmfile.feature b/tests/bdd/features/single-cluster-eks-helmfile.feature index dcda7c463..ee65768ca 100644 --- a/tests/bdd/features/single-cluster-eks-helmfile.feature +++ b/tests/bdd/features/single-cluster-eks-helmfile.feature @@ -103,8 +103,7 @@ Feature: Install a single-cluster NVCF stack on a pre-provisioned EKS cluster wi # DNS resolver. AWS DNS propagation typically lags ~30-90s # behind NLB programming; without this wait, subsequent # in-pod connections can fail intermittently. - When I run command "tests/bdd/scripts/wait-for-dns.sh ${EKS_GATEWAY_ADDR} 180" - Then the command exit code should be 0 + Then DNS name "${EKS_GATEWAY_ADDR}" should resolve within "180" seconds # 6. Copy base.yaml -> eks-bdd.yaml and patch with the EKS # knobs (including global.domain from the just-exported diff --git a/tests/bdd/steps/assertion_steps.go b/tests/bdd/steps/assertion_steps.go index dcd5bac6a..034b4218f 100644 --- a/tests/bdd/steps/assertion_steps.go +++ b/tests/bdd/steps/assertion_steps.go @@ -53,6 +53,7 @@ func registerAssertionSteps(ctx *godog.ScenarioContext, sc *ScenarioContext) { ctx.Step(`^these Kubernetes resources should not exist in namespace "([^"]*)" using context "([^"]*)":$`, sc.kubernetesResourcesShouldNotExist) ctx.Step(`^Kubernetes resource "([^"/]+)/([^"]+)" in namespace "([^"]*)" using context "([^"]*)" should contain:$`, sc.kubernetesResourceShouldContain) ctx.Step(`^deployment "([^"]*)" in namespace "([^"]*)" using context "([^"]*)" should complete rollout within "([^"]*)"$`, sc.deploymentShouldCompleteRollout) + ctx.Step(`^DNS name "([^"]*)" should resolve within "([^"]*)" seconds$`, sc.dnsNameShouldResolve) ctx.Step(`^NVCFBackend "([^"]*)" in namespace "([^"]*)" using context "([^"]*)" should report agent status "([^"]*)" within "([^"]*)"$`, sc.nvcfBackendShouldReportAgentStatus) ctx.Step(`^these Gateway API routes should be accepted and resolved using context "([^"]*)" within "([^"]*)":$`, sc.gatewayAPIRoutesShouldBeAcceptedAndResolved) } @@ -359,6 +360,22 @@ func (sc *ScenarioContext) deploymentShouldCompleteRollout(ctx context.Context, return nil } +func (sc *ScenarioContext) dnsNameShouldResolve(ctx context.Context, hostname, timeout string) error { + command, err := dsl.DNSResolutionCommand(hostname, timeout) + if err != nil { + return err + } + if err := sc.runResolvedSuccessfully(ctx, command); err != nil { + return fmt.Errorf( + "DNS name %q did not resolve within %s seconds: %w", + strings.TrimSpace(dsl.Interpolate(hostname)), + strings.TrimSpace(dsl.Interpolate(timeout)), + err, + ) + } + return nil +} + func (sc *ScenarioContext) nvcfBackendShouldReportAgentStatus(ctx context.Context, name, namespace, kubeContext, agentStatus, timeout string) error { command, err := dsl.NVCFBackendAgentStatusCommand(name, namespace, kubeContext, agentStatus, timeout) if err != nil { diff --git a/tests/bdd/steps/steps_test.go b/tests/bdd/steps/steps_test.go index 55aca5e6d..5dc0504d3 100644 --- a/tests/bdd/steps/steps_test.go +++ b/tests/bdd/steps/steps_test.go @@ -997,6 +997,75 @@ func TestDeploymentShouldCompleteRolloutRunsExplicitWait(t *testing.T) { } } +func TestDNSNameShouldResolveRunsExplicitWait(t *testing.T) { + sc, fake := newScenarioContext(t) + fake.result = harness.Result{ExitCode: 0} + t.Setenv("BDD_DNS_NAME", "api.192-0-2-10.nip.io") + t.Setenv("BDD_DNS_TIMEOUT", "180") + + if err := sc.dnsNameShouldResolve(context.Background(), "${BDD_DNS_NAME}", "${BDD_DNS_TIMEOUT}"); err != nil { + t.Fatalf("wait for DNS resolution: %v", err) + } + want := "tests/bdd/scripts/wait-for-dns.sh api.192-0-2-10.nip.io 180" + if len(fake.runs) != 1 || fake.runs[0].command != want { + t.Fatalf("runs = %#v, want %q", fake.runs, want) + } +} + +func TestDNSNameShouldResolveRejectsInvalidInputsBeforeRunning(t *testing.T) { + tests := []struct { + name string + hostname string + timeout string + want string + }{ + { + name: "empty hostname", + hostname: " ", + timeout: "180", + want: "DNS name is empty", + }, + { + name: "invalid timeout", + hostname: "gateway.example.com", + timeout: "-1", + want: "not a non-negative integer", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + sc, fake := newScenarioContext(t) + err := sc.dnsNameShouldResolve(context.Background(), tc.hostname, tc.timeout) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want error containing %q", err, tc.want) + } + if len(fake.runs) != 0 { + t.Fatalf("runs = %d, want 0", len(fake.runs)) + } + }) + } +} + +func TestDNSNameShouldResolveFailureNamesTargetWithoutResolverOutput(t *testing.T) { + sc, fake := newScenarioContext(t) + secretOutput := "unrelated-resolver-output" + fake.result = harness.Result{ExitCode: 2, Stdout: secretOutput, Stderr: secretOutput} + + err := sc.dnsNameShouldResolve(context.Background(), "gateway.example.com", "180") + if err == nil { + t.Fatal("expected DNS resolution failure") + } + for _, want := range []string{`DNS name "gateway.example.com"`, "within 180 seconds"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %q, want %q", err, want) + } + } + if strings.Contains(err.Error(), secretOutput) { + t.Fatalf("error leaked resolver output: %v", err) + } +} + func TestKubernetesResourceShouldContainFailureDoesNotExposeResourceValues(t *testing.T) { sc, fake := newScenarioContext(t) fake.result = harness.Result{ExitCode: 0, Stdout: `data: From 3a5c4ff1e02ac0676a5c07e5959a930733e9690b Mon Sep 17 00:00:00 2001 From: k402xxxcenxxx Date: Sat, 29 Aug 2026 21:12:13 +0800 Subject: [PATCH 2/2] test(bdd): avoid DNS wait deadline overflow Compare elapsed time instead of adding the timeout to the Unix timestamp so the maximum int64 timeout cannot overflow Bash deadline arithmetic. Cover both normal and maximum int64 timeouts in the DNS script execution test. Refs: #1086 Signed-off-by: k402xxxcenxxx --- tests/bdd/fixtures_test.go | 41 ++++++++++++++++++++----------- tests/bdd/scripts/wait-for-dns.sh | 6 +++-- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/tests/bdd/fixtures_test.go b/tests/bdd/fixtures_test.go index cf602a4c1..a053051ca 100644 --- a/tests/bdd/fixtures_test.go +++ b/tests/bdd/fixtures_test.go @@ -384,7 +384,6 @@ printf '%s has address 192.0.2.10\n' "$1" func TestWaitForDNSRequiresStableSystemResolution(t *testing.T) { binDir := t.TempDir() - countPath := filepath.Join(binDir, "resolver-count") resolverScript := `#!/usr/bin/env bash set -euo pipefail count=0 @@ -410,21 +409,33 @@ fi t.Fatalf("write fake sleep: %v", err) } - cmd := exec.Command("bash", "scripts/wait-for-dns.sh", "gateway.example.invalid", "30") - cmd.Env = append(os.Environ(), "FAKE_RESOLVER_COUNT="+countPath, "PATH="+binDir+":"+os.Getenv("PATH")) - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("wait for stable DNS: %v\n%s", err, out) + tests := []struct { + name string + timeout string + }{ + {name: "normal timeout", timeout: "30"}, + {name: "maximum int64 timeout", timeout: "9223372036854775807"}, } - if got := string(out); !strings.Contains(got, "3 consecutive system-resolver checks after 5 attempts") { - t.Fatalf("wait output did not report stable resolution: %q", got) - } - count, err := os.ReadFile(countPath) - if err != nil { - t.Fatalf("read resolver attempt count: %v", err) - } - if got, want := strings.TrimSpace(string(count)), "5"; got != want { - t.Fatalf("resolver attempts = %s, want %s", got, want) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + countPath := filepath.Join(t.TempDir(), "resolver-count") + cmd := exec.Command("bash", "scripts/wait-for-dns.sh", "gateway.example.invalid", tc.timeout) + cmd.Env = append(os.Environ(), "FAKE_RESOLVER_COUNT="+countPath, "PATH="+binDir+":"+os.Getenv("PATH")) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("wait for stable DNS: %v\n%s", err, out) + } + if got := string(out); !strings.Contains(got, "3 consecutive system-resolver checks after 5 attempts") { + t.Fatalf("wait output did not report stable resolution: %q", got) + } + count, err := os.ReadFile(countPath) + if err != nil { + t.Fatalf("read resolver attempt count: %v", err) + } + if got, want := strings.TrimSpace(string(count)), "5"; got != want { + t.Fatalf("resolver attempts = %s, want %s", got, want) + } + }) } } diff --git a/tests/bdd/scripts/wait-for-dns.sh b/tests/bdd/scripts/wait-for-dns.sh index 639722919..9119d6994 100755 --- a/tests/bdd/scripts/wait-for-dns.sh +++ b/tests/bdd/scripts/wait-for-dns.sh @@ -41,7 +41,7 @@ if ! command -v python3 >/dev/null 2>&1; then exit 69 fi -deadline=$(( $(date +%s) + TIMEOUT_SECONDS )) +start_time=$(date +%s) attempt=0 consecutive_successes=0 while [[ "$consecutive_successes" -lt 3 ]]; do @@ -60,7 +60,9 @@ PY if [[ "$consecutive_successes" -ge 3 ]]; then break fi - if [[ $(date +%s) -ge "$deadline" ]]; then + current_time=$(date +%s) + elapsed_seconds=$(( current_time - start_time )) + if (( elapsed_seconds >= TIMEOUT_SECONDS )); then echo "timed out after ${TIMEOUT_SECONDS}s waiting for DNS for $HOSTNAME (attempts=$attempt)" >&2 exit 2 fi