diff --git a/cmd/cli/app/quickstart/quickstart.go b/cmd/cli/app/quickstart/quickstart.go index e253fbcd27..d2a22bbc17 100644 --- a/cmd/cli/app/quickstart/quickstart.go +++ b/cmd/cli/app/quickstart/quickstart.go @@ -23,6 +23,7 @@ import ( "github.com/mindersec/minder/cmd/cli/app/profile" minderprov "github.com/mindersec/minder/cmd/cli/app/provider" "github.com/mindersec/minder/cmd/cli/app/repo" + cliintern "github.com/mindersec/minder/internal/cli" ghclient "github.com/mindersec/minder/internal/providers/github/clients" "github.com/mindersec/minder/internal/util/cli" minderv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1" @@ -249,9 +250,46 @@ func quickstartCommand( r := fmt.Sprintf("%s/%s", result.Owner, result.Name) registeredRepos = append(registeredRepos, r) } + repoURL, err := cmd.Flags().GetString("catalog-repo") + if err != nil { + return err + } + if repoURL == "" { + repoURL = defaultQuickstartCatalogRepoURL + } + + return loadCatalog(cmd, ruleClient, profileClient, provider, project, registeredRepos, repoURL) +} + +const ( + quickstartRuleTypeFilePath = "rule-types/github/secret_scanning.yaml" + quickstartProfileFilePath = "profiles/github/profile.yaml" + defaultQuickstartCatalogRepoURL = "https://github.com/mindersec/minder-rules-and-profiles" +) +// loadCatalog drives the catalog portion of the quickstart flow. +// +// It first asks the user to confirm creation of the initial quickstart +// resources (the secret_scanning rule type and its profile), then attempts +// to source those resources from the configured catalog repository using +// loadCatalogFromRepo. +// +// If cloning the catalog repository or reading/parsing its files fails for +// any reason, loadCatalog prints a warning and transparently falls back to +// runExistingFlow, which uses the embedded quickstart YAML files shipped +// with the CLI. This preserves the original quickstart behavior while +// preferring the up-to-date remote catalog when available. +func loadCatalog( + cmd *cobra.Command, + ruleClient minderv1.RuleTypeServiceClient, + profileClient minderv1.ProfileServiceClient, + provider string, + project string, + registeredRepos []string, + repoURL string, +) error { // Step 3 - Confirm rule type creation - yes = cli.PrintYesNoPrompt(cmd, + yes := cli.PrintYesNoPrompt(cmd, stepPromptMsgRuleType, "Proceed?", "Quickstart operation cancelled.", @@ -260,17 +298,156 @@ func quickstartCommand( return nil } - // Creating the rule type - cmd.Println("Creating rule type...") + if err := loadCatalogFromRepo(cmd, ruleClient, profileClient, provider, project, registeredRepos, repoURL); err != nil { + cmd.Printf("Warning: failed to load quickstart catalog from %s: %v\n", repoURL, err) + cmd.Printf("Falling back to embedded quickstart catalog.\n") + return runExistingFlow(cmd, ruleClient, profileClient, provider, project, registeredRepos) + } + + return nil +} + +// loadCatalogFromRepo loads the quickstart rule type and profile directly +// from the configured Git repository. +// +// The function clones the catalog repository in memory, opens the +// quickstart rule type and profile YAML files from the in-memory +// filesystem, parses them into protobuf/CLI profile structures, applies +// the current provider/project context, and then creates the resources via +// the RuleType and Profile gRPC services. +// +// It mirrors the prompts and output of the original quickstart flow while +// sourcing the definitions from a remote catalog instead of the embedded +// YAML. Any error is propagated to the caller so that a higher-level +// fallback (to the embedded flow) can be applied. +func loadCatalogFromRepo( + cmd *cobra.Command, + ruleClient minderv1.RuleTypeServiceClient, + profileClient minderv1.ProfileServiceClient, + provider string, + project string, + registeredRepos []string, + repoURL string, +) error { + fs, err := cliintern.CloneRepoFilesystem(repoURL) + if err != nil { + return fmt.Errorf("failed to clone catalog repository: %w", err) + } + + rtReader, err := fs.Open(quickstartRuleTypeFilePath) + if err != nil { + return fmt.Errorf("failed to open rule type file: %w", err) + } + defer rtReader.Close() + + rt := &minderv1.RuleType{} + if err := minderv1.ParseResource(rtReader, rt); err != nil { + return fmt.Errorf("failed to parse rule type: %w", err) + } + + rt.Context = &minderv1.Context{ + Provider: &provider, + Project: &project, + } + + ctx, cancel := getQuickstartContext(cmd.Context(), viper.GetViper()) + defer cancel() + + cmd.Printf("Creating rule type from remote catalog...\n") + _, err = ruleClient.CreateRuleType(ctx, &minderv1.CreateRuleTypeRequest{RuleType: rt}) + if err != nil { + if st, ok := status.FromError(err); ok { + if st.Code() != codes.AlreadyExists { + return fmt.Errorf("error creating rule type from remote catalog: %w", err) + } + cmd.Println("Rule type secret_scanning already exists") + } else { + return cli.MessageAndError("error creating rule type", err) + } + } - // Load the rule type from the embedded file system + yes := cli.PrintYesNoPrompt(cmd, + fmt.Sprintf(stepPromptMsgProfile, strings.Join(registeredRepos, "\n")), + "Proceed?", + "Quickstart operation cancelled.", + true) + if !yes { + return nil + } + + cmd.Printf("Creating profile from remote catalog...\n") + profileReader, err := fs.Open(quickstartProfileFilePath) + if err != nil { + return fmt.Errorf("failed to open profile file: %w", err) + } + defer profileReader.Close() + + p, err := profiles.ParseYAML(profileReader) + if err != nil { + return fmt.Errorf("failed to parse profile: %w", err) + } + + p.Context = &minderv1.Context{ + Provider: &provider, + Project: &project, + } + + ctx, cancel = getQuickstartContext(cmd.Context(), viper.GetViper()) + defer cancel() + + alreadyExists := false + resp, err := profileClient.CreateProfile(ctx, &minderv1.CreateProfileRequest{Profile: p}) + if err != nil { + if st, ok := status.FromError(err); ok { + if st.Code() != codes.AlreadyExists { + return cli.MessageAndError("error creating profile", err) + } + alreadyExists = true + } else { + return cli.MessageAndError("error creating profile", err) + } + } + + if alreadyExists { + cmd.Println(cli.WarningBanner.Render(stepPromptMsgFinishExisting + stepPromptMsgFinishBase)) + } else { + cmd.Println(cli.WarningBanner.Render(stepPromptMsgFinishOK + stepPromptMsgFinishBase)) + cmd.Println("Profile details (minder profile list):") + table := profile.NewProfileRulesTable(cmd.OutOrStdout()) + profile.RenderProfileRulesTable(resp.GetProfile(), table) + table.Render() + } + + return nil +} + +// runExistingFlow contains the original quickstart catalog logic that uses +// the embedded secret_scanning rule type and profile YAML files. +// +// This function is used as a safe fallback when loading the catalog from +// the configured repository fails. It recreates the previous Step 3 and +// Step 4 behavior by: +// - reading secret_scanning.yaml and profile.yaml from the embedded FS, +// - parsing them into the appropriate rule type and profile structures, +// - applying the current provider/project context, and +// - creating the resources via the corresponding gRPC services, including +// handling AlreadyExists responses and printing the final banners and +// profile details table. +func runExistingFlow( + cmd *cobra.Command, + ruleClient minderv1.RuleTypeServiceClient, + profileClient minderv1.ProfileServiceClient, + provider string, + project string, + registeredRepos []string, +) error { + cmd.Println("Creating rule type...") reader, err := content.Open("embed/secret_scanning.yaml") if err != nil { return cli.MessageAndError("error opening rule type", err) } rt := &minderv1.RuleType{} - if err := minderv1.ParseResource(reader, rt); err != nil { return cli.MessageAndError("error parsing rule type", err) } @@ -284,14 +461,10 @@ func quickstartCommand( Project: &project, } - // New context so we don't time out between steps - ctx, cancel = getQuickstartContext(cmd.Context(), viper.GetViper()) + ctx, cancel := getQuickstartContext(cmd.Context(), viper.GetViper()) defer cancel() - // Create the rule type in minder - _, err = ruleClient.CreateRuleType(ctx, &minderv1.CreateRuleTypeRequest{ - RuleType: rt, - }) + _, err = ruleClient.CreateRuleType(ctx, &minderv1.CreateRuleTypeRequest{RuleType: rt}) if err != nil { if st, ok := status.FromError(err); ok { if st.Code() != codes.AlreadyExists { @@ -303,9 +476,8 @@ func quickstartCommand( } } - // Step 4 - Confirm profile creation - yes = cli.PrintYesNoPrompt(cmd, - fmt.Sprintf(stepPromptMsgProfile, strings.Join(registeredRepos[:], "\n")), + yes := cli.PrintYesNoPrompt(cmd, + fmt.Sprintf(stepPromptMsgProfile, strings.Join(registeredRepos, "\n")), "Proceed?", "Quickstart operation cancelled.", true) @@ -313,14 +485,12 @@ func quickstartCommand( return nil } - // Creating the profile cmd.Println("Creating profile...") reader, err = content.Open("embed/profile.yaml") if err != nil { return cli.MessageAndError("error opening profile", err) } - // Load the profile from the embedded file system p, err := profiles.ParseYAML(reader) if err != nil { return cli.MessageAndError("error parsing profile", err) @@ -335,15 +505,11 @@ func quickstartCommand( Project: &project, } - // New context so we don't time out between steps ctx, cancel = getQuickstartContext(cmd.Context(), viper.GetViper()) defer cancel() alreadyExists := false - // Create the profile in minder - resp, err := profileClient.CreateProfile(ctx, &minderv1.CreateProfileRequest{ - Profile: p, - }) + resp, err := profileClient.CreateProfile(ctx, &minderv1.CreateProfileRequest{Profile: p}) if err != nil { if st, ok := status.FromError(err); ok { if st.Code() != codes.AlreadyExists { @@ -355,19 +521,16 @@ func quickstartCommand( } } - // Finish - Confirm profile creation if alreadyExists { - // Print the "profile already exists" message cmd.Println(cli.WarningBanner.Render(stepPromptMsgFinishExisting + stepPromptMsgFinishBase)) } else { - // Print the "profile created" message cmd.Println(cli.WarningBanner.Render(stepPromptMsgFinishOK + stepPromptMsgFinishBase)) - // Print the profile create result table cmd.Println("Profile details (minder profile list):") table := profile.NewProfileRulesTable(cmd.OutOrStdout()) profile.RenderProfileRulesTable(resp.GetProfile(), table) table.Render() } + return nil } @@ -378,6 +541,7 @@ func init() { cmd.Flags().StringP("project", "j", "", "ID of the project") cmd.Flags().StringP("token", "t", "", "Personal Access Token (PAT) to use for enrollment") cmd.Flags().StringP("owner", "o", "", "Owner to filter on for provider resources") + cmd.Flags().String("catalog-repo", "", "Repository URL to load quickstart catalog from") // Bind flags if err := viper.BindPFlag("token", cmd.Flags().Lookup("token")); err != nil { cmd.Printf("error: %s", err) diff --git a/internal/cli/catalog.go b/internal/cli/catalog.go new file mode 100644 index 0000000000..be398edfc4 --- /dev/null +++ b/internal/cli/catalog.go @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright 2023 The Minder Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package cli contains reusable CLI helper logic for catalog operations +package cli + +import ( + "fmt" + + "github.com/go-git/go-billy/v5" + git "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/storage/memory" +) + +// CloneRepoFilesystem clones the given Git repository URL into an in-memory +// storage and returns its filesystem. Callers can use the returned +// filesystem to open files and traverse directories without touching disk. +func CloneRepoFilesystem(repoURL string) (billy.Filesystem, error) { + repo, err := git.Clone(memory.NewStorage(), nil, &git.CloneOptions{ + URL: repoURL, + Depth: 1, + }) + if err != nil { + return nil, fmt.Errorf("failed to clone repository %s: %w", repoURL, err) + } + + worktree, err := repo.Worktree() + if err != nil { + return nil, fmt.Errorf("failed to get worktree for %s: %w", repoURL, err) + } + + return worktree.Filesystem, nil +}