From 1e97b3d98f19fa0f4c2c40e584e2ff01d652a217 Mon Sep 17 00:00:00 2001 From: katara-Jayprakash Date: Fri, 20 Mar 2026 11:35:39 +0530 Subject: [PATCH 1/8] feat: Add mgrctl get system command prototype --- mgrctl/cmd/cmd.go | 2 + mgrctl/cmd/get/get.go | 35 +++++++++++++++++ mgrctl/cmd/get/system.go | 77 ++++++++++++++++++++++++++++++++++++++ shared/api/types/system.go | 14 +++++++ 4 files changed, 128 insertions(+) create mode 100644 mgrctl/cmd/get/get.go create mode 100644 mgrctl/cmd/get/system.go create mode 100644 shared/api/types/system.go diff --git a/mgrctl/cmd/cmd.go b/mgrctl/cmd/cmd.go index bb8bcb6e428..78809060675 100644 --- a/mgrctl/cmd/cmd.go +++ b/mgrctl/cmd/cmd.go @@ -14,6 +14,7 @@ import ( "github.com/uyuni-project/uyuni-tools/mgrctl/cmd/api" "github.com/uyuni-project/uyuni-tools/mgrctl/cmd/cp" "github.com/uyuni-project/uyuni-tools/mgrctl/cmd/exec" + "github.com/uyuni-project/uyuni-tools/mgrctl/cmd/get" "github.com/uyuni-project/uyuni-tools/mgrctl/cmd/proxy" "github.com/uyuni-project/uyuni-tools/mgrctl/cmd/ssh" "github.com/uyuni-project/uyuni-tools/mgrctl/cmd/term" @@ -51,6 +52,7 @@ func NewUyunictlCommand() *cobra.Command { apiCmd := api.NewCommand(globalFlags) rootCmd.AddCommand(apiCmd) + rootCmd.AddCommand(get.NewCommand(globalFlags)) rootCmd.AddCommand(exec.NewCommand(globalFlags)) rootCmd.AddCommand(term.NewCommand(globalFlags)) rootCmd.AddCommand(cp.NewCommand(globalFlags)) diff --git a/mgrctl/cmd/get/get.go b/mgrctl/cmd/get/get.go new file mode 100644 index 00000000000..4e8e5e756ab --- /dev/null +++ b/mgrctl/cmd/get/get.go @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 SUSE LLC +// +// SPDX-License-Identifier: Apache-2.0 + +package get + +import ( + "github.com/spf13/cobra" + "github.com/uyuni-project/uyuni-tools/shared/api" + . "github.com/uyuni-project/uyuni-tools/shared/l10n" + "github.com/uyuni-project/uyuni-tools/shared/types" +) + +type getFlags struct { + api.ConnectionDetails `mapstructure:"api"` + OutputFormat string `mapstructure:"output"` // e.g., "json", "yaml", "table" +} + +// NewCommand generates the 'mgrctl get' command root. +func NewCommand(globalFlags *types.GlobalFlags) *cobra.Command { + var flags getFlags + + getCmd := &cobra.Command{ + Use: "get [resource] [options]", + Short: L("Display one or many resources (systems, groups, etc.)"), + Long: L("Prints tables, JSON, or YAML of Uyuni API resources. Modeled after kubectl get."), + } + + getCmd.PersistentFlags().StringVarP(&flags.OutputFormat, "output", "o", "table", L("Output format. One of: table|json|yaml")) + + getCmd.AddCommand(newSystemCommand(globalFlags, &flags)) + + api.AddAPIFlags(getCmd) + return getCmd +} diff --git a/mgrctl/cmd/get/system.go b/mgrctl/cmd/get/system.go new file mode 100644 index 00000000000..878f2b9faaa --- /dev/null +++ b/mgrctl/cmd/get/system.go @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2026 SUSE LLC +// +// SPDX-License-Identifier: Apache-2.0 + +package get + +import ( + "encoding/json" + "fmt" + "os" + "text/tabwriter" + + "github.com/spf13/cobra" + "github.com/uyuni-project/uyuni-tools/shared/api" + apitypes "github.com/uyuni-project/uyuni-tools/shared/api/types" + . "github.com/uyuni-project/uyuni-tools/shared/l10n" + "github.com/uyuni-project/uyuni-tools/shared/types" + "github.com/uyuni-project/uyuni-tools/shared/utils" + "gopkg.in/yaml.v3" +) + +func newSystemCommand(globalFlags *types.GlobalFlags, parentFlags *getFlags) *cobra.Command { + return &cobra.Command{ + Use: "system [name/id]", + Aliases: []string{"systems"}, + Short: L("List or get details for registered systems"), + RunE: func(cmd *cobra.Command, args []string) error { + return runGetSystem(globalFlags, parentFlags, args) + }, + } +} + +func runGetSystem(_ *types.GlobalFlags, flags *getFlags, _ []string) error { + client, err := api.Init(&flags.ConnectionDetails) + if err == nil && (client.Details.User != "" || client.Details.InSession) { + err = client.Login() + } + if err != nil { + return utils.Errorf(err, L("unable to login to the server")) + } + + res, err := api.Get[[]apitypes.System](client, "system/listSystems") + if err != nil { + return utils.Errorf(err, L("failed to fetch systems from API")) + } + + systems := res.Result + + switch flags.OutputFormat { + case "json": + out, err := json.MarshalIndent(systems, "", " ") + if err != nil { + return utils.Errorf(err, L("failed to marshal JSON")) + } + fmt.Println(string(out)) + case "yaml": + out, err := yaml.Marshal(systems) + if err != nil { + return utils.Errorf(err, L("failed to marshal YAML")) + } + fmt.Println(string(out)) + default: + w := tabwriter.NewWriter(os.Stdout, 0, 0, 4, ' ', 0) + fmt.Fprintln(w, "ID\tNAME\tLAST_CHECKIN\tCREATED") + for _, sys := range systems { + fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", + sys.ID, + sys.Name, + sys.LastCheckin, + sys.Created, + ) + } + w.Flush() + } + + return nil +} diff --git a/shared/api/types/system.go b/shared/api/types/system.go new file mode 100644 index 00000000000..f660e61482a --- /dev/null +++ b/shared/api/types/system.go @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2026 SUSE LLC +// +// SPDX-License-Identifier: Apache-2.0 + +package types + +// System describes an Uyuni registered system/minion in the API. +type System struct { + ID int `json:"id" mapstructure:"id"` + Name string `json:"name" mapstructure:"name"` + LastCheckin string `json:"last_checkin" mapstructure:"last_checkin"` + Created string `json:"created" mapstructure:"created"` + LastBoot string `json:"last_boot" mapstructure:"last_boot"` +} From da18b51bb6022d327815b76be87376e5ba049f2e Mon Sep 17 00:00:00 2001 From: katara-Jayprakash Date: Mon, 23 Mar 2026 23:37:54 +0530 Subject: [PATCH 2/8] added test for system_test.go namespace Signed-off-by: katara-Jayprakash --- mgrctl/cmd/get/system_test.go | 73 +++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 mgrctl/cmd/get/system_test.go diff --git a/mgrctl/cmd/get/system_test.go b/mgrctl/cmd/get/system_test.go new file mode 100644 index 00000000000..67dbda7d726 --- /dev/null +++ b/mgrctl/cmd/get/system_test.go @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2026 SUSE LLC +// +// SPDX-License-Identifier: Apache-2.0 + +package get + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/uyuni-project/uyuni-tools/shared/api" + "github.com/uyuni-project/uyuni-tools/shared/types" +) + +var mockSystemJSON = `{ + "success": true, + "result": [ + { + "id": 1001, + "name": "test-system.uy", + "last_checkin": "2026-03-19 14:00:00", + "created": "2026-03-19 13:00:00" + } + ] +}` + +func TestRunSystem_JSON(t *testing.T) { + // 1. Create a mocked TLS server that simulates the Uyuni API + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Mock the login authentication endpoint + if strings.HasSuffix(r.URL.Path, "auth/login") { + w.Header().Set("Set-Cookie", "pxt-session-cookie=mock_cookie; Max-Age=3600") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"success": true, "result": "mock_cookie"}`)) + return + } + + // Mock the actual system endpoint + if strings.HasSuffix(r.URL.Path, "system/listSystems") { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(mockSystemJSON)) + return + } + + w.WriteHeader(http.StatusNotFound) + })) + defer ts.Close() + + // 2. Configure the mocked API Server details + mockHost := strings.TrimPrefix(ts.URL, "https://") + + globalFlags := &types.GlobalFlags{} + flags := &getFlags{ + ConnectionDetails: api.ConnectionDetails{ + Server: mockHost, + User: "admin", + Password: "mockpassword", + Insecure: true, + }, + OutputFormat: "json", + } + + // 3. Execute the function natively + cmd := newSystemCommand(globalFlags, flags) + err := cmd.RunE(cmd, []string{}) + + // 4. Assert no error occurred during authentication, fetching, and formatting + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } +} From 40b7efd598a19fe1252346912188244d8f435390 Mon Sep 17 00:00:00 2001 From: katara-Jayprakash Date: Mon, 11 May 2026 21:39:01 +0530 Subject: [PATCH 3/8] fix: govulncheck -tags ptf and golangci-lint Signed-off-by: katara-Jayprakash --- mgrctl/cmd/get/get.go | 8 +++++++- mgrctl/cmd/get/system.go | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/mgrctl/cmd/get/get.go b/mgrctl/cmd/get/get.go index 4e8e5e756ab..6507a06ec75 100644 --- a/mgrctl/cmd/get/get.go +++ b/mgrctl/cmd/get/get.go @@ -26,7 +26,13 @@ func NewCommand(globalFlags *types.GlobalFlags) *cobra.Command { Long: L("Prints tables, JSON, or YAML of Uyuni API resources. Modeled after kubectl get."), } - getCmd.PersistentFlags().StringVarP(&flags.OutputFormat, "output", "o", "table", L("Output format. One of: table|json|yaml")) + getCmd.PersistentFlags().StringVarP( + &flags.OutputFormat, + "output", + "o", + "table", + L("Output format. One of: table|json|yaml"), + ) getCmd.AddCommand(newSystemCommand(globalFlags, &flags)) diff --git a/mgrctl/cmd/get/system.go b/mgrctl/cmd/get/system.go index 878f2b9faaa..c6678dd247f 100644 --- a/mgrctl/cmd/get/system.go +++ b/mgrctl/cmd/get/system.go @@ -16,7 +16,7 @@ import ( . "github.com/uyuni-project/uyuni-tools/shared/l10n" "github.com/uyuni-project/uyuni-tools/shared/types" "github.com/uyuni-project/uyuni-tools/shared/utils" - "gopkg.in/yaml.v3" + "gopkg.in/yaml.v2" ) func newSystemCommand(globalFlags *types.GlobalFlags, parentFlags *getFlags) *cobra.Command { @@ -24,7 +24,7 @@ func newSystemCommand(globalFlags *types.GlobalFlags, parentFlags *getFlags) *co Use: "system [name/id]", Aliases: []string{"systems"}, Short: L("List or get details for registered systems"), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { return runGetSystem(globalFlags, parentFlags, args) }, } From e0aab72ee0a9268b49dfe264a1108626eb034946 Mon Sep 17 00:00:00 2001 From: katara-Jayprakash Date: Mon, 11 May 2026 21:53:27 +0530 Subject: [PATCH 4/8] chore: trigger CI rerun Signed-off-by: katara-Jayprakash From 7584b1e82f36021525122dfb0953c5ac3575b84d Mon Sep 17 00:00:00 2001 From: katara-Jayprakash Date: Tue, 9 Jun 2026 19:30:19 +0530 Subject: [PATCH 5/8] working on something Signed-off-by: katara-Jayprakash --- .gitignore | 1 + mgrctl/cmd/get/client.go | 25 ++++++++++++ mgrctl/cmd/get/descriptor.go | 19 +++++++++ mgrctl/cmd/get/get.go | 5 ++- mgrctl/cmd/get/printer.go | 74 +++++++++++++++++++++++++++++++++++ mgrctl/cmd/get/run.go | 32 +++++++++++++++ mgrctl/cmd/get/system.go | 67 +++++-------------------------- mgrctl/cmd/get/system_desc.go | 42 ++++++++++++++++++++ mgrctl/cmd/get/system_test.go | 2 +- 9 files changed, 207 insertions(+), 60 deletions(-) create mode 100644 mgrctl/cmd/get/client.go create mode 100644 mgrctl/cmd/get/descriptor.go create mode 100644 mgrctl/cmd/get/printer.go create mode 100644 mgrctl/cmd/get/run.go create mode 100644 mgrctl/cmd/get/system_desc.go diff --git a/.gitignore b/.gitignore index cb751bf9e29..2254e9b826e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ vendor.tar.gz __pycache__ **/tags *.mo +.DS_Store diff --git a/mgrctl/cmd/get/client.go b/mgrctl/cmd/get/client.go new file mode 100644 index 00000000000..f0de0264532 --- /dev/null +++ b/mgrctl/cmd/get/client.go @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 SUSE LLC +// +// SPDX-License-Identifier: Apache-2.0 + +package get + +import ( + "github.com/uyuni-project/uyuni-tools/shared/api" + . "github.com/uyuni-project/uyuni-tools/shared/l10n" + "github.com/uyuni-project/uyuni-tools/shared/utils" +) + +func newClient(conn api.ConnectionDetails) (*api.APIClient, error) { + client, err := api.Init(&conn) + if err != nil { + return nil, err + } + if client.Details.User != "" || client.Details.InSession { + err = client.Login() + } + if err != nil { + return nil, utils.Errorf(err, L("unable to login to the server")) + } + return client, nil +} diff --git a/mgrctl/cmd/get/descriptor.go b/mgrctl/cmd/get/descriptor.go new file mode 100644 index 00000000000..be19e945ace --- /dev/null +++ b/mgrctl/cmd/get/descriptor.go @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 SUSE LLC +// +// SPDX-License-Identifier: Apache-2.0 + +package get + +import "github.com/uyuni-project/uyuni-tools/shared/api" + +type ColumnDef struct { + Header string + Field string +} + +type Resource[T any] interface { + List(client *api.APIClient) ([]T, error) + Get(client *api.APIClient, name string) (T, error) + Columns() []ColumnDef + FilterFields() []string +} diff --git a/mgrctl/cmd/get/get.go b/mgrctl/cmd/get/get.go index 6507a06ec75..1bcf295a777 100644 --- a/mgrctl/cmd/get/get.go +++ b/mgrctl/cmd/get/get.go @@ -11,14 +11,14 @@ import ( "github.com/uyuni-project/uyuni-tools/shared/types" ) -type getFlags struct { +type getOptions struct { api.ConnectionDetails `mapstructure:"api"` OutputFormat string `mapstructure:"output"` // e.g., "json", "yaml", "table" } // NewCommand generates the 'mgrctl get' command root. func NewCommand(globalFlags *types.GlobalFlags) *cobra.Command { - var flags getFlags + var flags getOptions getCmd := &cobra.Command{ Use: "get [resource] [options]", @@ -34,6 +34,7 @@ func NewCommand(globalFlags *types.GlobalFlags) *cobra.Command { L("Output format. One of: table|json|yaml"), ) + // passing the -o flag to the subcommands to be able to use it in the same way for all the resources getCmd.AddCommand(newSystemCommand(globalFlags, &flags)) api.AddAPIFlags(getCmd) diff --git a/mgrctl/cmd/get/printer.go b/mgrctl/cmd/get/printer.go new file mode 100644 index 00000000000..61ab42266e8 --- /dev/null +++ b/mgrctl/cmd/get/printer.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2026 SUSE LLC +// +// SPDX-License-Identifier: Apache-2.0 + +package get + +import ( + "encoding/json" + "fmt" + "io" + "reflect" + "text/tabwriter" + + "gopkg.in/yaml.v2" +) + +func printJSON[T any](items []T, out io.Writer) error { + bytes, err := json.MarshalIndent(items, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintln(out, string(bytes)) + return err +} + +func printYAML[T any](items []T, out io.Writer) error { + bytes, err := yaml.Marshal(items) + if err != nil { + return err + } + _, err = fmt.Fprint(out, string(bytes)) + return err +} + +func printTable[T any](items []T, cols []ColumnDef, out io.Writer) error { + w := tabwriter.NewWriter(out, 0, 0, 4, ' ', 0) + + // Print headers + for i, col := range cols { + if i > 0 { + fmt.Fprint(w, "\t") + } + fmt.Fprint(w, col.Header) + } + fmt.Fprintln(w) + + // Print rows + for _, item := range items { + v := reflect.ValueOf(item) + for i, col := range cols { + if i > 0 { + fmt.Fprint(w, "\t") + } + field := v.FieldByName(col.Field) + if field.IsValid() { + fmt.Fprintf(w, "%v", field.Interface()) + } + } + fmt.Fprintln(w) + } + + return w.Flush() +} + +func printOutput[T any](format string, items []T, cols []ColumnDef, out io.Writer) error { + switch format { + case "json": + return printJSON(items, out) + case "yaml": + return printYAML(items, out) + default: + return printTable(items, cols, out) + } +} diff --git a/mgrctl/cmd/get/run.go b/mgrctl/cmd/get/run.go new file mode 100644 index 00000000000..9d1d8accfdb --- /dev/null +++ b/mgrctl/cmd/get/run.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 SUSE LLC +// +// SPDX-License-Identifier: Apache-2.0 + +package get + +import "os" + +func runGet[T any](flags *getOptions, res Resource[T], name string) error { + client, err := newClient(flags.ConnectionDetails) + if err != nil { + return err + } + + var items []T + if name != "" { + item, err := res.Get(client, name) + if err != nil { + return err + } + items = []T{item} + } else { + items, err = res.List(client) + if err != nil { + return err + } + } + + cols := res.Columns() + + return printOutput(flags.OutputFormat, items, cols, os.Stdout) +} diff --git a/mgrctl/cmd/get/system.go b/mgrctl/cmd/get/system.go index c6678dd247f..502f76debcd 100644 --- a/mgrctl/cmd/get/system.go +++ b/mgrctl/cmd/get/system.go @@ -5,73 +5,26 @@ package get import ( - "encoding/json" - "fmt" - "os" - "text/tabwriter" - "github.com/spf13/cobra" - "github.com/uyuni-project/uyuni-tools/shared/api" - apitypes "github.com/uyuni-project/uyuni-tools/shared/api/types" . "github.com/uyuni-project/uyuni-tools/shared/l10n" "github.com/uyuni-project/uyuni-tools/shared/types" "github.com/uyuni-project/uyuni-tools/shared/utils" - "gopkg.in/yaml.v2" ) -func newSystemCommand(globalFlags *types.GlobalFlags, parentFlags *getFlags) *cobra.Command { +func newSystemCommand(globalFlags *types.GlobalFlags, parentFlags *getOptions) *cobra.Command { return &cobra.Command{ Use: "system [name/id]", Aliases: []string{"systems"}, Short: L("List or get details for registered systems"), - RunE: func(_ *cobra.Command, args []string) error { - return runGetSystem(globalFlags, parentFlags, args) + RunE: func(cmd *cobra.Command, args []string) error { + return utils.CommandHelper(globalFlags, cmd, args, parentFlags, nil, + func(_ *types.GlobalFlags, flags *getOptions, _ *cobra.Command, args []string) error { + name := "" + if len(args) > 0 { + name = args[0] + } + return runGet(flags, systemResource{}, name) + }) }, } } - -func runGetSystem(_ *types.GlobalFlags, flags *getFlags, _ []string) error { - client, err := api.Init(&flags.ConnectionDetails) - if err == nil && (client.Details.User != "" || client.Details.InSession) { - err = client.Login() - } - if err != nil { - return utils.Errorf(err, L("unable to login to the server")) - } - - res, err := api.Get[[]apitypes.System](client, "system/listSystems") - if err != nil { - return utils.Errorf(err, L("failed to fetch systems from API")) - } - - systems := res.Result - - switch flags.OutputFormat { - case "json": - out, err := json.MarshalIndent(systems, "", " ") - if err != nil { - return utils.Errorf(err, L("failed to marshal JSON")) - } - fmt.Println(string(out)) - case "yaml": - out, err := yaml.Marshal(systems) - if err != nil { - return utils.Errorf(err, L("failed to marshal YAML")) - } - fmt.Println(string(out)) - default: - w := tabwriter.NewWriter(os.Stdout, 0, 0, 4, ' ', 0) - fmt.Fprintln(w, "ID\tNAME\tLAST_CHECKIN\tCREATED") - for _, sys := range systems { - fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", - sys.ID, - sys.Name, - sys.LastCheckin, - sys.Created, - ) - } - w.Flush() - } - - return nil -} diff --git a/mgrctl/cmd/get/system_desc.go b/mgrctl/cmd/get/system_desc.go new file mode 100644 index 00000000000..e9359eb99fe --- /dev/null +++ b/mgrctl/cmd/get/system_desc.go @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 SUSE LLC +// +// SPDX-License-Identifier: Apache-2.0 + +package get + +import ( + "fmt" + + "github.com/uyuni-project/uyuni-tools/shared/api" + apitypes "github.com/uyuni-project/uyuni-tools/shared/api/types" +) + +type systemResource struct{} + +// 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. +func (systemResource) List(client *api.APIClient) ([]apitypes.System, error) { + res, err := api.Get[[]apitypes.System](client, "system/listSystems") + if err != nil { + return nil, err + } + return res.Result, nil +} + +// Get by name or ID is not yet implemented because the API does not support it directly. We would need to fetch all systems and filter client-side, which is inefficient. +// This is a placeholder for future implementation when the API supports it. +func (systemResource) Get(client *api.APIClient, name string) (apitypes.System, error) { + return apitypes.System{}, fmt.Errorf("get by name not yet implemented") +} + +func (systemResource) Columns() []ColumnDef { + return []ColumnDef{ + {Header: "ID", Field: "ID"}, + {Header: "NAME", Field: "Name"}, + {Header: "LAST_CHECKIN", Field: "LastCheckin"}, + {Header: "CREATED", Field: "Created"}, + } +} + +func (systemResource) FilterFields() []string { + return []string{"Name", "ID"} +} diff --git a/mgrctl/cmd/get/system_test.go b/mgrctl/cmd/get/system_test.go index 67dbda7d726..59e128518ed 100644 --- a/mgrctl/cmd/get/system_test.go +++ b/mgrctl/cmd/get/system_test.go @@ -52,7 +52,7 @@ func TestRunSystem_JSON(t *testing.T) { mockHost := strings.TrimPrefix(ts.URL, "https://") globalFlags := &types.GlobalFlags{} - flags := &getFlags{ + flags := &getOptions{ ConnectionDetails: api.ConnectionDetails{ Server: mockHost, User: "admin", From 6f0a0dd8c8fcba15438b336e976f1d3405554f65 Mon Sep 17 00:00:00 2001 From: katara-Jayprakash Date: Tue, 23 Jun 2026 23:48:27 +0530 Subject: [PATCH 6/8] feat: add filter flag and custom columns support to get system command Signed-off-by: katara-Jayprakash --- mgrctl/cmd/get/printer.go | 88 ++++++++++++++++++ mgrctl/cmd/get/printer_test.go | 163 +++++++++++++++++++++++++++++++++ mgrctl/cmd/get/run.go | 9 +- mgrctl/cmd/get/system.go | 13 ++- mgrctl/cmd/get/system_desc.go | 26 +++++- shared/testutils/asserts.go | 4 +- 6 files changed, 295 insertions(+), 8 deletions(-) create mode 100644 mgrctl/cmd/get/printer_test.go diff --git a/mgrctl/cmd/get/printer.go b/mgrctl/cmd/get/printer.go index 61ab42266e8..c20eaa497a9 100644 --- a/mgrctl/cmd/get/printer.go +++ b/mgrctl/cmd/get/printer.go @@ -8,7 +8,9 @@ import ( "encoding/json" "fmt" "io" + "os" "reflect" + "strings" "text/tabwriter" "gopkg.in/yaml.v2" @@ -62,7 +64,93 @@ func printTable[T any](items []T, cols []ColumnDef, out io.Writer) error { return w.Flush() } +func mapJsonPathToField[T any](path string) string { + path = strings.TrimPrefix(path, ".") + + typ := reflect.TypeOf(new(T)).Elem() + if typ.Kind() == reflect.Ptr { + typ = typ.Elem() + } + + if typ.Kind() != reflect.Struct { + return path // fallback + } + + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + jsonTag := field.Tag.Get("json") + if jsonTag != "" { + name := strings.Split(jsonTag, ",")[0] + if name == path { + return field.Name + } + } + } + + return path // fallback if no tag matches +} + +func parseCustomColumns[T any](spec string) []ColumnDef { + var cols []ColumnDef + parts := strings.Split(spec, ",") + for _, part := range parts { + kv := strings.SplitN(part, ":", 2) + if len(kv) == 2 { + cols = append(cols, ColumnDef{ + Header: strings.TrimSpace(kv[0]), + Field: mapJsonPathToField[T](strings.TrimSpace(kv[1])), + }) + } + } + return cols +} + func printOutput[T any](format string, items []T, cols []ColumnDef, out io.Writer) error { + if strings.HasPrefix(format, "custom-columns=") { + spec := strings.TrimPrefix(format, "custom-columns=") + newCols := parseCustomColumns[T](spec) + if len(newCols) == 0 { + return fmt.Errorf("custom-columns format specified but no columns given") + } + return printTable(items, newCols, out) + } + + if strings.HasPrefix(format, "custom-columns-file=") { + filename := strings.TrimPrefix(format, "custom-columns-file=") + content, err := os.ReadFile(filename) + if err != nil { + return err + } + + lines := strings.Split(string(content), "\n") + var headers, paths []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if headers == nil { + headers = fields + } else if paths == nil { + paths = fields + } + } + + if len(headers) != len(paths) || len(headers) == 0 { + return fmt.Errorf("invalid custom-columns file format") + } + + var specParts []string + for i := range headers { + specParts = append(specParts, headers[i]+":"+paths[i]) + } + spec := strings.Join(specParts, ",") + + newCols := parseCustomColumns[T](spec) + return printTable(items, newCols, out) + } + switch format { case "json": return printJSON(items, out) diff --git a/mgrctl/cmd/get/printer_test.go b/mgrctl/cmd/get/printer_test.go new file mode 100644 index 00000000000..1bb98b93e67 --- /dev/null +++ b/mgrctl/cmd/get/printer_test.go @@ -0,0 +1,163 @@ +// SPDX-FileCopyrightText: 2026 SUSE LLC +// +// SPDX-License-Identifier: Apache-2.0 + +package get + +import ( + "bytes" + "os" + "strings" + "testing" + + apitypes "github.com/uyuni-project/uyuni-tools/shared/api/types" +) + +func TestMapJsonPathToField(t *testing.T) { + tests := []struct { + path string + expected string + }{ + {".id", "ID"}, + {".name", "Name"}, + {".last_checkin", "LastCheckin"}, + {".created", "Created"}, + {".last_boot", "LastBoot"}, + {"id", "ID"}, // without leading dot + {"name", "Name"}, // without leading dot + {".bogus", "bogus"}, // unknown field falls back (dot stripped) + } + + for _, tt := range tests { + got := mapJsonPathToField[apitypes.System](tt.path) + if got != tt.expected { + t.Errorf("mapJsonPathToField(%q) = %q, want %q", tt.path, got, tt.expected) + } + } +} + +func TestParseCustomColumns(t *testing.T) { + spec := "ID:.id,NAME:.name,LAST_SEEN:.last_checkin" + cols := parseCustomColumns[apitypes.System](spec) + + if len(cols) != 3 { + t.Fatalf("expected 3 columns, got %d", len(cols)) + } + + expected := []ColumnDef{ + {Header: "ID", Field: "ID"}, + {Header: "NAME", Field: "Name"}, + {Header: "LAST_SEEN", Field: "LastCheckin"}, + } + + for i, col := range cols { + if col.Header != expected[i].Header { + t.Errorf("col[%d].Header = %q, want %q", i, col.Header, expected[i].Header) + } + if col.Field != expected[i].Field { + t.Errorf("col[%d].Field = %q, want %q", i, col.Field, expected[i].Field) + } + } +} + +func TestPrintOutputCustomColumns(t *testing.T) { + items := []apitypes.System{ + {ID: 1001, Name: "web-server-01", LastCheckin: "2026-06-20 14:00:00"}, + {ID: 1002, Name: "db-server-02", LastCheckin: "2026-06-21 09:30:00"}, + } + + var buf bytes.Buffer + format := "custom-columns=ID:.id,NAME:.name,LAST_SEEN:.last_checkin" + err := printOutput(format, items, nil, &buf) + if err != nil { + t.Fatalf("printOutput returned error: %v", err) + } + + output := buf.String() + + // Check headers are present + if !strings.Contains(output, "ID") { + t.Error("output missing ID header") + } + if !strings.Contains(output, "NAME") { + t.Error("output missing NAME header") + } + if !strings.Contains(output, "LAST_SEEN") { + t.Error("output missing LAST_SEEN header") + } + + // Check data rows are present + if !strings.Contains(output, "1001") { + t.Error("output missing system ID 1001") + } + if !strings.Contains(output, "web-server-01") { + t.Error("output missing system name web-server-01") + } + if !strings.Contains(output, "db-server-02") { + t.Error("output missing system name db-server-02") + } + if !strings.Contains(output, "2026-06-21 09:30:00") { + t.Error("output missing last_checkin value") + } +} + +func TestPrintOutputCustomColumnsFile(t *testing.T) { + // Create a temp template file + content := "ID NAME LAST_SEEN\n.id .name .last_checkin\n" + tmpFile, err := os.CreateTemp("", "custom-cols-*.txt") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.WriteString(content); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + tmpFile.Close() + + items := []apitypes.System{ + {ID: 2001, Name: "test-minion", LastCheckin: "2026-06-22 10:00:00"}, + } + + var buf bytes.Buffer + format := "custom-columns-file=" + tmpFile.Name() + err = printOutput(format, items, nil, &buf) + if err != nil { + t.Fatalf("printOutput returned error: %v", err) + } + + output := buf.String() + + if !strings.Contains(output, "2001") { + t.Error("output missing system ID 2001") + } + if !strings.Contains(output, "test-minion") { + t.Error("output missing system name test-minion") + } +} + +func TestPrintOutputCustomColumnsEmpty(t *testing.T) { + items := []apitypes.System{} + var buf bytes.Buffer + format := "custom-columns=ID:.id" + err := printOutput(format, items, nil, &buf) + if err != nil { + t.Fatalf("printOutput returned error: %v", err) + } + + output := buf.String() + // Should still print the header even with no data + if !strings.Contains(output, "ID") { + t.Error("output missing ID header even with empty data") + } +} + +func TestPrintOutputCustomColumnsNoSpec(t *testing.T) { + items := []apitypes.System{} + var buf bytes.Buffer + format := "custom-columns=" + err := printOutput(format, items, nil, &buf) + if err == nil { + t.Error("expected error for empty custom-columns spec, got nil") + } +} diff --git a/mgrctl/cmd/get/run.go b/mgrctl/cmd/get/run.go index 9d1d8accfdb..39ba5657997 100644 --- a/mgrctl/cmd/get/run.go +++ b/mgrctl/cmd/get/run.go @@ -6,13 +6,20 @@ package get import "os" +// This runGet function is a generic interface function implementation for the 'get' command. +// It takes care of fetching the data from the API and printing it in the desired format (table, JSON, YAML). + func runGet[T any](flags *getOptions, res Resource[T], name string) error { client, err := newClient(flags.ConnectionDetails) if err != nil { return err } - var items []T + var items []T // We declare a list (slice) of things. + //mgrctl get system + + // if name like web.01.uyuni.suse.com, is not provided we fetch all items, etc + // otherwise we fetch the specific item and put it in a slice to be printed if name != "" { item, err := res.Get(client, name) if err != nil { diff --git a/mgrctl/cmd/get/system.go b/mgrctl/cmd/get/system.go index 502f76debcd..1db3d32aaac 100644 --- a/mgrctl/cmd/get/system.go +++ b/mgrctl/cmd/get/system.go @@ -11,8 +11,14 @@ import ( "github.com/uyuni-project/uyuni-tools/shared/utils" ) +type systemFlags struct { + Filter string `mapstructure:"filter"` +} + func newSystemCommand(globalFlags *types.GlobalFlags, parentFlags *getOptions) *cobra.Command { - return &cobra.Command{ + var sysFlags systemFlags + + cmd := &cobra.Command{ Use: "system [name/id]", Aliases: []string{"systems"}, Short: L("List or get details for registered systems"), @@ -23,8 +29,11 @@ func newSystemCommand(globalFlags *types.GlobalFlags, parentFlags *getOptions) * if len(args) > 0 { name = args[0] } - return runGet(flags, systemResource{}, name) + return runGet(flags, systemResource{flags: &sysFlags}, name) }) }, } + + cmd.Flags().StringVar(&sysFlags.Filter, "filter", "", L("Filter list (e.g. name=foo)")) + return cmd } diff --git a/mgrctl/cmd/get/system_desc.go b/mgrctl/cmd/get/system_desc.go index e9359eb99fe..bd8ff2e24ea 100644 --- a/mgrctl/cmd/get/system_desc.go +++ b/mgrctl/cmd/get/system_desc.go @@ -6,15 +6,35 @@ package get import ( "fmt" + "strings" "github.com/uyuni-project/uyuni-tools/shared/api" apitypes "github.com/uyuni-project/uyuni-tools/shared/api/types" ) -type systemResource struct{} +type systemResource struct { + flags *systemFlags +} + +// List returns all systems. +func (s systemResource) List(client *api.APIClient) ([]apitypes.System, error) { + // If the user explicitly provided the --filter flag, hit the filtered endpoint + if s.flags != nil && s.flags.Filter != "" { + parts := strings.SplitN(s.flags.Filter, "=", 2) + if len(parts) == 2 { + filterKey := parts[0] + filterValue := parts[1] + // The API strictly requires filterKey, filterValue, page, and pageSize + url := fmt.Sprintf("system/listSystemsFiltered?filterKey=%s&filterValue=%s&page=0&pageSize=50", filterKey, filterValue) + res, err := api.Get[[]apitypes.System](client, url) + if err != nil { + return nil, err + } + return res.Result, nil + } + } -// 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. -func (systemResource) List(client *api.APIClient) ([]apitypes.System, error) { + // Otherwise, default to the standard endpoint res, err := api.Get[[]apitypes.System](client, "system/listSystems") if err != nil { return nil, err diff --git a/shared/testutils/asserts.go b/shared/testutils/asserts.go index e8d6b445568..cab47e7f791 100644 --- a/shared/testutils/asserts.go +++ b/shared/testutils/asserts.go @@ -37,10 +37,10 @@ func DiffStrings(expected string, actual string) string { if exp != act { if i < len(expectedLines) { - diff.WriteString(fmt.Sprintf("-%d: %s\n", i+1, exp)) + fmt.Fprintf(&diff, "-%d: %s\n", i+1, exp) } if i < len(actualLines) { - diff.WriteString(fmt.Sprintf("+%d: %s\n", i+1, act)) + fmt.Fprintf(&diff, "+%d: %s\n", i+1, act) } } } From 8ed8c60e62fe42996f5293f7150bd7675c8f2634 Mon Sep 17 00:00:00 2001 From: katara-Jayprakash Date: Tue, 30 Jun 2026 18:44:03 +0530 Subject: [PATCH 7/8] Refactor get command Signed-off-by: katara-Jayprakash --- mgrctl/cmd/get/client.go | 25 ----- mgrctl/cmd/get/descriptor.go | 19 ---- mgrctl/cmd/get/get.go | 71 +++++++++---- mgrctl/cmd/get/printer.go | 180 +++++++++++++++++---------------- mgrctl/cmd/get/printer_test.go | 163 ----------------------------- mgrctl/cmd/get/resource.go | 82 +++++++++++++++ mgrctl/cmd/get/run.go | 39 ------- mgrctl/cmd/get/system.go | 39 ++----- mgrctl/cmd/get/system_desc.go | 62 ------------ mgrctl/cmd/get/system_test.go | 73 ------------- mgrctl/cmd/get/systemgroup.go | 26 +++++ shared/api/types/filtered.go | 23 +++++ 12 files changed, 288 insertions(+), 514 deletions(-) delete mode 100644 mgrctl/cmd/get/client.go delete mode 100644 mgrctl/cmd/get/descriptor.go delete mode 100644 mgrctl/cmd/get/printer_test.go create mode 100644 mgrctl/cmd/get/resource.go delete mode 100644 mgrctl/cmd/get/run.go delete mode 100644 mgrctl/cmd/get/system_desc.go delete mode 100644 mgrctl/cmd/get/system_test.go create mode 100644 mgrctl/cmd/get/systemgroup.go create mode 100644 shared/api/types/filtered.go diff --git a/mgrctl/cmd/get/client.go b/mgrctl/cmd/get/client.go deleted file mode 100644 index f0de0264532..00000000000 --- a/mgrctl/cmd/get/client.go +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-FileCopyrightText: 2026 SUSE LLC -// -// SPDX-License-Identifier: Apache-2.0 - -package get - -import ( - "github.com/uyuni-project/uyuni-tools/shared/api" - . "github.com/uyuni-project/uyuni-tools/shared/l10n" - "github.com/uyuni-project/uyuni-tools/shared/utils" -) - -func newClient(conn api.ConnectionDetails) (*api.APIClient, error) { - client, err := api.Init(&conn) - if err != nil { - return nil, err - } - if client.Details.User != "" || client.Details.InSession { - err = client.Login() - } - if err != nil { - return nil, utils.Errorf(err, L("unable to login to the server")) - } - return client, nil -} diff --git a/mgrctl/cmd/get/descriptor.go b/mgrctl/cmd/get/descriptor.go deleted file mode 100644 index be19e945ace..00000000000 --- a/mgrctl/cmd/get/descriptor.go +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-FileCopyrightText: 2026 SUSE LLC -// -// SPDX-License-Identifier: Apache-2.0 - -package get - -import "github.com/uyuni-project/uyuni-tools/shared/api" - -type ColumnDef struct { - Header string - Field string -} - -type Resource[T any] interface { - List(client *api.APIClient) ([]T, error) - Get(client *api.APIClient, name string) (T, error) - Columns() []ColumnDef - FilterFields() []string -} diff --git a/mgrctl/cmd/get/get.go b/mgrctl/cmd/get/get.go index 1bcf295a777..4b3f22600c5 100644 --- a/mgrctl/cmd/get/get.go +++ b/mgrctl/cmd/get/get.go @@ -5,38 +5,71 @@ package get import ( + "os" + + "github.com/rs/zerolog/log" "github.com/spf13/cobra" "github.com/uyuni-project/uyuni-tools/shared/api" . "github.com/uyuni-project/uyuni-tools/shared/l10n" "github.com/uyuni-project/uyuni-tools/shared/types" + "github.com/uyuni-project/uyuni-tools/shared/utils" ) -type getOptions struct { +type getFlags struct { api.ConnectionDetails `mapstructure:"api"` - OutputFormat string `mapstructure:"output"` // e.g., "json", "yaml", "table" + OutputFormat string `mapstructure:"output"` + Filter string `mapstructure:"filter"` + Page int `mapstructure:"-"` + PageSize int `mapstructure:"-"` } -// NewCommand generates the 'mgrctl get' command root. func NewCommand(globalFlags *types.GlobalFlags) *cobra.Command { - var flags getOptions + var flags getFlags + + cmd := &cobra.Command{ + Use: "get [flags]", + Short: L("Display one or many resources"), + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + ValidArgs: registeredTypes(), + RunE: func(cmd *cobra.Command, args []string) error { + return utils.CommandHelper(globalFlags, cmd, args, &flags, nil, runGet) + }, + } + + cmd.Flags().StringVarP(&flags.OutputFormat, "output", "o", "table", + L("Output format: table|json|yaml|custom-columns=SPEC|custom-columns-file=PATH")) + cmd.Flags().StringVar(&flags.Filter, "filter", "", + L("Filter expression (e.g. extra_pkg_count=0)")) + cmd.Flags().IntVar(&flags.Page, "page", 0, + L("Page number for paginated results (0-indexed)")) + cmd.Flags().IntVar(&flags.PageSize, "page-size", 50, + L("Number of items per page")) + + api.AddAPIFlags(cmd) + return cmd +} + +func runGet(_ *types.GlobalFlags, flags *getFlags, _ *cobra.Command, args []string) error { + resourceType := args[0] + log.Debug().Msgf("Running get %s", resourceType) - getCmd := &cobra.Command{ - Use: "get [resource] [options]", - Short: L("Display one or many resources (systems, groups, etc.)"), - Long: L("Prints tables, JSON, or YAML of Uyuni API resources. Modeled after kubectl get."), + fetcher, err := lookupFetcher(resourceType) + if err != nil { + return err } - getCmd.PersistentFlags().StringVarP( - &flags.OutputFormat, - "output", - "o", - "table", - L("Output format. One of: table|json|yaml"), - ) + client, err := api.Init(&flags.ConnectionDetails) + if err == nil && (client.Details.User != "" || client.Details.InSession) { + err = client.Login() + } + if err != nil { + return utils.Errorf(err, L("unable to login to the server")) + } - // passing the -o flag to the subcommands to be able to use it in the same way for all the resources - getCmd.AddCommand(newSystemCommand(globalFlags, &flags)) + items, err := fetcher.List(client, flags.Filter, flags.Page, flags.PageSize) + if err != nil { + return err + } - api.AddAPIFlags(getCmd) - return getCmd + return printOutput(flags.OutputFormat, items, fetcher.Columns(), os.Stdout) } diff --git a/mgrctl/cmd/get/printer.go b/mgrctl/cmd/get/printer.go index c20eaa497a9..ea2f5403613 100644 --- a/mgrctl/cmd/get/printer.go +++ b/mgrctl/cmd/get/printer.go @@ -9,35 +9,62 @@ import ( "fmt" "io" "os" - "reflect" "strings" "text/tabwriter" "gopkg.in/yaml.v2" ) -func printJSON[T any](items []T, out io.Writer) error { - bytes, err := json.MarshalIndent(items, "", " ") +func printOutput(format string, items []map[string]any, cols []ColumnDef, out io.Writer) error { + if strings.HasPrefix(format, "custom-columns=") { + spec := strings.TrimPrefix(format, "custom-columns=") + parsed := parseCustomColumns(spec) + if len(parsed) == 0 { + return fmt.Errorf("custom-columns format specified but no valid columns given") + } + return printTable(items, parsed, out) + } + + if strings.HasPrefix(format, "custom-columns-file=") { + path := strings.TrimPrefix(format, "custom-columns-file=") + fileCols, err := parseCustomColumnsFile(path) + if err != nil { + return err + } + return printTable(items, fileCols, out) + } + + switch format { + case "json": + return printJSON(items, out) + case "yaml": + return printYAML(items, out) + default: + return printTable(items, cols, out) + } +} + +func printJSON(items []map[string]any, out io.Writer) error { + data, err := json.MarshalIndent(items, "", " ") if err != nil { return err } - _, err = fmt.Fprintln(out, string(bytes)) + _, err = fmt.Fprintln(out, string(data)) return err } -func printYAML[T any](items []T, out io.Writer) error { - bytes, err := yaml.Marshal(items) +func printYAML(items []map[string]any, out io.Writer) error { + data, err := yaml.Marshal(items) if err != nil { return err } - _, err = fmt.Fprint(out, string(bytes)) + _, err = fmt.Fprint(out, string(data)) return err } -func printTable[T any](items []T, cols []ColumnDef, out io.Writer) error { +func printTable(items []map[string]any, cols []ColumnDef, out io.Writer) error { w := tabwriter.NewWriter(out, 0, 0, 4, ' ', 0) - // Print headers for i, col := range cols { if i > 0 { fmt.Fprint(w, "\t") @@ -46,16 +73,13 @@ func printTable[T any](items []T, cols []ColumnDef, out io.Writer) error { } fmt.Fprintln(w) - // Print rows for _, item := range items { - v := reflect.ValueOf(item) for i, col := range cols { if i > 0 { fmt.Fprint(w, "\t") } - field := v.FieldByName(col.Field) - if field.IsValid() { - fmt.Fprintf(w, "%v", field.Interface()) + if val, ok := fieldValue(item, col.Field); ok { + fmt.Fprint(w, formatValue(val)) } } fmt.Fprintln(w) @@ -64,99 +88,83 @@ func printTable[T any](items []T, cols []ColumnDef, out io.Writer) error { return w.Flush() } -func mapJsonPathToField[T any](path string) string { - path = strings.TrimPrefix(path, ".") - - typ := reflect.TypeOf(new(T)).Elem() - if typ.Kind() == reflect.Ptr { - typ = typ.Elem() +func formatValue(v any) string { + if f, ok := v.(float64); ok && f == float64(int64(f)) { + return fmt.Sprintf("%d", int64(f)) } - - if typ.Kind() != reflect.Struct { - return path // fallback - } - - for i := 0; i < typ.NumField(); i++ { - field := typ.Field(i) - jsonTag := field.Tag.Get("json") - if jsonTag != "" { - name := strings.Split(jsonTag, ",")[0] - if name == path { - return field.Name - } - } - } - - return path // fallback if no tag matches + return fmt.Sprintf("%v", v) } -func parseCustomColumns[T any](spec string) []ColumnDef { +func parseCustomColumns(spec string) []ColumnDef { var cols []ColumnDef - parts := strings.Split(spec, ",") - for _, part := range parts { + for _, part := range strings.Split(spec, ",") { kv := strings.SplitN(part, ":", 2) if len(kv) == 2 { cols = append(cols, ColumnDef{ Header: strings.TrimSpace(kv[0]), - Field: mapJsonPathToField[T](strings.TrimSpace(kv[1])), + Field: normalizeFieldPath(kv[1]), }) } } return cols } -func printOutput[T any](format string, items []T, cols []ColumnDef, out io.Writer) error { - if strings.HasPrefix(format, "custom-columns=") { - spec := strings.TrimPrefix(format, "custom-columns=") - newCols := parseCustomColumns[T](spec) - if len(newCols) == 0 { - return fmt.Errorf("custom-columns format specified but no columns given") - } - return printTable(items, newCols, out) +func parseCustomColumnsFile(path string) ([]ColumnDef, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, err } - if strings.HasPrefix(format, "custom-columns-file=") { - filename := strings.TrimPrefix(format, "custom-columns-file=") - content, err := os.ReadFile(filename) - if err != nil { - return err + lines := strings.Split(string(content), "\n") + var headers, paths []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue } - - lines := strings.Split(string(content), "\n") - var headers, paths []string - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - fields := strings.Fields(line) - if headers == nil { - headers = fields - } else if paths == nil { - paths = fields - } + if headers == nil { + headers = strings.Fields(line) + } else if paths == nil { + paths = strings.Fields(line) } + } - if len(headers) != len(paths) || len(headers) == 0 { - return fmt.Errorf("invalid custom-columns file format") - } + if len(headers) != len(paths) || len(headers) == 0 { + return nil, fmt.Errorf("invalid custom-columns file format") + } - var specParts []string - for i := range headers { - specParts = append(specParts, headers[i]+":"+paths[i]) - } - spec := strings.Join(specParts, ",") - - newCols := parseCustomColumns[T](spec) - return printTable(items, newCols, out) + var cols []ColumnDef + for i := range headers { + cols = append(cols, ColumnDef{ + Header: headers[i], + Field: normalizeFieldPath(paths[i]), + }) } + return cols, nil +} - switch format { - case "json": - return printJSON(items, out) - case "yaml": - return printYAML(items, out) - default: - return printTable(items, cols, out) +func normalizeFieldPath(path string) string { + return strings.TrimPrefix(strings.TrimSpace(path), ".") +} + +func fieldValue(item map[string]any, path string) (any, bool) { + if path == "" { + return nil, false + } + + var current any = item + for _, part := range strings.Split(normalizeFieldPath(path), ".") { + if part == "" { + return nil, false + } + m, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = m[part] + if !ok { + return nil, false + } } + return current, true } diff --git a/mgrctl/cmd/get/printer_test.go b/mgrctl/cmd/get/printer_test.go deleted file mode 100644 index 1bb98b93e67..00000000000 --- a/mgrctl/cmd/get/printer_test.go +++ /dev/null @@ -1,163 +0,0 @@ -// SPDX-FileCopyrightText: 2026 SUSE LLC -// -// SPDX-License-Identifier: Apache-2.0 - -package get - -import ( - "bytes" - "os" - "strings" - "testing" - - apitypes "github.com/uyuni-project/uyuni-tools/shared/api/types" -) - -func TestMapJsonPathToField(t *testing.T) { - tests := []struct { - path string - expected string - }{ - {".id", "ID"}, - {".name", "Name"}, - {".last_checkin", "LastCheckin"}, - {".created", "Created"}, - {".last_boot", "LastBoot"}, - {"id", "ID"}, // without leading dot - {"name", "Name"}, // without leading dot - {".bogus", "bogus"}, // unknown field falls back (dot stripped) - } - - for _, tt := range tests { - got := mapJsonPathToField[apitypes.System](tt.path) - if got != tt.expected { - t.Errorf("mapJsonPathToField(%q) = %q, want %q", tt.path, got, tt.expected) - } - } -} - -func TestParseCustomColumns(t *testing.T) { - spec := "ID:.id,NAME:.name,LAST_SEEN:.last_checkin" - cols := parseCustomColumns[apitypes.System](spec) - - if len(cols) != 3 { - t.Fatalf("expected 3 columns, got %d", len(cols)) - } - - expected := []ColumnDef{ - {Header: "ID", Field: "ID"}, - {Header: "NAME", Field: "Name"}, - {Header: "LAST_SEEN", Field: "LastCheckin"}, - } - - for i, col := range cols { - if col.Header != expected[i].Header { - t.Errorf("col[%d].Header = %q, want %q", i, col.Header, expected[i].Header) - } - if col.Field != expected[i].Field { - t.Errorf("col[%d].Field = %q, want %q", i, col.Field, expected[i].Field) - } - } -} - -func TestPrintOutputCustomColumns(t *testing.T) { - items := []apitypes.System{ - {ID: 1001, Name: "web-server-01", LastCheckin: "2026-06-20 14:00:00"}, - {ID: 1002, Name: "db-server-02", LastCheckin: "2026-06-21 09:30:00"}, - } - - var buf bytes.Buffer - format := "custom-columns=ID:.id,NAME:.name,LAST_SEEN:.last_checkin" - err := printOutput(format, items, nil, &buf) - if err != nil { - t.Fatalf("printOutput returned error: %v", err) - } - - output := buf.String() - - // Check headers are present - if !strings.Contains(output, "ID") { - t.Error("output missing ID header") - } - if !strings.Contains(output, "NAME") { - t.Error("output missing NAME header") - } - if !strings.Contains(output, "LAST_SEEN") { - t.Error("output missing LAST_SEEN header") - } - - // Check data rows are present - if !strings.Contains(output, "1001") { - t.Error("output missing system ID 1001") - } - if !strings.Contains(output, "web-server-01") { - t.Error("output missing system name web-server-01") - } - if !strings.Contains(output, "db-server-02") { - t.Error("output missing system name db-server-02") - } - if !strings.Contains(output, "2026-06-21 09:30:00") { - t.Error("output missing last_checkin value") - } -} - -func TestPrintOutputCustomColumnsFile(t *testing.T) { - // Create a temp template file - content := "ID NAME LAST_SEEN\n.id .name .last_checkin\n" - tmpFile, err := os.CreateTemp("", "custom-cols-*.txt") - if err != nil { - t.Fatalf("failed to create temp file: %v", err) - } - defer os.Remove(tmpFile.Name()) - - if _, err := tmpFile.WriteString(content); err != nil { - t.Fatalf("failed to write temp file: %v", err) - } - tmpFile.Close() - - items := []apitypes.System{ - {ID: 2001, Name: "test-minion", LastCheckin: "2026-06-22 10:00:00"}, - } - - var buf bytes.Buffer - format := "custom-columns-file=" + tmpFile.Name() - err = printOutput(format, items, nil, &buf) - if err != nil { - t.Fatalf("printOutput returned error: %v", err) - } - - output := buf.String() - - if !strings.Contains(output, "2001") { - t.Error("output missing system ID 2001") - } - if !strings.Contains(output, "test-minion") { - t.Error("output missing system name test-minion") - } -} - -func TestPrintOutputCustomColumnsEmpty(t *testing.T) { - items := []apitypes.System{} - var buf bytes.Buffer - format := "custom-columns=ID:.id" - err := printOutput(format, items, nil, &buf) - if err != nil { - t.Fatalf("printOutput returned error: %v", err) - } - - output := buf.String() - // Should still print the header even with no data - if !strings.Contains(output, "ID") { - t.Error("output missing ID header even with empty data") - } -} - -func TestPrintOutputCustomColumnsNoSpec(t *testing.T) { - items := []apitypes.System{} - var buf bytes.Buffer - format := "custom-columns=" - err := printOutput(format, items, nil, &buf) - if err == nil { - t.Error("expected error for empty custom-columns spec, got nil") - } -} diff --git a/mgrctl/cmd/get/resource.go b/mgrctl/cmd/get/resource.go new file mode 100644 index 00000000000..43677228413 --- /dev/null +++ b/mgrctl/cmd/get/resource.go @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2026 SUSE LLC +// +// SPDX-License-Identifier: Apache-2.0 + +package get + +import ( + "fmt" + "net/url" + "sort" + "strings" + + "github.com/uyuni-project/uyuni-tools/shared/api" + apitypes "github.com/uyuni-project/uyuni-tools/shared/api/types" + . "github.com/uyuni-project/uyuni-tools/shared/l10n" +) + +type ColumnDef struct { + Header string + Field string +} + +type ResourceFetcher interface { + List(client *api.APIClient, filter string, page, pageSize int) ([]map[string]any, error) + Columns() []ColumnDef +} + +var resourceTypes = map[string]ResourceFetcher{ + "system": systemFetcher{}, + "systemgroup": systemGroupFetcher{}, +} + +func registeredTypes() []string { + names := make([]string, 0, len(resourceTypes)) + for name := range resourceTypes { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func registeredTypesText() string { + return strings.Join(registeredTypes(), ", ") +} + +func lookupFetcher(name string) (ResourceFetcher, error) { + fetcher, ok := resourceTypes[name] + if !ok { + return nil, fmt.Errorf(L("unknown resource type %q; available: %s"), name, registeredTypesText()) + } + return fetcher, nil +} + +func listFiltered(client *api.APIClient, endpoint, filter string, page, pageSize int) ([]map[string]any, error) { + filterKey, filterValue := "", "" + if filter != "" { + filterKey, filterValue = parseFilter(filter) + } + + query := url.Values{} + query.Set("filterKey", filterKey) + query.Set("filterValue", filterValue) + query.Set("page", fmt.Sprintf("%d", page)) + query.Set("pageSize", fmt.Sprintf("%d", pageSize)) + + res, err := api.Get[apitypes.FilteredResponse[map[string]any]]( + client, fmt.Sprintf("%s?%s", endpoint, query.Encode()), + ) + if err != nil { + return nil, err + } + return res.Result.Data, nil +} + +func parseFilter(expr string) (string, string) { + for _, op := range []string{">=", "<=", "!=", "=", ">", "<"} { + if i := strings.Index(expr, op); i >= 0 { + return strings.TrimSpace(expr[:i]), strings.TrimSpace(expr[i:]) + } + } + return strings.TrimSpace(expr), "" +} diff --git a/mgrctl/cmd/get/run.go b/mgrctl/cmd/get/run.go deleted file mode 100644 index 39ba5657997..00000000000 --- a/mgrctl/cmd/get/run.go +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-FileCopyrightText: 2026 SUSE LLC -// -// SPDX-License-Identifier: Apache-2.0 - -package get - -import "os" - -// This runGet function is a generic interface function implementation for the 'get' command. -// It takes care of fetching the data from the API and printing it in the desired format (table, JSON, YAML). - -func runGet[T any](flags *getOptions, res Resource[T], name string) error { - client, err := newClient(flags.ConnectionDetails) - if err != nil { - return err - } - - var items []T // We declare a list (slice) of things. - //mgrctl get system - - // if name like web.01.uyuni.suse.com, is not provided we fetch all items, etc - // otherwise we fetch the specific item and put it in a slice to be printed - if name != "" { - item, err := res.Get(client, name) - if err != nil { - return err - } - items = []T{item} - } else { - items, err = res.List(client) - if err != nil { - return err - } - } - - cols := res.Columns() - - return printOutput(flags.OutputFormat, items, cols, os.Stdout) -} diff --git a/mgrctl/cmd/get/system.go b/mgrctl/cmd/get/system.go index 1db3d32aaac..d4822c69e4a 100644 --- a/mgrctl/cmd/get/system.go +++ b/mgrctl/cmd/get/system.go @@ -4,36 +4,19 @@ package get -import ( - "github.com/spf13/cobra" - . "github.com/uyuni-project/uyuni-tools/shared/l10n" - "github.com/uyuni-project/uyuni-tools/shared/types" - "github.com/uyuni-project/uyuni-tools/shared/utils" -) +import "github.com/uyuni-project/uyuni-tools/shared/api" -type systemFlags struct { - Filter string `mapstructure:"filter"` -} +type systemFetcher struct{} -func newSystemCommand(globalFlags *types.GlobalFlags, parentFlags *getOptions) *cobra.Command { - var sysFlags systemFlags +func (systemFetcher) List(client *api.APIClient, filter string, page, pageSize int) ([]map[string]any, error) { + return listFiltered(client, "system/listSystemsFiltered", filter, page, pageSize) +} - cmd := &cobra.Command{ - Use: "system [name/id]", - Aliases: []string{"systems"}, - Short: L("List or get details for registered systems"), - RunE: func(cmd *cobra.Command, args []string) error { - return utils.CommandHelper(globalFlags, cmd, args, parentFlags, nil, - func(_ *types.GlobalFlags, flags *getOptions, _ *cobra.Command, args []string) error { - name := "" - if len(args) > 0 { - name = args[0] - } - return runGet(flags, systemResource{flags: &sysFlags}, name) - }) - }, +func (systemFetcher) Columns() []ColumnDef { + return []ColumnDef{ + {Header: "ID", Field: "id"}, + {Header: "NAME", Field: "name"}, + {Header: "LAST_CHECKIN", Field: "last_checkin"}, + {Header: "CREATED", Field: "created"}, } - - cmd.Flags().StringVar(&sysFlags.Filter, "filter", "", L("Filter list (e.g. name=foo)")) - return cmd } diff --git a/mgrctl/cmd/get/system_desc.go b/mgrctl/cmd/get/system_desc.go deleted file mode 100644 index bd8ff2e24ea..00000000000 --- a/mgrctl/cmd/get/system_desc.go +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-FileCopyrightText: 2026 SUSE LLC -// -// SPDX-License-Identifier: Apache-2.0 - -package get - -import ( - "fmt" - "strings" - - "github.com/uyuni-project/uyuni-tools/shared/api" - apitypes "github.com/uyuni-project/uyuni-tools/shared/api/types" -) - -type systemResource struct { - flags *systemFlags -} - -// List returns all systems. -func (s systemResource) List(client *api.APIClient) ([]apitypes.System, error) { - // If the user explicitly provided the --filter flag, hit the filtered endpoint - if s.flags != nil && s.flags.Filter != "" { - parts := strings.SplitN(s.flags.Filter, "=", 2) - if len(parts) == 2 { - filterKey := parts[0] - filterValue := parts[1] - // The API strictly requires filterKey, filterValue, page, and pageSize - url := fmt.Sprintf("system/listSystemsFiltered?filterKey=%s&filterValue=%s&page=0&pageSize=50", filterKey, filterValue) - res, err := api.Get[[]apitypes.System](client, url) - if err != nil { - return nil, err - } - return res.Result, nil - } - } - - // Otherwise, default to the standard endpoint - res, err := api.Get[[]apitypes.System](client, "system/listSystems") - if err != nil { - return nil, err - } - return res.Result, nil -} - -// Get by name or ID is not yet implemented because the API does not support it directly. We would need to fetch all systems and filter client-side, which is inefficient. -// This is a placeholder for future implementation when the API supports it. -func (systemResource) Get(client *api.APIClient, name string) (apitypes.System, error) { - return apitypes.System{}, fmt.Errorf("get by name not yet implemented") -} - -func (systemResource) Columns() []ColumnDef { - return []ColumnDef{ - {Header: "ID", Field: "ID"}, - {Header: "NAME", Field: "Name"}, - {Header: "LAST_CHECKIN", Field: "LastCheckin"}, - {Header: "CREATED", Field: "Created"}, - } -} - -func (systemResource) FilterFields() []string { - return []string{"Name", "ID"} -} diff --git a/mgrctl/cmd/get/system_test.go b/mgrctl/cmd/get/system_test.go deleted file mode 100644 index 59e128518ed..00000000000 --- a/mgrctl/cmd/get/system_test.go +++ /dev/null @@ -1,73 +0,0 @@ -// SPDX-FileCopyrightText: 2026 SUSE LLC -// -// SPDX-License-Identifier: Apache-2.0 - -package get - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/uyuni-project/uyuni-tools/shared/api" - "github.com/uyuni-project/uyuni-tools/shared/types" -) - -var mockSystemJSON = `{ - "success": true, - "result": [ - { - "id": 1001, - "name": "test-system.uy", - "last_checkin": "2026-03-19 14:00:00", - "created": "2026-03-19 13:00:00" - } - ] -}` - -func TestRunSystem_JSON(t *testing.T) { - // 1. Create a mocked TLS server that simulates the Uyuni API - ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Mock the login authentication endpoint - if strings.HasSuffix(r.URL.Path, "auth/login") { - w.Header().Set("Set-Cookie", "pxt-session-cookie=mock_cookie; Max-Age=3600") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"success": true, "result": "mock_cookie"}`)) - return - } - - // Mock the actual system endpoint - if strings.HasSuffix(r.URL.Path, "system/listSystems") { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(mockSystemJSON)) - return - } - - w.WriteHeader(http.StatusNotFound) - })) - defer ts.Close() - - // 2. Configure the mocked API Server details - mockHost := strings.TrimPrefix(ts.URL, "https://") - - globalFlags := &types.GlobalFlags{} - flags := &getOptions{ - ConnectionDetails: api.ConnectionDetails{ - Server: mockHost, - User: "admin", - Password: "mockpassword", - Insecure: true, - }, - OutputFormat: "json", - } - - // 3. Execute the function natively - cmd := newSystemCommand(globalFlags, flags) - err := cmd.RunE(cmd, []string{}) - - // 4. Assert no error occurred during authentication, fetching, and formatting - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } -} diff --git a/mgrctl/cmd/get/systemgroup.go b/mgrctl/cmd/get/systemgroup.go new file mode 100644 index 00000000000..6d2aecc01ec --- /dev/null +++ b/mgrctl/cmd/get/systemgroup.go @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 SUSE LLC +// +// SPDX-License-Identifier: Apache-2.0 + +package get + +import "github.com/uyuni-project/uyuni-tools/shared/api" + +type systemGroupFetcher struct{} + +func (systemGroupFetcher) List(client *api.APIClient, _ string, _, _ int) ([]map[string]any, error) { + res, err := api.Get[[]map[string]any](client, "systemgroup/listAllGroups") + if err != nil { + return nil, err + } + return res.Result, nil +} + +func (systemGroupFetcher) Columns() []ColumnDef { + return []ColumnDef{ + {Header: "ID", Field: "id"}, + {Header: "NAME", Field: "name"}, + {Header: "DESCRIPTION", Field: "description"}, + {Header: "SYSTEM_COUNT", Field: "system_count"}, + } +} diff --git a/shared/api/types/filtered.go b/shared/api/types/filtered.go new file mode 100644 index 00000000000..353c04268d5 --- /dev/null +++ b/shared/api/types/filtered.go @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 SUSE LLC +// +// SPDX-License-Identifier: Apache-2.0 + +package types + +// FilteredResponse describes the paginated response from filtered API endpoints +// like system/listSystemsFiltered. +type FilteredResponse[T any] struct { + Data []T `json:"data"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +// SystemGroup describes an Uyuni system group in the API. +type SystemGroup struct { + ID int `json:"id" mapstructure:"id"` + Name string `json:"name" mapstructure:"name"` + Description string `json:"description" mapstructure:"description"` + OrgID int `json:"org_id" mapstructure:"org_id"` + SystemCount int `json:"system_count" mapstructure:"system_count"` +} From 701705d8812cb64196082e6a23f5f0ed2b7fcada Mon Sep 17 00:00:00 2001 From: katara-Jayprakash Date: Tue, 30 Jun 2026 19:04:35 +0530 Subject: [PATCH 8/8] Adding comments Signed-off-by: katara-Jayprakash --- .gitignore | 2 +- mgrctl/cmd/get/get.go | 31 ++++++-- mgrctl/cmd/get/resource.go | 87 +++++++++++---------- mgrctl/cmd/get/resource_test.go | 27 +++++++ mgrctl/cmd/get/system.go | 39 +++++++-- mgrctl/cmd/get/systemgroup.go | 17 ++-- shared/api/types/filtered.go | 2 +- {mgrctl/cmd/get => shared/utils}/printer.go | 21 ++++- shared/utils/printer_test.go | 57 ++++++++++++++ 9 files changed, 220 insertions(+), 63 deletions(-) create mode 100644 mgrctl/cmd/get/resource_test.go rename {mgrctl/cmd/get => shared/utils}/printer.go (88%) create mode 100644 shared/utils/printer_test.go diff --git a/.gitignore b/.gitignore index 2254e9b826e..1998d000389 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2023 SUSE LLC +# SPDX-FileCopyrightText: 2026 SUSE LLC # # SPDX-License-Identifier: Apache-2.0 diff --git a/mgrctl/cmd/get/get.go b/mgrctl/cmd/get/get.go index 4b3f22600c5..93e99cacf4f 100644 --- a/mgrctl/cmd/get/get.go +++ b/mgrctl/cmd/get/get.go @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2026 SUSE LLC +// SPDX-FileCopyrightText: 2026 Jayprakash // // SPDX-License-Identifier: Apache-2.0 @@ -29,6 +29,24 @@ func NewCommand(globalFlags *types.GlobalFlags) *cobra.Command { cmd := &cobra.Command{ Use: "get [flags]", Short: L("Display one or many resources"), + Example: ` mgrctl get system + mgrctl get systemgroup + mgrctl get system --page 0 --page-size 10 + mgrctl get system --filter "extra_pkg_count>0"`, + Long: L(`Fetch and display Uyuni API resources in table, JSON, or YAML format. + +The resource type is passed as the first argument. Filtering and pagination +are handled server-side when the API endpoint supports it. + +Available resource types: +` + GetResourceHelp() + ` + +Filter Operators: + >=, <=, !=, =, >, < (e.g. extra_pkg_count>0) + +Custom Columns: + Use -o custom-columns=HEADER:path,HEADER:path to extract specific JSON fields. + Example: -o custom-columns=ID:.id,NAME:.name`), Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), ValidArgs: registeredTypes(), RunE: func(cmd *cobra.Command, args []string) error { @@ -36,8 +54,7 @@ func NewCommand(globalFlags *types.GlobalFlags) *cobra.Command { }, } - cmd.Flags().StringVarP(&flags.OutputFormat, "output", "o", "table", - L("Output format: table|json|yaml|custom-columns=SPEC|custom-columns-file=PATH")) + utils.AddOutputFlag(cmd, &flags.OutputFormat) cmd.Flags().StringVar(&flags.Filter, "filter", "", L("Filter expression (e.g. extra_pkg_count=0)")) cmd.Flags().IntVar(&flags.Page, "page", 0, @@ -66,10 +83,14 @@ func runGet(_ *types.GlobalFlags, flags *getFlags, _ *cobra.Command, args []stri return utils.Errorf(err, L("unable to login to the server")) } - items, err := fetcher.List(client, flags.Filter, flags.Page, flags.PageSize) + items, total, err := fetcher.List(client, flags.Filter, flags.Page, flags.PageSize) if err != nil { return err } - return printOutput(flags.OutputFormat, items, fetcher.Columns(), os.Stdout) + if total > 0 && flags.PageSize > 0 { + log.Info().Msgf("Fetched %d items out of %d total", len(items), total) + } + + return utils.PrintOutput(flags.OutputFormat, items, fetcher.Columns(), os.Stdout) } diff --git a/mgrctl/cmd/get/resource.go b/mgrctl/cmd/get/resource.go index 43677228413..a7aeced76eb 100644 --- a/mgrctl/cmd/get/resource.go +++ b/mgrctl/cmd/get/resource.go @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2026 SUSE LLC +// SPDX-FileCopyrightText: 2026 Jayprakash // // SPDX-License-Identifier: Apache-2.0 @@ -6,34 +6,44 @@ package get import ( "fmt" - "net/url" "sort" "strings" "github.com/uyuni-project/uyuni-tools/shared/api" apitypes "github.com/uyuni-project/uyuni-tools/shared/api/types" . "github.com/uyuni-project/uyuni-tools/shared/l10n" + "github.com/uyuni-project/uyuni-tools/shared/utils" ) -type ColumnDef struct { - Header string - Field string +type ResourceFetcher interface { + List(client *api.APIClient, filter string, page, pageSize int) ([]map[string]any, int, error) + Columns() []utils.ColumnDef } -type ResourceFetcher interface { - List(client *api.APIClient, filter string, page, pageSize int) ([]map[string]any, error) - Columns() []ColumnDef +type Resource struct { + Fetcher ResourceFetcher + Aliases []string + Description string } -var resourceTypes = map[string]ResourceFetcher{ - "system": systemFetcher{}, - "systemgroup": systemGroupFetcher{}, +var resourceTypes = map[string]Resource{ + "system": { + Fetcher: systemFetcher{}, + Aliases: []string{"sys"}, + Description: "List systems", + }, + "systemgroup": { + Fetcher: systemGroupFetcher{}, + Aliases: []string{"grp"}, + Description: "List system groups", + }, } func registeredTypes() []string { - names := make([]string, 0, len(resourceTypes)) - for name := range resourceTypes { + names := make([]string, 0) + for name, res := range resourceTypes { names = append(names, name) + names = append(names, res.Aliases...) } sort.Strings(names) return names @@ -43,40 +53,37 @@ func registeredTypesText() string { return strings.Join(registeredTypes(), ", ") } -func lookupFetcher(name string) (ResourceFetcher, error) { - fetcher, ok := resourceTypes[name] - if !ok { - return nil, fmt.Errorf(L("unknown resource type %q; available: %s"), name, registeredTypesText()) +func GetResourceHelp() string { + var lines []string + for name, res := range resourceTypes { + aliases := "" + if len(res.Aliases) > 0 { + aliases = fmt.Sprintf(" (%s)", strings.Join(res.Aliases, ", ")) + } + lines = append(lines, fmt.Sprintf(" %-20s - %s", name+aliases, L(res.Description))) } - return fetcher, nil + sort.Strings(lines) + return strings.Join(lines, "\n") } -func listFiltered(client *api.APIClient, endpoint, filter string, page, pageSize int) ([]map[string]any, error) { - filterKey, filterValue := "", "" - if filter != "" { - filterKey, filterValue = parseFilter(filter) +func lookupFetcher(name string) (ResourceFetcher, error) { + if res, ok := resourceTypes[name]; ok { + return res.Fetcher, nil } + for _, res := range resourceTypes { + if utils.Contains(res.Aliases, name) { + return res.Fetcher, nil + } + } + return nil, fmt.Errorf(L("unknown resource type %[1]q; available: %[2]s"), name, registeredTypesText()) +} - query := url.Values{} - query.Set("filterKey", filterKey) - query.Set("filterValue", filterValue) - query.Set("page", fmt.Sprintf("%d", page)) - query.Set("pageSize", fmt.Sprintf("%d", pageSize)) - +func listFiltered(client *api.APIClient, endpoint, query string) ([]map[string]any, int, error) { res, err := api.Get[apitypes.FilteredResponse[map[string]any]]( - client, fmt.Sprintf("%s?%s", endpoint, query.Encode()), + client, fmt.Sprintf("%s?%s", endpoint, query), ) if err != nil { - return nil, err - } - return res.Result.Data, nil -} - -func parseFilter(expr string) (string, string) { - for _, op := range []string{">=", "<=", "!=", "=", ">", "<"} { - if i := strings.Index(expr, op); i >= 0 { - return strings.TrimSpace(expr[:i]), strings.TrimSpace(expr[i:]) - } + return nil, 0, err } - return strings.TrimSpace(expr), "" + return res.Result.Data, res.Result.Total, nil } diff --git a/mgrctl/cmd/get/resource_test.go b/mgrctl/cmd/get/resource_test.go new file mode 100644 index 00000000000..b05ddd527d5 --- /dev/null +++ b/mgrctl/cmd/get/resource_test.go @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 Jayprakash +// +// SPDX-License-Identifier: Apache-2.0 + +package get + +import ( + "testing" +) + +func TestResourceTypesNoDuplicates(t *testing.T) { + seen := make(map[string]bool) + + for name, res := range resourceTypes { + if seen[name] { + t.Errorf("Duplicate resource key found: %s", name) + } + seen[name] = true + + for _, alias := range res.Aliases { + if seen[alias] { + t.Errorf("Duplicate resource alias found: %s (in resource %s)", alias, name) + } + seen[alias] = true + } + } +} diff --git a/mgrctl/cmd/get/system.go b/mgrctl/cmd/get/system.go index d4822c69e4a..d6c8f425292 100644 --- a/mgrctl/cmd/get/system.go +++ b/mgrctl/cmd/get/system.go @@ -1,19 +1,46 @@ -// SPDX-FileCopyrightText: 2026 SUSE LLC +// SPDX-FileCopyrightText: 2026 Jayprakash // // SPDX-License-Identifier: Apache-2.0 package get -import "github.com/uyuni-project/uyuni-tools/shared/api" +import ( + "fmt" + "net/url" + "strings" + + "github.com/uyuni-project/uyuni-tools/shared/api" + "github.com/uyuni-project/uyuni-tools/shared/utils" +) type systemFetcher struct{} -func (systemFetcher) List(client *api.APIClient, filter string, page, pageSize int) ([]map[string]any, error) { - return listFiltered(client, "system/listSystemsFiltered", filter, page, pageSize) +func (systemFetcher) List(client *api.APIClient, filter string, page, pageSize int) ([]map[string]any, int, error) { + filterKey, filterValue := "", "" + if filter != "" { + filterKey, filterValue = parseFilter(filter) + } + + query := url.Values{} + query.Set("filterKey", filterKey) + query.Set("filterValue", filterValue) + query.Set("page", fmt.Sprintf("%d", page)) + query.Set("pageSize", fmt.Sprintf("%d", pageSize)) + + return listFiltered(client, "system/listSystemsFiltered", query.Encode()) +} + +func parseFilter(expr string) (string, string) { + for _, op := range []string{">=", "<=", "!=", "=", ">", "<"} { + if i := strings.Index(expr, op); i >= 0 { + return strings.TrimSpace(expr[:i]), strings.TrimSpace(expr[i:]) + } + } + return strings.TrimSpace(expr), "" } -func (systemFetcher) Columns() []ColumnDef { - return []ColumnDef{ +func (systemFetcher) Columns() []utils.ColumnDef { + return []utils.ColumnDef{ {Header: "ID", Field: "id"}, {Header: "NAME", Field: "name"}, {Header: "LAST_CHECKIN", Field: "last_checkin"}, diff --git a/mgrctl/cmd/get/systemgroup.go b/mgrctl/cmd/get/systemgroup.go index 6d2aecc01ec..9a278f2a539 100644 --- a/mgrctl/cmd/get/systemgroup.go +++ b/mgrctl/cmd/get/systemgroup.go @@ -1,23 +1,26 @@ -// SPDX-FileCopyrightText: 2026 SUSE LLC +// SPDX-FileCopyrightText: 2026 Jayprakash // // SPDX-License-Identifier: Apache-2.0 package get -import "github.com/uyuni-project/uyuni-tools/shared/api" +import ( + "github.com/uyuni-project/uyuni-tools/shared/api" + "github.com/uyuni-project/uyuni-tools/shared/utils" +) type systemGroupFetcher struct{} -func (systemGroupFetcher) List(client *api.APIClient, _ string, _, _ int) ([]map[string]any, error) { +func (systemGroupFetcher) List(client *api.APIClient, _ string, _, _ int) ([]map[string]any, int, error) { res, err := api.Get[[]map[string]any](client, "systemgroup/listAllGroups") if err != nil { - return nil, err + return nil, 0, err } - return res.Result, nil + return res.Result, len(res.Result), nil } -func (systemGroupFetcher) Columns() []ColumnDef { - return []ColumnDef{ +func (systemGroupFetcher) Columns() []utils.ColumnDef { + return []utils.ColumnDef{ {Header: "ID", Field: "id"}, {Header: "NAME", Field: "name"}, {Header: "DESCRIPTION", Field: "description"}, diff --git a/shared/api/types/filtered.go b/shared/api/types/filtered.go index 353c04268d5..2df81b9351b 100644 --- a/shared/api/types/filtered.go +++ b/shared/api/types/filtered.go @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2026 SUSE LLC +// SPDX-FileCopyrightText: 2026 Jayprakash // // SPDX-License-Identifier: Apache-2.0 diff --git a/mgrctl/cmd/get/printer.go b/shared/utils/printer.go similarity index 88% rename from mgrctl/cmd/get/printer.go rename to shared/utils/printer.go index ea2f5403613..238c28bd089 100644 --- a/mgrctl/cmd/get/printer.go +++ b/shared/utils/printer.go @@ -1,8 +1,8 @@ -// SPDX-FileCopyrightText: 2026 SUSE LLC +// SPDX-FileCopyrightText: 2026 Jayprakash // // SPDX-License-Identifier: Apache-2.0 -package get +package utils import ( "encoding/json" @@ -12,10 +12,22 @@ import ( "strings" "text/tabwriter" + "github.com/spf13/cobra" + . "github.com/uyuni-project/uyuni-tools/shared/l10n" "gopkg.in/yaml.v2" ) -func printOutput(format string, items []map[string]any, cols []ColumnDef, out io.Writer) error { +type ColumnDef struct { + Header string + Field string +} + +func AddOutputFlag(cmd *cobra.Command, outputFormat *string) { + cmd.Flags().StringVarP(outputFormat, "output", "o", "table", + L("Output format: table|json|yaml|custom-columns=SPEC|custom-columns-file=PATH")) +} + +func PrintOutput(format string, items []map[string]any, cols []ColumnDef, out io.Writer) error { if strings.HasPrefix(format, "custom-columns=") { spec := strings.TrimPrefix(format, "custom-columns=") parsed := parseCustomColumns(spec) @@ -97,6 +109,7 @@ func formatValue(v any) string { func parseCustomColumns(spec string) []ColumnDef { var cols []ColumnDef + for _, part := range strings.Split(spec, ",") { kv := strings.SplitN(part, ":", 2) if len(kv) == 2 { @@ -157,10 +170,12 @@ func fieldValue(item map[string]any, path string) (any, bool) { if part == "" { return nil, false } + m, ok := current.(map[string]any) if !ok { return nil, false } + current, ok = m[part] if !ok { return nil, false diff --git a/shared/utils/printer_test.go b/shared/utils/printer_test.go new file mode 100644 index 00000000000..98a8c3176eb --- /dev/null +++ b/shared/utils/printer_test.go @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2026 Jayprakash +// +// SPDX-License-Identifier: Apache-2.0 + +package utils + +import ( + "reflect" + "testing" +) + +func TestParseCustomColumns(t *testing.T) { + spec := "ID:.id,NAME:.name,Nested Field:.status.state" + expected := []ColumnDef{ + {Header: "ID", Field: "id"}, + {Header: "NAME", Field: "name"}, + {Header: "Nested Field", Field: "status.state"}, + } + + result := parseCustomColumns(spec) + + if !reflect.DeepEqual(result, expected) { + t.Errorf("Expected %v, got %v", expected, result) + } +} + +func TestFieldValue(t *testing.T) { + item := map[string]any{ + "id": 123, + "name": "web-server", + "status": map[string]any{ + "state": "running", + }, + } + + tests := []struct { + path string + expected any + found bool + }{ + {"id", 123, true}, + {"name", "web-server", true}, + {"status.state", "running", true}, + {"missing", nil, false}, + {"status.missing", nil, false}, + } + + for _, tt := range tests { + result, ok := fieldValue(item, tt.path) + if ok != tt.found { + t.Errorf("Expected found=%v for path %q, got %v", tt.found, tt.path, ok) + } + if result != tt.expected { + t.Errorf("Expected %v for path %q, got %v", tt.expected, tt.path, result) + } + } +}