Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 189 additions & 25 deletions cmd/cli/app/quickstart/quickstart.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.",
Expand All @@ -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)
}
Expand All @@ -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 {
Expand All @@ -303,24 +476,21 @@ 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)
if !yes {
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)
Expand All @@ -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 {
Expand All @@ -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
}

Expand All @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions internal/cli/catalog.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading