From 020cdd0250e230bd3c2ed134344cee5326d53d15 Mon Sep 17 00:00:00 2001 From: Stephanie Baum Date: Sun, 30 Aug 2026 09:32:04 -0700 Subject: [PATCH 1/2] test(bdd): add autoscaler and vanity smoke coverage Exercise autoscale-from-zero and exact-host Vanity Gateway invocation on the current self-managed stack. Keep the scenarios outside-in and inherit component versions and product fixes from main. Closes #1360 Closes #1361 Signed-off-by: Stephanie Baum --- .../tests/observability-autoscaler.sh | 6 +- tests/bdd/PLAN.md | 3 +- tests/bdd/dsl/manifests.go | 4 +- tests/bdd/features/observability-all.feature | 66 +++++++++- .../features/observability-control.feature | 1 - .../features/single-cluster-helmfile.feature | 49 +++++++- .../bdd/fixtures/self-managed-local-bdd.yaml | 3 + tests/bdd/godog_test.go | 93 ++++++++++++-- tests/bdd/steps/nvcf_cli_steps.go | 114 +++++++++++++++++- tests/bdd/steps/nvcf_cli_steps_test.go | 72 +++++++++++ 10 files changed, 387 insertions(+), 24 deletions(-) diff --git a/deploy/stacks/self-managed/tests/observability-autoscaler.sh b/deploy/stacks/self-managed/tests/observability-autoscaler.sh index bfc9557be..2a2af31c8 100755 --- a/deploy/stacks/self-managed/tests/observability-autoscaler.sh +++ b/deploy/stacks/self-managed/tests/observability-autoscaler.sh @@ -70,6 +70,10 @@ write_autoscaler_values() { write_autoscaler_values "$work_dir/autoscaler-values.yaml" autoscaler_values="$work_dir/autoscaler-values.yaml" +autoscaler_tag="$(yq -r '.functionautoscaler.image.tag // ""' "$autoscaler_values")" +if [[ -n "$autoscaler_tag" ]]; then + fail "stack duplicated the function autoscaler chart-owned image tag" +fi for expected in \ 'CASSANDRA__CONTACT_POINTS: cassandra.cassandra-system.svc.cluster.local' \ 'CASSANDRA__IS_DEVELOPMENT: "false"' \ @@ -90,7 +94,7 @@ helm template function-autoscaler "$repo_dir/deploy/helm/function-autoscaler" \ autoscaler_manifests="$work_dir/autoscaler-manifests.yaml" grep -q 'image: nvcr.io/YOUR_ORG/YOUR_TEAM/nvcf-function-autoscaler:1.19.0' "$autoscaler_manifests" || - fail "self-managed stack did not pin the autoscaler image" + fail "self-managed stack did not render the chart-owned autoscaler image" test "$(grep -c '^kind: ConfigMap$' "$autoscaler_manifests")" = "2" || fail "autoscaler chart did not render only its env and Vault template ConfigMaps" grep -q 'name: function-autoscaler-env' "$autoscaler_manifests" || diff --git a/tests/bdd/PLAN.md b/tests/bdd/PLAN.md index e1b7ce77b..09d8fdff4 100644 --- a/tests/bdd/PLAN.md +++ b/tests/bdd/PLAN.md @@ -155,10 +155,11 @@ original order. Repeated options and empty values are preserved. | `Given I use NVCF CLI config {string}` | Interpolates and stores the supplied config argument without resolving or checking the path. Later lifecycle steps pass it to `--config`. | | `When I successfully create function {string} from image {string} with CLI options:` | Runs `function create --name --image ` followed by the option rows. | | `When I successfully deploy the function selected by NVCF CLI with options:` | Runs `function deploy create` followed by the option rows. Function selection remains owned by CLI state. | -| `When I successfully generate a function API key with CLI options:` | Runs `api-key generate --for function` followed by the option rows. | +| `When I successfully generate a function API key with CLI options:` | Runs `api-key generate --for function` followed by the option rows and suppresses secret-bearing stdout. | | `When I successfully invoke the function selected by NVCF CLI over HTTP with timeout {string} seconds and poll duration {string} seconds:` (JSON docstring) | Runs `function invoke` with the exact request body, timeout, and poll duration. | | `When I successfully invoke the function selected by NVCF CLI over plaintext gRPC service {string} method {string} with timeout {string} seconds and poll duration {string} seconds:` (JSON docstring) | Runs `function invoke --grpc --grpc-plaintext` with the visible service, method, request, timeout, and poll duration. | | `When I successfully invoke model {string} at {string} with timeout {string} seconds:` (JSON docstring) | Runs `function invoke` with the visible model, inference URL, exact request body, and timeout. | +| `When I successfully invoke the function selected by NVCF CLI through Vanity Gateway host {string} path {string} with timeout {string} seconds:` (JSON docstring) | Sends an exact-host HTTP request through the local Envoy listener with the saved function API key passed over sensitive stdin, never argv or command logs. | | `When I successfully undeploy the function selected by NVCF CLI` | Runs `function delete --deployment-only`. Function selection remains owned by CLI state. | ### Assertions (Then / And) diff --git a/tests/bdd/dsl/manifests.go b/tests/bdd/dsl/manifests.go index ff46039db..77b52f283 100644 --- a/tests/bdd/dsl/manifests.go +++ b/tests/bdd/dsl/manifests.go @@ -48,10 +48,10 @@ func RenderedManifestsContainResource(root string, resource KubernetesResource) return fmt.Errorf("rendered manifests directory is empty") } if resource.Kind == "" { - return fmt.Errorf("Kubernetes resource kind is empty") + return fmt.Errorf("kubernetes resource kind is empty") } if resource.Name == "" { - return fmt.Errorf("Kubernetes resource name is empty") + return fmt.Errorf("kubernetes resource name is empty") } info, err := os.Stat(root) if err != nil { diff --git a/tests/bdd/features/observability-all.feature b/tests/bdd/features/observability-all.feature index 2ba720f4d..5efb6eb86 100644 --- a/tests/bdd/features/observability-all.feature +++ b/tests/bdd/features/observability-all.feature @@ -22,7 +22,6 @@ Feature: Install local Helmfile observability for both planes | global.helm.sources.repository | ${SAMPLE_NGC_ORG}/${SAMPLE_NGC_TEAM} | | global.image.repository | ${SAMPLE_NGC_ORG}/${SAMPLE_NGC_TEAM} | | observability.profile | all | - | functionAutoscaler.image.tag | 1.18.10 | # Give the shared observability Helmfile the same named environment. And I prepare Helmfile environment "local-bdd-observability-all" for stack "observability" from fixture "tests/bdd/fixtures/self-managed-local-bdd.yaml" with values: | global.imagePullSecrets[0].name | nvcr-pull-secret | @@ -119,6 +118,7 @@ Feature: Install local Helmfile observability for both planes | victoria-metrics | monitoring | 1 | | otel-collector | monitoring | 1 | | default-monitors | monitoring | 1 | + | function-autoscaler | nvcf | 1 | | nvca-operator | nvca-operator | 1 | Then deployment "nvca-operator" in namespace "nvca-operator" using context "k3d-ncp-local" should complete rollout within "10m" @@ -148,3 +148,67 @@ Feature: Install local Helmfile observability for both planes enabled: true imageRepository: nvcr.io/${SAMPLE_NGC_ORG}/${SAMPLE_NGC_TEAM}/nvcf-otel-collector """ + + # Depends on the installed all-profile stack and registered compute plane + # from the preceding scenario. It is not a standalone tag target. + @function-autoscaler @function-lifecycle + Scenario: Autoscaler starts an idle function to serve its first request + Given I use NVCF CLI config "${REPO_ROOT}/tests/bdd/fixtures/nvcf-cli-local.yaml" + + When I successfully create function "bdd-autoscaled-load-tester-supreme" from image "nvcr.io/${SAMPLE_NGC_ORG}/${SAMPLE_NGC_TEAM}/load_tester_supreme:0.0.8" with CLI options: + | option | value | + | --inference-url | /echo | + | --inference-port | 8000 | + | --health-uri | /health | + | --health-port | 8000 | + | --health-timeout | PT30S | + + And I successfully deploy the function selected by NVCF CLI with options: + | option | value | + | --gpu | H100 | + | --instance-type | NCP.GPU.H100_1x | + | --backend | ncp-local | + | --regions | us-west-1 | + | --min-instances | 0 | + | --max-instances | 1 | + | --timeout | 900 | + + And I successfully generate a function API key with CLI options: + | option | value | + | --description | bdd-autoscaled-load-tester-supreme | + | --scopes | invoke_function,list_functions,queue_details,list_functions_details | + + # The invocation plane returns after its short hold-open window while the + # autoscaler and compute plane complete a cold start. A successful response + # or the expected 504 proves the first request reached the invocation path. + When I run command: + """ + ${NVCF_CLI} --config ${REPO_ROOT}/tests/bdd/fixtures/nvcf-cli-local.yaml function invoke --request-body '{"message":"bdd-autoscaler-echo","repeats":1}' --timeout 60 --poll-duration 5 + """ + Then the command output should contain one of: + | text | + | bdd-autoscaler-echo | + | API error 504 | + + When I successfully run command: + """ + /bin/bash -c 'set -euo pipefail + status="$("$1" --config "$2" status --json)" + function_id="$(jq -er ".currentFunction | select(.hasFunction == true) | .functionId" <<<"$status")" + version_id="$(jq -er ".currentFunction | select(.hasFunction == true) | .versionId" <<<"$status")" + for attempt in {1..120}; do + if "$1" --config "$2" cluster agent get-function "$function_id" "$version_id" --compute-plane-context "$3" --json | jq -e ".instanceCount == 1 and ([.instances[] | select((.status | ascii_downcase) == \"running\")] | length == 1)" >/dev/null; then + exit 0 + fi + sleep 5 + done + exit 1' bdd-autoscaler ${NVCF_CLI} ${REPO_ROOT}/tests/bdd/fixtures/nvcf-cli-local.yaml k3d-ncp-local + """ + + When I successfully invoke the function selected by NVCF CLI over HTTP with timeout "600" seconds and poll duration "5" seconds: + """ + {"message":"bdd-autoscaler-echo","repeats":1} + """ + Then the command output should contain "bdd-autoscaler-echo" + + And I successfully undeploy the function selected by NVCF CLI diff --git a/tests/bdd/features/observability-control.feature b/tests/bdd/features/observability-control.feature index 89d9d2341..b254bd1cc 100644 --- a/tests/bdd/features/observability-control.feature +++ b/tests/bdd/features/observability-control.feature @@ -19,7 +19,6 @@ Feature: Install local Helmfile observability with the control profile | global.helm.sources.repository | ${SAMPLE_NGC_ORG}/${SAMPLE_NGC_TEAM} | | global.image.repository | ${SAMPLE_NGC_ORG}/${SAMPLE_NGC_TEAM} | | observability.profile | control | - | functionAutoscaler.image.tag | 1.18.10 | # Set the shared observability stack environment. And I prepare Helmfile environment "local-bdd-observability-control" for stack "observability" from fixture "tests/bdd/fixtures/self-managed-local-bdd.yaml" with values: | global.imagePullSecrets[0].name | nvcr-pull-secret | diff --git a/tests/bdd/features/single-cluster-helmfile.feature b/tests/bdd/features/single-cluster-helmfile.feature index 3d420bcc4..d1e9c011f 100644 --- a/tests/bdd/features/single-cluster-helmfile.feature +++ b/tests/bdd/features/single-cluster-helmfile.feature @@ -2,8 +2,8 @@ Feature: Install a local single-cluster NVCF stack with Helmfile As a self-managed NVCF operator, I want to use the documented Helmfile workflow against a local k3d cluster, - so that I can install a single-cluster control plane with NVCA and the LLM - gateway add-on enabled. + so that I can install a single-cluster control plane with NVCA, the LLM + gateway, and Vanity Gateway add-ons enabled. Rule: Operator authors the local Helmfile environment file @@ -16,7 +16,7 @@ Feature: Install a local single-cluster NVCF stack with Helmfile # The fixture is a copy of deploy/stacks/self-managed/environments/local.yaml, # which already carries every ncp-local local-mode override (storageClass, # replica counts, NVCA self-managed endpoints, addons.llm.*, agentConfig, - # ingress.gatewayApi.*). The Background only overlays the operator-specific + # ingress.gatewayApi.*, addons.vanityGateway.*). The Background only overlays the operator-specific # values that vary per NGC org and pull-secret name. And I prepare Helmfile environment "local-bdd" for stack "self-managed" from fixture "tests/bdd/fixtures/self-managed-local-bdd.yaml" with values: | global.imagePullSecrets[0].name | nvcr-pull-secret | @@ -36,7 +36,7 @@ Feature: Install a local single-cluster NVCF stack with Helmfile Then the command exit code should be 0 And the command output should not contain "Error:" - Rule: Helmfile installs the local control plane with LLM gateway add-ons + Rule: Helmfile installs the local control plane with gateway add-ons Background: # This rule depends on the earlier environment-authoring @@ -90,6 +90,11 @@ Feature: Install a local single-cluster NVCF stack with Helmfile | ingress | envoy-gateway-system | | llm-request-router | nvcf | | llm-api-gateway | nvcf | + | vanity-gateway | nvcf | + + Then these Gateway API routes should be accepted and resolved using context "k3d-ncp-local" within "2m": + | kind | name | namespace | parent | + | HTTPRoute | vanity-gateway | envoy-gateway-system | shared-gw | When I run command "kubectl --context k3d-ncp-local get configmap/nvcf-api-remote-config -n nvcf -o yaml" Then the command exit code should be 0 @@ -166,7 +171,7 @@ Feature: Install a local single-cluster NVCF stack with Helmfile # earlier control-plane install and NVCA registration scenario in # this feature run, and is not a standalone tag target. @function-lifecycle - Scenario: Operator creates, deploys, and invokes the Load Tester Supreme sample function + Scenario: Operator invokes the Load Tester Supreme sample function through default and vanity endpoints Given I use NVCF CLI config "${REPO_ROOT}/tests/bdd/fixtures/nvcf-cli-local.yaml" When I successfully create function "bdd-load-tester-supreme" from image "nvcr.io/${SAMPLE_NGC_ORG}/${SAMPLE_NGC_TEAM}/load_tester_supreme:0.0.8" with CLI options: @@ -198,6 +203,40 @@ Feature: Install a local single-cluster NVCF stack with Helmfile """ Then the command output should contain "bdd-echo" + # Vanity Gateway mappings are Helm values, so read the function identity + # from the operator's CLI and apply only the mapped release. + When I run command: + """ + /bin/bash -c 'set -euo pipefail; "$1" --config "$2" status --json | jq -er ".currentFunction | select(.hasFunction == true) | .functionId"' bdd-vanity-function-id ${NVCF_CLI} ${REPO_ROOT}/tests/bdd/fixtures/nvcf-cli-local.yaml + """ + Then the command exit code should be 0 + And I export command output to environment variable "BDD_VANITY_FUNCTION_ID" + + When I run command: + """ + /bin/bash -c 'set -euo pipefail; "$1" --config "$2" status --json | jq -er ".currentFunction | select(.hasFunction == true) | .versionId"' bdd-vanity-version-id ${NVCF_CLI} ${REPO_ROOT}/tests/bdd/fixtures/nvcf-cli-local.yaml + """ + Then the command exit code should be 0 + And I export command output to environment variable "BDD_VANITY_VERSION_ID" + + And I update yaml file "deploy/stacks/self-managed/environments/local-bdd.yaml" with keys: + | addons.vanityGateway.mappingConfig.v2config.vanity.bdd.host | vanity.localhost | + | addons.vanityGateway.mappingConfig.v2config.vanity.bdd.paths.echo.path | /bdd/echo | + | addons.vanityGateway.mappingConfig.v2config.vanity.bdd.paths.echo.functionID | ${BDD_VANITY_FUNCTION_ID} | + | addons.vanityGateway.mappingConfig.v2config.vanity.bdd.paths.echo.functionVersionID | ${BDD_VANITY_VERSION_ID} | + | addons.vanityGateway.mappingConfig.v2config.vanity.bdd.paths.echo.outgoingPathOverride | /echo | + + When I successfully run command "k3d kubeconfig merge ncp-local --output ${REPO_ROOT}/tests/bdd/out/ncp-local-vanity-kubeconfig.yaml --overwrite --kubeconfig-switch-context=false" + And I successfully run command "make -C deploy/stacks/self-managed apply HELMFILE_ENV=local-bdd HELMFILE_SELECTOR=name=vanity-gateway KUBECONFIG_FILE=${REPO_ROOT}/tests/bdd/out/ncp-local-vanity-kubeconfig.yaml" + And I successfully run command "kubectl --context k3d-ncp-local rollout restart deployment/vanity-gateway --namespace nvcf" + Then deployment "vanity-gateway" in namespace "nvcf" using context "k3d-ncp-local" should complete rollout within "5m" + + When I successfully invoke the function selected by NVCF CLI through Vanity Gateway host "vanity.localhost" path "/bdd/echo" with timeout "120" seconds: + """ + {"message":"bdd-vanity-echo","repeats":1} + """ + Then the command output should contain "bdd-vanity-echo" + # Remove the deployment: the local sizing cannot hold every # scenario's deployment at once. And I successfully undeploy the function selected by NVCF CLI diff --git a/tests/bdd/fixtures/self-managed-local-bdd.yaml b/tests/bdd/fixtures/self-managed-local-bdd.yaml index 5b9aeddff..495788eb7 100644 --- a/tests/bdd/fixtures/self-managed-local-bdd.yaml +++ b/tests/bdd/fixtures/self-managed-local-bdd.yaml @@ -88,6 +88,9 @@ addons: "random": "random" } } + vanityGateway: + enabled: true + replicaCount: 1 # Gateway API Ingress (POC) # Application Developer role: Only HTTPRoutes diff --git a/tests/bdd/godog_test.go b/tests/bdd/godog_test.go index 970c26657..57b148239 100644 --- a/tests/bdd/godog_test.go +++ b/tests/bdd/godog_test.go @@ -417,13 +417,35 @@ func TestMultiClusterUpFeatureFileWiresToSteps(t *testing.T) { // so the I copy / I update yaml chain has a real source file. The // fake runner is pre-loaded with canned JSON for the Helm release assertion. func TestSingleClusterHelmfileFeatureFileWiresToSteps(t *testing.T) { + const vanityInvokeScript = `IFS= read -r api_key || [ -n "$api_key" ]; exec curl --silent --show-error --fail-with-body --header "Authorization: Bearer ${api_key}" "$@"` + const vanityFunctionIDCommand = `/bin/bash -c 'set -euo pipefail; "$1" --config "$2" status --json |` + + ` jq -er ".currentFunction | select(.hasFunction == true) | .functionId"'` + + ` bdd-vanity-function-id /usr/bin/nvcf-cli /repo-root-placeholder/tests/bdd/fixtures/nvcf-cli-local.yaml` + const vanityVersionIDCommand = `/bin/bash -c 'set -euo pipefail; "$1" --config "$2" status --json |` + + ` jq -er ".currentFunction | select(.hasFunction == true) | .versionId"'` + + ` bdd-vanity-version-id /usr/bin/nvcf-cli /repo-root-placeholder/tests/bdd/fixtures/nvcf-cli-local.yaml` + vanityInvokeCommand := dsl.BuildCommand( + "/bin/sh", "-c", vanityInvokeScript, "vanity-gateway-request", + "--request", "POST", + "--header", "Host: vanity.localhost", + "--header", "Content-Type: application/json", + "--data", `{"message":"bdd-vanity-echo","repeats":1}`, + "--retry", "24", + "--retry-all-errors", + "--retry-delay", "5", + "--retry-max-time", "120", + "--max-time", "120", + "http://127.0.0.1:8080/bdd/echo", + ) + home := t.TempDir() + t.Setenv("HOME", home) t.Setenv("NGC_API_KEY", "test-key") t.Setenv("SAMPLE_NGC_ORG", "test-org") t.Setenv("SAMPLE_NGC_TEAM", "test-team") t.Setenv("NVCF_CLI", "/usr/bin/nvcf-cli") t.Setenv("REPO_ROOT", "/repo-root-placeholder") suite := newWiringSuite(t, newFakeRunner(map[string]harness.Result{ - "helm list --all-namespaces --kube-context k3d-ncp-local -o json": {ExitCode: 0, Stdout: helmListAllNamespacesJSON()}, + "helm list --all-namespaces --kube-context k3d-ncp-local -o json": {ExitCode: 0, Stdout: helmListAllNamespacesWithVanityJSON()}, "kubectl --context k3d-ncp-local get configmap/nvcf-api-remote-config -n nvcf -o yaml": { ExitCode: 0, Stdout: "data:\n nvcf-api.yaml: |\n nvcf:\n sidecars:\n llm-router-client-image: nvcr.io/test-org/test-team/pylon:test\n", @@ -432,6 +454,18 @@ func TestSingleClusterHelmfileFeatureFileWiresToSteps(t *testing.T) { ExitCode: 0, Stdout: "Function invocation completed!\n\nResponse:\n{\"rawResponse\":\"bdd-echo\"}\n", }, + vanityFunctionIDCommand: { + ExitCode: 0, + Stdout: "function-1\n", + }, + vanityVersionIDCommand: { + ExitCode: 0, + Stdout: "version-1\n", + }, + vanityInvokeCommand: { + ExitCode: 0, + Stdout: "Function invocation completed!\n\nResponse:\n{\"rawResponse\":\"bdd-vanity-echo\"}\n", + }, "/usr/bin/nvcf-cli --config /repo-root-placeholder/tests/bdd/fixtures/nvcf-cli-local.yaml function invoke" + " --grpc --grpc-plaintext --grpc-service Echo --grpc-method EchoMessage" + " --request-body '{\"message\":\"bdd-grpc-echo\"}' --timeout 120 --poll-duration 5": { @@ -459,6 +493,13 @@ func TestSingleClusterHelmfileFeatureFileWiresToSteps(t *testing.T) { seedStackSecretsTemplate(t, suite.Config.RepoRoot) writeProfileHandoffArtifact(t, suite.Config.RepoRoot) writeHelmfileRegisterValues(t, suite.Config.RepoRoot) + if err := os.WriteFile( + filepath.Join(home, ".nvcf-cli.nvcf-cli-local.state"), + []byte(`{"apiKey":"wiring-function-api-key"}`), + 0o600, + ); err != nil { + t.Fatalf("write NVCF CLI state: %v", err) + } sc := steps.NewScenarioContext(suite) featurePath := mustResolveFeaturePath(t, "single-cluster-helmfile.feature") @@ -484,6 +525,18 @@ func TestSingleClusterHelmfileFeatureFileWiresToSteps(t *testing.T) { if !commandRanThatContains(suite.Runner.(*fakeRunner).runs, "function invoke") { t.Fatal("function invoke CLI command was never invoked") } + if !commandRanThatContainsAll(suite.Runner.(*fakeRunner).runs, + "curl --silent --show-error --fail-with-body", + "Host: vanity.localhost", + "http://127.0.0.1:8080/bdd/echo", + "bdd-vanity-echo") { + t.Fatal("Vanity Gateway exact-host request was never invoked") + } + if !commandRanThatContainsAll(suite.Runner.(*fakeRunner).runs, + "apply HELMFILE_ENV=local-bdd", + "HELMFILE_SELECTOR=name=vanity-gateway") { + t.Fatal("Vanity Gateway mapping was not applied through the targeted Helmfile release") + } if !commandRanThatContains(suite.Runner.(*fakeRunner).runs, "function invoke --grpc --grpc-plaintext") { t.Fatal("gRPC function invoke CLI command was never invoked") } @@ -698,14 +751,6 @@ func TestObservabilityControlFeatureFileWiresToSteps(t *testing.T) { if !commandRanThatContains(runs, "install HELMFILE_ENV=local-bdd-observability-control") { t.Fatal("control-profile Helmfile install command was never invoked") } - environmentPath := filepath.Join(suite.Config.RepoRoot, "deploy", "stacks", "self-managed", "environments", "local-bdd-observability-control.yaml") - imageTag, found, err := dsl.ReadYAMLKey(environmentPath, "functionAutoscaler.image.tag") - if err != nil { - t.Fatalf("read control-profile autoscaler image tag: %v", err) - } - if !found || imageTag != "1.18.10" { - t.Fatalf("control-profile autoscaler image tag = %q, found = %t; want 1.18.10", imageTag, found) - } } func observabilityControlHelmListJSON() string { @@ -815,7 +860,6 @@ func TestObservabilityComputeFeatureFileWiresToSteps(t *testing.T) { t.Fatalf("NGC API key leaked into command arguments: %s", run) } } - for _, stack := range []string{"self-managed", "observability", "nvcf-compute-plane"} { environmentPath := filepath.Join(suite.Config.RepoRoot, "deploy", "stacks", stack, "environments", "local-bdd-observability-compute.yaml") profile, found, err := dsl.ReadYAMLKey(environmentPath, "observability.profile") @@ -892,6 +936,14 @@ func TestObservabilityAllFeatureFileWiresToSteps(t *testing.T) { ExitCode: 0, Stdout: observabilityCollectorYAML(), }, + "/usr/bin/nvcf-cli --config /repo-root-placeholder/tests/bdd/fixtures/nvcf-cli-local.yaml function invoke --request-body '{\"message\":\"bdd-autoscaler-echo\",\"repeats\":1}' --timeout 60 --poll-duration 5": { + ExitCode: 1, + Stderr: "Error: failed to invoke function: API error 504:\n", + }, + "/usr/bin/nvcf-cli --config /repo-root-placeholder/tests/bdd/fixtures/nvcf-cli-local.yaml function invoke --request-body '{\"message\":\"bdd-autoscaler-echo\",\"repeats\":1}' --timeout 600 --poll-duration 5": { + ExitCode: 0, + Stdout: "Function invocation completed!\n\nResponse:\n{\"rawResponse\":\"bdd-autoscaler-echo\"}\n", + }, })) seedHelmfileLocalBDDFixture(t, suite.Config.RepoRoot) seedComputePlaneLocalBDDFixture(t, suite.Config.RepoRoot) @@ -951,6 +1003,19 @@ func TestObservabilityAllFeatureFileWiresToSteps(t *testing.T) { t.Fatalf("NGC API key leaked into command arguments: %s", run) } } + if !commandRanThatContainsAll(runs, + "function deploy create", + "--min-instances 0", + "--max-instances 1") { + t.Fatal("autoscaler smoke function was not deployed from zero with a one-instance ceiling") + } + if !commandRanThatContainsAll(runs, + "cluster agent get-function \"$function_id\" \"$version_id\"", + "--compute-plane-context \"$3\"", + "--json") { + t.Fatal("autoscaler smoke did not observe the selected function on the compute plane") + } + assertFunctionDeploymentsUseInstanceType(t, runs, "NCP.GPU.H100_1x", 1) for _, stack := range []string{"self-managed", "observability", "nvcf-compute-plane"} { environmentPath := filepath.Join(suite.Config.RepoRoot, "deploy", "stacks", stack, "environments", "local-bdd-observability-all.yaml") @@ -968,7 +1033,6 @@ func TestObservabilityAllFeatureFileWiresToSteps(t *testing.T) { key string want string }{ - {stack: "self-managed", key: "functionAutoscaler.image.tag", want: "1.18.10"}, {stack: "nvcf-compute-plane", key: "global.nvcaOperator.selfManaged.otelCollector.enabled", want: "true"}, } for _, assertion := range assertions { @@ -990,6 +1054,7 @@ func observabilityAllHelmListJSON() string { {"name":"victoria-metrics","namespace":"monitoring","revision":"1","status":"deployed"}, {"name":"otel-collector","namespace":"monitoring","revision":"1","status":"deployed"}, {"name":"default-monitors","namespace":"monitoring","revision":"1","status":"deployed"}, +{"name":"function-autoscaler","namespace":"nvcf","revision":"1","status":"deployed"}, {"name":"nvca-operator","namespace":"nvca-operator","revision":"1","status":"deployed"} ]` } @@ -1369,6 +1434,12 @@ func helmListAllNamespacesJSON() string { ]` } +func helmListAllNamespacesWithVanityJSON() string { + return strings.TrimSuffix(helmListAllNamespacesJSON(), "\n]") + `, +{"name":"vanity-gateway","namespace":"nvcf","status":"deployed"} +]` +} + // helmListNVCAJSON returns canned helm-list output for the // nvca-operator namespace. func helmListNVCAJSON() string { diff --git a/tests/bdd/steps/nvcf_cli_steps.go b/tests/bdd/steps/nvcf_cli_steps.go index 990d5b59b..b69c82c2d 100644 --- a/tests/bdd/steps/nvcf_cli_steps.go +++ b/tests/bdd/steps/nvcf_cli_steps.go @@ -19,17 +19,25 @@ package steps import ( "context" + "encoding/json" "fmt" + "os" + "path/filepath" "strings" "time" "github.com/cucumber/godog" "nvcf-bdd/dsl" + "nvcf-bdd/harness" ) var modelInvocationRetryInterval = time.Second +type cliAuthState struct { + APIKey string `json:"apiKey"` +} + func registerNVCFCLISteps(ctx *godog.ScenarioContext, sc *ScenarioContext) { ctx.Step(`^I use NVCF CLI config "([^"]*)"$`, sc.iUseNVCFCLIConfig) ctx.Step(`^I successfully create function "([^"]*)" from image "([^"]*)" with CLI options:$`, sc.iSuccessfullyCreateFunction) @@ -38,6 +46,7 @@ func registerNVCFCLISteps(ctx *godog.ScenarioContext, sc *ScenarioContext) { ctx.Step(`^I successfully invoke the function selected by NVCF CLI over HTTP with timeout "([^"]*)" seconds and poll duration "([^"]*)" seconds:$`, sc.iSuccessfullyInvokeFunctionHTTP) ctx.Step(`^I successfully invoke the function selected by NVCF CLI over plaintext gRPC service "([^"]*)" method "([^"]*)" with timeout "([^"]*)" seconds and poll duration "([^"]*)" seconds:$`, sc.iSuccessfullyInvokeFunctionGRPC) ctx.Step(`^I successfully invoke model "([^"]*)" at "([^"]*)" with timeout "([^"]*)" seconds:$`, sc.iSuccessfullyInvokeModel) + ctx.Step(`^I successfully invoke the function selected by NVCF CLI through Vanity Gateway host "([^"]*)" path "([^"]*)" with timeout "([^"]*)" seconds:$`, sc.iSuccessfullyInvokeFunctionThroughVanityGateway) ctx.Step(`^I successfully undeploy the function selected by NVCF CLI$`, sc.iSuccessfullyUndeploySelectedFunction) } @@ -62,7 +71,14 @@ func (sc *ScenarioContext) iSuccessfullyDeploySelectedFunction(ctx context.Conte } func (sc *ScenarioContext) iSuccessfullyGenerateFunctionAPIKey(ctx context.Context, table *godog.Table) error { - return sc.runNVCFCLIWithOptions(ctx, []string{"api-key", "generate", "--for", "function"}, table) + options, err := nvcfCLIOptions(table) + if err != nil { + return err + } + return sc.runNVCFCLISuppressingStdout( + ctx, + append([]string{"api-key", "generate", "--for", "function"}, options...)..., + ) } func (sc *ScenarioContext) iSuccessfullyInvokeFunctionHTTP( @@ -157,6 +173,88 @@ func (sc *ScenarioContext) iSuccessfullyInvokeModel( } } +func (sc *ScenarioContext) iSuccessfullyInvokeFunctionThroughVanityGateway( + ctx context.Context, + host, + inferenceURL, + timeout string, + doc *godog.DocString, +) error { + apiKey, err := currentFunctionAPIKey(sc.NVCFCLIConfig) + if err != nil { + return err + } + if strings.ContainsAny(apiKey, "\r\n\x00") { + return fmt.Errorf("saved function API key contains an invalid control character") + } + + // nvcf-cli prefixes INVOKE_HOST with the selected function ID for normal + // function invocations. Vanity Gateway routes use the exact configured host, + // so send this smoke request directly through the local Envoy listener. The + // shell reads the key from stdin to keep it out of argv and command logs. + // Envoy can briefly return an HTTP error while a just-rolled gateway backend + // propagates; retries remain bounded by the scenario timeout. + script := `IFS= read -r api_key || [ -n "$api_key" ]; exec curl --silent --show-error --fail-with-body --header "Authorization: Bearer ${api_key}" "$@"` + command := dsl.BuildCommand( + "/bin/sh", "-c", script, "vanity-gateway-request", + "--request", "POST", + "--header", "Host: "+dsl.Interpolate(host), + "--header", "Content-Type: application/json", + "--data", dsl.Interpolate(doc.Content), + "--retry", "24", + "--retry-all-errors", + "--retry-delay", "5", + "--retry-max-time", dsl.Interpolate(timeout), + "--max-time", dsl.Interpolate(timeout), + "http://127.0.0.1:8080"+dsl.Interpolate(inferenceURL), + ) + if err := sc.runResolvedAndRecordWith( + ctx, + command, + func(runCtx context.Context, resolved string) (harness.Result, error) { + return sc.Suite.Runner.RunWithSensitiveStdin(runCtx, resolved, apiKey) + }, + ); err != nil { + return err + } + return sc.commandExitCodeShouldBe(0) +} + +func currentFunctionAPIKey(configName string) (string, error) { + statePath, err := nvcfCLIStatePath(configName) + if err != nil { + return "", err + } + body, err := os.ReadFile(statePath) + if err != nil { + return "", fmt.Errorf("read NVCF CLI state: %w", err) + } + var state cliAuthState + if err := json.Unmarshal(body, &state); err != nil { + return "", fmt.Errorf("parse NVCF CLI state: %w", err) + } + state.APIKey = strings.TrimSpace(state.APIKey) + if state.APIKey == "" { + return "", fmt.Errorf("NVCF CLI state does not contain a function API key") + } + return state.APIKey, nil +} + +func nvcfCLIStatePath(configName string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + contextName := filepath.Base(strings.TrimSpace(configName)) + if extension := filepath.Ext(contextName); extension != "" { + contextName = strings.TrimSuffix(contextName, extension) + } + if contextName == "" || contextName == "default" || contextName == ".nvcf-cli" { + return filepath.Join(home, ".nvcf-cli.state"), nil + } + return filepath.Join(home, ".nvcf-cli."+contextName+".state"), nil +} + func (sc *ScenarioContext) iSuccessfullyUndeploySelectedFunction(ctx context.Context) error { return sc.runNVCFCLI(ctx, "function", "delete", "--deployment-only") } @@ -174,6 +272,18 @@ func (sc *ScenarioContext) runNVCFCLIWithOptions( } func (sc *ScenarioContext) runNVCFCLI(ctx context.Context, args ...string) error { + return sc.runResolvedSuccessfully(ctx, dsl.BuildCommand(sc.nvcfCLICommandArgs(args...)...)) +} + +func (sc *ScenarioContext) runNVCFCLISuppressingStdout(ctx context.Context, args ...string) error { + commandArgs := append( + []string{"/bin/sh", "-c", `exec "$@" >/dev/null`, "nvcf-cli"}, + sc.nvcfCLICommandArgs(args...)..., + ) + return sc.runResolvedSuccessfully(ctx, dsl.BuildCommand(commandArgs...)) +} + +func (sc *ScenarioContext) nvcfCLICommandArgs(args ...string) []string { commandArgs := make([]string, 0, len(args)+3) commandArgs = append(commandArgs, dsl.Interpolate("${NVCF_CLI}"), @@ -183,7 +293,7 @@ func (sc *ScenarioContext) runNVCFCLI(ctx context.Context, args ...string) error for _, arg := range args { commandArgs = append(commandArgs, dsl.Interpolate(arg)) } - return sc.runResolvedSuccessfully(ctx, dsl.BuildCommand(commandArgs...)) + return commandArgs } func nvcfCLIOptions(table *godog.Table) ([]string, error) { diff --git a/tests/bdd/steps/nvcf_cli_steps_test.go b/tests/bdd/steps/nvcf_cli_steps_test.go index d93a2753f..606773131 100644 --- a/tests/bdd/steps/nvcf_cli_steps_test.go +++ b/tests/bdd/steps/nvcf_cli_steps_test.go @@ -20,6 +20,8 @@ package steps import ( "context" "errors" + "os" + "path/filepath" "strings" "testing" "time" @@ -120,6 +122,76 @@ func TestNVCFCLIInvocationAdaptersExposeAllArguments(t *testing.T) { } } +func TestVanityGatewayInvocationUsesExactHostAndKeepsAPIKeyOutOfCommand(t *testing.T) { + sc, fake := newScenarioContext(t) + home := t.TempDir() + t.Setenv("HOME", home) + sc.NVCFCLIConfig = "config.yaml" + const apiKey = "sensitive-function-api-key" + statePath := filepath.Join(home, ".nvcf-cli.config.state") + if err := os.WriteFile(statePath, []byte(`{"apiKey":"`+apiKey+`"}`), 0o600); err != nil { + t.Fatalf("write CLI state: %v", err) + } + fake.result = harness.Result{ExitCode: 0, Stdout: `{"rawResponse":"vanity"}`} + + err := sc.iSuccessfullyInvokeFunctionThroughVanityGateway( + context.Background(), + "vanity.localhost", + "/bdd/echo", + "120", + &godog.DocString{Content: `{"message":"vanity"}`}, + ) + if err != nil { + t.Fatalf("invoke through Vanity Gateway: %v", err) + } + if len(fake.runs) != 1 { + t.Fatalf("runs = %+v, want one request", fake.runs) + } + run := fake.runs[0] + for _, expected := range []string{ + "curl --silent --show-error --fail-with-body", + "Host: vanity.localhost", + "Content-Type: application/json", + `{"message":"vanity"}`, + "--retry 24 --retry-all-errors --retry-delay 5 --retry-max-time 120", + "http://127.0.0.1:8080/bdd/echo", + } { + if !strings.Contains(run.command, expected) { + t.Fatalf("command = %q, want %q", run.command, expected) + } + } + if strings.Contains(run.command, apiKey) { + t.Fatalf("command contains function API key: %q", run.command) + } + if run.sensitiveStdin != apiKey { + t.Fatalf("sensitive stdin length = %d, want %d", len(run.sensitiveStdin), len(apiKey)) + } +} + +func TestFunctionAPIKeyGenerationSuppressesSecretBearingStdout(t *testing.T) { + sc, fake := newScenarioContext(t) + t.Setenv("NVCF_CLI", "nvcf-cli") + sc.NVCFCLIConfig = "config.yaml" + fake.result = harness.Result{ExitCode: 0} + options := docTable(t, [][]string{ + {"option", "value"}, + {"--description", "bdd key"}, + }) + + if err := sc.iSuccessfullyGenerateFunctionAPIKey(context.Background(), options); err != nil { + t.Fatalf("generate function API key: %v", err) + } + if len(fake.runs) != 1 { + t.Fatalf("runs = %+v, want one request", fake.runs) + } + command := fake.runs[0].command + for _, expected := range []string{"/bin/sh -c", `exec "$@" >/dev/null`, "api-key generate --for function", "--description 'bdd key'"} { + if !strings.Contains(command, expected) { + t.Fatalf("command = %q, want %q", command, expected) + } + } +} + func TestNVCFCLIModelInvocationRetriesNoEligibleCandidates(t *testing.T) { sc, fake := newScenarioContext(t) t.Setenv("NVCF_CLI", "nvcf-cli") From 47feef79e174da375ccbe9a2ac160f3ce407fe93 Mon Sep 17 00:00:00 2001 From: Stephanie Baum Date: Mon, 31 Aug 2026 08:18:15 -0700 Subject: [PATCH 2/2] test(bdd): address autoscaler and vanity review feedback --- tests/bdd/features/observability-all.feature | 47 ++++++++++++------- .../features/single-cluster-helmfile.feature | 43 +++++++---------- tests/bdd/godog_test.go | 11 +++++ tests/bdd/steps/nvcf_cli_steps.go | 2 + 4 files changed, 60 insertions(+), 43 deletions(-) diff --git a/tests/bdd/features/observability-all.feature b/tests/bdd/features/observability-all.feature index 5efb6eb86..895ff0d68 100644 --- a/tests/bdd/features/observability-all.feature +++ b/tests/bdd/features/observability-all.feature @@ -61,41 +61,36 @@ Feature: Install local Helmfile observability for both planes | monitoring | Scenario: All profile installs one shared stack with both monitor families - When I run command: + When I successfully run command: """ make -C deploy/stacks/self-managed install HELMFILE_ENV=local-bdd-observability-all KUBECONFIG_FILE=${REPO_ROOT}/tests/bdd/out/ncp-local-observability-all-kubeconfig.yaml """ - Then the command exit code should be 0 - When I run command: + When I successfully run command: """ env KUBECONFIG=${REPO_ROOT}/tests/bdd/out/ncp-local-observability-all-kubeconfig.yaml ${NVCF_CLI} --config ${REPO_ROOT}/tests/bdd/fixtures/nvcf-cli-local.yaml self-hosted --control-plane-stack deploy/stacks/self-managed --env local-bdd-observability-all control-plane profile export --cluster-name ncp-local """ - Then the command exit code should be 0 - And file "deploy/stacks/self-managed/out/control-plane-profile.yaml" should exist + Then file "deploy/stacks/self-managed/out/control-plane-profile.yaml" should exist - When I run command: + When I successfully run command: """ ${NVCF_CLI} --config ${REPO_ROOT}/tests/bdd/fixtures/nvcf-cli-local.yaml init """ - Then the command exit code should be 0 - When I run command: + When I successfully run command: """ make -C deploy/stacks/nvcf-compute-plane register-cluster CLUSTER_NAME=ncp-local CONTROL_PLANE_PROFILE=${REPO_ROOT}/deploy/stacks/self-managed/out/control-plane-profile.yaml COMPUTE_KUBE_CONTEXT=k3d-ncp-local KUBECONFIG_FILE=${REPO_ROOT}/tests/bdd/out/ncp-local-observability-all-kubeconfig.yaml NVCF_CLI=${NVCF_CLI} NVCF_CLI_CONFIG=${REPO_ROOT}/tests/bdd/fixtures/nvcf-cli-local.yaml """ - Then the command exit code should be 0 - And file "deploy/stacks/nvcf-compute-plane/registration/ncp-local-register-values.yaml" should exist + Then file "deploy/stacks/nvcf-compute-plane/registration/ncp-local-register-values.yaml" should exist And yaml file "deploy/stacks/nvcf-compute-plane/registration/ncp-local-register-values.yaml" should have non-empty keys: | key | | clusterID | | clusterGroupID | - When I run command: + When I successfully run command: """ make -C deploy/stacks/nvcf-compute-plane install CLUSTER_NAME=ncp-local HELMFILE_ENV=local-bdd-observability-all KUBECONFIG_FILE=${REPO_ROOT}/tests/bdd/out/ncp-local-observability-all-kubeconfig.yaml NVCF_CLI=${NVCF_CLI} NVCF_CLI_CONFIG=${REPO_ROOT}/tests/bdd/fixtures/nvcf-cli-local.yaml """ - Then the command exit code should be 0 # Self-hosted NVCA intentionally creates an empty NGC service-key secret. # Supply the existing local credential so the NVCA collector can start, @@ -149,8 +144,9 @@ Feature: Install local Helmfile observability for both planes imageRepository: nvcr.io/${SAMPLE_NGC_ORG}/${SAMPLE_NGC_TEAM}/nvcf-otel-collector """ - # Depends on the installed all-profile stack and registered compute plane - # from the preceding scenario. It is not a standalone tag target. + # The autoscaler is enabled by both control and all profiles. This functional + # smoke stays in all because it requires the registered compute plane from + # the preceding scenario. It is not a standalone tag target. @function-autoscaler @function-lifecycle Scenario: Autoscaler starts an idle function to serve its first request Given I use NVCF CLI config "${REPO_ROOT}/tests/bdd/fixtures/nvcf-cli-local.yaml" @@ -178,6 +174,21 @@ Feature: Install local Helmfile observability for both planes | --description | bdd-autoscaled-load-tester-supreme | | --scopes | invoke_function,list_functions,queue_details,list_functions_details | + # Prove the function is idle before the first request creates demand. The + # compute-plane CLI lists only scheduled functions, so no matching entry + # also represents zero instances. + # TODO(https://github.com/NVIDIA/nvcf/issues/1419): replace this shell + # command with a selected-function instance-count DSL assertion. + When I successfully run command: + """ + /bin/bash -c 'set -euo pipefail + status="$("$1" --config "$2" status --json)" + function_id="$(jq -er ".currentFunction | select(.hasFunction == true) | .functionId" <<<"$status")" + version_id="$(jq -er ".currentFunction | select(.hasFunction == true) | .versionId" <<<"$status")" + functions="$("$1" --config "$2" cluster agent list-functions --compute-plane-context "$3" --kubeconfig "$4" --json)" + jq -e --arg function_id "$function_id" --arg version_id "$version_id" "[.[] | select(.functionId == \$function_id and .functionVersionId == \$version_id)] | all(.instanceCount == 0)" <<<"$functions" >/dev/null' bdd-autoscaler-zero ${NVCF_CLI} ${REPO_ROOT}/tests/bdd/fixtures/nvcf-cli-local.yaml k3d-ncp-local ${REPO_ROOT}/tests/bdd/out/ncp-local-observability-all-kubeconfig.yaml + """ + # The invocation plane returns after its short hold-open window while the # autoscaler and compute plane complete a cold start. A successful response # or the expected 504 proves the first request reached the invocation path. @@ -190,6 +201,8 @@ Feature: Install local Helmfile observability for both planes | bdd-autoscaler-echo | | API error 504 | + # TODO(https://github.com/NVIDIA/nvcf/issues/1419): replace this polling + # loop with a selected-function instance-readiness DSL assertion. When I successfully run command: """ /bin/bash -c 'set -euo pipefail @@ -197,15 +210,15 @@ Feature: Install local Helmfile observability for both planes function_id="$(jq -er ".currentFunction | select(.hasFunction == true) | .functionId" <<<"$status")" version_id="$(jq -er ".currentFunction | select(.hasFunction == true) | .versionId" <<<"$status")" for attempt in {1..120}; do - if "$1" --config "$2" cluster agent get-function "$function_id" "$version_id" --compute-plane-context "$3" --json | jq -e ".instanceCount == 1 and ([.instances[] | select((.status | ascii_downcase) == \"running\")] | length == 1)" >/dev/null; then + if "$1" --config "$2" cluster agent get-function "$function_id" "$version_id" --compute-plane-context "$3" --kubeconfig "$4" --json | jq -e ".instanceCount == 1 and ([.instances[] | select((.status | ascii_downcase) == \"running\")] | length == 1)" >/dev/null; then exit 0 fi sleep 5 done - exit 1' bdd-autoscaler ${NVCF_CLI} ${REPO_ROOT}/tests/bdd/fixtures/nvcf-cli-local.yaml k3d-ncp-local + exit 1' bdd-autoscaler ${NVCF_CLI} ${REPO_ROOT}/tests/bdd/fixtures/nvcf-cli-local.yaml k3d-ncp-local ${REPO_ROOT}/tests/bdd/out/ncp-local-observability-all-kubeconfig.yaml """ - When I successfully invoke the function selected by NVCF CLI over HTTP with timeout "600" seconds and poll duration "5" seconds: + And I successfully invoke the function selected by NVCF CLI over HTTP with timeout "600" seconds and poll duration "5" seconds: """ {"message":"bdd-autoscaler-echo","repeats":1} """ diff --git a/tests/bdd/features/single-cluster-helmfile.feature b/tests/bdd/features/single-cluster-helmfile.feature index d1e9c011f..7126a2af3 100644 --- a/tests/bdd/features/single-cluster-helmfile.feature +++ b/tests/bdd/features/single-cluster-helmfile.feature @@ -31,10 +31,9 @@ Feature: Install a local single-cluster NVCF stack with Helmfile And I prepare self-managed secrets file "deploy/stacks/self-managed/secrets/local-bdd-secrets.yaml" from template "deploy/stacks/self-managed/secrets/secrets.yaml.template" using the current NGC registry credential Scenario: Operator validates the authored Helmfile environment renders - When I run command "make -C deploy/stacks/self-managed template HELMFILE_ENV=local-bdd" + When I successfully run command "make -C deploy/stacks/self-managed template HELMFILE_ENV=local-bdd" - Then the command exit code should be 0 - And the command output should not contain "Error:" + Then the command output should not contain "Error:" Rule: Helmfile installs the local control plane with gateway add-ons @@ -66,9 +65,7 @@ Feature: Install a local single-cluster NVCF stack with Helmfile @llm-gateway Scenario: Operator installs the control plane through the local Helmfile environment - When I run command "make -C deploy/stacks/self-managed install HELMFILE_ENV=local-bdd" - - Then the command exit code should be 0 + When I successfully run command "make -C deploy/stacks/self-managed install HELMFILE_ENV=local-bdd" Then these Helm releases should be deployed using context "k3d-ncp-local": | name | namespace | @@ -96,9 +93,8 @@ Feature: Install a local single-cluster NVCF stack with Helmfile | kind | name | namespace | parent | | HTTPRoute | vanity-gateway | envoy-gateway-system | shared-gw | - When I run command "kubectl --context k3d-ncp-local get configmap/nvcf-api-remote-config -n nvcf -o yaml" - Then the command exit code should be 0 - And the command output should contain "llm-router-client-image: nvcr.io/${SAMPLE_NGC_ORG}/${SAMPLE_NGC_TEAM}/pylon:" + When I successfully run command "kubectl --context k3d-ncp-local get configmap/nvcf-api-remote-config -n nvcf -o yaml" + Then the command output should contain "llm-router-client-image: nvcr.io/${SAMPLE_NGC_ORG}/${SAMPLE_NGC_TEAM}/pylon:" Rule: Helmfile installs NVCA on the same local cluster after registration via the stack Makefile @@ -114,25 +110,22 @@ Feature: Install a local single-cluster NVCF stack with Helmfile @nvca-registration Scenario: Operator registers the local cluster and installs the NVCA operator - When I run command: + When I successfully run command: """ ${NVCF_CLI} --config ${REPO_ROOT}/tests/bdd/fixtures/nvcf-cli-local.yaml self-hosted --control-plane-stack deploy/stacks/self-managed --env local-bdd control-plane profile export --cluster-name ncp-local """ - Then the command exit code should be 0 - And file "deploy/stacks/self-managed/out/control-plane-profile.yaml" should exist + Then file "deploy/stacks/self-managed/out/control-plane-profile.yaml" should exist - When I run command: + When I successfully run command: """ ${NVCF_CLI} --config ${REPO_ROOT}/tests/bdd/fixtures/nvcf-cli-local.yaml init """ - Then the command exit code should be 0 - When I run command: + When I successfully run command: """ make -C deploy/stacks/nvcf-compute-plane register-cluster CLUSTER_NAME=ncp-local CONTROL_PLANE_PROFILE=${REPO_ROOT}/deploy/stacks/self-managed/out/control-plane-profile.yaml COMPUTE_KUBE_CONTEXT=k3d-ncp-local NVCF_CLI=${NVCF_CLI} NVCF_CLI_CONFIG=${REPO_ROOT}/tests/bdd/fixtures/nvcf-cli-local.yaml """ - Then the command exit code should be 0 - And file "deploy/stacks/nvcf-compute-plane/registration/ncp-local-register-values.yaml" should exist + Then file "deploy/stacks/nvcf-compute-plane/registration/ncp-local-register-values.yaml" should exist # The target cluster matches controlPlane.clusterName in the exported # profile, so registration selects the in-cluster service endpoints. And yaml file "deploy/stacks/nvcf-compute-plane/registration/ncp-local-register-values.yaml" should contain: @@ -151,11 +144,10 @@ Feature: Install a local single-cluster NVCF stack with Helmfile | clusterID | | clusterGroupID | - When I run command: + When I successfully run command: """ make -C deploy/stacks/nvcf-compute-plane install CLUSTER_NAME=ncp-local HELMFILE_ENV=local-bdd NVCF_CLI=${NVCF_CLI} NVCF_CLI_CONFIG=${REPO_ROOT}/tests/bdd/fixtures/nvcf-cli-local.yaml """ - Then the command exit code should be 0 Then these Helm releases should be deployed using context "k3d-ncp-local": | name | namespace | @@ -205,18 +197,18 @@ Feature: Install a local single-cluster NVCF stack with Helmfile # Vanity Gateway mappings are Helm values, so read the function identity # from the operator's CLI and apply only the mapped release. - When I run command: + # TODO(https://github.com/NVIDIA/nvcf/issues/1419): replace these shell + # commands with selected-function identity DSL steps. + When I successfully run command: """ /bin/bash -c 'set -euo pipefail; "$1" --config "$2" status --json | jq -er ".currentFunction | select(.hasFunction == true) | .functionId"' bdd-vanity-function-id ${NVCF_CLI} ${REPO_ROOT}/tests/bdd/fixtures/nvcf-cli-local.yaml """ - Then the command exit code should be 0 And I export command output to environment variable "BDD_VANITY_FUNCTION_ID" - When I run command: + When I successfully run command: """ /bin/bash -c 'set -euo pipefail; "$1" --config "$2" status --json | jq -er ".currentFunction | select(.hasFunction == true) | .versionId"' bdd-vanity-version-id ${NVCF_CLI} ${REPO_ROOT}/tests/bdd/fixtures/nvcf-cli-local.yaml """ - Then the command exit code should be 0 And I export command output to environment variable "BDD_VANITY_VERSION_ID" And I update yaml file "deploy/stacks/self-managed/environments/local-bdd.yaml" with keys: @@ -324,12 +316,11 @@ Feature: Install a local single-cluster NVCF stack with Helmfile # curl reports only the status code so the assertion cannot # match response-body noise. - When I run command: + When I successfully run command: """ curl -s -o /dev/null -w "%{http_code}" -X POST http://llm.localhost:8080/v1/chat/completions -H "Content-Type: application/json" -d '{"model":"unauthenticated/check","messages":[]}' """ - Then the command exit code should be 0 - And the command output should contain "401" + Then the command output should contain "401" # Leave the GPU capacity free, same as the echo scenarios. And I successfully undeploy the function selected by NVCF CLI diff --git a/tests/bdd/godog_test.go b/tests/bdd/godog_test.go index 57b148239..a349d8aa4 100644 --- a/tests/bdd/godog_test.go +++ b/tests/bdd/godog_test.go @@ -1009,9 +1009,20 @@ func TestObservabilityAllFeatureFileWiresToSteps(t *testing.T) { "--max-instances 1") { t.Fatal("autoscaler smoke function was not deployed from zero with a one-instance ceiling") } + if !commandRanThatContainsAll(runs, + "cluster agent list-functions", + "--compute-plane-context \"$3\"", + "--kubeconfig \"$4\"", + "ncp-local-observability-all-kubeconfig.yaml", + "all(.instanceCount == 0)") { + t.Fatal("autoscaler smoke did not prove the selected function started at zero instances") + } if !commandRanThatContainsAll(runs, "cluster agent get-function \"$function_id\" \"$version_id\"", "--compute-plane-context \"$3\"", + "--kubeconfig \"$4\"", + "ncp-local-observability-all-kubeconfig.yaml", + ".instanceCount == 1", "--json") { t.Fatal("autoscaler smoke did not observe the selected function on the compute plane") } diff --git a/tests/bdd/steps/nvcf_cli_steps.go b/tests/bdd/steps/nvcf_cli_steps.go index b69c82c2d..debf1d36c 100644 --- a/tests/bdd/steps/nvcf_cli_steps.go +++ b/tests/bdd/steps/nvcf_cli_steps.go @@ -192,6 +192,8 @@ func (sc *ScenarioContext) iSuccessfullyInvokeFunctionThroughVanityGateway( // function invocations. Vanity Gateway routes use the exact configured host, // so send this smoke request directly through the local Envoy listener. The // shell reads the key from stdin to keep it out of argv and command logs. + // TODO(https://github.com/NVIDIA/nvcf/issues/1399): replace this curl path + // with first-class nvcf-cli Vanity Gateway invocation support. // Envoy can briefly return an HTTP error while a just-rolled gateway backend // propagates; retries remain bounded by the scenario timeout. script := `IFS= read -r api_key || [ -n "$api_key" ]; exec curl --silent --show-error --fail-with-body --header "Authorization: Bearer ${api_key}" "$@"`