From 3cfd42c48fb3126054191eeb9f769e66dd9b46a0 Mon Sep 17 00:00:00 2001 From: Alexej Disterhoft Date: Mon, 1 Jan 2024 00:26:13 +0100 Subject: [PATCH 1/7] feat: improve list output Better date / age printing, better status, other misc improvements. Signed-off-by: Alexej Disterhoft --- internal/list/output.go | 70 +++++++++++++++++++++++---------------- internal/ticket/ticket.go | 19 +++++++---- internal/util/date.go | 47 ++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 34 deletions(-) create mode 100644 internal/util/date.go diff --git a/internal/list/output.go b/internal/list/output.go index 3710378..27a0001 100644 --- a/internal/list/output.go +++ b/internal/list/output.go @@ -7,6 +7,8 @@ import ( "io" "time" + "github.com/nobbs/kubectl-mapr-ticket/internal/ticket" + "github.com/nobbs/kubectl-mapr-ticket/internal/util" "github.com/spf13/cobra" metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -48,23 +50,35 @@ var ( Priority: 1, }, { - Name: "Created", + Name: "Expiry Time", Type: "string", Format: "date-time", - Description: "Creation time of the ticket", + Description: "Timestamp of the ticket expiry", Priority: 1, }, { - Name: "Expires", + Name: "Status", Type: "string", - Format: "date-time", - Description: "Expiration time of the ticket", + Description: "Status of the ticket", Priority: 0, }, { - Name: "Status", + Name: "Span", Type: "string", - Description: "Status of the ticket", + Description: "Duration of the ticket", + Priority: 1, + }, + { + Name: "Creation Time", + Type: "string", + Format: "date-time", + Description: "Creation time of the ticket", + Priority: 1, + }, + { + Name: "Age", + Type: "string", + Description: "Time since the ticket was created", Priority: 0, }, } @@ -77,10 +91,7 @@ func Print(cmd *cobra.Command, items []ListItem) error { switch format { case "table", "wide": // generate table for output - table, err := generateTable(items) - if err != nil { - return err - } + table := generateTable(items) // print table printer := printers.NewTablePrinter(printers.PrintOptions{ @@ -88,7 +99,7 @@ func Print(cmd *cobra.Command, items []ListItem) error { Wide: format == "wide", }) - err = printer.PrintObj(table, cmd.OutOrStdout()) + err := printer.PrintObj(table, cmd.OutOrStdout()) if err != nil { return err } @@ -103,13 +114,13 @@ func Print(cmd *cobra.Command, items []ListItem) error { } // generateTable generates a table from the secrets containing MapR tickets -func generateTable(items []ListItem) (*metaV1.Table, error) { +func generateTable(items []ListItem) *metaV1.Table { rows := generateRows(items) return &metaV1.Table{ ColumnDefinitions: listTableColumns, Rows: rows, - }, nil + } } // generateRows generates the rows for the table from the secrets containing @@ -118,7 +129,7 @@ func generateRows(items []ListItem) []metaV1.TableRow { rows := make([]metaV1.TableRow, 0, len(items)) for _, item := range items { - rows = append(rows, *generateRow(item)) + rows = append(rows, *generateRow(&item)) } return rows @@ -126,29 +137,24 @@ func generateRows(items []ListItem) []metaV1.TableRow { // generateRow generates a row for the table from the secret containing a MapR // ticket -func generateRow(item ListItem) *metaV1.TableRow { +func generateRow(item *ListItem) *metaV1.TableRow { row := &metaV1.TableRow{ Object: runtime.RawExtension{ Object: item.Secret, }, } - var status string - if item.Ticket.IsExpired() { - status = "Expired" - } else { - status = "Valid" - } - row.Cells = []any{ item.Secret.Name, item.Ticket.Cluster, item.Ticket.UserCreds.GetUserName(), item.Ticket.UserCreds.GetUid(), item.Ticket.UserCreds.GetGids(), - item.Ticket.CreateTimeToHuman(time.RFC3339), item.Ticket.ExpiryTimeToHuman(time.RFC3339), - status, + getStatus(item.Ticket), + util.ShortHumanDuration(item.Ticket.ExpiryTime().Sub(item.Ticket.CreationTime())), + item.Ticket.CreateTimeToHuman(time.RFC3339), + util.ShortHumanDurationUntilNow(item.Ticket.CreationTime()), } return row @@ -159,7 +165,7 @@ func printEncoded(items []ListItem, format string, stream io.Writer) error { if len(items) == 1 { // encode single item - _, err := bytesBuffer.Write(encodeItem(items[0], format)) + _, err := bytesBuffer.Write(encodeItem(&items[0], format)) if err != nil { return err } @@ -177,7 +183,7 @@ func printEncoded(items []ListItem, format string, stream io.Writer) error { return err } - return fmt.Errorf("not implemented") + return nil } func encodeItems(items []ListItem, format string) []byte { @@ -201,7 +207,7 @@ func encodeItems(items []ListItem, format string) []byte { return nil } -func encodeItem(item ListItem, format string) []byte { +func encodeItem(item *ListItem, format string) []byte { switch format { case "json": encoded, err := json.MarshalIndent(item, "", " ") @@ -221,3 +227,11 @@ func encodeItem(item ListItem, format string) []byte { return nil } + +func getStatus(ticket *ticket.MaprTicket) string { + if ticket.IsExpired() { + return fmt.Sprintf("Expired (%s ago)", util.ShortHumanDurationComparedToNow(ticket.ExpiryTime())) + } + + return fmt.Sprintf("Valid (%s left)", util.ShortHumanDurationComparedToNow(ticket.ExpiryTime())) +} diff --git a/internal/ticket/ticket.go b/internal/ticket/ticket.go index 45d61a0..27fc162 100644 --- a/internal/ticket/ticket.go +++ b/internal/ticket/ticket.go @@ -41,18 +41,25 @@ func NewTicketFromSecret(secret *coreV1.Secret) (*MaprTicket, error) { // isExpired returns true if the ticket is expired func (ticket *MaprTicket) IsExpired() bool { - t := time.Unix(int64(ticket.GetExpiryTime()), 0) - return time.Now().After(t) + return time.Now().After(ticket.ExpiryTime()) } // expiryTimeToHuman returns the expiry time in a human readable format func (ticket *MaprTicket) ExpiryTimeToHuman(format string) string { - t := time.Unix(int64(ticket.GetExpiryTime()), 0) - return t.Format(format) + return ticket.ExpiryTime().Format(format) } // createTimeToHuman returns the creation time in a human readable format func (ticket *MaprTicket) CreateTimeToHuman(format string) string { - t := time.Unix(int64(ticket.GetCreationTimeSec()), 0) - return t.Format(format) + return ticket.CreationTime().Format(format) +} + +// ExpiryTime returns the expiry time of the ticket as a time.Time object +func (ticket *MaprTicket) ExpiryTime() time.Time { + return time.Unix(int64(ticket.GetExpiryTime()), 0) +} + +// CreationTime returns the creation time of the ticket as a time.Time object +func (ticket *MaprTicket) CreationTime() time.Time { + return time.Unix(int64(ticket.GetCreationTimeSec()), 0) } diff --git a/internal/util/date.go b/internal/util/date.go new file mode 100644 index 0000000..524536c --- /dev/null +++ b/internal/util/date.go @@ -0,0 +1,47 @@ +package util + +import ( + "time" + + "k8s.io/apimachinery/pkg/util/duration" +) + +func HumanDurationComparedToNow(t time.Time) string { + difference := time.Since(t) + + if difference < 0 { + return HumanDuration(-difference) + } + + return HumanDuration(difference) +} + +func ShortHumanDurationComparedToNow(t time.Time) string { + difference := time.Since(t) + + if difference < 0 { + return ShortHumanDuration(-difference) + } + + return ShortHumanDuration(difference) +} + +func HumanDurationUntilNow(t time.Time) string { + difference := time.Since(t) + + return HumanDuration(difference) +} + +func ShortHumanDurationUntilNow(t time.Time) string { + difference := time.Since(t) + + return ShortHumanDuration(difference) +} + +func HumanDuration(time time.Duration) string { + return duration.HumanDuration(time) +} + +func ShortHumanDuration(time time.Duration) string { + return duration.ShortHumanDuration(time) +} From bf7fd5c2bb257c24cfd8ec75c68634791bfa442e Mon Sep 17 00:00:00 2001 From: Alexej Disterhoft Date: Mon, 1 Jan 2024 00:27:00 +0100 Subject: [PATCH 2/7] feat: implement `used-by` command to find ticket using pvs Signed-off-by: Alexej Disterhoft --- cmd/cli/root.go | 1 + cmd/cli/usedby.go | 125 +++++++++++++++++++++++++++ internal/volumes/output.go | 156 +++++++++++++++++++++++++++++++++ internal/volumes/volumes.go | 168 ++++++++++++++++++++++++++++++++++++ 4 files changed, 450 insertions(+) create mode 100644 cmd/cli/usedby.go create mode 100644 internal/volumes/output.go create mode 100644 internal/volumes/volumes.go diff --git a/cmd/cli/root.go b/cmd/cli/root.go index 3e74f9f..aab6643 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -43,6 +43,7 @@ requiring access to the MapR cluster.`, rootCmd.AddCommand( newListCmd(rootOpts), newVersionCmd(rootOpts), + newUsedByCmd(rootOpts), ) return rootCmd diff --git a/cmd/cli/usedby.go b/cmd/cli/usedby.go new file mode 100644 index 0000000..8a7641e --- /dev/null +++ b/cmd/cli/usedby.go @@ -0,0 +1,125 @@ +package cli + +import ( + "fmt" + + "github.com/nobbs/kubectl-mapr-ticket/internal/util" + "github.com/nobbs/kubectl-mapr-ticket/internal/volumes" + "github.com/spf13/cobra" +) + +type UsedByOptions struct { + *rootCmdOptions + + // Args are the arguments passed to the command + args []string + + // SecretName is the name of the secret to find persistent volumes for + SecretName string + + // AllSecrets indicates whether to find persistent volumes for all secrets + // in the current namespace + AllSecrets bool + + // OutputFormat is the format to use for output + OutputFormat string +} + +func NewUsedByOptions(rootOpts *rootCmdOptions) *UsedByOptions { + return &UsedByOptions{ + rootCmdOptions: rootOpts, + } +} + +func newUsedByCmd(rootOpts *rootCmdOptions) *cobra.Command { + o := NewUsedByOptions(rootOpts) + + cmd := &cobra.Command{ + Use: "used-by {secret-name|--all} [flags]", + Short: "List all persistent volumes that use the specified MapR ticket secret", + Long: `List all persistent volumes that use the specified MapR ticket secret and print +some information about them.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := o.Complete(cmd, args); err != nil { + return err + } + + if err := o.Validate(); err != nil { + return err + } + + if err := o.Run(cmd, args); err != nil { + return err + } + + return nil + }, + } + + // set IOStreams for the command + cmd.SetIn(o.IOStreams.In) + cmd.SetOut(o.IOStreams.Out) + cmd.SetErr(o.IOStreams.ErrOut) + + // add flags + cmd.Flags().StringVarP(&o.OutputFormat, "output", "o", "table", "Output format. One of: table|wide") + cmd.Flags().BoolVarP(&o.AllSecrets, "all", "a", false, "List persistent volumes for all MapR ticket secrets in the current namespace") + + return cmd +} + +func (o *UsedByOptions) Complete(cmd *cobra.Command, args []string) error { + o.args = args + + if len(args) > 0 { + o.SecretName = args[0] + } + + return nil +} + +func (o *UsedByOptions) Validate() error { + // ensure that the secret name was provided + if !o.AllSecrets && o.SecretName == "" { + return fmt.Errorf("either --all or a secret name must be provided") + } + + // ensure that the output format is valid + if o.OutputFormat != "table" && o.OutputFormat != "wide" { + return fmt.Errorf("output format %s is not valid", o.OutputFormat) + } + + return nil +} + +func (o *UsedByOptions) Run(cmd *cobra.Command, args []string) error { + client, err := util.ClientFromFlags(o.kubernetesConfigFlags) + if err != nil { + return err + } + + // create list options + opts := []volumes.ListerOption{} + + // if we are listing volumes for all secrets in the namespace, create an option to do so + if o.AllSecrets { + opts = append(opts, volumes.WithAllSecrets()) + } + + // create lister + lister := volumes.NewLister(client, o.SecretName, *o.kubernetesConfigFlags.Namespace, opts...) + + // run the lister + pvs, err := lister.Run() + if err != nil { + return err + } + + // print the volumes + if err := volumes.Print(cmd, pvs); err != nil { + return err + } + + return nil +} diff --git a/internal/volumes/output.go b/internal/volumes/output.go new file mode 100644 index 0000000..23d54a4 --- /dev/null +++ b/internal/volumes/output.go @@ -0,0 +1,156 @@ +package volumes + +import ( + "github.com/nobbs/kubectl-mapr-ticket/internal/util" + "github.com/spf13/cobra" + coreV1 "k8s.io/api/core/v1" + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/printers" +) + +var ( + tableColumnDefinitions = []metaV1.TableColumnDefinition{ + { + Name: "Name", + Type: "string", + Format: "name", + Description: "Name of the persistent volume", + Priority: 0, + }, + { + Name: "Secret Namespace", + Type: "string", + Description: "Namespace of the secret containing the MapR ticket", + Priority: 0, + }, + { + Name: "Secret", + Type: "string", + Description: "Name of the secret containing the MapR ticket", + Priority: 0, + }, + { + Name: "Claim Namespace", + Type: "string", + Description: "Namespace of the persistent volume claim", + Priority: 0, + }, + { + Name: "Claim", + Type: "string", + Description: "Name of the persistent volume claim", + Priority: 0, + }, + { + Name: "Volume Path", + Type: "string", + Description: "Path of the volume on the MapR cluster", + Priority: 1, + }, + { + Name: "Volume Handle", + Type: "string", + Description: "Handle of the volume on the MapR cluster", + Priority: 1, + }, + { + Name: "Age", + Type: "string", + Format: "date-time", + Description: "Creation time of the volume", + Priority: 0, + }, + } +) + +func Print(cmd *cobra.Command, volumes []coreV1.PersistentVolume) error { + format := cmd.Flag("output").Value.String() + + // generate the table + table := generableTable(volumes) + + // print the table + printer := printers.NewTablePrinter(printers.PrintOptions{ + Wide: format == "wide", + }) + + err := printer.PrintObj(table, cmd.OutOrStdout()) + if err != nil { + return err + } + + return nil +} + +func generableTable(pvs []coreV1.PersistentVolume) *metaV1.Table { + rows := generateRows(pvs) + + return &metaV1.Table{ + ColumnDefinitions: tableColumnDefinitions, + Rows: rows, + } +} + +func generateRows(pvs []coreV1.PersistentVolume) []metaV1.TableRow { + rows := make([]metaV1.TableRow, 0, len(pvs)) + + for _, pv := range pvs { + rows = append(rows, *generateRow(&pv)) + } + + return rows +} + +func generateRow(pv *coreV1.PersistentVolume) *metaV1.TableRow { + row := &metaV1.TableRow{ + Object: runtime.RawExtension{ + Object: pv, + }, + } + + row.Cells = []any{ + pv.Name, + getNodePublishSecretRefNamespace(pv), + getNodePublishSecretRefName(pv), + getClaimNamespace(pv), + getClaimName(pv), + pv.Spec.CSI.VolumeAttributes["volumePath"], + pv.Spec.CSI.VolumeHandle, + util.HumanDurationUntilNow(pv.CreationTimestamp.Time), + } + + return row +} + +func getNodePublishSecretRefName(pv *coreV1.PersistentVolume) string { + if pv.Spec.CSI != nil && pv.Spec.CSI.NodePublishSecretRef != nil { + return pv.Spec.CSI.NodePublishSecretRef.Name + } + + return "" +} + +func getNodePublishSecretRefNamespace(pv *coreV1.PersistentVolume) string { + if pv.Spec.CSI != nil && pv.Spec.CSI.NodePublishSecretRef != nil { + return pv.Spec.CSI.NodePublishSecretRef.Namespace + } + + return "" +} + +func getClaimName(pv *coreV1.PersistentVolume) string { + if pv.Spec.ClaimRef != nil { + return pv.Spec.ClaimRef.Name + } + + return "" +} + +func getClaimNamespace(pv *coreV1.PersistentVolume) string { + if pv.Spec.ClaimRef != nil { + return pv.Spec.ClaimRef.Namespace + } + + return "" +} diff --git a/internal/volumes/volumes.go b/internal/volumes/volumes.go new file mode 100644 index 0000000..7689e25 --- /dev/null +++ b/internal/volumes/volumes.go @@ -0,0 +1,168 @@ +package volumes + +import ( + "context" + + coreV1 "k8s.io/api/core/v1" + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + typedV1 "k8s.io/client-go/kubernetes/typed/core/v1" +) + +var ( + // maprCSIProvisioners is a list of the default MapR CSI provisioners + // that we support. + maprCSIProvisioners = []string{ + "com.mapr.csi-kdf", + "com.mapr.csi-nfskdf", + } +) + +type Lister struct { + client typedV1.PersistentVolumeInterface + secretName string + namespace string + + allSecrets bool +} + +type ListerOption func(*Lister) + +func WithAllSecrets() ListerOption { + return func(l *Lister) { + l.allSecrets = true + } +} + +func NewLister(client kubernetes.Interface, secretName string, namespace string, opts ...ListerOption) *Lister { + l := &Lister{ + client: client.CoreV1().PersistentVolumes(), + secretName: secretName, + namespace: namespace, + } + + for _, opt := range opts { + opt(l) + } + + return l +} + +func (l *Lister) Run() ([]coreV1.PersistentVolume, error) { + // Unfortunately, we have to list all persistent volumes and filter them + // ourselves, because there is no way to filter them by label selector. + volumes, err := l.client.List(context.TODO(), metaV1.ListOptions{}) + if err != nil { + return nil, err + } + + // Filter the volumes to only MapR CSI-based ones + filtered := l.filterVolumesToMaprCSI(volumes.Items) + + // If we are listing volumes for all secrets in the namespace, let's + // filter the volumes to only ones that use a NodePublishSecretRef in + // the namespace. Otherwise, let's filter the volumes to only ones that + // use the specified secret. + if l.allSecrets { + filtered = l.filterVolumeUsesTicketInNamespace(filtered) + } else { + filtered = l.filterVolumeUsesTicket(filtered) + } + + return filtered, nil +} + +func (l *Lister) filterVolumesToMaprCSI(volumes []coreV1.PersistentVolume) []coreV1.PersistentVolume { + var filtered []coreV1.PersistentVolume + + for _, volume := range volumes { + if l.volumeIsMaprCSIBased(&volume) { + filtered = append(filtered, volume) + } + } + + return filtered +} + +func (l *Lister) filterVolumeUsesTicketInNamespace(volumes []coreV1.PersistentVolume) []coreV1.PersistentVolume { + var filtered []coreV1.PersistentVolume + + for _, volume := range volumes { + if l.volumeUsesTicketInNamespace(&volume) { + filtered = append(filtered, volume) + } + } + + return filtered +} + +func (l *Lister) filterVolumeUsesTicket(volumes []coreV1.PersistentVolume) []coreV1.PersistentVolume { + var filtered []coreV1.PersistentVolume + + for _, volume := range volumes { + if l.volumeUsesTicket(&volume) { + filtered = append(filtered, volume) + } + } + + return filtered +} + +func (l *Lister) volumeUsesTicketInNamespace(volume *coreV1.PersistentVolume) bool { + // Check if the volume uses a CSI driver + if volume.Spec.CSI == nil { + return false + } + + // Check if the volume uses a NodePublishSecretRef + if volume.Spec.CSI.NodePublishSecretRef == nil { + return false + } + + // Check if the volume uses a NodePublishSecretRef in the specified namespace + if volume.Spec.CSI.NodePublishSecretRef.Namespace != l.namespace { + return false + } + + return true +} + +func (l *Lister) volumeUsesTicket(volume *coreV1.PersistentVolume) bool { + // Check if the volume uses a CSI driver + if volume.Spec.CSI == nil { + return false + } + + // Check if the volume uses a NodePublishSecretRef + if volume.Spec.CSI.NodePublishSecretRef == nil { + return false + } + + // Check if the volume uses the specified secret + if volume.Spec.CSI.NodePublishSecretRef.Name != l.secretName { + return false + } + + // Check if the volume uses the specified namespace + if volume.Spec.CSI.NodePublishSecretRef.Namespace != l.namespace { + return false + } + + return true +} + +func (l *Lister) volumeIsMaprCSIBased(volume *coreV1.PersistentVolume) bool { + // Check if the volume is MapR CSI-based + if volume.Spec.CSI == nil { + return false + } + + // Check if the volume is provisioned by one of the MapR CSI provisioners + for _, provisioner := range maprCSIProvisioners { + if volume.Spec.CSI.Driver == provisioner { + return true + } + } + + return false +} From e85a796f35007754d0d325195d8a51d867507eb1 Mon Sep 17 00:00:00 2001 From: Alexej Disterhoft Date: Mon, 1 Jan 2024 00:54:33 +0100 Subject: [PATCH 3/7] feat: add `used-by` option to list command --- cmd/cli/list.go | 18 ++++++ internal/list/list.go | 115 ++++++++++++++++++++++++++++-------- internal/list/output.go | 31 ++++++++++ internal/volumes/volumes.go | 37 ++++++------ 4 files changed, 157 insertions(+), 44 deletions(-) diff --git a/cmd/cli/list.go b/cmd/cli/list.go index 2ddd66a..1784c42 100644 --- a/cmd/cli/list.go +++ b/cmd/cli/list.go @@ -41,6 +41,14 @@ type ListOptions struct { // FilterByMaprGID indicates whether to filter secrets to only those that have // a ticket for the specified GID FilterByMaprGID uint32 + + // FilterByInUse indicates whether to filter secrets to only those that are + // in use by a persistent volume + FilterByInUse bool + + // ShowInUse indicates whether to show only secrets that are in use by a + // persistent volume + ShowInUse bool } func NewListOptions(rootOpts *rootCmdOptions) *ListOptions { @@ -89,6 +97,8 @@ some information about them.`, cmd.Flags().StringVarP(&o.FilterByMaprUser, "mapr-user", "u", "", "Only show secrets with tickets for the specified MapR user") cmd.Flags().Uint32Var(&o.FilterByMaprUID, "mapr-uid", 0, "Only show secrets with tickets for the specified UID") cmd.Flags().Uint32Var(&o.FilterByMaprGID, "mapr-gid", 0, "Only show secrets with tickets for the specified GID") + cmd.Flags().BoolVarP(&o.FilterByInUse, "in-use", "I", false, "If true, only show secrets that are in use by a persistent volume") + cmd.Flags().BoolVarP(&o.ShowInUse, "show-in-use", "i", false, "If true, add a column to the output indicating whether the secret is in use by a persistent volume") cmd.MarkFlagsMutuallyExclusive("only-expired", "only-unexpired") return cmd @@ -153,6 +163,14 @@ func (o *ListOptions) Run(cmd *cobra.Command, args []string) error { opts = append(opts, list.WithFilterByGID(o.FilterByMaprGID)) } + if cmd.Flags().Changed("in-use") && o.FilterByInUse { + opts = append(opts, list.WithFilterByInUse()) + } + + if cmd.Flags().Changed("show-in-use") && o.ShowInUse { + opts = append(opts, list.WithShowInUse()) + } + // create lister lister := list.NewLister(client, *o.kubernetesConfigFlags.Namespace, opts...) diff --git a/internal/list/list.go b/internal/list/list.go index 56ddfb7..858e9a9 100644 --- a/internal/list/list.go +++ b/internal/list/list.go @@ -4,19 +4,20 @@ import ( "context" "github.com/nobbs/kubectl-mapr-ticket/internal/ticket" + "github.com/nobbs/kubectl-mapr-ticket/internal/volumes" coreV1 "k8s.io/api/core/v1" metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" - typedV1 "k8s.io/client-go/kubernetes/typed/core/v1" ) type ListItem struct { Secret *coreV1.Secret `json:"originalSecret"` Ticket *ticket.MaprTicket `json:"parsedTicket"` + InUse bool `json:"inUse"` } type Lister struct { - client typedV1.SecretInterface + client kubernetes.Interface namespace string filterOnlyExpired bool @@ -25,6 +26,8 @@ type Lister struct { filterByMaprUser *string filterByUID *uint32 filterByGID *uint32 + filterByInUse bool + showInUse bool } type ListerOption func(*Lister) @@ -65,6 +68,18 @@ func WithFilterOnlyUnexpired() ListerOption { } } +func WithFilterByInUse() ListerOption { + return func(l *Lister) { + l.filterByInUse = true + } +} + +func WithShowInUse() ListerOption { + return func(l *Lister) { + l.showInUse = true + } +} + // NewLister creates a new Lister func NewLister(client kubernetes.Interface, namespace string, opts ...ListerOption) *Lister { const ( @@ -73,7 +88,7 @@ func NewLister(client kubernetes.Interface, namespace string, opts ...ListerOpti ) l := &Lister{ - client: client.CoreV1().Secrets(namespace), + client: client, namespace: namespace, filterOnlyExpired: defaultFilterOnlyExpired, filterOnlyUnexpired: defaultFilterOnlyUnexpired, @@ -87,49 +102,62 @@ func NewLister(client kubernetes.Interface, namespace string, opts ...ListerOpti } func (l *Lister) Run() ([]ListItem, error) { - secrets, err := l.client.List(context.TODO(), metaV1.ListOptions{}) + secrets, err := l.client.CoreV1().Secrets(l.namespace).List(context.TODO(), metaV1.ListOptions{}) if err != nil { return nil, err } // convert secrets to items, parse all tickets - items := l.parseSecretsToItems(secrets.Items) + items := parseSecretsToItems(secrets.Items) // filter items to only expired tickets, if requested if l.filterOnlyExpired { - items = l.filterItemsOnlyExpired(items) + items = filterItemsOnlyExpired(items) } // filter items to only unexpired tickets, if requested if l.filterOnlyUnexpired { - items = l.filterItemsOnlyUnexpired(items) + items = filterItemsOnlyUnexpired(items) } // filter items to only tickets for the specified MapR cluster, if requested if l.filterByMaprCluster != nil && *l.filterByMaprCluster != "" { - items = l.filterItemsByMaprCluster(items) + items = filterItemsByMaprCluster(items, *l.filterByMaprCluster) } // filter items to only tickets for the specified MapR user, if requested if l.filterByMaprUser != nil && *l.filterByMaprUser != "" { - items = l.filterItemsByMaprUser(items) + items = filterItemsByMaprUser(items, *l.filterByMaprUser) } // filter items to only tickets for the specified UID, if requested if l.filterByUID != nil { - items = l.filterItemsByUID(items) + items = filterItemsByUID(items, *l.filterByUID) } // filter items to only tickets for the specified GID, if requested if l.filterByGID != nil { - items = l.filterItemsByGID(items) + items = filterItemsByGID(items, *l.filterByGID) + } + + // enrich items with an InUse condition, if requested + if l.showInUse || l.filterByInUse { + items, err = l.enrichItemsWithInUseCondition(items) + if err != nil { + return nil, err + } + } + + // filter items to only tickets that are in use by a persistent volume, if requested + if l.filterByInUse { + items = filterItemsToOnlyInUse(items) } return items, nil } // filterSecretsWithMaprTicketKey filters secrets to only those that contain a MapR ticket key -func (l *Lister) filterSecretsWithMaprTicketKey(secrets []coreV1.Secret) []coreV1.Secret { +func filterSecretsWithMaprTicketKey(secrets []coreV1.Secret) []coreV1.Secret { var filtered []coreV1.Secret for _, secret := range secrets { @@ -142,10 +170,10 @@ func (l *Lister) filterSecretsWithMaprTicketKey(secrets []coreV1.Secret) []coreV } // parseSecretsToItems parses secrets to items, ignoring secrets that don't contain a MapR ticket -func (l *Lister) parseSecretsToItems(secrets []coreV1.Secret) []ListItem { +func parseSecretsToItems(secrets []coreV1.Secret) []ListItem { var items []ListItem - for i := range l.filterSecretsWithMaprTicketKey(secrets) { + for i := range filterSecretsWithMaprTicketKey(secrets) { ticket, err := ticket.NewTicketFromSecret(&secrets[i]) if err != nil { continue @@ -161,7 +189,7 @@ func (l *Lister) parseSecretsToItems(secrets []coreV1.Secret) []ListItem { } // filterItemsOnlyExpired filters items to only tickets that are expired already -func (l *Lister) filterItemsOnlyExpired(items []ListItem) []ListItem { +func filterItemsOnlyExpired(items []ListItem) []ListItem { var filtered []ListItem for _, item := range items { @@ -174,7 +202,7 @@ func (l *Lister) filterItemsOnlyExpired(items []ListItem) []ListItem { } // filterItemsOnlyUnexpired filters items to only tickets that are not expired yet -func (l *Lister) filterItemsOnlyUnexpired(items []ListItem) []ListItem { +func filterItemsOnlyUnexpired(items []ListItem) []ListItem { var filtered []ListItem for _, item := range items { @@ -187,11 +215,11 @@ func (l *Lister) filterItemsOnlyUnexpired(items []ListItem) []ListItem { } // filterItemsByMaprCluster filters items to only tickets for the specified MapR cluster -func (l *Lister) filterItemsByMaprCluster(items []ListItem) []ListItem { +func filterItemsByMaprCluster(items []ListItem, cluster string) []ListItem { var filtered []ListItem for _, item := range items { - if item.Ticket.Cluster == *l.filterByMaprCluster { + if item.Ticket.Cluster == cluster { filtered = append(filtered, item) } } @@ -200,11 +228,11 @@ func (l *Lister) filterItemsByMaprCluster(items []ListItem) []ListItem { } // filterItemsByMaprUser filters items to only tickets for the specified MapR user -func (l *Lister) filterItemsByMaprUser(items []ListItem) []ListItem { +func filterItemsByMaprUser(items []ListItem, user string) []ListItem { var filtered []ListItem for _, item := range items { - if item.Ticket.UserCreds.GetUserName() == *l.filterByMaprUser { + if item.Ticket.UserCreds.GetUserName() == user { filtered = append(filtered, item) } } @@ -213,11 +241,11 @@ func (l *Lister) filterItemsByMaprUser(items []ListItem) []ListItem { } // filterItemsByUID filters items to only tickets for the specified UID -func (l *Lister) filterItemsByUID(items []ListItem) []ListItem { +func filterItemsByUID(items []ListItem, uid uint32) []ListItem { var filtered []ListItem for _, item := range items { - if *item.Ticket.UserCreds.Uid == *l.filterByUID { + if *item.Ticket.UserCreds.Uid == uid { filtered = append(filtered, item) } } @@ -226,13 +254,13 @@ func (l *Lister) filterItemsByUID(items []ListItem) []ListItem { } // filterItemsByGID filters items to only tickets for the specified GID -func (l *Lister) filterItemsByGID(items []ListItem) []ListItem { +func filterItemsByGID(items []ListItem, gid uint32) []ListItem { var filtered []ListItem for _, item := range items { // check if GID is in the list of GIDs - for _, gid := range item.Ticket.UserCreds.Gids { - if gid == *l.filterByGID { + for _, gotGid := range item.Ticket.UserCreds.Gids { + if gotGid == gid { filtered = append(filtered, item) break } @@ -241,3 +269,40 @@ func (l *Lister) filterItemsByGID(items []ListItem) []ListItem { return filtered } + +// enrichItemsWithInUseCondition enriches items with an InUse condition based on whether a +// persistent volume is using the ticket or not +func (l *Lister) enrichItemsWithInUseCondition(items []ListItem) ([]ListItem, error) { + pvs, err := l.client.CoreV1().PersistentVolumes().List(context.TODO(), metaV1.ListOptions{}) + if err != nil { + return nil, err + } + + // Filter the volumes to only MapR CSI-based ones + maprVolumes := volumes.FilterVolumesToMaprCSI(pvs.Items) + + // check for each ticket if it is in use by a persistent volume + for i := range items { + for _, volume := range maprVolumes { + if volumes.UsesTicket(&volume, items[i].Secret.Name, items[i].Secret.Namespace) { + items[i].InUse = true + break + } + } + } + + return items, nil +} + +// filterItemsToOnlyInUse filters items to only tickets that are in use by a persistent volume +func filterItemsToOnlyInUse(items []ListItem) []ListItem { + var filtered []ListItem + + for _, item := range items { + if item.InUse { + filtered = append(filtered, item) + } + } + + return filtered +} diff --git a/internal/list/output.go b/internal/list/output.go index 27a0001..27fe327 100644 --- a/internal/list/output.go +++ b/internal/list/output.go @@ -87,12 +87,18 @@ var ( func Print(cmd *cobra.Command, items []ListItem) error { format := cmd.Flag("output").Value.String() allNamespaces := cmd.Flag("all-namespaces").Changed && cmd.Flag("all-namespaces").Value.String() == "true" + withInUse := cmd.Flag("show-in-use").Changed && cmd.Flag("show-in-use").Value.String() == "true" switch format { case "table", "wide": // generate table for output table := generateTable(items) + // enrich table with in use column + if withInUse { + enrichTableWithInUse(table, items) + } + // print table printer := printers.NewTablePrinter(printers.PrintOptions{ WithNamespace: allNamespaces, @@ -160,6 +166,31 @@ func generateRow(item *ListItem) *metaV1.TableRow { return row } +// enrichTableWithInUse enriches the table with a column indicating whether the +// ticket is in use by a persistent volume or not +func enrichTableWithInUse(table *metaV1.Table, items []ListItem) { + numColumns := len(listTableColumns) + + table.ColumnDefinitions = append( + table.ColumnDefinitions[:numColumns-1], + metaV1.TableColumnDefinition{ + Name: "In Use", + Type: "boolean", + Description: "Whether the ticket is in use by a persistent volume or not", + Priority: 0, + }, + table.ColumnDefinitions[numColumns-1], + ) + + for i := range table.Rows { + table.Rows[i].Cells = append( + table.Rows[i].Cells[:numColumns-1], + items[i].InUse, + table.Rows[i].Cells[numColumns-1], + ) + } +} + func printEncoded(items []ListItem, format string, stream io.Writer) error { bytesBuffer := bytes.NewBuffer([]byte{}) diff --git a/internal/volumes/volumes.go b/internal/volumes/volumes.go index 7689e25..e69019e 100644 --- a/internal/volumes/volumes.go +++ b/internal/volumes/volumes.go @@ -6,7 +6,6 @@ import ( coreV1 "k8s.io/api/core/v1" metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" - typedV1 "k8s.io/client-go/kubernetes/typed/core/v1" ) var ( @@ -19,7 +18,7 @@ var ( ) type Lister struct { - client typedV1.PersistentVolumeInterface + client kubernetes.Interface secretName string namespace string @@ -36,7 +35,7 @@ func WithAllSecrets() ListerOption { func NewLister(client kubernetes.Interface, secretName string, namespace string, opts ...ListerOption) *Lister { l := &Lister{ - client: client.CoreV1().PersistentVolumes(), + client: client, secretName: secretName, namespace: namespace, } @@ -51,32 +50,32 @@ func NewLister(client kubernetes.Interface, secretName string, namespace string, func (l *Lister) Run() ([]coreV1.PersistentVolume, error) { // Unfortunately, we have to list all persistent volumes and filter them // ourselves, because there is no way to filter them by label selector. - volumes, err := l.client.List(context.TODO(), metaV1.ListOptions{}) + volumes, err := l.client.CoreV1().PersistentVolumes().List(context.TODO(), metaV1.ListOptions{}) if err != nil { return nil, err } // Filter the volumes to only MapR CSI-based ones - filtered := l.filterVolumesToMaprCSI(volumes.Items) + filtered := FilterVolumesToMaprCSI(volumes.Items) // If we are listing volumes for all secrets in the namespace, let's // filter the volumes to only ones that use a NodePublishSecretRef in // the namespace. Otherwise, let's filter the volumes to only ones that // use the specified secret. if l.allSecrets { - filtered = l.filterVolumeUsesTicketInNamespace(filtered) + filtered = filterVolumeUsesTicketFromNamespace(filtered, l.namespace) } else { - filtered = l.filterVolumeUsesTicket(filtered) + filtered = filterVolumeUsesTicket(filtered, l.secretName, l.namespace) } return filtered, nil } -func (l *Lister) filterVolumesToMaprCSI(volumes []coreV1.PersistentVolume) []coreV1.PersistentVolume { +func FilterVolumesToMaprCSI(volumes []coreV1.PersistentVolume) []coreV1.PersistentVolume { var filtered []coreV1.PersistentVolume for _, volume := range volumes { - if l.volumeIsMaprCSIBased(&volume) { + if isMaprCSIBased(&volume) { filtered = append(filtered, volume) } } @@ -84,11 +83,11 @@ func (l *Lister) filterVolumesToMaprCSI(volumes []coreV1.PersistentVolume) []cor return filtered } -func (l *Lister) filterVolumeUsesTicketInNamespace(volumes []coreV1.PersistentVolume) []coreV1.PersistentVolume { +func filterVolumeUsesTicketFromNamespace(volumes []coreV1.PersistentVolume, namespace string) []coreV1.PersistentVolume { var filtered []coreV1.PersistentVolume for _, volume := range volumes { - if l.volumeUsesTicketInNamespace(&volume) { + if usesTicketFromNamespace(&volume, namespace) { filtered = append(filtered, volume) } } @@ -96,11 +95,11 @@ func (l *Lister) filterVolumeUsesTicketInNamespace(volumes []coreV1.PersistentVo return filtered } -func (l *Lister) filterVolumeUsesTicket(volumes []coreV1.PersistentVolume) []coreV1.PersistentVolume { +func filterVolumeUsesTicket(volumes []coreV1.PersistentVolume, secretName, namespace string) []coreV1.PersistentVolume { var filtered []coreV1.PersistentVolume for _, volume := range volumes { - if l.volumeUsesTicket(&volume) { + if UsesTicket(&volume, secretName, namespace) { filtered = append(filtered, volume) } } @@ -108,7 +107,7 @@ func (l *Lister) filterVolumeUsesTicket(volumes []coreV1.PersistentVolume) []cor return filtered } -func (l *Lister) volumeUsesTicketInNamespace(volume *coreV1.PersistentVolume) bool { +func usesTicketFromNamespace(volume *coreV1.PersistentVolume, namespace string) bool { // Check if the volume uses a CSI driver if volume.Spec.CSI == nil { return false @@ -120,14 +119,14 @@ func (l *Lister) volumeUsesTicketInNamespace(volume *coreV1.PersistentVolume) bo } // Check if the volume uses a NodePublishSecretRef in the specified namespace - if volume.Spec.CSI.NodePublishSecretRef.Namespace != l.namespace { + if volume.Spec.CSI.NodePublishSecretRef.Namespace != namespace { return false } return true } -func (l *Lister) volumeUsesTicket(volume *coreV1.PersistentVolume) bool { +func UsesTicket(volume *coreV1.PersistentVolume, secretName, namespace string) bool { // Check if the volume uses a CSI driver if volume.Spec.CSI == nil { return false @@ -139,19 +138,19 @@ func (l *Lister) volumeUsesTicket(volume *coreV1.PersistentVolume) bool { } // Check if the volume uses the specified secret - if volume.Spec.CSI.NodePublishSecretRef.Name != l.secretName { + if volume.Spec.CSI.NodePublishSecretRef.Name != secretName { return false } // Check if the volume uses the specified namespace - if volume.Spec.CSI.NodePublishSecretRef.Namespace != l.namespace { + if volume.Spec.CSI.NodePublishSecretRef.Namespace != namespace { return false } return true } -func (l *Lister) volumeIsMaprCSIBased(volume *coreV1.PersistentVolume) bool { +func isMaprCSIBased(volume *coreV1.PersistentVolume) bool { // Check if the volume is MapR CSI-based if volume.Spec.CSI == nil { return false From 3dc47d9c226342fa0c94164b24df4bb7a7845357 Mon Sep 17 00:00:00 2001 From: Alexej Disterhoft Date: Mon, 1 Jan 2024 01:25:50 +0100 Subject: [PATCH 4/7] chore: update go.mod Signed-off-by: Alexej Disterhoft --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 955b947..8d96857 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( k8s.io/apimachinery v0.29.0 k8s.io/cli-runtime v0.29.0 k8s.io/client-go v0.29.0 + sigs.k8s.io/yaml v1.3.0 ) require ( @@ -66,5 +67,4 @@ require ( sigs.k8s.io/kustomize/api v0.13.5-0.20230601165947-6ce0bf390ce3 // indirect sigs.k8s.io/kustomize/kyaml v0.14.3-0.20230601165947-6ce0bf390ce3 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect - sigs.k8s.io/yaml v1.3.0 // indirect ) From 0c10d1867e71463bef980ddf1b8cd8220b911e13 Mon Sep 17 00:00:00 2001 From: Alexej Disterhoft Date: Mon, 1 Jan 2024 12:22:58 +0100 Subject: [PATCH 5/7] chore(lint): disable complexity linters for now Signed-off-by: Alexej Disterhoft --- .golangci.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 855d249..65ffb66 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -6,17 +6,11 @@ linters: - errorlint - dogsled - nilnil - - gocyclo - - gocognit - funlen - dupl - gocritic linters-settings: - gocyclo: - min-complexity: 10 - gocognit: - min-complexity: 20 funlen: lines: 120 statements: 120 From 539e5f5c27b48a7896555b1236eec5dfc94ecef6 Mon Sep 17 00:00:00 2001 From: Alexej Disterhoft Date: Mon, 1 Jan 2024 12:27:11 +0100 Subject: [PATCH 6/7] docs: add `used-by` to README Signed-off-by: Alexej Disterhoft --- README.md | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7d244e9..5bcfdad 100644 --- a/README.md +++ b/README.md @@ -33,17 +33,34 @@ $ kubectl mapr-ticket --help ## Usage -Currently, `kubectl-mapr-ticket` supports only the `list` command. This command will list all MapR tickets deployed in the current namespace. The output will include the name of the secret, the MapR cluster name, the username, and the expiry date of the ticket. +The plugin can be invoked using the `kubectl mapr-ticket` command. The plugin supports the following subcommands: + +- `list` - List all MapR tickets deployed in the current namespace. +- `used-by` - List all Persistent Volumes that are using a specific MapR ticket. + +### List + +The `list` subcommand will list all MapR tickets deployed in the current namespace. The output by default will be a table with the following columns. Additional flags can be used to customize the output, see `kubectl mapr-ticket list --help` for more details. ```console $ kubectl mapr-ticket list -NAME MAPR CLUSTER USER EXPIRATION -mapr-dev-ticket-user-a demo.dev.mapr.com user_a 2028-11-09T15:50:57+01:00 (Expired) -mapr-dev-ticket-user-b demo.dev.mapr.com user_b 2028-11-09T15:50:55+01:00 (Expired) -mapr-dev-ticket-user-c demo.dev.mapr.com user_c 2028-11-09T15:50:51+01:00 (Expired) -mapr-prod-ticket-user-a demo.prod.mapr.com user_a 2023-11-19T08:47:05+01:00 -mapr-prod-ticket-user-b demo.prod.mapr.com user_b 2023-11-19T08:47:03+01:00 -mapr-prod-ticket-user-c demo.prod.mapr.com user_c 2023-11-19T08:47:02+01:00 +NAME MAPR CLUSTER USER STATUS AGE +mapr-dev-ticket-user-a demo.dev.mapr.com user_a Valid (4y left) 75d +mapr-dev-ticket-user-b demo.dev.mapr.com user_b Valid (4y left) 75d +mapr-dev-ticket-user-c demo.dev.mapr.com user_c Valid (4y left) 75d +mapr-prod-ticket-user-a demo.prod.mapr.com user_a Expired (43d ago) 73d +mapr-prod-ticket-user-b demo.prod.mapr.com user_b Expired (43d ago) 73d +mapr-prod-ticket-user-c demo.prod.mapr.com user_c Expired (43d ago) 73d +``` + +### Used By + +The `used-by` subcommand will list all Persistent Volumes that are using a specific MapR ticket or any ticket in the current namespace if `--all` is specified. The output by default will be a table with the following columns. Additional flags can be used to customize the output, see `kubectl mapr-ticket used-by --help` for more details. + +```console +$ kubectl mapr-ticket mapr-ticket-secret -n test-csi +NAME SECRET NAMESPACE SECRET CLAIM NAMESPACE CLAIM AGE +test-static-pv test-csi mapr-ticket-secret 13h ``` ## Does this require a connection to a MapR cluster? From 8dcf97ca719f2a02613ed13ed9fef0017b25d5c4 Mon Sep 17 00:00:00 2001 From: Alexej Disterhoft Date: Mon, 1 Jan 2024 12:28:29 +0100 Subject: [PATCH 7/7] ci(release): also update versions in Readme Signed-off-by: Alexej Disterhoft --- release-please-config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-please-config.json b/release-please-config.json index 0dc5486..7976c18 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -10,7 +10,7 @@ "release-type": "go", "draft": false, "prerelease": false, - "extra-files": ["internal/version/version.go"], + "extra-files": ["internal/version/version.go", "README.md"], "initial-version": "0.1.0" } },