From 22725bd13535fb110420429973b3511761dcaf02 Mon Sep 17 00:00:00 2001 From: Rahmat Hidayat Date: Wed, 2 Feb 2022 11:05:24 +0700 Subject: [PATCH] feat(cli): add `plan` and `apply` command for provider & policy (#129) * feat(cli): add apply command for policy & provider * feat(cli): add plan command for policy & provider * chore(cli): enhance diff formatting * chore(cmd): omit provider credentials from diff --- cmd/policy.go | 169 +++++++++++++++++++++++++++++++++++++++++++++++ cmd/provider.go | 172 ++++++++++++++++++++++++++++++++++++++++++++++++ cmd/utils.go | 12 ++++ go.mod | 1 + 4 files changed, 354 insertions(+) diff --git a/cmd/policy.go b/cmd/policy.go index be24caafc..bbbbf5887 100644 --- a/cmd/policy.go +++ b/cmd/policy.go @@ -7,6 +7,7 @@ import ( "os" "strconv" "strings" + "time" "github.com/MakeNowJust/heredoc" handlerv1beta1 "github.com/odpf/guardian/api/handler/v1beta1" @@ -16,6 +17,9 @@ import ( "github.com/odpf/salt/printer" "github.com/odpf/salt/term" "github.com/spf13/cobra" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gopkg.in/yaml.v3" ) // PolicyCmd is the root command for the policies subcommand. @@ -44,6 +48,8 @@ func PolicyCmd(c *app.CLIConfig, adapter handlerv1beta1.ProtoAdapter) *cobra.Com cmd.AddCommand(getPolicyCmd(c, adapter)) cmd.AddCommand(createPolicyCmd(c, adapter)) cmd.AddCommand(updatePolicyCmd(c, adapter)) + cmd.AddCommand(planPolicyCmd(c, adapter)) + cmd.AddCommand(applyPolicyCmd(c, adapter)) cmd.AddCommand(initPolicyCmd(c)) return cmd @@ -327,6 +333,169 @@ func initPolicyCmd(c *app.CLIConfig) *cobra.Command { return cmd } + +func applyPolicyCmd(c *app.CLIConfig, adapter handlerv1beta1.ProtoAdapter) *cobra.Command { + var filePath string + + cmd := &cobra.Command{ + Use: "apply", + Short: "Apply a policy config", + Long: heredoc.Doc(` + Create or edit a policy from a file. + `), + Example: heredoc.Doc(` + $ guardian policy apply --file= + `), + Annotations: map[string]string{ + "group:core": "true", + }, + RunE: func(cmd *cobra.Command, args []string) error { + spinner := printer.Spin("") + defer spinner.Stop() + + var policy domain.Policy + if err := parseFile(filePath, &policy); err != nil { + return err + } + + policyProto, err := adapter.ToPolicyProto(&policy) + if err != nil { + return err + } + + ctx := context.Background() + client, cancel, err := createClient(ctx, c.Host) + if err != nil { + return err + } + defer cancel() + + policyID := policyProto.GetId() + _, err = client.GetPolicy(ctx, &guardianv1beta1.GetPolicyRequest{ + Id: policyID, + }) + policyExists := true + if err != nil { + if e, ok := status.FromError(err); ok && e.Code() == codes.NotFound { + policyExists = false + } else { + return err + } + } + + if policyExists { + res, err := client.UpdatePolicy(ctx, &guardianv1beta1.UpdatePolicyRequest{ + Id: policyID, + Policy: policyProto, + }) + if err != nil { + return err + } + spinner.Stop() + + fmt.Printf("Policy updated to version: %v\n", res.GetPolicy().GetVersion()) + } else { + res, err := client.CreatePolicy(ctx, &guardianv1beta1.CreatePolicyRequest{ + Policy: policyProto, + }) + if err != nil { + return err + } + spinner.Stop() + + fmt.Printf("Policy created with id: %v\n", res.GetPolicy().GetId()) + } + + return nil + }, + } + + cmd.Flags().StringVarP(&filePath, "file", "f", "", "Path to the policy config") + cmd.MarkFlagRequired("file") + + return cmd +} + +func planPolicyCmd(c *app.CLIConfig, adapter handlerv1beta1.ProtoAdapter) *cobra.Command { + var filePath string + + cmd := &cobra.Command{ + Use: "plan", + Short: "Show changes from the new policy", + Long: heredoc.Doc(` + Show changes from the new policy. This will not actually apply the policy config. + `), + Example: heredoc.Doc(` + $ guardian policy plan --file= + `), + Annotations: map[string]string{ + "group:core": "true", + }, + RunE: func(cmd *cobra.Command, args []string) error { + spinner := printer.Spin("") + defer spinner.Stop() + + var newPolicy domain.Policy + if err := parseFile(filePath, &newPolicy); err != nil { + return err + } + + policyProto, err := adapter.ToPolicyProto(&newPolicy) + if err != nil { + return err + } + + ctx := context.Background() + client, cancel, err := createClient(ctx, c.Host) + if err != nil { + return err + } + defer cancel() + + policyID := policyProto.GetId() + res, err := client.GetPolicy(ctx, &guardianv1beta1.GetPolicyRequest{ + Id: policyID, + }) + if err != nil { + return err + } + + existingPolicy, err := adapter.FromPolicyProto(res.GetPolicy()) + if err != nil { + return fmt.Errorf("unable to parse existing policy: %w", err) + } + if existingPolicy != nil { + newPolicy.Version = existingPolicy.Version + 1 + newPolicy.CreatedAt = existingPolicy.CreatedAt + } else { + newPolicy.Version = 1 + newPolicy.CreatedAt = time.Now() + } + newPolicy.UpdatedAt = time.Now() + + existingPolicyYaml, err := yaml.Marshal(existingPolicy) + if err != nil { + return fmt.Errorf("failed to marshal existing policy: %w", err) + } + newPolicyYaml, err := yaml.Marshal(newPolicy) + if err != nil { + return fmt.Errorf("failed to marshal new policy: %w", err) + } + + diffs := diff(string(existingPolicyYaml), string(newPolicyYaml)) + + spinner.Stop() + fmt.Println(diffs) + return nil + }, + } + + cmd.Flags().StringVarP(&filePath, "file", "f", "", "Path to the policy config") + cmd.MarkFlagRequired("file") + + return cmd +} + func getVersion(versionFlag string, policyId []string) (uint64, error) { if versionFlag != "" { ver, err := strconv.ParseUint(versionFlag, 10, 32) diff --git a/cmd/provider.go b/cmd/provider.go index 05ab66c47..c82eb89d0 100644 --- a/cmd/provider.go +++ b/cmd/provider.go @@ -13,6 +13,7 @@ import ( "github.com/odpf/guardian/domain" "github.com/odpf/salt/printer" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) func ProviderCmd(c *app.CLIConfig, adapter handlerv1beta1.ProtoAdapter) *cobra.Command { @@ -40,6 +41,8 @@ func ProviderCmd(c *app.CLIConfig, adapter handlerv1beta1.ProtoAdapter) *cobra.C cmd.AddCommand(viewProviderCmd(c, adapter)) cmd.AddCommand(createProviderCmd(c, adapter)) cmd.AddCommand(editProviderCmd(c, adapter)) + cmd.AddCommand(planProviderCmd(c, adapter)) + cmd.AddCommand(applyProviderCmd(c, adapter)) cmd.AddCommand(initProviderCmd(c)) return cmd @@ -292,3 +295,172 @@ func initProviderCmd(c *app.CLIConfig) *cobra.Command { return cmd } + +func applyProviderCmd(c *app.CLIConfig, adapter handlerv1beta1.ProtoAdapter) *cobra.Command { + var filePath string + cmd := &cobra.Command{ + Use: "apply", + Short: "Apply a provider", + Long: heredoc.Doc(` + Create or edit a provider from a file. + `), + Example: heredoc.Doc(` + $ guardian provider apply --file + `), + Annotations: map[string]string{ + "group:core": "true", + }, + RunE: func(cmd *cobra.Command, args []string) error { + spinner := printer.Spin("") + defer spinner.Stop() + + var providerConfig domain.ProviderConfig + if err := parseFile(filePath, &providerConfig); err != nil { + return err + } + + configProto, err := adapter.ToProviderConfigProto(&providerConfig) + if err != nil { + return err + } + + ctx := context.Background() + client, cancel, err := createClient(ctx, c.Host) + if err != nil { + return err + } + defer cancel() + + pType := configProto.GetType() + pUrn := configProto.GetUrn() + + listRes, err := client.ListProviders(ctx, &guardianv1beta1.ListProvidersRequest{}) // TODO: filter by type & urn + if err != nil { + return err + } + providerID := "" + for _, p := range listRes.GetProviders() { + if p.GetType() == pType && p.GetUrn() == pUrn { + providerID = p.GetId() + } + } + + if providerID == "" { + res, err := client.CreateProvider(ctx, &guardianv1beta1.CreateProviderRequest{ + Config: configProto, + }) + if err != nil { + return err + } + + spinner.Stop() + fmt.Printf("Provider created with id: %v", res.GetProvider().GetId()) + } else { + _, err = client.UpdateProvider(ctx, &guardianv1beta1.UpdateProviderRequest{ + Id: providerID, + Config: configProto, + }) + if err != nil { + return err + } + + spinner.Stop() + fmt.Println("Successfully updated provider") + } + + return nil + }, + } + + cmd.Flags().StringVarP(&filePath, "file", "f", "", "Path to the provider config") + cmd.MarkFlagRequired("file") + + return cmd +} + +func planProviderCmd(c *app.CLIConfig, adapter handlerv1beta1.ProtoAdapter) *cobra.Command { + var filePath string + cmd := &cobra.Command{ + Use: "plan", + Short: "Show changes from the new provider", + Long: heredoc.Doc(` + Show changes from the new provider. This will not actually apply the provider config. + `), + Example: heredoc.Doc(` + $ guardian provider plan --file= + `), + Annotations: map[string]string{ + "group:core": "true", + }, + RunE: func(cmd *cobra.Command, args []string) error { + spinner := printer.Spin("") + defer spinner.Stop() + + var newProvider domain.ProviderConfig + if err := parseFile(filePath, &newProvider); err != nil { + return err + } + + ctx := context.Background() + client, cancel, err := createClient(ctx, c.Host) + if err != nil { + return err + } + defer cancel() + + pType := newProvider.Type + pUrn := newProvider.URN + + listRes, err := client.ListProviders(ctx, &guardianv1beta1.ListProvidersRequest{}) // TODO: filter by type & urn + if err != nil { + return err + } + providerID := "" + for _, p := range listRes.GetProviders() { + if p.GetType() == pType && p.GetUrn() == pUrn { + providerID = p.GetId() + } + } + + var existingProvider *domain.ProviderConfig + if providerID != "" { + getRes, err := client.GetProvider(ctx, &guardianv1beta1.GetProviderRequest{ + Id: providerID, + }) + if err != nil { + return err + } + + pc, err := adapter.FromProviderConfigProto(getRes.GetProvider().GetConfig()) + if err != nil { + return fmt.Errorf("unable to parse existing provider: %w", err) + } + existingProvider = pc + } + + existingProvider.Credentials = nil + newProvider.Credentials = nil + // TODO: show decrypted credentials value instead of omitting them + + existingProviderYaml, err := yaml.Marshal(existingProvider) + if err != nil { + return fmt.Errorf("failed to marshal existing provider: %w", err) + } + newProviderYaml, err := yaml.Marshal(newProvider) + if err != nil { + return fmt.Errorf("failed to marshal new provider: %w", err) + } + + diffs := diff(string(existingProviderYaml), string(newProviderYaml)) + + spinner.Stop() + fmt.Println(diffs) + return nil + }, + } + + cmd.Flags().StringVarP(&filePath, "file", "f", "", "Path to the provider config") + cmd.MarkFlagRequired("file") + + return cmd +} diff --git a/cmd/utils.go b/cmd/utils.go index fb0350cb7..f5737ae0d 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -7,6 +7,7 @@ import ( "io/ioutil" "path/filepath" + "github.com/sergi/go-diff/diffmatchpatch" "gopkg.in/yaml.v3" ) @@ -31,3 +32,14 @@ func parseFile(filePath string, v interface{}) error { return nil } + +func diff(a, b string) string { + dmp := diffmatchpatch.New() + + aDmp, bDmp, dmpStrings := dmp.DiffLinesToChars(a, b) + diffs := dmp.DiffMain(aDmp, bDmp, false) + diffs = dmp.DiffCharsToLines(diffs, dmpStrings) + diffs = dmp.DiffCleanupSemantic(diffs) + + return dmp.DiffPrettyText(diffs) +} diff --git a/go.mod b/go.mod index 94347e0ca..94e9c3174 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/mitchellh/mapstructure v1.4.1 github.com/odpf/salt v0.0.0-20220123021549-36df4f993e88 github.com/robfig/cron/v3 v3.0.1 + github.com/sergi/go-diff v1.0.0 github.com/sirupsen/logrus v1.8.1 github.com/spf13/cobra v1.2.1 github.com/stretchr/testify v1.7.0