diff --git a/README.md b/README.md index 6377773199..695b8076f4 100644 --- a/README.md +++ b/README.md @@ -83,39 +83,60 @@ minder auth login Upon completion, you should see that the Minder Server is set to `api.stacklok.com`. -## Enroll a repository provider +## Quickstart -Minder supports GitHub as a provider to enroll repositories. To enroll your provider, run: +Minder provides a "happy path" that guides you through the process of creating your first profile in Minder. +In just a few seconds, you will register your repositories and enable secret scanning protection for all of them. +To do so, run: ```bash -minder provider enroll --provider github +minder quickstart ``` -A browser session will open, and you will be prompted to login to your GitHub. -Once you have granted Minder access, you will be redirected back, and the user will be enrolled. -The minder CLI application will report the session is complete. +This will prompt you to enroll your provider, select the repositories you'd like, create the `secret_scanning` +rule type and create a profile which enables secret scanning for the selected repositories. -## Register a repository - -Now that you've granted the GitHub app permissions to access your repositories, you can register them: +To see the status of your profile, run: ```bash -minder repo register --provider github +minder profile_status list --profile quickstart-profile --detailed ``` -Once you've registered the repositories, the Minder server will listen for events from GitHub and will -automatically create the necessary webhooks for you. +You should see the overall profile status and a detailed view of the rule evaluation statuses for each of your registered repositories. + +Minder will continue to keep track of your repositories and will ensure to fix any drifts from the desired state by +using the `remediate` feature or alert you, if needed, using the `alert` feature. + +Congratulations! 🎉 You've now successfully created your first profile! + +## What's next? + +You can now continue to explore Minder's features by adding or removing more repositories, create more profiles with +various rules, and much more. There's a lot more to Minder than just secret scanning. -Now you can run `minder` commands against the public instance of Minder where you can manage your registered repositories -and create custom profiles that would help ensure your repositories are configured consistently and securely. +The `secret_scanning` rule is just one of the many rule types that Minder supports. + +You can see the full list of ready-to-use rules and profiles +maintained by Minder's team here - [stacklok/minder-rules-and-profiles](https://github.com/stacklok/minder-rules-and-profiles). + +In case there's something you don't find there yet, Minder is designed to be extensible. +This allows for users to create their own custom rule types and profiles and ensure the specifics of their security +posture are attested to. + +Now that you have everything set up, you can continue to run `minder` commands against the public instance of Minder +where you can manage your registered repositories, create profiles, rules and much more, so you can ensure your repositories are +configured consistently and securely. For more information about `minder`, see: * `minder` CLI commands - [Docs](https://minder-docs.stacklok.dev/ref/cli/minder). * `minder` REST API Documentation - [Docs](https://minder-docs.stacklok.dev/ref/api). +* `minder` rules and profiles maintained by Minder's team - [GitHub](https://github.com/stacklok/minder-rules-and-profiles). * Minder documentation - [Docs](https://minder-docs.stacklok.dev). # Development +This section describes how to build and run Minder from source. + ## Build from source ### Prerequisites diff --git a/cmd/cli/app/profile/profile_create.go b/cmd/cli/app/profile/profile_create.go index 7c9e4f9182..29fe199011 100644 --- a/cmd/cli/app/profile/profile_create.go +++ b/cmd/cli/app/profile/profile_create.go @@ -95,8 +95,8 @@ within a minder control plane.`, return fmt.Errorf("error creating profile: %w", err) } - table := initializeTable(cmd) - renderProfileTable(resp.GetProfile(), table) + table := InitializeTable(cmd) + RenderProfileTable(resp.GetProfile(), table) table.Render() return nil }, diff --git a/cmd/cli/app/profile/profile_get.go b/cmd/cli/app/profile/profile_get.go index 53f36bc0db..bceecc72da 100644 --- a/cmd/cli/app/profile/profile_get.go +++ b/cmd/cli/app/profile/profile_get.go @@ -97,9 +97,9 @@ func init() { } func handleGetTableOutput(cmd *cobra.Command, profile *pb.Profile) { - table := initializeTable(cmd) + table := InitializeTable(cmd) - renderProfileTable(profile, table) + RenderProfileTable(profile, table) table.Render() } diff --git a/cmd/cli/app/profile/profile_list.go b/cmd/cli/app/profile/profile_list.go index b75433e0a3..e22de59176 100644 --- a/cmd/cli/app/profile/profile_list.go +++ b/cmd/cli/app/profile/profile_list.go @@ -104,10 +104,10 @@ func init() { } func handleListTableOutput(cmd *cobra.Command, resp *pb.ListProfilesResponse) { - table := initializeTable(cmd) + table := InitializeTable(cmd) for _, v := range resp.Profiles { - renderProfileTable(v, table) + RenderProfileTable(v, table) } table.Render() } diff --git a/cmd/cli/app/profile/profile_update.go b/cmd/cli/app/profile/profile_update.go index 89ba9cb7aa..8df3e66fe2 100644 --- a/cmd/cli/app/profile/profile_update.go +++ b/cmd/cli/app/profile/profile_update.go @@ -95,8 +95,8 @@ within a minder control plane.`, return fmt.Errorf("error updating profile: %w", err) } - table := initializeTable(cmd) - renderProfileTable(resp.GetProfile(), table) + table := InitializeTable(cmd) + RenderProfileTable(resp.GetProfile(), table) table.Render() return nil }, diff --git a/cmd/cli/app/profile/table_render.go b/cmd/cli/app/profile/table_render.go index ddf5a43a3e..5bc3972612 100644 --- a/cmd/cli/app/profile/table_render.go +++ b/cmd/cli/app/profile/table_render.go @@ -24,7 +24,8 @@ import ( minderv1 "github.com/stacklok/minder/pkg/api/protobuf/go/minder/v1" ) -func initializeTable(cmd *cobra.Command) *tablewriter.Table { +// InitializeTable initializes the table for rendering profiles +func InitializeTable(cmd *cobra.Command) *tablewriter.Table { table := tablewriter.NewWriter(cmd.OutOrStdout()) table.SetHeader([]string{"Id", "Name", "Provider", "Entity", "Rule", "Rule Params", "Rule Definition"}) table.SetRowLine(true) @@ -36,7 +37,8 @@ func initializeTable(cmd *cobra.Command) *tablewriter.Table { return table } -func renderProfileTable( +// RenderProfileTable renders the profile table +func RenderProfileTable( p *minderv1.Profile, table *tablewriter.Table, ) { diff --git a/cmd/cli/app/provider/provider_enroll.go b/cmd/cli/app/provider/provider_enroll.go index 6d6ebf40ef..b63dfa96db 100644 --- a/cmd/cli/app/provider/provider_enroll.go +++ b/cmd/cli/app/provider/provider_enroll.go @@ -120,86 +120,101 @@ actions such as adding repositories.`, } }, Run: func(cmd *cobra.Command, args []string) { - provider := util.GetConfigValue(viper.GetViper(), "provider", "provider", cmd, "").(string) - if provider != ghclient.Github { - fmt.Fprintf(os.Stderr, "Only %s is supported at this time\n", ghclient.Github) - os.Exit(1) - } - project := viper.GetString("project-id") - pat := util.GetConfigValue(viper.GetViper(), "token", "token", cmd, "").(string) - owner := util.GetConfigValue(viper.GetViper(), "owner", "owner", cmd, "").(string) - - // Ask for confirmation if an owner is set on purpose - ownerPromptStr := "your personal account" - if owner != "" { - ownerPromptStr = fmt.Sprintf("the %s organisation", owner) - } - yes := cli.PrintYesNoPrompt(cmd, - fmt.Sprintf("You are about to enroll repositories from %s.", ownerPromptStr), - "Do you confirm?", - "Enroll operation cancelled.") - if !yes { - return + msg, err := EnrollProviderCmd(cmd, args) + util.ExitNicelyOnError(err, msg) + }, +} + +// EnrollProviderCmd is the command for enrolling a provider +func EnrollProviderCmd(cmd *cobra.Command, _ []string) (string, error) { + provider := util.GetConfigValue(viper.GetViper(), "provider", "provider", cmd, "").(string) + if provider != ghclient.Github { + msg := fmt.Sprintf("Only %s is supported at this time", ghclient.Github) + return "", fmt.Errorf(msg) + } + project := viper.GetString("project") + pat := util.GetConfigValue(viper.GetViper(), "token", "token", cmd, "").(string) + owner := util.GetConfigValue(viper.GetViper(), "owner", "owner", cmd, "").(string) + + // Ask for confirmation if an owner is set on purpose + ownerPromptStr := "your personal account" + if owner != "" { + ownerPromptStr = fmt.Sprintf("the %s organisation", owner) + } + yes := cli.PrintYesNoPrompt(cmd, + fmt.Sprintf("You are about to enroll repositories from %s.", ownerPromptStr), + "Do you confirm?", + "Enroll operation cancelled.") + if !yes { + return "", nil + } + + conn, err := util.GrpcForCommand(cmd, viper.GetViper()) + if err != nil { + return "Error getting grpc connection", err + } + defer conn.Close() + + client := pb.NewOAuthServiceClient(conn) + ctx, cancel := util.GetAppContext() + defer cancel() + oAuthCallbackCtx, oAuthCancel := context.WithTimeout(context.Background(), MAX_CALLS*time.Second) + defer oAuthCancel() + + if pat != "" { + // use pat for enrollment + _, err := client.StoreProviderToken(context.Background(), + &pb.StoreProviderTokenRequest{Provider: provider, ProjectId: project, AccessToken: pat, Owner: &owner}) + if err != nil { + return "Error storing token", err } - conn, err := util.GrpcForCommand(cmd, viper.GetViper()) - util.ExitNicelyOnError(err, "Error getting grpc connection") - defer conn.Close() + cli.PrintCmd(cmd, "Provider enrolled successfully") + return "", nil + } - client := pb.NewOAuthServiceClient(conn) - ctx, cancel := util.GetAppContext() - defer cancel() - oAuthCallbackCtx, oAuthCancel := context.WithTimeout(context.Background(), MAX_CALLS*time.Second) - defer oAuthCancel() + // Get random port + port, err := rand.GetRandomPort() + if err != nil { + return "Error getting random port", err + } - if pat != "" { - // use pat for enrollment - _, err := client.StoreProviderToken(context.Background(), - &pb.StoreProviderTokenRequest{Provider: provider, ProjectId: project, AccessToken: pat, Owner: &owner}) - util.ExitNicelyOnError(err, "Error storing token") + resp, err := client.GetAuthorizationURL(ctx, &pb.GetAuthorizationURLRequest{ + Provider: provider, + ProjectId: project, + Cli: true, + Port: int32(port), + Owner: &owner, + }) + if err != nil { + return "Error getting authorization URL", err + } - cli.PrintCmd(cmd, "Provider enrolled successfully") - return - } + fmt.Printf("Your browser will now be opened to: %s\n", resp.GetUrl()) + fmt.Println("Please follow the instructions on the page to complete the OAuth flow.") + fmt.Println("Once the flow is complete, the CLI will close") + fmt.Println("If this is a headless environment, please copy and paste the URL into a browser on a different machine.") - // Get random port - port, err := rand.GetRandomPort() - util.ExitNicelyOnError(err, "Error getting random port") - - resp, err := client.GetAuthorizationURL(ctx, &pb.GetAuthorizationURLRequest{ - Provider: provider, - ProjectId: project, - Cli: true, - Port: int32(port), - Owner: &owner, - }) - util.ExitNicelyOnError(err, "Error getting authorization URL") - - fmt.Printf("Your browser will now be opened to: %s\n", resp.GetUrl()) - fmt.Println("Please follow the instructions on the page to complete the OAuth flow.") - fmt.Println("Once the flow is complete, the CLI will close") - fmt.Println("If this is a headless environment, please copy and paste the URL into a browser on a different machine.") - - if err := browser.OpenURL(resp.GetUrl()); err != nil { - fmt.Fprintf(os.Stderr, "Error opening browser: %s\n", err) - fmt.Println("Please copy and paste the URL into a browser.") - } - openTime := time.Now().Unix() + if err := browser.OpenURL(resp.GetUrl()); err != nil { + fmt.Fprintf(os.Stderr, "Error opening browser: %s\n", err) + fmt.Println("Please copy and paste the URL into a browser.") + } + openTime := time.Now().Unix() - var wg sync.WaitGroup - wg.Add(1) + var wg sync.WaitGroup + wg.Add(1) - go callBackServer(oAuthCallbackCtx, provider, project, fmt.Sprintf("%d", port), &wg, client, openTime) - wg.Wait() + go callBackServer(oAuthCallbackCtx, provider, project, fmt.Sprintf("%d", port), &wg, client, openTime) + wg.Wait() - cli.PrintCmd(cmd, "Provider enrolled successfully") - }, + cli.PrintCmd(cmd, "Provider enrolled successfully") + return "", nil } func init() { ProviderCmd.AddCommand(enrollProviderCmd) enrollProviderCmd.Flags().StringP("provider", "p", "", "Name for the provider to enroll") - enrollProviderCmd.Flags().StringP("project-id", "g", "", "ID of the project for enrolling the provider") + enrollProviderCmd.Flags().StringP("project", "r", "", "ID of the project for enrolling the provider") enrollProviderCmd.Flags().StringP("token", "t", "", "Personal Access Token (PAT) to use for enrollment") enrollProviderCmd.Flags().StringP("owner", "o", "", "Owner to filter on for provider resources") if err := enrollProviderCmd.MarkFlagRequired("provider"); err != nil { diff --git a/cmd/cli/app/quickstart/embed/profile.yaml b/cmd/cli/app/quickstart/embed/profile.yaml new file mode 100644 index 0000000000..f780689d44 --- /dev/null +++ b/cmd/cli/app/quickstart/embed/profile.yaml @@ -0,0 +1,26 @@ +# Copyright 2023 Stacklok, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +--- +# sample profile for the quickstart command +version: v1 +type: profile +name: quickstart-profile +context: + provider: github +alert: "on" +remediate: "on" +repository: + - type: secret_scanning + def: + enabled: true diff --git a/cmd/cli/app/quickstart/embed/secret_scanning.yaml b/cmd/cli/app/quickstart/embed/secret_scanning.yaml new file mode 100644 index 0000000000..41958eb15a --- /dev/null +++ b/cmd/cli/app/quickstart/embed/secret_scanning.yaml @@ -0,0 +1,71 @@ +# Copyright 2023 Stacklok, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +--- +version: v1 +type: rule-type +name: secret_scanning +context: + provider: github +description: Verifies that secret scanning is enabled for a given repository. +guidance: | + Secret scanning is a feature that scans repositories for secrets and alerts + the repository owner when a secret is found. To enable this feature in GitHub, + you must enable it in the repository settings. + + For more information, see + https://docs.github.com/en/github/administering-a-repository/about-secret-scanning +def: + # Defines the section of the pipeline the rule will appear in. + # This will affect the template used to render multiple parts + # of the rule. + in_entity: repository + # Defines the schema for writing a rule with this rule being checked + rule_schema: + properties: + enabled: + type: boolean + default: true + # Defines the configuration for ingesting data relevant for the rule + ingest: + type: rest + rest: + # This is the path to the data source. Given that this will evaluate + # for each repository in the organization, we use a template that + # will be evaluated for each repository. The structure to use is the + # protobuf structure for the entity that is being evaluated. + endpoint: "/repos/{{.Entity.Owner}}/{{.Entity.Name}}" + # This is the method to use to retrieve the data. It should already default to JSON + parse: json + # Defines the configuration for evaluating data ingested against the given profile + eval: + type: jq + jq: + # Ingested points to the data retrieved in the `ingest` section + - ingested: + def: '.security_and_analysis.secret_scanning.status == "enabled"' + # profile points to the profile itself. + profile: + def: ".enabled" + remediate: + type: rest + rest: + method: PATCH + endpoint: "/repos/{{.Entity.Owner}}/{{.Entity.Name}}" + body: | + { "security_and_analysis": {"secret_scanning": { "status": "enabled" } } } + # Defines the configuration for alerting on the rule + alert: + type: security_advisory + security_advisory: + severity: "medium" \ No newline at end of file diff --git a/cmd/cli/app/quickstart/quickstart.go b/cmd/cli/app/quickstart/quickstart.go new file mode 100644 index 0000000000..ea9e86ade6 --- /dev/null +++ b/cmd/cli/app/quickstart/quickstart.go @@ -0,0 +1,310 @@ +// +// Copyright 2023 Stacklok, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package quickstart provides the quickstart command for the minder CLI +// which is used to provide the means to quickly get started with minder. +package quickstart + +import ( + "embed" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/stacklok/minder/cmd/cli/app" + "github.com/stacklok/minder/cmd/cli/app/profile" + minderprov "github.com/stacklok/minder/cmd/cli/app/provider" + "github.com/stacklok/minder/cmd/cli/app/repo" + "github.com/stacklok/minder/internal/engine" + "github.com/stacklok/minder/internal/util" + "github.com/stacklok/minder/internal/util/cli" + minderv1 "github.com/stacklok/minder/pkg/api/protobuf/go/minder/v1" +) + +const ( + // nolint:lll + stepPromptMsgWelcome = ` +Welcome! 👋 + +You are about to go through the quickstart process for Minder. Throughout this, you will: + +* Enroll your provider +* Register your repositories +* Create a rule type +* Create a profile + +Let's get started! +` + // nolint:lll + stepPromptMsgEnroll = ` +Step 1 - Enroll your provider. + +This will enroll the provider for your repositories. + +Currently Minder works with Github, but we are planning support for other providers too! + +The command we are about to do is the following: + +minder provider enroll --provider github +` + // nolint:lll + stepPromptMsgRegister = ` +Step 2 - Register your repositories. + +Now that you have enrolled your provider successfully, you can register your repositories. + +The command we are about to do is the following: + +minder repo register --provider github +` + // nolint:lll + stepPromptMsgRuleType = ` +Step 3 - Create your first rule type - secret_scanning. + +Now that you have registered your repositories with Minder, let's create your first rule type! + +For the purpose of this quickstart, we are going to use a rule of type "secret_scanning" (secret_scanning.yaml). +Secret scanning is about protecting you from accidentally leaking secrets in your repository. + +The command we are about to do is the following: + +minder rule_type create -f secret_scanning.yaml +` + // nolint:lll + stepPromptMsgProfile = ` +Step 4 - Create your first profile. + +So far you have enrolled a provider, registered your repositories and created a rule type for secrets scanning. +It's time to stitch all of that together by creating a profile. + +Let's create a profile that enables secret scanning for all of your registered repositories. + +We'll enable the remediate and alert features too, so Minder can automatically remediate any non-compliant repositories and alert you if needed. + +Your profile will be applied to the following repositories: + +%s + +The command we are about to do is the following: + +minder profile create -f quickstart-profile.yaml +` + // nolint:lll + stepPromptMsgFinish = ` +Congratulations! 🎉 You've now successfully created your first profile in Minder! + +You can now continue to explore Minder's features by adding or removing more repositories, create custom profiles with various rules, and much more. + +For more information about Minder, see: +* GitHub - https://github.com/stacklok/minder +* CLI commands - https://minder-docs.stacklok.dev/ref/cli/minder +* Rules and profiles maintained by Minder's team - https://github.com/stacklok/minder-rules-and-profiles +* Official documentation - https://minder-docs.stacklok.dev + +Thank you for using Minder! +` +) + +//go:embed embed* +var content embed.FS + +var cmd = &cobra.Command{ + Use: "quickstart", + Short: "Quickstart minder", + Long: "The quickstart command provide the means to quickly get started with minder", + PreRun: func(cmd *cobra.Command, args []string) { + if err := viper.BindPFlags(cmd.Flags()); err != nil { + fmt.Fprintf(os.Stderr, "Error binding flags: %s\n", err) + } + }, + RunE: func(cmd *cobra.Command, args []string) error { + proj := viper.GetString("project") + provider := viper.GetString("provider") + + // Confirm user wants to go through the quickstart process + yes := cli.PrintYesNoPrompt(cmd, + stepPromptMsgWelcome, + "Proceed?", + "Quickstart operation cancelled.") + if !yes { + return nil + } + + // Step 1 - Confirm enrolling + yes = cli.PrintYesNoPrompt(cmd, + stepPromptMsgEnroll, + "Proceed?", + "Quickstart operation cancelled.") + if !yes { + return nil + } + + // Enroll provider + msg, err := minderprov.EnrollProviderCmd(cmd, args) + if err != nil { + return fmt.Errorf("%s: %w", msg, err) + } + + // Get the grpc connection and other resources + conn, err := util.GrpcForCommand(cmd, viper.GetViper()) + if err != nil { + return fmt.Errorf("error getting grpc connection: %w", err) + } + defer conn.Close() + + // Step 2 - Confirm repository registration + yes = cli.PrintYesNoPrompt(cmd, + stepPromptMsgRegister, + "Proceed?", + "Quickstart operation cancelled.") + if !yes { + return nil + } + + // Prompt to register repositories + results, msg, err := repo.RegisterCmd(cmd, args) + util.ExitNicelyOnError(err, msg) + + var registeredRepos []string + for _, result := range results { + repo := fmt.Sprintf("%s/%s", result.Repository.Owner, result.Repository.Name) + registeredRepos = append(registeredRepos, repo) + } + + // Step 3 - Confirm rule type creation + yes = cli.PrintYesNoPrompt(cmd, + stepPromptMsgRuleType, + "Proceed?", + "Quickstart operation cancelled.") + if !yes { + return nil + } + + // Create a client for the profile and rule type service + client := minderv1.NewProfileServiceClient(conn) + + // Creating the rule type + cmd.Println("Creating rule type...") + + // Load the rule type from the embedded file system + preader, _ := content.Open("embed/secret_scanning.yaml") + rt, err := minderv1.ParseRuleType(preader) + if err != nil { + return fmt.Errorf("error parsing rule type: %w", err) + } + + if rt.Context == nil { + rt.Context = &minderv1.Context{} + } + + if proj != "" { + rt.Context.Project = &proj + } + + if provider != "" { + rt.Context.Provider = provider + } + + // Create the rule type in minder (new context, so we don't time out) + ctx, cancel := util.GetAppContext() + defer cancel() + + _, err = client.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: %w", err) + } + cmd.Println("Rule type secret_scanning already exists") + } else { + return fmt.Errorf("error creating rule type from: %w", err) + } + } + + // Step 4 - Confirm profile creation + yes = cli.PrintYesNoPrompt(cmd, + fmt.Sprintf(stepPromptMsgProfile, strings.Join(registeredRepos[:], "\n")), + "Proceed?", + "Quickstart operation cancelled.") + if !yes { + return nil + } + + // Creating the profile + cmd.Println("Creating profile...") + preader, _ = content.Open("embed/profile.yaml") + + // Load the profile from the embedded file system + p, err := engine.ParseYAML(preader) + if err != nil { + return fmt.Errorf("error parsing profile: %w", err) + } + + if p.Context == nil { + p.Context = &minderv1.Context{} + } + + if proj != "" { + p.Context.Project = &proj + } + + if provider != "" { + p.Context.Provider = provider + } + + // Create the profile in minder (new context, so we don't time out) + ctx, cancel = util.GetAppContext() + defer cancel() + + resp, err := client.CreateProfile(ctx, &minderv1.CreateProfileRequest{ + Profile: p, + }) + if err != nil { + if st, ok := status.FromError(err); ok { + if st.Code() != codes.AlreadyExists { + return fmt.Errorf("error creating profile: %w", err) + } + cmd.Println("Hey, it seems you already tried the quickstart command and created such a profile. " + + "In case you have registered new repositories this time, the profile will be already applied " + + "to them.") + } else { + return fmt.Errorf("error creating profile: %w", err) + } + } else { + table := profile.InitializeTable(cmd) + profile.RenderProfileTable(resp.GetProfile(), table) + table.Render() + } + + // Finish - Confirm profile creation + cli.PrintCmd(cmd, cli.WarningBanner.Render(stepPromptMsgFinish)) + return nil + }, +} + +func init() { + app.RootCmd.AddCommand(cmd) + cmd.Flags().StringP("project", "r", "", "Project to create the quickstart profile in") + cmd.Flags().StringP("provider", "p", "github", "Name of the provider") + 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") +} diff --git a/cmd/cli/app/repo/repo_register.go b/cmd/cli/app/repo/repo_register.go index 3a98d72afd..897b053dc5 100644 --- a/cmd/cli/app/repo/repo_register.go +++ b/cmd/cli/app/repo/repo_register.go @@ -43,9 +43,9 @@ import ( var errNoRepositoriesSelected = errors.New("No repositories selected") var cfgFlagRepos string -// repo_registerCmd represents the register command to register a repo with the +// repoRegisterCmd represents the register command to register a repo with the // minder control plane -var repo_registerCmd = &cobra.Command{ +var repoRegisterCmd = &cobra.Command{ Use: "register", Short: "Register a repo with the minder control plane", Long: `Repo register is used to register a repo with the minder control plane`, @@ -55,140 +55,17 @@ var repo_registerCmd = &cobra.Command{ } }, Run: func(cmd *cobra.Command, args []string) { - provider := util.GetConfigValue(viper.GetViper(), "provider", "provider", cmd, "").(string) - if provider != github.Github { - fmt.Fprintf(os.Stderr, "Only %s is supported at this time\n", github.Github) - os.Exit(1) - } - projectID := viper.GetString("project-id") - - conn, err := util.GrpcForCommand(cmd, viper.GetViper()) - util.ExitNicelyOnError(err, "Error getting grpc connection") - defer conn.Close() - - client := pb.NewRepositoryServiceClient(conn) - ctx, cancel := util.GetAppContext() - defer cancel() - - // Get the list of repos - listResp, err := client.ListRepositories(ctx, &pb.ListRepositoriesRequest{ - Provider: provider, - ProjectId: projectID, - }) - if err != nil { - cli.PrintCmd(cmd, "Error getting list of repos: %s\n", err) - os.Exit(1) - } - - // Get a list of remote repos - remoteListResp, err := client.ListRemoteRepositoriesFromProvider(ctx, &pb.ListRemoteRepositoriesFromProviderRequest{ - Provider: provider, - ProjectId: projectID, - }) - if err != nil { - cli.PrintCmd(cmd, "Error getting list of remote repos: %s\n", err) - os.Exit(1) - } - - // Unregistered repos are in remoteListResp but not in listResp - // build a list of unregistered repos - var unregisteredRepos []*pb.UpstreamRepositoryRef - for _, remoteRepo := range remoteListResp.Results { - found := false - for _, repo := range listResp.Results { - if remoteRepo.Owner == repo.Owner && remoteRepo.Name == repo.Name { - found = true - break - } - } - if !found { - unregisteredRepos = append(unregisteredRepos, &pb.UpstreamRepositoryRef{ - Owner: remoteRepo.Owner, - Name: remoteRepo.Name, - RepoId: remoteRepo.RepoId, - }) - } - } - - cli.PrintCmd(cmd, "Found %d remote repositories: %d registered and %d unregistered.\n", - len(remoteListResp.Results), len(listResp.Results), len(unregisteredRepos)) - - // Get the selected repos - selectedRepos, err := getSelectedRepositories(unregisteredRepos, cfgFlagRepos) - if err != nil { - if errors.Is(err, errNoRepositoriesSelected) { - _, _ = fmt.Fprintf(os.Stderr, "%v\n", err) - } else { - _, _ = fmt.Fprintf(os.Stderr, "Error getting selected repos: %s\n", err) - } - os.Exit(1) - } - - results := []*pb.RegisterRepoResult{} - for idx := range selectedRepos { - repo := selectedRepos[idx] - // Construct the RegisterRepositoryRequest - request := &pb.RegisterRepositoryRequest{ - Provider: provider, - Repository: repo, - ProjectId: projectID, - } - - result, err := client.RegisterRepository(context.Background(), request) - if err != nil { - _, _ = fmt.Fprintf(os.Stderr, "Error registering repository %s: %s\n", repo.Name, err) - continue - } - - results = append(results, result.Result) - } - - // Register the repos - // The result gives a list of repositories with the registration status - // Let's parse the results and print the status - columns := []table.Column{ - {Title: "Repository", Width: 35}, - {Title: "Status", Width: 15}, - {Title: "Message", Width: 60}, - } - - rows := make([]table.Row, len(results)) - for i, result := range results { - rows[i] = table.Row{ - fmt.Sprintf("%s/%s", result.Repository.Owner, result.Repository.Name), - } - - if result.Status.Success { - rows[i] = append(rows[i], "Registered") - } else { - rows[i] = append(rows[i], "Failed") - } - - if result.Status.Error != nil { - rows[i] = append(rows[i], *result.Status.Error) - } else { - rows[i] = append(rows[i], "") - } - } - - t := table.New( - table.WithColumns(columns), - table.WithRows(rows), - table.WithFocused(false), - table.WithHeight(len(rows)), - table.WithStyles(cli.TableHiddenSelectStyles), - ) - - cli.PrintCmd(cmd, cli.TableRender(t)) + _, msg, err := RegisterCmd(cmd, args) + util.ExitNicelyOnError(err, msg) }, } func init() { - RepoCmd.AddCommand(repo_registerCmd) - repo_registerCmd.Flags().StringP("provider", "p", "", "Name for the provider to enroll") - repo_registerCmd.Flags().StringP("project-id", "g", "", "ID of the project for repo registration") - repo_registerCmd.Flags().StringVar(&cfgFlagRepos, "repo", "", "List of repositories to register, i.e owner/repo,owner/repo") - if err := repo_registerCmd.MarkFlagRequired("provider"); err != nil { + RepoCmd.AddCommand(repoRegisterCmd) + repoRegisterCmd.Flags().StringP("provider", "p", "", "Name for the provider to enroll") + repoRegisterCmd.Flags().StringP("project-id", "g", "", "ID of the project for repo registration") + repoRegisterCmd.Flags().StringVar(&cfgFlagRepos, "repo", "", "List of repositories to register, i.e owner/repo,owner/repo") + if err := repoRegisterCmd.MarkFlagRequired("provider"); err != nil { _, _ = fmt.Fprintf(os.Stderr, "Error marking flag as required: %s\n", err) } } @@ -265,3 +142,135 @@ func getSelectedRepositories(repoList []*pb.UpstreamRepositoryRef, flagRepos str } return protoRepos, nil } + +// RegisterCmd represents the register command to register a repo with minder +// +//nolint:gocyclo +func RegisterCmd(cmd *cobra.Command, _ []string) ([]*pb.RegisterRepoResult, string, error) { + provider := util.GetConfigValue(viper.GetViper(), "provider", "provider", cmd, "").(string) + if provider != github.Github { + msg := fmt.Sprintf("Only %s is supported at this time", github.Github) + return nil, "", fmt.Errorf(msg) + } + projectID := viper.GetString("project-id") + + conn, err := util.GrpcForCommand(cmd, viper.GetViper()) + if err != nil { + msg := "Error getting grpc connection" + return nil, msg, err + } + defer conn.Close() + + client := pb.NewRepositoryServiceClient(conn) + ctx, cancel := util.GetAppContext() + defer cancel() + + // Get the list of repos + listResp, err := client.ListRepositories(ctx, &pb.ListRepositoriesRequest{ + Provider: provider, + ProjectId: projectID, + }) + if err != nil { + msg := "Error getting list of repos" + return nil, msg, err + } + + // Get a list of remote repos + remoteListResp, err := client.ListRemoteRepositoriesFromProvider(ctx, &pb.ListRemoteRepositoriesFromProviderRequest{ + Provider: provider, + ProjectId: projectID, + }) + if err != nil { + msg := "Error getting list of remote repos" + return nil, msg, err + } + + // Unregistered repos are in remoteListResp but not in listResp + // build a list of unregistered repos + var unregisteredRepos []*pb.UpstreamRepositoryRef + for _, remoteRepo := range remoteListResp.Results { + found := false + for _, repo := range listResp.Results { + if remoteRepo.Owner == repo.Owner && remoteRepo.Name == repo.Name { + found = true + break + } + } + if !found { + unregisteredRepos = append(unregisteredRepos, &pb.UpstreamRepositoryRef{ + Owner: remoteRepo.Owner, + Name: remoteRepo.Name, + RepoId: remoteRepo.RepoId, + }) + } + } + + cli.PrintCmd(cmd, "Found %d remote repositories: %d registered and %d unregistered.\n", + len(remoteListResp.Results), len(listResp.Results), len(unregisteredRepos)) + + // Get the selected repos + selectedRepos, err := getSelectedRepositories(unregisteredRepos, cfgFlagRepos) + if err != nil { + return nil, "", err + } + + results := []*pb.RegisterRepoResult{} + for idx := range selectedRepos { + repo := selectedRepos[idx] + // Construct the RegisterRepositoryRequest + request := &pb.RegisterRepositoryRequest{ + Provider: provider, + Repository: repo, + ProjectId: projectID, + } + + result, err := client.RegisterRepository(context.Background(), request) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Error registering repository %s: %s\n", repo.Name, err) + continue + } + + results = append(results, result.Result) + } + + // Register the repos + // The result gives a list of repositories with the registration status + // Let's parse the results and print the status + columns := []table.Column{ + {Title: "Repository", Width: 35}, + {Title: "Status", Width: 15}, + {Title: "Message", Width: 60}, + } + + rows := make([]table.Row, len(results)) + var registeredRepos []*pb.RegisterRepoResult + for i, result := range results { + rows[i] = table.Row{ + fmt.Sprintf("%s/%s", result.Repository.Owner, result.Repository.Name), + } + + if result.Status.Success { + rows[i] = append(rows[i], "Registered") + registeredRepos = append(registeredRepos, result) + } else { + rows[i] = append(rows[i], "Failed") + } + + if result.Status.Error != nil { + rows[i] = append(rows[i], *result.Status.Error) + } else { + rows[i] = append(rows[i], "") + } + } + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(false), + table.WithHeight(len(rows)), + table.WithStyles(cli.TableHiddenSelectStyles), + ) + + cli.PrintCmd(cmd, cli.TableRender(t)) + return registeredRepos, "", nil +} diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 459983c1de..40a5684ae1 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -24,6 +24,7 @@ import ( _ "github.com/stacklok/minder/cmd/cli/app/profile" _ "github.com/stacklok/minder/cmd/cli/app/profile_status" _ "github.com/stacklok/minder/cmd/cli/app/provider" + _ "github.com/stacklok/minder/cmd/cli/app/quickstart" _ "github.com/stacklok/minder/cmd/cli/app/repo" _ "github.com/stacklok/minder/cmd/cli/app/rule_type" _ "github.com/stacklok/minder/cmd/cli/app/version" diff --git a/internal/util/statuses.go b/internal/util/statuses.go index 4728434749..2c852e48f3 100644 --- a/internal/util/statuses.go +++ b/internal/util/statuses.go @@ -186,12 +186,15 @@ func (s *NiceStatus) Error() string { // ExitNicelyOnError print a message and exit with the right code func ExitNicelyOnError(err error, message string) { if err != nil { + if message != "" { + fmt.Fprintf(os.Stderr, "%s: ", message) + } if rpcStatus, ok := status.FromError(err); ok { nice := FromRpcError(rpcStatus) - fmt.Fprintf(os.Stderr, "%s: %s\n", message, nice) + fmt.Fprintf(os.Stderr, "%s\n", nice) os.Exit(int(nice.Code)) } else { - fmt.Fprintf(os.Stderr, "%s: %s\n", message, err) + fmt.Fprintf(os.Stderr, "%s\n", err) os.Exit(1) } }