diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index f0f1fb1237..36712d1890 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -55,6 +55,18 @@ on: type: boolean default: false +env: + # Pinned versions of the tooling the test scripts drive from the runner. + # helm used to be installed on the test node by tests/scripts/prerequisites.sh + # straight from the helm get-helm-3 master script, i.e. whatever release was + # current at the time the job ran. Pinning it here is a deliberate change. + HELM_VERSION: v3.19.0 + # Kept in sync with spec.kubernetes.version in tests/holodeck.yaml. + KUBECTL_VERSION: v1.35.4 + JQ_VERSION: "1.7.1" + # From https://github.com/jqlang/jq/releases/download/jq-1.7.1/sha256sum.txt + JQ_SHA256: "5942c9b0934e510ee61eb3e30273f1b3fe2590df93933a93d7c58b81d19c8ff5" + jobs: variables: uses: ./.github/workflows/variables.yaml @@ -79,6 +91,11 @@ jobs: permissions: contents: read id-token: write + env: + # Holodeck is configured with kubernetes.remoteAccess, so it drops a + # kubeconfig pointing at the node's public API server endpoint here. + KUBECONFIG: ${{ github.workspace }}/kubeconfig + LOG_DIR: ${{ github.workspace }}/logs steps: - uses: actions/checkout@v7 name: Check out code @@ -90,6 +107,26 @@ jobs: with: name: values-overrides path: ${{ github.workspace }} + - name: Install helm + uses: azure/setup-helm@v4 + with: + version: ${{ env.HELM_VERSION }} + - name: Install kubectl + uses: azure/setup-kubectl@v4 + with: + version: ${{ env.KUBECTL_VERSION }} + - name: Install jq and verify runner tooling + run: | + set -euo pipefail + mkdir -p "${RUNNER_TEMP}/bin" + curl -fsSL -o "${RUNNER_TEMP}/bin/jq" \ + "https://github.com/jqlang/jq/releases/download/jq-${JQ_VERSION}/jq-linux-amd64" + echo "${JQ_SHA256} ${RUNNER_TEMP}/bin/jq" | sha256sum -c - + chmod +x "${RUNNER_TEMP}/bin/jq" + echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH" + helm version + kubectl version --client + "${RUNNER_TEMP}/bin/jq" --version - name: Set up Holodeck uses: NVIDIA/holodeck@v0.3.7 with: @@ -102,43 +139,79 @@ jobs: uses: mikefarah/yq@v4 with: cmd: yq '.status.properties[] | select(.name == "public-dns-name") | .value' /github/workspace/.cache/holodeck.yaml - - name: Set test environment + - name: Configure node SSH access env: + AWS_SSH_KEY: ${{ secrets.AWS_SSH_KEY }} PUBLIC_DNS_NAME: ${{ steps.get_public_dns_name.outputs.result }} run: | - echo "instance_hostname=ubuntu@${PUBLIC_DNS_NAME}" >> $GITHUB_ENV - echo "private_key=${{ github.workspace }}/key.pem" >> $GITHUB_ENV - - name: Write SSH key - env: - AWS_SSH_KEY: ${{ secrets.AWS_SSH_KEY }} + set -euo pipefail + test -n "${PUBLIC_DNS_NAME}" + SSH_DIR="${RUNNER_TEMP}/holodeck-ssh" + mkdir -p "${SSH_DIR}" + chmod 700 "${SSH_DIR}" + install -m 600 /dev/null "${SSH_DIR}/id_rsa" + printf '%s\n' "${AWS_SSH_KEY}" > "${SSH_DIR}/id_rsa" + install -m 600 /dev/null "${SSH_DIR}/known_hosts" + echo "NODE_SSH_HOST=ubuntu@${PUBLIC_DNS_NAME}" >> "$GITHUB_ENV" + echo "NODE_SSH_KEY=${SSH_DIR}/id_rsa" >> "$GITHUB_ENV" + echo "NODE_SSH_KNOWN_HOSTS=${SSH_DIR}/known_hosts" >> "$GITHUB_ENV" + - name: Verify cluster access run: | - echo "${AWS_SSH_KEY}" > ${private_key} && chmod 400 ${private_key} - - name: Copy values override file to remote + set -euo pipefail + test -r "${KUBECONFIG}" + kubectl cluster-info + kubectl get nodes -o wide + - name: Select values override file if: ${{ inputs.use_values_override }} run: | - scp -i ${private_key} -o StrictHostKeyChecking=no \ - ${{ github.workspace }}/values-overrides.yaml \ - ${instance_hostname}:/tmp/values-overrides.yaml - echo "VALUES_FILE=/tmp/values-overrides.yaml" >> $GITHUB_ENV + set -euo pipefail + echo "VALUES_FILE=${GITHUB_WORKSPACE}/values-overrides.yaml" >> "$GITHUB_ENV" + - name: Load kernel modules on the node + run: | + set -euo pipefail + # node-exec.sh falls back to running the operation locally when + # NODE_SSH_HOST is empty, which is the developer path. In CI that + # would run modprobe against the shared runner, so fail loudly + # instead. + test -n "${NODE_SSH_HOST}" + ./tests/scripts/node-exec.sh load-modules - name: Run e2e tests env: OPERATOR_VERSION: ${{ needs.variables.outputs.operator_version }} OPERATOR_IMAGE: ${{ needs.variables.outputs.operator_image }} GPU_PRODUCT_NAME: "Tesla-T4" - SKIP_LAUNCH: "true" CONTAINER_RUNTIME: "containerd" - TEST_CASE: "./tests/cases/defaults.sh" run: | - ./tests/ci-run-e2e.sh ${OPERATOR_IMAGE} ${OPERATOR_VERSION} ${GPU_PRODUCT_NAME} ${TEST_CASE} || rc=$? - ./tests/scripts/pull.sh /tmp/logs logs - exit $rc + set -euo pipefail + # The restart-operator test shells out to node-exec.sh, which runs + # locally when NODE_SSH_HOST is empty. Refuse to start rather than + # let crictl run against the shared runner. + test -n "${NODE_SSH_HOST}" + mkdir -p "${LOG_DIR}" + ./tests/cases/defaults.sh + - name: Collect cluster diagnostics + if: always() + run: | + mkdir -p "${LOG_DIR}" + kubectl get nodes -o wide > "${LOG_DIR}/nodes.txt" 2>&1 || true + kubectl get pods -A -o wide > "${LOG_DIR}/pods.txt" 2>&1 || true + kubectl get events -A --sort-by=.lastTimestamp > "${LOG_DIR}/events.txt" 2>&1 || true + helm list -A > "${LOG_DIR}/helm-releases.txt" 2>&1 || true - name: Archive test logs - if: ${{ failure() }} + # always() rather than failure(): a job stopped by timeout-minutes is + # cancelled, not failed, so failure() would skip the upload on exactly + # the runs whose logs we most need. + if: always() uses: actions/upload-artifact@v7 with: name: containerd-e2e-test-logs path: ./logs/ retention-days: 15 + - name: Remove credentials from the runner + if: always() + run: | + rm -rf "${RUNNER_TEMP}/holodeck-ssh" + rm -f "${KUBECONFIG}" e2e-tests-nvidiadriver: needs: [variables, publish-helm-oci-chart] @@ -147,6 +220,11 @@ jobs: permissions: contents: read id-token: write + env: + # Holodeck is configured with kubernetes.remoteAccess, so it drops a + # kubeconfig pointing at the node's public API server endpoint here. + KUBECONFIG: ${{ github.workspace }}/kubeconfig + LOG_DIR: ${{ github.workspace }}/logs steps: - uses: actions/checkout@v7 name: Check out code @@ -158,6 +236,26 @@ jobs: with: name: values-overrides path: ${{ github.workspace }} + - name: Install helm + uses: azure/setup-helm@v4 + with: + version: ${{ env.HELM_VERSION }} + - name: Install kubectl + uses: azure/setup-kubectl@v4 + with: + version: ${{ env.KUBECTL_VERSION }} + - name: Install jq and verify runner tooling + run: | + set -euo pipefail + mkdir -p "${RUNNER_TEMP}/bin" + curl -fsSL -o "${RUNNER_TEMP}/bin/jq" \ + "https://github.com/jqlang/jq/releases/download/jq-${JQ_VERSION}/jq-linux-amd64" + echo "${JQ_SHA256} ${RUNNER_TEMP}/bin/jq" | sha256sum -c - + chmod +x "${RUNNER_TEMP}/bin/jq" + echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH" + helm version + kubectl version --client + "${RUNNER_TEMP}/bin/jq" --version - name: Set up Holodeck uses: NVIDIA/holodeck@v0.3.7 with: @@ -170,40 +268,76 @@ jobs: uses: mikefarah/yq@v4 with: cmd: yq '.status.properties[] | select(.name == "public-dns-name") | .value' /github/workspace/.cache/holodeck.yaml - - name: Set test environment + - name: Configure node SSH access env: + AWS_SSH_KEY: ${{ secrets.AWS_SSH_KEY }} PUBLIC_DNS_NAME: ${{ steps.get_public_dns_name.outputs.result }} run: | - echo "instance_hostname=ubuntu@${PUBLIC_DNS_NAME}" >> $GITHUB_ENV - echo "private_key=${{ github.workspace }}/key.pem" >> $GITHUB_ENV - - name: Write SSH key - env: - AWS_SSH_KEY: ${{ secrets.AWS_SSH_KEY }} + set -euo pipefail + test -n "${PUBLIC_DNS_NAME}" + SSH_DIR="${RUNNER_TEMP}/holodeck-ssh" + mkdir -p "${SSH_DIR}" + chmod 700 "${SSH_DIR}" + install -m 600 /dev/null "${SSH_DIR}/id_rsa" + printf '%s\n' "${AWS_SSH_KEY}" > "${SSH_DIR}/id_rsa" + install -m 600 /dev/null "${SSH_DIR}/known_hosts" + echo "NODE_SSH_HOST=ubuntu@${PUBLIC_DNS_NAME}" >> "$GITHUB_ENV" + echo "NODE_SSH_KEY=${SSH_DIR}/id_rsa" >> "$GITHUB_ENV" + echo "NODE_SSH_KNOWN_HOSTS=${SSH_DIR}/known_hosts" >> "$GITHUB_ENV" + - name: Verify cluster access run: | - echo "${AWS_SSH_KEY}" > ${private_key} && chmod 400 ${private_key} - - name: Copy values override file to remote + set -euo pipefail + test -r "${KUBECONFIG}" + kubectl cluster-info + kubectl get nodes -o wide + - name: Select values override file if: ${{ inputs.use_values_override }} run: | - scp -i ${private_key} -o StrictHostKeyChecking=no \ - ${{ github.workspace }}/values-overrides.yaml \ - ${instance_hostname}:/tmp/values-overrides.yaml - echo "VALUES_FILE=/tmp/values-overrides.yaml" >> $GITHUB_ENV + set -euo pipefail + echo "VALUES_FILE=${GITHUB_WORKSPACE}/values-overrides.yaml" >> "$GITHUB_ENV" + - name: Load kernel modules on the node + run: | + set -euo pipefail + # node-exec.sh falls back to running the operation locally when + # NODE_SSH_HOST is empty, which is the developer path. In CI that + # would run modprobe against the shared runner, so fail loudly + # instead. + test -n "${NODE_SSH_HOST}" + ./tests/scripts/node-exec.sh load-modules - name: Run e2e tests env: OPERATOR_VERSION: ${{ needs.variables.outputs.operator_version }} OPERATOR_IMAGE: ${{ needs.variables.outputs.operator_image }} GPU_PRODUCT_NAME: "Tesla-T4" - SKIP_LAUNCH: "true" CONTAINER_RUNTIME: "containerd" - TEST_CASE: "./tests/cases/nvidia-driver.sh" run: | - ./tests/ci-run-e2e.sh ${OPERATOR_IMAGE} ${OPERATOR_VERSION} ${GPU_PRODUCT_NAME} ${TEST_CASE} || rc=$? - ./tests/scripts/pull.sh /tmp/logs logs - exit $rc + set -euo pipefail + # The restart-operator test shells out to node-exec.sh, which runs + # locally when NODE_SSH_HOST is empty. Refuse to start rather than + # let crictl run against the shared runner. + test -n "${NODE_SSH_HOST}" + mkdir -p "${LOG_DIR}" + ./tests/cases/nvidia-driver.sh + - name: Collect cluster diagnostics + if: always() + run: | + mkdir -p "${LOG_DIR}" + kubectl get nodes -o wide > "${LOG_DIR}/nodes.txt" 2>&1 || true + kubectl get pods -A -o wide > "${LOG_DIR}/pods.txt" 2>&1 || true + kubectl get events -A --sort-by=.lastTimestamp > "${LOG_DIR}/events.txt" 2>&1 || true + helm list -A > "${LOG_DIR}/helm-releases.txt" 2>&1 || true - name: Archive test logs - if: ${{ failure() }} + # always() rather than failure(): a job stopped by timeout-minutes is + # cancelled, not failed, so failure() would skip the upload on exactly + # the runs whose logs we most need. + if: always() uses: actions/upload-artifact@v7 with: name: nvidiadriver-e2e-test-logs path: ./logs/ retention-days: 15 + - name: Remove credentials from the runner + if: always() + run: | + rm -rf "${RUNNER_TEMP}/holodeck-ssh" + rm -f "${KUBECONFIG}" diff --git a/tests/README.md b/tests/README.md index 9b14de2462..3a4096ce5c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,5 +1,22 @@ # GPU operator test utilities +## Testing in CI +CI no longer uses `local.sh` or `ci-run-e2e.sh`, and no longer syncs the project +folder to the test instance. Those remain the developer path described below. + +Instead, the e2e workflow provisions a Holodeck environment with +`kubernetes.remoteAccess` enabled, which gives the GitHub Actions runner a +kubeconfig for the cluster. The case scripts (`cases/defaults.sh`, +`cases/nvidia-driver.sh`) then run on the runner itself, and everything they do +-- helm, kubectl, log collection -- goes over that kubeconfig. + +Only two operations still need a shell on the instance, and both go through +`scripts/node-exec.sh`, which dispatches `scripts/node-operations.sh` over SSH: +loading the `i2c_core` and `ipmi_msghandler` kernel modules, and killing the +gpu-operator container for the operator restart test. With `NODE_SSH_HOST` +unset, `node-exec.sh` runs the operation locally, so the developer path below is +unaffected. + ## Testing locally The `local.sh` script allows for triggering basic end-to-end testing of the GPU operator from a local machine. diff --git a/tests/holodeck.yaml b/tests/holodeck.yaml index 3050eaf680..880aa417a6 100644 --- a/tests/holodeck.yaml +++ b/tests/holodeck.yaml @@ -24,3 +24,4 @@ spec: version: v1.35.4 crictlVersion: v1.35.0 calicoVersion: v3.31.5 + remoteAccess: true diff --git a/tests/scripts/.definitions.sh b/tests/scripts/.definitions.sh index f422c31adf..fa9af4c2cc 100644 --- a/tests/scripts/.definitions.sh +++ b/tests/scripts/.definitions.sh @@ -14,6 +14,7 @@ TERRAFORM="terraform -chdir=${TERRAFORM_DIR}" # Set default values if not defined : ${HELM:="helm"} +: ${KUBECTL_REQUEST_TIMEOUT:="15s"} : ${LOG_DIR:="/tmp/logs"} : ${PROJECT:="$(basename "${PROJECT_DIR}")"} : ${TEST_NAMESPACE:="test-operator"} diff --git a/tests/scripts/checks.sh b/tests/scripts/checks.sh index 5056d48b2f..492a90b792 100755 --- a/tests/scripts/checks.sh +++ b/tests/scripts/checks.sh @@ -2,10 +2,10 @@ check_pod_ready() { local pod_label=$1 - local current_time=0 + local deadline=$((SECONDS + 60 * 45)) while :; do echo "Checking $pod_label pod" - kubectl get pods -lapp=$pod_label -n ${TEST_NAMESPACE} + kubectl get pods -lapp=$pod_label -n ${TEST_NAMESPACE} || true echo "Checking $pod_label pod readiness" is_pod_ready=$(kubectl get pods -lapp=$pod_label -n ${TEST_NAMESPACE} -ojsonpath='{range .items[*]}{.status.conditions[?(@.type=="Ready")].status}{"\n"}{end}' 2>/dev/null || echo "terminated") @@ -21,30 +21,35 @@ check_pod_ready() { fi fi - if [[ "${current_time}" -gt $((60 * 45)) ]]; then + if (( SECONDS > deadline )); then echo "timeout reached" exit 1; fi # Echo useful information on stdout - kubectl get pods -n ${TEST_NAMESPACE} + kubectl get pods -n ${TEST_NAMESPACE} || true echo "Sleeping 5 seconds" - current_time=$((${current_time} + 5)) sleep 5 done } check_pod_deleted() { local pod_label=$1 - local current_time=0 + local deadline=$((SECONDS + 60 * 45)) + local pod_list while :; do echo "Checking $pod_label pod" - kubectl get pods -lapp=$pod_label -n ${TEST_NAMESPACE} + kubectl get pods -lapp=$pod_label -n ${TEST_NAMESPACE} || true echo "Checking if $pod_label pod has been deleted" - # note: $(kubectl get pods -o jsonpath='.items' | jq length) does not work for older kubectl clients - num_pods=$(kubectl get pods -lapp=$pod_label -n ${TEST_NAMESPACE} -o json | jq '.items' | jq length) + # Leave the count empty when the query itself fails, so that an + # unreachable API is never read as the pod having been deleted. + if pod_list=$(kubectl get pods -lapp=$pod_label -n ${TEST_NAMESPACE} --no-headers); then + num_pods=$(echo "${pod_list}" | grep -c . || true) + else + num_pods="" + fi if [ "${num_pods}" = 0 ]; then echo "Pod $pod_label has been deleted" @@ -53,16 +58,15 @@ check_pod_deleted() { echo "Pod $pod_label has not been deleted" fi - if [[ "${current_time}" -gt $((60 * 45)) ]]; then + if (( SECONDS > deadline )); then echo "timeout reached" exit 1; fi # Echo useful information on stdout - kubectl get pods -n ${TEST_NAMESPACE} + kubectl get pods -n ${TEST_NAMESPACE} || true echo "Sleeping 5 seconds" - current_time=$((${current_time} + 5)) sleep 5 done } @@ -72,7 +76,7 @@ check_no_restarts() { restartCount=$(kubectl get pod -lapp=$pod_label -n ${TEST_NAMESPACE} -o jsonpath='{.items[*].status.containerStatuses[0].restartCount}') if [ $restartCount -gt 1 ]; then echo "$pod_label restarted multiple times: $restartCount" - kubectl logs -p -lapp=$pod_label --all-containers -n ${TEST_NAMESPACE} + kubectl logs -p -lapp=$pod_label --all-containers -n ${TEST_NAMESPACE} || true exit 1 fi echo "Repeated restarts not observed for pod $pod_label" @@ -85,14 +89,11 @@ test_restart_operator() { local ns=${1} local runtime=${2} - if [[ x"${runtime}" == x"containerd" ]]; then - # The operator is the only container that has the string '"gpu-operator"' - # TODO: This requires permissions on containerd.sock - sudo crictl rm --force "$(sudo crictl ps --name gpu-operator | awk '{if(NR>1)print $1}')" - else - # The operator is the only container that has the string '"gpu-operator"' - docker kill "$(docker ps --format '{{.ID}} {{.Command}}' | grep "gpu-operator" | cut -f 1 -d ' ')" - fi + # Killing the operator container mutates the node, so it is dispatched to the + # node itself. node-exec.sh runs it either over SSH or locally. + local checks_script_dir + checks_script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + "${checks_script_dir}"/node-exec.sh restart-operator-container "${runtime}" for i in $(seq 1 10); do # Sleep a reasonable amount of time for k8s to update the container status to crashing @@ -112,54 +113,78 @@ test_restart_operator() { exit 1 } +# Every pod in the cluster as " " lines. custom-columns keeps +# this to a few hundred bytes; the equivalent -o json is hundreds of kilobytes. +list_all_pods() { + kubectl get pods --all-namespaces -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name --no-headers || true +} + +# Regenerate the describe and log files for the pods listed by list_all_pods. +# The log files are rewritten in full rather than tailed, so that the artifact +# left behind holds the complete output of every container. +collect_pod_logs() { + local log_dir=$1 + local pods=$2 + local ns pod + + while read -r ns pod; do + [[ -n "${pod}" ]] || continue + echo "Generating logs for pod: ${pod} ns: ${ns}" + echo "------------------------------------------------" >> "${log_dir}/${pod}.describe" + kubectl -n "${ns}" describe pods "${pod}" >> "${log_dir}/${pod}.describe" || true + kubectl -n "${ns}" logs "${pod}" --all-containers=true > "${log_dir}/${pod}.logs" || true + done <<< "${pods}" +} + check_gpu_pod_ready() { local log_dir=$1 - local current_time=0 + local deadline=$((SECONDS + 60 * 45)) + # Regenerating every pod's describe and log files is the expensive part of + # this loop, so it runs on its own slower cadence while the readiness check + # below keeps polling every 5 seconds. + local next_collection=0 # Ensure the log directory exists mkdir -p ${log_dir} while :; do - pods="$(kubectl get --all-namespaces pods -o json | jq '.items[] | {name: .metadata.name, ns: .metadata.namespace}' | jq -s -c .)" - status=$(kubectl get pods gpu-operator-test -o json | jq -r .status.phase) + status=$(kubectl get pods gpu-operator-test -o jsonpath='{.status.phase}' || true) if [ "${status}" = "Succeeded" ]; then echo "GPU pod terminated successfully" rc=0 + collect_pod_logs "${log_dir}" "$(list_all_pods)" break; fi - if [[ "${current_time}" -gt $((60 * 45)) ]]; then + if (( SECONDS > deadline )); then echo "timeout reached" + # Collect once more so that the artifact reflects the state at the + # timeout rather than the state at the last scheduled collection. + collect_pod_logs "${log_dir}" "$(list_all_pods)" exit 1 fi - # Echo useful information on stdout - kubectl get pods --all-namespaces - - for pod in $(echo "$pods" | jq -r .[].name); do - ns=$(echo "$pods" | jq -r ".[] | select(.name == \"$pod\") | .ns") - echo "Generating logs for pod: ${pod} ns: ${ns}" - echo "------------------------------------------------" >> "${log_dir}/${pod}.describe" - kubectl -n "${ns}" describe pods "${pod}" >> "${log_dir}/${pod}.describe" - kubectl -n "${ns}" logs "${pod}" --all-containers=true > "${log_dir}/${pod}.logs" || true - done - + # Echo useful information on stdout and record it at the same time echo "Generating cluster logs" echo "------------------------------------------------" >> "${log_dir}/cluster.logs" - kubectl get --all-namespaces pods >> "${log_dir}/cluster.logs" + kubectl get pods --all-namespaces | tee -a "${log_dir}/cluster.logs" || true + + if (( SECONDS >= next_collection )); then + collect_pod_logs "${log_dir}" "$(list_all_pods)" + next_collection=$((SECONDS + 30)) + fi echo "Sleeping 5 seconds" - current_time=$((${current_time} + 5)) sleep 5; done } # TODO: deduplicate the logic found in this file by moving the duplicate to a common method and parameterizing the labels to select on check_nvidia_driver_pods_ready() { - local current_time=0 + local deadline=$((SECONDS + 60 * 45)) while :; do echo "Checking nvidia driver pod" - kubectl get pods -l "app.kubernetes.io/component=nvidia-driver" -n ${TEST_NAMESPACE} + kubectl get pods -l "app.kubernetes.io/component=nvidia-driver" -n ${TEST_NAMESPACE} || true echo "Checking nvidia driver pod readiness" is_pod_ready=$(kubectl get pods -l "app.kubernetes.io/component=nvidia-driver" -n ${TEST_NAMESPACE} -ojsonpath='{range .items[*]}{.status.conditions[?(@.type=="Ready")].status}{"\n"}{end}' 2>/dev/null || echo "terminated") @@ -175,16 +200,15 @@ check_nvidia_driver_pods_ready() { fi fi - if [[ "${current_time}" -gt $((60 * 45)) ]]; then + if (( SECONDS > deadline )); then echo "timeout reached" exit 1; fi # Echo useful information on stdout - kubectl get pods -n ${TEST_NAMESPACE} + kubectl get pods -n ${TEST_NAMESPACE} || true echo "Sleeping 5 seconds" - current_time=$((${current_time} + 5)) sleep 5 done } @@ -193,34 +217,46 @@ check_no_driver_pod_restarts() { restartCount=$(kubectl get pod -l "app.kubernetes.io/component=nvidia-driver" -n ${TEST_NAMESPACE} -o jsonpath='{.items[*].status.containerStatuses[0].restartCount}') if [ $restartCount -gt 1 ]; then echo "nvidia driver pod restarted multiple times: $restartCount" - kubectl logs -p -l "app.kubernetes.io/component=nvidia-driver" --all-containers -n ${TEST_NAMESPACE} + kubectl logs -p -l "app.kubernetes.io/component=nvidia-driver" --all-containers -n ${TEST_NAMESPACE} || true exit 1 fi echo "Repeated restarts not observed for the nvidia driver pod" return 0 } +# Report that the cluster API could not be reached. The tests now run from a +# runner outside the cluster, so any call can fail while the node restarts its +# container runtime during a driver upgrade. Callers treat this as "not ready +# yet" and keep retrying until their own deadline expires. The timestamp is +# here so that a later run can show whether such an outage is brief or +# permanent. +api_unreachable() { + echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') WARNING: cluster API unreachable while ${1}; treating as not ready and retrying" +} + +# Purely diagnostic. Every call here is guarded so that a debug dump can never +# be the thing that ends the run. print_driver_upgrade_debug() { echo "current state of driver upgrade" - kubectl get node -l nvidia.com/gpu.present \ - -o custom-columns=NODE:.metadata.name,OWNER:.metadata.labels.nvidia\\.com/gpu-operator\\.driver\\.owner,UPGRADE_STATE:.metadata.labels.nvidia\\.com/gpu-driver-upgrade-state --no-headers + kubectl get node -l nvidia.com/gpu.present --request-timeout="${KUBECTL_REQUEST_TIMEOUT}" \ + -o custom-columns=NODE:.metadata.name,OWNER:.metadata.labels.nvidia\\.com/gpu-operator\\.driver\\.owner,UPGRADE_STATE:.metadata.labels.nvidia\\.com/gpu-driver-upgrade-state --no-headers || true echo "" echo "driver pods" - kubectl get pods -l "app.kubernetes.io/component=nvidia-driver" -n ${TEST_NAMESPACE} -o wide || true + kubectl get pods -l "app.kubernetes.io/component=nvidia-driver" -n ${TEST_NAMESPACE} -o wide --request-timeout="${KUBECTL_REQUEST_TIMEOUT}" || true echo "" echo "gpu operator operands" - kubectl get pods -n ${TEST_NAMESPACE} -o wide || true + kubectl get pods -n ${TEST_NAMESPACE} -o wide --request-timeout="${KUBECTL_REQUEST_TIMEOUT}" || true echo "" echo "driver daemonsets" - kubectl get daemonsets -l "app.kubernetes.io/component=nvidia-driver" -n ${TEST_NAMESPACE} -o wide || true + kubectl get daemonsets -l "app.kubernetes.io/component=nvidia-driver" -n ${TEST_NAMESPACE} -o wide --request-timeout="${KUBECTL_REQUEST_TIMEOUT}" || true echo "" echo "NVIDIADriver status" local nvidiadriver_status - if nvidiadriver_status=$(kubectl get nvidiadriver -o json 2>/dev/null); then + if nvidiadriver_status=$(kubectl get nvidiadriver -o json --request-timeout="${KUBECTL_REQUEST_TIMEOUT}" 2>/dev/null); then echo "${nvidiadriver_status}" | jq -r ' (["NAME", "DEFAULT", "STATE", "REASON", "MESSAGE"] | @tsv), ( @@ -241,39 +277,75 @@ print_driver_upgrade_debug() { } wait_for_driver_upgrade_done() { - gpu_node_count=$(kubectl get node -l nvidia.com/gpu.present --no-headers | wc -l) - local current_time=0 + local deadline=$((SECONDS + 60 * 45)) + # Time at which the next full debug dump is due. Iterations can take much + # longer than the nominal sleep, so track a due time rather than testing the + # elapsed time for divisibility, which would skip dumps entirely. + local next_debug=0 + local node_list="" + local upgraded_count=0 + local upgrade_state="" + + # The driver upgrade restarts the container runtime on the node, so from a + # runner outside the cluster every query below can fail for a while. A failed + # query means "not upgraded yet" and is retried until the deadline; it must + # never be read as the upgrade having finished. + gpu_node_count="" + echo "waiting for the gpu driver upgrade to complete" while :; do - local upgraded_count=0 - for node in $(kubectl get nodes -o NAME); do - upgrade_state=$(kubectl get $node -ojsonpath='{.metadata.labels.nvidia\.com/gpu-driver-upgrade-state}') - if [ "${upgrade_state}" = "upgrade-done" ]; then - upgraded_count=$((${upgraded_count} + 1)) + upgraded_count=0 + + # Resolve the expected node count once and keep it. Re-reading it every + # iteration would risk sampling a transient count while the upgrade churns + # node labels, which could make the comparison below succeed early. + if [[ -z "${gpu_node_count}" ]]; then + if node_list=$(kubectl get node -l nvidia.com/gpu.present --no-headers --request-timeout="${KUBECTL_REQUEST_TIMEOUT}"); then + gpu_node_count=$(echo "${node_list}" | grep -c . || true) + else + api_unreachable "counting the GPU nodes" fi - done - if [[ $upgraded_count -eq $gpu_node_count ]]; then + fi + + if node_list=$(kubectl get nodes -o NAME --request-timeout="${KUBECTL_REQUEST_TIMEOUT}"); then + for node in ${node_list}; do + if upgrade_state=$(kubectl get "$node" -ojsonpath='{.metadata.labels.nvidia\.com/gpu-driver-upgrade-state}' --request-timeout="${KUBECTL_REQUEST_TIMEOUT}"); then + if [ "${upgrade_state}" = "upgrade-done" ]; then + upgraded_count=$((upgraded_count + 1)) + fi + else + api_unreachable "reading the upgrade state of ${node}" + fi + done + else + api_unreachable "listing the nodes" + fi + + # The node count guard keeps an unreachable API from being mistaken for a + # finished upgrade, which is what comparing 0 against an empty count would + # otherwise do. + if [[ -n "${gpu_node_count}" ]] && [[ $upgraded_count -eq $gpu_node_count ]]; then echo "gpu driver upgrade completed successfully" break; else - echo "gpu driver still in progress. $upgraded_count/$gpu_node_count node(s) upgraded" + echo "gpu driver still in progress. $upgraded_count/${gpu_node_count:-unknown} node(s) upgraded" fi - if [[ "${current_time}" -gt $((60 * 45)) ]]; then + if (( SECONDS > deadline )); then echo "timeout reached" print_driver_upgrade_debug exit 1; fi - if [[ $((current_time % 30)) -eq 0 ]]; then + if (( SECONDS >= next_debug )); then print_driver_upgrade_debug + next_debug=$((SECONDS + 30)) else - kubectl get node -l nvidia.com/gpu.present \ - -o custom-columns=NODE:.metadata.name,OWNER:.metadata.labels.nvidia\\.com/gpu-operator\\.driver\\.owner,UPGRADE_STATE:.metadata.labels.nvidia\\.com/gpu-driver-upgrade-state --no-headers + kubectl get node -l nvidia.com/gpu.present --request-timeout="${KUBECTL_REQUEST_TIMEOUT}" \ + -o custom-columns=NODE:.metadata.name,OWNER:.metadata.labels.nvidia\\.com/gpu-operator\\.driver\\.owner,UPGRADE_STATE:.metadata.labels.nvidia\\.com/gpu-driver-upgrade-state --no-headers || true fi echo "Sleeping 5 seconds" - current_time=$((${current_time} + 5)) sleep 5 done } diff --git a/tests/scripts/migrate-clusterpolicy-to-nvidiadriver.sh b/tests/scripts/migrate-clusterpolicy-to-nvidiadriver.sh index 8cbaae72c5..14be3937bc 100755 --- a/tests/scripts/migrate-clusterpolicy-to-nvidiadriver.sh +++ b/tests/scripts/migrate-clusterpolicy-to-nvidiadriver.sh @@ -23,7 +23,7 @@ get_helm_release_name() { } wait_for_legacy_driver_daemonset_deleted() { - local elapsed_time=0 + local deadline=$((SECONDS + 300)) echo "Waiting for ClusterPolicy-owned driver DaemonSet to be deleted" while :; do @@ -32,20 +32,19 @@ wait_for_legacy_driver_daemonset_deleted() { break fi - if [[ "${elapsed_time}" -gt 300 ]]; then + if (( SECONDS > deadline )); then echo "timeout reached waiting for legacy driver DaemonSet deletion" - kubectl get daemonset -n "${TEST_NAMESPACE}" -o wide + kubectl get daemonset -n "${TEST_NAMESPACE}" -o wide || true exit 1 fi sleep 5 - elapsed_time=$((${elapsed_time} + 5)) done } wait_for_orphaned_legacy_driver_pod() { local pod_name=$1 - local elapsed_time=0 + local deadline=$((SECONDS + 300)) echo "Waiting for legacy driver pod/${pod_name} to become orphaned" while :; do @@ -55,19 +54,18 @@ wait_for_orphaned_legacy_driver_pod() { break fi - if [[ "${elapsed_time}" -gt 300 ]]; then + if (( SECONDS > deadline )); then echo "timeout reached waiting for legacy driver pod to become orphaned" - kubectl get pod "${pod_name}" -n "${TEST_NAMESPACE}" -o yaml + kubectl get pod "${pod_name}" -n "${TEST_NAMESPACE}" -o yaml || true exit 1 fi sleep 5 - elapsed_time=$((${elapsed_time} + 5)) done } wait_for_default_nvidiadriver() { - local elapsed_time=0 + local deadline=$((SECONDS + 300)) echo "Waiting for default NVIDIADriver to be rendered" while :; do @@ -76,20 +74,19 @@ wait_for_default_nvidiadriver() { break fi - if [[ "${elapsed_time}" -gt 300 ]]; then + if (( SECONDS > deadline )); then echo "timeout reached waiting for default NVIDIADriver" kubectl get nvidiadriver || true exit 1 fi sleep 5 - elapsed_time=$((${elapsed_time} + 5)) done } wait_for_nvidiadriver_owner_labels() { local driver_name=$1 - local elapsed_time=0 + local deadline=$((SECONDS + 300)) local gpu_node_count gpu_node_count=$(kubectl get node -l nvidia.com/gpu.present=true --no-headers | wc -l) @@ -101,21 +98,20 @@ wait_for_nvidiadriver_owner_labels() { break fi - if [[ "${elapsed_time}" -gt 300 ]]; then + if (( SECONDS > deadline )); then echo "timeout reached waiting for NVIDIADriver owner labels" kubectl get nodes -l nvidia.com/gpu.present=true -o json | - jq -r '.items[] | [.metadata.name, (.metadata.labels["nvidia.com/gpu-operator.driver.owner"] // "-")] | @tsv' + jq -r '.items[] | [.metadata.name, (.metadata.labels["nvidia.com/gpu-operator.driver.owner"] // "-")] | @tsv' || true exit 1 fi sleep 5 - elapsed_time=$((${elapsed_time} + 5)) done } wait_for_nvidiadriver_daemonset() { local driver_name=$1 - local elapsed_time=0 + local deadline=$((SECONDS + 300)) echo "Waiting for NVIDIADriver-owned driver DaemonSet" while :; do @@ -125,20 +121,19 @@ wait_for_nvidiadriver_daemonset() { break fi - if [[ "${elapsed_time}" -gt 300 ]]; then + if (( SECONDS > deadline )); then echo "timeout reached waiting for NVIDIADriver-owned driver DaemonSet" - kubectl get daemonset -n "${TEST_NAMESPACE}" -o yaml + kubectl get daemonset -n "${TEST_NAMESPACE}" -o yaml || true exit 1 fi sleep 5 - elapsed_time=$((${elapsed_time} + 5)) done } wait_for_legacy_driver_pod_deleted() { local pod_name=$1 - local elapsed_time=0 + local deadline=$((SECONDS + 300)) echo "Waiting for orphaned legacy driver pod/${pod_name} to be deleted by the upgrade flow" while :; do @@ -146,7 +141,7 @@ wait_for_legacy_driver_pod_deleted() { break fi - if [[ "${elapsed_time}" -gt 300 ]]; then + if (( SECONDS > deadline )); then echo "timeout reached waiting for orphaned legacy driver pod deletion" print_driver_upgrade_debug kubectl get pod "${pod_name}" -n "${TEST_NAMESPACE}" -o yaml || true @@ -155,21 +150,20 @@ wait_for_legacy_driver_pod_deleted() { print_driver_upgrade_debug sleep 5 - elapsed_time=$((${elapsed_time} + 5)) done } legacy_driver_pod=$(kubectl get pod -l app=nvidia-driver-daemonset -n "${TEST_NAMESPACE}" -o jsonpath='{.items[0].metadata.name}') if [[ -z "${legacy_driver_pod}" ]]; then echo "legacy ClusterPolicy driver pod not found" - kubectl get pods -n "${TEST_NAMESPACE}" -o wide + kubectl get pods -n "${TEST_NAMESPACE}" -o wide || true exit 1 fi operator_name=$(get_helm_release_name) if [[ -z "${operator_name}" ]]; then echo "GPU Operator Helm release not found in namespace ${TEST_NAMESPACE}" - ${HELM} list -n "${TEST_NAMESPACE}" + ${HELM} list -n "${TEST_NAMESPACE}" || true exit 1 fi diff --git a/tests/scripts/node-exec.sh b/tests/scripts/node-exec.sh new file mode 100755 index 0000000000..27b7a5cf1d --- /dev/null +++ b/tests/scripts/node-exec.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash + +# Copyright NVIDIA CORPORATION +# +# 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. + +set -euo pipefail + +# Usage: node-exec.sh [args...] +# +# Runs one of the node-operations.sh operations on the node hosting the cluster. +# When NODE_SSH_HOST is set the operation is streamed to the node over SSH; +# otherwise it is executed locally, which is the developer path where the tests +# already run on the node itself. The operation names and their arguments are +# documented by node-operations.sh, which also reports them on bad input. +# +# Environment: +# NODE_SSH_HOST user@host of the node, e.g. ubuntu@ec2-1-2-3-4.compute.amazonaws.com. +# If unset or empty the operation is executed locally. +# NODE_SSH_KEY Path to the private key. Required when NODE_SSH_HOST is set. +# NODE_SSH_KNOWN_HOSTS Path to the known_hosts file. Required when NODE_SSH_HOST is set. + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +NODE_OPERATIONS="${SCRIPT_DIR}/node-operations.sh" + +require_readable_file() { + local name="${1}" + local value="${2}" + + if [[ -z "${value}" ]]; then + echo "Error: ${name} must be set when NODE_SSH_HOST is set" >&2 + exit 1 + fi + if [[ ! -r "${value}" ]]; then + echo "Error: ${name} '${value}' does not exist or is not readable" >&2 + exit 1 + fi +} + +if [[ -z "${NODE_SSH_HOST:-}" ]]; then + echo "Running '$*' locally" + exec bash "${NODE_OPERATIONS}" "$@" +fi + +require_readable_file "NODE_SSH_KEY" "${NODE_SSH_KEY:-}" +require_readable_file "NODE_SSH_KNOWN_HOSTS" "${NODE_SSH_KNOWN_HOSTS:-}" + +# Quote the arguments so that they survive the remote shell. +REMOTE_ARGS="" +if (( $# )); then + printf -v REMOTE_ARGS ' %q' "$@" +fi + +echo "Running '$*' on ${NODE_SSH_HOST}" +ssh -i "${NODE_SSH_KEY}" \ + -o BatchMode=yes \ + -o ConnectTimeout=30 \ + -o StrictHostKeyChecking=accept-new \ + -o UserKnownHostsFile="${NODE_SSH_KNOWN_HOSTS}" \ + "${NODE_SSH_HOST}" \ + "bash -s --${REMOTE_ARGS}" < "${NODE_OPERATIONS}" diff --git a/tests/scripts/node-operations.sh b/tests/scripts/node-operations.sh new file mode 100755 index 0000000000..58c4e17c97 --- /dev/null +++ b/tests/scripts/node-operations.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash + +# Copyright NVIDIA CORPORATION +# +# 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. + +set -euo pipefail + +# This script runs ON the node hosting the cluster. It is either executed +# locally or streamed over SSH stdin via `bash -s --`, which means it MUST stay +# fully self-contained: it must not source sibling scripts such as +# .definitions.sh and must not refer to any path relative to the repository, +# since the repository is not guaranteed to exist on the node. + +usage() { + cat <<'EOF' +Usage: node-operations.sh [args...] + +Operations: + load-modules + Load the kernel modules required by the GPU Operator. + + restart-operator-container + Kill the running gpu-operator container so that kubernetes restarts it. + Supported runtimes: containerd, docker. +EOF +} + +load_modules() { + echo "Load kernel modules i2c_core and ipmi_msghandler" + sudo modprobe -a i2c_core ipmi_msghandler +} + +# The container selection pipelines below are kept as they were in +# tests/scripts/checks.sh so that the behaviour of the restart test does not +# change. +restart_operator_container() { + local runtime="${1:-}" + local container_id="" + + case "${runtime}" in + containerd) + # The operator is the only container that has the string '"gpu-operator"' + # TODO: This requires permissions on containerd.sock + container_id="$(sudo crictl ps --name gpu-operator | awk '{if(NR>1)print $1}')" || true + if [[ -z "${container_id}" ]]; then + echo "Error: no running gpu-operator container found via crictl" >&2 + return 1 + fi + sudo crictl rm --force "${container_id}" + ;; + docker) + # The operator is the only container that has the string '"gpu-operator"' + container_id="$(docker ps --format '{{.ID}} {{.Command}}' | grep "gpu-operator" | cut -f 1 -d ' ')" || true + if [[ -z "${container_id}" ]]; then + echo "Error: no running gpu-operator container found via docker" >&2 + return 1 + fi + docker kill "${container_id}" + ;; + *) + echo "Error: unknown runtime '${runtime}'. Supported runtimes: containerd, docker" >&2 + return 1 + ;; + esac +} + +main() { + if [[ $# -lt 1 ]]; then + usage >&2 + exit 2 + fi + + local operation="${1}" + shift + + case "${operation}" in + load-modules) + load_modules "$@" + ;; + restart-operator-container) + restart_operator_container "$@" + ;; + *) + echo "Error: unknown operation '${operation}'" >&2 + usage >&2 + exit 2 + ;; + esac +} + +main "$@" diff --git a/tests/scripts/update-clusterpolicy.sh b/tests/scripts/update-clusterpolicy.sh index 7a53901c74..6aacbdb107 100755 --- a/tests/scripts/update-clusterpolicy.sh +++ b/tests/scripts/update-clusterpolicy.sh @@ -150,7 +150,7 @@ test_gpu_sharing() { kubectl wait --for=condition=available --timeout=300s deployment/nvidia-plugin-test -n $TEST_NAMESPACE if [ $? -ne 0 ]; then echo "cannot run parallel pods with GPU sharing enabled" - kubectl get pods -l app=nvidia-plugin-test -n $TEST_NAMESPACE + kubectl get pods -l app=nvidia-plugin-test -n $TEST_NAMESPACE || true exit 1 fi diff --git a/tests/scripts/update-nvidiadriver.sh b/tests/scripts/update-nvidiadriver.sh index d104673654..3816b09312 100755 --- a/tests/scripts/update-nvidiadriver.sh +++ b/tests/scripts/update-nvidiadriver.sh @@ -56,7 +56,7 @@ set_default_driver() { wait_for_default_nvidiadriver() { local expected_name=$1 - local current_time=0 + local deadline=$((SECONDS + 120)) echo "Waiting for NVIDIADriver/${expected_name} to be the only default" while :; do @@ -67,14 +67,13 @@ wait_for_default_nvidiadriver() { break fi - if [[ "${current_time}" -gt 120 ]]; then + if (( SECONDS > deadline )); then echo "timeout reached waiting for NVIDIADriver/${expected_name} to be the only default" - kubectl get nvidiadriver + kubectl get nvidiadriver || true exit 1 fi sleep 5 - current_time=$((${current_time} + 5)) done } @@ -83,7 +82,7 @@ test_arbitrary_name_default_nvidiadriver() { current_default=$(get_default_nvidiadriver_name) if [[ -z "${current_default}" ]]; then echo "default NVIDIADriver not found" - kubectl get nvidiadriver + kubectl get nvidiadriver || true exit 1 fi @@ -110,7 +109,7 @@ create_nvidiadriver() { default_name=$(get_default_nvidiadriver_name) if [[ -z "${default_name}" ]]; then echo "default NVIDIADriver not found" - kubectl get nvidiadriver + kubectl get nvidiadriver || true exit 1 fi @@ -120,7 +119,7 @@ create_nvidiadriver() { wait_for_nvidiadriver_owner() { local driver_name=$1 - local current_time=0 + local deadline=$((SECONDS + $((60 * 15)))) local gpu_node_count gpu_node_count=$(kubectl get node -l nvidia.com/gpu.present=true --no-headers | wc -l) @@ -133,16 +132,15 @@ wait_for_nvidiadriver_owner() { break fi - if [[ "${current_time}" -gt $((60 * 15)) ]]; then + if (( SECONDS > deadline )); then echo "timeout reached waiting for NVIDIADriver/${driver_name} ownership" kubectl get nodes -l nvidia.com/gpu.present=true -o json | - jq -r '.items[] | [.metadata.name, (.metadata.labels["nvidia.com/gpu-operator.driver.owner"] // "-")] | @tsv' + jq -r '.items[] | [.metadata.name, (.metadata.labels["nvidia.com/gpu-operator.driver.owner"] // "-")] | @tsv' || true exit 1 fi echo "NVIDIADriver/${driver_name} owns ${owned_count}/${gpu_node_count} GPU node(s)" sleep 5 - current_time=$((${current_time} + 5)) done } @@ -154,7 +152,7 @@ get_nvidiadriver_daemonsets() { wait_for_nvidiadriver_daemonsets() { local driver_name=$1 - local current_time=0 + local deadline=$((SECONDS + $((60 * 15)))) echo "Waiting for daemonsets owned by NVIDIADriver/${driver_name}" while :; do @@ -164,14 +162,13 @@ wait_for_nvidiadriver_daemonsets() { break fi - if [[ "${current_time}" -gt $((60 * 15)) ]]; then + if (( SECONDS > deadline )); then echo "timeout reached waiting for daemonsets owned by NVIDIADriver/${driver_name}" - kubectl get daemonset -l "app.kubernetes.io/component=nvidia-driver" -n "$TEST_NAMESPACE" -o yaml + kubectl get daemonset -l "app.kubernetes.io/component=nvidia-driver" -n "$TEST_NAMESPACE" -o yaml || true exit 1 fi sleep 5 - current_time=$((${current_time} + 5)) done } @@ -184,20 +181,19 @@ test_driver_image_updates() { fi # Verify update is applied to Driver Daemonset - local current_time=0 + local deadline=$((SECONDS + 120)) while :; do if get_nvidiadriver_daemonsets "${NVIDIA_DRIVER_NAME}" | jq -e --arg version "${TARGET_DRIVER_VERSION}" 'length > 0 and all(.[]; .spec.template.spec.containers[0].image | contains($version))' >/dev/null; then break fi - if [[ "${current_time}" -gt 120 ]]; then + if (( SECONDS > deadline )); then echo "Image update failed for driver daemonset to version $TARGET_DRIVER_VERSION" - get_nvidiadriver_daemonsets "${NVIDIA_DRIVER_NAME}" + get_nvidiadriver_daemonsets "${NVIDIA_DRIVER_NAME}" || true exit 1 fi sleep 5 - current_time=$((${current_time} + 5)) done echo "driver daemonset image updated successfully to version $TARGET_DRIVER_VERSION" @@ -223,20 +219,19 @@ test_custom_labels_override() { # Wait for the operator to update the pod template with new labels echo "Waiting for DaemonSet pod template to be updated with new labels..." - local current_time=0 + local deadline=$((SECONDS + 120)) while :; do if get_nvidiadriver_daemonsets "${NVIDIA_DRIVER_NAME}" | jq -e 'length > 0 and all(.[]; .spec.template.metadata.labels.cloudprovider == "aws" and .spec.template.metadata.labels.platform == "kubernetes")' >/dev/null; then break fi - if [[ "${current_time}" -gt 120 ]]; then + if (( SECONDS > deadline )); then echo "timeout reached waiting for DaemonSet pod template labels" - get_nvidiadriver_daemonsets "${NVIDIA_DRIVER_NAME}" + get_nvidiadriver_daemonsets "${NVIDIA_DRIVER_NAME}" || true exit 1 fi sleep 5 - current_time=$((${current_time} + 5)) done # Delete driver pod to force recreation with updated labels. Existing pods are not automatically restarted due to the DaemonSet's 'OnDelete` updateStrategy. @@ -253,7 +248,7 @@ test_custom_labels_override() { gpu_node_count=$(kubectl get node -l nvidia.com/gpu.present=true --no-headers | wc -l) if [[ "${labeled_pod_count}" -ne "${gpu_node_count}" ]]; then echo "Custom labels are missing from one or more NVIDIADriver/${NVIDIA_DRIVER_NAME} pods" - kubectl get pods -n "$TEST_NAMESPACE" -l "app.kubernetes.io/component=nvidia-driver" --show-labels + kubectl get pods -n "$TEST_NAMESPACE" -l "app.kubernetes.io/component=nvidia-driver" --show-labels || true exit 1 fi } @@ -268,7 +263,7 @@ assert_nvidiadriver_owner_count() { if [[ "${owned_count}" -ne "${gpu_node_count}" ]]; then echo "Expected ${gpu_node_count} GPU node(s) to remain owned by NVIDIADriver/${driver_name}, found ${owned_count}" kubectl get nodes -l nvidia.com/gpu.present=true -o json | - jq -r '.items[] | [.metadata.name, (.metadata.labels["nvidia.com/gpu-operator.driver.owner"] // "-")] | @tsv' + jq -r '.items[] | [.metadata.name, (.metadata.labels["nvidia.com/gpu-operator.driver.owner"] // "-")] | @tsv' || true exit 1 fi } @@ -276,7 +271,7 @@ assert_nvidiadriver_owner_count() { wait_for_nvidiadriver_condition_message() { local driver_name=$1 local message=$2 - local current_time=0 + local deadline=$((SECONDS + 120)) echo "Waiting for NVIDIADriver/${driver_name} status message to contain: ${message}" while :; do @@ -287,20 +282,19 @@ wait_for_nvidiadriver_condition_message() { break fi - if [[ "${current_time}" -gt 120 ]]; then + if (( SECONDS > deadline )); then echo "timeout reached waiting for NVIDIADriver/${driver_name} status message" - kubectl get nvidiadriver/"${driver_name}" -o yaml + kubectl get nvidiadriver/"${driver_name}" -o yaml || true exit 1 fi sleep 5 - current_time=$((${current_time} + 5)) done } wait_for_nvidiadriver_ready() { local driver_name=$1 - local current_time=0 + local deadline=$((SECONDS + 120)) echo "Waiting for NVIDIADriver/${driver_name} to report Ready" while :; do @@ -312,14 +306,13 @@ wait_for_nvidiadriver_ready() { break fi - if [[ "${current_time}" -gt 120 ]]; then + if (( SECONDS > deadline )); then echo "timeout reached waiting for NVIDIADriver/${driver_name} to report Ready" - kubectl get nvidiadriver/"${driver_name}" -o yaml + kubectl get nvidiadriver/"${driver_name}" -o yaml || true exit 1 fi sleep 5 - current_time=$((${current_time} + 5)) done }