Skip to content

Commit 9b288c7

Browse files
feat: add filter flag and custom columns support to get system command
Signed-off-by: katara-Jayprakash <katarajayprakash@icloud.com>
1 parent 29bb062 commit 9b288c7

6 files changed

Lines changed: 295 additions & 8 deletions

File tree

mgrctl/cmd/get/printer.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import (
88
"encoding/json"
99
"fmt"
1010
"io"
11+
"os"
1112
"reflect"
13+
"strings"
1214
"text/tabwriter"
1315

1416
"gopkg.in/yaml.v2"
@@ -62,7 +64,93 @@ func printTable[T any](items []T, cols []ColumnDef, out io.Writer) error {
6264
return w.Flush()
6365
}
6466

67+
func mapJsonPathToField[T any](path string) string {
68+
path = strings.TrimPrefix(path, ".")
69+
70+
typ := reflect.TypeOf(new(T)).Elem()
71+
if typ.Kind() == reflect.Ptr {
72+
typ = typ.Elem()
73+
}
74+
75+
if typ.Kind() != reflect.Struct {
76+
return path // fallback
77+
}
78+
79+
for i := 0; i < typ.NumField(); i++ {
80+
field := typ.Field(i)
81+
jsonTag := field.Tag.Get("json")
82+
if jsonTag != "" {
83+
name := strings.Split(jsonTag, ",")[0]
84+
if name == path {
85+
return field.Name
86+
}
87+
}
88+
}
89+
90+
return path // fallback if no tag matches
91+
}
92+
93+
func parseCustomColumns[T any](spec string) []ColumnDef {
94+
var cols []ColumnDef
95+
parts := strings.Split(spec, ",")
96+
for _, part := range parts {
97+
kv := strings.SplitN(part, ":", 2)
98+
if len(kv) == 2 {
99+
cols = append(cols, ColumnDef{
100+
Header: strings.TrimSpace(kv[0]),
101+
Field: mapJsonPathToField[T](strings.TrimSpace(kv[1])),
102+
})
103+
}
104+
}
105+
return cols
106+
}
107+
65108
func printOutput[T any](format string, items []T, cols []ColumnDef, out io.Writer) error {
109+
if strings.HasPrefix(format, "custom-columns=") {
110+
spec := strings.TrimPrefix(format, "custom-columns=")
111+
newCols := parseCustomColumns[T](spec)
112+
if len(newCols) == 0 {
113+
return fmt.Errorf("custom-columns format specified but no columns given")
114+
}
115+
return printTable(items, newCols, out)
116+
}
117+
118+
if strings.HasPrefix(format, "custom-columns-file=") {
119+
filename := strings.TrimPrefix(format, "custom-columns-file=")
120+
content, err := os.ReadFile(filename)
121+
if err != nil {
122+
return err
123+
}
124+
125+
lines := strings.Split(string(content), "\n")
126+
var headers, paths []string
127+
for _, line := range lines {
128+
line = strings.TrimSpace(line)
129+
if line == "" {
130+
continue
131+
}
132+
fields := strings.Fields(line)
133+
if headers == nil {
134+
headers = fields
135+
} else if paths == nil {
136+
paths = fields
137+
}
138+
}
139+
140+
if len(headers) != len(paths) || len(headers) == 0 {
141+
return fmt.Errorf("invalid custom-columns file format")
142+
}
143+
144+
var specParts []string
145+
for i := range headers {
146+
specParts = append(specParts, headers[i]+":"+paths[i])
147+
}
148+
spec := strings.Join(specParts, ",")
149+
150+
newCols := parseCustomColumns[T](spec)
151+
return printTable(items, newCols, out)
152+
}
153+
66154
switch format {
67155
case "json":
68156
return printJSON(items, out)

mgrctl/cmd/get/printer_test.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// SPDX-FileCopyrightText: 2026 SUSE LLC
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package get
6+
7+
import (
8+
"bytes"
9+
"os"
10+
"strings"
11+
"testing"
12+
13+
apitypes "github.com/uyuni-project/uyuni-tools/shared/api/types"
14+
)
15+
16+
func TestMapJsonPathToField(t *testing.T) {
17+
tests := []struct {
18+
path string
19+
expected string
20+
}{
21+
{".id", "ID"},
22+
{".name", "Name"},
23+
{".last_checkin", "LastCheckin"},
24+
{".created", "Created"},
25+
{".last_boot", "LastBoot"},
26+
{"id", "ID"}, // without leading dot
27+
{"name", "Name"}, // without leading dot
28+
{".bogus", "bogus"}, // unknown field falls back (dot stripped)
29+
}
30+
31+
for _, tt := range tests {
32+
got := mapJsonPathToField[apitypes.System](tt.path)
33+
if got != tt.expected {
34+
t.Errorf("mapJsonPathToField(%q) = %q, want %q", tt.path, got, tt.expected)
35+
}
36+
}
37+
}
38+
39+
func TestParseCustomColumns(t *testing.T) {
40+
spec := "ID:.id,NAME:.name,LAST_SEEN:.last_checkin"
41+
cols := parseCustomColumns[apitypes.System](spec)
42+
43+
if len(cols) != 3 {
44+
t.Fatalf("expected 3 columns, got %d", len(cols))
45+
}
46+
47+
expected := []ColumnDef{
48+
{Header: "ID", Field: "ID"},
49+
{Header: "NAME", Field: "Name"},
50+
{Header: "LAST_SEEN", Field: "LastCheckin"},
51+
}
52+
53+
for i, col := range cols {
54+
if col.Header != expected[i].Header {
55+
t.Errorf("col[%d].Header = %q, want %q", i, col.Header, expected[i].Header)
56+
}
57+
if col.Field != expected[i].Field {
58+
t.Errorf("col[%d].Field = %q, want %q", i, col.Field, expected[i].Field)
59+
}
60+
}
61+
}
62+
63+
func TestPrintOutputCustomColumns(t *testing.T) {
64+
items := []apitypes.System{
65+
{ID: 1001, Name: "web-server-01", LastCheckin: "2026-06-20 14:00:00"},
66+
{ID: 1002, Name: "db-server-02", LastCheckin: "2026-06-21 09:30:00"},
67+
}
68+
69+
var buf bytes.Buffer
70+
format := "custom-columns=ID:.id,NAME:.name,LAST_SEEN:.last_checkin"
71+
err := printOutput(format, items, nil, &buf)
72+
if err != nil {
73+
t.Fatalf("printOutput returned error: %v", err)
74+
}
75+
76+
output := buf.String()
77+
78+
// Check headers are present
79+
if !strings.Contains(output, "ID") {
80+
t.Error("output missing ID header")
81+
}
82+
if !strings.Contains(output, "NAME") {
83+
t.Error("output missing NAME header")
84+
}
85+
if !strings.Contains(output, "LAST_SEEN") {
86+
t.Error("output missing LAST_SEEN header")
87+
}
88+
89+
// Check data rows are present
90+
if !strings.Contains(output, "1001") {
91+
t.Error("output missing system ID 1001")
92+
}
93+
if !strings.Contains(output, "web-server-01") {
94+
t.Error("output missing system name web-server-01")
95+
}
96+
if !strings.Contains(output, "db-server-02") {
97+
t.Error("output missing system name db-server-02")
98+
}
99+
if !strings.Contains(output, "2026-06-21 09:30:00") {
100+
t.Error("output missing last_checkin value")
101+
}
102+
}
103+
104+
func TestPrintOutputCustomColumnsFile(t *testing.T) {
105+
// Create a temp template file
106+
content := "ID NAME LAST_SEEN\n.id .name .last_checkin\n"
107+
tmpFile, err := os.CreateTemp("", "custom-cols-*.txt")
108+
if err != nil {
109+
t.Fatalf("failed to create temp file: %v", err)
110+
}
111+
defer os.Remove(tmpFile.Name())
112+
113+
if _, err := tmpFile.WriteString(content); err != nil {
114+
t.Fatalf("failed to write temp file: %v", err)
115+
}
116+
tmpFile.Close()
117+
118+
items := []apitypes.System{
119+
{ID: 2001, Name: "test-minion", LastCheckin: "2026-06-22 10:00:00"},
120+
}
121+
122+
var buf bytes.Buffer
123+
format := "custom-columns-file=" + tmpFile.Name()
124+
err = printOutput(format, items, nil, &buf)
125+
if err != nil {
126+
t.Fatalf("printOutput returned error: %v", err)
127+
}
128+
129+
output := buf.String()
130+
131+
if !strings.Contains(output, "2001") {
132+
t.Error("output missing system ID 2001")
133+
}
134+
if !strings.Contains(output, "test-minion") {
135+
t.Error("output missing system name test-minion")
136+
}
137+
}
138+
139+
func TestPrintOutputCustomColumnsEmpty(t *testing.T) {
140+
items := []apitypes.System{}
141+
var buf bytes.Buffer
142+
format := "custom-columns=ID:.id"
143+
err := printOutput(format, items, nil, &buf)
144+
if err != nil {
145+
t.Fatalf("printOutput returned error: %v", err)
146+
}
147+
148+
output := buf.String()
149+
// Should still print the header even with no data
150+
if !strings.Contains(output, "ID") {
151+
t.Error("output missing ID header even with empty data")
152+
}
153+
}
154+
155+
func TestPrintOutputCustomColumnsNoSpec(t *testing.T) {
156+
items := []apitypes.System{}
157+
var buf bytes.Buffer
158+
format := "custom-columns="
159+
err := printOutput(format, items, nil, &buf)
160+
if err == nil {
161+
t.Error("expected error for empty custom-columns spec, got nil")
162+
}
163+
}

mgrctl/cmd/get/run.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,20 @@ package get
66

77
import "os"
88

9+
// This runGet function is a generic interface function implementation for the 'get' command.
10+
// It takes care of fetching the data from the API and printing it in the desired format (table, JSON, YAML).
11+
912
func runGet[T any](flags *getOptions, res Resource[T], name string) error {
1013
client, err := newClient(flags.ConnectionDetails)
1114
if err != nil {
1215
return err
1316
}
1417

15-
var items []T
18+
var items []T // We declare a list (slice) of things.
19+
//mgrctl get system
20+
21+
// if name like web.01.uyuni.suse.com, is not provided we fetch all items, etc
22+
// otherwise we fetch the specific item and put it in a slice to be printed
1623
if name != "" {
1724
item, err := res.Get(client, name)
1825
if err != nil {

mgrctl/cmd/get/system.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,14 @@ import (
1111
"github.com/uyuni-project/uyuni-tools/shared/utils"
1212
)
1313

14+
type systemFlags struct {
15+
Filter string `mapstructure:"filter"`
16+
}
17+
1418
func newSystemCommand(globalFlags *types.GlobalFlags, parentFlags *getOptions) *cobra.Command {
15-
return &cobra.Command{
19+
var sysFlags systemFlags
20+
21+
cmd := &cobra.Command{
1622
Use: "system [name/id]",
1723
Aliases: []string{"systems"},
1824
Short: L("List or get details for registered systems"),
@@ -23,8 +29,11 @@ func newSystemCommand(globalFlags *types.GlobalFlags, parentFlags *getOptions) *
2329
if len(args) > 0 {
2430
name = args[0]
2531
}
26-
return runGet(flags, systemResource{}, name)
32+
return runGet(flags, systemResource{flags: &sysFlags}, name)
2733
})
2834
},
2935
}
36+
37+
cmd.Flags().StringVar(&sysFlags.Filter, "filter", "", L("Filter list (e.g. name=foo)"))
38+
return cmd
3039
}

mgrctl/cmd/get/system_desc.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,35 @@ package get
66

77
import (
88
"fmt"
9+
"strings"
910

1011
"github.com/uyuni-project/uyuni-tools/shared/api"
1112
apitypes "github.com/uyuni-project/uyuni-tools/shared/api/types"
1213
)
1314

14-
type systemResource struct{}
15+
type systemResource struct {
16+
flags *systemFlags
17+
}
18+
19+
// List returns all systems.
20+
func (s systemResource) List(client *api.APIClient) ([]apitypes.System, error) {
21+
// If the user explicitly provided the --filter flag, hit the filtered endpoint
22+
if s.flags != nil && s.flags.Filter != "" {
23+
parts := strings.SplitN(s.flags.Filter, "=", 2)
24+
if len(parts) == 2 {
25+
filterKey := parts[0]
26+
filterValue := parts[1]
27+
// The API strictly requires filterKey, filterValue, page, and pageSize
28+
url := fmt.Sprintf("system/listSystemsFiltered?filterKey=%s&filterValue=%s&page=0&pageSize=50", filterKey, filterValue)
29+
res, err := api.Get[[]apitypes.System](client, url)
30+
if err != nil {
31+
return nil, err
32+
}
33+
return res.Result, nil
34+
}
35+
}
1536

16-
// List returns all systems. The API does not support filtering by name or ID, so the filtering is done client-side in the runGet function.
17-
func (systemResource) List(client *api.APIClient) ([]apitypes.System, error) {
37+
// Otherwise, default to the standard endpoint
1838
res, err := api.Get[[]apitypes.System](client, "system/listSystems")
1939
if err != nil {
2040
return nil, err

shared/testutils/asserts.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,10 @@ func DiffStrings(expected string, actual string) string {
3737

3838
if exp != act {
3939
if i < len(expectedLines) {
40-
diff.WriteString(fmt.Sprintf("-%d: %s\n", i+1, exp))
40+
fmt.Fprintf(&diff, "-%d: %s\n", i+1, exp)
4141
}
4242
if i < len(actualLines) {
43-
diff.WriteString(fmt.Sprintf("+%d: %s\n", i+1, act))
43+
fmt.Fprintf(&diff, "+%d: %s\n", i+1, act)
4444
}
4545
}
4646
}

0 commit comments

Comments
 (0)