Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

On disk fake client #1729

Merged
merged 5 commits into from
Jul 23, 2024
Merged
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
89 changes: 89 additions & 0 deletions pkg/manifestclient/encoding.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package manifestclient

import (
"encoding/json"
"fmt"
"strings"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer"
)

func individualFromList(objList *unstructured.UnstructuredList, name string) (*unstructured.Unstructured, error) {
individualKind := strings.TrimSuffix(objList.GetKind(), "List")

for _, obj := range objList.Items {
if obj.GetName() != name {
continue
}

ret := obj.DeepCopy()
ret.SetKind(individualKind)
return ret, nil
}

return nil, fmt.Errorf("not found in this list")
}

func readListFile(contentReader RawReader, path string) (*unstructured.UnstructuredList, error) {
content, err := contentReader.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("unable to read %q: %w", path, err)
}

return decodeListObj(content)
}

func readIndividualFile(contentReader RawReader, path string) (*unstructured.Unstructured, error) {
content, err := contentReader.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("unable to read %q: %w", path, err)
}

return decodeIndividualObj(content)
}

var localScheme = runtime.NewScheme()
var codecs = serializer.NewCodecFactory(localScheme)

func decodeIndividualObj(content []byte) (*unstructured.Unstructured, error) {
obj, _, err := codecs.UniversalDecoder().Decode(content, nil, &unstructured.Unstructured{})
if err != nil {
return nil, fmt.Errorf("unable to decode: %w", err)
}
return obj.(*unstructured.Unstructured), nil
}

func decodeListObj(content []byte) (*unstructured.UnstructuredList, error) {
obj, _, err := codecs.UniversalDecoder().Decode(content, nil, &unstructured.UnstructuredList{})
if err != nil {
return nil, fmt.Errorf("unable to decode: %w", err)
}
return obj.(*unstructured.UnstructuredList), nil
}

func serializeIndividualObjToJSON(obj *unstructured.Unstructured) (string, error) {
ret, err := json.MarshalIndent(obj.Object, "", " ")
if err != nil {
return "", err
}
return string(ret) + "\n", nil
}

func serializeListObjToJSON(obj *unstructured.UnstructuredList) (string, error) {
ret, err := json.MarshalIndent(obj, "", " ")
if err != nil {
return "", err
}
return string(ret) + "\n", nil
}

func serializeAPIResourceListToJSON(obj *metav1.APIResourceList) (string, error) {
ret, err := json.MarshalIndent(obj, "", " ")
if err != nil {
return "", err
}
return string(ret) + "\n", nil
}
113 changes: 113 additions & 0 deletions pkg/manifestclient/get.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package manifestclient

import (
"errors"
"fmt"
"io/fs"
"path/filepath"

apirequest "k8s.io/apiserver/pkg/endpoints/request"
)

// must-gather has a few different ways to store resources
// 1. cluster-scoped-resource/group/resource/<name>.yaml
// 2. cluster-scoped-resource/group/resource.yaml
// 3. namespaces/<namespace>/group/resource/<name>.yaml
// 4. namespaces/<namespace>/group/resource.yaml
// we have to choose which to prefer and we should always prefer the #2 if it's available.
// Keep in mind that to produce a cluster-scoped list of namespaced resources, you can need to navigate many namespaces.
func (mrt *manifestRoundTripper) get(requestInfo *apirequest.RequestInfo) ([]byte, error) {
if len(requestInfo.Name) == 0 {
return nil, fmt.Errorf("name required for GET")
}
if len(requestInfo.Resource) == 0 {
return nil, fmt.Errorf("resource required for GET")
}
requiredAPIVersion := fmt.Sprintf("%s/%s", requestInfo.APIGroup, requestInfo.APIVersion)
if len(requestInfo.APIGroup) == 0 {
requiredAPIVersion = fmt.Sprintf("%s", requestInfo.APIVersion)
}

individualFilePath := individualFileLocation(requestInfo)
individualObj, individualErr := readIndividualFile(mrt.contentReader, individualFilePath)
switch {
case errors.Is(individualErr, fs.ErrNotExist):
// try for the list
case individualErr != nil:
return nil, fmt.Errorf("unable to read file: %w", individualErr)
default:
if individualObj.GetAPIVersion() != requiredAPIVersion {
return nil, fmt.Errorf("actual version %v does not match request %v", individualObj.GetAPIVersion(), requiredAPIVersion)
}
ret, err := serializeIndividualObjToJSON(individualObj)
if err != nil {
return nil, fmt.Errorf("failed to serialize %v: %v", individualFilePath, err)
}
return []byte(ret), nil
}

listFilePath := listFileLocation(requestInfo)
listObj, listErr := readListFile(mrt.contentReader, listFilePath)
switch {
case errors.Is(listErr, fs.ErrNotExist):
// we need this to be a not-found when sent back
return nil, newNotFound(requestInfo)

case listErr != nil:
return nil, fmt.Errorf("unable to read file: %w", listErr)
default:
obj, err := individualFromList(listObj, requestInfo.Name)
if obj == nil {
return nil, newNotFound(requestInfo)
}
if obj.GetAPIVersion() != requiredAPIVersion {
return nil, fmt.Errorf("actual version %v does not match request %v", obj.GetAPIVersion(), requiredAPIVersion)
}

ret, err := serializeIndividualObjToJSON(obj)
if err != nil {
return nil, fmt.Errorf("failed to serialize %v: %v", listFilePath, err)
}
return []byte(ret), nil
}
}

func individualFileLocation(requestInfo *apirequest.RequestInfo) string {
fileParts := []string{}

if len(requestInfo.Namespace) > 0 {
fileParts = append(fileParts, "namespaces", requestInfo.Namespace)
} else {
fileParts = append(fileParts, "cluster-scoped-resources")
}

if len(requestInfo.APIGroup) > 0 {
fileParts = append(fileParts, requestInfo.APIGroup)
} else {
fileParts = append(fileParts, "core")
}

fileParts = append(fileParts, requestInfo.Resource, fmt.Sprintf("%s.yaml", requestInfo.Name))

return filepath.Join(fileParts...)
}

func listFileLocation(requestInfo *apirequest.RequestInfo) string {
fileParts := []string{}

if len(requestInfo.Namespace) > 0 {
fileParts = append(fileParts, "namespaces", requestInfo.Namespace)
} else {
fileParts = append(fileParts, "cluster-scoped-resources")
}

if len(requestInfo.APIGroup) > 0 {
fileParts = append(fileParts, requestInfo.APIGroup)
} else {
fileParts = append(fileParts, "core")
}

fileParts = append(fileParts, fmt.Sprintf("%s.yaml", requestInfo.Resource))

return filepath.Join(fileParts...)
}
192 changes: 192 additions & 0 deletions pkg/manifestclient/group_resource_discovery.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package manifestclient

import (
"errors"
"fmt"
"io/fs"
"path/filepath"
"strings"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
apirequest "k8s.io/apiserver/pkg/endpoints/request"
)

func (mrt *manifestRoundTripper) getGroupResourceDiscovery(requestInfo *apirequest.RequestInfo) ([]byte, error) {
if len(requestInfo.Path) == 0 {
return nil, fmt.Errorf("path required for group resource discovery")
}

apiResourceList := &metav1.APIResourceList{}

group, version, err := splitGroupVersionFromRequestPath(requestInfo.Path)
if err != nil {
return nil, fmt.Errorf("unable to split group/version from path: %w", err)
}

apiResourceList.GroupVersion = fmt.Sprintf("%s/%s", group, version)
if group == "core" {
apiResourceList.GroupVersion = version
}

// Map of resource name to APIResource.
apiResources := map[string]metav1.APIResource{}

clusterGroupPath := filepath.Join("cluster-scoped-resources", group)
clusterGroupDirEntries, err := mrt.contentReader.ReadDir(clusterGroupPath)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("unable to read directory: %w", err)
}

apiResourcesForClusterScope, err := getAPIResourcesFromNamespaceDirEntries(clusterGroupDirEntries, mrt.contentReader, group, version, clusterGroupPath, false /* cluster-scoped */)
if err != nil {
return nil, fmt.Errorf("unable to get resources from cluster-scoped directory: %w", err)
}
for resourceName, apiResource := range apiResourcesForClusterScope {
apiResources[resourceName] = apiResource
}

namespaceDirEntries, err := mrt.contentReader.ReadDir("namespaces")
if err != nil {
return nil, fmt.Errorf("unable to read directory: %w", err)
}
for _, namespaceDirEntry := range namespaceDirEntries {
if !namespaceDirEntry.IsDir() {
continue
}

namespaceGroupPath := filepath.Join("namespaces", namespaceDirEntry.Name(), group)
namespaceGroupDirEntries, err := mrt.contentReader.ReadDir(namespaceGroupPath)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("unable to read directory: %w", err)
} else if errors.Is(err, fs.ErrNotExist) {
// No resources for this namespace.
continue
}

apiResourcesForNamespace, err := getAPIResourcesFromNamespaceDirEntries(namespaceGroupDirEntries, mrt.contentReader, group, version, namespaceGroupPath, true /* namespaced */)
if err != nil {
return nil, fmt.Errorf("unable to get resources from namespace directory: %w", err)
}

for resourceName, apiResource := range apiResourcesForNamespace {
apiResources[resourceName] = apiResource
}
}

for _, apiResource := range apiResources {
apiResourceList.APIResources = append(apiResourceList.APIResources, apiResource)
}

ret, err := serializeAPIResourceListToJSON(apiResourceList)
if err != nil {
return nil, fmt.Errorf("failed to serialize group resource discovery: %v", err)
}
return []byte(ret), nil
}

func splitGroupVersionFromRequestPath(path string) (string, string, error) {
if path == "/api/v1" {
return "core", "v1", nil
}

parts := strings.Split(path, "/")
if len(parts) != 4 {
return "", "", fmt.Errorf("invalid path: %s", path)
}

return parts[2], parts[3], nil
}

func getResourceDirAPIServerListEntry(contentReader RawReader, groupPath, resourceName, group, version string, namespaced bool) (*metav1.APIResource, error) {
resourceDirEntries, err := contentReader.ReadDir(filepath.Join(groupPath, resourceName))
if err != nil {
return nil, fmt.Errorf("unable to read directory: %w", err)
}
for _, fileEntry := range resourceDirEntries {
if !strings.HasSuffix(fileEntry.Name(), ".yaml") {
// There shouldn't be anything that hits this, but ignore it if there is.
continue
}

individualObj, individualErr := readIndividualFile(contentReader, filepath.Join(groupPath, resourceName, fileEntry.Name()))
if individualErr != nil {
return nil, fmt.Errorf("unable to read file: %w", individualErr)
}

groupVersion := fmt.Sprintf("%s/%s", group, version)
if group == "core" {
group = ""
groupVersion = version
}

if individualObj.GetAPIVersion() != groupVersion {
continue
}

// No point checking further, all files should produce the same APIResource.
return &metav1.APIResource{
Name: resourceName,
Kind: individualObj.GetKind(),
Group: group,
Version: version,
Namespaced: namespaced,
Verbs: []string{"get", "list", "watch"},
}, nil
}

return nil, nil
}

func getAPIResourcesFromNamespaceDirEntries(dirEntries []fs.DirEntry, contentReader RawReader, group, version string, basePath string, namespaced bool) (map[string]metav1.APIResource, error) {
apiResources := map[string]metav1.APIResource{}
for _, dirEntry := range dirEntries {
// Directories are named after the resource and contain individual resources.
if dirEntry.IsDir() {
apiResource, err := getResourceDirAPIServerListEntry(contentReader, basePath, dirEntry.Name(), group, version, namespaced)
if err != nil {
return nil, fmt.Errorf("unable to get resource from directory: %w", err)
}
if apiResource != nil {
apiResources[dirEntry.Name()] = *apiResource
}
}

if !strings.HasSuffix(dirEntry.Name(), ".yaml") {
// There shouldn't be anything that hits this, but ignore it if there is.
continue
}

resourceName := strings.TrimSuffix(dirEntry.Name(), ".yaml")
if _, ok := apiResources[resourceName]; ok {
// We already have this resource.
continue
}

// Files are named after the resource and contain a list of resources.
listObj, err := readListFile(contentReader, filepath.Join(basePath, dirEntry.Name()))
if err != nil {
return nil, fmt.Errorf("unable to read list file: %w", err)
}

for _, obj := range listObj.Items {
if obj.GetAPIVersion() != fmt.Sprintf("%s/%s", group, version) {
continue
}

apiResources[resourceName] = metav1.APIResource{
Name: resourceName,
Kind: obj.GetKind(),
Group: group,
Version: version,
Namespaced: namespaced,
Verbs: []string{"get", "list", "watch"},
}

// Once we find a resource in the expected group/version, we can break.
// Anything else would produce the same APIResource.
break
}
}

return apiResources, nil
}
Loading