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:
parent
67bade6c2a
commit
813b7fe984
|
@ -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."`
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
Loading…
Reference in New Issue