Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow to use a whole local directory as IaC source #292

Merged
merged 1 commit into from
Feb 26, 2021
Merged
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
7 changes: 7 additions & 0 deletions doc/cmd/scan/iac_source.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ driftctl scan \

# You can also use every file under a given prefix for S3
driftctl scan --from tfstate+s3://statebucketdriftctl/states

# ... or in a given local folder
# driftctl will recursively use all files under this folder.
#
# N.B. Symlinks under the root folder will be ignored.
# If the folder itself is a symlink it will be followed.
driftctl scan --from tfstate://my-states/directory
```


Expand Down
81 changes: 81 additions & 0 deletions pkg/iac/terraform/state/enumerator/file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package enumerator

import (
"io/fs"
"os"
"path/filepath"
"strings"

"github.com/cloudskiff/driftctl/pkg/iac/config"
)

type FileEnumeratorConfig struct {
Bucket *string
Prefix *string
}

type FileEnumerator struct {
config config.SupplierConfig
}

func NewFileEnumerator(config config.SupplierConfig) *FileEnumerator {
return &FileEnumerator{
config,
}
}

// File enumeration does not follow symlinks.
// We may use something like this https://pkg.go.dev/github.com/facebookgo/symwalk
// to follow links, but it allows infinite loop so be careful !
// If a symlink is given as root path, we will follow it, but symlinks under this path
// will not be resolved.
func (s *FileEnumerator) Enumerate() ([]string, error) {
path := s.config.Path
info, err := os.Lstat(path)
if err != nil {
return nil, err
}

// if we got a symlink, use its destination
if info.Mode()&os.ModeSymlink != 0 {
destination, err := filepath.EvalSymlinks(path)
if err != nil {
return nil, err
}
path = destination
info, err = os.Stat(destination)
if err != nil {
return nil, err
}
}

if info != nil && !info.IsDir() {
return []string{path}, nil
}

keys := make([]string, 0)

err = filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error {
// Not tested, we should remove a file or folder after WalkDir has enumerated the whole tree in memory
// This edge case does not really need to be covered by tests
if err != nil {
return err
}
// Skip symlinks
if d.Type()&os.ModeSymlink != 0 {
return nil
}

// Ignore .backup files generated by terraform
if strings.HasSuffix(path, ".backup") {
return nil
}

if !d.IsDir() {
keys = append(keys, path)
}
return nil
})

return keys, err
}
90 changes: 90 additions & 0 deletions pkg/iac/terraform/state/enumerator/file_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package enumerator

import (
"reflect"
"testing"

"github.com/cloudskiff/driftctl/pkg/iac/config"
)

func TestFileEnumerator_Enumerate(t *testing.T) {
tests := []struct {
name string
config config.SupplierConfig
want []string
err string
}{
{
name: "subfolder nesting",
config: config.SupplierConfig{
Path: "testdata/states",
},
want: []string{
"testdata/states/route53/directory/route53.state",
"testdata/states/s3/terraform.tfstate",
"testdata/states/terraform.tfstate",
},
},
{
name: "symlinked folder",
config: config.SupplierConfig{
Path: "testdata/symlink",
},
want: []string{
"testdata/states/route53/directory/route53.state",
"testdata/states/s3/terraform.tfstate",
"testdata/states/terraform.tfstate",
},
},
{
name: "single state file",
config: config.SupplierConfig{
Path: "testdata/states/terraform.tfstate",
},
want: []string{
"testdata/states/terraform.tfstate",
},
},
{
name: "single symlink state file",
config: config.SupplierConfig{
Path: "testdata/states/symlink.tfstate",
},
want: []string{
"testdata/states/terraform.tfstate",
},
},
{
name: "invalid folder",
config: config.SupplierConfig{
Path: "/tmp/dummy-folder/that/does/not/exist",
},
want: nil,
err: "lstat /tmp/dummy-folder/that/does/not/exist: no such file or directory",
},
{
name: "invalid symlink",
config: config.SupplierConfig{
Path: "testdata/invalid_symlink/invalid",
},
want: nil,
err: "lstat testdata/invalid_symlink/test: no such file or directory",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := NewFileEnumerator(tt.config)
got, err := s.Enumerate()
if err != nil && err.Error() != tt.err {
t.Fatalf("Expected error '%s', got '%s'", tt.err, err.Error())
}
if err != nil && tt.err == "" {
t.Fatalf("Expected error '%s' but got nil", tt.err)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("Enumerate() got = %v, want %v", got, tt.want)
}
})
}
}
5 changes: 4 additions & 1 deletion pkg/iac/terraform/state/enumerator/state_enumerator.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ type StateEnumerator interface {

func GetEnumerator(config config.SupplierConfig) StateEnumerator {

if config.Backend == backend.BackendKeyS3 {
switch config.Backend {
case backend.BackendKeyFile:
return NewFileEnumerator(config)
case backend.BackendKeyS3:
return NewS3Enumerator(config)
}

Expand Down
Empty file.
1 change: 1 addition & 0 deletions pkg/iac/terraform/state/enumerator/testdata/symlink
27 changes: 27 additions & 0 deletions pkg/iac/terraform/state/terraform_state_reader_acc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,33 @@ import (
"github.com/cloudskiff/driftctl/test/acceptance/awsutils"
)

func TestAcc_StateReader_WithMultipleStatesInDirectory(t *testing.T) {
acceptance.Run(t, acceptance.AccTestCase{
Paths: []string{
"./testdata/acc/multiple_states_local/s3",
"./testdata/acc/multiple_states_local/route53",
},
Args: []string{
"scan",
"--from", "tfstate://testdata/acc/multiple_states_local/states",
"--filter", "Type=='aws_s3_bucket' || Type=='aws_route53_zone'",
},
Checks: []acceptance.AccCheck{
{
Check: func(result *acceptance.ScanResult, stdout string, err error) {
if err != nil {
t.Fatal(err)
}
result.AssertInfrastructureIsInSync()
result.AssertManagedCount(2)
result.Equal("aws_route53_zone", result.Managed()[0].TerraformType())
result.Equal("aws_s3_bucket", result.Managed()[1].TerraformType())
},
},
},
})
}

func TestAcc_StateReader_WithMultiplesStatesInS3(t *testing.T) {
stateBucketName := "driftctl-acc-test-only"
acceptance.Run(t, acceptance.AccTestCase{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
states

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
provider "aws" {
region = "us-east-1"
}

terraform {
required_providers {
aws = {
version = "~> 3.19.0"
}
}

backend "local" {
path = "../states/route53/terraform.tfstate"
}
}

resource "random_string" "prefix" {
length = 6
upper = false
special = false
}

resource "aws_route53_zone" "foobar" {
name = "${random_string.prefix.result}-example.com"
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
provider "aws" {
region = "us-east-1"
}

terraform {
required_providers {
aws = {
version = "~> 3.19.0"
}
}

backend "local" {
path = "../states/s3/terraform.tfstate"
}
}

resource "random_string" "prefix" {
length = 6
upper = false
special = false
}

resource "aws_s3_bucket" "foobar" {
bucket = "${random_string.prefix.result}.driftctl-test.com"
}