From d9e7dc6995ef5d591df59852453ae0c284f6f0c6 Mon Sep 17 00:00:00 2001 From: "Jose R. Gonzalez" Date: Mon, 16 Jun 2025 16:06:55 -0500 Subject: [PATCH] Add utilities to cleanup product listings or components in one-off scenarios Signed-off-by: Jose R. Gonzalez --- internal/catalogapi/catalogapi.go | 19 +++++ .../cmd/archivecomponent/archivecomponent.go | 70 ++++++++++++++++++ internal/cmd/productctl/cmd/cmd.go | 9 +++ .../deleteproductlisting.go | 72 +++++++++++++++++++ internal/cmd/productctl/cmd/fetch/fetch.go | 15 ++-- internal/genpyxis/generated.go | 2 +- internal/genpyxis/queries.graphql | 1 + 7 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 internal/cmd/productctl/cmd/archivecomponent/archivecomponent.go create mode 100644 internal/cmd/productctl/cmd/deleteproductlisting/deleteproductlisting.go diff --git a/internal/catalogapi/catalogapi.go b/internal/catalogapi/catalogapi.go index 0da30a0..43882cf 100644 --- a/internal/catalogapi/catalogapi.go +++ b/internal/catalogapi/catalogapi.go @@ -265,8 +265,10 @@ func PopulateProduct( return nil, err } + attachedIDs := make([]string, len(associatedComponents)) newListing.With.Components = make([]*resource.Component, 0, len(associatedComponents)) for _, v := range associatedComponents { + attachedIDs = append(attachedIDs, v.Id) converted, err := resource.JSONConvert[resource.Component](v) if err != nil { return nil, err @@ -276,6 +278,23 @@ func PopulateProduct( } slices.SortStableFunc(newListing.With.Components, func(a, b *resource.Component) int { return strings.Compare(a.ID, b.ID) }) + + // The cert_projects field in the product listing will contain + // attached-but-archived components. ComponentsForListing (above) only + // returns active components, so we'll true this up on the client side. + slices.Sort(attachedIDs) + slices.Sort(newListing.Spec.CertProjects) + + L.Info("foo") + if !slices.Equal(attachedIDs, newListing.Spec.CertProjects) { + L.Debug("product listing has attached components that are not marked as active.") + L.Debug( + "replacing product_listing.spec.cert_projects with only attached IDs", + "original", newListing.Spec.CertProjects, + "replacement", attachedIDs, + ) + newListing.Spec.CertProjects = attachedIDs + } } return &newListing, nil diff --git a/internal/cmd/productctl/cmd/archivecomponent/archivecomponent.go b/internal/cmd/productctl/cmd/archivecomponent/archivecomponent.go new file mode 100644 index 0000000..7ea349f --- /dev/null +++ b/internal/cmd/productctl/cmd/archivecomponent/archivecomponent.go @@ -0,0 +1,70 @@ +package archivecomponent + +import ( + "context" + + "github.com/Khan/genqlient/graphql" + "github.com/spf13/cobra" + + "github.com/opdev/productctl/internal/catalogapi" + "github.com/opdev/productctl/internal/cli" + "github.com/opdev/productctl/internal/genpyxis" + "github.com/opdev/productctl/internal/logger" +) + +func Command() *cobra.Command { + cmd := &cobra.Command{ + Use: "archive-component ", + Short: "Archives the component with the specified component ID", + Long: `Archives the component with the specified component ID + +This should be considered a destructive operation. Note that there are various reasons why the API may reject this operation. Those reasons may need to be handled directly via the Partner Connect UI.`, + Args: cobra.MinimumNArgs(1), // The component ID + RunE: runE, + } + + return cmd +} + +func runE(cmd *cobra.Command, args []string) error { + L := logger.FromContextOrDiscard(cmd.Context()) + _, token, err := cli.EnsureEnv() + if err != nil { + return err + } + + var endpoint string + if cmd.Flags().Changed(cli.FlagIDCustomEndpoint) { + endpoint, _ = cmd.Flags().GetString(cli.FlagIDCustomEndpoint) + L.Debug("custom endpoint set, using it over env value", "endpoint", endpoint) + } else { + env, _ := cmd.Flags().GetString(cli.FlagIDEndpoint) + endpoint, err = cli.ResolveAPIEndpoint(env) + if err != nil { + return err + } + L.Debug("endpoint resolved", "endpoint", endpoint) + } + + return run(cmd.Context(), args[0], token, endpoint) +} + +func run(ctx context.Context, componentID string, token string, endpoint catalogapi.APIEndpoint) error { + L := logger.FromContextOrDiscard(ctx) + L.Info("archiving component", "_id", componentID) + + L.Debug("building graphql client") + httpClient := catalogapi.TokenAuthenticatedHTTPClient(token, L.With("name", "httpclient")) + client := graphql.NewClient(endpoint, httpClient) + + resp, err := genpyxis.ArchiveComponent(ctx, client, componentID) + if err != nil { + return err + } + + if gqlErr := resp.Update_certification_project.GetError(); gqlErr != nil { + return catalogapi.ParseGraphQLResponseError(gqlErr) + } + + return nil +} diff --git a/internal/cmd/productctl/cmd/cmd.go b/internal/cmd/productctl/cmd/cmd.go index cd5dd7b..b696a57 100644 --- a/internal/cmd/productctl/cmd/cmd.go +++ b/internal/cmd/productctl/cmd/cmd.go @@ -9,9 +9,11 @@ import ( "github.com/opdev/productctl/internal/cli" "github.com/opdev/productctl/internal/cmd/productctl/cmd/apply" + "github.com/opdev/productctl/internal/cmd/productctl/cmd/archivecomponent" "github.com/opdev/productctl/internal/cmd/productctl/cmd/bridge" "github.com/opdev/productctl/internal/cmd/productctl/cmd/cleanup" "github.com/opdev/productctl/internal/cmd/productctl/cmd/create" + "github.com/opdev/productctl/internal/cmd/productctl/cmd/deleteproductlisting" "github.com/opdev/productctl/internal/cmd/productctl/cmd/fetch" "github.com/opdev/productctl/internal/cmd/productctl/cmd/jsonschema" "github.com/opdev/productctl/internal/cmd/productctl/cmd/sanitize" @@ -36,6 +38,13 @@ func RootCmd() *cobra.Command { cmd.AddCommand(version.Command()) cmd.PersistentFlags().String(cli.FlagIDLogLevel, "info", "The verbosity of the tool itself. Ex. error, warn, info, debug") + util := bridge.Command("util", "Utilities for the management of your Partner Connect account") + util.PersistentFlags().String(cli.FlagIDEndpoint, "prod", "The catalog API environment to use. Choose from stage, prod") + util.PersistentFlags().String(cli.FlagIDCustomEndpoint, "", "Define a custom API endpoint. Supersedes predefined environment values like \"prod\" if set") + util.AddCommand(archivecomponent.Command()) + util.AddCommand(deleteproductlisting.Command()) + cmd.AddCommand(util) + // Build the product management command tree. product := bridge.Command("product", "Manage your Product Listing") product.PersistentFlags().String(cli.FlagIDEndpoint, "prod", "The catalog API environment to use. Choose from stage, prod") diff --git a/internal/cmd/productctl/cmd/deleteproductlisting/deleteproductlisting.go b/internal/cmd/productctl/cmd/deleteproductlisting/deleteproductlisting.go new file mode 100644 index 0000000..ff71f68 --- /dev/null +++ b/internal/cmd/productctl/cmd/deleteproductlisting/deleteproductlisting.go @@ -0,0 +1,72 @@ +package deleteproductlisting + +import ( + "context" + + "github.com/Khan/genqlient/graphql" + "github.com/spf13/cobra" + + "github.com/opdev/productctl/internal/catalogapi" + "github.com/opdev/productctl/internal/cli" + "github.com/opdev/productctl/internal/genpyxis" + "github.com/opdev/productctl/internal/logger" +) + +func Command() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete-productlisting ", + Short: "Deletes the product listingwith the specified product ID.", + Long: `Deletes the product listing with the specified product ID + +This should be considered a destructive operation. Note that there are various reasons why the API may reject this operation. Those reasons may need to be handled directly via the Partner Connect UI. +`, + Args: cobra.MinimumNArgs(1), // The product ID + RunE: runE, + } + + return cmd +} + +func runE(cmd *cobra.Command, args []string) error { + L := logger.FromContextOrDiscard(cmd.Context()) + _, token, err := cli.EnsureEnv() + if err != nil { + return err + } + + var endpoint string + if cmd.Flags().Changed(cli.FlagIDCustomEndpoint) { + endpoint, _ = cmd.Flags().GetString(cli.FlagIDCustomEndpoint) + L.Debug("custom endpoint set, using it over env value", "endpoint", endpoint) + } else { + env, _ := cmd.Flags().GetString(cli.FlagIDEndpoint) + endpoint, err = cli.ResolveAPIEndpoint(env) + if err != nil { + return err + } + L.Debug("endpoint resolved", "endpoint", endpoint) + } + + return run(cmd.Context(), args[0], token, endpoint) +} + +func run(ctx context.Context, listingID string, token string, endpoint catalogapi.APIEndpoint) error { + L := logger.FromContextOrDiscard(ctx) + L.Info("deleting product listing", "_id", listingID) + + L.Debug("building graphql client") + httpClient := catalogapi.TokenAuthenticatedHTTPClient(token, L.With("name", "httpclient")) + client := graphql.NewClient(endpoint, httpClient) + + resp, err := genpyxis.DeleteProduct(ctx, client, listingID) + if err != nil { + return err + } + + if gqlErr := resp.Update_product_listing.GetError(); gqlErr != nil { + return catalogapi.ParseGraphQLResponseError(gqlErr) + } + + L.Info("done") + return nil +} diff --git a/internal/cmd/productctl/cmd/fetch/fetch.go b/internal/cmd/productctl/cmd/fetch/fetch.go index e98811c..56b6d44 100644 --- a/internal/cmd/productctl/cmd/fetch/fetch.go +++ b/internal/cmd/productctl/cmd/fetch/fetch.go @@ -2,7 +2,6 @@ package fetch import ( - "context" "fmt" "os" @@ -19,9 +18,14 @@ func Command() *cobra.Command { cmd := &cobra.Command{ Use: "fetch ", Short: "Get a pre-existing product listing", - Long: "Get data about a pre-existing product listing by its ID and generate its declaration for storage on disk.", - Args: cobra.MinimumNArgs(1), - RunE: getProductListingRunE, + Long: `Get data about a pre-existing product listing by its ID and generate its declaration for storage on disk. + +Only components attached to this product listing with an "active" status will be returned. + +This command does not overwrite an existing file, and relies in output redirection to store the contents to disk at any location you would prefer. +`, + Args: cobra.MinimumNArgs(1), + RunE: getProductListingRunE, } return cmd @@ -49,11 +53,10 @@ func getProductListingRunE(cmd *cobra.Command, args []string) error { L.Debug("endpoint resolved", "endpoint", endpoint) } - ctx := context.Background() httpClient := catalogapi.TokenAuthenticatedHTTPClient(token, L.With("name", "httpclient")) client := graphql.NewClient(endpoint, httpClient) - newListing, err := catalogapi.PopulateProduct(ctx, client, productID) + newListing, err := catalogapi.PopulateProduct(cmd.Context(), client, productID) if err != nil { return err } diff --git a/internal/genpyxis/generated.go b/internal/genpyxis/generated.go index 5396170..caa8b11 100644 --- a/internal/genpyxis/generated.go +++ b/internal/genpyxis/generated.go @@ -2726,7 +2726,7 @@ func ArchiveComponent( // The query or mutation executed by ComponentsForListing. const ComponentsForListing_Operation = ` query ComponentsForListing ($productID: ObjectIDFilterScalar, $page: Int!, $pageSize: Int!) { - find_product_listing_certification_projects(id: $productID, page: $page, page_size: $pageSize) { + find_product_listing_certification_projects(id: $productID, page: $page, page_size: $pageSize, filter: {project_status:{eq:"active"}}) { data { ... ComponentSupportedFields } diff --git a/internal/genpyxis/queries.graphql b/internal/genpyxis/queries.graphql index 7022473..1d55e68 100644 --- a/internal/genpyxis/queries.graphql +++ b/internal/genpyxis/queries.graphql @@ -112,6 +112,7 @@ query ComponentsForListing( id: $productID page: $page page_size: $pageSize + filter: {project_status: {eq: "active"}} ) { # @genqlient(flatten: true) data {