diff --git a/cmd/list/aliases.go b/cmd/list/aliases.go index 917602f..0cacc2e 100644 --- a/cmd/list/aliases.go +++ b/cmd/list/aliases.go @@ -1,7 +1,10 @@ package list import ( + "errors" "fmt" + "net/http" + "strings" "github.com/fosrl/cli/internal/api" "github.com/fosrl/cli/internal/config" @@ -11,12 +14,24 @@ import ( const aliasesPageSize = 1000 +type aliasesFetchOptions struct { + includeLabels bool + labelFilter []string +} + func aliasesCmd() *cobra.Command { - return &cobra.Command{ + var withLabels bool + var labelFilter []string + + cmd := &cobra.Command{ Use: "aliases", Short: "Print every private host alias you can reach in the current organization", Long: `Lists all private site aliases you have access to in your selected organization—one name per line.`, RunE: func(cmd *cobra.Command, args []string) error { + if cmd.Flags().Changed("labels") { + withLabels = true + } + apiClient := api.FromContext(cmd.Context()) accountStore := config.AccountStoreFromContext(cmd.Context()) @@ -25,22 +40,164 @@ func aliasesCmd() *cobra.Command { return err } - for page := 1; ; page++ { - data, err := apiClient.ListUserResourceAliases(orgID, page, aliasesPageSize) - if err != nil { - return err - } - for _, a := range data.Aliases { - fmt.Println(a) - } - if len(data.Aliases) == 0 { - break - } - if len(data.Aliases) < aliasesPageSize { - break - } + requested := aliasesFetchOptions{ + includeLabels: withLabels, + labelFilter: labelFilter, } + + data, err := fetchAllAliases(apiClient, orgID, requested) + if err != nil { + return err + } + + printAliases(data, withLabels) return nil }, } + + cmd.Flags().StringSliceVarP(&labelFilter, "label", "l", nil, "Only list aliases for resources with this label (repeatable, OR)") + cmd.Flags().BoolVarP(&withLabels, "with-labels", "L", false, "Include label names in output (alias and comma-separated labels, tab-separated)") + _ = cmd.Flags().Bool("labels", false, "Deprecated: use --with-labels") + _ = cmd.Flags().MarkDeprecated("labels", "use --with-labels instead") + + return cmd +} + +func fetchAllAliases(apiClient *api.Client, orgID string, requested aliasesFetchOptions) (*api.ListUserResourceAliasesData, error) { + effective, clientSideFilter, err := resolveAliasesFetchOptions(apiClient, orgID, requested) + if err != nil { + return nil, err + } + + var combined api.ListUserResourceAliasesData + for page := 1; ; page++ { + pageData, err := apiClient.ListUserResourceAliases(orgID, page, aliasesPageSize, api.ListUserResourceAliasesOptions{ + IncludeLabels: effective.includeLabels, + LabelFilter: effective.labelFilter, + }) + if err != nil { + return nil, err + } + + combined.Aliases = append(combined.Aliases, pageData.Aliases...) + combined.Items = append(combined.Items, pageData.Items...) + combined.Pagination = pageData.Pagination + + if len(pageData.Aliases) == 0 { + break + } + if len(pageData.Aliases) < aliasesPageSize { + break + } + } + + if clientSideFilter { + combined = *filterAliasesByLabels(&combined, requested.labelFilter) + } + + return &combined, nil +} + +func resolveAliasesFetchOptions(apiClient *api.Client, orgID string, requested aliasesFetchOptions) (aliasesFetchOptions, bool, error) { + effective := requested + + if err := probeAliasesPage(apiClient, orgID, effective); err == nil { + return effective, false, nil + } else if !isBadRequest(err) { + return effective, false, err + } + + if len(requested.labelFilter) > 0 { + effective = aliasesFetchOptions{ + includeLabels: true, + labelFilter: nil, + } + if err := probeAliasesPage(apiClient, orgID, effective); err == nil { + return effective, true, nil + } else if !isBadRequest(err) { + return effective, false, err + } + } + + effective = aliasesFetchOptions{} + if err := probeAliasesPage(apiClient, orgID, effective); err != nil { + return effective, false, err + } + + return effective, false, nil +} + +func probeAliasesPage(apiClient *api.Client, orgID string, opts aliasesFetchOptions) error { + _, err := apiClient.ListUserResourceAliases(orgID, 1, 1, api.ListUserResourceAliasesOptions{ + IncludeLabels: opts.includeLabels, + LabelFilter: opts.labelFilter, + }) + return err +} + +func isBadRequest(err error) bool { + var apiErr *api.ErrorResponse + return errors.As(err, &apiErr) && apiErr.Status == http.StatusBadRequest +} + +func filterAliasesByLabels(data *api.ListUserResourceAliasesData, labelFilter []string) *api.ListUserResourceAliasesData { + if len(labelFilter) == 0 { + return data + } + + filterSet := make(map[string]struct{}, len(labelFilter)) + for _, label := range labelFilter { + filterSet[label] = struct{}{} + } + + if len(data.Items) == 0 { + return data + } + + filteredItems := make([]api.UserResourceAliasItem, 0, len(data.Items)) + for _, item := range data.Items { + if aliasMatchesLabelFilter(item.Labels, filterSet) { + filteredItems = append(filteredItems, item) + } + } + + aliases := make([]string, len(filteredItems)) + for i, item := range filteredItems { + aliases[i] = item.Alias + } + + return &api.ListUserResourceAliasesData{ + Aliases: aliases, + Items: filteredItems, + Pagination: data.Pagination, + } +} + +func aliasMatchesLabelFilter(labels []string, filterSet map[string]struct{}) bool { + for _, label := range labels { + if _, ok := filterSet[label]; ok { + return true + } + } + return false +} + +func printAliases(data *api.ListUserResourceAliasesData, withLabels bool) { + if !withLabels { + for _, alias := range data.Aliases { + fmt.Println(alias) + } + return + } + + if len(data.Items) > 0 { + for _, item := range data.Items { + fmt.Printf("%s\t%s\n", item.Alias, strings.Join(item.Labels, ",")) + } + return + } + + for _, alias := range data.Aliases { + fmt.Printf("%s\t\n", alias) + } } diff --git a/internal/api/client.go b/internal/api/client.go index e9b6e52..56f4893 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -352,17 +352,28 @@ func (c *Client) CheckOrgUserAccess(orgID, userID string) (*CheckOrgUserAccessRe return &response, nil } +// ListUserResourceAliasesOptions configures GET /org/:orgId/user-resource-aliases. +type ListUserResourceAliasesOptions struct { + IncludeLabels bool + LabelFilter []string +} + // ListUserResourceAliases returns one page of host-mode private site resource aliases for the user in the org. -func (c *Client) ListUserResourceAliases(orgID string, page, pageSize int) (*ListUserResourceAliasesData, error) { +func (c *Client) ListUserResourceAliases(orgID string, page, pageSize int, opts ListUserResourceAliasesOptions) (*ListUserResourceAliasesData, error) { path := fmt.Sprintf("/org/%s/user-resource-aliases", url.PathEscape(orgID)) var data ListUserResourceAliasesData - opts := RequestOptions{ - Query: map[string]string{ - "page": strconv.Itoa(page), - "pageSize": strconv.Itoa(pageSize), - }, + query := map[string]string{ + "page": strconv.Itoa(page), + "pageSize": strconv.Itoa(pageSize), + } + if opts.IncludeLabels { + query["includeLabels"] = "true" + } + if len(opts.LabelFilter) > 0 { + query["labels"] = strings.Join(opts.LabelFilter, ",") } - if err := c.Get(path, &data, opts); err != nil { + reqOpts := RequestOptions{Query: query} + if err := c.Get(path, &data, reqOpts); err != nil { return nil, err } return &data, nil diff --git a/internal/api/types.go b/internal/api/types.go index 9319f60..f8cc52c 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -308,10 +308,17 @@ type SignSSHKeyResponse struct { Data SignSSHKeyData `json:"data"` } +// UserResourceAliasItem is one alias with optional label names from includeLabels=true. +type UserResourceAliasItem struct { + Alias string `json:"alias"` + Labels []string `json:"labels"` +} + // ListUserResourceAliasesData is the inner `data` of GET /org/:orgId/user-resource-aliases. type ListUserResourceAliasesData struct { - Aliases []string `json:"aliases"` - Pagination AliasesPagination `json:"pagination"` + Aliases []string `json:"aliases"` + Items []UserResourceAliasItem `json:"items,omitempty"` + Pagination AliasesPagination `json:"pagination"` } // AliasesPagination matches the paginated API envelope for user-resource-aliases.