diff --git a/hack/verify-min-k8s-version.sh b/hack/verify-min-k8s-version.sh index 692bf33f..f6f5d378 100755 --- a/hack/verify-min-k8s-version.sh +++ b/hack/verify-min-k8s-version.sh @@ -14,11 +14,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -# Verifies that the hardcoded Kubernetes minimum version in pkg/kind/kind.go -# and pkg/minikube/minikube.go matches DefaultKubernetesMinVersion in the -# version of knative.dev/pkg pinned in our go.sum. We keep a copy locally -# rather than importing the package to avoid pulling in client-go and its -# transitive dependency tree. +# Verifies that the hardcoded Kubernetes minimum version in pkg/kind/kind.go, +# pkg/minikube/minikube.go, and pkg/install/install.go (the recommended kubectl +# version) matches DefaultKubernetesMinVersion in the version of knative.dev/pkg +# pinned in our go.sum. We keep a copy locally rather than importing the package +# to avoid pulling in client-go and its transitive dependency tree. # # If --write is passed, the script rewrites the local files in place when # they drift from upstream (used by the auto-bump workflow). Default is @@ -61,27 +61,36 @@ kind_version="$(extract pkg/kind/kind.go \ 's/.*kubernetesVersion[[:space:]]*=[[:space:]]*"kindest\/node:v([0-9.]+)".*/\1/p')" minikube_version="$(extract pkg/minikube/minikube.go \ 's/.*kubernetesVersion[[:space:]]*=[[:space:]]*"([0-9.]+)".*/\1/p')" +kubectl_version="$(extract pkg/install/install.go \ + 's/.*kubectlMinVersion[[:space:]]*=[[:space:]]*"([0-9.]+)".*/\1/p')" +# Each entry is name:var:version:file, where var is the Go variable name holding +# the version literal. The kind/minikube clusters and the kubectl-version gate in +# pkg/install all track DefaultKubernetesMinVersion; keeping them in one loop means +# a single upstream bump updates every site. fail=0 -for entry in "kind:$kind_version:pkg/kind/kind.go" "minikube:$minikube_version:pkg/minikube/minikube.go"; do - name="${entry%%:*}" - rest="${entry#*:}" +for entry in \ + "kind:kubernetesVersion:$kind_version:pkg/kind/kind.go" \ + "minikube:kubernetesVersion:$minikube_version:pkg/minikube/minikube.go" \ + "kubectl:kubectlMinVersion:$kubectl_version:pkg/install/install.go"; do + name="${entry%%:*}"; rest="${entry#*:}" + var="${rest%%:*}"; rest="${rest#*:}" local_version="${rest%%:*}" file="${rest#*:}" if [[ -z "$local_version" ]]; then - echo "ERROR: could not parse kubernetesVersion from $file" >&2 + echo "ERROR: could not parse $var from $file" >&2 fail=1 elif [[ "$local_version" != "$upstream_version" ]]; then if [[ $WRITE -eq 1 ]]; then - sed -i.bak -E "s/(kubernetesVersion[[:space:]]*=[[:space:]]*\"(kindest\/node:v)?)${local_version}/\1${upstream_version}/" "$REPO_ROOT/$file" + sed -i.bak -E "s/(${var}[[:space:]]*=[[:space:]]*\"(kindest\/node:v)?)${local_version}/\1${upstream_version}/" "$REPO_ROOT/$file" rm -f "$REPO_ROOT/$file.bak" - echo "UPDATED: $name kubernetesVersion in $file: $local_version -> $upstream_version" + echo "UPDATED: $name $var in $file: $local_version -> $upstream_version" else - echo "ERROR: $name kubernetesVersion in $file is $local_version, upstream is $upstream_version (knative/pkg@$ref)" >&2 + echo "ERROR: $name $var in $file is $local_version, upstream is $upstream_version (knative/pkg@$ref)" >&2 fail=1 fi else - echo "OK: $name kubernetesVersion ($local_version) matches upstream (knative/pkg@$ref)" + echo "OK: $name $var ($local_version) matches upstream (knative/pkg@$ref)" fi done diff --git a/pkg/install/install.go b/pkg/install/install.go index 0b681542..a5261221 100644 --- a/pkg/install/install.go +++ b/pkg/install/install.go @@ -16,11 +16,47 @@ package install import ( "fmt" + "os" "os/exec" + "regexp" + "strconv" "strings" "time" ) +// kubectlMinVersion is the minimum kubectl version we recommend. We gate on it +// because waitForPodsReady relies on `kubectl wait --for=create` with a label +// selector, which only works correctly on kubectl >= 1.33 (see +// kubernetes/kubernetes#128662). +// +// kubectlMinVersion must match DefaultKubernetesMinVersion in +// https://github.com/knative/pkg/blob/main/version/version.go (without the +// leading "v"), matching the kind and minikube cluster versions. The +// verify-min-k8s-version CI check enforces this. We only compare the major and +// minor components at runtime; the patch component is kept so the literal +// matches upstream's format for that check. +var kubectlMinVersion = "1.34.0" + +// kubectlGitVersion matches the "vMAJOR.MINOR" prefix of kubectl's reported +// git version, e.g. "v1.34.0" or "v1.33.2-eks-1234". +var kubectlGitVersion = regexp.MustCompile(`^v?(\d+)\.(\d+)`) + +// kubectlMinMajor / kubectlMinMinor are parsed from kubectlMinVersion. +var kubectlMinMajor, kubectlMinMinor = mustParseMinVersion(kubectlMinVersion) + +// mustParseMinVersion parses the leading MAJOR.MINOR out of kubectlMinVersion. +// It panics on a malformed literal, which can only happen if kubectlMinVersion +// is edited to an invalid value (a programming/CI error, caught by tests). +func mustParseMinVersion(v string) (int, int) { + m := kubectlGitVersion.FindStringSubmatch(v) + if m == nil { + panic(fmt.Sprintf("kn-plugin-quickstart: malformed kubectlMinVersion %q", v)) + } + major, _ := strconv.Atoi(m[1]) + minor, _ := strconv.Atoi(m[2]) + return major, minor +} + // Component versions are generated at buildtime via the hack/build.sh script var ServingVersion string var KourierVersion string @@ -238,9 +274,83 @@ func waitForCRDsEstablished() error { return runCommand(exec.Command("kubectl", "wait", "--for=condition=Established", "--all", "crd")) } +// CheckKubectlVersion validates that the user has a recent enough version of +// kubectl installed. If not, it warns the user and prompts them to continue, +// mirroring the behavior of the kind and minikube version checks. +func CheckKubectlVersion() error { + versionCheck := exec.Command("kubectl", "version", "--client", "-o", "json") + out, err := versionCheck.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to get kubectl version: %w", err) + } + + major, minor, err := parseKubectlVersion(string(out)) + if err != nil { + return fmt.Errorf("unable to parse kubectl version: %w", err) + } + fmt.Printf(" kubectl version is: v%d.%d\n", major, minor) + + if major < kubectlMinMajor || (major == kubectlMinMajor && minor < kubectlMinMinor) { + var resp string + fmt.Printf("WARNING: We recommend at least kubectl v%d.%d, while you are using v%d.%d\n", kubectlMinMajor, kubectlMinMinor, major, minor) + fmt.Println("You can download a newer version from https://kubernetes.io/docs/tasks/tools/install-kubectl") + fmt.Print("Continue anyway? (not recommended) [y/N]: ") + fmt.Scanf("%s", &resp) + if resp != "y" && resp != "Y" { + fmt.Println("Installation stopped. Please upgrade kubectl and run again") + os.Exit(0) + } + } + + return nil +} + +// parseKubectlVersion extracts the client major and minor version from the +// JSON output of `kubectl version --client -o json`. It reads the gitVersion +// field (e.g. "v1.34.0") rather than the major/minor fields, which some +// distributions emit with non-numeric suffixes (e.g. minor "33+"). +func parseKubectlVersion(jsonOut string) (int, int, error) { + // Pull gitVersion out of the JSON without a full struct decode so we stay + // resilient to extra fields; fall back to matching any vX.Y in the blob. + gitVersion := "" + if idx := strings.Index(jsonOut, `"gitVersion"`); idx >= 0 { + rest := jsonOut[idx:] + if start := strings.Index(rest, `:`); start >= 0 { + rest = rest[start+1:] + if open := strings.Index(rest, `"`); open >= 0 { + rest = rest[open+1:] + if close := strings.Index(rest, `"`); close >= 0 { + gitVersion = rest[:close] + } + } + } + } + + m := kubectlGitVersion.FindStringSubmatch(gitVersion) + if m == nil { + return 0, 0, fmt.Errorf("could not find a version in kubectl output: %q", gitVersion) + } + major, err := strconv.Atoi(m[1]) + if err != nil { + return 0, 0, err + } + minor, err := strconv.Atoi(m[2]) + if err != nil { + return 0, 0, err + } + return major, minor, nil +} + // waitForPodsReady waits for all pods in the given namespace to be ready. +// +// We pass both --for=create and --for=condition=Ready because kubectl wait +// exits immediately with "no matching resources found" when no pods match the +// selector yet (e.g. the deployment controller hasn't created them). --for=create +// is always evaluated first, so this waits for the pods to appear and then for +// them to become Ready. This requires kubectl >= 1.33 for label-selector waits, +// which is within the versions this plugin already supports. func waitForPodsReady(ns string) error { - return runCommand(exec.Command("kubectl", "wait", "pod", "--timeout=10m", "--for=condition=Ready", "-l", "!job-name", "-n", ns)) + return runCommand(exec.Command("kubectl", "wait", "pod", "--timeout=10m", "--for=create", "--for=condition=Ready", "-l", "!job-name", "-n", ns)) } // waitForWebhookReady waits for the Knative Serving webhook to be ready. diff --git a/pkg/install/install_test.go b/pkg/install/install_test.go new file mode 100644 index 00000000..8496aba9 --- /dev/null +++ b/pkg/install/install_test.go @@ -0,0 +1,70 @@ +// Copyright © 2026 The Knative 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 install + +import "testing" + +func TestParseKubectlVersion(t *testing.T) { + tests := []struct { + name string + json string + wantMajor int + wantMinor int + wantErr bool + }{{ + name: "standard release", + json: `{"clientVersion":{"major":"1","minor":"34","gitVersion":"v1.34.0"}}`, + wantMajor: 1, + wantMinor: 34, + }, { + name: "patch version", + json: `{"clientVersion":{"major":"1","minor":"33","gitVersion":"v1.33.2"}}`, + wantMajor: 1, + wantMinor: 33, + }, { + name: "vendor suffix in gitVersion", + // EKS and other distributions append a suffix; we must read the + // numeric prefix, not choke on it. + json: `{"clientVersion":{"major":"1","minor":"33+","gitVersion":"v1.33.2-eks-1234567"}}`, + wantMajor: 1, + wantMinor: 33, + }, { + name: "no version present", + json: `{"clientVersion":{"buildDate":"2026-01-01"}}`, + wantErr: true, + }, { + name: "garbage", + json: `not json at all`, + wantErr: true, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + major, minor, err := parseKubectlVersion(tt.json) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got major=%d minor=%d", major, minor) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if major != tt.wantMajor || minor != tt.wantMinor { + t.Errorf("got v%d.%d, want v%d.%d", major, minor, tt.wantMajor, tt.wantMinor) + } + }) + } +} diff --git a/pkg/kind/kind.go b/pkg/kind/kind.go index 2c5f0221..a5ee0658 100644 --- a/pkg/kind/kind.go +++ b/pkg/kind/kind.go @@ -120,6 +120,9 @@ func createKindCluster(registry bool, extraMountHostPath string, extraMountConta if err := checkKindVersion(); err != nil { return fmt.Errorf("unable to check kind version: %w", err) } + if err := install.CheckKubectlVersion(); err != nil { + return fmt.Errorf("unable to check kubectl version: %w", err) + } if registry { fmt.Println("💽 Installing local registry...") if err := pullLocalRegistryImage(dcli); err != nil { diff --git a/pkg/minikube/minikube.go b/pkg/minikube/minikube.go index 32ec1c50..90a21d41 100644 --- a/pkg/minikube/minikube.go +++ b/pkg/minikube/minikube.go @@ -127,6 +127,9 @@ func createMinikubeCluster() error { if err := checkMinikubeVersion(); err != nil { return fmt.Errorf("unable to get minikube version: %w", err) } + if err := install.CheckKubectlVersion(); err != nil { + return fmt.Errorf("unable to check kubectl version: %w", err) + } if err := checkForExistingCluster(); err != nil { return fmt.Errorf("failure while handling or checking for existing minikube cluster: %w", err) }