mirror of
https://github.com/vmware-tanzu/velero.git
synced 2026-08-15 19:56:06 +00:00
Merge branch 'main' into block-uploader-restore-progress
This commit is contained in:
@@ -0,0 +1 @@
|
||||
Cancel hook exec stream on timeout and bound hook timeouts
|
||||
@@ -0,0 +1 @@
|
||||
Add printer columns for VolumeSnapshotLocation so kubectl shows provider and phase
|
||||
@@ -0,0 +1 @@
|
||||
Verify downloaded build tools against architecture-specific SHA-256 checksums before installation.
|
||||
@@ -16,7 +16,19 @@ spec:
|
||||
singular: volumesnapshotlocation
|
||||
scope: Namespaced
|
||||
versions:
|
||||
- name: v1
|
||||
- additionalPrinterColumns:
|
||||
- description: Provider is the provider of the volume storage
|
||||
jsonPath: .spec.provider
|
||||
name: Provider
|
||||
type: string
|
||||
- description: Volume Snapshot Location status such as Available/Unavailable
|
||||
jsonPath: .status.phase
|
||||
name: Phase
|
||||
type: string
|
||||
- jsonPath: .metadata.creationTimestamp
|
||||
name: Age
|
||||
type: date
|
||||
name: v1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
description: VolumeSnapshotLocation is a location where Velero stores volume
|
||||
@@ -93,3 +105,4 @@ spec:
|
||||
type: object
|
||||
served: true
|
||||
storage: true
|
||||
subresources: {}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+48
-33
@@ -29,8 +29,18 @@ RUN go install sigs.k8s.io/controller-runtime/tools/setup-envtest@v0.0.0-2026030
|
||||
ENVTEST_ASSETS_DIR=$(setup-envtest use 1.33.0 --bin-dir /usr/local/kubebuilder/bin -p path) && \
|
||||
cp -r ${ENVTEST_ASSETS_DIR}/* /usr/local/kubebuilder/bin/
|
||||
|
||||
RUN wget --quiet https://github.com/kubernetes-sigs/kubebuilder/releases/download/v3.2.0/kubebuilder_linux_$(go env GOARCH) && \
|
||||
mv kubebuilder_linux_$(go env GOARCH) /usr/local/kubebuilder/bin/kubebuilder && \
|
||||
RUN set -eux; \
|
||||
ARCH="$(go env GOARCH)"; \
|
||||
case "$ARCH" in \
|
||||
amd64) KUBEBUILDER_SHA256="102bb0f586dcb50951aded67856483a2ee114057c56475b3cda6051a12832a72" ;; \
|
||||
arm64) KUBEBUILDER_SHA256="0a340ea925c801aa71344becdefce96eda6fa0bc92352b9c7bcb36a4f8c56314" ;; \
|
||||
ppc64le) KUBEBUILDER_SHA256="74473d094908caad852a77088f64bb64eb4c79497f6695eb5e9e8bc4bacd9409" ;; \
|
||||
*) echo "Unsupported kubebuilder architecture: $ARCH" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
FILE="kubebuilder_linux_$ARCH"; \
|
||||
wget --quiet "https://github.com/kubernetes-sigs/kubebuilder/releases/download/v3.2.0/$FILE"; \
|
||||
echo "$KUBEBUILDER_SHA256 $FILE" | sha256sum -c -; \
|
||||
mv "$FILE" /usr/local/kubebuilder/bin/kubebuilder; \
|
||||
chmod +x /usr/local/kubebuilder/bin/kubebuilder
|
||||
|
||||
# get controller-tools
|
||||
@@ -52,26 +62,27 @@ RUN apt-get update && apt-get install -y unzip
|
||||
# cpu = "ppcle_64"
|
||||
# snippet from: https://github.com/protocolbuffers/protobuf/blob/d445953603e66eb8992a39b4e10fcafec8501f24/protobuf_release.bzl#L18-L24
|
||||
# cpu names: https://github.com/bazelbuild/platforms/blob/main/cpu/BUILD
|
||||
RUN ARCH=$(go env GOARCH) && \
|
||||
if [ "$ARCH" = "s390x" ] ; then \
|
||||
ARCH="s390_64"; \
|
||||
elif [ "$ARCH" = "arm64" ] ; then \
|
||||
ARCH="aarch_64"; \
|
||||
elif [ "$ARCH" = "ppc64le" ] ; then \
|
||||
ARCH="ppcle_64"; \
|
||||
elif [ "$ARCH" = "ppc64" ] ; then \
|
||||
ARCH="ppcle_64"; \
|
||||
else \
|
||||
ARCH=$(uname -m); \
|
||||
fi && echo "ARCH=$ARCH" && \
|
||||
wget --quiet https://github.com/protocolbuffers/protobuf/releases/download/v25.2/protoc-25.2-linux-$ARCH.zip && \
|
||||
unzip protoc-25.2-linux-$ARCH.zip; \
|
||||
rm *.zip && \
|
||||
mv bin/protoc /usr/bin/protoc && \
|
||||
mv include/google /usr/include && \
|
||||
chmod a+x /usr/include/google && \
|
||||
chmod a+x /usr/include/google/protobuf && \
|
||||
chmod a+r -R /usr/include/google && \
|
||||
RUN set -eux; \
|
||||
GOARCH="$(go env GOARCH)"; \
|
||||
case "$GOARCH" in \
|
||||
amd64) ARCH="x86_64"; PROTOC_SHA256="78ab9c3288919bdaa6cfcec6127a04813cf8a0ce406afa625e48e816abee2878" ;; \
|
||||
386) ARCH="x86_32"; PROTOC_SHA256="cc1c6e31a9b333c3e6d026aac5fdc1f7d70c6cd8851631505188ca9826acee5a" ;; \
|
||||
arm64) ARCH="aarch_64"; PROTOC_SHA256="07683afc764e4efa3fa969d5f049fbc2bdfc6b4e7786a0b233413ac0d8753f6b" ;; \
|
||||
ppc64|ppc64le) ARCH="ppcle_64"; PROTOC_SHA256="cea283337101ed08ff6c76a98461b1d871bac21f41dc1dabdfddaa5d99df9339" ;; \
|
||||
s390x) ARCH="s390_64"; PROTOC_SHA256="8a13ec6518585f7664d58f929417c9e6d0c4aeedf3bcdd854aeafceb5ef0a389" ;; \
|
||||
*) echo "Unsupported protoc architecture: $GOARCH" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
echo "ARCH=$ARCH"; \
|
||||
FILE="protoc-25.2-linux-$ARCH.zip"; \
|
||||
wget --quiet "https://github.com/protocolbuffers/protobuf/releases/download/v25.2/$FILE"; \
|
||||
echo "$PROTOC_SHA256 $FILE" | sha256sum -c -; \
|
||||
unzip "$FILE"; \
|
||||
rm "$FILE"; \
|
||||
mv bin/protoc /usr/bin/protoc; \
|
||||
mv include/google /usr/include; \
|
||||
chmod a+x /usr/include/google; \
|
||||
chmod a+x /usr/include/google/protobuf; \
|
||||
chmod a+r -R /usr/include/google; \
|
||||
chmod +x /usr/bin/protoc
|
||||
RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@${PROTOC_GEN_GO_VERSION} \
|
||||
&& go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.3.0
|
||||
@@ -84,17 +95,21 @@ RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@${PROTOC_GEN_GO_VERS
|
||||
# {{- else if eq .Arch "386" }}i386
|
||||
# {{- else }}{{ .Arch }}{{ end }}
|
||||
# {{- if .Arm }}v{{ .Arm }}{{ end -}}
|
||||
RUN ARCH=$(go env GOARCH) && \
|
||||
if [ "$ARCH" = "amd64" ] ; then \
|
||||
ARCH="x86_64"; \
|
||||
elif [ "$ARCH" = "386" ] ; then \
|
||||
ARCH="i386"; \
|
||||
elif [ "$ARCH" = "ppc64le" ] ; then \
|
||||
ARCH="ppc64"; \
|
||||
fi && \
|
||||
wget --quiet "https://github.com/goreleaser/goreleaser/releases/download/v1.26.2/goreleaser_Linux_$ARCH.tar.gz" && \
|
||||
tar xvf goreleaser_Linux_$ARCH.tar.gz; \
|
||||
mv goreleaser /usr/bin/goreleaser && \
|
||||
RUN set -eux; \
|
||||
GOARCH="$(go env GOARCH)"; \
|
||||
case "$GOARCH" in \
|
||||
amd64) ARCH="x86_64"; GORELEASER_SHA256="cfbdf12e3ea20e4c3a209d07311f43c2e0baf20d5cce09bcdc232567e0f34307" ;; \
|
||||
386) ARCH="i386"; GORELEASER_SHA256="21c236575cccd29588182b570b4ffe83ad8fb96cd3b13b2af79feafd8ae37b1b" ;; \
|
||||
arm64) ARCH="arm64"; GORELEASER_SHA256="2b984e2932b24be0d638c7dab7357a59d86eb79ca7fee1afd31be5ebb1847cbb" ;; \
|
||||
arm) ARCH="armv7"; GORELEASER_SHA256="6db2899885be19f123b36192a42dcfb3bb2b3e1009fec7277517969e96d8a7c6" ;; \
|
||||
ppc64|ppc64le) ARCH="ppc64"; GORELEASER_SHA256="76d060ebb8d48e76fde45983f87040fe3ac0ca37c5ace4648a956959b81bfdf0" ;; \
|
||||
*) echo "Unsupported goreleaser architecture: $GOARCH" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
FILE="goreleaser_Linux_$ARCH.tar.gz"; \
|
||||
wget --quiet "https://github.com/goreleaser/goreleaser/releases/download/v1.26.2/$FILE"; \
|
||||
echo "$GORELEASER_SHA256 $FILE" | sha256sum -c -; \
|
||||
tar xvf "$FILE"; \
|
||||
mv goreleaser /usr/bin/goreleaser; \
|
||||
chmod +x /usr/bin/goreleaser
|
||||
|
||||
# get golangci-lint
|
||||
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright 2026 the Velero contributors.
|
||||
#
|
||||
# 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
|
||||
|
||||
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
DOCKERFILE="${ROOT_DIR}/hack/build-image/Dockerfile"
|
||||
|
||||
verify_block() {
|
||||
local tool=$1
|
||||
local start=$2
|
||||
local end=$3
|
||||
local hash_variable=$4
|
||||
local install_pattern=$5
|
||||
shift 5
|
||||
local expected_arches=("$@")
|
||||
local block
|
||||
|
||||
block=$(awk -v start="${start}" -v end="${end}" '
|
||||
$0 ~ start { printing = 1 }
|
||||
printing { print }
|
||||
printing && $0 ~ end { exit }
|
||||
' "${DOCKERFILE}")
|
||||
|
||||
if [[ -z "${block}" ]]; then
|
||||
echo "Unable to find ${tool} install block" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local actual_arches
|
||||
actual_arches=$(printf '%s\n' "${block}" |
|
||||
sed -nE "s/^[[:space:]]*([[:alnum:]_|]+)\).*${hash_variable}=\"([[:xdigit:]]+)\".*/\1 \2/p")
|
||||
|
||||
local expected_arch
|
||||
for expected_arch in "${expected_arches[@]}"; do
|
||||
if ! printf '%s\n' "${actual_arches}" | awk -v arch="${expected_arch}" '
|
||||
$1 == arch && length($2) == 64 && $2 ~ /^[0-9a-f]+$/ { found = 1 }
|
||||
END { exit !found }
|
||||
'; then
|
||||
echo "${tool} is missing a lowercase 64-hex SHA-256 for ${expected_arch}" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
local actual_count
|
||||
actual_count=$(printf '%s\n' "${actual_arches}" | sed '/^$/d' | wc -l | tr -d ' ')
|
||||
if [[ "${actual_count}" -ne "${#expected_arches[@]}" ]]; then
|
||||
echo "${tool} architecture mapping changed; update this verification gate" >&2
|
||||
printf '%s\n' "${actual_arches}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! printf '%s\n' "${block}" | grep -Eq '^ \*\).*Unsupported .+ architecture:.+exit 1'; then
|
||||
echo "${tool} does not fail closed for unknown architectures" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local download_line checksum_line install_line
|
||||
download_line=$(printf '%s\n' "${block}" | grep -n 'wget --quiet' | head -1 | cut -d: -f1)
|
||||
checksum_line=$(printf '%s\n' "${block}" | grep -n "echo \"\$${hash_variable} \$FILE\" | sha256sum -c -" | head -1 | cut -d: -f1)
|
||||
install_line=$(printf '%s\n' "${block}" | grep -nE "${install_pattern}" | head -1 | cut -d: -f1)
|
||||
|
||||
if [[ -z "${download_line}" || -z "${checksum_line}" || -z "${install_line}" ||
|
||||
"${download_line}" -ge "${checksum_line}" || "${checksum_line}" -ge "${install_line}" ]]; then
|
||||
echo "${tool} must download, verify, then install/extract in that order" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! printf '%s\n' "${block}" | grep -q '^RUN set -eux;'; then
|
||||
echo "${tool} install block must use strict shell error handling" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
verify_block \
|
||||
kubebuilder \
|
||||
'^RUN set -eux;.*$' \
|
||||
'^# get controller-tools$' \
|
||||
KUBEBUILDER_SHA256 \
|
||||
'mv "\$FILE"' \
|
||||
amd64 arm64 ppc64le
|
||||
|
||||
verify_block \
|
||||
protoc \
|
||||
'^# cpu names:' \
|
||||
'^RUN go install google.golang.org/protobuf' \
|
||||
PROTOC_SHA256 \
|
||||
'unzip "\$FILE"' \
|
||||
amd64 386 arm64 'ppc64|ppc64le' s390x
|
||||
|
||||
verify_block \
|
||||
goreleaser \
|
||||
'^# goreleaser name template' \
|
||||
'^# get golangci-lint$' \
|
||||
GORELEASER_SHA256 \
|
||||
'tar xvf "\$FILE"' \
|
||||
amd64 386 arm64 arm 'ppc64|ppc64le'
|
||||
|
||||
echo "Verified pinned build-tool checksums and fail-closed install ordering"
|
||||
@@ -27,6 +27,9 @@ import (
|
||||
// +kubebuilder:resource:shortName=vsl
|
||||
// +kubebuilder:object:generate=true
|
||||
// +kubebuilder:storageversion
|
||||
// +kubebuilder:printcolumn:name="Provider",type="string",JSONPath=".spec.provider",description="Provider is the provider of the volume storage"
|
||||
// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase",description="Volume Snapshot Location status such as Available/Unavailable"
|
||||
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
|
||||
|
||||
// VolumeSnapshotLocation is a location where Velero stores volume snapshots.
|
||||
type VolumeSnapshotLocation struct {
|
||||
|
||||
@@ -36,6 +36,10 @@ import (
|
||||
|
||||
const defaultTimeout = 30 * time.Second
|
||||
|
||||
// maxHookTimeout bounds a user-supplied hook timeout, which can come from a pod
|
||||
// annotation, so a single hook cannot hold up a backup for an unbounded time.
|
||||
const maxHookTimeout = 4 * time.Hour
|
||||
|
||||
// PodCommandExecutor is capable of executing a command in a container in a pod.
|
||||
type PodCommandExecutor interface {
|
||||
// ExecutePodCommand executes a command in a container in a pod. If the command takes longer than
|
||||
@@ -112,9 +116,15 @@ func (e *defaultPodCommandExecutor) ExecutePodCommand(log logrus.FieldLogger, it
|
||||
localHook.OnError = api.HookErrorModeFail
|
||||
}
|
||||
|
||||
if localHook.Timeout.Duration == 0 {
|
||||
// A non-positive timeout is not a valid bound. Timeouts sourced from pod annotations are
|
||||
// parsed with time.ParseDuration, which accepts negative values, and a negative duration
|
||||
// would otherwise leave the hook without any timeout at all.
|
||||
if localHook.Timeout.Duration <= 0 {
|
||||
localHook.Timeout.Duration = defaultTimeout
|
||||
}
|
||||
if localHook.Timeout.Duration > maxHookTimeout {
|
||||
localHook.Timeout.Duration = maxHookTimeout
|
||||
}
|
||||
|
||||
hookLog := log.WithFields(
|
||||
logrus.Fields{
|
||||
@@ -158,23 +168,26 @@ func (e *defaultPodCommandExecutor) ExecutePodCommand(log logrus.FieldLogger, it
|
||||
Stderr: &stderr,
|
||||
}
|
||||
|
||||
errCh := make(chan error)
|
||||
// The timeout drives the context so the exec stream is actually canceled, rather than
|
||||
// being left running on the API server after this function has returned.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), localHook.Timeout.Duration)
|
||||
defer cancel()
|
||||
|
||||
// Buffered so the goroutine below can always send its result and exit, even when this
|
||||
// function has already returned on the timeout path.
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
err = executor.StreamWithContext(context.Background(), streamOptions)
|
||||
errCh <- err
|
||||
streamErr := executor.StreamWithContext(ctx, streamOptions)
|
||||
// Inspect the local context as soon as the stream returns. Otherwise a stream error
|
||||
// completed before the deadline could be misclassified if this goroutine sends its
|
||||
// result before the caller is scheduled to receive it.
|
||||
errCh <- normalizeExecHookError(streamErr, ctx.Err(), localHook.Timeout.Duration)
|
||||
}()
|
||||
|
||||
var timeoutCh <-chan time.Time
|
||||
if localHook.Timeout.Duration > 0 {
|
||||
timer := time.NewTimer(localHook.Timeout.Duration)
|
||||
defer timer.Stop()
|
||||
timeoutCh = timer.C
|
||||
}
|
||||
|
||||
select {
|
||||
case err = <-errCh:
|
||||
case <-timeoutCh:
|
||||
case <-ctx.Done():
|
||||
return errors.Errorf("timed out after %v", localHook.Timeout.Duration)
|
||||
}
|
||||
|
||||
@@ -184,6 +197,14 @@ func (e *defaultPodCommandExecutor) ExecutePodCommand(log logrus.FieldLogger, it
|
||||
return err
|
||||
}
|
||||
|
||||
func normalizeExecHookError(streamErr, contextErr error, timeout time.Duration) error {
|
||||
if errors.Is(contextErr, context.DeadlineExceeded) {
|
||||
return errors.Errorf("timed out after %v", timeout)
|
||||
}
|
||||
|
||||
return streamErr
|
||||
}
|
||||
|
||||
func ensureContainerExists(pod *corev1api.Pod, container string) error {
|
||||
existsAsMainContainer := slices.ContainsFunc(pod.Spec.Containers, func(c corev1api.Container) bool {
|
||||
return c.Name == container
|
||||
|
||||
@@ -177,6 +177,33 @@ func TestExecutePodCommand(t *testing.T) {
|
||||
hookError: errors.New("hook error"),
|
||||
expectedError: "hook error",
|
||||
},
|
||||
{
|
||||
name: "stream deadline exceeded before local timeout",
|
||||
command: []string{"some", "command"},
|
||||
expectedContainerName: "foo",
|
||||
expectedErrorMode: v1.HookErrorModeFail,
|
||||
expectedTimeout: defaultTimeout,
|
||||
hookError: context.DeadlineExceeded,
|
||||
expectedError: context.DeadlineExceeded.Error(),
|
||||
},
|
||||
{
|
||||
// Timeouts from pod annotations go through time.ParseDuration, which accepts
|
||||
// negative values. Without clamping, the hook would run with no timeout at all.
|
||||
name: "negative timeout falls back to the default",
|
||||
command: []string{"some", "command"},
|
||||
expectedContainerName: "foo",
|
||||
expectedErrorMode: v1.HookErrorModeFail,
|
||||
timeout: -1 * time.Second,
|
||||
expectedTimeout: 30 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "timeout above the maximum is capped",
|
||||
command: []string{"some", "command"},
|
||||
expectedContainerName: "foo",
|
||||
expectedErrorMode: v1.HookErrorModeFail,
|
||||
timeout: 100000 * time.Hour,
|
||||
expectedTimeout: maxHookTimeout,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
@@ -246,6 +273,54 @@ func TestExecutePodCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeExecHookError(t *testing.T) {
|
||||
hookErr := errors.New("hook error")
|
||||
tests := []struct {
|
||||
name string
|
||||
streamErr error
|
||||
contextErr error
|
||||
expectedError string
|
||||
preserveStreamErr bool
|
||||
}{
|
||||
{
|
||||
name: "local context deadline exceeded",
|
||||
streamErr: context.DeadlineExceeded,
|
||||
contextErr: context.DeadlineExceeded,
|
||||
expectedError: "timed out after 30s",
|
||||
},
|
||||
{
|
||||
name: "stream deadline exceeded before local timeout",
|
||||
streamErr: context.DeadlineExceeded,
|
||||
expectedError: context.DeadlineExceeded.Error(),
|
||||
preserveStreamErr: true,
|
||||
},
|
||||
{
|
||||
name: "ordinary hook error",
|
||||
streamErr: hookErr,
|
||||
expectedError: hookErr.Error(),
|
||||
preserveStreamErr: true,
|
||||
},
|
||||
{
|
||||
name: "no errors",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := normalizeExecHookError(test.streamErr, test.contextErr, defaultTimeout)
|
||||
if test.expectedError == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.EqualError(t, err, test.expectedError)
|
||||
if test.preserveStreamErr && err != test.streamErr {
|
||||
t.Fatalf("expected stream error to be returned unchanged")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureContainerExists(t *testing.T) {
|
||||
pod := &corev1api.Pod{
|
||||
Spec: corev1api.PodSpec{
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
Copyright 2026 the Velero contributors.
|
||||
|
||||
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 podexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/remotecommand"
|
||||
|
||||
v1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
|
||||
velerotest "github.com/vmware-tanzu/velero/pkg/test"
|
||||
)
|
||||
|
||||
const timeoutTestPodJSON = `{
|
||||
"metadata": {"namespace": "ns", "name": "pod-1"},
|
||||
"spec": {"containers": [{"name": "container-1"}]}
|
||||
}`
|
||||
|
||||
// contextAwareExecutor returns once its context is canceled, like the SPDY executor does.
|
||||
type contextAwareExecutor struct {
|
||||
canceled chan struct{}
|
||||
canceledOnce bool
|
||||
}
|
||||
|
||||
func (e *contextAwareExecutor) Stream(options remotecommand.StreamOptions) error { return nil }
|
||||
|
||||
func (e *contextAwareExecutor) StreamWithContext(ctx context.Context, options remotecommand.StreamOptions) error {
|
||||
<-ctx.Done()
|
||||
if !e.canceledOnce {
|
||||
e.canceledOnce = true
|
||||
close(e.canceled)
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// contextIgnoringExecutor lets the outer timeout path return before the stream does.
|
||||
// Once released, the stream goroutine can only exit if its result channel is buffered.
|
||||
type contextIgnoringExecutor struct {
|
||||
release <-chan struct{}
|
||||
returned *sync.WaitGroup
|
||||
}
|
||||
|
||||
func (e *contextIgnoringExecutor) Stream(options remotecommand.StreamOptions) error { return nil }
|
||||
|
||||
func (e *contextIgnoringExecutor) StreamWithContext(ctx context.Context, options remotecommand.StreamOptions) error {
|
||||
defer e.returned.Done()
|
||||
<-e.release
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTimeoutTestExecutor(t *testing.T, exec remotecommand.Executor) (*defaultPodCommandExecutor, map[string]any) {
|
||||
t.Helper()
|
||||
|
||||
clientConfig := &rest.Config{}
|
||||
poster := &mockPoster{}
|
||||
podCommandExecutor := NewPodCommandExecutor(clientConfig, poster).(*defaultPodCommandExecutor)
|
||||
|
||||
factory := &mockStreamExecutorFactory{}
|
||||
podCommandExecutor.streamExecutorFactory = factory
|
||||
|
||||
baseURL, _ := url.Parse("https://some.server")
|
||||
contentConfig := rest.ClientContentConfig{GroupVersion: schema.GroupVersion{Group: "", Version: "v1"}}
|
||||
poster.On("Post").Return(rest.NewRequestWithClient(baseURL, "/api/v1", contentConfig, nil))
|
||||
factory.On("NewSPDYExecutor", clientConfig, "POST", mock.Anything).Return(exec, nil)
|
||||
|
||||
pod, err := velerotest.GetAsMap(timeoutTestPodJSON)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return podCommandExecutor, pod
|
||||
}
|
||||
|
||||
func timeoutTestHook(timeout time.Duration) *v1.ExecHook {
|
||||
return &v1.ExecHook{
|
||||
Container: "container-1",
|
||||
Command: []string{"sh", "-c", "sleep 60"},
|
||||
Timeout: metav1.Duration{Duration: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
// A hook that times out must have its exec stream canceled, otherwise the command keeps
|
||||
// running on the API server after ExecutePodCommand has returned.
|
||||
func TestExecutePodCommandCancelsStreamOnTimeout(t *testing.T) {
|
||||
exec := &contextAwareExecutor{canceled: make(chan struct{})}
|
||||
podCommandExecutor, pod := newTimeoutTestExecutor(t, exec)
|
||||
|
||||
err := podCommandExecutor.ExecutePodCommand(velerotest.NewLogger(), pod, "ns", "pod-1", "hookName", timeoutTestHook(100*time.Millisecond))
|
||||
if err == nil {
|
||||
t.Fatal("expected a timeout error")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-exec.canceled:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("stream was not canceled after the hook timed out")
|
||||
}
|
||||
}
|
||||
|
||||
// When the stream returns because the context expired, both select cases are ready and one
|
||||
// is picked at random, so the reported error must not depend on which one wins.
|
||||
func TestExecutePodCommandTimeoutErrorIsDeterministic(t *testing.T) {
|
||||
const (
|
||||
rounds = 50
|
||||
expectedError = "timed out after 1ms"
|
||||
)
|
||||
|
||||
messages := map[string]int{}
|
||||
for range rounds {
|
||||
exec := &contextAwareExecutor{canceled: make(chan struct{})}
|
||||
podCommandExecutor, pod := newTimeoutTestExecutor(t, exec)
|
||||
|
||||
err := podCommandExecutor.ExecutePodCommand(velerotest.NewLogger(), pod, "ns", "pod-1", "hookName", timeoutTestHook(time.Millisecond))
|
||||
if err == nil {
|
||||
t.Fatal("expected a timeout error")
|
||||
}
|
||||
if err.Error() != expectedError {
|
||||
t.Fatalf("expected %q, got %q", expectedError, err)
|
||||
}
|
||||
messages[err.Error()]++
|
||||
}
|
||||
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("expected one error message, got %d: %v", len(messages), messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePodCommandDoesNotLeakOnTimeout(t *testing.T) {
|
||||
const rounds = 10
|
||||
|
||||
runtime.GC()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
before := runtime.NumGoroutine()
|
||||
|
||||
release := make(chan struct{})
|
||||
returned := &sync.WaitGroup{}
|
||||
for range rounds {
|
||||
returned.Add(1)
|
||||
exec := &contextIgnoringExecutor{release: release, returned: returned}
|
||||
podCommandExecutor, pod := newTimeoutTestExecutor(t, exec)
|
||||
|
||||
if err := podCommandExecutor.ExecutePodCommand(velerotest.NewLogger(), pod, "ns", "pod-1", "hookName", timeoutTestHook(50*time.Millisecond)); err == nil {
|
||||
t.Fatal("expected a timeout error")
|
||||
}
|
||||
}
|
||||
|
||||
// Every ExecutePodCommand call has already taken the timeout path. Releasing the
|
||||
// streams now forces their goroutines to send into an errCh with no receiver.
|
||||
close(release)
|
||||
returned.Wait()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
runtime.GC()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if leaked := runtime.NumGoroutine() - before; leaked >= rounds {
|
||||
t.Fatalf("%d goroutines leaked over %d timed out hooks", leaked, rounds)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user