feat(crank): add beta describe command

Co-authored-by: jbasement <j.keller@celonis.com>
Co-authored-by: Philippe Scorsolini <p.scorsolini@gmail.com>
Signed-off-by: Philippe Scorsolini <p.scorsolini@gmail.com>
This commit is contained in:
jbasement 2023-10-04 20:28:11 +02:00 committed by Philippe Scorsolini
parent 67bade6c2a
commit 813b7fe984
10 changed files with 1067 additions and 0 deletions

View File

@ -20,6 +20,7 @@ limitations under the License.
package beta
import (
"github.com/crossplane/crossplane/cmd/crank/beta/describe"
"github.com/crossplane/crossplane/cmd/crank/beta/render"
"github.com/crossplane/crossplane/cmd/crank/beta/xpkg"
)
@ -28,6 +29,7 @@ import (
type Cmd struct {
// Subcommands and flags will appear in the CLI help output in the same
// order they're specified here. Keep them in alphabetical order.
Describe describe.Cmd `cmd:"" help:"Describe a Crossplane resource."`
Render render.Cmd `cmd:"" help:"Render a composite resource (XR)."`
XPKG xpkg.Cmd `cmd:"" help:"Manage Crossplane packages."`
}

View File

@ -0,0 +1,127 @@
/*
Copyright 2023 The Crossplane 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 describe contains the describe command.
package describe
import (
"context"
"strings"
"github.com/alecthomas/kong"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
ctrl "sigs.k8s.io/controller-runtime"
"github.com/crossplane/crossplane-runtime/pkg/errors"
"github.com/crossplane/crossplane-runtime/pkg/logging"
"github.com/crossplane/crossplane/cmd/crank/beta/describe/internal/printer"
"github.com/crossplane/crossplane/cmd/crank/beta/describe/internal/resource"
)
const (
errGetResource = "cannot get requested resource"
errCliOutput = "cannot print output"
errKubeConfig = "failed to get kubeconfig"
errCouldntInitKubeClient = "cannot init kubeclient"
errCannotGetKindAndName = "cannot get kind and name"
errCannotGetMapping = "cannot get mapping for resource"
errCannotInitPrinter = "cannot init new printer"
errFmtInvalidResourceName = "invalid combined kind and name format, should be in the form of 'resource.group.example.org/name', got: %q"
)
// Cmd describes a Crossplane resource.
type Cmd struct {
Resource string `arg:"" required:"" help:"'TYPE[.VERSION][.GROUP][/NAME]' identifying the Crossplane resource."`
Name string `arg:"" optional:"" help:"Name of the Crossplane resource. Ignored if already passed as part of the RESOURCE argument."`
// TODO(phisco): add support for all the usual kubectl flags; configFlags := genericclioptions.NewConfigFlags(true).AddFlags(...)
Namespace string `short:"n" name:"namespace" help:"Namespace of resource to describe." default:"default"`
Output string `short:"o" name:"output" help:"Output format. One of: default, json." enum:"default,json" default:"default"`
}
// Run runs the describe command.
func (c *Cmd) Run(k *kong.Context, logger logging.Logger) error {
logger = logger.WithValues("Resource", c.Resource)
kubeconfig, err := ctrl.GetConfig()
if err != nil {
logger.Debug(errKubeConfig, "error", err)
return errors.Wrap(err, errKubeConfig)
}
logger.Debug("Found kubeconfig")
// Get client for k8s package
client, err := resource.NewClient(kubeconfig)
if err != nil {
return errors.Wrap(err, errCouldntInitKubeClient)
}
logger.Debug("Built client")
kind, name, err := c.getKindAndName()
if err != nil {
return errors.Wrap(err, errCannotGetKindAndName)
}
mapping, err := client.MappingFor(kind)
if err != nil {
return errors.Wrap(err, errCannotGetMapping)
}
// Init new printer
p, err := printer.New(c.Output)
if err != nil {
return errors.Wrap(err, errCannotInitPrinter)
}
logger.Debug("Built printer", "output", c.Output)
// Get Resource object. Contains k8s resource and all its children, also as Resource.
rootRef := &v1.ObjectReference{
Kind: mapping.GroupVersionKind.Kind,
APIVersion: mapping.GroupVersionKind.GroupVersion().String(),
Name: name,
}
if mapping.Scope.Name() == meta.RESTScopeNameNamespace && c.Namespace != "" {
rootRef.Namespace = c.Namespace
}
logger.Debug("Getting resource tree", "rootRef", rootRef.String())
root, err := client.GetResourceTree(context.Background(), rootRef)
if err != nil {
logger.Debug(errGetResource, "error", err)
return errors.Wrap(err, errGetResource)
}
logger.Debug("Got resource tree", "root", root)
// Print resources
err = p.Print(k.Stdout, root)
if err != nil {
return errors.Wrap(err, errCliOutput)
}
return nil
}
func (c *Cmd) getKindAndName() (string, string, error) {
if c.Name != "" {
return c.Resource, c.Name, nil
}
kindAndName := strings.SplitN(c.Resource, "/", 2)
if len(kindAndName) != 2 {
return "", "", errors.Errorf(errFmtInvalidResourceName, c.Resource)
}
return kindAndName[0], kindAndName[1], nil
}

View File

@ -0,0 +1,137 @@
/*
Copyright 2023 The Crossplane 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 printer
import (
"fmt"
"io"
"strings"
"k8s.io/cli-runtime/pkg/printers"
xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
"github.com/crossplane/crossplane-runtime/pkg/errors"
"github.com/crossplane/crossplane/cmd/crank/beta/describe/internal/resource"
)
const (
errFmtCannotWriteHeader = "cannot write header: %s"
errFmtCannotWriteRow = "cannot write row: %s"
errFmtCannotFlushTabWriter = "cannot flush tab writer: %s"
)
// DefaultPrinter defines the DefaultPrinter configuration
type DefaultPrinter struct {
}
var _ Printer = &DefaultPrinter{}
type defaultPrinterRow struct {
namespace string
apiVersion string
name string
ready string
synced string
latestEvent string
}
func (r *defaultPrinterRow) String() string {
return strings.Join([]string{
r.namespace,
r.apiVersion,
r.name,
r.ready,
r.synced,
r.latestEvent,
}, "\t") + "\t"
}
// Print implements the Printer interface by prints the resource tree in a
// human-readable format.
func (p *DefaultPrinter) Print(w io.Writer, root *resource.Resource) error {
tw := printers.GetNewTabWriter(w)
headers := defaultPrinterRow{
namespace: "NAMESPACE",
apiVersion: "APIVERSION",
name: "NAME",
ready: "READY",
synced: "SYNCED",
latestEvent: "LATESTEVENT",
}
if _, err := fmt.Fprintln(tw, headers.String()); err != nil {
return errors.Errorf(errFmtCannotWriteHeader, err)
}
type queueItem struct {
resource *resource.Resource
depth int
isLast bool
prefix string
}
// Initialize LIFO queue with root element
queue := []*queueItem{{resource: root}}
for len(queue) > 0 {
// Dequeue first element
var item *queueItem
item, queue = queue[0], queue[1:] //nolint:gosec // false positive, the queue length has been checked above and [1:] works even if the queue is a single element
// Choose the right prefix
name := strings.Builder{}
childPrefix := item.prefix
if item.depth > 0 {
if item.isLast {
name.WriteString(item.prefix + "└── ")
childPrefix += " "
} else {
name.WriteString(item.prefix + "├── ")
childPrefix += "│ "
}
}
name.WriteString(fmt.Sprintf("%s/%s", item.resource.Unstructured.GetKind(), item.resource.Unstructured.GetName()))
row := defaultPrinterRow{
namespace: item.resource.Unstructured.GetNamespace(),
apiVersion: item.resource.Unstructured.GetAPIVersion(),
name: name.String(),
ready: string(item.resource.GetCondition(xpv1.TypeReady).Status),
synced: string(item.resource.GetCondition(xpv1.TypeSynced).Status),
}
if e := item.resource.LatestEvent; e != nil {
row.latestEvent = fmt.Sprintf("[%s] %s", e.Type, e.Message)
}
if _, err := fmt.Fprintln(tw, row.String()); err != nil {
return errors.Errorf(errFmtCannotWriteRow, err)
}
// Enqueue the children of the current node
for idx := len(item.resource.Children) - 1; idx >= 0; idx-- {
isLast := idx == len(item.resource.Children)-1
queue = append([]*queueItem{{resource: item.resource.Children[idx], depth: item.depth + 1, isLast: isLast, prefix: childPrefix}}, queue...)
}
}
if err := tw.Flush(); err != nil {
return errors.Errorf(errFmtCannotFlushTabWriter, err)
}
return nil
}

View File

@ -0,0 +1,129 @@
/*
Copyright 2023 The Crossplane 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 printer
import (
"bytes"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
v1 "k8s.io/api/core/v1"
"github.com/crossplane/crossplane-runtime/pkg/test"
"github.com/crossplane/crossplane/cmd/crank/beta/describe/internal/resource"
)
func TestDefaultPrinter(t *testing.T) {
type args struct {
resource *resource.Resource
}
type want struct {
output string
err error
}
cases := map[string]struct {
reason string
args args
want want
}{
// Test valid resource
"ResourceWithChildren": {
reason: "Should print a complex Resource with children and events.",
args: args{
resource: &resource.Resource{
Unstructured: DummyManifest("ObjectStorage", "test-resource", "True", "True"),
LatestEvent: &v1.Event{
Type: "Normal",
Message: "Successfully selected composition",
},
Children: []*resource.Resource{
{
Unstructured: DummyManifest("XObjectStorage", "test-resource-hash", "True", "True"),
LatestEvent: nil,
Children: []*resource.Resource{
{
Unstructured: DummyManifest("Bucket", "test-resource-bucket-hash", "True", "True"),
LatestEvent: &v1.Event{
Type: "Warning",
Message: "Error with bucket",
},
Children: []*resource.Resource{
{
Unstructured: DummyManifest("User", "test-resource-child-1-bucket-hash", "True", "False"),
},
{
Unstructured: DummyManifest("User", "test-resource-child-2-bucket-hash", "True", "False"),
Children: []*resource.Resource{
{
Unstructured: DummyManifest("User", "test-resource-child-2-1-bucket-hash", "True", "False"),
},
},
},
},
},
{
Unstructured: DummyManifest("User", "test-resource-user-hash", "True", "True"),
LatestEvent: &v1.Event{
Type: "Normal",
Message: "User ready",
},
},
},
},
},
},
},
want: want{
// Note: Use spaces instead of tabs for intendation
output: `
NAMESPACE APIVERSION NAME READY SYNCED LATESTEVENT
default test.cloud/v1alpha1 ObjectStorage/test-resource True True [Normal] Successfully selected composition
default test.cloud/v1alpha1 XObjectStorage/test-resource-hash True True
default test.cloud/v1alpha1 Bucket/test-resource-bucket-hash True True [Warning] Error with bucket
default test.cloud/v1alpha1 User/test-resource-child-1-bucket-hash False True
default test.cloud/v1alpha1 User/test-resource-child-2-bucket-hash False True
default test.cloud/v1alpha1 User/test-resource-child-2-1-bucket-hash False True
default test.cloud/v1alpha1 User/test-resource-user-hash True True [Normal] User ready
`,
err: nil,
},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
p := DefaultPrinter{}
var buf bytes.Buffer
err := p.Print(&buf, tc.args.resource)
got := buf.String()
// Check error
if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
t.Errorf("%s\nCliTableAddResource(): -want, +got:\n%s", tc.reason, diff)
}
// Check table
if diff := cmp.Diff(strings.TrimSpace(tc.want.output), strings.TrimSpace(got)); diff != "" {
t.Errorf("%s\nCliTableAddResource(): -want, +got:\n%s", tc.reason, diff)
}
})
}
}

View File

@ -0,0 +1,47 @@
/*
Copyright 2023 The Crossplane 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 printer
import (
"encoding/json"
"fmt"
"io"
"github.com/pkg/errors"
"github.com/crossplane/crossplane/cmd/crank/beta/describe/internal/resource"
)
const (
errCannotMarshalJSON = "cannot marshal resource graph as JSON"
)
// JSONPrinter is a printer that prints the resource graph as JSON.
type JSONPrinter struct {
}
var _ Printer = &JSONPrinter{}
// Print implements the Printer interface.
func (p *JSONPrinter) Print(w io.Writer, root *resource.Resource) error {
out, err := json.MarshalIndent(root, "", " ")
if err != nil {
return errors.Wrap(err, errCannotMarshalJSON)
}
_, err = fmt.Fprintln(w, string(out))
return err
}

View File

@ -0,0 +1,235 @@
/*
Copyright 2023 The Crossplane 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 printer
import (
"bytes"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
v1 "k8s.io/api/core/v1"
"github.com/crossplane/crossplane-runtime/pkg/test"
"github.com/crossplane/crossplane/cmd/crank/beta/describe/internal/resource"
)
func TestJSONPrinter(t *testing.T) {
type args struct {
resource *resource.Resource
}
type want struct {
output string
err error
}
cases := map[string]struct {
reason string
args args
want want
}{
// Test valid resource
"ResourceWithChildren": {
reason: "Should print a complex Resource with children and events.",
args: args{
resource: &resource.Resource{
Unstructured: DummyManifest("ObjectStorage", "test-resource", "True", "True"),
LatestEvent: &v1.Event{
Message: "Successfully selected composition",
},
Children: []*resource.Resource{
{
Unstructured: DummyManifest("XObjectStorage", "test-resource-hash", "True", "True"),
Children: []*resource.Resource{
{
Unstructured: DummyManifest("Bucket", "test-resource-bucket-hash", "True", "True"),
LatestEvent: &v1.Event{
Message: "Synced bucket",
},
},
{
Unstructured: DummyManifest("User", "test-resource-user-hash", "True", "True"),
LatestEvent: &v1.Event{
Message: "User ready",
},
},
},
},
},
},
},
want: want{
// Note: Use spaces instead of tabs for intendation
output: `
{
"object": {
"apiVersion": "test.cloud/v1alpha1",
"kind": "ObjectStorage",
"metadata": {
"name": "test-resource",
"namespace": "default"
},
"status": {
"conditions": [
{
"status": "True",
"type": "Synced"
},
{
"status": "True",
"type": "Ready"
}
]
}
},
"children": [
{
"object": {
"apiVersion": "test.cloud/v1alpha1",
"kind": "XObjectStorage",
"metadata": {
"name": "test-resource-hash",
"namespace": "default"
},
"status": {
"conditions": [
{
"status": "True",
"type": "Synced"
},
{
"status": "True",
"type": "Ready"
}
]
}
},
"children": [
{
"object": {
"apiVersion": "test.cloud/v1alpha1",
"kind": "Bucket",
"metadata": {
"name": "test-resource-bucket-hash",
"namespace": "default"
},
"status": {
"conditions": [
{
"status": "True",
"type": "Synced"
},
{
"status": "True",
"type": "Ready"
}
]
}
},
"latestEvent": {
"metadata": {
"creationTimestamp": null
},
"involvedObject": {},
"message": "Synced bucket",
"source": {},
"firstTimestamp": null,
"lastTimestamp": null,
"eventTime": null,
"reportingComponent": "",
"reportingInstance": ""
}
},
{
"object": {
"apiVersion": "test.cloud/v1alpha1",
"kind": "User",
"metadata": {
"name": "test-resource-user-hash",
"namespace": "default"
},
"status": {
"conditions": [
{
"status": "True",
"type": "Synced"
},
{
"status": "True",
"type": "Ready"
}
]
}
},
"latestEvent": {
"metadata": {
"creationTimestamp": null
},
"involvedObject": {},
"message": "User ready",
"source": {},
"firstTimestamp": null,
"lastTimestamp": null,
"eventTime": null,
"reportingComponent": "",
"reportingInstance": ""
}
}
]
}
],
"latestEvent": {
"metadata": {
"creationTimestamp": null
},
"involvedObject": {},
"message": "Successfully selected composition",
"source": {},
"firstTimestamp": null,
"lastTimestamp": null,
"eventTime": null,
"reportingComponent": "",
"reportingInstance": ""
}
}
`,
err: nil,
},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
p := JSONPrinter{}
var buf bytes.Buffer
err := p.Print(&buf, tc.args.resource)
got := buf.String()
// Check error
if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
t.Errorf("%s\nCliTableAddResource(): -want, +got:\n%s", tc.reason, diff)
}
// Check table
if diff := cmp.Diff(strings.TrimSpace(tc.want.output), strings.TrimSpace(got)); diff != "" {
t.Errorf("%s\nCliTableAddResource(): -want, +got:\n%s", tc.reason, diff)
}
})
}
}

View File

@ -0,0 +1,61 @@
/*
Copyright 2023 The Crossplane 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 printer contains the definition of the Printer interface and the
// implementation of all the available printers implementing it.
package printer
import (
"io"
"github.com/pkg/errors"
"github.com/crossplane/crossplane/cmd/crank/beta/describe/internal/resource"
)
const (
errFmtUnknownPrinterType = "unknown printer output type: %s"
)
// Type represents the type of printer.
type Type string
// Implemented PrinterTypes
const (
TypeDefault Type = "default"
TypeJSON Type = "json"
)
// Printer implements the interface which is used by all printers in this package.
type Printer interface {
Print(io.Writer, *resource.Resource) error
}
// New creates a new printer based on the specified type.
func New(typeStr string) (Printer, error) {
var p Printer
switch Type(typeStr) {
case TypeDefault:
p = &DefaultPrinter{}
case TypeJSON:
p = &JSONPrinter{}
default:
return nil, errors.Errorf(errFmtUnknownPrinterType, typeStr)
}
return p, nil
}

View File

@ -0,0 +1,47 @@
/*
Copyright 2023 The Crossplane 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 printer
import "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
// Returns an unstructured that has basic fields set to be used by other tests.
func DummyManifest(kind, name, syncedStatus, readyStatus string) unstructured.Unstructured {
m := unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "test.cloud/v1alpha1",
"kind": kind,
"metadata": map[string]interface{}{
"name": name,
"namespace": "default",
},
"status": map[string]interface{}{
"conditions": []interface{}{
map[string]interface{}{
"status": syncedStatus,
"type": "Synced",
},
map[string]interface{}{
"status": readyStatus,
"type": "Ready",
},
},
},
},
}
return m
}

View File

@ -0,0 +1,238 @@
/*
Copyright 2023 The Crossplane 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 resource
import (
"context"
"fmt"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/discovery"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
"github.com/crossplane/crossplane-runtime/pkg/errors"
"github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/claim"
"github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/composite"
)
const (
errCouldntGetRootResource = "couldn't get root resource"
errCouldntGetChildResource = "couldn't get child resource"
errCouldntGetRESTMapping = "couldn't get REST mapping for resource"
errCouldntGetResource = "couldn't get resource"
errCouldntGetEventForResource = "couldn't get event for resource"
errCouldntGetEventListForResource = "couldn't get event list for resource"
errFmtResourceTypeNotFound = "the server doesn't have a resource type %q"
)
// Client to get a Resource with all its children and latest events.
type Client struct {
dynClient *dynamic.DynamicClient
clientset *kubernetes.Clientset
rmapper meta.RESTMapper
discoveryClient *discovery.DiscoveryClient
}
// GetResourceTree returns the requested Resource and all its children, with
// latest events for each of them, if any.
func (kc *Client) GetResourceTree(ctx context.Context, rootRef *v1.ObjectReference) (*Resource, error) {
// Get the root resource
root, err := kc.getResource(ctx, rootRef)
if err != nil {
return nil, errors.Wrap(err, errCouldntGetRootResource)
}
// breadth-first search of children
queue := []*Resource{root}
for len(queue) > 0 {
res := queue[0] // Take the first element
queue = queue[1:] // Dequeue the first element
refs := getResourceChildrenRefs(res)
if err != nil {
return nil, errors.Wrap(err, errCouldntGetRootResource)
}
for i := range refs {
child, err := kc.getResource(ctx, &refs[i])
if err != nil {
return nil, errors.Wrap(err, errCouldntGetChildResource)
}
res.Children = append(res.Children, child)
queue = append(queue, child) // Enqueue child
}
}
return root, nil
}
// getResource returns the requested Resource with the latest event set.
func (kc *Client) getResource(ctx context.Context, ref *v1.ObjectReference) (*Resource, error) {
rm, err := kc.rmapper.RESTMapping(ref.GroupVersionKind().GroupKind(), ref.GroupVersionKind().Version)
if err != nil {
return nil, errors.Wrap(err, errCouldntGetRESTMapping)
}
result, err := kc.dynClient.Resource(rm.Resource).Namespace(ref.Namespace).Get(ctx, ref.Name, metav1.GetOptions{})
if err != nil {
return nil, errors.Wrap(err, errCouldntGetResource)
}
// Get event
event, err := kc.getLatestEvent(ctx, *ref)
if err != nil {
return nil, errors.Wrap(err, errCouldntGetEventForResource)
}
res := &Resource{Unstructured: *result, LatestEvent: event}
return res, nil
}
// getResourceChildrenRefs returns the references to the children for the given
// Resource, assuming it's a Crossplane resource, XR or XRC.
func getResourceChildrenRefs(r *Resource) []v1.ObjectReference {
obj := r.Unstructured
// collect owner references
var refs []v1.ObjectReference
xr := composite.Unstructured{Unstructured: obj}
refs = append(refs, xr.GetResourceReferences()...)
xrc := claim.Unstructured{Unstructured: obj}
if ref := xrc.GetResourceReference(); ref != nil {
refs = append(refs, v1.ObjectReference{
APIVersion: ref.APIVersion,
Kind: ref.Kind,
Name: ref.Name,
Namespace: ref.Namespace,
UID: ref.UID,
})
}
return refs
}
// The getLatestEvent returns the latest Event for the given resource reference.
func (kc *Client) getLatestEvent(ctx context.Context, ref v1.ObjectReference) (*v1.Event, error) {
// List events for the resource.
fieldSelector := fmt.Sprintf("involvedObject.name=%s,involvedObject.kind=%s,involvedObject.apiVersion=%s", ref.Name, ref.Kind, ref.APIVersion)
if ref.UID != "" {
fieldSelector = fmt.Sprintf("%s,involvedObject.uid=%s", fieldSelector, ref.UID)
}
eventList, err := kc.clientset.CoreV1().Events(ref.Namespace).List(ctx, metav1.ListOptions{
FieldSelector: fieldSelector,
})
if err != nil {
return nil, errors.Wrap(err, errCouldntGetEventListForResource)
}
// Check if there are any events.
if len(eventList.Items) == 0 {
return nil, nil
}
latestEvent := eventList.Items[0]
for _, event := range eventList.Items {
if event.LastTimestamp.After(latestEvent.LastTimestamp.Time) {
latestEvent = event
}
}
// Get the latest event.
return &latestEvent, nil
}
// MappingFor returns the RESTMapping for the given resource or kind argument.
// Copied over from cli-runtime pkg/resource Builder.
func (kc *Client) MappingFor(resourceOrKindArg string) (*meta.RESTMapping, error) {
// TODO(phisco): actually use the Builder.
fullySpecifiedGVR, groupResource := schema.ParseResourceArg(resourceOrKindArg)
gvk := schema.GroupVersionKind{}
if fullySpecifiedGVR != nil {
gvk, _ = kc.rmapper.KindFor(*fullySpecifiedGVR)
}
if gvk.Empty() {
gvk, _ = kc.rmapper.KindFor(groupResource.WithVersion(""))
}
if !gvk.Empty() {
return kc.rmapper.RESTMapping(gvk.GroupKind(), gvk.Version)
}
fullySpecifiedGVK, groupKind := schema.ParseKindArg(resourceOrKindArg)
if fullySpecifiedGVK == nil {
gvk := groupKind.WithVersion("")
fullySpecifiedGVK = &gvk
}
if !fullySpecifiedGVK.Empty() {
if mapping, err := kc.rmapper.RESTMapping(fullySpecifiedGVK.GroupKind(), fullySpecifiedGVK.Version); err == nil {
return mapping, nil
}
}
mapping, err := kc.rmapper.RESTMapping(groupKind, gvk.Version)
if err != nil {
// if we error out here, it is because we could not match a resource or a kind
// for the given argument. To maintain consistency with previous behavior,
// announce that a resource type could not be found.
// if the error is _not_ a *meta.NoKindMatchError, then we had trouble doing discovery,
// so we should return the original error since it may help a user diagnose what is actually wrong
if meta.IsNoMatchError(err) {
return nil, fmt.Errorf(errFmtResourceTypeNotFound, groupResource.Resource)
}
return nil, err
}
return mapping, nil
}
// NewClient returns a new Client.
func NewClient(config *rest.Config) (*Client, error) {
// Use to get custom resources
dynClient, err := dynamic.NewForConfig(config)
if err != nil {
return nil, err
}
httpClient, err := rest.HTTPClientFor(config)
if err != nil {
return nil, err
}
// Use to discover API resources
discoveryClient, err := discovery.NewDiscoveryClientForConfig(config)
if err != nil {
return nil, err
}
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
return nil, err
}
rmapper, err := apiutil.NewDynamicRESTMapper(config, httpClient)
if err != nil {
return nil, err
}
return &Client{
dynClient: dynClient,
clientset: clientset,
rmapper: rmapper,
discoveryClient: discoveryClient,
}, nil
}

View File

@ -0,0 +1,44 @@
/*
Copyright 2023 The Crossplane 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 resource contains the definition of the Resource used by all describe
// printers, and the client used to get a Resource and its children.
package resource
import (
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
"github.com/crossplane/crossplane-runtime/pkg/fieldpath"
)
// Resource struct represents a kubernetes resource.
type Resource struct {
Unstructured unstructured.Unstructured `json:"object"`
Children []*Resource `json:"children,omitempty"`
LatestEvent *v1.Event `json:"latestEvent,omitempty"`
}
// GetCondition of this resource.
func (r *Resource) GetCondition(ct xpv1.ConditionType) xpv1.Condition {
conditioned := xpv1.ConditionedStatus{}
// The path is directly `status` because conditions are inline.
if err := fieldpath.Pave(r.Unstructured.Object).GetValueInto("status", &conditioned); err != nil {
return xpv1.Condition{}
}
return conditioned.GetCondition(ct)
}