diff --git a/cmd/kube-controller-manager/app/controllermanager.go b/cmd/kube-controller-manager/app/controllermanager.go index 572a34e868b..ddfb135b201 100644 --- a/cmd/kube-controller-manager/app/controllermanager.go +++ b/cmd/kube-controller-manager/app/controllermanager.go @@ -40,6 +40,7 @@ import ( "k8s.io/apimachinery/pkg/util/wait" "k8s.io/apiserver/pkg/server/healthz" "k8s.io/apiserver/pkg/server/mux" + "k8s.io/apiserver/pkg/server/statusz" utilfeature "k8s.io/apiserver/pkg/util/feature" cacheddiscovery "k8s.io/client-go/discovery/cached/memory" "k8s.io/client-go/informers" @@ -67,7 +68,6 @@ import ( "k8s.io/component-base/version/verflag" zpagesfeatures "k8s.io/component-base/zpages/features" "k8s.io/component-base/zpages/flagz" - "k8s.io/component-base/zpages/statusz" genericcontrollermanager "k8s.io/controller-manager/app" "k8s.io/controller-manager/controller" "k8s.io/controller-manager/pkg/clientbuilder" diff --git a/cmd/kube-proxy/app/server.go b/cmd/kube-proxy/app/server.go index 81aa2b7241a..0100183a959 100644 --- a/cmd/kube-proxy/app/server.go +++ b/cmd/kube-proxy/app/server.go @@ -41,6 +41,7 @@ import ( "k8s.io/apiserver/pkg/server/healthz" "k8s.io/apiserver/pkg/server/mux" "k8s.io/apiserver/pkg/server/routes" + "k8s.io/apiserver/pkg/server/statusz" "k8s.io/apiserver/pkg/util/compatibility" utilfeature "k8s.io/apiserver/pkg/util/feature" "k8s.io/client-go/informers" @@ -62,7 +63,6 @@ import ( "k8s.io/component-base/version/verflag" zpagesfeatures "k8s.io/component-base/zpages/features" "k8s.io/component-base/zpages/flagz" - "k8s.io/component-base/zpages/statusz" nodeutil "k8s.io/component-helpers/node/util" "k8s.io/klog/v2" api "k8s.io/kubernetes/pkg/apis/core" diff --git a/cmd/kube-proxy/app/server_test.go b/cmd/kube-proxy/app/server_test.go index 495ce5c2318..4c85915d913 100644 --- a/cmd/kube-proxy/app/server_test.go +++ b/cmd/kube-proxy/app/server_test.go @@ -28,8 +28,8 @@ import ( "time" "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apiserver/pkg/server/statusz" "k8s.io/apiserver/pkg/util/compatibility" - "k8s.io/component-base/zpages/statusz" v1 "k8s.io/api/core/v1" kubeproxyconfig "k8s.io/kubernetes/pkg/proxy/apis/config" diff --git a/cmd/kube-scheduler/app/server.go b/cmd/kube-scheduler/app/server.go index 65fb40ac563..633417189d5 100644 --- a/cmd/kube-scheduler/app/server.go +++ b/cmd/kube-scheduler/app/server.go @@ -40,6 +40,7 @@ import ( "k8s.io/apiserver/pkg/server/healthz" "k8s.io/apiserver/pkg/server/mux" "k8s.io/apiserver/pkg/server/routes" + "k8s.io/apiserver/pkg/server/statusz" "k8s.io/apiserver/pkg/util/compatibility" utilfeature "k8s.io/apiserver/pkg/util/feature" "k8s.io/client-go/informers" @@ -61,7 +62,6 @@ import ( "k8s.io/component-base/version/verflag" zpagesfeatures "k8s.io/component-base/zpages/features" "k8s.io/component-base/zpages/flagz" - "k8s.io/component-base/zpages/statusz" "k8s.io/klog/v2" schedulerserverconfig "k8s.io/kubernetes/cmd/kube-scheduler/app/config" "k8s.io/kubernetes/cmd/kube-scheduler/app/options" diff --git a/pkg/generated/openapi/zz_generated.openapi.go b/pkg/generated/openapi/zz_generated.openapi.go index 55e56242cc8..57596d7e903 100644 --- a/pkg/generated/openapi/zz_generated.openapi.go +++ b/pkg/generated/openapi/zz_generated.openapi.go @@ -90,6 +90,7 @@ import ( intstr "k8s.io/apimachinery/pkg/util/intstr" version "k8s.io/apimachinery/pkg/version" auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" + apiv1alpha1 "k8s.io/apiserver/pkg/server/statusz/api/v1alpha1" clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1" clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" configv1alpha1 "k8s.io/cloud-provider/config/v1alpha1" @@ -1336,6 +1337,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA auditv1.Policy{}.OpenAPIModelName(): schema_pkg_apis_audit_v1_Policy(ref), auditv1.PolicyList{}.OpenAPIModelName(): schema_pkg_apis_audit_v1_PolicyList(ref), auditv1.PolicyRule{}.OpenAPIModelName(): schema_pkg_apis_audit_v1_PolicyRule(ref), + apiv1alpha1.Statusz{}.OpenAPIModelName(): schema_server_statusz_api_v1alpha1_Statusz(ref), clientauthenticationv1.Cluster{}.OpenAPIModelName(): schema_pkg_apis_clientauthentication_v1_Cluster(ref), clientauthenticationv1.ExecCredential{}.OpenAPIModelName(): schema_pkg_apis_clientauthentication_v1_ExecCredential(ref), clientauthenticationv1.ExecCredentialSpec{}.OpenAPIModelName(): schema_pkg_apis_clientauthentication_v1_ExecCredentialSpec(ref), @@ -64828,6 +64830,100 @@ func schema_pkg_apis_audit_v1_PolicyRule(ref common.ReferenceCallback) common.Op } } +func schema_server_statusz_api_v1alpha1_Statusz(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Statusz is a struct used for versioned statusz endpoint.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Description: "Standard object's metadata.", + Default: map[string]interface{}{}, + Ref: ref(metav1.ObjectMeta{}.OpenAPIModelName()), + }, + }, + "startTime": { + SchemaProps: spec.SchemaProps{ + Description: "StartTime is the time the component process was initiated.", + Ref: ref(metav1.Time{}.OpenAPIModelName()), + }, + }, + "uptimeSeconds": { + SchemaProps: spec.SchemaProps{ + Description: "UptimeSeconds is the duration in seconds for which the component has been running continuously.", + Default: 0, + Type: []string{"integer"}, + Format: "int64", + }, + }, + "goVersion": { + SchemaProps: spec.SchemaProps{ + Description: "GoVersion is the version of the Go programming language used to build the binary. The format is not guaranteed to be consistent across different Go builds.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "binaryVersion": { + SchemaProps: spec.SchemaProps{ + Description: "BinaryVersion is the version of the component's binary. The format is not guaranteed to be semantic versioning and may be an arbitrary string.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "emulationVersion": { + SchemaProps: spec.SchemaProps{ + Description: "EmulationVersion is the Kubernetes API version which this component is emulating. if present, formatted as \".\"", + Type: []string{"string"}, + Format: "", + }, + }, + "paths": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "set", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "Paths contains relative URLs to other essential read-only endpoints for debugging and troubleshooting.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + }, + Required: []string{"startTime", "uptimeSeconds", "binaryVersion"}, + }, + }, + Dependencies: []string{ + metav1.ObjectMeta{}.OpenAPIModelName(), metav1.Time{}.OpenAPIModelName()}, + } +} + func schema_pkg_apis_clientauthentication_v1_Cluster(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/pkg/kubelet/server/auth.go b/pkg/kubelet/server/auth.go index 43a6ad0fe51..6f5fb75096d 100644 --- a/pkg/kubelet/server/auth.go +++ b/pkg/kubelet/server/auth.go @@ -26,10 +26,10 @@ import ( "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/server/healthz" + "k8s.io/apiserver/pkg/server/statusz" utilfeature "k8s.io/apiserver/pkg/util/feature" "k8s.io/component-base/configz" "k8s.io/component-base/zpages/flagz" - "k8s.io/component-base/zpages/statusz" "k8s.io/klog/v2" "k8s.io/kubernetes/pkg/features" ) diff --git a/pkg/kubelet/server/server.go b/pkg/kubelet/server/server.go index b0d8d7fa3ae..8cd47f46281 100644 --- a/pkg/kubelet/server/server.go +++ b/pkg/kubelet/server/server.go @@ -59,6 +59,7 @@ import ( "k8s.io/apiserver/pkg/server/healthz" "k8s.io/apiserver/pkg/server/httplog" "k8s.io/apiserver/pkg/server/routes" + "k8s.io/apiserver/pkg/server/statusz" "k8s.io/apiserver/pkg/util/compatibility" utilfeature "k8s.io/apiserver/pkg/util/feature" "k8s.io/apiserver/pkg/util/flushwriter" @@ -70,7 +71,6 @@ import ( "k8s.io/component-base/metrics/prometheus/slis" zpagesfeatures "k8s.io/component-base/zpages/features" "k8s.io/component-base/zpages/flagz" - "k8s.io/component-base/zpages/statusz" runtimeapi "k8s.io/cri-api/pkg/apis/runtime/v1" "k8s.io/cri-client/pkg/util" podresourcesapi "k8s.io/kubelet/pkg/apis/podresources/v1" diff --git a/staging/src/k8s.io/apiserver/pkg/server/genericapiserver.go b/staging/src/k8s.io/apiserver/pkg/server/genericapiserver.go index e687f099df1..9f0cbc3380a 100644 --- a/staging/src/k8s.io/apiserver/pkg/server/genericapiserver.go +++ b/staging/src/k8s.io/apiserver/pkg/server/genericapiserver.go @@ -51,13 +51,13 @@ import ( "k8s.io/apiserver/pkg/registry/rest" "k8s.io/apiserver/pkg/server/healthz" "k8s.io/apiserver/pkg/server/routes" + "k8s.io/apiserver/pkg/server/statusz" "k8s.io/apiserver/pkg/storageversion" utilfeature "k8s.io/apiserver/pkg/util/feature" restclient "k8s.io/client-go/rest" basecompatibility "k8s.io/component-base/compatibility" "k8s.io/component-base/featuregate" zpagesfeatures "k8s.io/component-base/zpages/features" - "k8s.io/component-base/zpages/statusz" "k8s.io/klog/v2" openapibuilder3 "k8s.io/kube-openapi/pkg/builder3" openapicommon "k8s.io/kube-openapi/pkg/common" diff --git a/staging/src/k8s.io/apiserver/pkg/server/statusz/OWNERS b/staging/src/k8s.io/apiserver/pkg/server/statusz/OWNERS new file mode 100644 index 00000000000..01225cdf85d --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/server/statusz/OWNERS @@ -0,0 +1,7 @@ +approvers: + - sig-instrumentation-approvers + - sig-api-machinery-approvers +reviewers: + - sig-instrumentation-reviewers +labels: + - sig/instrumentation diff --git a/staging/src/k8s.io/apiserver/pkg/server/statusz/api/OWNERS b/staging/src/k8s.io/apiserver/pkg/server/statusz/api/OWNERS new file mode 100644 index 00000000000..0cd610188ae --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/server/statusz/api/OWNERS @@ -0,0 +1,11 @@ +# Disable inheritance as this is an api owners file +options: + no_parent_owners: true +approvers: + - api-approvers +reviewers: + - api-reviewers + - sig-instrumentation-reviewers +labels: + - kind/api-change + - sig/instrumentation diff --git a/staging/src/k8s.io/apiserver/pkg/server/statusz/api/v1alpha1/doc.go b/staging/src/k8s.io/apiserver/pkg/server/statusz/api/v1alpha1/doc.go new file mode 100644 index 00000000000..aa0fdaed0b6 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/server/statusz/api/v1alpha1/doc.go @@ -0,0 +1,22 @@ +/* +Copyright 2025 The Kubernetes 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. +*/ + +// +k8s:deepcopy-gen=package +// +k8s:openapi-gen=true +// +k8s:openapi-model-package=io.k8s.apiserver.pkg.server.statusz.api.v1alpha1 + +// Package v1alpha1 contains API Schema definitions for the zpages v1alpha1 API group +package v1alpha1 diff --git a/staging/src/k8s.io/apiserver/pkg/server/statusz/api/v1alpha1/register.go b/staging/src/k8s.io/apiserver/pkg/server/statusz/api/v1alpha1/register.go new file mode 100644 index 00000000000..6a64dc21b44 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/server/statusz/api/v1alpha1/register.go @@ -0,0 +1,47 @@ +/* +Copyright 2025 The Kubernetes 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 v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +const ( + GroupName = "config.k8s.io" + Version = "v1alpha1" +) + +// SchemeGroupVersion is group version used to register these objects +var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: Version} + +var ( + // SchemeBuilder initializes a scheme builder + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + // AddToScheme is a global function that adds this group's types to a scheme + AddToScheme = SchemeBuilder.AddToScheme +) + +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &Statusz{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/staging/src/k8s.io/apiserver/pkg/server/statusz/api/v1alpha1/types.go b/staging/src/k8s.io/apiserver/pkg/server/statusz/api/v1alpha1/types.go new file mode 100644 index 00000000000..ccbe05cb8f4 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/server/statusz/api/v1alpha1/types.go @@ -0,0 +1,50 @@ +/* +Copyright 2025 The Kubernetes 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 v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// Statusz is a struct used for versioned statusz endpoint. +type Statusz struct { + // TypeMeta is the type metadata for the object. + metav1.TypeMeta `json:",inline"` + // Standard object's metadata. + // +optional + metav1.ObjectMeta `json:"metadata,omitempty"` + // StartTime is the time the component process was initiated. + StartTime metav1.Time `json:"startTime"` + // UptimeSeconds is the duration in seconds for which the component has been running continuously. + UptimeSeconds int64 `json:"uptimeSeconds"` + // GoVersion is the version of the Go programming language used to build the binary. + // The format is not guaranteed to be consistent across different Go builds. + // +optional + GoVersion string `json:"goVersion"` + // BinaryVersion is the version of the component's binary. + // The format is not guaranteed to be semantic versioning and may be an arbitrary string. + BinaryVersion string `json:"binaryVersion"` + // EmulationVersion is the Kubernetes API version which this component is emulating. + // if present, formatted as "." + // +optional + EmulationVersion string `json:"emulationVersion,omitempty"` + // Paths contains relative URLs to other essential read-only endpoints for debugging and troubleshooting. + // +optional + // +listType=set + Paths []string `json:"paths"` +} diff --git a/staging/src/k8s.io/apiserver/pkg/server/statusz/api/v1alpha1/zz_generated.deepcopy.go b/staging/src/k8s.io/apiserver/pkg/server/statusz/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 00000000000..e36206ae441 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/server/statusz/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,58 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Statusz) DeepCopyInto(out *Statusz) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.StartTime.DeepCopyInto(&out.StartTime) + if in.Paths != nil { + in, out := &in.Paths, &out.Paths + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Statusz. +func (in *Statusz) DeepCopy() *Statusz { + if in == nil { + return nil + } + out := new(Statusz) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Statusz) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} diff --git a/staging/src/k8s.io/apiserver/pkg/server/statusz/api/v1alpha1/zz_generated.model_name.go b/staging/src/k8s.io/apiserver/pkg/server/statusz/api/v1alpha1/zz_generated.model_name.go new file mode 100644 index 00000000000..8f2bca4ce3c --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/server/statusz/api/v1alpha1/zz_generated.model_name.go @@ -0,0 +1,27 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by openapi-gen. DO NOT EDIT. + +package v1alpha1 + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in Statusz) OpenAPIModelName() string { + return "io.k8s.apiserver.pkg.server.statusz.api.v1alpha1.Statusz" +} diff --git a/staging/src/k8s.io/apiserver/pkg/server/statusz/negotiate/negotiation.go b/staging/src/k8s.io/apiserver/pkg/server/statusz/negotiate/negotiation.go new file mode 100644 index 00000000000..1925c20c35e --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/server/statusz/negotiate/negotiation.go @@ -0,0 +1,54 @@ +/* +Copyright 2025 The Kubernetes 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 negotiate + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// StatuszEndpointRestrictions implements content negotiation restrictions for the z-pages. +// It is used to validate and restrict which GroupVersionKinds are allowed for structured responses. +type StatuszEndpointRestrictions struct{} + +// AllowsMediaTypeTransform checks if the provided GVK is supported for structured z-page responses. +func (StatuszEndpointRestrictions) AllowsMediaTypeTransform(mimeType string, mimeSubType string, gvk *schema.GroupVersionKind) bool { + if mimeType == "text" && mimeSubType == "plain" { + return gvk == nil + } + return isStructured(gvk) +} + +func (StatuszEndpointRestrictions) AllowsServerVersion(string) bool { + return false +} + +func (StatuszEndpointRestrictions) AllowsStreamSchema(s string) bool { + return false +} + +func isStructured(gvk *schema.GroupVersionKind) bool { + if gvk != nil { + if gvk.Group == "config.k8s.io" && gvk.Version == "v1alpha1" { + // TODO: extend this to Flagz once we have a structured Flagz type. + if gvk.Kind == "Statusz" { + return true + } + } + } + + return false +} diff --git a/staging/src/k8s.io/component-base/zpages/statusz/registry.go b/staging/src/k8s.io/apiserver/pkg/server/statusz/registry.go similarity index 90% rename from staging/src/k8s.io/component-base/zpages/statusz/registry.go rename to staging/src/k8s.io/apiserver/pkg/server/statusz/registry.go index 92f468e52d7..d27cb9400d2 100644 --- a/staging/src/k8s.io/component-base/zpages/statusz/registry.go +++ b/staging/src/k8s.io/apiserver/pkg/server/statusz/registry.go @@ -20,9 +20,9 @@ import ( "time" "k8s.io/apimachinery/pkg/util/version" + "k8s.io/component-base/compatibility" "k8s.io/klog/v2" - "k8s.io/component-base/compatibility" compbasemetrics "k8s.io/component-base/metrics" utilversion "k8s.io/component-base/version" ) @@ -33,6 +33,7 @@ type statuszRegistry interface { binaryVersion() *version.Version emulationVersion() *version.Version paths() []string + deprecatedVersions() map[string]bool } type registry struct { @@ -40,6 +41,8 @@ type registry struct { effectiveVersion compatibility.EffectiveVersion // listedPaths is an alphabetically sorted list of paths to be reported at /. listedPaths []string + // deprecatedVersionsMap is a map of deprecated statusz versions. + deprecatedVersionsMap map[string]bool } // Option is a function to configure registry. @@ -88,3 +91,7 @@ func (r *registry) paths() []string { return nil } + +func (r *registry) deprecatedVersions() map[string]bool { + return r.deprecatedVersionsMap +} diff --git a/staging/src/k8s.io/component-base/zpages/statusz/registry_test.go b/staging/src/k8s.io/apiserver/pkg/server/statusz/registry_test.go similarity index 100% rename from staging/src/k8s.io/component-base/zpages/statusz/registry_test.go rename to staging/src/k8s.io/apiserver/pkg/server/statusz/registry_test.go diff --git a/staging/src/k8s.io/apiserver/pkg/server/statusz/statusz.go b/staging/src/k8s.io/apiserver/pkg/server/statusz/statusz.go new file mode 100644 index 00000000000..f9f24dfbd7f --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/server/statusz/statusz.go @@ -0,0 +1,257 @@ +/* +Copyright 2024 The Kubernetes 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 statusz + +import ( + "fmt" + "net/http" + "sort" + "strings" + "time" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/component-base/compatibility" + + "k8s.io/apiserver/pkg/endpoints/handlers/negotiation" + "k8s.io/apiserver/pkg/endpoints/handlers/responsewriters" + "k8s.io/apiserver/pkg/server/statusz/negotiate" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + v1alpha1 "k8s.io/apiserver/pkg/server/statusz/api/v1alpha1" +) + +var ( + delimiters = []string{":", ": ", "=", " "} + nonDebuggingEndpoints = map[string]bool{ + "/apis": true, + "/api": true, + "/openid": true, + "/openapi": true, + "/.well-known": true, + } +) + +const ( + DefaultStatuszPath = "/statusz" + Kind = "Statusz" + GroupName = "config.k8s.io" + Version = "v1alpha1" +) + +const headerFmt = ` +%s statusz +Warning: This endpoint is not meant to be machine parseable, has no formatting compatibility guarantees and is for debugging purposes only. +` + +var schemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: Version} + +type mux interface { + Handle(path string, handler http.Handler) +} + +type ListedPathsOption []string + +func NewRegistry(effectiveVersion compatibility.EffectiveVersion, opts ...Option) statuszRegistry { + r := ®istry{ + effectiveVersion: effectiveVersion, + } + for _, opt := range opts { + opt(r) + } + + return r +} + +func Install(m mux, componentName string, reg statuszRegistry) { + scheme := runtime.NewScheme() + utilruntime.Must(v1alpha1.AddToScheme(scheme)) + codecFactory := serializer.NewCodecFactory( + scheme, + serializer.WithSerializer(func(_ runtime.ObjectCreater, _ runtime.ObjectTyper) runtime.SerializerInfo { + textSerializer := statuszTextSerializer{componentName, reg} + return runtime.SerializerInfo{ + MediaType: "text/plain", + MediaTypeType: "text", + MediaTypeSubType: "plain", + EncodesAsText: true, + Serializer: textSerializer, + PrettySerializer: textSerializer, + } + }), + ) + m.Handle(DefaultStatuszPath, handleStatusz(componentName, reg, codecFactory, negotiate.StatuszEndpointRestrictions{})) +} + +func handleStatusz(componentName string, reg statuszRegistry, serializer runtime.NegotiatedSerializer, restrictions negotiate.StatuszEndpointRestrictions) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + obj := statusz(componentName, reg) + acceptHeader := r.Header.Get("Accept") + if strings.TrimSpace(acceptHeader) == "" { + writePlainTextResponse(obj, serializer, w) + return + } + + mediaType, serializerInfo, err := negotiation.NegotiateOutputMediaType(r, serializer, restrictions) + if err != nil { + utilruntime.HandleError(err) + responsewriters.ErrorNegotiated( + err, + serializer, + schema.GroupVersion{}, + w, + r, + ) + return + } + + var targetGV schema.GroupVersion + switch serializerInfo.MediaType { + case "application/json": + if mediaType.Convert == nil { + err := fmt.Errorf("content negotiation failed: mediaType.Convert is nil for application/json") + utilruntime.HandleError(err) + responsewriters.ErrorNegotiated( + err, + serializer, + schema.GroupVersion{}, + w, + r, + ) + return + } + targetGV = mediaType.Convert.GroupVersion() + deprecated := reg.deprecatedVersions()[targetGV.Version] + if deprecated { + w.Header().Set("Warning", `299 - "This version of the statusz endpoint is deprecated. Please use a newer version."`) + } + case "text/plain": + // Even though text/plain serialization does not use the group/version, + // the serialization machinery expects a non-zero schema.GroupVersion to be passed. + // Passing the zero value can cause errors or unexpected behavior in the negotiation logic. + targetGV = schemeGroupVersion + default: + err = fmt.Errorf("content negotiation failed: unsupported media type '%s'", serializerInfo.MediaType) + utilruntime.HandleError(err) + responsewriters.ErrorNegotiated( + err, + serializer, + schema.GroupVersion{}, + w, + r, + ) + return + } + + writeResponse(obj, serializer, targetGV, restrictions, w, r) + } +} + +// writePlainTextResponse writes the statusz response as text/plain using the registered serializer. +func writePlainTextResponse(obj runtime.Object, serializer runtime.NegotiatedSerializer, w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + // Find the text/plain serializer + var textSerializer runtime.Serializer + for _, info := range serializer.SupportedMediaTypes() { + if info.MediaType == "text/plain" { + textSerializer = info.Serializer + break + } + } + if textSerializer == nil { + utilruntime.HandleError(fmt.Errorf("text/plain serializer not available")) + w.WriteHeader(http.StatusInternalServerError) + return + } + if err := textSerializer.Encode(obj, w); err != nil { + utilruntime.HandleError(fmt.Errorf("error encoding statusz as text/plain: %w", err)) + w.WriteHeader(http.StatusInternalServerError) + } +} + +func writeResponse(obj runtime.Object, serializer runtime.NegotiatedSerializer, targetGV schema.GroupVersion, restrictions negotiate.StatuszEndpointRestrictions, w http.ResponseWriter, r *http.Request) { + responsewriters.WriteObjectNegotiated( + serializer, + restrictions, + targetGV, + w, + r, + http.StatusOK, + obj, + true, + ) +} + +func statusz(componentName string, reg statuszRegistry) *v1alpha1.Statusz { + startTime := reg.processStartTime() + upTimeSeconds := max(0, int64(time.Since(startTime).Seconds())) + goVersion := reg.goVersion() + binaryVersion := reg.binaryVersion().String() + var emulationVersion string + if reg.emulationVersion() != nil { + emulationVersion = reg.emulationVersion().String() + } + + paths := aggregatePaths(reg.paths()) + data := &v1alpha1.Statusz{ + TypeMeta: metav1.TypeMeta{ + Kind: Kind, + APIVersion: fmt.Sprintf("%s/%s", GroupName, Version), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: componentName, + }, + StartTime: metav1.Time{Time: startTime}, + UptimeSeconds: upTimeSeconds, + GoVersion: goVersion, + BinaryVersion: binaryVersion, + EmulationVersion: emulationVersion, + Paths: paths, + } + + return data +} + +func uptime(t time.Time) string { + upSince := int64(time.Since(t).Seconds()) + return fmt.Sprintf("%d hr %02d min %02d sec", + upSince/3600, (upSince/60)%60, upSince%60) +} + +func aggregatePaths(listedPaths []string) []string { + paths := make(map[string]bool) + for _, listedPath := range listedPaths { + parts := strings.Split(listedPath, "/") + if len(parts) < 2 || parts[1] == "" { + continue + } + folder := "/" + parts[1] + if !paths[folder] && !nonDebuggingEndpoints[folder] { + paths[folder] = true + } + } + + var sortedPaths []string + for p := range paths { + sortedPaths = append(sortedPaths, p) + } + sort.Strings(sortedPaths) + + return sortedPaths +} diff --git a/staging/src/k8s.io/apiserver/pkg/server/statusz/statusz_test.go b/staging/src/k8s.io/apiserver/pkg/server/statusz/statusz_test.go new file mode 100644 index 00000000000..cd0b2a17d45 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/server/statusz/statusz_test.go @@ -0,0 +1,361 @@ +/* +Copyright 2024 The Kubernetes 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 statusz + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + + "k8s.io/apimachinery/pkg/util/version" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + v1alpha1 "k8s.io/apiserver/pkg/server/statusz/api/v1alpha1" +) + +const wantTmpl = ` +%s statusz +Warning: This endpoint is not meant to be machine parseable, has no formatting compatibility guarantees and is for debugging purposes only. + +Started: %v +Up: %s +Go version: %s +Binary version: %v +Emulation version: %v +Paths: /livez /readyz +` + +const wantTmplWithoutEmulation = ` +%s statusz +Warning: This endpoint is not meant to be machine parseable, has no formatting compatibility guarantees and is for debugging purposes only. + +Started: %v +Up: %s +Go version: %s +Binary version: %v + +Paths: /livez /readyz +` + +func TestHandleStatusz(t *testing.T) { + delimiters = []string{":"} + fakeStartTime := time.Now() + fakeUptime := uptime(fakeStartTime) + fakeGoVersion := "1.21" + fakeBvStr := "1.31" + fakeEvStr := "1.30" + fakeBinaryVersion := parseVersion(t, fakeBvStr) + fakeEmulationVersion := parseVersion(t, fakeEvStr) + fakeListedPaths := []string{"/livez/poststarthook/peer-discovery-cache-sync", "/livez/post", "/readyz/informer-sync", "/readyz/log", "/readyz/ping"} + tests := []struct { + name string + acceptHeader string + componentName string + registry fakeRegistry + wantStatusCode int + wantBody string + wantJSONBody *v1alpha1.Statusz + wantWarning bool + }{ + { + name: "valid request for text/plain", + acceptHeader: "text/plain", + componentName: "test-server", + registry: fakeRegistry{ + startTime: fakeStartTime, + goVer: fakeGoVersion, + binaryVer: fakeBinaryVersion, + emulationVer: fakeEmulationVersion, + listedPaths: fakeListedPaths, + }, + wantStatusCode: http.StatusOK, + wantBody: fmt.Sprintf( + wantTmpl, + "test-server", + fakeStartTime.Format(time.UnixDate), + fakeUptime, + fakeGoVersion, + fakeBinaryVersion, + fakeEmulationVersion, + ), + }, + { + name: "valid request for v1alpha1", + acceptHeader: "application/json;v=v1alpha1;g=config.k8s.io;as=Statusz", + componentName: "test-server", + registry: fakeRegistry{ + startTime: fakeStartTime, + goVer: fakeGoVersion, + binaryVer: fakeBinaryVersion, + emulationVer: fakeEmulationVersion, + listedPaths: fakeListedPaths, + deprecated: map[string]bool{}, + }, + wantStatusCode: http.StatusOK, + wantJSONBody: &v1alpha1.Statusz{ + TypeMeta: metav1.TypeMeta{ + Kind: Kind, + APIVersion: fmt.Sprintf("%s/%s", GroupName, Version), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + }, + StartTime: metav1.Time{Time: fakeStartTime}, + UptimeSeconds: int64(time.Since(fakeStartTime).Seconds()), + GoVersion: fakeGoVersion, + BinaryVersion: fakeBvStr, + EmulationVersion: fakeEvStr, + Paths: []string{"/livez", "/readyz"}, + }, + }, + { + name: "no accept header", + acceptHeader: "", + componentName: "test-server", + registry: fakeRegistry{ + startTime: fakeStartTime, + goVer: fakeGoVersion, + binaryVer: fakeBinaryVersion, + emulationVer: fakeEmulationVersion, + listedPaths: fakeListedPaths, + }, + wantStatusCode: http.StatusOK, + wantBody: fmt.Sprintf( + wantTmpl, + "test-server", + fakeStartTime.Format(time.UnixDate), + fakeUptime, + fakeGoVersion, + fakeBinaryVersion, + fakeEmulationVersion, + ), + }, + { + name: "invalid accept header", + acceptHeader: "application/xml", + componentName: "test-server", + wantStatusCode: http.StatusNotAcceptable, + }, + { + name: "missing emulation version", + acceptHeader: "text/plain", + componentName: "test-server", + registry: fakeRegistry{ + startTime: fakeStartTime, + goVer: fakeGoVersion, + binaryVer: fakeBinaryVersion, + emulationVer: nil, + listedPaths: fakeListedPaths, + }, + wantStatusCode: http.StatusOK, + wantBody: fmt.Sprintf( + wantTmplWithoutEmulation, + "test-server", + fakeStartTime.Format(time.UnixDate), + fakeUptime, + fakeGoVersion, + fakeBinaryVersion, + ), + }, + { + name: "application/json without params", + acceptHeader: "application/json", + componentName: "test-server", + wantStatusCode: http.StatusNotAcceptable, + }, + { + name: "application/json with missing as", + acceptHeader: "application/json;v=v1alpha1;g=config.k8s.io", + componentName: "test-server", + wantStatusCode: http.StatusNotAcceptable, + }, + { + name: "wildcard accept header", + acceptHeader: "*/*", + componentName: "test-server", + registry: fakeRegistry{ + startTime: fakeStartTime, + goVer: fakeGoVersion, + binaryVer: fakeBinaryVersion, + emulationVer: fakeEmulationVersion, + listedPaths: fakeListedPaths, + }, + wantStatusCode: http.StatusOK, + wantBody: fmt.Sprintf( + wantTmpl, + "test-server", + fakeStartTime.Format(time.UnixDate), + fakeUptime, + fakeGoVersion, + fakeBinaryVersion, + fakeEmulationVersion, + ), + }, + { + name: "bad json header fall back wildcard", + acceptHeader: "application/json;v=foo;g=config.k8s.io;as=Statusz,*/*", + componentName: "test-server", + registry: fakeRegistry{ + startTime: fakeStartTime, + goVer: fakeGoVersion, + binaryVer: fakeBinaryVersion, + emulationVer: fakeEmulationVersion, + listedPaths: fakeListedPaths, + }, + wantStatusCode: http.StatusOK, + wantBody: fmt.Sprintf( + wantTmpl, + "test-server", + fakeStartTime.Format(time.UnixDate), + fakeUptime, + fakeGoVersion, + fakeBinaryVersion, + fakeEmulationVersion, + ), + }, + { + name: "deprecated version request", + acceptHeader: "application/json;v=v1alpha1;g=config.k8s.io;as=Statusz", + componentName: "test-server", + registry: fakeRegistry{ + startTime: fakeStartTime, + goVer: fakeGoVersion, + binaryVer: fakeBinaryVersion, + emulationVer: fakeEmulationVersion, + listedPaths: fakeListedPaths, + deprecated: map[string]bool{ + "v1alpha1": true, + }, + }, + wantStatusCode: http.StatusOK, + wantJSONBody: &v1alpha1.Statusz{ + TypeMeta: metav1.TypeMeta{ + Kind: Kind, + APIVersion: fmt.Sprintf("%s/%s", GroupName, Version), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-server", + }, + StartTime: metav1.Time{Time: fakeStartTime}, + UptimeSeconds: int64(time.Since(fakeStartTime).Seconds()), + GoVersion: fakeGoVersion, + BinaryVersion: fakeBvStr, + EmulationVersion: fakeEvStr, + Paths: []string{"/livez", "/readyz"}, + }, + wantWarning: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mux := http.NewServeMux() + Install(mux, tt.componentName, tt.registry) + + path := "/statusz" + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://example.com%s", path), nil) + if err != nil { + t.Fatalf("unexpected error while creating request: %v", err) + } + if tt.acceptHeader != "" { + req.Header.Set("Accept", tt.acceptHeader) + } + + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != tt.wantStatusCode { + t.Fatalf("want status code: %v, got: %v", tt.wantStatusCode, w.Code) + } + + if tt.wantStatusCode == http.StatusOK { + if tt.wantJSONBody != nil { + var got v1alpha1.Statusz + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("unexpected error while unmarshalling response: %v", err) + } + if diff := cmp.Diff(*tt.wantJSONBody, got, timeEqual()); diff != "" { + t.Errorf("Unexpected diff on response (-want,+got):\n%s", diff) + } + if tt.wantWarning { + if !strings.Contains(w.Header().Get("Warning"), "deprecated") { + t.Errorf("expected deprecation warning in header, but got: %s", w.Header().Get("Warning")) + } + } + } else { + if diff := cmp.Diff(tt.wantBody, string(w.Body.String())); diff != "" { + t.Errorf("Unexpected diff on response (-want,+got):\n%s", diff) + } + } + } + }) + } +} + +func parseVersion(t *testing.T, v string) *version.Version { + parsed, err := version.ParseMajorMinor(v) + if err != nil { + t.Fatalf("error parsing binary version: %s", v) + } + + return parsed +} + +type fakeRegistry struct { + startTime time.Time + goVer string + binaryVer *version.Version + emulationVer *version.Version + listedPaths []string + deprecated map[string]bool +} + +func (f fakeRegistry) deprecatedVersions() map[string]bool { + return f.deprecated +} + +func (f fakeRegistry) processStartTime() time.Time { + return f.startTime +} + +func (f fakeRegistry) goVersion() string { + return f.goVer +} + +func (f fakeRegistry) binaryVersion() *version.Version { + return f.binaryVer +} + +func (f fakeRegistry) emulationVersion() *version.Version { + return f.emulationVer +} + +func (f fakeRegistry) paths() []string { + return f.listedPaths +} + +func timeEqual() cmp.Option { + return cmp.Comparer(func(expectedTime, actualTime metav1.Time) bool { + return expectedTime.Truncate(time.Second).Equal(actualTime.Truncate(time.Second)) + }) +} diff --git a/staging/src/k8s.io/apiserver/pkg/server/statusz/textserializer.go b/staging/src/k8s.io/apiserver/pkg/server/statusz/textserializer.go new file mode 100644 index 00000000000..c8bdc2c72a1 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/server/statusz/textserializer.go @@ -0,0 +1,88 @@ +/* +Copyright 2025 The Kubernetes 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 statusz + +import ( + "fmt" + "html" + "io" + "math/rand" + "strings" + "time" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + v1alpha1 "k8s.io/apiserver/pkg/server/statusz/api/v1alpha1" +) + +// statuszTextSerializer implements runtime.Serializer for text/plain output. +type statuszTextSerializer struct { + componentName string + reg statuszRegistry +} + +// Encode writes the statusz information in plain text format to the given writer, using the provided obj. +func (s statuszTextSerializer) Encode(obj runtime.Object, w io.Writer) error { + if _, err := fmt.Fprintf(w, headerFmt, s.componentName); err != nil { + return err + } + + randomIndex := rand.Intn(len(delimiters)) + delim := html.EscapeString(delimiters[randomIndex]) + + statuszObj, ok := obj.(*v1alpha1.Statusz) + if !ok { + return fmt.Errorf("expected *v1alpha1.Statusz, got %T", obj) + } + + startTime := html.EscapeString(statuszObj.StartTime.Time.Format(time.UnixDate)) + uptimeStr := html.EscapeString(uptime(statuszObj.StartTime.Time)) + goVersion := html.EscapeString(statuszObj.GoVersion) + binaryVersion := html.EscapeString(statuszObj.BinaryVersion) + + var emulationVersion string + if statuszObj.EmulationVersion != "" { + emulationVersion = fmt.Sprintf(`Emulation version%s %s`, delim, html.EscapeString(statuszObj.EmulationVersion)) + } + + paths := strings.Join(statuszObj.Paths, " ") + if paths != "" { + paths = fmt.Sprintf(`Paths%s %s`, delim, html.EscapeString(paths)) + } + + status := fmt.Sprintf(` +Started%[1]s %[2]s +Up%[1]s %[3]s +Go version%[1]s %[4]s +Binary version%[1]s %[5]s +%[6]s +%[7]s +`, delim, startTime, uptimeStr, goVersion, binaryVersion, emulationVersion, paths) + _, err := fmt.Fprint(w, status) + return err +} + +// Decode is not supported for text/plain serialization. +func (s statuszTextSerializer) Decode(data []byte, gvk *schema.GroupVersionKind, into runtime.Object) (runtime.Object, *schema.GroupVersionKind, error) { + return nil, nil, fmt.Errorf("decode not supported for text/plain") +} + +// Identifier returns a unique identifier for this serializer. +func (s statuszTextSerializer) Identifier() runtime.Identifier { + return runtime.Identifier("statuszTextSerializer") +} diff --git a/staging/src/k8s.io/component-base/zpages/statusz/statusz.go b/staging/src/k8s.io/component-base/zpages/statusz/statusz.go deleted file mode 100644 index f001a63c62a..00000000000 --- a/staging/src/k8s.io/component-base/zpages/statusz/statusz.go +++ /dev/null @@ -1,146 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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 statusz - -import ( - "fmt" - "html" - "math/rand" - "net/http" - "sort" - "strings" - "time" - - "k8s.io/component-base/compatibility" - "k8s.io/component-base/zpages/httputil" - "k8s.io/klog/v2" -) - -var ( - delimiters = []string{":", ": ", "=", " "} - nonDebuggingEndpoints = map[string]bool{ - "/apis": true, - "/api": true, - "/openid": true, - "/openapi": true, - "/.well-known": true, - } -) - -const DefaultStatuszPath = "/statusz" - -const headerFmt = ` -%s statusz -Warning: This endpoint is not meant to be machine parseable, has no formatting compatibility guarantees and is for debugging purposes only. -` - -type mux interface { - Handle(path string, handler http.Handler) -} - -type ListedPathsOption []string - -func NewRegistry(effectiveVersion compatibility.EffectiveVersion, opts ...func(*registry)) statuszRegistry { - r := ®istry{effectiveVersion: effectiveVersion} - for _, opt := range opts { - opt(r) - } - - return r -} - -func Install(m mux, componentName string, reg statuszRegistry) { - m.Handle(DefaultStatuszPath, handleStatusz(componentName, reg)) -} - -func handleStatusz(componentName string, reg statuszRegistry) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if !httputil.AcceptableMediaType(r) { - http.Error(w, httputil.ErrUnsupportedMediaType.Error(), http.StatusNotAcceptable) - return - } - - fmt.Fprintf(w, headerFmt, componentName) - data, err := populateStatuszData(reg, componentName) - if err != nil { - klog.Errorf("error while populating statusz data: %v", err) - http.Error(w, "error while populating statusz data", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - fmt.Fprint(w, data) - } -} - -func populateStatuszData(reg statuszRegistry, componentName string) (string, error) { - randomIndex := rand.Intn(len(delimiters)) - delim := html.EscapeString(delimiters[randomIndex]) - startTime := html.EscapeString(reg.processStartTime().Format(time.UnixDate)) - uptime := html.EscapeString(uptime(reg.processStartTime())) - goVersion := html.EscapeString(reg.goVersion()) - binaryVersion := html.EscapeString(reg.binaryVersion().String()) - - var emulationVersion string - if reg.emulationVersion() != nil { - emulationVersion = fmt.Sprintf(`Emulation version%s %s`, delim, html.EscapeString(reg.emulationVersion().String())) - } - paths := aggregatePaths(reg.paths()) - if paths != "" { - paths = fmt.Sprintf(`Paths%s %s`, delim, html.EscapeString(paths)) - } - - status := fmt.Sprintf(` -Started%[1]s %[2]s -Up%[1]s %[3]s -Go version%[1]s %[4]s -Binary version%[1]s %[5]s -%[6]s -%[7]s -`, delim, startTime, uptime, goVersion, binaryVersion, emulationVersion, paths) - - return status, nil -} - -func uptime(t time.Time) string { - upSince := int64(time.Since(t).Seconds()) - return fmt.Sprintf("%d hr %02d min %02d sec", - upSince/3600, (upSince/60)%60, upSince%60) -} - -func aggregatePaths(listedPaths []string) string { - paths := make(map[string]bool) - for _, listedPath := range listedPaths { - folder := "/" + strings.Split(listedPath, "/")[1] - if !paths[folder] && !nonDebuggingEndpoints[folder] { - paths[folder] = true - } - } - - var sortedPaths []string - for p := range paths { - sortedPaths = append(sortedPaths, p) - } - sort.Strings(sortedPaths) - - var path string - for _, p := range sortedPaths { - path += " " + p - } - - return path -} diff --git a/staging/src/k8s.io/component-base/zpages/statusz/statusz_test.go b/staging/src/k8s.io/component-base/zpages/statusz/statusz_test.go deleted file mode 100644 index 8d9f5d0e933..00000000000 --- a/staging/src/k8s.io/component-base/zpages/statusz/statusz_test.go +++ /dev/null @@ -1,228 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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 statusz - -import ( - "fmt" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "k8s.io/apimachinery/pkg/util/version" -) - -const wantTmpl = ` -%s statusz -Warning: This endpoint is not meant to be machine parseable, has no formatting compatibility guarantees and is for debugging purposes only. - -Started: %v -Up: %s -Go version: %s -Binary version: %v -Emulation version: %v -Paths: /livez /readyz -` - -const wantTmplWithoutEmulation = ` -%s statusz -Warning: This endpoint is not meant to be machine parseable, has no formatting compatibility guarantees and is for debugging purposes only. - -Started: %v -Up: %s -Go version: %s -Binary version: %v - -Paths: /livez /readyz -` - -const wantTmplWithKubeApiserverComp = ` -%s statusz -Warning: This endpoint is not meant to be machine parseable, has no formatting compatibility guarantees and is for debugging purposes only. - -Started: %v -Up: %s -Go version: %s -Binary version: %v - -Paths: /livez /readyz -` - -func TestStatusz(t *testing.T) { - delimiters = []string{":"} - fakeStartTime := time.Now() - fakeUptime := uptime(fakeStartTime) - fakeGoVersion := "1.21" - fakeBvStr := "1.31" - fakeEvStr := "1.30" - fakeBinaryVersion := parseVersion(t, fakeBvStr) - fakeEmulationVersion := parseVersion(t, fakeEvStr) - fakeListedPaths := []string{"/livez/poststarthook/peer-discovery-cache-sync", "/livez/post", "/readyz/informer-sync", "/readyz/log", "/readyz/ping"} - tests := []struct { - name string - componentName string - reqHeader string - registry fakeRegistry - wantStatusCode int - wantBody string - }{ - { - name: "invalid header", - reqHeader: "some header", - wantStatusCode: http.StatusNotAcceptable, - }, - { - name: "valid request", - componentName: "test-server", - reqHeader: "text/plain; charset=utf-8", - registry: fakeRegistry{ - startTime: fakeStartTime, - goVer: fakeGoVersion, - binaryVer: fakeBinaryVersion, - emulationVer: fakeEmulationVersion, - listedPaths: fakeListedPaths, - }, - wantStatusCode: http.StatusOK, - wantBody: fmt.Sprintf( - wantTmpl, - "test-server", - fakeStartTime.Format(time.UnixDate), - fakeUptime, - fakeGoVersion, - fakeBinaryVersion, - fakeEmulationVersion, - ), - }, - { - name: "missing emulation version", - componentName: "test-server", - reqHeader: "text/plain; charset=utf-8", - registry: fakeRegistry{ - startTime: fakeStartTime, - goVer: fakeGoVersion, - binaryVer: fakeBinaryVersion, - emulationVer: nil, - listedPaths: fakeListedPaths, - }, - wantStatusCode: http.StatusOK, - wantBody: fmt.Sprintf( - wantTmplWithoutEmulation, - "test-server", - fakeStartTime.Format(time.UnixDate), - fakeUptime, - fakeGoVersion, - fakeBinaryVersion, - ), - }, - { - name: "valid request for kube-apiserver", - componentName: "kube-apiserver", - reqHeader: "text/plain; charset=utf-8", - registry: fakeRegistry{ - startTime: fakeStartTime, - goVer: fakeGoVersion, - binaryVer: fakeBinaryVersion, - emulationVer: nil, - listedPaths: fakeListedPaths, - }, - wantStatusCode: http.StatusOK, - wantBody: fmt.Sprintf( - wantTmplWithKubeApiserverComp, - "kube-apiserver", - fakeStartTime.Format(time.UnixDate), - fakeUptime, - fakeGoVersion, - fakeBinaryVersion, - ), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mux := http.NewServeMux() - - Install(mux, tt.componentName, tt.registry) - - path := "/statusz" - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://example.com%s", path), nil) - if err != nil { - t.Fatalf("unexpected error while creating request: %v", err) - } - - req.Header.Set("Accept", "text/plain; charset=utf-8") - if tt.reqHeader != "" { - req.Header.Set("Accept", tt.reqHeader) - } - - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - - if w.Code != tt.wantStatusCode { - t.Fatalf("want status code: %v, got: %v", tt.wantStatusCode, w.Code) - } - - if tt.wantStatusCode == http.StatusOK { - c := w.Header().Get("Content-Type") - if c != "text/plain; charset=utf-8" { - t.Fatalf("want header: %v, got: %v", "text/plain", c) - } - - if diff := cmp.Diff(tt.wantBody, string(w.Body.String())); diff != "" { - t.Errorf("Unexpected diff on response (-want,+got):\n%s", diff) - } - } - }) - } -} - -func parseVersion(t *testing.T, v string) *version.Version { - parsed, err := version.ParseMajorMinor(v) - if err != nil { - t.Fatalf("error parsing binary version: %s", v) - } - - return parsed -} - -type fakeRegistry struct { - startTime time.Time - goVer string - binaryVer *version.Version - emulationVer *version.Version - listedPaths []string -} - -func (f fakeRegistry) processStartTime() time.Time { - return f.startTime -} - -func (f fakeRegistry) goVersion() string { - return f.goVer -} - -func (f fakeRegistry) binaryVersion() *version.Version { - return f.binaryVer -} - -func (f fakeRegistry) emulationVersion() *version.Version { - return f.emulationVer -} - -func (f fakeRegistry) paths() []string { - return f.listedPaths -} diff --git a/test/integration/controlplane/kube_apiserver_test.go b/test/integration/controlplane/kube_apiserver_test.go index d26823155ae..e9d684e2c5d 100644 --- a/test/integration/controlplane/kube_apiserver_test.go +++ b/test/integration/controlplane/kube_apiserver_test.go @@ -27,6 +27,7 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" "k8s.io/apiextensions-apiserver/test/integration/fixtures" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" @@ -47,6 +48,8 @@ import ( kubeapiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing" "k8s.io/kubernetes/test/integration/etcd" "k8s.io/kubernetes/test/integration/framework" + + v1alpha1 "k8s.io/apiserver/pkg/server/statusz/api/v1alpha1" ) const ( @@ -181,23 +184,111 @@ func TestStatusz(t *testing.T) { t.Fatalf("Unexpected error: %v", err) } - res := client.CoreV1().RESTClient().Get().RequestURI("/statusz").Do(context.TODO()) - var status int - res.StatusCode(&status) - if status != http.StatusOK { - t.Fatalf("statusz/ should be healthy, got %v", status) + wantBodyStr := "statusz\nWarning: This endpoint is not meant to be machine parseable" + wantBodyJSON := &v1alpha1.Statusz{ + // StartTime, UptimeSeconds, GoVersion, BinaryVersion, + // EmulationVersion, Paths are dynamic, so we only check + // static fields + TypeMeta: metav1.TypeMeta{ + Kind: "Statusz", + APIVersion: "config.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "apiserver", + }, + Paths: []string{"/healthz", "/livez", "/metrics", "/readyz", "/statusz", "/version"}, } - expectedHeader := ` -apiserver statusz -Warning: This endpoint is not meant to be machine parseable, has no formatting compatibility guarantees and is for debugging purposes only.` - - raw, err := res.Raw() - if err != nil { - t.Fatal(err) - } - if !bytes.Contains(raw, []byte(expectedHeader)) { - t.Fatalf("Header mismatch!\nExpected:\n%s\n\nGot:\n%s", expectedHeader, string(raw)) + for _, tc := range []struct { + name string + acceptHeader string + wantStatus int + wantBodySub string // for text/plain responses + wantJSON *v1alpha1.Statusz // for structured response + }{ + { + name: "text plain response", + acceptHeader: "text/plain", + wantStatus: http.StatusOK, + wantBodySub: wantBodyStr, + }, + { + name: "structured json response", + acceptHeader: "application/json;v=v1alpha1;g=config.k8s.io;as=Statusz", + wantStatus: http.StatusOK, + wantJSON: wantBodyJSON, + }, + { + name: "no accept header (defaults to text)", + acceptHeader: "", + wantStatus: http.StatusOK, + wantBodySub: wantBodyStr, + }, + { + name: "invalid accept header", + acceptHeader: "application/xml", + wantStatus: http.StatusNotAcceptable, + }, + { + name: "application/json without params", + acceptHeader: "application/json", + wantStatus: http.StatusNotAcceptable, + }, + { + name: "application/json with missing as", + acceptHeader: "application/json;v=v1alpha1;g=config.k8s.io", + wantStatus: http.StatusNotAcceptable, + }, + { + name: "wildcard accept header", + acceptHeader: "*/*", + wantStatus: http.StatusOK, + wantBodySub: wantBodyStr, + }, + { + name: "bad json header fall back wildcard", + acceptHeader: "application/json;v=foo;g=config.k8s.io;as=Statusz,*/*", + wantStatus: http.StatusOK, + wantBodySub: wantBodyStr, + }, + } { + t.Run(tc.name, func(t *testing.T) { + req := client.CoreV1().RESTClient().Get().RequestURI("/statusz") + req.SetHeader("Accept", tc.acceptHeader) + res := req.Do(context.TODO()) + var status int + res.StatusCode(&status) + if status != tc.wantStatus { + t.Fatalf("want status %d, got %d", tc.wantStatus, status) + } + raw, err := res.Raw() + if err != nil && tc.wantStatus == http.StatusOK { + t.Fatalf("unexpected error: %v", err) + } + if tc.wantStatus == http.StatusOK { + if tc.wantBodySub != "" { + if !bytes.Contains(raw, []byte(tc.wantBodySub)) { + t.Errorf("body missing expected substring: %q\nGot:\n%s", tc.wantBodySub, string(raw)) + } + } + if tc.wantJSON != nil { + var got v1alpha1.Statusz + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("error unmarshalling JSON: %v", err) + } + // Only check static fields, since others are dynamic + if got.TypeMeta != tc.wantJSON.TypeMeta { + t.Errorf("TypeMeta mismatch: want %+v, got %+v", tc.wantJSON.TypeMeta, got.TypeMeta) + } + if got.ObjectMeta.Name != tc.wantJSON.ObjectMeta.Name { + t.Errorf("ObjectMeta.Name mismatch: want %q, got %q", tc.wantJSON.ObjectMeta.Name, got.ObjectMeta.Name) + } + if diff := cmp.Diff(tc.wantJSON.Paths, got.Paths); diff != "" { + t.Errorf("Paths mismatch (-want,+got):\n%s", diff) + } + } + } + }) } } diff --git a/test/integration/scheduler/serving/endpoints_test.go b/test/integration/scheduler/serving/endpoints_test.go index adf416e8a09..1e2f005b7ea 100644 --- a/test/integration/scheduler/serving/endpoints_test.go +++ b/test/integration/scheduler/serving/endpoints_test.go @@ -19,6 +19,7 @@ package serving import ( "crypto/tls" "crypto/x509" + "encoding/json" "fmt" "io" "net/http" @@ -28,6 +29,9 @@ import ( "strings" "testing" + "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/server/statusz/api/v1alpha1" utilfeature "k8s.io/apiserver/pkg/util/feature" "k8s.io/client-go/util/retry" featuregatetesting "k8s.io/component-base/featuregate/testing" @@ -40,8 +44,7 @@ import ( func TestEndpointHandlers(t *testing.T) { featuregatetesting.SetFeatureGatesDuringTest(t, utilfeature.DefaultFeatureGate, featuregatetesting.FeatureOverrides{ - features.ComponentFlagz: true, - features.ComponentStatusz: true, + features.ComponentFlagz: true, }) server, configStr, _, err := startTestAPIServer(t) @@ -133,17 +136,6 @@ func TestEndpointHandlers(t *testing.T) { `Warning: This endpoint is not meant to be machine parseable, ` + `has no formatting compatibility guarantees and is for debugging purposes only.`, }, - { - name: "/statusz", - path: "/statusz", - requestHeader: map[string]string{"Accept": "text/plain"}, - wantResponseCode: http.StatusOK, - wantResponseBodyRegx: `(?s)^\n` + - `kube-scheduler statusz\n` + - `Warning: This endpoint is not meant to be machine parseable, ` + - `has no formatting compatibility guarantees and is for debugging purposes only.+` + - `Paths([:=\s]+)/configz /flagz /healthz /livez /metrics /readyz\n$`, - }, } for _, tt := range tests { @@ -170,7 +162,7 @@ func TestEndpointHandlers(t *testing.T) { if err != nil { t.Fatalf("Failed to get client from test server: %v", err) } - req, err := http.NewRequest("GET", base+tt.path, nil) + req, err := http.NewRequest(http.MethodGet, base+tt.path, nil) if err != nil { t.Fatalf("failed to request: %v", err) } @@ -225,6 +217,162 @@ func TestEndpointHandlers(t *testing.T) { } } +func TestSchedulerStatusz(t *testing.T) { + featuregatetesting.SetFeatureGatesDuringTest(t, utilfeature.DefaultFeatureGate, featuregatetesting.FeatureOverrides{ + features.ComponentStatusz: true, + }) + + server, configStr, _, err := startTestAPIServer(t) + if err != nil { + t.Fatalf("Failed to start kube-apiserver server: %v", err) + } + defer server.TearDownFn() + + apiserverConfig, err := os.CreateTemp("", "kubeconfig") + if err != nil { + t.Fatalf("Failed to create config file: %v", err) + } + defer func() { + _ = os.Remove(apiserverConfig.Name()) + }() + if _, err = apiserverConfig.WriteString(configStr); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + _, ctx := ktesting.NewTestContext(t) + result, err := kubeschedulertesting.StartTestServer( + t, ctx, + []string{"--kubeconfig", apiserverConfig.Name(), "--leader-elect=false", "--authorization-always-allow-paths=/statusz"}, + ) + if err != nil { + t.Fatalf("Failed to start kube-scheduler server: %v", err) + } + if result.TearDownFn != nil { + defer result.TearDownFn() + } + + client, base, err := clientAndURLFromTestServer(result) + if err != nil { + t.Fatalf("Failed to get client from test server: %v", err) + } + + wantBodyStr := "kube-scheduler statusz\nWarning: This endpoint is not meant to be machine parseable" + wantBodyJSON := &v1alpha1.Statusz{ + TypeMeta: metav1.TypeMeta{ + Kind: "Statusz", + APIVersion: "config.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-scheduler", + }, + Paths: []string{"/configz", "/healthz", "/livez", "/metrics", "/readyz"}, + } + + for _, tc := range []struct { + name string + acceptHeader string + wantStatus int + wantBodySub string // for text/plain + wantJSON *v1alpha1.Statusz // for application/json + }{ + { + name: "text plain response", + acceptHeader: "text/plain", + wantStatus: http.StatusOK, + wantBodySub: wantBodyStr, + }, + { + name: "structured json response", + acceptHeader: "application/json;v=v1alpha1;g=config.k8s.io;as=Statusz", + wantStatus: http.StatusOK, + wantJSON: wantBodyJSON, + }, + { + name: "no accept header (defaults to text)", + acceptHeader: "", + wantStatus: http.StatusOK, + wantBodySub: wantBodyStr, + }, + { + name: "invalid accept header", + acceptHeader: "application/xml", + wantStatus: http.StatusNotAcceptable, + }, + { + name: "application/json without params", + acceptHeader: "application/json", + wantStatus: http.StatusNotAcceptable, + }, + { + name: "application/json with missing as", + acceptHeader: "application/json;v=v1alpha1;g=config.k8s.io", + wantStatus: http.StatusNotAcceptable, + }, + { + name: "wildcard accept header", + acceptHeader: "*/*", + wantStatus: http.StatusOK, + wantBodySub: wantBodyStr, + }, + { + name: "bad json header fall back wildcard", + acceptHeader: "application/json;v=foo;g=config.k8s.io;as=Statusz,*/*", + wantStatus: http.StatusOK, + wantBodySub: wantBodyStr, + }, + } { + t.Run(tc.name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, base+"/statusz", nil) + if err != nil { + t.Fatalf("failed to request: %v", err) + } + + req.Header.Set("Accept", tc.acceptHeader) + r, err := client.Do(req) + if err != nil { + t.Fatalf("failed to GET /statusz: %v", err) + } + + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + if err = r.Body.Close(); err != nil { + t.Fatalf("failed to close response body: %v", err) + } + + if r.StatusCode != tc.wantStatus { + t.Fatalf("want status %d, got %d", tc.wantStatus, r.StatusCode) + } + + if tc.wantStatus == http.StatusOK { + if tc.wantBodySub != "" { + if !strings.Contains(string(body), tc.wantBodySub) { + t.Errorf("body missing expected substring: %q\nGot:\n%s", tc.wantBodySub, string(body)) + } + } + if tc.wantJSON != nil { + var got v1alpha1.Statusz + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("error unmarshalling JSON: %v", err) + } + // Only check static fields, since others are dynamic + if got.TypeMeta != tc.wantJSON.TypeMeta { + t.Errorf("TypeMeta mismatch: want %+v, got %+v", tc.wantJSON.TypeMeta, got.TypeMeta) + } + if got.ObjectMeta.Name != tc.wantJSON.ObjectMeta.Name { + t.Errorf("ObjectMeta.Name mismatch: want %q, got %q", tc.wantJSON.ObjectMeta.Name, got.ObjectMeta.Name) + } + if diff := cmp.Diff(tc.wantJSON.Paths, got.Paths); diff != "" { + t.Errorf("Paths mismatch (-want,+got):\n%s", diff) + } + } + } + }) + } +} + // TODO: Make this a util function once there is a unified way to start a testing apiserver so that we can reuse it. func startTestAPIServer(t *testing.T) (server *kubeapiservertesting.TestServer, apiserverConfig, token string, err error) { // Insulate this test from picking up in-cluster config when run inside a pod diff --git a/test/integration/serving/serving_test.go b/test/integration/serving/serving_test.go index b1b74a38537..1f6b197f0ee 100644 --- a/test/integration/serving/serving_test.go +++ b/test/integration/serving/serving_test.go @@ -20,17 +20,21 @@ import ( "context" "crypto/tls" "crypto/x509" + "encoding/json" "fmt" "io" "net" "net/http" "os" "path" - "reflect" "slices" "strings" "testing" + "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/server/statusz/api/v1alpha1" + "k8s.io/apiserver/pkg/server" "k8s.io/apiserver/pkg/server/options" utilfeature "k8s.io/apiserver/pkg/util/feature" @@ -296,7 +300,6 @@ func fakeCloudProviderFactory(io.Reader) (cloudprovider.Interface, error) { } func TestKubeControllerManagerServingStatusz(t *testing.T) { - // authenticate to apiserver via bearer token token := "flwqkenfjasasdfmwerasd" // Fake token for testing. tokenFile, err := os.CreateTemp("", "kubeconfig") @@ -349,131 +352,161 @@ users: t.Fatal(err) } - tests := []struct { - name string - flags []string - path string - anonymous bool // to use the token or not - wantErr bool - wantSecureCode *int - wantPaths []string + wantBodyStr := "kube-controller-manager statusz\nWarning: This endpoint is not meant to be machine parseable" + wantBodyJSON := &v1alpha1.Statusz{ + TypeMeta: metav1.TypeMeta{ + Kind: "Statusz", + APIVersion: "config.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-controller-manager", + }, + Paths: []string{"/configz", "/healthz", "/metrics"}, + } + + testCases := []struct { + name string + acceptHeader string + wantStatus int + wantBodySub string // for text/plain + wantJSON *v1alpha1.Statusz // for structured json }{ { - name: "serving /statusz", - flags: []string{ - "--authentication-skip-lookup", // to survive inaccessible extensions-apiserver-authentication configmap - "--authentication-kubeconfig", apiserverConfig.Name(), - "--authorization-kubeconfig", apiserverConfig.Name(), - "--authorization-always-allow-paths", "/statusz", - "--kubeconfig", apiserverConfig.Name(), - "--leader-elect=false", - }, - path: "/statusz", - anonymous: false, - wantErr: false, - wantSecureCode: ptr.To(http.StatusOK), - wantPaths: []string{"/configz", "/healthz", "/metrics"}, + name: "text plain response", + acceptHeader: "text/plain", + wantStatus: http.StatusOK, + wantBodySub: wantBodyStr, + }, + { + name: "structured json response", + acceptHeader: "application/json;v=v1alpha1;g=config.k8s.io;as=Statusz", + wantStatus: http.StatusOK, + wantJSON: wantBodyJSON, + }, + { + name: "no accept header (defaults to text)", + acceptHeader: "", + wantStatus: http.StatusOK, + wantBodySub: wantBodyStr, + }, + { + name: "invalid accept header", + acceptHeader: "application/xml", + wantStatus: http.StatusNotAcceptable, + }, + { + name: "application/json without params", + acceptHeader: "application/json", + wantStatus: http.StatusNotAcceptable, + }, + { + name: "application/json with missing as", + acceptHeader: "application/json;v=v1alpha1;g=config.k8s.io", + wantStatus: http.StatusNotAcceptable, + }, + { + name: "wildcard accept header", + acceptHeader: "*/*", + wantStatus: http.StatusOK, + wantBodySub: wantBodyStr, + }, + { + name: "bad json header fall back wildcard", + acceptHeader: "application/json;v=foo;g=config.k8s.io;as=Statusz,*/*", + wantStatus: http.StatusOK, + wantBodySub: wantBodyStr, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, zpagesfeatures.ComponentStatusz, true) - _, ctx := ktesting.NewTestContext(t) - secureOptions, secureInfo, tearDownFn, err := kubeControllerManagerTester{}.StartTestServer(t, ctx, slices.Concat(tt.flags, []string{})) - if tearDownFn != nil { - defer tearDownFn() - } - if (err != nil) != tt.wantErr { - t.Fatalf("StartTestServer() error = %v, wantErr %v", err, tt.wantErr) - } + + featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, zpagesfeatures.ComponentStatusz, true) + _, ctx := ktesting.NewTestContext(t) + flags := []string{ + "--authentication-skip-lookup", + "--authentication-kubeconfig", apiserverConfig.Name(), + "--authorization-kubeconfig", apiserverConfig.Name(), + "--authorization-always-allow-paths", "/statusz", + "--kubeconfig", apiserverConfig.Name(), + "--leader-elect=false", + } + secureOptions, secureInfo, tearDownFn, err := kubeControllerManagerTester{}.StartTestServer(t, ctx, flags) + if tearDownFn != nil { + defer tearDownFn() + } + if err != nil { + t.Fatalf("StartTestServer() error = %v", err) + } + if secureInfo == nil { + t.Fatalf("SecureServing not enabled") + } + _, port, err := net.SplitHostPort(secureInfo.Listener.Addr().String()) + if err != nil { + t.Fatalf("could not get host and port from %s : %v", secureInfo.Listener.Addr().String(), err) + } + url := fmt.Sprintf("https://127.0.0.1:%s/statusz", port) + + // read self-signed server cert disk + pool := x509.NewCertPool() + serverCertPath := path.Join(secureOptions.ServerCert.CertDirectory, secureOptions.ServerCert.PairName+".crt") + serverCert, err := os.ReadFile(serverCertPath) + if err != nil { + t.Fatalf("Failed to read component server cert %q: %v", serverCertPath, err) + } + pool.AppendCertsFromPEM(serverCert) + tr := &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: pool, + }, + } + client := &http.Client{Transport: tr} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { - return + t.Fatal(err) } - if want, got := tt.wantSecureCode != nil, secureInfo != nil; want != got { - t.Errorf("SecureServing enabled: expected=%v got=%v", want, got) - } else if want { - // only interested on the port, because we are using always localhost - _, port, err := net.SplitHostPort(secureInfo.Listener.Addr().String()) - if err != nil { - t.Fatalf("could not get host and port from %s : %v", secureInfo.Listener.Addr().String(), err) - } - // use IPv4 because the self-signed cert does not support [::] - url := fmt.Sprintf("https://127.0.0.1:%s%s", port, tt.path) + req.Header.Set("Accept", tc.acceptHeader) + req.Header.Add("Authorization", fmt.Sprintf("Token %s", token)) + r, err := client.Do(req) + if err != nil { + t.Fatalf("failed to GET /statusz: %v", err) + } - // read self-signed server cert disk - pool := x509.NewCertPool() - serverCertPath := path.Join(secureOptions.ServerCert.CertDirectory, secureOptions.ServerCert.PairName+".crt") - serverCert, err := os.ReadFile(serverCertPath) - if err != nil { - t.Fatalf("Failed to read component server cert %q: %v", serverCertPath, err) - } - pool.AppendCertsFromPEM(serverCert) - tr := &http.Transport{ - TLSClientConfig: &tls.Config{ - RootCAs: pool, - }, - } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } - client := &http.Client{Transport: tr} - req, err := http.NewRequest(http.MethodGet, url, nil) - req.Header.Set("Accept", "text/plain") - if err != nil { - t.Fatal(err) - } - if !tt.anonymous { - req.Header.Add("Authorization", fmt.Sprintf("Token %s", token)) - } - r, err := client.Do(req) - if err != nil { - t.Fatalf("failed to GET %s from component: %v", tt.path, err) - } + if err = r.Body.Close(); err != nil { + t.Fatalf("failed to close response body: %v", err) + } - body, err := io.ReadAll(r.Body) - if err != nil { - t.Fatalf("failed to read response body: %v", err) - } - defer func() { - if err := r.Body.Close(); err != nil { - t.Fatalf("Error closing response body: %v", err) - } - }() + if r.StatusCode != tc.wantStatus { + t.Fatalf("want status %d, got %d", tc.wantStatus, r.StatusCode) + } - if got, expected := r.StatusCode, *tt.wantSecureCode; got != expected { - t.Fatalf("expected http %d at %s of component, got: %d", expected, tt.path, got) - } - - bodyStr := string(body) - - if !strings.Contains(bodyStr, "Paths") { - t.Error("response does not contain Paths section") - } - - var foundPathsRaw []string - for line := range strings.SplitSeq(bodyStr, "\n") { - if strings.HasPrefix(line, "Paths") { - parts := strings.Fields(line) - if len(parts) > 1 { - foundPathsRaw = parts[1:] // Skip "Paths" label - } - break + if tc.wantStatus == http.StatusOK { + if tc.wantBodySub != "" { + if !strings.Contains(string(body), tc.wantBodySub) { + t.Errorf("body missing expected substring: %q\nGot:\n%s", tc.wantBodySub, string(body)) } } - - expectedPaths := tt.wantPaths - - foundPathsSet := make(map[string]struct{}) - for _, p := range foundPathsRaw { - foundPathsSet[p] = struct{}{} - } - - expectedPathsSet := make(map[string]struct{}) - for _, p := range expectedPaths { - expectedPathsSet[p] = struct{}{} - } - - if !reflect.DeepEqual(foundPathsSet, expectedPathsSet) { - t.Errorf("path mismatch:\n- want: %v\n- got: %v", expectedPaths, foundPathsRaw) + if tc.wantJSON != nil { + var got v1alpha1.Statusz + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("error unmarshalling JSON: %v", err) + } + // Only check static fields, since others are dynamic + if got.TypeMeta != tc.wantJSON.TypeMeta { + t.Errorf("TypeMeta mismatch: want %+v, got %+v", tc.wantJSON.TypeMeta, got.TypeMeta) + } + if got.ObjectMeta.Name != tc.wantJSON.ObjectMeta.Name { + t.Errorf("ObjectMeta.Name mismatch: want %q, got %q", tc.wantJSON.ObjectMeta.Name, got.ObjectMeta.Name) + } + if diff := cmp.Diff(tc.wantJSON.Paths, got.Paths); diff != "" { + t.Errorf("Paths mismatch (-want,+got):\n%s", diff) + } } } })