├── .gitignore ├── .golangci.yml ├── cmd └── cfnctl │ └── main.go ├── CONTRIBUTING.md ├── didyoumean ├── suggestions.go └── suggestions_test.go ├── aws └── aws.go ├── commands ├── validate.go ├── version_test.go ├── testdata │ └── template.yaml ├── plan_test.go ├── output_test.go ├── helper.go ├── destroy_test.go ├── output.go ├── apply_test.go ├── version.go ├── apply.go ├── destroy.go └── plan.go ├── Makefile ├── .github ├── ISSUE_TEMPLATE │ └── feature_request.md └── workflows │ └── ci.yml ├── .goreleaser.yaml ├── utils └── utils.go ├── go.mod ├── cli ├── types.go ├── params │ └── params.go └── cli.go ├── pkg └── client │ ├── types.go │ └── api.go ├── internal ├── interactive │ └── table.go └── mock │ └── mocksvc.go ├── README.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── go.sum └── cfnctl.svg /.gitignore: -------------------------------------------------------------------------------- 1 | slask 2 | main 3 | cfnctl 4 | .vscode 5 | tags 6 | 7 | dist/ 8 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | staticcheck: 3 | checks: 4 | - all 5 | - '-SA1006' # disable the rule SA1000 6 | 7 | -------------------------------------------------------------------------------- /cmd/cfnctl/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/rogerwelin/cfnctl/cli" 7 | ) 8 | 9 | func main() { 10 | cli.RunCLI(os.Args) 11 | } 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributions are welcome! To request a feature create a new issue with the label feature-request. Find a bug? Please add an issue with the label bugs. Pull requests are also welcomed but please add an issue on the requested feature first (unless it's a simple bug fix or readme change) 2 | 3 | -------------------------------------------------------------------------------- /didyoumean/suggestions.go: -------------------------------------------------------------------------------- 1 | package didyoumean 2 | 3 | import ( 4 | "github.com/agext/levenshtein" 5 | ) 6 | 7 | // NameSuggestion takes available commands and the mis-spelled command and returns closest match 8 | func NameSuggestion(given string, suggestions []string) string { 9 | for _, suggestion := range suggestions { 10 | dist := levenshtein.Distance(given, suggestion, nil) 11 | if dist < 3 { 12 | return suggestion 13 | } 14 | } 15 | return "" 16 | } 17 | -------------------------------------------------------------------------------- /aws/aws.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/aws/aws-sdk-go-v2/config" 7 | "github.com/aws/aws-sdk-go-v2/service/cloudformation" 8 | ) 9 | 10 | // NewAWS returns a new cloudformation client 11 | func NewAWS() (*cloudformation.Client, error) { 12 | cfg, err := config.LoadDefaultConfig(context.TODO()) 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | // if we pass in a profile 18 | // apa, err := config.LoadSharedConfigProfile() 19 | 20 | svc := cloudformation.NewFromConfig(cfg) 21 | 22 | return svc, nil 23 | } 24 | -------------------------------------------------------------------------------- /commands/validate.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/rogerwelin/cfnctl/aws" 5 | "github.com/rogerwelin/cfnctl/pkg/client" 6 | "github.com/rogerwelin/cfnctl/utils" 7 | ) 8 | 9 | // Validate validates a given CF template 10 | func Validate(templatePath string) error { 11 | svc, err := aws.NewAWS() 12 | if err != nil { 13 | return err 14 | } 15 | dat, err := utils.ReadFile(templatePath) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | ctl := &client.Cfnctl{ 21 | Svc: svc, 22 | TemplateBody: string(dat), 23 | } 24 | 25 | err = ctl.ValidateCFTemplate() 26 | if err != nil { 27 | return err 28 | } 29 | 30 | return nil 31 | } 32 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | check: test lint vet 2 | 3 | .PHONY: test 4 | test: 5 | go test -cover -race -v ./... 6 | 7 | .PHONY: lint 8 | lint: 9 | golint ./... 10 | 11 | .PHONY: vet 12 | vet: 13 | go vet ./... 14 | 15 | .PHONY: build 16 | build: 17 | CGO_ENABLED=0 go build ./cmd/cfnctl 18 | 19 | # ==================================================================================== # 20 | # QUALITY CONTROL 21 | # ==================================================================================== # 22 | 23 | .PHONY: audit 24 | audit: 25 | @echo 'Tidying and verifying module dependencies...' 26 | go mod tidy 27 | go mod verify 28 | @echo 'Vetting code...' 29 | go vet ./... 30 | staticcheck ./... 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /commands/version_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestVersion(t *testing.T) { 9 | 10 | expectedStr := "Cfnctl version v0.1.1\n" 11 | buf := &bytes.Buffer{} 12 | err := OutputVersion("0.1.1", buf) 13 | if err != nil { 14 | t.Errorf("Expected ok but got: %v", err) 15 | } 16 | 17 | if buf.String() != expectedStr { 18 | t.Errorf("Expected str:\n %s but got:\n %s", expectedStr, buf.String()) 19 | } 20 | 21 | expectedStr2 := "Cfnctl version v1.0.0\n" 22 | buf.Reset() 23 | 24 | err = OutputVersion("1.0.0", buf) 25 | if err != nil { 26 | t.Errorf("Expected ok but got: %v", err) 27 | } 28 | 29 | if buf.String() != expectedStr2 { 30 | t.Errorf("Expected str:\n%s but got:\n %s", expectedStr2, buf.String()) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /didyoumean/suggestions_test.go: -------------------------------------------------------------------------------- 1 | package didyoumean 2 | 3 | import "testing" 4 | 5 | var suggestions = []struct { 6 | in []string 7 | given string 8 | expected string 9 | }{ 10 | {[]string{"apply", "delete", "plan", "validate", "version"}, "verzion", "version"}, 11 | {[]string{"apply", "delete", "plan", "validate", "version"}, "aply", "apply"}, 12 | {[]string{"apply", "delete", "plan", "validate", "version"}, "pan", "plan"}, 13 | {[]string{"apply", "delete", "plan", "validate", "version"}, "gibberish", ""}, 14 | } 15 | 16 | func TestSuggestions(t *testing.T) { 17 | for i, tt := range suggestions { 18 | actual := NameSuggestion(tt.given, tt.in) 19 | if actual != tt.expected { 20 | t.Errorf("test %d: NameSuggestion(%s, %v): expected %s but got %s", i+1, tt.given, tt.in, tt.expected, actual) 21 | } 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /commands/testdata/template.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | AWSTemplateFormatVersion: "2010-09-09" 3 | 4 | 5 | Resources: 6 | 7 | CloudwatchRole: 8 | Type: AWS::IAM::Role 9 | Properties: 10 | RoleName: CloudWatch-Role 11 | AssumeRolePolicyDocument: 12 | Version: '2012-10-17' 13 | Statement: 14 | - Effect: Allow 15 | Principal: 16 | Service: 17 | - ec2.amazonaws.com 18 | Action: 19 | - sts:AssumeRole 20 | Path: "/" 21 | ManagedPolicyArns: 22 | - arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess 23 | - arn:aws:iam::aws:policy/CloudWatchLogsFullAccess 24 | 25 | Bucket: 26 | Type: AWS::S3::Bucket 27 | 28 | 29 | Outputs: 30 | Bucket: 31 | Description: S3 Bucket arn 32 | Value: !GetAtt Bucket.Arn 33 | Export: 34 | Name: Bucket 35 | 36 | -------------------------------------------------------------------------------- /commands/plan_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/rogerwelin/cfnctl/internal/mock" 8 | "github.com/rogerwelin/cfnctl/pkg/client" 9 | ) 10 | 11 | func TestPlan(t *testing.T) { 12 | 13 | expectedStr := "\nCfnctl will perform the following actions:\n\n\nPlan: 0 to add, 0 to change, 0 to destroy\n\n" 14 | 15 | svc := mock.NewMockAPI() 16 | buf := &bytes.Buffer{} 17 | 18 | ctl := client.New( 19 | client.WithSvc(svc), 20 | client.WithStackName("stack"), 21 | client.WithChangesetName("change-stack"), 22 | client.WithTemplatePath("testdata/template.yaml"), 23 | client.WithAutoApprove(true), 24 | client.WithOutput(buf), 25 | ) 26 | 27 | _, err := Plan(ctl, false) 28 | if err != nil { 29 | t.Errorf("Expected err to be nil but got: %v", err) 30 | } 31 | 32 | if buf.String() != expectedStr { 33 | t.Errorf("Expected str:\n%s but got:\n %s", expectedStr, buf.String()) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /commands/output_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/rogerwelin/cfnctl/internal/mock" 9 | "github.com/rogerwelin/cfnctl/pkg/client" 10 | ) 11 | 12 | func TestOutput(t *testing.T) { 13 | 14 | svc := mock.NewMockAPI() 15 | buf := &bytes.Buffer{} 16 | 17 | ctl := client.New( 18 | client.WithSvc(svc), 19 | client.WithStackName("stack"), 20 | client.WithChangesetName("change-stack"), 21 | client.WithTemplatePath("testdata/template.yaml"), 22 | client.WithAutoApprove(true), 23 | client.WithOutput(buf), 24 | ) 25 | 26 | err := Output(ctl) 27 | if err != nil { 28 | t.Errorf("Expected err to be nil but got: %v\n", err) 29 | } 30 | 31 | if !strings.Contains(buf.String(), "Bucket") { 32 | t.Error("Output did not contain expected name: Bucket") 33 | } 34 | 35 | if !strings.Contains(buf.String(), "TestBucket") { 36 | t.Error("Output did not contain expected value: TestBucket") 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | builds: 5 | - env: 6 | - CGO_ENABLED=0 7 | goos: 8 | - linux 9 | - windows 10 | - darwin 11 | main: ./cmd/cfnctl 12 | binary: cfnctl 13 | archives: 14 | - format: tar.gz 15 | name_template: >- 16 | {{ .ProjectName }}_ 17 | {{- title .Os }}_ 18 | {{- if eq .Arch "amd64" }}x86_64 19 | {{- else if eq .Arch "386" }}i386 20 | {{- else }}{{ .Arch }}{{ end }} 21 | {{- if .Arm }}v{{ .Arm }}{{ end }} 22 | # use zip for windows archives 23 | format_overrides: 24 | - goos: windows 25 | format: zip 26 | checksum: 27 | name_template: 'checksums.txt' 28 | snapshot: 29 | name_template: "{{ incpatch .Version }}-next" 30 | changelog: 31 | sort: asc 32 | filters: 33 | exclude: 34 | - '^docs:' 35 | - '^test:' 36 | - 'LICENSE' 37 | - 'Makefile' 38 | - 'README.md' 39 | - 'CONTRIBUTING.md' 40 | - 'CODE_OF_CONDUCT.md' 41 | -------------------------------------------------------------------------------- /commands/helper.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/rogerwelin/cfnctl/aws" 7 | "github.com/rogerwelin/cfnctl/pkg/client" 8 | "github.com/rogerwelin/cfnctl/utils" 9 | ) 10 | 11 | // CommandBuilder returns a new Cfnctl svc 12 | func CommandBuilder(templateFile, varsFile string, autoApprove bool) (*client.Cfnctl, error) { 13 | 14 | svc, err := aws.NewAWS() 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | templateBody, err := utils.ReadFile(templateFile) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | stackName := utils.TrimFileSuffix(templateFile) 25 | 26 | ctl := client.New( 27 | client.WithSvc(svc), 28 | client.WithTemplateBody(string(templateBody)), 29 | client.WithTemplatePath(templateFile), 30 | client.WithStackName(stackName), 31 | client.WithChangesetName(stackName), 32 | client.WithVarsFile(varsFile), 33 | client.WithAutoApprove(autoApprove), 34 | client.WithOutput(os.Stdout), 35 | ) 36 | 37 | return ctl, nil 38 | } 39 | -------------------------------------------------------------------------------- /commands/destroy_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/rogerwelin/cfnctl/internal/mock" 8 | "github.com/rogerwelin/cfnctl/pkg/client" 9 | ) 10 | 11 | func TestDestroy(t *testing.T) { 12 | 13 | expectedStr := "\nNo changes. No objects need to be destroyed\n\nEither you have not created any objects yet, there is no Stack named stack or the existing objects were already deleted outside of Cfnctl\n\nDestroy complete! Resources: 0 destroyed\n" 14 | 15 | svc := mock.NewMockAPI() 16 | buf := &bytes.Buffer{} 17 | 18 | ctl := client.New( 19 | client.WithSvc(svc), 20 | client.WithStackName("stack"), 21 | client.WithChangesetName("change-stack"), 22 | client.WithTemplatePath("testdata/template.yaml"), 23 | client.WithAutoApprove(true), 24 | client.WithOutput(buf), 25 | ) 26 | 27 | err := Destroy(ctl) 28 | if err != nil { 29 | t.Errorf("Expected err to be nil but got: %v", err) 30 | } 31 | 32 | if buf.String() != expectedStr { 33 | t.Errorf("Expected str:\n%s but got:\n %s", expectedStr, buf.String()) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /commands/output.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" 8 | "github.com/olekukonko/tablewriter" 9 | "github.com/rogerwelin/cfnctl/pkg/client" 10 | ) 11 | 12 | func outputExportTable(values []types.Export, writer io.Writer) { 13 | tableData := [][]string{} 14 | table := tablewriter.NewWriter(writer) 15 | table.SetHeader([]string{"Name", "Value"}) 16 | table.SetHeaderColor( 17 | tablewriter.Colors{tablewriter.Bold}, 18 | tablewriter.Colors{tablewriter.Bold}, 19 | ) 20 | 21 | for _, item := range values { 22 | arr := []string{ 23 | *item.Name, 24 | *item.Value, 25 | } 26 | tableData = append(tableData, arr) 27 | } 28 | 29 | for _, item := range tableData { 30 | table.Append(item) 31 | } 32 | 33 | fmt.Printf("\n") 34 | table.Render() 35 | fmt.Printf("\n") 36 | } 37 | 38 | func Output(ctl *client.Cfnctl) error { 39 | out, err := ctl.ListExportValues() 40 | 41 | if err != nil { 42 | return nil 43 | } 44 | 45 | if len(out) == 0 { 46 | fmt.Fprintf(ctl.Output, "No exported values in the selected region") 47 | } 48 | 49 | outputExportTable(out, ctl.Output) 50 | 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "math/rand" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | // ReadFile is a utility function for reading files 12 | func ReadFile(path string) ([]byte, error) { 13 | dat, err := os.ReadFile(path) 14 | if err != nil { 15 | return nil, err 16 | } 17 | return dat, nil 18 | } 19 | 20 | // ReturnRandom returns a ranomized string with the length based on input 21 | func ReturnRandom(value int) string { 22 | stringArr := []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "x", "y", "z", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T"} 23 | newString := "" 24 | 25 | for i := 0; i <= value; i++ { 26 | s1 := rand.NewSource(time.Now().UnixNano()) 27 | r1 := rand.New(s1) 28 | randIndex := r1.Intn(len(stringArr)) 29 | newString = newString + stringArr[randIndex] 30 | } 31 | return newString 32 | } 33 | 34 | // TrimFileSuffix trims the suffix on the file 35 | func TrimFileSuffix(path string) string { 36 | file := filepath.Base(path) 37 | return strings.TrimSuffix(file, filepath.Ext(file)) 38 | } 39 | -------------------------------------------------------------------------------- /commands/apply_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/rogerwelin/cfnctl/internal/mock" 8 | "github.com/rogerwelin/cfnctl/pkg/client" 9 | ) 10 | 11 | func TestApply(t *testing.T) { 12 | 13 | var expectedStr = ` 14 | Cfnctl will perform the following actions: 15 | 16 | 17 | Plan: 0 to add, 0 to change, 0 to destroy 18 | 19 | 20 | No changes. Your infrastructure matches the configuration 21 | 22 | Cfnctl has compared your real infrastructure against your configuration and found no differences, so no changes are needed. 23 | 24 | Apply complete! Resources: 0 added, 0 changed, 0 destroyed 25 | ` 26 | svc := mock.NewMockAPI() 27 | buf := &bytes.Buffer{} 28 | 29 | ctl := client.New( 30 | client.WithSvc(svc), 31 | client.WithStackName("stack"), 32 | client.WithChangesetName("change-stack"), 33 | client.WithTemplatePath("testdata/template.yaml"), 34 | client.WithAutoApprove(true), 35 | client.WithOutput(buf), 36 | ) 37 | 38 | err := Apply(ctl) 39 | if err != nil { 40 | t.Errorf("Expected str:\n%s but got:\n %s", expectedStr, buf.String()) 41 | } 42 | 43 | if buf.String() != expectedStr { 44 | t.Fatal("apply output was not expected string") 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /commands/version.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/hashicorp/go-version" 10 | ) 11 | 12 | var latestVersion = "https://api.github.com/repos/rogerwelin/cfnctl/releases/latest" 13 | 14 | type githubRelease struct { 15 | TagName string `json:"tag_name"` 16 | Name string `json:"name"` 17 | } 18 | 19 | // OutputVersion queries github releases and will output whenever there are a newer release available 20 | func OutputVersion(ver string, writer io.Writer) error { 21 | resp, err := http.Get(latestVersion) 22 | if err != nil { 23 | return err 24 | } 25 | defer resp.Body.Close() 26 | v := &githubRelease{} 27 | err = json.NewDecoder(resp.Body).Decode(v) 28 | 29 | if err != nil { 30 | return err 31 | } 32 | 33 | v1, err := version.NewVersion(ver) 34 | if err != nil { 35 | return err 36 | } 37 | v2, err := version.NewVersion(v.TagName) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | if v1.LessThan(v2) { 43 | fmt.Fprintf(writer, "Cfnctl version v%s\n\nYour version of Cfnctl is out of date. The latest version is v%s\n", v1, v2) 44 | } else { 45 | fmt.Fprintf(writer, "Cfnctl version v%s\n", v1) 46 | } 47 | 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref_name }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | golangci: 17 | name: lint 18 | runs-on: ubuntu-latest 19 | timeout-minutes: 5 20 | strategy: 21 | fail-fast: true 22 | matrix: 23 | go: ['1.20.x'] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Install Go 30 | uses: actions/setup-go@v4 31 | with: 32 | go-version: ${{ matrix.go }} 33 | check-latest: true 34 | 35 | - name: Go Tidy 36 | run: go mod tidy && git diff --exit-code 37 | 38 | - name: Go Mod 39 | run: go mod download 40 | 41 | # catch supply chain attacks 42 | - name: Go Mod Verify 43 | run: go mod verify 44 | 45 | - name: Lint 46 | uses: golangci/golangci-lint-action@v2 47 | with: 48 | version: latest 49 | 50 | test: 51 | name: test 52 | runs-on: ubuntu-latest 53 | timeout-minutes: 5 54 | strategy: 55 | fail-fast: true 56 | matrix: 57 | go: ['stable', 'oldstable'] 58 | 59 | steps: 60 | - name: Checkout 61 | uses: actions/checkout@v3 62 | 63 | - name: Install Go 64 | uses: actions/setup-go@v4 65 | with: 66 | go-version: ${{ matrix.go }} 67 | check-latest: true 68 | 69 | - name: Test 70 | run: make test 71 | 72 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rogerwelin/cfnctl 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/agext/levenshtein v1.2.3 7 | github.com/aws/aws-sdk-go-v2 v1.4.0 8 | github.com/aws/aws-sdk-go-v2/config v1.1.7 9 | github.com/aws/aws-sdk-go-v2/service/cloudformation v1.4.0 10 | github.com/aws/smithy-go v1.4.0 11 | github.com/awslabs/goformation/v4 v4.19.1 12 | github.com/buger/goterm v1.0.4 13 | github.com/fatih/color v1.10.0 14 | github.com/hashicorp/go-version v1.3.0 15 | github.com/manifoldco/promptui v0.8.0 16 | github.com/olekukonko/tablewriter v0.0.5 17 | github.com/urfave/cli/v2 v2.3.0 18 | gopkg.in/yaml.v2 v2.4.0 19 | ) 20 | 21 | require ( 22 | github.com/aws/aws-sdk-go-v2/credentials v1.1.7 // indirect 23 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.0.7 // indirect 24 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.0.7 // indirect 25 | github.com/aws/aws-sdk-go-v2/service/sso v1.1.6 // indirect 26 | github.com/aws/aws-sdk-go-v2/service/sts v1.3.1 // indirect 27 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect 28 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect 29 | github.com/imdario/mergo v0.3.12 // indirect 30 | github.com/jmespath/go-jmespath v0.4.0 // indirect 31 | github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a // indirect 32 | github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a // indirect 33 | github.com/mattn/go-colorable v0.1.8 // indirect 34 | github.com/mattn/go-isatty v0.0.17 // indirect 35 | github.com/mattn/go-runewidth v0.0.14 // indirect 36 | github.com/rivo/uniseg v0.2.0 // indirect 37 | github.com/russross/blackfriday/v2 v2.0.1 // indirect 38 | github.com/sanathkr/go-yaml v0.0.0-20170819195128-ed9d249f429b // indirect 39 | github.com/sanathkr/yaml v0.0.0-20170819201035-0056894fa522 // indirect 40 | github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect 41 | golang.org/x/sys v0.6.0 // indirect 42 | golang.org/x/text v0.3.7 // indirect 43 | ) 44 | -------------------------------------------------------------------------------- /cli/types.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/fatih/color" 8 | "github.com/rogerwelin/cfnctl/aws" 9 | "github.com/rogerwelin/cfnctl/commands" 10 | "github.com/rogerwelin/cfnctl/pkg/client" 11 | ) 12 | 13 | type Validate struct { 14 | TemplatePath string 15 | } 16 | 17 | type Plan struct { 18 | TemplatePath string 19 | ParamFile string 20 | } 21 | 22 | type Apply struct { 23 | AutoApprove bool 24 | TemplatePath string 25 | ParamFile string 26 | } 27 | 28 | type Destroy struct { 29 | AutoApprove bool 30 | TemplatePath string 31 | } 32 | 33 | type Output struct{} 34 | 35 | type Version struct { 36 | Version string 37 | } 38 | 39 | // Runner interface simplifies command interaction 40 | type Runner interface { 41 | Run() error 42 | } 43 | 44 | // Run executes the function receives command 45 | func (p *Plan) Run() error { 46 | ctl, err := commands.CommandBuilder(p.TemplatePath, p.ParamFile, false) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | _, err = commands.Plan(ctl, true) 52 | 53 | return err 54 | } 55 | 56 | // Run executes the function receives command 57 | func (v *Validate) Run() error { 58 | greenBold := color.New(color.Bold, color.FgHiGreen).SprintFunc() 59 | err := commands.Validate(v.TemplatePath) 60 | if err != nil { 61 | return err 62 | } 63 | fmt.Printf("%s The configuration is valid.\n", greenBold("Success!")) 64 | return nil 65 | } 66 | 67 | // Run executes the function receives command 68 | func (a *Apply) Run() error { 69 | ctl, err := commands.CommandBuilder(a.TemplatePath, a.ParamFile, a.AutoApprove) 70 | if err != nil { 71 | return err 72 | } 73 | err = commands.Apply(ctl) 74 | return err 75 | } 76 | 77 | // Run executes the function receives command 78 | func (d *Destroy) Run() error { 79 | ctl, err := commands.CommandBuilder(d.TemplatePath, "", d.AutoApprove) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | err = commands.Destroy(ctl) 85 | return err 86 | } 87 | 88 | // Run executes the function receives command 89 | func (v *Version) Run() error { 90 | err := commands.OutputVersion(v.Version, os.Stdout) 91 | return err 92 | } 93 | 94 | // Run executes the function receives command 95 | func (o *Output) Run() error { 96 | svc, err := aws.NewAWS() 97 | if err != nil { 98 | return err 99 | } 100 | 101 | ctl := client.New( 102 | client.WithSvc(svc), 103 | client.WithOutput(os.Stdout), 104 | ) 105 | 106 | err = commands.Output(ctl) 107 | return err 108 | } 109 | -------------------------------------------------------------------------------- /pkg/client/types.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "io" 6 | 7 | "github.com/aws/aws-sdk-go-v2/service/cloudformation" 8 | "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" 9 | ) 10 | 11 | // CloudformationAPI provides access to AWS CloudFormation API 12 | type CloudformationAPI interface { 13 | ExecuteChangeSet(ctx context.Context, params *cloudformation.ExecuteChangeSetInput, optFns ...func(*cloudformation.Options)) (*cloudformation.ExecuteChangeSetOutput, error) 14 | CreateChangeSet(ctx context.Context, params *cloudformation.CreateChangeSetInput, optFns ...func(*cloudformation.Options)) (*cloudformation.CreateChangeSetOutput, error) 15 | DescribeChangeSet(ctx context.Context, params *cloudformation.DescribeChangeSetInput, optFns ...func(*cloudformation.Options)) (*cloudformation.DescribeChangeSetOutput, error) 16 | DeleteChangeSet(ctx context.Context, params *cloudformation.DeleteChangeSetInput, optFns ...func(*cloudformation.Options)) (*cloudformation.DeleteChangeSetOutput, error) 17 | DescribeStacks(ctx context.Context, params *cloudformation.DescribeStacksInput, optFns ...func(*cloudformation.Options)) (*cloudformation.DescribeStacksOutput, error) 18 | DescribeStackResources(ctx context.Context, params *cloudformation.DescribeStackResourcesInput, optFns ...func(*cloudformation.Options)) (*cloudformation.DescribeStackResourcesOutput, error) 19 | ListChangeSets(ctx context.Context, params *cloudformation.ListChangeSetsInput, optFns ...func(*cloudformation.Options)) (*cloudformation.ListChangeSetsOutput, error) 20 | ListStacks(ctx context.Context, params *cloudformation.ListStacksInput, optFns ...func(*cloudformation.Options)) (*cloudformation.ListStacksOutput, error) 21 | ValidateTemplate(ctx context.Context, params *cloudformation.ValidateTemplateInput, optFns ...func(*cloudformation.Options)) (*cloudformation.ValidateTemplateOutput, error) 22 | ListExports(ctx context.Context, params *cloudformation.ListExportsInput, optFns ...func(*cloudformation.Options)) (*cloudformation.ListExportsOutput, error) 23 | DeleteStack(ctx context.Context, params *cloudformation.DeleteStackInput, optFns ...func(*cloudformation.Options)) (*cloudformation.DeleteStackOutput, error) 24 | } 25 | 26 | // Cfnctl provides access to all actions in the programs lifecycle 27 | type Cfnctl struct { 28 | AutoApprove bool 29 | VarsFile string 30 | StackName string 31 | ChangesetName string 32 | TemplateBody string 33 | TemplatePath string 34 | Parameters []types.Parameter 35 | Output io.Writer 36 | Svc CloudformationAPI 37 | DoDeleteChangeset bool 38 | } 39 | -------------------------------------------------------------------------------- /cli/params/params.go: -------------------------------------------------------------------------------- 1 | package params 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" 8 | "github.com/awslabs/goformation/v4" 9 | "github.com/fatih/color" 10 | "github.com/manifoldco/promptui" 11 | "gopkg.in/yaml.v2" 12 | ) 13 | 14 | type parameters []struct { 15 | ParameterKey string `yaml:"ParameterKey"` 16 | ParameterValue string `yaml:"ParameterValue"` 17 | } 18 | 19 | // BuildInputParams builds CF parameter struct from given input 20 | func BuildInputParams(params []string) ([]types.Parameter, error) { 21 | res := make(map[string]string) 22 | var cfParams []types.Parameter 23 | whiteBold := color.New(color.Bold).SprintfFunc() 24 | 25 | if len(params) > 1 { 26 | fmt.Printf("%s\n\n", whiteBold("Enter parameter values:")) 27 | } else { 28 | fmt.Printf("%s\n\n", whiteBold("Enter parameter value:")) 29 | } 30 | 31 | for _, val := range params { 32 | p := promptui.Prompt{ 33 | Label: val, 34 | } 35 | result, err := p.Run() 36 | if err != nil { 37 | return nil, err 38 | } 39 | res[val] = result 40 | } 41 | 42 | for key, val := range res { 43 | param := types.Parameter{ 44 | ParameterKey: &key, 45 | ParameterValue: &val, 46 | } 47 | cfParams = append(cfParams, param) 48 | } 49 | 50 | return cfParams, nil 51 | } 52 | 53 | // CheckInputParams checks if the given CF template contains parameters or not 54 | func CheckInputParams(path string) (bool, []string, error) { 55 | var params []string 56 | 57 | template, err := goformation.Open(path) 58 | if err != nil { 59 | return false, nil, fmt.Errorf("could not open template, %w", err) 60 | } 61 | 62 | if len(template.Parameters) == 0 { 63 | return false, nil, nil 64 | } 65 | 66 | for key, val := range template.Parameters { 67 | if val.Default == nil { 68 | params = append(params, key) 69 | } 70 | } 71 | 72 | return true, params, nil 73 | } 74 | 75 | // MergeFileParams reads parameters from a separate file and returns CF API parameter type 76 | func MergeFileParams(path string) ([]types.Parameter, error) { 77 | var params []types.Parameter 78 | var paramStruct parameters 79 | paramFile, err := os.ReadFile(path) 80 | if err != nil { 81 | fmt.Println(err) 82 | return nil, err 83 | } 84 | 85 | err = yaml.Unmarshal(paramFile, ¶mStruct) 86 | if err != nil { 87 | fmt.Println(err) 88 | return nil, err 89 | } 90 | 91 | for _, val := range paramStruct { 92 | param := types.Parameter{ 93 | ParameterKey: &val.ParameterKey, 94 | ParameterValue: &val.ParameterValue, 95 | } 96 | params = append(params, param) 97 | } 98 | 99 | return params, nil 100 | } 101 | -------------------------------------------------------------------------------- /commands/apply.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/buger/goterm" 11 | "github.com/fatih/color" 12 | "github.com/rogerwelin/cfnctl/internal/interactive" 13 | "github.com/rogerwelin/cfnctl/pkg/client" 14 | ) 15 | 16 | // Apply executes a given CF template 17 | func Apply(ctl *client.Cfnctl) error { 18 | greenBold := color.New(color.Bold, color.FgHiGreen).SprintFunc() 19 | whiteBold := color.New(color.Bold).SprintfFunc() 20 | 21 | eventsChan := make(chan interactive.StackResourceEvents) 22 | doneChan := make(chan bool) 23 | 24 | pc, err := Plan(ctl, false) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | if !pc.containsChanges { 30 | fmt.Fprintf(ctl.Output, "\n%s. %s\n\n", greenBold("No changes"), whiteBold("Your infrastructure matches the configuration")) 31 | fmt.Fprintf(ctl.Output, "Cfnctl has compared your real infrastructure against your configuration and found no differences, so no changes are needed.\n") 32 | fmt.Fprintf(ctl.Output, "\n%s %d added, %d changed, %d destroyed\n", greenBold("Apply complete! Resources:"), (pc.changes["add"]), pc.changes["change"], pc.changes["destroy"]) 33 | return nil 34 | } 35 | 36 | if !ctl.AutoApprove { 37 | reader := bufio.NewReader(os.Stdin) 38 | 39 | fmt.Fprintf(ctl.Output, "%s\n"+ 40 | " Cfnctl will perform the actions described above.\n"+ 41 | " Only 'yes' will be accepted to approve.\n\n"+ 42 | " %s", whiteBold("Do you want to perform the following actions?"), whiteBold("Enter a value: ")) 43 | 44 | choice, err := reader.ReadString('\n') 45 | if err != nil { 46 | return err 47 | } 48 | 49 | choice = strings.TrimSuffix(choice, "\n") 50 | 51 | if choice != "yes" { 52 | fmt.Fprintf(ctl.Output, "\nApply cancelled.\n") 53 | return nil 54 | } 55 | } 56 | 57 | goterm.Clear() 58 | 59 | err = ctl.ApplyChangeSet() 60 | if err != nil { 61 | return err 62 | } 63 | 64 | go interactive.StreamStackResources(eventsChan, doneChan) 65 | 66 | // to be improved 67 | for { 68 | time.Sleep(500 * time.Millisecond) 69 | status, err := ctl.DescribeStack() 70 | if err != nil { 71 | return err 72 | } 73 | if status == "UPDATE_COMPLETE" || status == "CREATE_FAILED" || status == "CREATE_COMPLETE" { 74 | break 75 | } else { 76 | event, err := ctl.DescribeStackResources() 77 | if err != nil { 78 | return err 79 | } 80 | eventsChan <- interactive.StackResourceEvents{Events: event} 81 | } 82 | } 83 | 84 | close(eventsChan) 85 | doneChan <- true 86 | 87 | // this is a really dirty hack 88 | // insert newlines so table does not dissapear 89 | numAdd := pc.changes["add"] 90 | numChange := pc.changes["change"] 91 | numDestroy := pc.changes["destroy"] 92 | total := numAdd + numChange + numDestroy + 4 // for header and padding 93 | //lint:ignore SA1006 I know what i'm doing 94 | fmt.Printf(strings.Repeat("\n", total)) 95 | fmt.Fprintf(ctl.Output, "\n%s %d added, %d changed, %d destroyed\n", greenBold("Apply complete! Resources:"), (pc.changes["add"]), pc.changes["change"], pc.changes["destroy"]) 96 | 97 | return nil 98 | } 99 | -------------------------------------------------------------------------------- /internal/interactive/table.go: -------------------------------------------------------------------------------- 1 | package interactive 2 | 3 | import ( 4 | "io" 5 | "os" 6 | 7 | "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" 8 | "github.com/buger/goterm" 9 | "github.com/olekukonko/tablewriter" 10 | ) 11 | 12 | type StackResourceEvents struct { 13 | Events []types.StackResource 14 | } 15 | 16 | func StreamStackResources(ch <-chan StackResourceEvents, done <-chan bool) { 17 | for { 18 | select { 19 | case <-done: 20 | return 21 | case item := <-ch: 22 | tableOutputter(item.Events, os.Stdout) 23 | } 24 | } 25 | } 26 | 27 | func tableOutputter(events []types.StackResource, writer io.Writer) { 28 | if events == nil { 29 | return 30 | } 31 | 32 | if len(events) == 0 { 33 | return 34 | } 35 | 36 | tableData := [][]string{} 37 | table := tablewriter.NewWriter(writer) 38 | table.SetHeader([]string{"Logical ID", "Physical ID", "Type", "Status", "Status Reason"}) 39 | table.SetHeaderColor( 40 | tablewriter.Colors{tablewriter.Bold}, 41 | tablewriter.Colors{tablewriter.Bold}, 42 | tablewriter.Colors{tablewriter.Bold}, 43 | tablewriter.Colors{tablewriter.Bold}, 44 | tablewriter.Colors{tablewriter.Bold}, 45 | ) 46 | table.SetAutoFormatHeaders(true) 47 | table.SetHeaderAlignment(tablewriter.ALIGN_RIGHT) 48 | table.SetAlignment(tablewriter.ALIGN_RIGHT) 49 | 50 | goterm.MoveCursor(1, 1) 51 | 52 | for _, item := range events { 53 | var physicalID string 54 | var statusReason string 55 | var logicalResourceId string 56 | var ResourceType string 57 | 58 | if item.PhysicalResourceId != nil { 59 | physicalID = *item.PhysicalResourceId 60 | } else { 61 | physicalID = "-" 62 | } 63 | 64 | if item.ResourceStatusReason != nil { 65 | statusReason = *item.ResourceStatusReason 66 | } else { 67 | statusReason = "-" 68 | } 69 | 70 | if item.LogicalResourceId != nil { 71 | logicalResourceId = *item.LogicalResourceId 72 | } else { 73 | logicalResourceId = "-" 74 | } 75 | 76 | if item.ResourceType != nil { 77 | ResourceType = *item.ResourceType 78 | } else { 79 | ResourceType = "-" 80 | } 81 | 82 | arr := []string{ 83 | logicalResourceId, 84 | physicalID, 85 | ResourceType, 86 | string(item.ResourceStatus), 87 | statusReason, 88 | } 89 | tableData = append(tableData, arr) 90 | } 91 | 92 | for i := range tableData { 93 | switch tableData[i][3] { 94 | case "CREATE_COMPLETE": 95 | table.Rich(tableData[i], []tablewriter.Colors{{}, {}, {}, {tablewriter.Normal, tablewriter.FgHiGreenColor}, {}}) 96 | case "CREATE_IN_PROGRESS": 97 | table.Rich(tableData[i], []tablewriter.Colors{{}, {}, {}, {tablewriter.Normal, tablewriter.FgHiBlueColor}, {}}) 98 | case "DELETE_IN_PROGRESS": 99 | table.Rich(tableData[i], []tablewriter.Colors{{}, {}, {}, {tablewriter.Normal, tablewriter.FgHiYellowColor}, {}}) 100 | case "DELETE_COMPLETE": 101 | table.Rich(tableData[i], []tablewriter.Colors{{}, {}, {}, {tablewriter.Normal, tablewriter.FgHiRedColor}, {}}) 102 | case "CREATE_FAILED": 103 | table.Rich(tableData[i], []tablewriter.Colors{{tablewriter.Normal, tablewriter.FgHiRedColor}}) 104 | default: 105 | table.Append(tableData[i]) 106 | } 107 | } 108 | 109 | table.Render() 110 | goterm.Flush() 111 | } 112 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## ☁️ Cfnctl 2 | 3 |
10 | 11 | [[ _pronounced_ cfn control _or_ cloudformation control ]] 12 | 13 | Are you a fan of Terraform but forced to use Cloudformation due to organizational or technical reasons? Introducing **cfnctl**, a CLI that brings the Terraform cli experience to Cloudformation. You'll never need to use the AWS Console for managing stacks again! 14 | 15 | With *cfnctl*, you write Cloudformation templates as usual but use the cli workflow that you are already used to from Terraform, including: 16 | 17 | * **apply** 18 | * **plan** 19 | * **destroy** 20 | * **output** 21 | 22 | 23 | ### Demo 24 | 25 | [](https://asciinema.org/a/abFfMrlLp3MTDHjrrzWpbyDDI?autoplay=1) 26 | 27 | 28 | ### Installation 29 | 30 | Grab a pre-built binary from the [GitHub Releases page](https://github.com/rogerwelin/cfnctl/releases) for your OS of choice 31 | 32 | 33 | ### Usage 34 | 35 | If you are a terraform user the *cfnctl* cli works as you would expect. Running the binary without flags will give you the help output: 36 | 37 | ```bash 38 | ✗ ./cfnctl 39 | NAME: 40 | cfnctl - ✨ Terraform cli experience for AWS Cloudformation 41 | 42 | COMMANDS: 43 | apply Create or update infrastructure 44 | plan Show changes required by the current configuration 45 | destroy Destroy previously-created infrastructure 46 | output Show all exported output values of the selected account and region 47 | validate Check whether the configuration is valid 48 | version Show the current Cfnctl version 49 | help, h Shows a list of commands or help for one command 50 | 51 | GLOBAL OPTIONS: 52 | --help, -h show help (default: false) 53 | 54 | Examples 55 | Apply infrastructure using the "apply" command. 56 | $ cfnctl apply --template-file mycfntmpl.yaml --auto-approve 57 | ``` 58 | 59 | 60 | ### Notice 61 | 62 | Cfnctl is under early development and is missing several features such as: 63 | 64 | * user cancellation does not clean up stacks, [Issue](https://github.com/rogerwelin/cfnctl/issues/1) 65 | * does not support uploading large templates to S3 yet, [Issue](https://github.com/rogerwelin/cfnctl/issues/2) 66 | * does not support drift detection yet, [Issue](https://github.com/rogerwelin/cfnctl/issues/5) 67 | 68 | Feature request and pull requests are welcome. Please see the [Contributing doc](https://github.com/rogerwelin/cfnctl/blob/master/CONTRIBUTING.md) 69 | 70 | If you read this far consider hitting the star ⭐ 71 | 72 | 73 | -------------------------------------------------------------------------------- /commands/destroy.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strings" 10 | "time" 11 | 12 | "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" 13 | "github.com/buger/goterm" 14 | "github.com/fatih/color" 15 | "github.com/olekukonko/tablewriter" 16 | "github.com/rogerwelin/cfnctl/internal/interactive" 17 | "github.com/rogerwelin/cfnctl/pkg/client" 18 | ) 19 | 20 | func destroytOutput(input []types.StackResource, writer io.Writer) int { 21 | tableData := [][]string{} 22 | table := tablewriter.NewWriter(os.Stdout) 23 | table.SetHeader([]string{"Action", "Logical ID", "Physical ID", "Resource type"}) 24 | table.SetHeaderColor( 25 | tablewriter.Colors{tablewriter.Bold}, 26 | tablewriter.Colors{tablewriter.Bold}, 27 | tablewriter.Colors{tablewriter.Bold}, 28 | tablewriter.Colors{tablewriter.Bold}, 29 | ) 30 | 31 | for _, v := range input { 32 | arr := []string{ 33 | "Destroy", 34 | *v.LogicalResourceId, 35 | *v.PhysicalResourceId, 36 | *v.ResourceType, 37 | } 38 | tableData = append(tableData, arr) 39 | } 40 | 41 | for i := range tableData { 42 | switch tableData[i][0] { 43 | case "Destroy": 44 | table.Rich(tableData[i], []tablewriter.Colors{{tablewriter.Normal, tablewriter.FgHiRedColor}}) 45 | default: 46 | table.Append(tableData[i]) 47 | } 48 | } 49 | 50 | fmt.Fprintf(writer, "\nCfnctl will perform the following actions:\n\n") 51 | 52 | table.Render() 53 | 54 | return len(tableData) 55 | } 56 | 57 | // Destroy destroys all the resources in a given stack 58 | func Destroy(ctl *client.Cfnctl) error { 59 | whiteBold := color.New(color.Bold).SprintfFunc() 60 | greenBold := color.New(color.Bold, color.FgHiGreen).SprintFunc() 61 | 62 | eventsChan := make(chan interactive.StackResourceEvents) 63 | doneChan := make(chan bool) 64 | 65 | // check if stack exists 66 | ok, err := ctl.IsStackCreated() 67 | if err != nil { 68 | return err 69 | } 70 | 71 | if !ok { 72 | fmt.Fprintf(ctl.Output, "\n%s %s\n\n", greenBold("No changes."), whiteBold("No objects need to be destroyed")) 73 | fmt.Fprintf(ctl.Output, "Either you have not created any objects yet, there is no Stack named %s or the existing objects were already deleted outside of Cfnctl\n\n", ctl.StackName) 74 | fmt.Fprintf(ctl.Output, "%s", greenBold("Destroy complete! Resources: 0 destroyed\n")) 75 | return nil 76 | } 77 | 78 | out, err := ctl.DescribeStackResources() 79 | if err != nil { 80 | return err 81 | } 82 | 83 | noChanges := destroytOutput(out, ctl.Output) 84 | 85 | if !ctl.AutoApprove { 86 | fmt.Fprintf(ctl.Output, "%s\n"+ 87 | " Cfnctl will destroy all your managed infrastructure, as shown above\n"+ 88 | " There is no undo. Only 'yes' will be accepted to approve.\n\n"+ 89 | " %s", whiteBold("Do you really want to destroy all resources?"), whiteBold("Enter a value: ")) 90 | 91 | reader := bufio.NewReader(os.Stdin) 92 | 93 | choice, err := reader.ReadString('\n') 94 | if err != nil { 95 | return err 96 | } 97 | 98 | choice = strings.TrimSuffix(choice, "\n") 99 | 100 | if choice != "yes" { 101 | fmt.Fprintf(ctl.Output, "\nDestroy cancelled.\n") 102 | return nil 103 | } 104 | } 105 | 106 | goterm.Clear() 107 | 108 | err = ctl.DestroyStack() 109 | if err != nil { 110 | return err 111 | } 112 | 113 | go interactive.StreamStackResources(eventsChan, doneChan) 114 | 115 | // to be improved 116 | for { 117 | time.Sleep(500 * time.Millisecond) 118 | status, err := ctl.DescribeStack() 119 | if err != nil { 120 | if errors.Is(err, client.ErrStackNotFound) { 121 | // move on when stack is deleted and cannot be retrieved again 122 | break 123 | } 124 | return err 125 | } 126 | if status == "DELETE_COMPLETE" { 127 | break 128 | } else { 129 | event, err := ctl.DescribeStackResources() 130 | if err != nil { 131 | return err 132 | } 133 | eventsChan <- interactive.StackResourceEvents{Events: event} 134 | } 135 | } 136 | 137 | close(eventsChan) 138 | doneChan <- true 139 | 140 | //lint:ignore SA1006 I know what i'm doing 141 | fmt.Printf(strings.Repeat("\n", noChanges+4)) 142 | 143 | fmt.Fprintf(ctl.Output, "\n%s %s %d %s\n", greenBold("Destroy complete!"), greenBold("Resources:"), noChanges, greenBold("destroyed")) 144 | 145 | return nil 146 | } 147 | -------------------------------------------------------------------------------- /internal/mock/mocksvc.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/aws/aws-sdk-go-v2/aws" 7 | "github.com/aws/aws-sdk-go-v2/service/cloudformation" 8 | "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" 9 | "github.com/aws/smithy-go/middleware" 10 | "github.com/rogerwelin/cfnctl/pkg/client" 11 | ) 12 | 13 | type mockAPI struct{} 14 | 15 | // NewMockAPI returns a new instance of mockAPI 16 | func NewMockAPI() client.CloudformationAPI { 17 | return mockAPI{} 18 | } 19 | 20 | // ExecuteChangeSet returns a mocked response 21 | func (m mockAPI) ExecuteChangeSet(ctx context.Context, params *cloudformation.ExecuteChangeSetInput, optFns ...func(*cloudformation.Options)) (*cloudformation.ExecuteChangeSetOutput, error) { 22 | 23 | res := middleware.Metadata{} 24 | res.Set("result", "ok") 25 | 26 | return &cloudformation.ExecuteChangeSetOutput{ 27 | ResultMetadata: res, 28 | }, nil 29 | } 30 | 31 | // ExecuteChangeSet returns a mocked response 32 | func (m mockAPI) CreateChangeSet(ctx context.Context, params *cloudformation.CreateChangeSetInput, optFns ...func(*cloudformation.Options)) (*cloudformation.CreateChangeSetOutput, error) { 33 | 34 | id := "apa" 35 | stackID := "123456" 36 | res := middleware.Metadata{} 37 | res.Set("Status", "change set created") 38 | 39 | return &cloudformation.CreateChangeSetOutput{ 40 | Id: &id, 41 | StackId: &stackID, 42 | ResultMetadata: res, 43 | }, nil 44 | } 45 | 46 | // DescribeChangeSet returns a mocked response 47 | func (m mockAPI) DescribeChangeSet(ctx context.Context, params *cloudformation.DescribeChangeSetInput, optFns ...func(*cloudformation.Options)) (*cloudformation.DescribeChangeSetOutput, error) { 48 | 49 | return &cloudformation.DescribeChangeSetOutput{ 50 | ChangeSetName: params.ChangeSetName, 51 | StackName: params.StackName, 52 | Status: "CREATE_COMPLETE", 53 | // Changes: , 54 | }, nil 55 | } 56 | 57 | // DeleteChangeSet returns a mocked response 58 | func (m mockAPI) DeleteChangeSet(ctx context.Context, params *cloudformation.DeleteChangeSetInput, optFns ...func(*cloudformation.Options)) (*cloudformation.DeleteChangeSetOutput, error) { 59 | res := middleware.Metadata{} 60 | res.Set("result", "ok") 61 | return &cloudformation.DeleteChangeSetOutput{ 62 | ResultMetadata: res, 63 | }, nil 64 | } 65 | 66 | // DescribeStacks returns a mocked response 67 | func (m mockAPI) DescribeStacks(ctx context.Context, params *cloudformation.DescribeStacksInput, optFns ...func(*cloudformation.Options)) (*cloudformation.DescribeStacksOutput, error) { 68 | return &cloudformation.DescribeStacksOutput{ 69 | // Stacks: , 70 | }, nil 71 | } 72 | 73 | // DescribeStackResources returns a mocked response 74 | func (m mockAPI) DescribeStackResources(ctx context.Context, params *cloudformation.DescribeStackResourcesInput, optFns ...func(*cloudformation.Options)) (*cloudformation.DescribeStackResourcesOutput, error) { 75 | return &cloudformation.DescribeStackResourcesOutput{}, nil 76 | } 77 | 78 | // ListChangeSets returns a mocked response 79 | func (m mockAPI) ListChangeSets(ctx context.Context, params *cloudformation.ListChangeSetsInput, optFns ...func(*cloudformation.Options)) (*cloudformation.ListChangeSetsOutput, error) { 80 | status := types.ChangeSetSummary{Status: "CREATE_COMPLETE"} 81 | sum := []types.ChangeSetSummary{status} 82 | 83 | return &cloudformation.ListChangeSetsOutput{Summaries: sum}, nil 84 | } 85 | 86 | // ListStacks returns a mocked response 87 | func (m mockAPI) ListStacks(ctx context.Context, params *cloudformation.ListStacksInput, optFns ...func(*cloudformation.Options)) (*cloudformation.ListStacksOutput, error) { 88 | return &cloudformation.ListStacksOutput{}, nil 89 | } 90 | 91 | // ValidateTemplate returns a mocked response 92 | func (m mockAPI) ValidateTemplate(ctx context.Context, params *cloudformation.ValidateTemplateInput, optFns ...func(*cloudformation.Options)) (*cloudformation.ValidateTemplateOutput, error) { 93 | return &cloudformation.ValidateTemplateOutput{}, nil 94 | } 95 | 96 | // ListExports returns a mocked response 97 | func (m mockAPI) ListExports(ctx context.Context, params *cloudformation.ListExportsInput, optFns ...func(*cloudformation.Options)) (*cloudformation.ListExportsOutput, error) { 98 | return &cloudformation.ListExportsOutput{ 99 | Exports: []types.Export{ 100 | { 101 | ExportingStackId: aws.String("template"), 102 | Name: aws.String("Bucket"), 103 | Value: aws.String("TestBucket"), 104 | }, 105 | }, 106 | }, nil 107 | } 108 | 109 | // DeleteStack returns a mocked response 110 | func (m mockAPI) DeleteStack(ctx context.Context, params *cloudformation.DeleteStackInput, optFns ...func(*cloudformation.Options)) (*cloudformation.DeleteStackOutput, error) { 111 | return &cloudformation.DeleteStackOutput{}, nil 112 | } 113 | -------------------------------------------------------------------------------- /commands/plan.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "time" 7 | 8 | "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" 9 | "github.com/fatih/color" 10 | "github.com/olekukonko/tablewriter" 11 | "github.com/rogerwelin/cfnctl/cli/params" 12 | "github.com/rogerwelin/cfnctl/pkg/client" 13 | ) 14 | 15 | type planChanges struct { 16 | containsChanges bool 17 | changes map[string]int 18 | } 19 | 20 | func planOutput(changes []types.Change, writer io.Writer) planChanges { 21 | tableData := [][]string{} 22 | table := tablewriter.NewWriter(writer) 23 | table.SetHeader([]string{"Action", "Logical ID", "Physical ID", "Resource type", "Replacement"}) 24 | table.SetHeaderColor( 25 | tablewriter.Colors{tablewriter.Bold}, 26 | tablewriter.Colors{tablewriter.Bold}, 27 | tablewriter.Colors{tablewriter.Bold}, 28 | tablewriter.Colors{tablewriter.Bold}, 29 | tablewriter.Colors{tablewriter.Bold}, 30 | ) 31 | 32 | actionMap := make(map[string]int) 33 | 34 | for _, v := range changes { 35 | var physicalID string 36 | var replacement string 37 | 38 | if v.ResourceChange.PhysicalResourceId != nil { 39 | physicalID = *v.ResourceChange.PhysicalResourceId 40 | } else { 41 | physicalID = "-" 42 | } 43 | 44 | if v.ResourceChange.Replacement != "" { 45 | replacement = string(v.ResourceChange.Replacement) 46 | } else { 47 | replacement = "-" 48 | } 49 | 50 | if v.ResourceChange.Action == "Add" { 51 | actionMap["add"]++ 52 | } else if v.ResourceChange.Action == "Remove" { 53 | actionMap["destroy"]++ 54 | } else if v.ResourceChange.Action == "Modify" { 55 | actionMap["change"]++ 56 | } 57 | 58 | arr := []string{ 59 | string(v.ResourceChange.Action), 60 | *v.ResourceChange.LogicalResourceId, 61 | physicalID, 62 | *v.ResourceChange.ResourceType, 63 | replacement, 64 | } 65 | tableData = append(tableData, arr) 66 | } 67 | 68 | for i := range tableData { 69 | switch tableData[i][0] { 70 | case "Add": 71 | table.Rich(tableData[i], []tablewriter.Colors{{tablewriter.Normal, tablewriter.FgHiGreenColor}}) 72 | case "Delete": 73 | table.Rich(tableData[i], []tablewriter.Colors{{tablewriter.Normal, tablewriter.FgHiRedColor}}) 74 | case "Modify": 75 | table.Rich(tableData[i], []tablewriter.Colors{{tablewriter.Normal, tablewriter.FgHiYellowColor}}) 76 | default: 77 | table.Append(tableData[i]) 78 | } 79 | } 80 | 81 | whiteBold := color.New(color.Bold).SprintFunc() 82 | fmt.Fprintf(writer, "\nCfnctl will perform the following actions:\n\n") 83 | 84 | var modifications bool 85 | 86 | if len(changes) != 0 { 87 | modifications = true 88 | table.Render() 89 | } else { 90 | modifications = false 91 | } 92 | 93 | fmt.Fprintf(writer, "\n%s %d to add, %d to change, %d to destroy\n\n", whiteBold("Plan:"), actionMap["add"], actionMap["change"], actionMap["destroy"]) 94 | 95 | pc := planChanges{ 96 | containsChanges: modifications, 97 | changes: actionMap, 98 | } 99 | 100 | return pc 101 | } 102 | 103 | // Plan gives a plan output of changes to be made from a given CF template 104 | func Plan(ctl *client.Cfnctl, deleteChangeSet bool) (planChanges, error) { 105 | 106 | pc := planChanges{} 107 | 108 | // if vars file is supplied 109 | if ctl.VarsFile != "" { 110 | out, err := params.MergeFileParams(ctl.VarsFile) 111 | ctl.Parameters = out 112 | if err != nil { 113 | return pc, err 114 | } 115 | err = ctl.CreateChangeSet() 116 | if err != nil { 117 | return pc, err 118 | } 119 | } else { 120 | // no vars file. check if tempalte contains params 121 | ok, outParams, err := params.CheckInputParams(ctl.TemplatePath) 122 | if err != nil { 123 | return pc, err 124 | } 125 | // no input params or default value set 126 | if !ok { 127 | // create change set 128 | err = ctl.CreateChangeSet() 129 | if err != nil { 130 | return pc, err 131 | } 132 | } else { 133 | // get user input 134 | out, err := params.BuildInputParams(outParams) 135 | if err != nil { 136 | return pc, err 137 | } 138 | ctl.Parameters = out 139 | err = ctl.CreateChangeSet() 140 | if err != nil { 141 | return pc, err 142 | } 143 | } 144 | } 145 | 146 | // needs to be improved 147 | count := 15 148 | for i := 0; i < count; i++ { 149 | time.Sleep(1 * time.Second) 150 | status, err := ctl.ListChangeSet() 151 | if err != nil { 152 | return pc, err 153 | } 154 | if status == "CREATE_COMPLETE" { 155 | break 156 | } 157 | } 158 | 159 | createEvents, err := ctl.DescribeChangeSet() 160 | if err != nil { 161 | return pc, err 162 | } 163 | 164 | pc = planOutput(createEvents, ctl.Output) 165 | 166 | // clean up changeset 167 | if deleteChangeSet { 168 | err = ctl.DeleteChangeSet() 169 | if err != nil { 170 | return pc, err 171 | } 172 | } 173 | return pc, nil 174 | } 175 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/fatih/color" 8 | "github.com/rogerwelin/cfnctl/didyoumean" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | var ( 13 | version = "0.1.0" 14 | cmds = []string{"apply", "destroy", "plan", "validate", "version", "output", "help"} 15 | ) 16 | 17 | // RunCLI runs a new instance of cfnctl 18 | func RunCLI(args []string) { 19 | app := cli.NewApp() 20 | setCustomCLITemplate(app) 21 | app.Name = "cfnctl" 22 | app.Usage = "✨ Terraform cli experience for AWS Cloudformation" 23 | app.HelpName = "cfnctl" 24 | app.EnableBashCompletion = true 25 | app.UsageText = "cfntl [global options]