From 325d22b340b61e4ae3d5c8efcc196c66852bd6c3 Mon Sep 17 00:00:00 2001 From: Ryan Pham Date: Mon, 28 Oct 2024 11:18:57 -0700 Subject: [PATCH] [cmd/match] add new match command This new command is similar to `akashi diff`, except it outputs resource paths without any extra metadata (i.e. emojis, explanations, etc.). Specifically, it will output any resource paths from the plan output which match the Akashi ruleset (or do not match if the `--invert` flag is provided). As Akashi currently does not support specifying full addresses for modules, this will be useful in parsing full addresses to use in conjunction with the `-target` flag for resource targeting. The functionality is distinct enough from `akashi diff`, that it seems like it justifies creating a distinct command rather than refactoring and piggybacking on any existing commands. Test Plan --------- Unit tests Create a TF directory with the following resources: ```terraform resource "google_storage_bucket" "auto-expire" { name = "no-public-access-bucket" location = "US" project = "my-proj" force_destroy = true public_access_prevention = "enforced" } resource "google_storage_bucket" "nonexist-auto-expire" { name = "no-public-access-bucket" location = "US" project = "my-proj" force_destroy = true public_access_prevention = "enforced" } resource "google_storage_bucket" "exist-auto-expire" { name = "no-public-access-bucket" location = "US" project = "my-proj" force_destroy = true public_access_prevention = "enforced" } ``` This should produce the following plan output: ``` $ terraform plan -no-color Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # google_storage_bucket.auto-expire will be created + resource "google_storage_bucket" "auto-expire" { + effective_labels = (known after apply) + force_destroy = true + id = (known after apply) + location = "US" + name = "no-public-access-bucket" + project = "kouzoh-p-ryanpham" + project_number = (known after apply) + public_access_prevention = "enforced" + rpo = (known after apply) + self_link = (known after apply) + storage_class = "STANDARD" + terraform_labels = (known after apply) + uniform_bucket_level_access = (known after apply) + url = (known after apply) + soft_delete_policy (known after apply) + versioning (known after apply) + website (known after apply) } # google_storage_bucket.exist-auto-expire will be created + resource "google_storage_bucket" "exist-auto-expire" { + effective_labels = (known after apply) + force_destroy = true + id = (known after apply) + location = "US" + name = "no-public-access-bucket" + project = "kouzoh-p-ryanpham" + project_number = (known after apply) + public_access_prevention = "enforced" + rpo = (known after apply) + self_link = (known after apply) + storage_class = "STANDARD" + terraform_labels = (known after apply) + uniform_bucket_level_access = (known after apply) + url = (known after apply) + soft_delete_policy (known after apply) + versioning (known after apply) + website (known after apply) } # google_storage_bucket.nonexist-auto-expire will be created + resource "google_storage_bucket" "nonexist-auto-expire" { + effective_labels = (known after apply) + force_destroy = true + id = (known after apply) + location = "US" + name = "no-public-access-bucket" + project = "kouzoh-p-ryanpham" + project_number = (known after apply) + public_access_prevention = "enforced" + rpo = (known after apply) + self_link = (known after apply) + storage_class = "STANDARD" + terraform_labels = (known after apply) + uniform_bucket_level_access = (known after apply) + url = (known after apply) + soft_delete_policy (known after apply) + versioning (known after apply) + website (known after apply) } Plan: 3 to add, 0 to change, 0 to destroy. ``` Specify the following Akashi policy ```yaml createdResources: strict: true requireName: true default: enforceAll: true ignoreExtraArgs: true resources: - type: google_storage_bucket name: auto-expire - type: google_storage_bucket name: exist-auto-expire updatedResources: strict: true requireName: true default: enforceAll: true ignoreExtraArgs: true resources: - type: google_storage_bucket name: nonexist-auto-expire ``` Running `akashi match` produces the following: ``` google_storage_bucket.auto-expire google_storage_bucket.exist-auto-expire $ ./akashi match /tmp/tf-test/akashi.yaml -f /tmp/tf-test/plan.state --invert google_storage_bucket.nonexist-auto-expire $ ./akashi match /tmp/tf-test/akashi.yaml -f /tmp/tf-test/plan.state -s , google_storage_bucket.auto-expire,google_storage_bucket.exist-auto-expire ``` --- cmd/akashi.go | 2 + internal/validate/validate_test.go | 2 +- pkg/cmd/match/match.go | 77 +++++++++++++++++ pkg/cmd/match/match_test.go | 128 +++++++++++++++++++++++++++++ pkg/cmd/validate/validate.go | 2 +- 5 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 pkg/cmd/match/match.go create mode 100644 pkg/cmd/match/match_test.go diff --git a/cmd/akashi.go b/cmd/akashi.go index c5aa3de..908733d 100644 --- a/cmd/akashi.go +++ b/cmd/akashi.go @@ -10,6 +10,7 @@ import ( "github.com/drlau/akashi/internal/compare" comparecmd "github.com/drlau/akashi/pkg/cmd/compare" diffcmd "github.com/drlau/akashi/pkg/cmd/diff" + matchcmd "github.com/drlau/akashi/pkg/cmd/match" validatecmd "github.com/drlau/akashi/pkg/cmd/validate" versioncmd "github.com/drlau/akashi/pkg/cmd/version" "github.com/drlau/akashi/pkg/plan" @@ -53,6 +54,7 @@ func NewCommand() *cobra.Command { cmd.AddCommand(comparecmd.NewCmdCompare()) cmd.AddCommand(diffcmd.NewCmdDiff()) + cmd.AddCommand(matchcmd.NewCmdMatch()) cmd.AddCommand(validatecmd.NewCmd()) cmd.AddCommand(versioncmd.NewCmdVersion(os.Stdout, version)) diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go index da9a1db..8370ab9 100644 --- a/internal/validate/validate_test.go +++ b/internal/validate/validate_test.go @@ -3,8 +3,8 @@ package validate import ( "testing" - "github.com/google/go-cmp/cmp" "github.com/drlau/akashi/pkg/ruleset" + "github.com/google/go-cmp/cmp" ) func TestValidate(t *testing.T) { diff --git a/pkg/cmd/match/match.go b/pkg/cmd/match/match.go new file mode 100644 index 0000000..23bebfe --- /dev/null +++ b/pkg/cmd/match/match.go @@ -0,0 +1,77 @@ +package match + +import ( + "fmt" + "io" + "strings" + + "github.com/drlau/akashi/internal/compare" + "github.com/drlau/akashi/pkg/plan" + "github.com/drlau/akashi/pkg/utils" + + "github.com/spf13/cobra" +) + +type MatchOptions struct { + File string + JSON bool + Invert bool + Separator string +} + +func NewCmdMatch() *cobra.Command { + opts := &MatchOptions{} + cmd := &cobra.Command{ + Use: "match ", + Short: "Outputs resource paths which match the ruleset", + Long: `Outputs resource paths from "terraform plan" which are defined in the ruleset`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + comparers, err := compare.NewComparerSet(args[0]) + if err != nil { + return err + } + + plan, err := plan.NewResourcePlans(opts.File, opts.JSON) + if err != nil { + return err + } + + out := utils.NewOutput(true) + cmd.SilenceErrors = true + runMatch(out, plan, comparers, opts) + + return nil + }, + } + + cmd.Flags().StringVarP(&opts.File, "file", "f", "", "read plan output from file") + cmd.Flags().BoolVarP(&opts.JSON, "json", "j", false, "read the contents as the output from 'terraform state show -json'") + cmd.Flags().BoolVarP(&opts.Invert, "invert", "i", false, "outputs resources which do not match the ruleset") + cmd.Flags().StringVarP(&opts.Separator, "separator", "s", "\n", "separator between resource paths") + + return cmd +} + +func runMatch(out io.Writer, rc []plan.ResourcePlan, comparers compare.ComparerSet, opts *MatchOptions) { + createComparer := comparers.CreateComparer + destroyComparer := comparers.DestroyComparer + updateComparer := comparers.UpdateComparer + + var matches []string + for _, r := range rc { + var match bool + if r.IsCreate() && createComparer != nil { + match = createComparer.Compare(r) + } else if r.IsDelete() && destroyComparer != nil { + match = destroyComparer.Compare(r) + } else if r.IsUpdate() && updateComparer != nil { + match = updateComparer.Compare(r) + } + if (!opts.Invert && match) || (opts.Invert && !match) { + matches = append(matches, r.GetAddress()) + } + } + + fmt.Fprintln(out, strings.Join(matches, opts.Separator)) +} diff --git a/pkg/cmd/match/match_test.go b/pkg/cmd/match/match_test.go new file mode 100644 index 0000000..10ac4b9 --- /dev/null +++ b/pkg/cmd/match/match_test.go @@ -0,0 +1,128 @@ +package match + +import ( + "bytes" + "testing" + + "github.com/drlau/akashi/internal/compare" + comparefakes "github.com/drlau/akashi/internal/compare/fakes" + "github.com/drlau/akashi/pkg/plan" + planfakes "github.com/drlau/akashi/pkg/plan/fakes" +) + +func TestRunMatch(t *testing.T) { + cases := map[string]struct { + comparers compare.ComparerSet + resourcePlan []plan.ResourcePlan + opts *MatchOptions + expectedOutput string + }{ + "outputs all matching resources from the ruleset": { + comparers: compare.ComparerSet{ + CreateComparer: &comparefakes.FakeComparer{ + CompareReturns: true, + }, + DestroyComparer: &comparefakes.FakeComparer{ + CompareReturns: false, + }, + }, + resourcePlan: []plan.ResourcePlan{ + &planfakes.FakeResourcePlan{ + CreateReturns: true, + AddressReturns: "address1", + NameReturns: "name", + TypeReturns: "type", + }, + &planfakes.FakeResourcePlan{ + CreateReturns: true, + AddressReturns: "address2", + NameReturns: "name", + TypeReturns: "type", + }, + &planfakes.FakeResourcePlan{ + DeleteReturns: true, + AddressReturns: "address3", + NameReturns: "name", + TypeReturns: "type", + }, + }, + opts: &MatchOptions{Separator: "\n"}, + expectedOutput: "address1\naddress2\n", + }, + "outputs all non-matching resources from ruleset when inverted": { + comparers: compare.ComparerSet{ + CreateComparer: &comparefakes.FakeComparer{ + CompareReturns: true, + }, + DestroyComparer: &comparefakes.FakeComparer{ + CompareReturns: false, + }, + }, + resourcePlan: []plan.ResourcePlan{ + &planfakes.FakeResourcePlan{ + CreateReturns: true, + AddressReturns: "address1", + NameReturns: "name", + TypeReturns: "type", + }, + &planfakes.FakeResourcePlan{ + CreateReturns: true, + AddressReturns: "address2", + NameReturns: "name", + TypeReturns: "type", + }, + &planfakes.FakeResourcePlan{ + DeleteReturns: true, + AddressReturns: "address3", + NameReturns: "name", + TypeReturns: "type", + }, + }, + opts: &MatchOptions{Separator: "\n", Invert: true}, + expectedOutput: "address3\n", + }, + "outputs all resources using custom separator": { + comparers: compare.ComparerSet{ + CreateComparer: &comparefakes.FakeComparer{ + CompareReturns: true, + }, + DestroyComparer: &comparefakes.FakeComparer{ + CompareReturns: false, + }, + }, + resourcePlan: []plan.ResourcePlan{ + &planfakes.FakeResourcePlan{ + CreateReturns: true, + AddressReturns: "address1", + NameReturns: "name", + TypeReturns: "type", + }, + &planfakes.FakeResourcePlan{ + CreateReturns: true, + AddressReturns: "address2", + NameReturns: "name", + TypeReturns: "type", + }, + &planfakes.FakeResourcePlan{ + DeleteReturns: true, + AddressReturns: "address3", + NameReturns: "name", + TypeReturns: "type", + }, + }, + opts: &MatchOptions{Separator: ","}, + expectedOutput: "address1,address2\n", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + var output bytes.Buffer + runMatch(&output, tc.resourcePlan, tc.comparers, tc.opts) + + if output.String() != tc.expectedOutput { + t.Errorf("Expected: %q\nGot: %q\n", tc.expectedOutput, output.String()) + } + }) + } +} diff --git a/pkg/cmd/validate/validate.go b/pkg/cmd/validate/validate.go index b67b28d..e80f2c1 100644 --- a/pkg/cmd/validate/validate.go +++ b/pkg/cmd/validate/validate.go @@ -1,8 +1,8 @@ package validate import ( - "os" "fmt" + "os" "github.com/drlau/akashi/internal/validate" "github.com/drlau/akashi/pkg/ruleset"