diff --git a/.gitignore b/.gitignore index cb751bf9e29..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 @@ -8,3 +8,4 @@ vendor.tar.gz __pycache__ **/tags *.mo +.DS_Store 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..93e99cacf4f --- /dev/null +++ b/mgrctl/cmd/get/get.go @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: 2026 Jayprakash +// +// SPDX-License-Identifier: Apache-2.0 + +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 getFlags struct { + api.ConnectionDetails `mapstructure:"api"` + OutputFormat string `mapstructure:"output"` + Filter string `mapstructure:"filter"` + Page int `mapstructure:"-"` + PageSize int `mapstructure:"-"` +} + +func NewCommand(globalFlags *types.GlobalFlags) *cobra.Command { + var flags getFlags + + 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 { + return utils.CommandHelper(globalFlags, cmd, args, &flags, nil, runGet) + }, + } + + 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, + 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) + + fetcher, err := lookupFetcher(resourceType) + if err != nil { + return err + } + + 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")) + } + + items, total, err := fetcher.List(client, flags.Filter, flags.Page, flags.PageSize) + if err != nil { + return err + } + + 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 new file mode 100644 index 00000000000..a7aeced76eb --- /dev/null +++ b/mgrctl/cmd/get/resource.go @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2026 Jayprakash +// +// SPDX-License-Identifier: Apache-2.0 + +package get + +import ( + "fmt" + "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 ResourceFetcher interface { + List(client *api.APIClient, filter string, page, pageSize int) ([]map[string]any, int, error) + Columns() []utils.ColumnDef +} + +type Resource struct { + Fetcher ResourceFetcher + Aliases []string + Description string +} + +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) + for name, res := range resourceTypes { + names = append(names, name) + names = append(names, res.Aliases...) + } + sort.Strings(names) + return names +} + +func registeredTypesText() string { + return strings.Join(registeredTypes(), ", ") +} + +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))) + } + sort.Strings(lines) + return strings.Join(lines, "\n") +} + +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()) +} + +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), + ) + if err != nil { + return nil, 0, err + } + 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 new file mode 100644 index 00000000000..d6c8f425292 --- /dev/null +++ b/mgrctl/cmd/get/system.go @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 Jayprakash +// +// SPDX-License-Identifier: Apache-2.0 + +package get + +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, 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() []utils.ColumnDef { + return []utils.ColumnDef{ + {Header: "ID", Field: "id"}, + {Header: "NAME", Field: "name"}, + {Header: "LAST_CHECKIN", Field: "last_checkin"}, + {Header: "CREATED", Field: "created"}, + } +} diff --git a/mgrctl/cmd/get/systemgroup.go b/mgrctl/cmd/get/systemgroup.go new file mode 100644 index 00000000000..9a278f2a539 --- /dev/null +++ b/mgrctl/cmd/get/systemgroup.go @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2026 Jayprakash +// +// SPDX-License-Identifier: Apache-2.0 + +package get + +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, int, error) { + res, err := api.Get[[]map[string]any](client, "systemgroup/listAllGroups") + if err != nil { + return nil, 0, err + } + return res.Result, len(res.Result), nil +} + +func (systemGroupFetcher) Columns() []utils.ColumnDef { + return []utils.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..2df81b9351b --- /dev/null +++ b/shared/api/types/filtered.go @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 Jayprakash +// +// 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"` +} 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"` +} 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) } } } diff --git a/shared/utils/printer.go b/shared/utils/printer.go new file mode 100644 index 00000000000..238c28bd089 --- /dev/null +++ b/shared/utils/printer.go @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: 2026 Jayprakash +// +// SPDX-License-Identifier: Apache-2.0 + +package utils + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + . "github.com/uyuni-project/uyuni-tools/shared/l10n" + "gopkg.in/yaml.v2" +) + +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) + 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(data)) + return err +} + +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(data)) + return err +} + +func printTable(items []map[string]any, cols []ColumnDef, out io.Writer) error { + w := tabwriter.NewWriter(out, 0, 0, 4, ' ', 0) + + for i, col := range cols { + if i > 0 { + fmt.Fprint(w, "\t") + } + fmt.Fprint(w, col.Header) + } + fmt.Fprintln(w) + + for _, item := range items { + for i, col := range cols { + if i > 0 { + fmt.Fprint(w, "\t") + } + if val, ok := fieldValue(item, col.Field); ok { + fmt.Fprint(w, formatValue(val)) + } + } + fmt.Fprintln(w) + } + + return w.Flush() +} + +func formatValue(v any) string { + if f, ok := v.(float64); ok && f == float64(int64(f)) { + return fmt.Sprintf("%d", int64(f)) + } + return fmt.Sprintf("%v", v) +} + +func parseCustomColumns(spec string) []ColumnDef { + var cols []ColumnDef + + 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: normalizeFieldPath(kv[1]), + }) + } + } + return cols +} + +func parseCustomColumnsFile(path string) ([]ColumnDef, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + lines := strings.Split(string(content), "\n") + var headers, paths []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if headers == nil { + headers = strings.Fields(line) + } else if paths == nil { + paths = strings.Fields(line) + } + } + + if len(headers) != len(paths) || len(headers) == 0 { + return nil, fmt.Errorf("invalid custom-columns file format") + } + + var cols []ColumnDef + for i := range headers { + cols = append(cols, ColumnDef{ + Header: headers[i], + Field: normalizeFieldPath(paths[i]), + }) + } + return cols, nil +} + +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/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) + } + } +}