Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 95 additions & 14 deletions pkg/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
rbacv1 "k8s.io/api/rbac/v1"
storagev1 "k8s.io/api/storage/v1"
extensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apiextensions-apiserver/pkg/registry/customresource/tableconvertor"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
Expand Down Expand Up @@ -393,7 +394,7 @@ func (h handler) getAPIV1ClusterResources(w http.ResponseWriter, r *http.Request
}

if asTable {
table, err := toTable(result, r)
table, err := h.toTable(result, r)
if err != nil {
log.Error("could not convert to table: ", err)
} else {
Expand Down Expand Up @@ -519,7 +520,7 @@ func (h handler) getAPIV1NamespaceResources(w http.ResponseWriter, r *http.Reque
}

if asTable {
table, err := toTable(decoded, r)
table, err := h.toTable(decoded, r)
if err != nil {
log.Warn("could not convert to table: ", err)
} else {
Expand Down Expand Up @@ -629,7 +630,7 @@ func (h handler) getAPIV1NamespaceResource(w http.ResponseWriter, r *http.Reques
}

if asTable {
table, err := toTable(result, r)
table, err := h.toTable(result, r)
if err != nil {
log.Warn("could not convert to table: ", err)
} else {
Expand Down Expand Up @@ -998,7 +999,7 @@ func (h handler) getAPIsClusterResources(w http.ResponseWriter, r *http.Request)
decoded = list
}

table, err := toTable(decoded, r)
table, err := h.toTable(decoded, r)
if err != nil {
log.Warn("could not convert to table:", err)
} else {
Expand Down Expand Up @@ -1065,7 +1066,7 @@ func (h handler) getAPIsClusterResources(w http.ResponseWriter, r *http.Request)
result = list
}

table, err := toTable(result, r)
table, err := h.toTable(result, r)
if err != nil {
log.Warn("could not convert to table:", err)
} else {
Expand All @@ -1085,7 +1086,7 @@ func (h handler) getAPIsClusterResource(w http.ResponseWriter, r *http.Request)
asTable := strings.Contains(r.Header.Get("Accept"), "as=Table") // who needs parsing
setResponse := func(d runtime.Object) {
if asTable {
table, err := toTable(d, r)
table, err := h.toTable(d, r)
if err != nil {
log.Warn("could not convert to table: ", err)
} else {
Expand Down Expand Up @@ -1193,7 +1194,7 @@ func (h handler) getAPIsNamespaceResources(w http.ResponseWriter, r *http.Reques
}

if asTable {
table, err := toTable(decoded, r)
table, err := h.toTable(decoded, r)
if err != nil {
log.Warn("could not convert to table: ", err)
} else {
Expand All @@ -1218,7 +1219,7 @@ func (h handler) getAPIsNamespaceResource(w http.ResponseWriter, r *http.Request

setResponse := func(d runtime.Object) {
if asTable {
table, err := toTable(d, r)
table, err := h.toTable(d, r)
if err != nil {
log.Warn("could not convert to table: ", err)
} else {
Expand Down Expand Up @@ -1538,7 +1539,62 @@ func podToSelectableFields(pod *corev1.Pod) fields.Set {
return generic.MergeFieldsSets(specificFieldsSet, objectMetaFieldsSet)
}

func toTable(object runtime.Object, r *http.Request) (runtime.Object, error) {
// crdPrinterColumns returns the additionalPrinterColumns for the CRD that matches the
// request's group/version/resource, when the bundle ships its CustomResourceDefinition.
// Returns ok=false when no CRD matches or it declares no extra columns, so the caller
// falls back to the built-in NAME/AGE table.
func (h handler) crdPrinterColumns(r *http.Request) ([]extensionsv1.CustomResourceColumnDefinition, bool) {
group := mux.Vars(r)["group"]
version := mux.Vars(r)["version"]
resource := mux.Vars(r)["resource"]
if group == "" || resource == "" {
return nil, false
}

fileName := filepath.Join(
h.clusterData.ClusterResourcesDir,
fmt.Sprintf("%s.json", sbctlutil.GetSBCompatibleResourceName("customresourcedefinitions")),
)
data, err := readFileAndLog(fileName)
if err != nil {
// A bundle without any CRDs is normal; only surface real read errors.
if !os.IsNotExist(err) {
log.Warn("could not read CRD definitions: ", err)
}
return nil, false
}

// Only v1 CRDs carry per-version additionalPrinterColumns; v1beta1 bundles
// fall through to the built-in NAME/AGE table.
var crdList extensionsv1.CustomResourceDefinitionList
if err := json.Unmarshal(data, &crdList); err != nil {
log.Warn("could not unmarshal CRD definitions: ", err)
return nil, false
}

for i := range crdList.Items {
crd := &crdList.Items[i]
if crd.Spec.Group != group {
continue
}
if crd.Spec.Names.Plural != resource && crd.Spec.Names.Singular != resource {
continue
}
for j := range crd.Spec.Versions {
v := &crd.Spec.Versions[j]
if v.Name != version {
continue
}
if len(v.AdditionalPrinterColumns) == 0 {
return nil, false
}
return v.AdditionalPrinterColumns, true
}
}
return nil, false
}

func (h handler) toTable(object runtime.Object, r *http.Request) (runtime.Object, error) {
switch o := object.(type) {
case *corev1.PodList:
converted := &apicore.PodList{}
Expand Down Expand Up @@ -1740,12 +1796,37 @@ func toTable(object runtime.Object, r *http.Request) (runtime.Object, error) {

ctx := context.TODO()
tableOptions := &metav1.TableOptions{}
tableConvertor := printerstorage.TableConvertor{
TableGenerator: printers.NewTableGenerator().With(printersinternal.AddHandlers),

var table *metav1.Table
var err error

// Custom resources arrive as unstructured objects, which the built-in table
// generator renders with only NAME/AGE. When the bundle ships the matching CRD
// definition, render with its additionalPrinterColumns so `kubectl get <cr> -o wide`
// shows the same columns a live apiserver would.
switch object.(type) {
case *unstructured.Unstructured, *unstructured.UnstructuredList:
if cols, ok := h.crdPrinterColumns(r); ok {
if conv, cerr := tableconvertor.New(cols); cerr == nil {
table, err = conv.ConvertToTable(ctx, object, tableOptions)
if err != nil {
log.Warn("could not convert custom resource to table via CRD columns: ", err)
table = nil
}
} else {
log.Warn("could not build CRD table convertor: ", cerr)
}
}
}
table, err := tableConvertor.ConvertToTable(ctx, object, tableOptions)
if err != nil {
return nil, err

if table == nil {
tableConvertor := printerstorage.TableConvertor{
TableGenerator: printers.NewTableGenerator().With(printersinternal.AddHandlers),
}
table, err = tableConvertor.ConvertToTable(ctx, object, tableOptions)
if err != nil {
return nil, err
}
}

// TODO: github.com/golang/gddo is no longer maintained. We should
Expand Down
35 changes: 35 additions & 0 deletions tests/backupstoragelocations_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package tests

import (
_ "embed"
"fmt"
"net/http"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

//go:embed results/backupstoragelocations_velero_namespace.json
var expectedGetBackupStorageLocationsResult string

var _ = Describe("GET /apis/velero.io/v1/namespaces/{namespace}/backupstoragelocations", func() {
Context("When getting a custom resource whose CRD defines additionalPrinterColumns", func() {
It("Renders the CRD-defined columns instead of only NAME/AGE", func() {
resp, statusCode, err := HTTPExec(
"GET",
fmt.Sprintf("%s/apis/velero.io/v1/namespaces/velero/backupstoragelocations", apiServerEndpoint),
getHeaders,
)
Expect(err).NotTo(HaveOccurred())
Expect(statusCode).To(Equal(http.StatusOK))

// The CRD's additionalPrinterColumns must surface as table columns. Without
// them a custom resource falls back to the built-in NAME/AGE table.
Expect(resp).To(ContainSubstring(`"name":"Phase"`))
Expect(resp).To(ContainSubstring(`"name":"Last Validated"`))
Expect(resp).To(ContainSubstring(`"name":"Default"`))

Expect(resp).To(Similar(expectedGetBackupStorageLocationsResult))
})
})
})
1 change: 1 addition & 0 deletions tests/results/backupstoragelocations_velero_namespace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"kind":"Table","apiVersion":"meta.k8s.io/v1","metadata":{},"columnDefinitions":[{"name":"Name","type":"string","format":"name","description":"Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names","priority":0},{"name":"Phase","type":"string","format":"","description":"Backup Storage Location status such as Available/Unavailable","priority":0},{"name":"Last Validated","type":"date","format":"","description":"LastValidationTime is the last time the backup store location was validated","priority":0},{"name":"Age","type":"date","format":"","description":"Custom resource definition column (in JSONPath format): .metadata.creationTimestamp","priority":0},{"name":"Default","type":"boolean","format":"","description":"Default backup storage location","priority":0}],"rows":[{"cells":["default","Available","4y44d","4y45d",true],"object":{"kind":"PartialObjectMetadata","apiVersion":"meta.k8s.io/v1","metadata":{"name":"default","namespace":"velero","creationTimestamp":"2022-04-11T22:51:33Z"}}}]}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[
{
"apiVersion": "velero.io/v1",
"kind": "BackupStorageLocation",
"metadata": {
"name": "default",
"namespace": "velero",
"creationTimestamp": "2022-04-11T22:51:33Z"
},
"spec": {
"default": true,
"provider": "aws",
"objectStorage": { "bucket": "velero-backups" }
},
"status": {
"phase": "Available",
"lastValidationTime": "2022-04-12T10:00:00Z"
}
}
]