-
Notifications
You must be signed in to change notification settings - Fork 136
fix(test): wait for control-plane recovery before retrying deploys #12608
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
c9427d9
Make deploy retries deadline-based and capture control-plane diagnostics
brooke-hamilton 8e1a963
Simplify the retry API and fix the pods.yaml diagnostics dump
brooke-hamilton 58a0a88
Bound the retry budget by the test binary deadline
brooke-hamilton fe78092
Fix the cloud functional test timeout ordering
brooke-hamilton e43e67b
Address review feedback on tests and diagnostics stderr
brooke-hamilton a72ee2d
Merge remote-tracking branch 'origin/main' into brooke-hamilton-exper…
brooke-hamilton ca8f99b
Correct a stale timeout value in the retry budget comment
brooke-hamilton 30a8de3
Merge remote-tracking branch 'origin/main' into brooke-hamilton-exper…
brooke-hamilton d03a3b5
Make the retry budget gate the delay-free fast path
brooke-hamilton 7dccc5e
Merge remote-tracking branch 'origin/main' into brooke-hamilton-exper…
brooke-hamilton 08aba72
Merge branch 'main' into brooke-hamilton-expert-spoon
brooke-hamilton 942ad5e
Merge main, converging with the retry fix from #12618
brooke-hamilton ca5e54f
Merge main and retain deadline-based deploy retries
brooke-hamilton File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,171 @@ | ||
| #!/bin/bash | ||
|
|
||
| # ============================================================================ | ||
| # Collect Kubernetes cluster diagnostics for functional test post-mortems. | ||
| # | ||
| # Functional tests intermittently fail with `connection reset by peer` or `EOF` | ||
| # against the Radius API. Diagnosing those runs requires distinguishing a | ||
| # control-plane outage (etcd slowness, a kube-apiserver liveness kill) from a | ||
| # Radius bug, and the pod snapshot alone cannot do that. This script captures | ||
| # the missing evidence: | ||
| # | ||
| # * pod state as YAML, which preserves `lastState.terminated.reason` and | ||
| # `exitCode` for containers that already restarted (`kubectl describe` has | ||
| # been observed to omit the `Last State` block entirely) | ||
| # * cluster-wide events, which record liveness/readiness probe failures and | ||
| # the resulting kills | ||
| # * node conditions, which record disk/memory/PID pressure | ||
| # * the current and previous logs of every kube-system container, including | ||
| # etcd - the previous instance's log is where an apiserver crash reason and | ||
| # etcd's `apply request took too long` / `slow fdatasync` warnings live | ||
| # * verbose /livez and /readyz output, which names the specific failing | ||
| # apiserver health check | ||
| # | ||
| # Collection is best effort by design: it runs after a test job that may have | ||
| # already started tearing the cluster down, so an individual kubectl failure is | ||
| # recorded in the output and never aborts the rest of the collection. | ||
| # ============================================================================ | ||
|
|
||
| set -euo pipefail | ||
|
|
||
| readonly SCRIPT_NAME="$(basename "$0")" | ||
|
|
||
| OUTPUT_DIR="" | ||
| PREFIX="cluster" | ||
|
|
||
| usage() { | ||
| echo "Usage: ${SCRIPT_NAME} --output-dir DIR [--prefix NAME]" | ||
| echo "Options:" | ||
| echo " -o, --output-dir Directory to write diagnostics into (required)" | ||
| echo " -p, --prefix Filename prefix for the output files" | ||
| echo " (default: cluster)" | ||
| echo " -h, --help Show this help" | ||
| exit 0 | ||
| } | ||
|
|
||
| validate_requirements() { | ||
| if [[ -z "${OUTPUT_DIR}" ]]; then | ||
| echo "Error: --output-dir is required" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| if ! command -v kubectl >/dev/null; then | ||
| echo "Error: kubectl is required but not installed" >&2 | ||
| exit 1 | ||
| fi | ||
| } | ||
|
|
||
| # capture appends the command line and its combined output to a file. A failing | ||
| # command is recorded rather than propagated so one unavailable resource does | ||
| # not stop the remaining collection. | ||
| capture() { | ||
| local output_file="$1" | ||
| shift | ||
|
|
||
| { | ||
| echo "==============================================================" | ||
| echo "\$ $*" | ||
| echo "==============================================================" | ||
| "$@" 2>&1 || echo "(command exited with code $?)" | ||
| echo | ||
| } >>"${output_file}" | ||
| } | ||
|
|
||
| collect_pod_state() { | ||
| # The plain-text snapshot keeps the historical pod-states file intact, and | ||
| # the YAML dump adds the machine-readable lastState/exitCode fields. | ||
| capture "${OUTPUT_DIR}/${PREFIX}-pod-states.log" kubectl get pods -A -o wide | ||
| capture "${OUTPUT_DIR}/${PREFIX}-pod-states.log" kubectl describe pods -A | ||
| capture "${OUTPUT_DIR}/${PREFIX}-pods.yaml" kubectl get pods -A -o yaml | ||
| } | ||
|
|
||
| collect_events() { | ||
| capture "${OUTPUT_DIR}/${PREFIX}-events.log" \ | ||
| kubectl get events -A --sort-by=.lastTimestamp | ||
| } | ||
|
|
||
| collect_node_state() { | ||
| capture "${OUTPUT_DIR}/${PREFIX}-nodes.log" kubectl get nodes -o wide | ||
| capture "${OUTPUT_DIR}/${PREFIX}-nodes.log" kubectl describe nodes | ||
| } | ||
|
|
||
| collect_control_plane_health() { | ||
| local output_file="${OUTPUT_DIR}/${PREFIX}-control-plane-health.log" | ||
|
|
||
| capture "${output_file}" kubectl get --raw "/livez?verbose" | ||
| capture "${output_file}" kubectl get --raw "/readyz?verbose" | ||
| capture "${output_file}" kubectl get apiservices -o wide | ||
| } | ||
|
|
||
| # collect_kube_system_logs writes the current and previous logs of every | ||
| # kube-system container. Timestamps are included so the logs can be correlated | ||
| # with Radius pod logs and with the test output. | ||
| collect_kube_system_logs() { | ||
| local logs_dir="${OUTPUT_DIR}/${PREFIX}-kube-system-logs" | ||
| mkdir -p "${logs_dir}" | ||
|
|
||
| local pods | ||
| if ! pods="$(kubectl get pods -n kube-system \ | ||
| -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}')"; then | ||
| echo "Warning: unable to list kube-system pods" >&2 | ||
| return 0 | ||
| fi | ||
|
|
||
| local pod container containers previous_file | ||
| for pod in ${pods}; do | ||
| if ! containers="$(kubectl get pod "${pod}" -n kube-system \ | ||
| -o jsonpath='{range .spec.initContainers[*]}{.name}{"\n"}{end}{range .spec.containers[*]}{.name}{"\n"}{end}')"; then | ||
| continue | ||
| fi | ||
|
|
||
| for container in ${containers}; do | ||
| kubectl logs "${pod}" -n kube-system -c "${container}" \ | ||
| --timestamps >"${logs_dir}/${pod}.${container}.log" 2>&1 || true | ||
|
|
||
| # A previous instance only exists once a container has restarted, | ||
| # and it holds the reason it died. Drop the file when there is no | ||
| # previous instance so the artifact only contains real restarts. | ||
| previous_file="${logs_dir}/${pod}.${container}.previous.log" | ||
| if ! kubectl logs "${pod}" -n kube-system -c "${container}" \ | ||
| --previous --timestamps >"${previous_file}" 2>&1; then | ||
| rm -f "${previous_file}" | ||
| fi | ||
| done | ||
| done | ||
| } | ||
|
|
||
| main() { | ||
| validate_requirements | ||
|
|
||
| mkdir -p "${OUTPUT_DIR}" | ||
|
|
||
| echo "Collecting cluster diagnostics into ${OUTPUT_DIR}" | ||
| collect_pod_state | ||
| collect_events | ||
| collect_node_state | ||
| collect_control_plane_health | ||
| collect_kube_system_logs | ||
| echo "Cluster diagnostics collection complete" | ||
| } | ||
|
|
||
| while [[ $# -gt 0 ]]; do | ||
| case $1 in | ||
| -o | --output-dir) | ||
| OUTPUT_DIR="$2" | ||
| shift 2 | ||
| ;; | ||
| -p | --prefix) | ||
| PREFIX="$2" | ||
| shift 2 | ||
| ;; | ||
| -h | --help) | ||
| usage | ||
| ;; | ||
| *) | ||
| echo "Unknown option: $1" >&2 | ||
| exit 1 | ||
| ;; | ||
| esac | ||
| done | ||
|
|
||
| main "$@" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| /* | ||
| Copyright 2023 The Radius Authors. | ||
|
|
||
| Licensed under the Apache License, Version 2.0 (the "License"); | ||
| you may not use this file except in compliance with the License. | ||
| You may obtain a copy of the License at | ||
|
|
||
| http://www.apache.org/licenses/LICENSE-2.0 | ||
|
|
||
| Unless required by applicable law or agreed to in writing, software | ||
| distributed under the License is distributed on an "AS IS" BASIS, | ||
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| See the License for the specific language governing permissions and | ||
| limitations under the License. | ||
| */ | ||
|
|
||
| package step | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "testing" | ||
| "time" | ||
|
|
||
| k8s "k8s.io/client-go/kubernetes" | ||
| "k8s.io/client-go/rest" | ||
| ) | ||
|
|
||
| const ( | ||
| // controlPlaneReadyPollInterval is how long to wait between readiness | ||
| // probes while the control plane is recovering. | ||
| controlPlaneReadyPollInterval = 5 * time.Second | ||
|
|
||
| // controlPlaneProbeTimeout bounds a single readiness probe so a hung | ||
| // kube-apiserver cannot consume the caller's entire retry budget in one | ||
| // request. | ||
| controlPlaneProbeTimeout = 10 * time.Second | ||
|
|
||
| // radiusAggregatedAPIPath is the aggregated API discovery path that rad | ||
| // itself requests when it opens a workspace connection (see | ||
| // pkg/cli/workspaces.Connection). Probing the same path exercises the whole | ||
| // chain the deployment depends on: the kube-apiserver, its aggregation | ||
| // layer, and the UCP APIService behind it. | ||
| radiusAggregatedAPIPath = "/apis/api.ucp.dev/v1alpha3" | ||
| ) | ||
|
|
||
| // newControlPlaneReadyWaiter returns a function that blocks until the | ||
| // Kubernetes control plane and the Radius aggregated API are both serving | ||
| // again, or until ctx is done. | ||
| // | ||
| // It exists because a fixed sleep is not a useful gate between deployment | ||
| // retries. When the kind control plane goes down, every queued retry fails | ||
| // instantly against a dead socket and burns the retry budget in seconds while | ||
| // the outage lasts minutes. Waiting for readiness instead makes each retry | ||
| // attempt meaningful. | ||
| // | ||
| // It returns nil when no client is available, which disables the gate and | ||
| // leaves the retry loop with its delay-only behavior. | ||
| func newControlPlaneReadyWaiter(t *testing.T, client k8s.Interface) func(context.Context) error { | ||
| if client == nil { | ||
| return nil | ||
| } | ||
|
|
||
| restClient := client.Discovery().RESTClient() | ||
| if restClient == nil { | ||
| return nil | ||
| } | ||
|
|
||
| return func(ctx context.Context) error { | ||
| return waitForControlPlaneReady(ctx, t, restClient) | ||
| } | ||
| } | ||
|
|
||
| // waitForControlPlaneReady polls until the control plane is ready or ctx is | ||
| // done. The error returned on timeout includes the last probe failure so the | ||
| // test log records which component was still unavailable. | ||
| func waitForControlPlaneReady(ctx context.Context, t *testing.T, client rest.Interface) error { | ||
| var lastErr error | ||
| for { | ||
| lastErr = checkControlPlaneReady(ctx, client) | ||
| if lastErr == nil { | ||
| return nil | ||
| } | ||
|
|
||
| t.Logf("waiting for the Radius control plane to become ready: %v", lastErr) | ||
|
|
||
| timer := time.NewTimer(controlPlaneReadyPollInterval) | ||
| select { | ||
| case <-timer.C: | ||
| case <-ctx.Done(): | ||
| timer.Stop() | ||
| return fmt.Errorf("control plane did not become ready: %w (last probe failure: %v)", ctx.Err(), lastErr) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // checkControlPlaneReady performs a single readiness probe. | ||
| func checkControlPlaneReady(ctx context.Context, client rest.Interface) error { | ||
| // /readyz reports whether the kube-apiserver has finished starting and its | ||
| // health checks - including the etcd backend check - are passing. It is | ||
| // served to any authenticated caller by the built-in | ||
| // system:public-info-viewer role, so it needs no extra RBAC. | ||
| if err := probe(ctx, client, "/readyz"); err != nil { | ||
| return fmt.Errorf("kube-apiserver is not ready: %w", err) | ||
| } | ||
|
|
||
| // The kube-apiserver can be ready before the aggregated API it proxies to | ||
| // is, and a request to an unavailable APIService fails with 503. Probing it | ||
| // avoids retrying a deployment that is guaranteed to fail. | ||
| if err := probe(ctx, client, radiusAggregatedAPIPath); err != nil { | ||
| return fmt.Errorf("the Radius aggregated API is not reachable: %w", err) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func probe(ctx context.Context, client rest.Interface, path string) error { | ||
| probeCtx, cancel := context.WithTimeout(ctx, controlPlaneProbeTimeout) | ||
| defer cancel() | ||
|
|
||
| return client.Get().AbsPath(path).Do(probeCtx).Error() | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.