Structured statusz

This commit is contained in:
Richa Banker 2025-09-27 18:34:32 -07:00
parent 6056b0dfa4
commit c1a95eb7e2
26 changed files with 1508 additions and 525 deletions

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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 \"<major>.<minor>\"",
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{

View file

@ -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"
)

View file

@ -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"

View file

@ -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"

View file

@ -0,0 +1,7 @@
approvers:
- sig-instrumentation-approvers
- sig-api-machinery-approvers
reviewers:
- sig-instrumentation-reviewers
labels:
- sig/instrumentation

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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 "<major>.<minor>"
// +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"`
}

View file

@ -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
}

View file

@ -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"
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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 := &registry{
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
}

View file

@ -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))
})
}

View file

@ -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")
}

View file

@ -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 := &registry{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
}

View file

@ -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
}

View file

@ -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)
}
}
}
})
}
}

View file

@ -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

View file

@ -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)
}
}
}
})