Skip to content

Commit

Permalink
feat(crank): add beta describe command
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
jbasement and phisco committed Oct 19, 2023
1 parent 11bbe13 commit 7452c0c
Show file tree
Hide file tree
Showing 10 changed files with 1,056 additions and 2 deletions.
6 changes: 4 additions & 2 deletions cmd/crank/beta/beta.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ 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"
)

// Cmd contains beta commands.
type Cmd struct {
XPKG xpkg.Cmd `cmd:"" help:"Manage packages."`
Render render.Cmd `cmd:"" help:"Render a claim or XR locally."`
XPKG xpkg.Cmd `cmd:"" help:"Manage packages."`
Render render.Cmd `cmd:"" help:"Render a claim or XR locally."`
Describe describe.Cmd `cmd:"" help:"Describe a Crossplane resource."`
}
127 changes: 127 additions & 0 deletions cmd/crank/beta/describe/describe.go
Original file line number Diff line number Diff line change
@@ -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"
"os"
"strings"

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(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(os.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
}
136 changes: 136 additions & 0 deletions cmd/crank/beta/describe/internal/printer/default.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
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 {
Indent string
}

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
}

// Initialize queue with root element
queue := make([]*queueItem, 0)
queue = append(queue, &queueItem{root, 0, false})

for len(queue) > 0 {
// Dequeue first element
item := queue[0] //nolint:gosec // false positive, the queue length has been checked above
queue = queue[1:] //nolint:gosec // false positive, works even if the queue is a single element

// Choose the right prefix
name := strings.Builder{}
name.WriteString(strings.Repeat(p.Indent, item.depth))

if item.depth > 0 {
if item.isLast {
name.WriteString("└─ ")
} else {
name.WriteString("├─ ")
}
}

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 := range item.resource.Children {
isLast := idx == len(item.resource.Children)-1
queue = append(queue, &queueItem{item.resource.Children[idx], item.depth + 1, isLast})
}
}
if err := tw.Flush(); err != nil {
return errors.Errorf(errFmtCannotFlushTabWriter, err)
}

return nil
}
115 changes: 115 additions & 0 deletions cmd/crank/beta/describe/internal/printer/default_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
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",
},
},
{
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-user-hash True True [Normal] User ready
`,
err: nil,
},
},
}

for name, tc := range cases {
t.Run(name, func(t *testing.T) {
p := DefaultPrinter{
Indent: " ",
}
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 comments on commit 7452c0c

Please sign in to comment.