├── .github ├── FUNDING.yml ├── renovate.json └── workflows │ ├── golangci-lint.yml │ ├── semantic-lint.yml │ ├── semantic.yml │ └── tests.yml ├── .gitignore ├── .golangci.yaml ├── .releaserc.yml ├── LICENSE ├── README.md ├── go.mod ├── go.sum └── pkg ├── config ├── config.go ├── config_test.go ├── extended_test.go ├── interface.go ├── testdata │ ├── broken-fixed.yaml │ ├── broken.yaml │ ├── deprecated-feature-flags.yaml │ ├── deprecated-resources-error.yaml │ ├── deprecated-resources-no-filters.yaml │ ├── deprecated-resources.yaml │ ├── deprecated.yaml │ ├── example.yaml │ ├── expanded.yaml │ ├── incomplete.yaml │ ├── invalid-preset.yaml │ ├── invalid.yaml │ └── no-blocklist.yaml ├── testsuite_test.go ├── types.go └── types_test.go ├── docs ├── generate.go └── generate_test.go ├── errors ├── errors.go └── errors_test.go ├── filter ├── README.md ├── filter.go ├── filter_test.go ├── testdata │ ├── global.yaml │ └── groups.yaml └── testsuite_test.go ├── log ├── formatter.go ├── formatter_test.go ├── log.go └── log_test.go ├── nuke ├── nuke.go ├── nuke_filter_test.go ├── nuke_run_test.go ├── nuke_test.go └── testsuite_test.go ├── queue ├── item.go ├── item_test.go ├── queue.go └── queue_test.go ├── registry ├── registry.go └── registry_test.go ├── resource ├── resource.go └── resource_test.go ├── scanner ├── scanner.go ├── scanner_test.go └── testsuite_test.go ├── settings ├── settings.go ├── settings_test.go └── testdata │ ├── settings-invalid.yaml │ └── settings.yaml ├── slices ├── chunk.go └── chunk_test.go ├── types ├── collection.go ├── collection_test.go ├── properties.go ├── properties_test.go ├── utils.go └── utils_test.go └── utils ├── indent.go ├── indent_test.go ├── util.go └── util_test.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ekristen 2 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | "config:best-practices" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | lint: 16 | name: lint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 20 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 21 | with: 22 | go-version: '1.24.x' 23 | cache: false 24 | - name: golangci-lint 25 | uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8 26 | -------------------------------------------------------------------------------- /.github/workflows/semantic-lint.yml: -------------------------------------------------------------------------------- 1 | name: semantic-lint 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | permissions: 11 | contents: read 12 | pull-requests: read 13 | 14 | jobs: 15 | lint: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | -------------------------------------------------------------------------------- /.github/workflows/semantic.yml: -------------------------------------------------------------------------------- 1 | name: semantic 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - next 7 | 8 | permissions: 9 | contents: read # for checkout 10 | 11 | jobs: 12 | release: 13 | name: release 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write # to be able to publish a GitHub release 17 | issues: write # to be able to comment on released issues 18 | pull-requests: write # to be able to comment on released pull requests 19 | id-token: write # to enable use of OIDC for npm provenance 20 | steps: 21 | - name: checkout 22 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 23 | with: 24 | fetch-depth: 0 25 | - name: setup node.js 26 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 27 | with: 28 | node-version: "lts/*" 29 | - name: generate-token 30 | id: generate_token 31 | uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2 32 | with: 33 | app_id: ${{ secrets.BOT_APP_ID }} 34 | private_key: ${{ secrets.BOT_APP_PRIVATE_KEY }} 35 | revoke: true 36 | - name: release 37 | env: 38 | GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} 39 | run: | 40 | npx \ 41 | -p @semantic-release/commit-analyzer \ 42 | -p @semantic-release/release-notes-generator \ 43 | -p @semantic-release/github \ 44 | semantic-release --debug 45 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | name: test 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 17 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 18 | with: 19 | go-version: '1.24.x' 20 | - name: download go mods 21 | run: | 22 | go mod download 23 | - name: run go tests 24 | run: | 25 | go test -timeout 60s -coverprofile=coverage.txt -covermode=atomic ./... 26 | - name: Upload coverage reports to Codecov 27 | uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5 28 | env: 29 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 30 | - name: Upload coverage reports to Codeclimate 31 | if: github.event_name != 'pull_request' 32 | uses: paambaati/codeclimate-action@f429536ee076d758a24705203199548125a28ca7 # v9.0.0 33 | env: 34 | CC_TEST_REPORTER_ID: ${{ secrets.CODECLIMATE_TEST_REPORTER_ID }} 35 | with: 36 | prefix: "github.com/ekristen/libnuke" 37 | coverageLocations: coverage.txt:gocov 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | coverage.txt -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: none 4 | enable: 5 | - bodyclose 6 | - dogsled 7 | - errcheck 8 | - funlen 9 | - goconst 10 | - gocritic 11 | - gocyclo 12 | - goprintffuncname 13 | - gosec 14 | - govet 15 | - ineffassign 16 | - lll 17 | - misspell 18 | - nakedret 19 | - nilnil 20 | - noctx 21 | - nolintlint 22 | - staticcheck 23 | - unconvert 24 | - unparam 25 | - unused 26 | - whitespace 27 | settings: 28 | dupl: 29 | threshold: 100 30 | funlen: 31 | lines: 100 32 | statements: 50 33 | goconst: 34 | min-len: 2 35 | min-occurrences: 3 36 | gocritic: 37 | disabled-checks: 38 | - dupImport 39 | - ifElseChain 40 | - octalLiteral 41 | - whyNoLint 42 | enabled-tags: 43 | - diagnostic 44 | - experimental 45 | - opinionated 46 | - performance 47 | - style 48 | gocyclo: 49 | min-complexity: 15 50 | lll: 51 | line-length: 140 52 | misspell: 53 | locale: US 54 | exclusions: 55 | generated: lax 56 | presets: 57 | - comments 58 | - common-false-positives 59 | - legacy 60 | - std-error-handling 61 | rules: 62 | - linters: 63 | - funlen 64 | path: _test\.go 65 | paths: 66 | - third_party$ 67 | - builtin$ 68 | - examples$ 69 | formatters: 70 | enable: 71 | - gofmt 72 | - goimports 73 | exclusions: 74 | generated: lax 75 | paths: 76 | - third_party$ 77 | - builtin$ 78 | - examples$ 79 | -------------------------------------------------------------------------------- /.releaserc.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - "@semantic-release/commit-analyzer" 3 | - "@semantic-release/release-notes-generator" 4 | - - "@semantic-release/github" 5 | - successComment: | 6 | :tada: This ${issue.pull_request ? 'pull request is included' : 'issue has been resolved'} in version ${nextRelease.version} :tada: 7 | 8 | The release is available on [GitHub release](https://github.com/ekristen/libnuke/releases/tag/${nextRelease.gitTag}) :rocket: 9 | 10 | branches: 11 | - name: +([0-9])?(.{+([0-9]),x}).x 12 | - name: main 13 | - name: next 14 | prerelease: true 15 | - name: pre/rc 16 | prerelease: '${name.replace(/^pre\\//g, "")}' 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Erik Kristensen 4 | Copyright (c) 2016 reBuy reCommerce GmbH 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # libnuke 2 | 3 | [![release](https://img.shields.io/github/release/ekristen/libnuke.svg)](https://github.com/ekristen/libnuke/releases) 4 | [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 5 | [![GoDoc](https://godoc.org/github.com/ekristen/libnuke?status.svg)](https://godoc.org/github.com/ekristen/libnuke) 6 | 7 | [![Known Vulnerabilities](https://snyk.io/test/github/ekristen/libnuke/badge.svg)](https://snyk.io/test/github/ekristen/libnuke) 8 | [![Maintainability](https://api.codeclimate.com/v1/badges/dc4078a236e89486b4ca/maintainability)](https://codeclimate.com/github/ekristen/libnuke/maintainability) 9 | [![Go Report Card](https://goreportcard.com/badge/github.com/ekristen/libnuke)](https://goreportcard.com/report/github.com/ekristen/libnuke) 10 | 11 | [![Current Test State](https://github.com/ekristen/libnuke/actions/workflows/tests.yml/badge.svg)](https://github.com/ekristen/libnuke/actions/workflows/tests.yml) 12 | [![Test Coverage](https://api.codeclimate.com/v1/badges/dc4078a236e89486b4ca/test_coverage)](https://codeclimate.com/github/ekristen/libnuke/test_coverage) 13 | [![codecov](https://codecov.io/gh/ekristen/libnuke/graph/badge.svg?token=UJJOUJ98G4)](https://codecov.io/gh/ekristen/libnuke) 14 | 15 | **Status: [Initial Development](https://semver.org/spec/v2.0.0-rc.1.html#spec-item-5)** - Everything works, but is still being abstracted and tailored to aws-nuke and azure-nuke, 16 | as such func signatures and other things may change in breaking ways until things stabilize. 17 | 18 | ## Overview 19 | 20 | This is an attempt to consolidate the commonalities between [aws-nuke](https://github.com/ekristen/aws-nuke) and [azure-nuke](https://github.com/ekristen/azure-nuke) into a single library 21 | that can be used between them and for future tooling, for example [gcp-nuke](https://github.com/ekristen/gcp-nuke). Additionally, the goal is to make it 22 | easier to add new features with better test coverage. 23 | 24 | The goal of this library is to have a well tested and stable library to build additional nuke tools on top of, while 25 | reducing the technical debt overhead of managing each tool individually. By abstracting away and testing the core parts 26 | of the code, each implementing tool can focus on adding resources to remove and lower the barrier of entry for new 27 | contributors. 28 | 29 | ## Attribution, License, and Copyright 30 | 31 | First, of all this library would not have been possible without the hard work of the team over at [rebuy-de](https://github.com/rebuy-de) 32 | and their original work on [rebuy-de/aws-nuke](https://github.com/rebuy-de/aws-nuke). 33 | 34 | This library is licensed under the MIT license. See the [LICENSE](LICENSE) file for more information. The bulk of this 35 | library was originally sourced from [rebuy-de/aws-nuke](https://github.com/rebuy-de/aws-nuke). See the [Sources](#sources) 36 | for more. 37 | 38 | ## History of the Library 39 | 40 | This all started when I created a managed fork of [aws-nuke](https://github.com/ekristen/aws-nuke) from the [original aws nuke](https://github.com/rebuy-de/aws-nuke). 41 | The fork became necessary after attempting to make contributions and respond to issues to learn that the current 42 | maintainers only have time to work on the project about once a month and while receptive to bringing in other people 43 | to help maintain, made it clear it would take time. Considering the feedback cycle was already weeks on initial 44 | communications, I had to make the hard decision to fork and maintain myself. 45 | 46 | After the fork, I created [azure-nuke](https://github.com/ekristen/azure-nuke) to fulfill a missing need there and 47 | quickly realized that it would be great to pull all the common code into a common library that could be shared by the 48 | two tools with the realization I would be also be making [gcp-nuke](https://github.com/ekristen/gcp-nuke) in the near 49 | future. 50 | 51 | ### A Few Notes About the Original Code 52 | 53 | The code initially written for [aws-nuke](https://github.com/rebuy-de/aws-nuke) for iterating over and clearing out resources was well-written, and I wanted to use it for other cloud providers. Originally I copied it for [azure-nuke,](https://github.com/ekristen/azure-nuke) 54 | but I didn't want to have to keep on maintaining multiple copies. 55 | 56 | There are a few shortcomings with the original code base. For example, there's no way to do dependency management. For 57 | example, some resources must be cleared before others can, or it will end in error. Now, the retry mechanism is **usually** sufficient for this, but only sometimes. 58 | 59 | The queue code was very novel in its approach, and I wanted to keep that, but I wanted to make sure it was 60 | agnostic to the system using it. As such, the queue package can be used for just about anything you want to queue 61 | and retry items. However, it is still geared towards the removal of said it, its primary interface has to have the `Remove` method is still available. 62 | 63 | ## License 64 | 65 | MIT 66 | 67 | ## Sources 68 | 69 | Most of this code originated from the original [aws-nuke](https://github.com/rebuy-de/aws-nuke) project. 70 | 71 | - [aws-nuke](https://github.com/ekristen/aws-nuke) 72 | - was originally a managed fork, it's now been split entirely from the original project, too much divergence 73 | - [aws-nuke original](https://github.com/rebuy-de/aws-nuke) 74 | - [azure-nuke](https://github.com/ekristen/azure-nuke) 75 | 76 | ## Versioning 77 | 78 | This library will follow the semver model. However, it is still in alpha/beta and as such the API is subject to change 79 | until it is stable and will remain on the `0.y.z` model until then. 80 | 81 | ## Packages 82 | 83 | I strongly dislike the use of the `internal` directory in any open source Golang project. Therefore, everything is in 84 | the `pkg` directory and exported wherever possible to allow others to use it. This project follows the semver model, so 85 | breaking changes will be made in a way that is compatible with semver. 86 | 87 | ### config 88 | 89 | This provides the configuration for libnuke. It contains the configuration for all the accounts, regions, 90 | and resource types. It also contains the presets that can be used to apply a set of filters to a nuke process. The 91 | configuration is loaded from a YAML file and is meant to be used by the implementing tool. Use of the configuration 92 | is not required but is recommended. The configuration can be implemented a specific way for each tool providing it 93 | has the necessary methods available. 94 | 95 | ### errors 96 | 97 | This provides common errors that can be used throughout the library for handling of resource errors 98 | 99 | ### filter 100 | 101 | This provides a way to filter resources based on a set of criteria. See [full documentation](pkg/filter/README.md) 102 | for more information. 103 | 104 | ### log 105 | 106 | This is a simple wrapper around `fmt.Println` that formats resource cleanup messages nicely. 107 | 108 | ### nuke 109 | 110 | This provides the framework for scanning for resources and then iterating over said resources to determine 111 | if they should be removed or not and in what order. 112 | 113 | ### queue 114 | 115 | This is a queue package that can be used for just about anything but is geared towards the removal of resources. 116 | 117 | ### resource 118 | 119 | This provides a way to interact with resources. This provides multiple interfaces to test against 120 | as resources can optionally implement these interfaces. 121 | 122 | ### settings 123 | 124 | This provides a way to handle settings for the library. The primary use case is arbitrary settings that resources might 125 | need to be configurable that changes the behavior of how said resource is to be removed. For example, EC2Instances 126 | have Deletion Protection, this allows the resource to define it needs a setting called `DisableDeletionProtection` and then 127 | allows that to be defined in the `config` package and then passed to the resource when it is being removed. 128 | 129 | ### types 130 | 131 | This is a collection of common types that are used throughout the library. 132 | 133 | ### utils 134 | 135 | This is a collection of common utilities that are used throughout the library. 136 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ekristen/libnuke 2 | 3 | go 1.21.6 4 | 5 | require ( 6 | github.com/fatih/color v1.18.0 7 | github.com/gotidy/ptr v1.4.0 8 | github.com/mb0/glob v0.0.0-20160210091149-1eb79d2de6c4 9 | github.com/sirupsen/logrus v1.9.3 10 | github.com/stevenle/topsort v0.2.0 11 | github.com/stretchr/testify v1.10.0 12 | golang.org/x/sync v0.11.0 13 | gopkg.in/yaml.v2 v2.4.0 14 | ) 15 | 16 | require ( 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/kr/text v0.2.0 // indirect 19 | github.com/mattn/go-colorable v0.1.13 // indirect 20 | github.com/mattn/go-isatty v0.0.20 // indirect 21 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect 22 | github.com/pmezard/go-difflib v1.0.0 // indirect 23 | golang.org/x/sys v0.25.0 // indirect 24 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect 25 | gopkg.in/yaml.v3 v3.0.1 // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 6 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 7 | github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= 8 | github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= 9 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 10 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 11 | github.com/gotidy/ptr v1.4.0 h1:7++suUs+HNHMnyz6/AW3SE+4EnBhupPSQTSI7QNijVc= 12 | github.com/gotidy/ptr v1.4.0/go.mod h1:MjRBG6/IETiiZGWI8LrRtISXEji+8b/jigmj2q0mEyM= 13 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 14 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 15 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 16 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 17 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 18 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 19 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 20 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 21 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 22 | github.com/mb0/glob v0.0.0-20160210091149-1eb79d2de6c4 h1:NK3O7S5FRD/wj7ORQ5C3Mx1STpyEMuFe+/F0Lakd1Nk= 23 | github.com/mb0/glob v0.0.0-20160210091149-1eb79d2de6c4/go.mod h1:FqD3ES5hx6zpzDainDaHgkTIqrPaI9uX4CVWqYZoQjY= 24 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 25 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 26 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 27 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 28 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 29 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 30 | github.com/stevenle/topsort v0.2.0 h1:LLWgtp34HPX6/RBDRS0kElVxGOTzGBLI1lSAa5Lb46k= 31 | github.com/stevenle/topsort v0.2.0/go.mod h1:ck2WG2/ZrOr6dLApQ/5Xrqy5wv3T0qhKYWE7r9tkibc= 32 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 33 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 34 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 35 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 36 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 37 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 38 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 39 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 40 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 41 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 42 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 43 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 44 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 45 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 46 | golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= 47 | golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 48 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 49 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 50 | golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= 51 | golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 52 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 53 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 54 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 55 | golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= 56 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 57 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 58 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 59 | golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= 60 | golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 61 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 62 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 63 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 64 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 65 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 66 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 67 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 68 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 69 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | // Package config provides the configuration for libnuke. It contains the configuration for all the accounts, regions, 2 | // and resource types. It also contains the presets that can be used to apply a set of filters to a nuke process. The 3 | // configuration is loaded from a YAML file and is meant to be used by the implementing tool. Use of the configuration 4 | // is not required but is recommended. The configuration can be implemented a specific way for each tool providing it 5 | // has the necessary methods available. 6 | package config 7 | 8 | import ( 9 | "fmt" 10 | "io" 11 | "os" 12 | 13 | "gopkg.in/yaml.v2" 14 | 15 | "github.com/sirupsen/logrus" 16 | 17 | "github.com/ekristen/libnuke/pkg/errors" 18 | "github.com/ekristen/libnuke/pkg/filter" 19 | "github.com/ekristen/libnuke/pkg/settings" 20 | ) 21 | 22 | // Config is the configuration for libnuke. It contains the configuration for all the accounts, regions, and resource 23 | // types. It also contains the presets that can be used to apply a set of filters to a nuke process. 24 | type Config struct { 25 | // Blocklist is a list of IDs that are to be excluded from the nuke process. In this case account is a generic term. 26 | // It can represent an AWS account, a GCP project, or an Azure tenant. 27 | Blocklist []string `yaml:"blocklist"` 28 | 29 | // Regions is a list of regions that are to be included during the nuke process. Region is fairly generic, it can 30 | // be an AWS region, a GCP region, or an Azure region, or any other region that is supported by the implementing 31 | // tool. 32 | Regions []string `yaml:"regions"` 33 | 34 | // Accounts is a map of accounts that are configured a certain way. Account is fairly generic, it can be an AWS 35 | // account, a GCP project, or an Azure tenant, or any other account that is supported by the implementing tool. 36 | Accounts map[string]*Account `yaml:"accounts"` 37 | 38 | // ResourceTypes is a collection of resource types that are to be included or excluded from the nuke process. 39 | ResourceTypes ResourceTypes `yaml:"resource-types"` 40 | 41 | // Presets is a list of presets that are to be used for the configuration. These are global presets that can be used 42 | // by any account. A Preset can also be defined at the account leve. 43 | Presets map[string]Preset `yaml:"presets"` 44 | 45 | // Settings is a collection of resource level settings that are to be used by the resource during the nuke process. 46 | // Resources define their own settings and this allows those settings to be defined in the configuration. The 47 | // appropriate settings are then passed to the appropriate resource during the nuke process. 48 | Settings *settings.Settings `yaml:"settings"` 49 | 50 | // Deprecations is a map of deprecated resource types to their replacements. This is passed in as part of the 51 | // configuration due to the fact the configuration has to resolve the filters in the presets to from any deprecated 52 | // resource types to their replacements. It cannot be imported from YAML, instead has to be configured post parsing. 53 | Deprecations map[string]string `yaml:"-"` 54 | 55 | // Log is the logrus entry to use for logging. It cannot be imported from YAML. 56 | Log *logrus.Entry `yaml:"-"` 57 | 58 | // Deprecated: Use Blocklist instead. Will remove in 4.x 59 | AccountBlacklist []string `yaml:"account-blacklist"` 60 | 61 | // Deprecated: Use Blocklist instead. Will remove in 4.x 62 | AccountBlocklist []string `yaml:"account-blocklist"` 63 | } 64 | 65 | // Options are the options for creating a new configuration. 66 | type Options struct { 67 | // Path to the config file 68 | Path string 69 | 70 | // Log is the logrus entry to use for logging 71 | Log *logrus.Entry 72 | 73 | // Deprecations is a map of deprecated resource types to their replacements. 74 | Deprecations map[string]string 75 | 76 | // NoResolveBlacklist will prevent the blocklist from being resolved. This is useful for tools that want to 77 | // implement their own blocklist. Advanced use only, typically for unit tests. 78 | NoResolveBlacklist bool 79 | 80 | // NoResolveDeprecations will prevent the Deprecations from being resolved. This is useful for tools that want to 81 | // implement their own Deprecations. Advanced used only, typically for unit tests. 82 | NoResolveDeprecations bool 83 | } 84 | 85 | // New creates a new configuration from a file. 86 | func New(opts Options) (*Config, error) { 87 | c := &Config{ 88 | Accounts: make(map[string]*Account), 89 | Presets: make(map[string]Preset), 90 | Deprecations: make(map[string]string), 91 | Settings: &settings.Settings{}, 92 | } 93 | 94 | if opts.Log != nil { 95 | c.Log = opts.Log 96 | } else { 97 | // Create a logger that discards all output 98 | // The only way output is logged is if the instantiating tool provides a logger 99 | logger := logrus.New() 100 | logger.SetOutput(io.Discard) 101 | c.Log = logger.WithField("component", "config") 102 | } 103 | 104 | if len(opts.Deprecations) > 0 { 105 | c.Deprecations = opts.Deprecations 106 | } 107 | 108 | err := c.Load(opts.Path) 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | if !opts.NoResolveBlacklist { 114 | c.Blocklist = c.ResolveBlocklist() 115 | } 116 | 117 | if !opts.NoResolveDeprecations { 118 | if err := c.ResolveDeprecations(); err != nil { 119 | return nil, err 120 | } 121 | } 122 | 123 | return c, nil 124 | } 125 | 126 | // Load loads a configuration from a file and parses it into a Config struct. 127 | func (c *Config) Load(path string) error { 128 | var err error 129 | 130 | raw, err := os.ReadFile(path) 131 | if err != nil { 132 | return err 133 | } 134 | 135 | if err := yaml.Unmarshal(raw, c); err != nil { 136 | return err 137 | } 138 | 139 | return nil 140 | } 141 | 142 | // ResolveBlocklist returns the blocklist to use to prevent action against the account. In this case account is a 143 | // generic term. It can represent an AWS account, a GCP project, or an Azure tenant. 144 | func (c *Config) ResolveBlocklist() []string { 145 | var blocklist []string 146 | 147 | if len(c.AccountBlocklist) > 0 { 148 | blocklist = append(blocklist, c.AccountBlocklist...) 149 | c.AccountBlocklist = nil 150 | c.Log.Warn("deprecated configuration key 'account-blocklist' - please use 'blocklist' instead") 151 | } 152 | 153 | if len(c.AccountBlacklist) > 0 { 154 | blocklist = append(blocklist, c.AccountBlacklist...) 155 | c.AccountBlacklist = nil 156 | c.Log.Warn("deprecated configuration key 'account-blacklist' - please use 'blocklist' instead") 157 | } 158 | 159 | if len(c.Blocklist) > 0 { 160 | blocklist = append(blocklist, c.Blocklist...) 161 | } 162 | 163 | return blocklist 164 | } 165 | 166 | // HasBlocklist returns true if the blocklist is not empty. 167 | func (c *Config) HasBlocklist() bool { 168 | var blocklist = c.ResolveBlocklist() 169 | return len(blocklist) > 0 170 | } 171 | 172 | // InBlocklist returns true if the searchID is in the blocklist. 173 | func (c *Config) InBlocklist(searchID string) bool { 174 | for _, blocklistID := range c.ResolveBlocklist() { 175 | if blocklistID == searchID { 176 | return true 177 | } 178 | } 179 | 180 | return false 181 | } 182 | 183 | // ValidateAccount checks the validity of the configuration that's been parsed 184 | func (c *Config) ValidateAccount(accountID string) error { 185 | if !c.HasBlocklist() { 186 | return errors.ErrNoBlocklistDefined 187 | } 188 | 189 | if c.InBlocklist(accountID) { 190 | return errors.ErrBlocklistAccount 191 | } 192 | 193 | if _, ok := c.Accounts[accountID]; !ok { 194 | return errors.ErrAccountNotConfigured 195 | } 196 | 197 | return nil 198 | } 199 | 200 | // Filters resolves all the filters and preset definitions into one set of filters 201 | func (c *Config) Filters(accountID string) (filter.Filters, error) { 202 | if _, ok := c.Accounts[accountID]; !ok { 203 | return nil, errors.ErrAccountNotConfigured 204 | } 205 | 206 | account := c.Accounts[accountID] 207 | 208 | if account == nil { 209 | return nil, errors.ErrAccountNotConfigured 210 | } 211 | 212 | filters := account.Filters 213 | 214 | if filters == nil { 215 | filters = filter.Filters{} 216 | } 217 | 218 | if account.Presets == nil { 219 | return filters, nil 220 | } 221 | 222 | for _, presetName := range account.Presets { 223 | notFound := errors.ErrUnknownPreset(presetName) 224 | if c.Presets == nil { 225 | return nil, notFound 226 | } 227 | 228 | preset, ok := c.Presets[presetName] 229 | if !ok { 230 | return nil, notFound 231 | } 232 | 233 | filters.Append(preset.Filters) 234 | } 235 | 236 | return filters, nil 237 | } 238 | 239 | // ResolveDeprecations resolves any Deprecations in the configuration. This is done after the configuration has been 240 | // parsed. It loops through all the accounts and their filters and replaces any deprecated resource types with the 241 | // new resource type. 242 | func (c *Config) ResolveDeprecations() error { 243 | for _, a := range c.Accounts { 244 | if a == nil { 245 | return nil 246 | } 247 | 248 | // Note: if there are no filters defined, then there's no substitution to perform. 249 | if a.Filters == nil { 250 | return nil 251 | } 252 | 253 | for resourceType, resources := range a.Filters { 254 | replacement, ok := c.Deprecations[resourceType] 255 | if !ok { 256 | continue 257 | } 258 | 259 | c.Log.Warnf("deprecated resource type '%s' - converting to '%s'", resourceType, replacement) 260 | if _, ok := a.Filters[replacement]; ok { 261 | return errors.ErrDeprecatedResourceType( 262 | fmt.Sprintf( 263 | "using deprecated resource type and replacement: '%s','%s'", resourceType, replacement)) 264 | } 265 | 266 | a.Filters[replacement] = resources 267 | 268 | delete(a.Filters, resourceType) 269 | } 270 | } 271 | 272 | return nil 273 | } 274 | -------------------------------------------------------------------------------- /pkg/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/sirupsen/logrus" 11 | "github.com/stretchr/testify/assert" 12 | 13 | "github.com/ekristen/libnuke/pkg/errors" 14 | ) 15 | 16 | func init() { 17 | if flag.Lookup("test.v") != nil { 18 | logrus.SetOutput(io.Discard) 19 | } 20 | logrus.SetLevel(logrus.TraceLevel) 21 | logrus.SetReportCaller(true) 22 | } 23 | 24 | func TestNew(t *testing.T) { 25 | opts := Options{ 26 | Path: "testdata/example.yaml", 27 | } 28 | c, err := New(opts) 29 | assert.NoError(t, err) 30 | assert.NotNil(t, c) 31 | } 32 | 33 | func TestNewWithLogger(t *testing.T) { 34 | opts := Options{ 35 | Path: "testdata/example.yaml", 36 | Log: logrus.WithField("component", "test"), 37 | } 38 | c, err := New(opts) 39 | assert.NoError(t, err) 40 | assert.NotNil(t, c) 41 | } 42 | 43 | func TestNewNonExistentConfig(t *testing.T) { 44 | opts := Options{ 45 | Path: "testdata/nonexistent.yaml", 46 | } 47 | _, err := New(opts) 48 | assert.Error(t, err) 49 | } 50 | 51 | func TestBlocklistDeprecations(t *testing.T) { 52 | logrus.AddHook(&TestGlobalHook{ 53 | t: t, 54 | tf: func(t *testing.T, e *logrus.Entry) { 55 | if strings.HasSuffix(e.Caller.File, "pkg/config/config.go") { 56 | return 57 | } 58 | 59 | switch e.Caller.Line { 60 | case 119: 61 | assert.Equal(t, "deprecated configuration key 'account-blacklist' - please use 'blocklist' instead", e.Message) 62 | case 125: 63 | assert.Equal(t, "deprecated configuration key 'account-blocklist' - please use 'blocklist' instead", e.Message) 64 | } 65 | }, 66 | }) 67 | defer logrus.StandardLogger().ReplaceHooks(make(logrus.LevelHooks)) 68 | 69 | opts := Options{ 70 | Path: "testdata/deprecated.yaml", 71 | } 72 | 73 | c, err := New(opts) 74 | 75 | assert.NoError(t, err) 76 | assert.NotNil(t, c) 77 | assert.Equal(t, 2, len(c.Blocklist)) 78 | } 79 | 80 | func TestHasBlocklist(t *testing.T) { 81 | opts := Options{ 82 | Path: "testdata/example.yaml", 83 | } 84 | c, err := New(opts) 85 | assert.NoError(t, err) 86 | assert.NotNil(t, c) 87 | 88 | assert.True(t, c.HasBlocklist()) 89 | } 90 | 91 | func TestInBlocklist(t *testing.T) { 92 | opts := Options{ 93 | Path: "testdata/example.yaml", 94 | } 95 | c, err := New(opts) 96 | assert.NoError(t, err) 97 | assert.NotNil(t, c) 98 | 99 | assert.True(t, c.InBlocklist("1234567890")) 100 | assert.False(t, c.InBlocklist("1234567890123")) 101 | } 102 | 103 | func TestValidateAccount(t *testing.T) { 104 | opts := Options{ 105 | Path: "testdata/example.yaml", 106 | } 107 | c, err := New(opts) 108 | assert.NoError(t, err) 109 | assert.NotNil(t, c) 110 | 111 | assert.Error(t, c.ValidateAccount("12345678901234")) 112 | assert.Error(t, c.ValidateAccount("1234567890")) 113 | assert.NoError(t, c.ValidateAccount("555133742")) 114 | } 115 | 116 | func TestFilters(t *testing.T) { 117 | opts := Options{ 118 | Path: "testdata/example.yaml", 119 | } 120 | c, err := New(opts) 121 | assert.NoError(t, err) 122 | assert.NotNil(t, c) 123 | 124 | // Test an account that is configured 125 | filters, err := c.Filters("555133742") 126 | assert.NoError(t, err) 127 | assert.NotNil(t, filters) 128 | assert.Equal(t, 3, len(filters)) 129 | 130 | // Test an account that is not configured 131 | filters, err = c.Filters("1234567890") 132 | assert.Error(t, err) 133 | assert.Nil(t, filters) 134 | } 135 | 136 | func TestResourceTypeDeprecations(t *testing.T) { 137 | logrus.AddHook(&TestGlobalHook{ 138 | t: t, 139 | tf: func(t *testing.T, e *logrus.Entry) { 140 | fmt.Println("here", e.Caller.File, e.Caller.Line) 141 | if strings.HasSuffix(e.Caller.File, "pkg/config/config.go") { 142 | return 143 | } 144 | 145 | if e.Caller.Line == 212 { 146 | assert.Equal(t, "deprecated resource type 'IamRole' - converting to 'IAMRole'", e.Message) 147 | } 148 | }, 149 | }) 150 | defer logrus.StandardLogger().ReplaceHooks(make(logrus.LevelHooks)) 151 | 152 | opts := Options{ 153 | Path: "testdata/deprecated-resources.yaml", 154 | Deprecations: map[string]string{"IamRole": "IAMRole"}, 155 | NoResolveDeprecations: true, 156 | } 157 | c, err := New(opts) 158 | assert.NoError(t, err) 159 | assert.NotNil(t, c) 160 | 161 | err = c.ResolveDeprecations() 162 | assert.NoError(t, err) 163 | } 164 | 165 | func TestResourceTypeDeprecationsNoFilters(t *testing.T) { 166 | opts := Options{ 167 | Path: "testdata/deprecated-resources-no-filters.yaml", 168 | Deprecations: map[string]string{"IamRole": "IAMRole"}, 169 | NoResolveDeprecations: true, 170 | } 171 | c, err := New(opts) 172 | assert.NoError(t, err) 173 | assert.NotNil(t, c) 174 | 175 | err = c.ResolveDeprecations() 176 | assert.NoError(t, err) 177 | } 178 | 179 | func TestResourceTypeDeprecationsError(t *testing.T) { 180 | logrus.AddHook(&TestGlobalHook{ 181 | t: t, 182 | tf: func(t *testing.T, e *logrus.Entry) { 183 | fmt.Println("here", e.Caller.File, e.Caller.Line) 184 | if strings.HasSuffix(e.Caller.File, "pkg/config/config.go") { 185 | return 186 | } 187 | 188 | if e.Caller.Line == 212 { 189 | assert.Equal(t, "deprecated resource type 'IamRole' - converting to 'IAMRole'", e.Message) 190 | } 191 | }, 192 | }) 193 | defer logrus.StandardLogger().ReplaceHooks(make(logrus.LevelHooks)) 194 | 195 | opts := Options{ 196 | Path: "testdata/deprecated-resources-error.yaml", 197 | Deprecations: map[string]string{"IamRole": "IAMRole"}, 198 | NoResolveDeprecations: true, 199 | } 200 | c, err := New(opts) 201 | assert.NoError(t, err) 202 | assert.NotNil(t, c) 203 | 204 | err = c.ResolveDeprecations() 205 | assert.Error(t, err) 206 | } 207 | 208 | func TestInvalid(t *testing.T) { 209 | opts := Options{ 210 | Path: "testdata/invalid.yaml", 211 | } 212 | _, err := New(opts) 213 | assert.Error(t, err) 214 | } 215 | 216 | func TestInvalidPreset(t *testing.T) { 217 | opts := Options{ 218 | Path: "testdata/invalid-preset.yaml", 219 | } 220 | c, err := New(opts) 221 | assert.NoError(t, err) 222 | 223 | _, err = c.Filters("555133742") 224 | assert.Error(t, err) 225 | } 226 | 227 | func TestNoBlocklist(t *testing.T) { 228 | opts := Options{ 229 | Path: "testdata/no-blocklist.yaml", 230 | } 231 | c, err := New(opts) 232 | assert.NoError(t, err) 233 | assert.False(t, c.HasBlocklist()) 234 | 235 | err = c.ValidateAccount("555133742") 236 | assert.Error(t, err) 237 | } 238 | 239 | func TestIncomplete(t *testing.T) { 240 | opts := Options{ 241 | Path: "testdata/incomplete.yaml", 242 | } 243 | c, err := New(opts) 244 | assert.NoError(t, err) 245 | _ = c 246 | } 247 | 248 | // TestBroken tests a broken configuration file with an incomplete account that is not properly 249 | // configured. This should return an error when trying to get the filters for the account. 250 | func TestBroken(t *testing.T) { 251 | opts := Options{ 252 | Path: "testdata/broken.yaml", 253 | } 254 | c, err := New(opts) 255 | assert.NoError(t, err) 256 | _ = c 257 | 258 | _, err = c.Filters("000000000000") 259 | assert.ErrorIs(t, err, errors.ErrAccountNotConfigured) 260 | } 261 | 262 | // TestBrokenFixed tests a fixed broken configuration where there is at minimum an empty object provided for the 263 | // account. This should return an empty set of filters and no errors. 264 | func TestBrokenFixed(t *testing.T) { 265 | opts := Options{ 266 | Path: "testdata/broken-fixed.yaml", 267 | } 268 | c, err := New(opts) 269 | assert.NoError(t, err) 270 | _ = c 271 | 272 | _, err = c.Filters("000000000000") 273 | assert.NoError(t, err) 274 | 275 | _, err = c.Filters("111111111111") 276 | assert.NoError(t, err) 277 | } 278 | -------------------------------------------------------------------------------- /pkg/config/extended_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "testing" 7 | 8 | "gopkg.in/yaml.v2" 9 | 10 | "github.com/sirupsen/logrus" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | type TestCustomService struct { 15 | Service string `yaml:"service"` 16 | URL string `yaml:"url"` 17 | TLSInsecureSkipVerify bool `yaml:"tls_insecure_skip_verify"` 18 | } 19 | 20 | type TestCustomServices []*TestCustomService 21 | 22 | // GetService returns the custom region or nil when no such custom endpoints are defined for this region 23 | func (services TestCustomServices) GetService(serviceType string) *TestCustomService { 24 | for _, s := range services { 25 | if serviceType == s.Service { 26 | return s 27 | } 28 | } 29 | return nil 30 | } 31 | 32 | type TestCustomRegion struct { 33 | Region string `yaml:"region"` 34 | Services TestCustomServices `yaml:"services"` 35 | TLSInsecureSkipVerify bool `yaml:"tls_insecure_skip_verify"` 36 | } 37 | 38 | type TestCustomEndpoints []*TestCustomRegion 39 | 40 | func (endpoints TestCustomEndpoints) GetRegion(region string) *TestCustomRegion { 41 | for _, r := range endpoints { 42 | if r.Region == region { 43 | if r.TLSInsecureSkipVerify { 44 | for _, s := range r.Services { 45 | s.TLSInsecureSkipVerify = r.TLSInsecureSkipVerify 46 | } 47 | } 48 | return r 49 | } 50 | } 51 | return nil 52 | } 53 | 54 | func (endpoints TestCustomEndpoints) GetURL(region, serviceType string) string { 55 | r := endpoints.GetRegion(region) 56 | if r == nil { 57 | return "" 58 | } 59 | s := r.Services.GetService(serviceType) 60 | if s == nil { 61 | return "" 62 | } 63 | return s.URL 64 | } 65 | 66 | type TestExpandedConfig struct { 67 | Config `yaml:",inline"` 68 | CustomEndpoints TestCustomEndpoints `yaml:"endpoints"` 69 | } 70 | 71 | func NewExpandedConfig(opts Options) (*TestExpandedConfig, error) { 72 | c := &TestExpandedConfig{ 73 | Config: Config{ 74 | Accounts: make(map[string]*Account), 75 | Presets: make(map[string]Preset), 76 | Deprecations: make(map[string]string), 77 | }, 78 | } 79 | 80 | if opts.Log != nil { 81 | c.Log = opts.Log 82 | } else { 83 | c.Log = logrus.NewEntry(logrus.New()).WithField("component", "config") 84 | } 85 | 86 | err := c.load(opts.Path) 87 | if err != nil { 88 | return nil, err 89 | } 90 | 91 | if !opts.NoResolveBlacklist { 92 | c.ResolveBlocklist() 93 | } 94 | 95 | if !opts.NoResolveDeprecations { 96 | if err := c.ResolveDeprecations(); err != nil { 97 | return nil, err 98 | } 99 | } 100 | 101 | return c, nil 102 | } 103 | 104 | // Load loads a configuration from a file and parses it into a Config struct. 105 | func (c *TestExpandedConfig) load(path string) error { 106 | var err error 107 | 108 | raw, err := os.ReadFile(path) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | dec := yaml.NewDecoder(bytes.NewReader(raw)) 114 | if err := dec.Decode(&c); err != nil { 115 | return err 116 | } 117 | 118 | return nil 119 | } 120 | 121 | func NewTestExpandedConfig(path string) (*TestExpandedConfig, error) { 122 | c := &TestExpandedConfig{ 123 | Config: Config{ 124 | Blocklist: make([]string, 0), 125 | Accounts: make(map[string]*Account), 126 | Presets: make(map[string]Preset), 127 | Regions: make([]string, 0), 128 | }, 129 | CustomEndpoints: make(TestCustomEndpoints, 0), 130 | } 131 | 132 | if err := c.load(path); err != nil { 133 | return nil, err 134 | } 135 | 136 | return c, nil 137 | } 138 | 139 | func Test_ExpandedConfig(t *testing.T) { 140 | expandedCfg, err := NewTestExpandedConfig("testdata/expanded.yaml") 141 | assert.NoError(t, err) 142 | 143 | assert.Equal(t, "us-east-1", expandedCfg.Regions[0]) 144 | assert.Len(t, expandedCfg.CustomEndpoints, 1) 145 | } 146 | -------------------------------------------------------------------------------- /pkg/config/interface.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/sirupsen/logrus" 5 | 6 | "github.com/ekristen/libnuke/pkg/filter" 7 | ) 8 | 9 | // IConfig is the interface for the config package. It is used to define the methods that are required for the 10 | // configuration to be used by libnuke. If you are implementing a tool that uses libnuke then you will need to implement 11 | // this interface for your configuration or use the build in config package. 12 | type IConfig interface { 13 | SetLog(log *logrus.Entry) 14 | ResolveBlocklist() []string 15 | HasBlocklist() bool 16 | InBlocklist(searchID string) bool 17 | Validate(accountID string) error 18 | Filters(accountID string) (filter.Filters, error) 19 | SetDeprecations(deprecations map[string]string) 20 | ResolveDeprecations() error 21 | } 22 | -------------------------------------------------------------------------------- /pkg/config/testdata/broken-fixed.yaml: -------------------------------------------------------------------------------- 1 | regions: 2 | - global 3 | - us-east-1 4 | - ap-southeast-2 5 | 6 | blocklist: 7 | - "000000000000" 8 | 9 | accounts: 10 | "000000000000": {} 11 | "111111111111": 12 | filters: 13 | __global__: 14 | - property: tag:aws-nuke 15 | value: "Disable" 16 | - property: tag:aws-nuke 17 | value: "disable" -------------------------------------------------------------------------------- /pkg/config/testdata/broken.yaml: -------------------------------------------------------------------------------- 1 | regions: 2 | - global 3 | - us-east-1 4 | - ap-southeast-2 5 | 6 | blocklist: 7 | - "000000000000" 8 | 9 | accounts: 10 | "000000000000": 11 | filters: 12 | __global__: 13 | - property: tag:aws-nuke 14 | value: "Disable" 15 | - property: tag:aws-nuke 16 | value: "disable" -------------------------------------------------------------------------------- /pkg/config/testdata/deprecated-feature-flags.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | regions: 3 | - "eu-west-1" 4 | - stratoscale 5 | 6 | blocklist: 7 | - 1234567890 8 | 9 | feature-flags: 10 | disable-deletion-protection: 11 | RDSInstance: true 12 | EC2Instance: true 13 | CloudformationStack: true 14 | ELBv2: true 15 | QLDBLedger: true 16 | disable-ec2-instance-stop-protection: true 17 | force-delete-lightsail-addons: true 18 | 19 | resource-types: 20 | targets: 21 | - DynamoDBTable 22 | - S3Bucket 23 | - S3Object 24 | excludes: 25 | - IAMRole 26 | 27 | accounts: 28 | 555133742: 29 | presets: 30 | - "terraform" 31 | resource-types: 32 | targets: 33 | - S3Bucket 34 | filters: 35 | IAMRole: 36 | - "uber.admin" 37 | IAMRolePolicyAttachment: 38 | - "uber.admin -> AdministratorAccess" 39 | 40 | -------------------------------------------------------------------------------- /pkg/config/testdata/deprecated-resources-error.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | regions: 3 | - us-east-1 4 | 5 | blocklist: 6 | - 1234567890 7 | 8 | resource-types: 9 | targets: 10 | - IamRole 11 | 12 | accounts: 13 | 555133742: 14 | presets: 15 | - "terraform" 16 | resource-types: 17 | targets: 18 | - S3Bucket 19 | filters: 20 | IamRole: 21 | - "uber.admin" 22 | IAMRole: 23 | - "uber.one" 24 | 25 | presets: 26 | terraform: 27 | filters: 28 | S3Bucket: 29 | - type: glob 30 | value: "my-statebucket-*" -------------------------------------------------------------------------------- /pkg/config/testdata/deprecated-resources-no-filters.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | regions: 3 | - us-east-1 4 | 5 | blocklist: 6 | - 1234567890 7 | 8 | resource-types: 9 | includes: 10 | - IAMRole 11 | 12 | accounts: 13 | 555133742: 14 | presets: 15 | - "terraform" 16 | resource-types: 17 | includes: 18 | - S3Bucket 19 | 20 | presets: 21 | terraform: 22 | filters: 23 | S3Bucket: 24 | - type: glob 25 | value: "my-statebucket-*" -------------------------------------------------------------------------------- /pkg/config/testdata/deprecated-resources.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | regions: 3 | - us-east-1 4 | 5 | blocklist: 6 | - 1234567890 7 | 8 | resource-types: 9 | targets: 10 | - IAMRole 11 | 12 | accounts: 13 | 555133742: 14 | presets: 15 | - "terraform" 16 | resource-types: 17 | targets: 18 | - S3Bucket 19 | filters: 20 | IamRole: 21 | - "uber.admin" 22 | IAMRolePolicyAttachment: 23 | - "uber.admin -> AdministratorAccess" 24 | 25 | presets: 26 | terraform: 27 | filters: 28 | S3Bucket: 29 | - type: glob 30 | value: "my-statebucket-*" -------------------------------------------------------------------------------- /pkg/config/testdata/deprecated.yaml: -------------------------------------------------------------------------------- 1 | account-blocklist: 2 | - 2234567890 3 | 4 | account-blacklist: 5 | - 1234567890 -------------------------------------------------------------------------------- /pkg/config/testdata/example.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | regions: 3 | - "eu-west-1" 4 | - stratoscale 5 | 6 | blocklist: 7 | - 1234567890 8 | 9 | resource-types: 10 | targets: 11 | - DynamoDBTable 12 | - S3Bucket 13 | - S3Object 14 | excludes: 15 | - IAMRole 16 | 17 | accounts: 18 | 555133742: 19 | presets: 20 | - "terraform" 21 | resource-types: 22 | targets: 23 | - S3Bucket 24 | filters: 25 | IAMRole: 26 | - "uber.admin" 27 | IAMRolePolicyAttachment: 28 | - "uber.admin -> AdministratorAccess" 29 | 30 | presets: 31 | terraform: 32 | filters: 33 | S3Bucket: 34 | - type: glob 35 | value: "my-statebucket-*" 36 | -------------------------------------------------------------------------------- /pkg/config/testdata/expanded.yaml: -------------------------------------------------------------------------------- 1 | regions: 2 | - "us-east-1" 3 | - stratoscale 4 | 5 | blocklist: 6 | - 1234567890 7 | 8 | endpoints: 9 | - region: stratoscale 10 | tls_insecure_skip_verify: true 11 | services: 12 | - service: ec2 13 | url: https://stratoscale.cloud.internal/api/v2/aws/ec2 14 | - service: s3 15 | url: https://stratoscale.cloud.internal:1060 16 | tls_insecure_skip_verify: true 17 | 18 | resource-types: 19 | targets: 20 | - DynamoDBTable 21 | - S3Bucket 22 | - S3Object 23 | excludes: 24 | - IAMRole 25 | cloud-control: 26 | - AWS::EC2::Instance 27 | 28 | accounts: 29 | 555133742: 30 | presets: 31 | - "terraform" 32 | resource-types: 33 | targets: 34 | - S3Bucket 35 | filters: 36 | IAMRole: 37 | - "uber.admin" 38 | IAMRolePolicyAttachment: 39 | - "uber.admin -> AdministratorAccess" 40 | 41 | presets: 42 | terraform: 43 | filters: 44 | S3Bucket: 45 | - type: glob 46 | value: "my-statebucket-*" -------------------------------------------------------------------------------- /pkg/config/testdata/incomplete.yaml: -------------------------------------------------------------------------------- 1 | regions: 2 | - global 3 | 4 | accounts: 5 | "0964xxxxxxxx": -------------------------------------------------------------------------------- /pkg/config/testdata/invalid-preset.yaml: -------------------------------------------------------------------------------- 1 | regions: 2 | - us-east-1 3 | 4 | accounts: 5 | 555133742: 6 | presets: 7 | - "terraform1" 8 | 9 | presets: 10 | terraform: 11 | filters: 12 | S3Bucket: 13 | - type: glob 14 | value: "my-statebucket-*" -------------------------------------------------------------------------------- /pkg/config/testdata/invalid.yaml: -------------------------------------------------------------------------------- 1 | regions: 2 | - testing 3 | invalid: test -------------------------------------------------------------------------------- /pkg/config/testdata/no-blocklist.yaml: -------------------------------------------------------------------------------- 1 | regions: 2 | - us-east-1 3 | 4 | resource-types: 5 | targets: 6 | - DynamoDBTable 7 | - S3Bucket 8 | - S3Object 9 | excludes: 10 | - IAMRole -------------------------------------------------------------------------------- /pkg/config/testsuite_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sirupsen/logrus" 7 | ) 8 | 9 | type TestGlobalHook struct { 10 | t *testing.T 11 | tf func(t *testing.T, e *logrus.Entry) 12 | } 13 | 14 | func (h *TestGlobalHook) Levels() []logrus.Level { 15 | return logrus.AllLevels 16 | } 17 | 18 | func (h *TestGlobalHook) Fire(e *logrus.Entry) error { 19 | if h.tf != nil { 20 | h.tf(h.t, e) 21 | } 22 | 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /pkg/config/types.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/ekristen/libnuke/pkg/filter" 5 | "github.com/ekristen/libnuke/pkg/types" 6 | "github.com/sirupsen/logrus" 7 | ) 8 | 9 | // Account is a collection of filters and resource types that are to be included or excluded from the nuke process. 10 | // While the word Account is used, it is not limited to AWS accounts. It can be used for any type of grouping of 11 | // resources. For example, you could have an account for your AWS account, another for your GCP project, and another 12 | // for your Azure tenant. It's tool implementation dependent. 13 | type Account struct { 14 | // Filters is a collection of filters that are to be included during the nuke process for the specific account. 15 | Filters filter.Filters `yaml:"filters"` 16 | 17 | // ResourceTypes is a collection of resource types that are to be included or excluded from the nuke process for 18 | // the specific account. 19 | ResourceTypes ResourceTypes `yaml:"resource-types"` 20 | 21 | // Presets is a list of presets that are to be used for the specific account configuration. The presets are 22 | // defined in the top level Presets field. 23 | Presets []string `yaml:"presets"` 24 | } 25 | 26 | // Preset is a collection of filters that are to be included during the nuke process. 27 | type Preset struct { 28 | Filters filter.Filters `yaml:"filters"` 29 | } 30 | 31 | // ResourceTypes is a collection of resource types that are to be included or excluded from the nuke process. The 32 | // Includes and Excludes fields are mutually exclusive. If a resource type is listed in both the Includes and Excludes 33 | // fields then the Excludes field will take precedence. Additionally, the Alternatives field is a list of resource types 34 | // that are to be used instead of the default resource. The primary use case for this is AWS Cloud Control API resources. 35 | type ResourceTypes struct { 36 | // Includes is a list of resource types that are to be included during the nuke process. If a resource type is 37 | // listed in both the Includes and Excludes fields then the Excludes field will take precedence. 38 | Includes types.Collection `yaml:"includes"` 39 | 40 | // Excludes is a list of resource types that are to be excluded during the nuke process. If a resource type is 41 | // listed in both the Includes and Excludes fields then the Excludes field will take precedence. 42 | Excludes types.Collection `yaml:"excludes"` 43 | 44 | // Alternatives is a list of resource types that are to be used instead of the default resource. The primary use 45 | // case for this is AWS Cloud Control API resources. If a resource has been registered with the Cloud Control API 46 | // then we want to use that resource instead of the default resource. This is a Resource level alternative, not 47 | // a resource instance (i.e. all resources of this type will use the alternative resource, not just the resources 48 | // that are associated with the alternative resource). 49 | Alternatives types.Collection `yaml:"alternatives"` 50 | 51 | // Targets is a list of resource types that are to be included during the nuke process. If a resource type is 52 | // listed in both the Targets and Excludes fields then the Excludes field will take precedence. 53 | // Deprecated: Use Includes instead. 54 | Targets types.Collection `yaml:"targets"` 55 | 56 | // CloudControl is a list of resource types that are to be used with the Cloud Control API. This is a Resource 57 | // level alternative. This was left in place to make the transition to libnuke and ekristen/aws-nuke@v3 easier 58 | // for existing users. 59 | // Deprecated: Use Alternatives instead. 60 | CloudControl types.Collection `yaml:"cloud-control"` 61 | } 62 | 63 | // GetIncludes returns the combined list of includes and targets. This is left over from the AWS Nuke 64 | // tool and is deprecated. It was left to make the transition to the library and ekristen/aws-nuke@v3 easier for 65 | // existing users. This will be removed in 4.x of ekristen/aws-nuke. 66 | func (r *ResourceTypes) GetIncludes() types.Collection { 67 | var combined types.Collection 68 | 69 | if r.Targets != nil { 70 | logrus.Warn("'targets' is deprecated. Please use 'includes' instead.") 71 | combined = combined.Union(r.Targets) 72 | } 73 | 74 | combined = combined.Union(r.Includes) 75 | return combined 76 | } 77 | 78 | // GetAlternatives returns the combined list of cloud control and alternatives. This is left over from the AWS Nuke 79 | // tool and is deprecated. It was left to make the transition to the library and ekristen/aws-nuke@v3 easier for 80 | // existing users. This will be removed in 4.x of ekristen/aws-nuke. 81 | func (r *ResourceTypes) GetAlternatives() types.Collection { 82 | var combined types.Collection 83 | 84 | if r.CloudControl != nil { 85 | logrus.Warn("'cloud-control' is deprecated. Please use 'alternatives' instead.") 86 | combined = combined.Union(r.CloudControl) 87 | } 88 | 89 | combined = combined.Union(r.Alternatives) 90 | return combined 91 | } 92 | -------------------------------------------------------------------------------- /pkg/config/types_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/ekristen/libnuke/pkg/types" 9 | ) 10 | 11 | func TestTypes_ResourceTypes(t *testing.T) { 12 | cases := []struct { 13 | name string 14 | includes []string 15 | targets []string 16 | excludes []string 17 | alternatives []string 18 | cloudcontrol []string 19 | wantIncludes types.Collection 20 | wantAlternatives types.Collection 21 | }{ 22 | { 23 | name: "overlapping includes/targets", 24 | includes: []string{"test"}, 25 | targets: []string{"test", "test2"}, 26 | wantIncludes: []string{"test", "test2"}, 27 | wantAlternatives: nil, 28 | }, 29 | { 30 | name: "overlapping alternatives/cloudcontrol", 31 | alternatives: []string{"test"}, 32 | cloudcontrol: []string{"test", "test2"}, 33 | wantIncludes: nil, 34 | wantAlternatives: []string{"test", "test2"}, 35 | }, 36 | } 37 | 38 | for _, tc := range cases { 39 | t.Run(tc.name, func(t *testing.T) { 40 | rt := ResourceTypes{ 41 | Includes: tc.includes, 42 | Targets: tc.targets, 43 | Excludes: tc.excludes, 44 | Alternatives: tc.alternatives, 45 | CloudControl: tc.cloudcontrol, 46 | } 47 | 48 | gotIncludes := rt.GetIncludes() 49 | gotAlternatives := rt.GetAlternatives() 50 | 51 | assert.Equal(t, tc.wantIncludes, gotIncludes) 52 | assert.Equal(t, tc.wantAlternatives, gotAlternatives) 53 | }) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pkg/docs/generate.go: -------------------------------------------------------------------------------- 1 | package docs 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | ) 8 | 9 | func GeneratePropertiesMap(data interface{}) map[string]string { 10 | properties := map[string]string{} 11 | 12 | if data == nil { 13 | return properties 14 | } 15 | 16 | v := reflect.ValueOf(data) 17 | if v.Kind() == reflect.Ptr { 18 | v = v.Elem() 19 | } 20 | t := v.Type() 21 | 22 | for i := 0; i < t.NumField(); i++ { 23 | field := t.Field(i) 24 | 25 | if !field.IsExported() { 26 | continue 27 | } 28 | 29 | propertyTag := field.Tag.Get("property") 30 | options := strings.Split(propertyTag, ",") 31 | name := field.Name 32 | prefix := "" 33 | 34 | if options[0] == "-" { 35 | continue 36 | } 37 | 38 | for _, option := range options { 39 | parts := strings.Split(option, "=") 40 | if len(parts) != 2 { 41 | continue 42 | } 43 | switch parts[0] { 44 | case "name": 45 | name = parts[1] 46 | case "prefix": 47 | prefix = parts[1] 48 | } 49 | } 50 | 51 | if prefix != "" && name != "Tags" { 52 | name = fmt.Sprintf("%s:%s", prefix, name) 53 | } 54 | 55 | descriptionTag := field.Tag.Get("description") 56 | 57 | if name == "Tags" { 58 | originalName := name 59 | name = "tag::" 60 | tagPrefix := "tag:" 61 | if prefix != "" { 62 | tagPrefix = fmt.Sprintf("tag:%s:", prefix) 63 | } 64 | 65 | descriptionTag = fmt.Sprintf( 66 | "This resource has tags with property `%s`. These are key/value pairs that are\n\t"+ 67 | "added as their own property with the prefix of `%s` (e.g. [%sexample: \"value\"]) ", 68 | originalName, tagPrefix, tagPrefix) 69 | 70 | if prefix != "" { 71 | name = fmt.Sprintf("tag:%s::", prefix) 72 | } 73 | } 74 | 75 | properties[name] = descriptionTag 76 | } 77 | 78 | return properties 79 | } 80 | -------------------------------------------------------------------------------- /pkg/docs/generate_test.go: -------------------------------------------------------------------------------- 1 | package docs 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGenerateProperties(t *testing.T) { 10 | type TestResource1 struct { 11 | Name string `description:"The name of the resource"` 12 | Region *string `description:"The region in which the resource resides"` 13 | VpcID string `description:"The VPC ID of the resource" property:"prefix=vpc"` 14 | Tags map[string]string `description:"The tags associated with the resource"` 15 | } 16 | 17 | type TestResource2 struct { 18 | Name string `description:"The name of the resource"` 19 | Region *string `description:"The region in which the resource resides"` 20 | Tags map[string]string `description:"The tags associated with the resource" property:"prefix=ee"` 21 | } 22 | 23 | type TestResource3 struct { 24 | Name string `description:"The name of the resource"` 25 | Ignore string `property:"-"` 26 | Example string `description:"A property rename" property:"name=Delta"` 27 | skipped string //nolint:unused 28 | } 29 | 30 | type TestResource4 struct { 31 | name string //nolint:unused 32 | ignore string //nolint:unused 33 | example string //nolint:unused 34 | } 35 | 36 | cases := []struct { 37 | name string 38 | in interface{} 39 | want map[string]string 40 | }{ 41 | { 42 | name: "TestResource1", 43 | in: TestResource1{}, 44 | want: map[string]string{ 45 | "Name": "The name of the resource", 46 | "Region": "The region in which the resource resides", 47 | "vpc:VpcID": "The VPC ID of the resource", 48 | "tag::": "This resource has tags with property `Tags`. These are key/value pairs that are\n\t" + 49 | "added as their own property with the prefix of `tag:` (e.g. [tag:example: \"value\"]) ", 50 | }, 51 | }, 52 | { 53 | name: "TestResource2", 54 | in: TestResource2{}, 55 | want: map[string]string{ 56 | "Name": "The name of the resource", 57 | "Region": "The region in which the resource resides", 58 | "tag:ee::": "This resource has tags with property `Tags`. These are key/value pairs that are\n\t" + 59 | "added as their own property with the prefix of `tag:ee:" + 60 | "` (e.g. [tag:ee:example: \"value\"]) ", 61 | }, 62 | }, 63 | { 64 | name: "TestResource3", 65 | in: TestResource3{}, 66 | want: map[string]string{ 67 | "Name": "The name of the resource", 68 | "Delta": "A property rename", 69 | }, 70 | }, 71 | { 72 | name: "PointerTestResource3", 73 | in: &TestResource3{}, 74 | want: map[string]string{ 75 | "Name": "The name of the resource", 76 | "Delta": "A property rename", 77 | }, 78 | }, 79 | { 80 | name: "TestResource4", 81 | in: TestResource4{}, 82 | want: map[string]string{}, 83 | }, 84 | { 85 | name: "nil", 86 | in: nil, 87 | want: map[string]string{}, 88 | }, 89 | } 90 | 91 | for _, c := range cases { 92 | t.Run(c.name, func(t *testing.T) { 93 | have := GeneratePropertiesMap(c.in) 94 | assert.Equal(t, c.want, have) 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /pkg/errors/errors.go: -------------------------------------------------------------------------------- 1 | // Package errors provides common errors that can be used throughout the library for handling of resource errors 2 | package errors 3 | 4 | import "errors" 5 | 6 | type ErrSkipRequest string 7 | 8 | func (err ErrSkipRequest) Error() string { 9 | return string(err) 10 | } 11 | 12 | type ErrUnknownEndpoint string 13 | 14 | func (err ErrUnknownEndpoint) Error() string { 15 | return string(err) 16 | } 17 | 18 | type ErrWaitResource string 19 | 20 | func (err ErrWaitResource) Error() string { 21 | return string(err) 22 | } 23 | 24 | type ErrHoldResource string 25 | 26 | func (err ErrHoldResource) Error() string { 27 | return string(err) 28 | } 29 | 30 | type ErrUnknownPreset string 31 | 32 | func (err ErrUnknownPreset) Error() string { 33 | return string(err) 34 | } 35 | 36 | type ErrDeprecatedResourceType string 37 | 38 | func (err ErrDeprecatedResourceType) Error() string { 39 | return string(err) 40 | } 41 | 42 | var ErrNoBlocklistDefined = errors.New("no blocklist defined") 43 | var ErrBlocklistAccount = errors.New("account is in blocklist") 44 | var ErrAccountNotConfigured = errors.New("account is not configured") 45 | -------------------------------------------------------------------------------- /pkg/errors/errors_test.go: -------------------------------------------------------------------------------- 1 | package errors_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | liberrors "github.com/ekristen/libnuke/pkg/errors" 10 | ) 11 | 12 | func TestErrorIs(t *testing.T) { 13 | err := liberrors.ErrSkipRequest("resource is regional") 14 | var testErr liberrors.ErrSkipRequest 15 | if !errors.As(err, &testErr) { 16 | t.Errorf("errors.Is failed") 17 | } 18 | } 19 | 20 | const testStringValue = "this is just a test" 21 | 22 | func TestErrors(t *testing.T) { 23 | cases := []struct { 24 | err error 25 | }{ 26 | { 27 | err: liberrors.ErrSkipRequest(testStringValue), 28 | }, 29 | {liberrors.ErrUnknownEndpoint(testStringValue)}, 30 | {liberrors.ErrWaitResource(testStringValue)}, 31 | {liberrors.ErrHoldResource(testStringValue)}, 32 | {liberrors.ErrUnknownPreset(testStringValue)}, 33 | {liberrors.ErrDeprecatedResourceType(testStringValue)}, 34 | } 35 | 36 | for _, c := range cases { 37 | if c.err == nil { 38 | t.Errorf("error is nil") 39 | } 40 | 41 | assert.Equal(t, c.err.Error(), testStringValue) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /pkg/filter/README.md: -------------------------------------------------------------------------------- 1 | # Filter 2 | 3 | The filter package is designed to allow you to define filters, primarily by yaml to be able to filter resources. This is 4 | used in the `nuke` package to filter resources based on a set of criteria. 5 | 6 | Filter's can be optionally added to a `group` filters within a `group` are combined with an `AND` operation. Filters in 7 | different `group` are combined with an `OR` operation. 8 | 9 | There is also the concept of a `global` filter that is applied to all resources. 10 | 11 | ## Global 12 | 13 | You can define a global filter that will be applied to all resources. This is useful for defining a set of filters that 14 | should be applied to all resources. 15 | 16 | It has a special key called `__global__`. 17 | 18 | This only works when you are defining it as a resource type as part of the `Filters` `map[string][]Filter` type. 19 | 20 | ## Types 21 | 22 | There are multiple filter types that can be used to filter the resources. These types are used to match against the 23 | property. 24 | 25 | - empty 26 | - exact 27 | - glob 28 | - regex 29 | - contains 30 | - dateOlderThan 31 | - dateOlderThanNow 32 | - suffix 33 | - prefix 34 | - In 35 | - NotIn 36 | 37 | ### empty / exact 38 | 39 | These are identical, if you leave your type empty, it will choose exact. Exact will only match if values are identical. 40 | 41 | ### glob 42 | 43 | A glob allows for matching values using asterisk for a wild card, you may have more than one asterisk. 44 | 45 | ### regex 46 | 47 | A regex allows for matching values with any valid regular expression. 48 | 49 | ### contains 50 | 51 | A contains type allows for matching a value if it has the value contained within the property value. 52 | 53 | ### dateOlderThan 54 | 55 | This allows you to filter a property's value based on whether it is older 56 | 57 | ### dateOlderThanNow 58 | 59 | This allows you to filter a properties value based on whether it is older than the current time in UTC with an addition 60 | or subtraction of a duration value. 61 | 62 | For example if the property is `CreatedDate` and the value is `2024-04-14T12:00:00Z` and the current time is 63 | `2024-04-14T18:00:00Z` then you can set a negative duration like `-12h`. In this case it would not match, as the 64 | `CreatedDate` would be **after** the adjusted time. 65 | 66 | If you adjusted it `-4h` then it **would** match as the `CreatedDate` would be **before** the adjusted time. 67 | 68 | ### suffix 69 | 70 | This allows you to match a value if the value being filtered on is the suffix of the property value. 71 | 72 | ### prefix 73 | 74 | This allows you to match a value if the value being filtered on is the prefix of the property value. 75 | 76 | ### In 77 | 78 | This allows you to match a value if it is in a list of values. 79 | 80 | ### NotIn 81 | 82 | This allows you to match a value if it is not in a list of values. -------------------------------------------------------------------------------- /pkg/filter/filter.go: -------------------------------------------------------------------------------- 1 | // Package filter provides a way to filter resources based on a set of criteria. 2 | package filter 3 | 4 | import ( 5 | "fmt" 6 | "regexp" 7 | "slices" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/mb0/glob" 13 | "github.com/sirupsen/logrus" 14 | ) 15 | 16 | type OpType string 17 | type Type string 18 | 19 | const ( 20 | Empty Type = "" 21 | Exact Type = "exact" 22 | Glob Type = "glob" 23 | Regex Type = "regex" 24 | Contains Type = "contains" 25 | DateOlderThan Type = "dateOlderThan" 26 | DateOlderThanNow Type = "dateOlderThanNow" 27 | Suffix Type = "suffix" 28 | Prefix Type = "prefix" 29 | NotIn Type = "NotIn" 30 | In Type = "In" 31 | 32 | And OpType = "and" 33 | Or OpType = "or" 34 | 35 | Global = "__global__" 36 | ) 37 | 38 | type Property interface { 39 | GetProperty(string) (string, error) 40 | } 41 | 42 | type Filters map[string][]Filter 43 | 44 | // Get returns the filters for a specific resource type or the global filters if they exist. If there are no filters it 45 | // returns nil 46 | func (f Filters) Get(resourceType string) []Filter { 47 | var filters []Filter 48 | 49 | if f[Global] != nil { 50 | filters = append(filters, f[Global]...) 51 | } 52 | 53 | if f[resourceType] != nil { 54 | filters = append(filters, f[resourceType]...) 55 | } 56 | 57 | if len(filters) == 0 { 58 | return nil 59 | } 60 | 61 | return filters 62 | } 63 | 64 | type FilterWithScope struct { 65 | Filter Filter 66 | Global bool 67 | } 68 | 69 | // GetByGroup returns the filters grouped by the group name for a specific resource type or the global filters if they exist. 70 | // If there are no filters it returns nil 71 | func (f Filters) GetByGroup(resourceType string) map[string][]FilterWithScope { 72 | filters := make(map[string][]FilterWithScope) 73 | 74 | if f[Global] != nil { 75 | for _, filter := range f[Global] { 76 | group := filter.Group 77 | if filters[group] == nil { 78 | filters[group] = []FilterWithScope{} 79 | } 80 | 81 | filters[group] = append(filters[group], FilterWithScope{ 82 | Filter: filter, 83 | Global: true, 84 | }) 85 | } 86 | } 87 | 88 | if f[resourceType] != nil { 89 | for _, filter := range f[resourceType] { 90 | group := filter.Group 91 | if filters[group] == nil { 92 | filters[group] = []FilterWithScope{} 93 | } 94 | 95 | filters[group] = append(filters[group], FilterWithScope{ 96 | Filter: filter, 97 | Global: false, 98 | }) 99 | } 100 | } 101 | 102 | if len(filters) == 0 { 103 | return nil 104 | } 105 | 106 | return filters 107 | } 108 | 109 | // Validate checks if the filters are valid or not and returns an error if they are not 110 | func (f Filters) Validate() error { 111 | for resourceType, filters := range f { 112 | for _, filter := range filters { 113 | if err := filter.Validate(); err != nil { 114 | return fmt.Errorf("%s: has an invalid filter: %+v", resourceType, filter) 115 | } 116 | } 117 | } 118 | 119 | return nil 120 | } 121 | 122 | // Append appends the filters from f2 to f. This is primarily used to append filters from a preset 123 | // to a set of filters that were defined on a resource type. 124 | func (f Filters) Append(f2 Filters) { 125 | for resourceType, filter := range f2 { 126 | f[resourceType] = append(f[resourceType], filter...) 127 | } 128 | } 129 | 130 | // Merge is an alias of Append for backwards compatibility 131 | // Deprecated: use Append instead 132 | func (f Filters) Merge(f2 Filters) { 133 | f.Append(f2) 134 | } 135 | 136 | // Match checks if the filters match the given property which is actually a queue item that meats the 137 | // property interface requirements 138 | func (f Filters) Match(resourceType string, p Property, log *logrus.Entry) (bool, error) { 139 | resourceFilters := f.GetByGroup(resourceType) 140 | if resourceFilters == nil { 141 | return false, nil 142 | } 143 | 144 | var groupCount int 145 | var totalCount int 146 | 147 | for _, groupFilters := range resourceFilters { 148 | var matchCount int 149 | totalCount++ 150 | 151 | for _, f := range groupFilters { 152 | prop, err := p.GetProperty(f.Filter.Property) 153 | if err != nil { 154 | // Note: this continues because we want it to continue if a property is not found for the time 155 | // being. This can also return an error we want as a warning if a resource does not support 156 | // custom properties. This can be triggered by __global__ filters that are applied to all resources. 157 | log.WithError(err).Warn("error getting property") 158 | continue 159 | } 160 | 161 | match, err := f.Filter.Match(prop) 162 | if err != nil { 163 | log.WithError(err).Warn("error matching filter") 164 | return false, err 165 | } 166 | 167 | log. 168 | WithField("filter_group", f.Filter.Group). 169 | WithField("filter_prop", f.Filter.Property). 170 | WithField("filter_type", f.Filter.Type). 171 | WithField("filter_value", f.Filter.Value). 172 | WithField("filter_invert", f.Filter.Invert). 173 | WithField("global", f.Global). 174 | WithField("prop_value", prop). 175 | WithField("match", match). 176 | Tracef("matching filter for group '%s': match=%t, invert=%t", f.Filter.Group, match, f.Filter.Invert) 177 | 178 | if f.Filter.Invert { 179 | match = !match 180 | } 181 | 182 | if match { 183 | matchCount++ 184 | } 185 | } 186 | 187 | log.Trace("matchCount: ", matchCount) 188 | 189 | if matchCount > 0 { 190 | groupCount++ 191 | } 192 | } 193 | 194 | log.Trace("groupCount: ", groupCount) 195 | log.Trace("totalCount: ", totalCount) 196 | 197 | // If the group count equals the total count, then all of the groups matched 198 | if groupCount == totalCount { 199 | return true, nil 200 | } 201 | 202 | return false, nil 203 | } 204 | 205 | // Filter is a filter to apply to a resource 206 | type Filter struct { 207 | // Group is the name of the group of filters, all filters in a group are ANDed together 208 | Group string `yaml:"group" json:"group"` 209 | 210 | // Type is the type of filter to apply 211 | Type Type `yaml:"type" json:"type"` 212 | 213 | // Property is the name of the property to filter on 214 | Property string `yaml:"property" json:"property"` 215 | 216 | // Value is the value to filter on 217 | Value string `yaml:"value" json:"value"` 218 | 219 | // Values allows for multiple values to be specified for a filter 220 | Values []string `yaml:"values" json:"values"` 221 | 222 | // Invert is a flag to invert the filter 223 | Invert bool `yaml:"invert" json:"invert"` 224 | } 225 | 226 | // Validate checks if the filter is valid 227 | func (f *Filter) Validate() error { 228 | if f.Property == "" && f.Value == "" { 229 | return fmt.Errorf("property and value cannot be empty") 230 | } 231 | 232 | return nil 233 | } 234 | 235 | // Match checks if the filter matches the given value 236 | func (f *Filter) Match(o string) (bool, error) { //nolint:gocyclo 237 | switch f.Type { 238 | case Empty, Exact: 239 | return f.Value == o, nil 240 | 241 | case Contains: 242 | return strings.Contains(o, f.Value), nil 243 | 244 | case Glob: 245 | return glob.Match(f.Value, o) 246 | 247 | case Regex: 248 | re, err := regexp.Compile(f.Value) 249 | if err != nil { 250 | return false, err 251 | } 252 | return re.MatchString(o), nil 253 | 254 | case DateOlderThan: 255 | if o == "" { 256 | return false, nil 257 | } 258 | duration, err := time.ParseDuration(f.Value) 259 | if err != nil { 260 | return false, err 261 | } 262 | fieldTime, err := parseDate(o) 263 | if err != nil { 264 | return false, err 265 | } 266 | fieldTimeWithOffset := fieldTime.Add(duration) 267 | 268 | return fieldTimeWithOffset.After(time.Now()), nil 269 | 270 | case DateOlderThanNow: 271 | if o == "" { 272 | return false, nil 273 | } 274 | duration, err := time.ParseDuration(f.Value) 275 | if err != nil { 276 | return false, err 277 | } 278 | fieldTime, err := parseDate(o) 279 | if err != nil { 280 | return false, err 281 | } 282 | 283 | adjustedTime := time.Now().UTC().Add(duration) 284 | 285 | return adjustedTime.After(fieldTime), nil 286 | 287 | case Prefix: 288 | return strings.HasPrefix(o, f.Value), nil 289 | 290 | case Suffix: 291 | return strings.HasSuffix(o, f.Value), nil 292 | 293 | case In: 294 | return slices.Contains(f.Values, o), nil 295 | 296 | case NotIn: 297 | return !slices.Contains(f.Values, o), nil 298 | 299 | default: 300 | return false, fmt.Errorf("unknown type %s", f.Type) 301 | } 302 | } 303 | 304 | // UnmarshalYAML unmarshals a filter from YAML data 305 | func (f *Filter) UnmarshalYAML(unmarshal func(interface{}) error) error { 306 | var value string 307 | 308 | if unmarshal(&value) == nil { 309 | f.Type = Exact 310 | f.Value = value 311 | f.Group = "default" 312 | return nil 313 | } 314 | 315 | m := map[string]interface{}{} 316 | err := unmarshal(m) 317 | if err != nil { 318 | fmt.Println("%%%%%%%%") 319 | return err 320 | } 321 | 322 | if m["group"] == nil { 323 | f.Group = "default" 324 | } else { 325 | f.Group = m["group"].(string) 326 | } 327 | 328 | if m["type"] == nil { 329 | f.Type = Exact 330 | } else { 331 | f.Type = Type(m["type"].(string)) 332 | } 333 | 334 | if m["value"] == nil { 335 | f.Value = "" 336 | } else { 337 | f.Value = m["value"].(string) 338 | } 339 | 340 | if m["values"] == nil { 341 | f.Values = []string{} 342 | } else { 343 | interfaceSlice := m["values"].([]interface{}) 344 | stringSlice := make([]string, len(interfaceSlice)) 345 | for i, v := range interfaceSlice { 346 | str, _ := v.(string) 347 | stringSlice[i] = str 348 | } 349 | 350 | f.Values = stringSlice 351 | } 352 | 353 | if m["property"] == nil { 354 | f.Property = "" 355 | } else { 356 | f.Property = m["property"].(string) 357 | } 358 | 359 | if m["invert"] == nil { 360 | f.Invert = false 361 | } else { 362 | switch val := m["invert"].(type) { 363 | case bool: 364 | f.Invert = val 365 | case string: 366 | invert, err := strconv.ParseBool(val) 367 | if err != nil { 368 | return err 369 | } 370 | f.Invert = invert 371 | } 372 | } 373 | 374 | return nil 375 | } 376 | 377 | // NewExactFilter creates a new filter that matches the exact value 378 | func NewExactFilter(value string) Filter { 379 | return Filter{ 380 | Type: Exact, 381 | Value: value, 382 | Group: "default", 383 | } 384 | } 385 | 386 | // parseDate parses a date from a string, it supports unix timestamps and RFC3339 formatted dates 387 | func parseDate(input string) (time.Time, error) { 388 | if i, err := strconv.ParseInt(input, 10, 64); err == nil { 389 | t := time.Unix(i, 0) 390 | return t, nil 391 | } 392 | 393 | formats := []string{"2006-01-02", 394 | "2006/01/02", 395 | "2006-01-02T15:04:05Z", 396 | "2006-01-02 15:04:05 -0700 MST", // Date format used by AWS for CreateTime on ASGs 397 | time.RFC3339Nano, // Format of t.MarshalText() and t.MarshalJSON() 398 | time.RFC3339, 399 | } 400 | for _, f := range formats { 401 | t, err := time.Parse(f, input) 402 | if err == nil { 403 | return t, nil 404 | } 405 | } 406 | return time.Now(), fmt.Errorf("unable to parse time %s", input) 407 | } 408 | -------------------------------------------------------------------------------- /pkg/filter/testdata/global.yaml: -------------------------------------------------------------------------------- 1 | filters: 2 | __global__: 3 | - property: prop3 4 | type: exact 5 | value: value3 6 | Resource1: 7 | - property: prop1 8 | type: exact 9 | value: value1 10 | Resource2: 11 | - property: prop2 12 | type: exact 13 | value: value2 -------------------------------------------------------------------------------- /pkg/filter/testdata/groups.yaml: -------------------------------------------------------------------------------- 1 | filters: 2 | Resource1: 3 | - property: prop1 4 | type: exact 5 | value: value1 6 | - property: prop2 7 | type: exact 8 | value: value2 9 | - property: prop3 10 | type: exact 11 | value: value3 12 | group: something-else 13 | Resource2: 14 | - property: prop2 15 | type: exact 16 | value: value2 -------------------------------------------------------------------------------- /pkg/filter/testsuite_test.go: -------------------------------------------------------------------------------- 1 | package filter_test 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ekristen/libnuke/pkg/types" 7 | ) 8 | 9 | type TestResource struct { 10 | Props types.Properties 11 | } 12 | 13 | func (t *TestResource) GetProperty(key string) (string, error) { 14 | if key == "no_stringer" { //nolint:staticcheck 15 | return "", fmt.Errorf("does not support legacy IDs") 16 | } else if key == "no_properties" { 17 | return "", fmt.Errorf("does not support custom properties") 18 | } 19 | 20 | return t.Props[key], nil 21 | } 22 | 23 | func (t *TestResource) Properties() types.Properties { 24 | return t.Props 25 | } 26 | -------------------------------------------------------------------------------- /pkg/log/formatter.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | type CustomFormatter struct { 12 | FallbackFormatter logrus.Formatter 13 | } 14 | 15 | func (f *CustomFormatter) Format(entry *logrus.Entry) ([]byte, error) { //nolint:gocyclo 16 | if f.FallbackFormatter == nil { 17 | f.FallbackFormatter = &logrus.TextFormatter{} 18 | } 19 | 20 | if entry == nil { 21 | return nil, nil 22 | } 23 | 24 | handler, ok := entry.Data["_handler"].(string) 25 | if ok && handler == "println" { 26 | delete(entry.Data, "_handler") 27 | return []byte(fmt.Sprintf("%s\n", entry.Message)), nil 28 | } 29 | 30 | resourceType, ok := entry.Data["type"].(string) 31 | if !ok { 32 | return f.FallbackFormatter.Format(entry) 33 | } 34 | 35 | if _, ok := entry.Data["owner"]; !ok { 36 | return f.FallbackFormatter.Format(entry) 37 | } 38 | if _, ok := entry.Data["state"]; !ok { 39 | return f.FallbackFormatter.Format(entry) 40 | } 41 | if _, ok := entry.Data["state_code"]; !ok { 42 | return f.FallbackFormatter.Format(entry) 43 | } 44 | if _, ok := entry.Data["name"]; !ok { 45 | return f.FallbackFormatter.Format(entry) 46 | } 47 | 48 | owner := entry.Data["owner"].(string) 49 | resourceName := entry.Data["name"].(string) 50 | state := entry.Data["state_code"].(int) 51 | 52 | var sortedFields = make([]string, 0) 53 | for k, v := range entry.Data { 54 | if strings.HasPrefix(k, "prop:") { 55 | if strings.HasPrefix(k, "prop:_") { 56 | continue 57 | } 58 | 59 | sortedFields = append(sortedFields, fmt.Sprintf("%s: %q", k[5:], v)) 60 | } 61 | } 62 | 63 | sort.Strings(sortedFields) 64 | 65 | msgColor := ReasonSuccess 66 | switch state { 67 | case 0, 1, 8: 68 | msgColor = ReasonSuccess 69 | case 2: 70 | msgColor = ReasonHold 71 | case 3: 72 | msgColor = ReasonRemoveTriggered 73 | case 4: 74 | msgColor = ReasonWaitDependency 75 | case 5: 76 | msgColor = ReasonWaitPending 77 | case 6: 78 | msgColor = ReasonError 79 | case 7: 80 | msgColor = ReasonSkip 81 | } 82 | 83 | msg := fmt.Sprintf("%s - %s - %s - %s - %s\n", 84 | ColorRegion.Sprint(owner), 85 | ColorResourceType.Sprint(resourceType), 86 | ColorResourceID.Sprint(resourceName), 87 | ColorResourceProperties.Sprintf("[%s]", strings.Join(sortedFields, ", ")), 88 | msgColor.Sprint(entry.Message)) 89 | 90 | return []byte(msg), nil 91 | } 92 | -------------------------------------------------------------------------------- /pkg/log/formatter_test.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/fatih/color" 8 | "github.com/sirupsen/logrus" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestCustomFormatter_Format(t *testing.T) { 13 | logrus.StandardLogger().ReplaceHooks(make(logrus.LevelHooks)) 14 | 15 | cases := []struct { 16 | name string 17 | input *logrus.Entry 18 | want []byte 19 | }{ 20 | { 21 | name: "empty", 22 | input: nil, 23 | want: nil, 24 | }, 25 | { 26 | name: "println", 27 | input: &logrus.Entry{ 28 | Message: "test message", 29 | Data: logrus.Fields{ 30 | "_handler": "println", 31 | }, 32 | }, 33 | want: []byte("test message\n"), 34 | }, 35 | { 36 | name: "invalid-type", 37 | input: &logrus.Entry{ 38 | Data: logrus.Fields{ 39 | "type": "test", 40 | }, 41 | }, 42 | want: []byte(`time="0001-01-01T00:00:00Z" level=panic type=test 43 | `), 44 | }, 45 | { 46 | name: "missing-name", 47 | input: &logrus.Entry{ 48 | Data: logrus.Fields{ 49 | "type": "test", 50 | "owner": "owner", 51 | "state": "new", 52 | "state_code": 0, 53 | }, 54 | }, 55 | want: []byte(`time="0001-01-01T00:00:00Z" level=panic owner=owner state=new state_code=0 type=test 56 | `), 57 | }, 58 | { 59 | name: "missing-state-code", 60 | input: &logrus.Entry{ 61 | Data: logrus.Fields{ 62 | "type": "test", 63 | "owner": "owner", 64 | "state": "new", 65 | "name": "resource", 66 | }, 67 | }, 68 | want: []byte(`time="0001-01-01T00:00:00Z" level=panic name=resource owner=owner state=new type=test 69 | `), 70 | }, 71 | { 72 | name: "missing-type", 73 | input: &logrus.Entry{ 74 | Data: logrus.Fields{ 75 | "owner": "owner", 76 | "name": "resource", 77 | }, 78 | }, 79 | want: []byte(`time="0001-01-01T00:00:00Z" level=panic name=resource owner=owner 80 | `), 81 | }, 82 | { 83 | name: "missing-owner", 84 | input: &logrus.Entry{ 85 | Data: logrus.Fields{ 86 | "type": "test", 87 | }, 88 | }, 89 | want: []byte(`time="0001-01-01T00:00:00Z" level=panic type=test 90 | `), 91 | }, 92 | { 93 | name: "missing-resource", 94 | input: &logrus.Entry{ 95 | Data: logrus.Fields{ 96 | "type": "test", 97 | "owner": "owner", 98 | }, 99 | }, 100 | want: []byte(`time="0001-01-01T00:00:00Z" level=panic owner=owner type=test 101 | `), 102 | }, 103 | { 104 | name: "missing-state", 105 | input: &logrus.Entry{ 106 | Data: logrus.Fields{ 107 | "type": "test", 108 | "owner": "owner", 109 | "name": "resource", 110 | }, 111 | }, 112 | want: []byte(`time="0001-01-01T00:00:00Z" level=panic name=resource owner=owner type=test 113 | `), 114 | }, 115 | { 116 | name: "reason-success", 117 | input: &logrus.Entry{ 118 | Message: "would remove", 119 | Data: logrus.Fields{ 120 | "type": "test", 121 | "owner": "owner", 122 | "name": "resource", 123 | "state": "new", 124 | "state_code": 0, 125 | "prop:one": "1", 126 | "prop:two": "2", 127 | }, 128 | }, 129 | want: []byte(fmt.Sprintf("%s - %s - %s - %s - %s\n", 130 | ColorRegion.Sprint("owner"), 131 | ColorResourceType.Sprint("test"), 132 | ColorResourceID.Sprint("resource"), 133 | ColorResourceProperties.Sprintf("[%s]", `one: "1"`+", "+`two: "2"`), 134 | ReasonSuccess.Sprint("would remove"))), 135 | }, 136 | { 137 | name: "reason-hold", 138 | input: &logrus.Entry{ 139 | Message: "test message", 140 | Data: logrus.Fields{ 141 | "type": "test", 142 | "owner": "owner", 143 | "name": "resource", 144 | "state": "hold", 145 | "state_code": 2, 146 | "prop:one": "1", 147 | "prop:two": "2", 148 | "prop:_tagPrefix": "tag", 149 | }, 150 | }, 151 | want: []byte(fmt.Sprintf("%s - %s - %s - %s - %s\n", 152 | ColorRegion.Sprint("owner"), 153 | ColorResourceType.Sprint("test"), 154 | ColorResourceID.Sprint("resource"), 155 | ColorResourceProperties.Sprintf("[%s]", `one: "1"`+", "+`two: "2"`), 156 | ReasonHold.Sprint("test message"))), 157 | }, 158 | { 159 | name: "reason-remove-triggered", 160 | input: &logrus.Entry{ 161 | Message: "test message", 162 | Data: logrus.Fields{ 163 | "type": "test", 164 | "owner": "owner", 165 | "name": "resource", 166 | "state": "pending", 167 | "state_code": 3, 168 | "prop:one": "1", 169 | "prop:two": "2", 170 | }, 171 | }, 172 | want: []byte(fmt.Sprintf("%s - %s - %s - %s - %s\n", 173 | ColorRegion.Sprint("owner"), 174 | ColorResourceType.Sprint("test"), 175 | ColorResourceID.Sprint("resource"), 176 | ColorResourceProperties.Sprintf("[%s]", `one: "1"`+", "+`two: "2"`), 177 | ReasonRemoveTriggered.Sprint("test message"))), 178 | }, 179 | } 180 | 181 | for _, tc := range cases { 182 | t.Run(tc.name, func(t *testing.T) { 183 | cf := CustomFormatter{} 184 | 185 | got, err := cf.Format(tc.input) 186 | assert.NoError(t, err) 187 | equal := assert.EqualValuesf(t, tc.want, got, "expected %v, got %v", tc.want, got) 188 | if !equal { 189 | fmt.Println("`" + string(tc.want) + "`") 190 | fmt.Println("`" + string(got) + "`") 191 | } 192 | }) 193 | } 194 | } 195 | 196 | func TestCustomFormatter_FormatReasons(t *testing.T) { 197 | testEntry := &logrus.Entry{ 198 | Message: "test message", 199 | Data: logrus.Fields{ 200 | "type": "test", 201 | "owner": "owner", 202 | "name": "resource", 203 | "state": 0, 204 | "prop:one": "1", 205 | "prop:two": "2", 206 | }, 207 | } 208 | 209 | cases := []struct { 210 | name string 211 | state string 212 | stateCode int 213 | color color.Color 214 | }{ 215 | { 216 | name: "reason-success", 217 | state: "new", 218 | stateCode: 0, 219 | color: ReasonSuccess, 220 | }, 221 | { 222 | name: "reason-hold", 223 | state: "hold", 224 | stateCode: 2, 225 | color: ReasonHold, 226 | }, 227 | { 228 | name: "reason-remove-triggered", 229 | state: "pending", 230 | stateCode: 3, 231 | color: ReasonRemoveTriggered, 232 | }, 233 | { 234 | name: "reason-wait-dependency", 235 | state: "pending-dependency", 236 | stateCode: 4, 237 | color: ReasonWaitDependency, 238 | }, 239 | { 240 | name: "reason-wait-pending", 241 | state: "waiting", 242 | stateCode: 5, 243 | color: ReasonWaitPending, 244 | }, 245 | { 246 | name: "reason-error", 247 | state: "failed", 248 | stateCode: 6, 249 | color: ReasonError, 250 | }, 251 | { 252 | name: "reason-skip", 253 | state: "filtered", 254 | stateCode: 7, 255 | color: ReasonSkip, 256 | }, 257 | } 258 | 259 | for _, tc := range cases { 260 | t.Run(tc.name, func(t *testing.T) { 261 | cf := CustomFormatter{} 262 | 263 | expected := []byte(fmt.Sprintf("%s - %s - %s - %s - %s\n", 264 | ColorRegion.Sprint("owner"), 265 | ColorResourceType.Sprint("test"), 266 | ColorResourceID.Sprint("resource"), 267 | ColorResourceProperties.Sprintf("[%s]", `one: "1"`+", "+`two: "2"`), 268 | tc.color.Sprint("test message"))) 269 | 270 | newTestEntry := testEntry 271 | newTestEntry.Data["state"] = tc.state 272 | newTestEntry.Data["state_code"] = tc.stateCode 273 | 274 | got, err := cf.Format(newTestEntry) 275 | assert.NoError(t, err) 276 | equal := assert.EqualValues(t, expected, got) 277 | if !equal { 278 | t.Errorf("not equal") 279 | fmt.Println(string(expected)) 280 | fmt.Println(string(got)) 281 | } 282 | }) 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /pkg/log/log.go: -------------------------------------------------------------------------------- 1 | // Package log provides a way to log messages to the screen with the appropriate coloring and formatting for readability 2 | package log 3 | 4 | import ( 5 | "fmt" 6 | "sort" 7 | "strings" 8 | 9 | "github.com/fatih/color" 10 | ) 11 | 12 | var ( 13 | ReasonSkip = *color.New(color.FgYellow) 14 | ReasonError = *color.New(color.FgRed) 15 | ReasonRemoveTriggered = *color.New(color.FgGreen) 16 | ReasonWaitPending = *color.New(color.FgBlue) 17 | ReasonWaitDependency = *color.New(color.FgCyan) 18 | ReasonSuccess = *color.New(color.FgGreen) 19 | ReasonHold = *color.New(color.FgMagenta) 20 | ) 21 | 22 | var ( 23 | ColorRegion = *color.New(color.Bold) 24 | ColorResourceType = *color.New() 25 | ColorResourceID = *color.New(color.Bold) 26 | ColorResourceProperties = *color.New(color.Italic) 27 | ) 28 | 29 | // Sorted -- Format the resource properties in sorted order ready for printing. 30 | // This ensures that multiple runs of aws-nuke produce stable output so 31 | // that they can be compared with each other. 32 | func Sorted(m map[string]string) string { 33 | keys := make([]string, 0, len(m)) 34 | for k := range m { 35 | if strings.HasPrefix(k, "_") { 36 | continue 37 | } 38 | 39 | keys = append(keys, k) 40 | } 41 | sort.Strings(keys) 42 | sorted := make([]string, 0, len(m)) 43 | for k := range keys { 44 | sorted = append(sorted, fmt.Sprintf("%s: %q", keys[k], m[keys[k]])) 45 | } 46 | return fmt.Sprintf("[%s]", strings.Join(sorted, ", ")) 47 | } 48 | -------------------------------------------------------------------------------- /pkg/log/log_test.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import "testing" 4 | 5 | func TestSorted(t *testing.T) { 6 | t.Parallel() 7 | 8 | cases := []struct { 9 | name string 10 | input map[string]string 11 | want string 12 | }{ 13 | { 14 | name: "empty", 15 | input: map[string]string{}, 16 | want: "[]", 17 | }, 18 | { 19 | name: "one", 20 | input: map[string]string{ 21 | "one": "1", 22 | }, 23 | want: `[one: "1"]`, 24 | }, 25 | { 26 | name: "two", 27 | input: map[string]string{ 28 | "one": "1", 29 | "two": "2", 30 | }, 31 | want: `[one: "1", two: "2"]`, 32 | }, 33 | { 34 | name: "out-of-order", 35 | input: map[string]string{ 36 | "two": "2", 37 | "one": "1", 38 | }, 39 | want: `[one: "1", two: "2"]`, 40 | }, 41 | { 42 | name: "underscore", 43 | input: map[string]string{ 44 | "_one": "1", 45 | }, 46 | want: "[]", 47 | }, 48 | } 49 | 50 | for _, tc := range cases { 51 | t.Run(tc.name, func(t *testing.T) { 52 | got := Sorted(tc.input) 53 | if got != tc.want { 54 | t.Errorf("sorted(%v) = %v; want %v", tc.input, got, tc.want) 55 | } 56 | }) 57 | } 58 | } 59 | 60 | func TestLog(t *testing.T) {} 61 | -------------------------------------------------------------------------------- /pkg/nuke/nuke_filter_test.go: -------------------------------------------------------------------------------- 1 | package nuke 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "io" 7 | "testing" 8 | "time" 9 | 10 | "github.com/sirupsen/logrus" 11 | "github.com/stretchr/testify/assert" 12 | 13 | "github.com/ekristen/libnuke/pkg/filter" 14 | "github.com/ekristen/libnuke/pkg/queue" 15 | "github.com/ekristen/libnuke/pkg/registry" 16 | "github.com/ekristen/libnuke/pkg/resource" 17 | "github.com/ekristen/libnuke/pkg/scanner" 18 | "github.com/ekristen/libnuke/pkg/types" 19 | ) 20 | 21 | func init() { 22 | if flag.Lookup("test.v") != nil { 23 | logrus.SetOutput(io.Discard) 24 | } 25 | logrus.SetLevel(logrus.TraceLevel) 26 | logrus.SetReportCaller(true) 27 | } 28 | 29 | func Test_NukeFiltersBad(t *testing.T) { 30 | filters := filter.Filters{ 31 | TestResourceType: []filter.Filter{ 32 | { 33 | Type: filter.Exact, 34 | }, 35 | }, 36 | } 37 | 38 | n := New(testParameters, filters, nil) 39 | n.SetLogger(logrus.WithField("test", true)) 40 | n.SetRunSleep(time.Millisecond * 5) 41 | 42 | err := n.Run(context.TODO()) 43 | assert.Error(t, err) 44 | assert.Contains(t, err.Error(), "testResourceType: has an invalid filter") 45 | } 46 | 47 | func Test_NukeFiltersMatch(t *testing.T) { 48 | registry.ClearRegistry() 49 | registry.Register(TestResourceRegistration2) 50 | 51 | filters := filter.Filters{ 52 | TestResourceType2: []filter.Filter{ 53 | { 54 | Type: filter.Exact, 55 | Property: "test", 56 | Value: "testing", 57 | }, 58 | }, 59 | } 60 | 61 | n := New(testParameters, filters, nil) 62 | n.SetLogger(logrus.WithField("test", true)) 63 | n.SetRunSleep(time.Millisecond * 5) 64 | 65 | opts := TestOpts{ 66 | SessionOne: "testing", 67 | SecondResource: true, 68 | } 69 | 70 | newScanner, err := scanner.New(&scanner.Config{ 71 | Owner: "Owner", 72 | ResourceTypes: []string{TestResourceType2}, 73 | Opts: opts, 74 | }) 75 | assert.NoError(t, err) 76 | 77 | sErr := n.RegisterScanner(testScope, newScanner) 78 | assert.NoError(t, sErr) 79 | 80 | err = n.Scan(context.TODO()) 81 | assert.NoError(t, err) 82 | assert.Equal(t, 1, n.Queue.Total()) 83 | assert.Equal(t, 1, n.Queue.Count(queue.ItemStateFiltered)) 84 | } 85 | 86 | func Test_NukeFiltersMatchGroups_Match(t *testing.T) { 87 | registry.ClearRegistry() 88 | registry.Register(TestResourceRegistration2) 89 | 90 | filters := filter.Filters{ 91 | TestResourceType2: []filter.Filter{ 92 | { 93 | Type: filter.Exact, 94 | Property: "test", 95 | Value: "testing", 96 | Group: "group1", 97 | }, 98 | { 99 | Type: filter.Exact, 100 | Property: "test2", 101 | Value: "testing", 102 | Group: "group2", 103 | }, 104 | }, 105 | } 106 | 107 | n := New(testParametersGroups, filters, nil) 108 | n.SetLogger(logrus.WithField("test", true)) 109 | n.SetRunSleep(time.Millisecond * 5) 110 | 111 | opts := TestOpts{ 112 | SessionOne: "testing", 113 | SecondResource: true, 114 | } 115 | newScanner, err := scanner.New(&scanner.Config{ 116 | Owner: "Owner", 117 | ResourceTypes: []string{TestResourceType2}, 118 | Opts: opts, 119 | }) 120 | assert.NoError(t, err) 121 | 122 | sErr := n.RegisterScanner(testScope, newScanner) 123 | assert.NoError(t, sErr) 124 | 125 | err = n.Scan(context.TODO()) 126 | assert.NoError(t, err) 127 | assert.Equal(t, 1, n.Queue.Total()) 128 | assert.Equal(t, 1, n.Queue.Count(queue.ItemStateFiltered)) 129 | } 130 | 131 | func Test_NukeFiltersMatchGroups_NoMatch(t *testing.T) { 132 | registry.ClearRegistry() 133 | registry.Register(TestResourceRegistration2) 134 | 135 | filters := filter.Filters{ 136 | TestResourceType2: []filter.Filter{ 137 | { 138 | Type: filter.Exact, 139 | Property: "test", 140 | Value: "testing", 141 | Group: "group1", 142 | }, 143 | { 144 | Type: filter.Exact, 145 | Property: "test2", 146 | Value: "testing!!!", 147 | Group: "group2", 148 | }, 149 | }, 150 | } 151 | 152 | n := New(testParametersGroups, filters, nil) 153 | n.SetLogger(logrus.WithField("test", true)) 154 | n.SetRunSleep(time.Millisecond * 5) 155 | 156 | opts := TestOpts{ 157 | SessionOne: "testing", 158 | SecondResource: true, 159 | } 160 | newScanner, err := scanner.New(&scanner.Config{ 161 | Owner: "Owner", 162 | ResourceTypes: []string{TestResourceType2}, 163 | Opts: opts, 164 | }) 165 | assert.NoError(t, err) 166 | 167 | sErr := n.RegisterScanner(testScope, newScanner) 168 | assert.NoError(t, sErr) 169 | 170 | err = n.Scan(context.TODO()) 171 | assert.NoError(t, err) 172 | assert.Equal(t, 1, n.Queue.Total()) 173 | assert.Equal(t, 0, n.Queue.Count(queue.ItemStateFiltered)) 174 | } 175 | 176 | func Test_NukeFiltersMatchGroups_NoMatch_WithError(t *testing.T) { 177 | registry.ClearRegistry() 178 | registry.Register(TestResourceRegistration2) 179 | 180 | filters := filter.Filters{ 181 | TestResourceType2: []filter.Filter{ 182 | { 183 | Type: filter.Exact, 184 | Property: "test", 185 | Value: "testing", 186 | Group: "group1", 187 | }, 188 | { 189 | Type: filter.Regex, 190 | Property: "test2", 191 | Value: "^(testing$", 192 | Group: "group2", 193 | }, 194 | }, 195 | } 196 | 197 | n := New(testParametersGroups, filters, nil) 198 | n.SetLogger(logrus.WithField("test", true)) 199 | n.SetRunSleep(time.Millisecond * 5) 200 | 201 | opts := TestOpts{ 202 | SessionOne: "testing", 203 | SecondResource: true, 204 | } 205 | newScanner, err := scanner.New(&scanner.Config{ 206 | Owner: "Owner", 207 | ResourceTypes: []string{TestResourceType2}, 208 | Opts: opts, 209 | }) 210 | assert.NoError(t, err) 211 | 212 | sErr := n.RegisterScanner(testScope, newScanner) 213 | assert.NoError(t, sErr) 214 | 215 | err = n.Scan(context.TODO()) 216 | assert.Error(t, err) 217 | assert.Equal(t, "error parsing regexp: missing closing ): `^(testing$`", err.Error()) 218 | } 219 | 220 | func Test_NukeFiltersMatchInverted(t *testing.T) { 221 | registry.ClearRegistry() 222 | registry.Register(TestResourceRegistration2) 223 | 224 | filters := filter.Filters{ 225 | TestResourceType2: []filter.Filter{ 226 | { 227 | Type: filter.Exact, 228 | Property: "test", 229 | Value: "testing", 230 | Invert: true, 231 | }, 232 | }, 233 | } 234 | 235 | n := New(testParameters, filters, nil) 236 | n.SetLogger(logrus.WithField("test", true)) 237 | n.SetRunSleep(time.Millisecond * 5) 238 | 239 | opts := TestOpts{ 240 | SessionOne: "testing", 241 | SecondResource: true, 242 | } 243 | newScanner, err := scanner.New(&scanner.Config{ 244 | Owner: "Owner", 245 | ResourceTypes: []string{TestResourceType2}, 246 | Opts: opts, 247 | }) 248 | assert.NoError(t, err) 249 | 250 | sErr := n.RegisterScanner(testScope, newScanner) 251 | assert.NoError(t, sErr) 252 | 253 | err = n.Scan(context.TODO()) 254 | assert.NoError(t, err) 255 | assert.Equal(t, 1, n.Queue.Total()) 256 | assert.Equal(t, 0, n.Queue.Count(queue.ItemStateFiltered)) 257 | } 258 | 259 | func Test_Nuke_Filters_NoMatch(t *testing.T) { 260 | registry.ClearRegistry() 261 | registry.Register(TestResourceRegistration2) 262 | 263 | filters := filter.Filters{ 264 | TestResourceType: []filter.Filter{ 265 | { 266 | Type: filter.Exact, 267 | Property: "test", 268 | Value: "testing", 269 | }, 270 | }, 271 | } 272 | 273 | n := New(testParameters, filters, nil) 274 | n.SetLogger(logrus.WithField("test", true)) 275 | n.SetRunSleep(time.Millisecond * 5) 276 | 277 | opts := TestOpts{ 278 | SessionOne: "testing", 279 | SecondResource: true, 280 | } 281 | newScanner, err := scanner.New(&scanner.Config{ 282 | Owner: "Owner", 283 | ResourceTypes: []string{TestResourceType2}, 284 | Opts: opts, 285 | }) 286 | assert.NoError(t, err) 287 | 288 | sErr := n.RegisterScanner(testScope, newScanner) 289 | assert.NoError(t, sErr) 290 | 291 | err = n.Scan(context.TODO()) 292 | assert.NoError(t, err) 293 | assert.Equal(t, 1, n.Queue.Total()) 294 | assert.Equal(t, 0, n.Queue.Count(queue.ItemStateFiltered)) 295 | } 296 | 297 | func Test_Nuke_Filters_ErrorCustomProps(t *testing.T) { 298 | registry.ClearRegistry() 299 | registry.Register(TestResourceRegistration) 300 | 301 | filters := filter.Filters{ 302 | TestResourceType: []filter.Filter{ 303 | { 304 | Type: filter.Exact, 305 | Property: "Name", 306 | Value: TestResourceType, 307 | }, 308 | }, 309 | } 310 | 311 | n := New(testParameters, filters, nil) 312 | n.SetLogger(logrus.WithField("test", true)) 313 | n.SetRunSleep(time.Millisecond * 5) 314 | 315 | opts := TestOpts{ 316 | SessionOne: "testing", 317 | } 318 | newScanner, err := scanner.New(&scanner.Config{ 319 | Owner: "Owner", 320 | ResourceTypes: []string{TestResourceType}, 321 | Opts: opts, 322 | }) 323 | assert.NoError(t, err) 324 | 325 | sErr := n.RegisterScanner(testScope, newScanner) 326 | assert.NoError(t, sErr) 327 | 328 | err = n.Scan(context.TODO()) 329 | assert.NoError(t, err) 330 | 331 | assert.Equal(t, 1, n.Queue.Total()) 332 | assert.Equal(t, 1, n.Queue.Count(queue.ItemStateNew)) 333 | assert.Equal(t, 0, n.Queue.Count(queue.ItemStateFiltered)) 334 | } 335 | 336 | type TestResourceFilter struct { 337 | Props types.Properties 338 | } 339 | 340 | func (r *TestResourceFilter) Properties() types.Properties { 341 | return r.Props 342 | } 343 | 344 | func (r *TestResourceFilter) Remove(_ context.Context) error { 345 | return nil 346 | } 347 | 348 | func Test_Nuke_Filters_Extra(t *testing.T) { 349 | filters := filter.Filters{ 350 | TestResourceType2: []filter.Filter{ 351 | { 352 | Type: filter.Glob, 353 | Property: "tag:aws:cloudformation:stack-name", 354 | Value: "StackSet-AWSControlTowerBP*", 355 | }, 356 | }, 357 | } 358 | 359 | n := New(testParameters, filters, nil) 360 | n.SetLogger(logrus.WithField("test", true)) 361 | n.SetRunSleep(time.Millisecond * 5) 362 | 363 | i := &queue.Item{ 364 | Resource: &TestResourceFilter{ 365 | Props: types.Properties{ 366 | "tag:aws:cloudformation:stack-name": "StackSet-AWSControlTowerBP-VPC-ACCOUNT-FACTORY-V1-c0bdd9c9-c338-4831-9c47-62443622c081", 367 | }, 368 | }, 369 | Type: TestResourceType2, 370 | } 371 | 372 | err := n.Filter(i) 373 | assert.NoError(t, err) 374 | assert.Equal(t, i.Reason, "filtered by config") 375 | } 376 | 377 | func Test_Nuke_Filters_Filtered(t *testing.T) { 378 | cases := []struct { 379 | name string 380 | error bool 381 | resources []resource.Resource 382 | filters filter.Filters 383 | }{ 384 | { 385 | name: "exact", 386 | resources: []resource.Resource{ 387 | &TestResourceFilter{ 388 | Props: types.Properties{ 389 | "tag:aws:cloudformation:stack-name": "StackSet-AWSControlTowerBP-VPC-ACCOUNT-FACTORY-V1-c0bdd9c9-c338-4831-9c47-62443622c081", 390 | }, 391 | }, 392 | }, 393 | filters: filter.Filters{ 394 | TestResourceType2: []filter.Filter{ 395 | { 396 | Type: filter.Exact, 397 | Property: "tag:aws:cloudformation:stack-name", 398 | Value: "StackSet-AWSControlTowerBP-VPC-ACCOUNT-FACTORY-V1-c0bdd9c9-c338-4831-9c47-62443622c081", 399 | }, 400 | }, 401 | }, 402 | }, 403 | { 404 | name: "global", 405 | resources: []resource.Resource{ 406 | &TestResourceFilter{ 407 | Props: types.Properties{ 408 | "tag:aws:cloudformation:stack-name": "StackSet-AWSControlTowerBP-VPC-ACCOUNT-FACTORY-V1-c0bdd9c9-c338-4831-9c47-62443622c081", 409 | }, 410 | }, 411 | }, 412 | filters: filter.Filters{ 413 | filter.Global: []filter.Filter{ 414 | { 415 | Type: filter.Exact, 416 | Property: "tag:aws:cloudformation:stack-name", 417 | Value: "StackSet-AWSControlTowerBP-VPC-ACCOUNT-FACTORY-V1-c0bdd9c9-c338-4831-9c47-62443622c081", 418 | }, 419 | }, 420 | TestResourceType2: []filter.Filter{ 421 | { 422 | Type: filter.Exact, 423 | Property: "tag:testing", 424 | Value: "test", 425 | }, 426 | }, 427 | }, 428 | }, 429 | { 430 | name: "invalid", 431 | error: true, 432 | resources: []resource.Resource{ 433 | &TestResourceFilter{ 434 | Props: types.Properties{ 435 | "tag:aws:cloudformation:stack-name": "StackSet-AWSControlTowerBP-VPC-ACCOUNT-FACTORY-V1-c0bdd9c9-c338-4831-9c47-62443622c081", 436 | }, 437 | }, 438 | }, 439 | filters: filter.Filters{ 440 | TestResourceType2: []filter.Filter{ 441 | { 442 | Type: "invalid-filter", 443 | Property: "tag:aws:cloudformation:stack-name", 444 | Value: "StackSet-AWSControlTowerBP-VPC-ACCOUNT-FACTORY-V1-c0bdd9c9-c338-4831-9c47-62443622c081", 445 | }, 446 | }, 447 | }, 448 | }, 449 | } 450 | 451 | for _, tc := range cases { 452 | t.Run(tc.name, func(t *testing.T) { 453 | n := New(testParameters, tc.filters, nil) 454 | n.SetLogger(logrus.WithField("test", true)) 455 | n.SetRunSleep(time.Millisecond * 5) 456 | 457 | for _, r := range tc.resources { 458 | i := &queue.Item{ 459 | Resource: r, 460 | Type: TestResourceType2, 461 | } 462 | 463 | err := n.Filter(i) 464 | if tc.error == true { 465 | assert.Error(t, err) 466 | continue 467 | } 468 | 469 | assert.NoError(t, err) 470 | assert.Equal(t, i.Reason, "filtered by config") 471 | } 472 | }) 473 | } 474 | } 475 | -------------------------------------------------------------------------------- /pkg/nuke/nuke_test.go: -------------------------------------------------------------------------------- 1 | package nuke 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/sirupsen/logrus" 11 | "github.com/stretchr/testify/assert" 12 | 13 | liberrors "github.com/ekristen/libnuke/pkg/errors" 14 | 15 | "github.com/ekristen/libnuke/pkg/queue" 16 | "github.com/ekristen/libnuke/pkg/registry" 17 | "github.com/ekristen/libnuke/pkg/resource" 18 | "github.com/ekristen/libnuke/pkg/scanner" 19 | "github.com/ekristen/libnuke/pkg/settings" 20 | ) 21 | 22 | var testParameters = &Parameters{ 23 | Force: true, 24 | ForceSleep: 3, 25 | Quiet: false, 26 | NoDryRun: false, 27 | } 28 | 29 | var testParametersRemove = &Parameters{ 30 | Force: true, 31 | ForceSleep: 3, 32 | Quiet: true, 33 | NoDryRun: true, 34 | } 35 | 36 | var testParametersGroups = &Parameters{ 37 | Force: true, 38 | ForceSleep: 3, 39 | Quiet: false, 40 | NoDryRun: false, 41 | UseFilterGroups: true, 42 | } 43 | 44 | const testScope registry.Scope = "test" 45 | 46 | func Test_Nuke_Version(t *testing.T) { 47 | logger := logrus.WithField("test", true) 48 | 49 | assertions := 0 50 | 51 | logrus.AddHook(&TestGlobalHook{ 52 | t: t, 53 | tf: func(t *testing.T, e *logrus.Entry) { 54 | if !strings.HasSuffix(e.Caller.File, "pkg/nuke/nuke.go") { 55 | return 56 | } 57 | 58 | if e.Caller.Line == 351 { 59 | assert.Equal(t, "1.0.0-test", e.Message) 60 | assertions++ 61 | } 62 | }, 63 | }) 64 | defer logrus.StandardLogger().ReplaceHooks(nil) 65 | 66 | n := New(testParameters, nil, nil) 67 | n.SetLogger(logger) 68 | n.SetRunSleep(time.Millisecond * 5) 69 | 70 | n.RegisterVersion("1.0.0-test") 71 | 72 | // Call the Version function 73 | n.Version() 74 | 75 | assert.Equal(t, 1, assertions) 76 | } 77 | 78 | func TestNuke_Settings(t *testing.T) { 79 | n := New(testParameters, nil, nil) 80 | n.SetLogger(logrus.WithField("test", true)) 81 | n.SetRunSleep(time.Millisecond * 5) 82 | n.Settings = &settings.Settings{ 83 | "TestResource": &settings.Setting{ 84 | "DisableDeletionProtection": true, 85 | }, 86 | } 87 | 88 | testResourceSettings := n.Settings.Get("TestResource") 89 | assert.NotNil(t, testResourceSettings) 90 | assert.Equal(t, true, testResourceSettings.Get("DisableDeletionProtection")) 91 | } 92 | 93 | func Test_Nuke_Validators_Default(t *testing.T) { 94 | n := New(testParameters, nil, nil) 95 | n.SetLogger(logrus.WithField("test", true)) 96 | n.SetRunSleep(time.Millisecond * 5) 97 | 98 | err := n.Validate() 99 | assert.NoError(t, err) 100 | } 101 | 102 | func Test_Nuke_Validators_Register1(t *testing.T) { 103 | n := New(testParameters, nil, nil) 104 | n.SetLogger(logrus.WithField("test", true)) 105 | n.SetRunSleep(time.Millisecond * 5) 106 | 107 | n.RegisterValidateHandler(func() error { 108 | return fmt.Errorf("validator called") 109 | }) 110 | 111 | err := n.Validate() 112 | assert.Error(t, err) 113 | assert.Equal(t, "validator called", err.Error()) 114 | } 115 | 116 | func Test_Nuke_Validators_Register2(t *testing.T) { 117 | n := New(testParameters, nil, nil) 118 | n.SetLogger(logrus.WithField("test", true)) 119 | n.SetRunSleep(time.Millisecond * 5) 120 | 121 | n.RegisterValidateHandler(func() error { 122 | return fmt.Errorf("validator called") 123 | }) 124 | 125 | n.RegisterValidateHandler(func() error { 126 | return fmt.Errorf("second validator called") 127 | }) 128 | 129 | assert.Len(t, n.ValidateHandlers, 2) 130 | } 131 | 132 | func Test_Nuke_Validators_Error(t *testing.T) { 133 | p := &Parameters{ 134 | Force: true, 135 | ForceSleep: 1, 136 | Quiet: true, 137 | } 138 | n := New(p, nil, nil) 139 | n.SetLogger(logrus.WithField("test", true)) 140 | n.SetRunSleep(time.Millisecond * 5) 141 | 142 | err := n.Validate() 143 | assert.Error(t, err) 144 | assert.Equal(t, "value for --force-sleep cannot be less than 3 seconds. This is for your own protection", err.Error()) 145 | } 146 | 147 | func Test_Nuke_ResourceTypes(t *testing.T) { 148 | n := New(testParameters, nil, nil) 149 | n.SetLogger(logrus.WithField("test", true)) 150 | n.SetRunSleep(time.Millisecond * 5) 151 | 152 | n.RegisterResourceTypes(testScope, "TestResource") 153 | 154 | assert.Len(t, n.ResourceTypes[testScope], 1) 155 | } 156 | 157 | func Test_Nuke_Scanners(t *testing.T) { 158 | n := New(testParameters, nil, nil) 159 | n.SetLogger(logrus.WithField("test", true)) 160 | n.SetRunSleep(time.Millisecond * 5) 161 | 162 | opts := struct { 163 | name string 164 | }{ 165 | name: "test", 166 | } 167 | 168 | s, err := scanner.New(&scanner.Config{ 169 | Owner: "test", 170 | ResourceTypes: []string{"TestResource"}, 171 | Opts: opts, 172 | }) 173 | assert.NoError(t, err) 174 | 175 | err = n.RegisterScanner(testScope, s) 176 | assert.NoError(t, err) 177 | 178 | assert.Len(t, n.Scanners[testScope], 1) 179 | } 180 | 181 | func Test_Nuke_Scanners_Duplicate(t *testing.T) { 182 | n := New(testParameters, nil, nil) 183 | n.SetLogger(logrus.WithField("test", true)) 184 | n.SetRunSleep(time.Millisecond * 5) 185 | 186 | opts := struct { 187 | name string 188 | }{ 189 | name: "test", 190 | } 191 | 192 | s, err := scanner.New(&scanner.Config{ 193 | Owner: "test", 194 | ResourceTypes: []string{"TestResource"}, 195 | Opts: opts, 196 | }) 197 | assert.NoError(t, err) 198 | 199 | err = n.RegisterScanner(testScope, s) 200 | assert.NoError(t, err) 201 | 202 | sErr := n.RegisterScanner(testScope, s) 203 | assert.Error(t, sErr) 204 | 205 | assert.Len(t, n.Scanners[testScope], 1) 206 | } 207 | 208 | func TestNuke_RegisterMultipleScanners(t *testing.T) { 209 | n := New(testParameters, nil, nil) 210 | n.SetLogger(logrus.WithField("test", true)) 211 | n.SetRunSleep(time.Millisecond * 5) 212 | 213 | opts := struct { 214 | name string 215 | }{ 216 | name: "test", 217 | } 218 | 219 | var mutateOpts = func(o interface{}, resourceType string) interface{} { 220 | return o 221 | } 222 | 223 | s, err := scanner.New(&scanner.Config{ 224 | Owner: "test", 225 | ResourceTypes: []string{"TestResource"}, 226 | Opts: opts, 227 | }) 228 | assert.NoError(t, err) 229 | assert.NoError(t, s.RegisterMutateOptsFunc(mutateOpts)) 230 | 231 | s2, err := scanner.New(&scanner.Config{ 232 | Owner: "test2", 233 | ResourceTypes: []string{"TestResource"}, 234 | Opts: opts, 235 | }) 236 | assert.NoError(t, err) 237 | assert.NoError(t, s2.RegisterMutateOptsFunc(mutateOpts)) 238 | 239 | assert.NoError(t, n.RegisterScanner(testScope, s)) 240 | assert.NoError(t, n.RegisterScanner(testScope, s2)) 241 | assert.Len(t, n.Scanners[testScope], 2) 242 | } 243 | 244 | func Test_Nuke_RegisterPrompt(t *testing.T) { 245 | n := New(testParameters, nil, nil) 246 | n.SetLogger(logrus.WithField("test", true)) 247 | n.SetRunSleep(time.Millisecond * 5) 248 | 249 | n.RegisterPrompt(func() error { 250 | return fmt.Errorf("prompt error") 251 | }) 252 | 253 | err := n.Prompt() 254 | assert.Error(t, err) 255 | assert.Equal(t, "prompt error", err.Error()) 256 | } 257 | 258 | // ------------------------------------------------------ 259 | 260 | func Test_Nuke_Scan(t *testing.T) { 261 | registry.ClearRegistry() 262 | registry.Register(TestResourceRegistration) 263 | registry.Register(®istry.Registration{ 264 | Name: TestResourceType2, 265 | Scope: "account", 266 | Lister: TestResourceLister{ 267 | Filtered: true, 268 | }, 269 | }) 270 | 271 | n := New(testParameters, nil, nil) 272 | n.SetLogger(logrus.WithField("test", true)) 273 | n.SetRunSleep(time.Millisecond * 5) 274 | 275 | opts := TestOpts{ 276 | SessionOne: "testing", 277 | } 278 | newScanner, err := scanner.New(&scanner.Config{ 279 | Owner: "Owner", 280 | ResourceTypes: []string{TestResourceType, TestResourceType2}, 281 | Opts: opts, 282 | }) 283 | assert.NoError(t, err) 284 | 285 | sErr := n.RegisterScanner(testScope, newScanner) 286 | assert.NoError(t, sErr) 287 | 288 | err = n.Scan(context.TODO()) 289 | assert.NoError(t, err) 290 | 291 | assert.Equal(t, 2, n.Queue.Total()) 292 | assert.Equal(t, 1, n.Queue.Count(queue.ItemStateNew)) 293 | assert.Equal(t, 1, n.Queue.Count(queue.ItemStateFiltered)) 294 | } 295 | 296 | // --------------------------------------------------------------------- 297 | 298 | type TestResource3 struct { 299 | Error bool 300 | } 301 | 302 | func (r *TestResource3) Remove(_ context.Context) error { 303 | if r.Error { 304 | return fmt.Errorf("remove error") 305 | } 306 | return nil 307 | } 308 | 309 | func Test_Nuke_HandleRemove(t *testing.T) { 310 | n := New(testParameters, nil, nil) 311 | n.SetLogger(logrus.WithField("test", true)) 312 | n.SetRunSleep(time.Millisecond * 5) 313 | 314 | i := &queue.Item{ 315 | Resource: &TestResource3{}, 316 | State: queue.ItemStateNew, 317 | } 318 | 319 | n.HandleRemove(context.TODO(), i) 320 | assert.Equal(t, queue.ItemStatePending, i.State) 321 | } 322 | 323 | func Test_Nuke_HandleRemoveError(t *testing.T) { 324 | n := New(testParameters, nil, nil) 325 | n.SetLogger(logrus.WithField("test", true)) 326 | n.SetRunSleep(time.Millisecond * 5) 327 | 328 | i := &queue.Item{ 329 | Resource: &TestResource3{ 330 | Error: true, 331 | }, 332 | State: queue.ItemStateNew, 333 | } 334 | 335 | n.HandleRemove(context.TODO(), i) 336 | assert.Equal(t, queue.ItemStateFailed, i.State) 337 | } 338 | 339 | // ------------------------------------------------------------ 340 | 341 | func Test_Nuke_Run(t *testing.T) { 342 | registry.ClearRegistry() 343 | registry.Register(TestResourceRegistration) 344 | 345 | p := &Parameters{ 346 | Force: true, 347 | ForceSleep: 3, 348 | Quiet: true, 349 | NoDryRun: true, 350 | } 351 | 352 | n := New(p, nil, nil) 353 | n.SetLogger(logrus.WithField("test", true)) 354 | n.SetRunSleep(time.Millisecond * 5) 355 | 356 | opts := TestOpts{ 357 | SessionOne: "testing", 358 | } 359 | newScanner, err := scanner.New(&scanner.Config{ 360 | Owner: "Owner", 361 | ResourceTypes: []string{TestResourceType}, 362 | Opts: opts, 363 | }) 364 | assert.NoError(t, err) 365 | 366 | sErr := n.RegisterScanner(testScope, newScanner) 367 | assert.NoError(t, sErr) 368 | 369 | err = n.Run(context.TODO()) 370 | assert.NoError(t, err) 371 | } 372 | 373 | func Test_Nuke_Run_Error(t *testing.T) { 374 | registry.ClearRegistry() 375 | registry.Register(®istry.Registration{ 376 | Name: TestResourceType2, 377 | Scope: "account", 378 | Lister: TestResourceLister{ 379 | RemoveError: true, 380 | }, 381 | }) 382 | 383 | p := &Parameters{ 384 | Force: true, 385 | ForceSleep: 3, 386 | Quiet: true, 387 | NoDryRun: true, 388 | } 389 | n := New(p, nil, nil) 390 | n.SetLogger(logrus.WithField("test", true)) 391 | n.SetRunSleep(time.Millisecond * 5) 392 | 393 | opts := TestOpts{ 394 | SessionOne: "testing", 395 | } 396 | newScanner, err := scanner.New(&scanner.Config{ 397 | Owner: "Owner", 398 | ResourceTypes: []string{TestResourceType2}, 399 | Opts: opts, 400 | }) 401 | assert.NoError(t, err) 402 | 403 | sErr := n.RegisterScanner(testScope, newScanner) 404 | assert.NoError(t, sErr) 405 | 406 | err = n.Run(context.TODO()) 407 | assert.NoError(t, err) 408 | } 409 | 410 | // ------------------------------------------------------------ 411 | 412 | var TestResource4Resources []resource.Resource 413 | 414 | type TestResource4 struct { 415 | id string 416 | parentID string 417 | } 418 | 419 | func (r *TestResource4) Remove(_ context.Context) error { 420 | if r.parentID != "" { 421 | parentFound := false 422 | 423 | for _, o := range TestResource4Resources { 424 | id := o.(resource.LegacyStringer).String() 425 | if id == r.parentID { 426 | parentFound = true 427 | } 428 | } 429 | 430 | if parentFound { 431 | return liberrors.ErrHoldResource("waiting for parent to be removed") 432 | } 433 | } 434 | 435 | return nil 436 | } 437 | 438 | func (r *TestResource4) String() string { 439 | return r.id 440 | } 441 | 442 | type TestResource4Lister struct { 443 | attempts int 444 | } 445 | 446 | func (l *TestResource4Lister) List(_ context.Context, _ interface{}) ([]resource.Resource, error) { 447 | l.attempts++ 448 | 449 | if l.attempts == 1 { 450 | for x := 0; x < 5; x++ { 451 | if x == 0 { 452 | TestResource4Resources = append(TestResource4Resources, &TestResource4{ 453 | id: fmt.Sprintf("resource-%d", x), 454 | parentID: "", 455 | }) 456 | } else { 457 | TestResource4Resources = append(TestResource4Resources, &TestResource4{ 458 | id: fmt.Sprintf("resource-%d", x), 459 | parentID: "resource-0", 460 | }) 461 | } 462 | } 463 | } else if l.attempts > 3 { 464 | TestResource4Resources = TestResource4Resources[1:] 465 | } 466 | 467 | return TestResource4Resources, nil 468 | } 469 | 470 | func Test_Nuke_Run_ItemStateHold(t *testing.T) { 471 | n := New(testParametersRemove, nil, nil) 472 | n.SetLogger(logrus.WithField("test", true)) 473 | n.SetRunSleep(time.Millisecond * 5) 474 | 475 | registry.ClearRegistry() 476 | registry.Register(®istry.Registration{ 477 | Name: "TestResource4", 478 | Scope: testScope, 479 | Lister: &TestResource4Lister{}, 480 | }) 481 | 482 | s, err := scanner.New(&scanner.Config{ 483 | Owner: "Owner", 484 | ResourceTypes: []string{"TestResource4"}, 485 | Opts: nil, 486 | }) 487 | assert.NoError(t, err) 488 | 489 | scannerErr := n.RegisterScanner(testScope, s) 490 | assert.NoError(t, scannerErr) 491 | 492 | runErr := n.Run(context.TODO()) 493 | assert.NoError(t, runErr) 494 | assert.Equal(t, 5, n.Queue.Count(queue.ItemStateFinished)) 495 | } 496 | -------------------------------------------------------------------------------- /pkg/nuke/testsuite_test.go: -------------------------------------------------------------------------------- 1 | package nuke 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/sirupsen/logrus" 9 | "github.com/stretchr/testify/assert" 10 | 11 | "github.com/ekristen/libnuke/pkg/errors" 12 | "github.com/ekristen/libnuke/pkg/registry" 13 | "github.com/ekristen/libnuke/pkg/resource" 14 | "github.com/ekristen/libnuke/pkg/settings" 15 | "github.com/ekristen/libnuke/pkg/types" 16 | ) 17 | 18 | type TestGlobalHook struct { 19 | t *testing.T 20 | tf func(t *testing.T, e *logrus.Entry) 21 | } 22 | 23 | func (h *TestGlobalHook) Levels() []logrus.Level { 24 | return logrus.AllLevels 25 | } 26 | 27 | func (h *TestGlobalHook) Fire(e *logrus.Entry) error { 28 | if h.tf != nil { 29 | h.tf(h.t, e) 30 | } 31 | 32 | return nil 33 | } 34 | 35 | var ( 36 | TestResourceType = "testResourceType" 37 | TestResourceRegistration = ®istry.Registration{ 38 | Name: TestResourceType, 39 | Scope: "account", 40 | Lister: &TestResourceLister{}, 41 | } 42 | 43 | TestResourceType2 = "testResourceType2" 44 | TestResourceRegistration2 = ®istry.Registration{ 45 | Name: TestResourceType2, 46 | Scope: "account", 47 | Lister: &TestResourceLister{}, 48 | DependsOn: []string{ 49 | TestResourceType, 50 | }, 51 | } 52 | ) 53 | 54 | type TestOpts struct { 55 | Test *testing.T 56 | SessionOne string 57 | SessionTwo string 58 | ThrowError bool 59 | ThrowSkipError bool 60 | ThrowEndpointError bool 61 | Panic bool 62 | SecondResource bool 63 | } 64 | 65 | type TestResourceLister struct { 66 | Filtered bool 67 | RemoveError bool 68 | } 69 | 70 | func (l TestResourceLister) List(_ context.Context, o interface{}) ([]resource.Resource, error) { 71 | opts := o.(TestOpts) 72 | 73 | if opts.ThrowError { 74 | return nil, assert.AnError 75 | } 76 | 77 | if opts.ThrowSkipError { 78 | return nil, errors.ErrSkipRequest("skip request error for testing") 79 | } 80 | 81 | if opts.ThrowEndpointError { 82 | return nil, errors.ErrUnknownEndpoint("unknown endpoint error for testing") 83 | } 84 | 85 | if opts.Panic { 86 | panic(fmt.Errorf("panic error for testing")) 87 | } 88 | 89 | if opts.SecondResource { 90 | return []resource.Resource{ 91 | &TestResource2{ 92 | Filtered: l.Filtered, 93 | RemoveError: l.RemoveError, 94 | }, 95 | }, nil 96 | } 97 | 98 | return []resource.Resource{ 99 | &TestResource{ 100 | Filtered: l.Filtered, 101 | RemoveError: l.RemoveError, 102 | }, 103 | }, nil 104 | } 105 | 106 | // -------------------------------------------------------------------------- 107 | 108 | type TestResource struct { 109 | Filtered bool 110 | RemoveError bool 111 | } 112 | 113 | func (r *TestResource) Filter() error { 114 | if r.Filtered { 115 | return fmt.Errorf("cannot remove default") 116 | } 117 | 118 | return nil 119 | } 120 | 121 | func (r *TestResource) Remove(_ context.Context) error { 122 | if r.RemoveError { 123 | return fmt.Errorf("remove error") 124 | } 125 | return nil 126 | } 127 | 128 | func (r *TestResource) Settings(setting *settings.Setting) { 129 | 130 | } 131 | 132 | type TestResource2 struct { 133 | Filtered bool 134 | RemoveError bool 135 | } 136 | 137 | func (r *TestResource2) Filter() error { 138 | if r.Filtered { 139 | return fmt.Errorf("cannot remove default") 140 | } 141 | 142 | return nil 143 | } 144 | 145 | func (r *TestResource2) Remove(_ context.Context) error { 146 | if r.RemoveError { 147 | return fmt.Errorf("remove error") 148 | } 149 | return nil 150 | } 151 | 152 | func (r *TestResource2) Properties() types.Properties { 153 | props := types.NewProperties() 154 | props.Set("test", "testing") 155 | props.Set("test2", "testing") 156 | return props 157 | } 158 | -------------------------------------------------------------------------------- /pkg/queue/item.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sort" 7 | "strings" 8 | 9 | "github.com/sirupsen/logrus" 10 | 11 | "github.com/ekristen/libnuke/pkg/log" 12 | "github.com/ekristen/libnuke/pkg/registry" 13 | "github.com/ekristen/libnuke/pkg/resource" 14 | ) 15 | 16 | type ItemState int 17 | 18 | func (s ItemState) String() string { 19 | switch s { 20 | case ItemStateNew: 21 | return "new" 22 | case ItemStateNewDependency: 23 | return "new-dependency" 24 | case ItemStateHold: 25 | return "hold" 26 | case ItemStatePending: 27 | return "pending" 28 | case ItemStatePendingDependency: 29 | return "pending-dependency" 30 | case ItemStateWaiting: 31 | return "waiting" 32 | case ItemStateFailed: 33 | return "failed" 34 | case ItemStateFiltered: 35 | return "filtered" 36 | case ItemStateFinished: 37 | return "finished" 38 | } 39 | return "unknown" 40 | } 41 | 42 | const ( 43 | ItemStateNew ItemState = iota 44 | ItemStateNewDependency 45 | ItemStateHold 46 | ItemStatePending 47 | ItemStatePendingDependency 48 | ItemStateWaiting 49 | ItemStateFailed 50 | ItemStateFiltered 51 | ItemStateFinished 52 | ) 53 | 54 | type IItem interface { 55 | resource.Resource 56 | Print() 57 | List() ([]resource.Resource, error) 58 | GetProperty(key string) (string, error) 59 | Equals(resource.Resource) bool 60 | GetState() ItemState 61 | } 62 | 63 | // Item is used to represent a specific resource, and it's current ItemState in the Queue 64 | type Item struct { 65 | Resource resource.Resource 66 | State ItemState 67 | Reason string 68 | Type string 69 | Owner string // region/subscription 70 | Opts interface{} 71 | Logger *logrus.Logger 72 | } 73 | 74 | // GetState returns the current State of the Item 75 | func (i *Item) GetState() ItemState { 76 | return i.State 77 | } 78 | 79 | // GetReason returns the current Reason for the Item which is usually coupled with an error 80 | func (i *Item) GetReason() string { 81 | return i.Reason 82 | } 83 | 84 | // List calls the List method for the lister for the Type that belongs to the Item which returns 85 | // a list of resources or an error. This primarily is used for the HandleWait function. 86 | func (i *Item) List(ctx context.Context, opts interface{}) ([]resource.Resource, error) { 87 | return registry.GetLister(i.Type).List(ctx, opts) 88 | } 89 | 90 | // GetProperty retrieves the string value of a property on the Item's Resource if it exists. 91 | func (i *Item) GetProperty(key string) (string, error) { 92 | if key == "" { 93 | stringer, ok := i.Resource.(resource.LegacyStringer) 94 | if !ok { 95 | return "", fmt.Errorf("%T does not support legacy IDs", i.Resource) 96 | } 97 | return stringer.String(), nil 98 | } 99 | 100 | getter, ok := i.Resource.(resource.PropertyGetter) 101 | if !ok { 102 | return "", fmt.Errorf("%T does not support custom properties", i.Resource) 103 | } 104 | 105 | return getter.Properties().Get(key), nil 106 | } 107 | 108 | // Equals checks if the current Item is identical to the argument Item in the Queue. 109 | func (i *Item) Equals(o resource.Resource) bool { 110 | iType := fmt.Sprintf("%T", i.Resource) 111 | oType := fmt.Sprintf("%T", o) 112 | if iType != oType { 113 | return false 114 | } 115 | 116 | iGetter, iOK := i.Resource.(resource.PropertyGetter) 117 | oGetter, oOK := o.(resource.PropertyGetter) 118 | if iOK != oOK { 119 | return false 120 | } 121 | if iOK && oOK { 122 | return iGetter.Properties().Equals(oGetter.Properties()) 123 | } 124 | 125 | iStringer, iOK := i.Resource.(resource.LegacyStringer) 126 | oStringer, oOK := o.(resource.LegacyStringer) 127 | if iOK != oOK { 128 | return false 129 | } 130 | if iOK && oOK { 131 | return iStringer.String() == oStringer.String() 132 | } 133 | 134 | return false 135 | } 136 | 137 | // Print displays the current status of an Item based on it's State 138 | func (i *Item) Print() { 139 | if i.Logger == nil { 140 | i.Logger = logrus.StandardLogger() 141 | i.Logger.SetFormatter(&log.CustomFormatter{}) 142 | } 143 | 144 | itemLog := i.Logger.WithFields(logrus.Fields{ 145 | "owner": i.Owner, 146 | "type": i.Type, 147 | "state": i.State.String(), 148 | "state_code": int(i.State), 149 | }) 150 | 151 | rString, ok := i.Resource.(resource.LegacyStringer) 152 | if ok { 153 | itemLog = itemLog.WithField("name", rString.String()) 154 | } 155 | 156 | rProp, ok := i.Resource.(resource.PropertyGetter) 157 | if ok { 158 | itemLog = itemLog.WithFields(sorted(rProp.Properties())) 159 | } 160 | 161 | switch i.State { 162 | case ItemStateNew: 163 | itemLog.Info("would remove") 164 | case ItemStateNewDependency: 165 | itemLog.Info("would remove after dependencies") 166 | case ItemStateHold: 167 | itemLog.Info("waiting for parent removal") 168 | case ItemStatePending: 169 | itemLog.Info("triggered remove") 170 | case ItemStatePendingDependency: 171 | itemLog.Infof("waiting on dependencies (%s)", i.Reason) 172 | case ItemStateWaiting: 173 | itemLog.Info("waiting for removal") 174 | case ItemStateFailed: 175 | itemLog.Info("failed") 176 | case ItemStateFiltered: 177 | itemLog.Infof("filtered: %s", i.Reason) 178 | case ItemStateFinished: 179 | itemLog.Info("removed") 180 | } 181 | } 182 | 183 | // sorted -- Format the resource properties in sorted order ready for printing. 184 | // This ensures that multiple runs of aws-nuke produce stable output so 185 | // that they can be compared with each other. 186 | func sorted(m map[string]string) logrus.Fields { 187 | out := logrus.Fields{} 188 | keys := make([]string, 0, len(m)) 189 | for k := range m { 190 | keys = append(keys, k) 191 | } 192 | sort.Strings(keys) 193 | for k := range keys { 194 | if strings.HasPrefix(keys[k], "_") { 195 | continue 196 | } 197 | 198 | out[fmt.Sprintf("prop:%s", keys[k])] = m[keys[k]] 199 | } 200 | return out 201 | } 202 | -------------------------------------------------------------------------------- /pkg/queue/item_test.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/sirupsen/logrus" 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/ekristen/libnuke/pkg/registry" 13 | "github.com/ekristen/libnuke/pkg/resource" 14 | "github.com/ekristen/libnuke/pkg/types" 15 | ) 16 | 17 | func init() { 18 | logrus.SetLevel(logrus.TraceLevel) 19 | } 20 | 21 | type TestItemResource struct { 22 | id string 23 | } 24 | 25 | func (r *TestItemResource) Properties() types.Properties { 26 | props := types.NewProperties() 27 | props.Set("name", r.id) 28 | return props 29 | } 30 | func (r *TestItemResource) Remove(_ context.Context) error { 31 | return nil 32 | } 33 | func (r *TestItemResource) String() string { 34 | return r.id 35 | } 36 | 37 | type TestItemResource2 struct{} 38 | 39 | func (r TestItemResource2) Remove(_ context.Context) error { 40 | return nil 41 | } 42 | 43 | type TestItemResourceLister struct{} 44 | 45 | func (l *TestItemResourceLister) List(_ context.Context, o interface{}) ([]resource.Resource, error) { 46 | return []resource.Resource{&TestItemResource{id: "test"}}, nil 47 | } 48 | 49 | var testItem = Item{ 50 | Resource: &TestItemResource{id: "test"}, 51 | State: ItemStateNew, 52 | Reason: "brand new", 53 | Type: "TestResource", 54 | } 55 | 56 | var testItem2 = Item{ 57 | Resource: &TestItemResource2{}, 58 | State: ItemStateNew, 59 | Reason: "brand new", 60 | } 61 | 62 | func Test_Item(t *testing.T) { 63 | i := testItem 64 | 65 | assert.Equal(t, ItemStateNew, i.GetState()) 66 | assert.Equal(t, "brand new", i.GetReason()) 67 | 68 | propVal, err := i.GetProperty("name") 69 | assert.NoError(t, err) 70 | assert.Equal(t, "test", propVal) 71 | 72 | assert.True(t, i.Equals(i.Resource)) 73 | assert.False(t, i.Equals(testItem2.Resource)) 74 | } 75 | 76 | func Test_ItemList(t *testing.T) { 77 | registry.ClearRegistry() 78 | registry.Register(®istry.Registration{ 79 | Name: "TestResource", 80 | Lister: &TestItemResourceLister{}, 81 | }) 82 | 83 | i := testItem 84 | list, err := i.List(context.TODO(), nil) 85 | assert.NoError(t, err) 86 | assert.Equal(t, 1, len(list)) 87 | } 88 | 89 | func Test_Item_LegacyStringer(t *testing.T) { 90 | i := testItem 91 | val, err := i.GetProperty("") 92 | assert.NoError(t, err) 93 | assert.Equal(t, "test", val) 94 | } 95 | 96 | func Test_Item_LegacyStringer_NoSupport(t *testing.T) { 97 | i := testItem2 98 | _, err := i.GetProperty("") 99 | assert.Error(t, err) 100 | assert.Equal(t, "*queue.TestItemResource2 does not support legacy IDs", err.Error()) 101 | } 102 | 103 | func Test_Item_Properties_NoSupport(t *testing.T) { 104 | i := testItem2 105 | _, err := i.GetProperty("test-prop") 106 | assert.Error(t, err) 107 | assert.Equal(t, "*queue.TestItemResource2 does not support custom properties", err.Error()) 108 | } 109 | 110 | func Test_ItemState_Stringer(t *testing.T) { 111 | cases := []struct { 112 | state ItemState 113 | want string 114 | }{ 115 | { 116 | state: ItemStateNew, 117 | want: "new", 118 | }, 119 | { 120 | state: ItemStatePending, 121 | want: "pending", 122 | }, 123 | { 124 | state: ItemStateNewDependency, 125 | want: "new-dependency", 126 | }, 127 | { 128 | state: ItemStatePendingDependency, 129 | want: "pending-dependency", 130 | }, 131 | { 132 | state: ItemStateWaiting, 133 | want: "waiting", 134 | }, 135 | { 136 | state: ItemStateFailed, 137 | want: "failed", 138 | }, 139 | { 140 | state: ItemStateFiltered, 141 | want: "filtered", 142 | }, 143 | { 144 | state: ItemStateFinished, 145 | want: "finished", 146 | }, 147 | { 148 | state: ItemStateHold, 149 | want: "hold", 150 | }, 151 | { 152 | state: ItemState(999), 153 | want: "unknown", 154 | }, 155 | } 156 | 157 | for _, tc := range cases { 158 | assert.Equal(t, tc.want, tc.state.String()) 159 | } 160 | } 161 | 162 | func Test_ItemPrint(t *testing.T) { 163 | cases := []struct { 164 | name string 165 | state ItemState 166 | want string 167 | }{ 168 | { 169 | name: "new", 170 | state: ItemStateNew, 171 | want: "would remove", 172 | }, 173 | { 174 | name: "pending", 175 | state: ItemStatePending, 176 | want: "triggered remove", 177 | }, 178 | { 179 | name: "new-dependency", 180 | state: ItemStateNewDependency, 181 | want: "would remove after dependencies", 182 | }, 183 | { 184 | name: "pending-dependency", 185 | state: ItemStatePendingDependency, 186 | want: "waiting on dependencies (brand new)", 187 | }, 188 | { 189 | name: "waiting", 190 | state: ItemStateWaiting, 191 | want: "waiting", 192 | }, 193 | { 194 | name: "failed", 195 | state: ItemStateFailed, 196 | want: "failed", 197 | }, 198 | { 199 | name: "filtered", 200 | state: ItemStateFiltered, 201 | want: "varies", 202 | }, 203 | { 204 | name: "finished", 205 | state: ItemStateFinished, 206 | want: "finished", 207 | }, 208 | { 209 | name: "hold", 210 | state: ItemStateHold, 211 | want: "waiting for parent removal", 212 | }, 213 | } 214 | 215 | for i, tc := range cases { 216 | t.Run(tc.name, func(t *testing.T) { 217 | i := &Item{ 218 | Resource: &TestItemResource{ 219 | id: fmt.Sprintf("test%d", i), 220 | }, 221 | State: tc.state, 222 | Type: "TestResource", 223 | Owner: "us-east-1", 224 | } 225 | i.Print() 226 | }) 227 | } 228 | } 229 | 230 | // ------------------------------------------------------------------------ 231 | 232 | type TestItemResourceProperties struct{} 233 | 234 | func (r *TestItemResourceProperties) Remove(_ context.Context) error { 235 | return nil 236 | } 237 | func (r *TestItemResourceProperties) Properties() types.Properties { 238 | return types.NewProperties().Set("test", "testing") 239 | } 240 | 241 | func Test_ItemEqualProperties(t *testing.T) { 242 | i := &Item{ 243 | Resource: &TestItemResourceProperties{}, 244 | State: ItemStateNew, 245 | Reason: "brand new", 246 | Type: "TestResource", 247 | } 248 | 249 | assert.True(t, i.Equals(i.Resource)) 250 | } 251 | 252 | // ------------------------------------------------------------------------ 253 | 254 | type TestItemResourceStringer struct{} 255 | 256 | func (r *TestItemResourceStringer) Remove(_ context.Context) error { 257 | return nil 258 | } 259 | func (r *TestItemResourceStringer) String() string { 260 | return "test" 261 | } 262 | 263 | func Test_ItemEqualStringer(t *testing.T) { 264 | i := &Item{ 265 | Resource: &TestItemResourceStringer{}, 266 | State: ItemStateNew, 267 | Reason: "brand new", 268 | Type: "TestResource", 269 | } 270 | 271 | ni := &Item{ 272 | Resource: &TestItemResourceNothing{}, 273 | State: ItemStateNew, 274 | Reason: "brand new", 275 | Type: "TestResource", 276 | } 277 | 278 | assert.True(t, i.Equals(i.Resource)) 279 | assert.False(t, i.Equals(ni.Resource)) 280 | } 281 | 282 | // ------------------------------------------------------------------------ 283 | 284 | type TestItemResourceNothing struct{} 285 | 286 | func (r *TestItemResourceNothing) Remove(_ context.Context) error { 287 | return nil 288 | } 289 | 290 | func Test_ItemEqualNothing(t *testing.T) { 291 | i := &Item{ 292 | Resource: &TestItemResourceNothing{}, 293 | State: ItemStateNew, 294 | Reason: "brand new", 295 | Type: "TestResource", 296 | } 297 | 298 | assert.False(t, i.Equals(i.Resource)) 299 | } 300 | 301 | // ------------------------------------------------------------------------ 302 | 303 | type TestItemResourceRevenant struct { 304 | Props types.Properties 305 | } 306 | 307 | func (r *TestItemResourceRevenant) Remove(_ context.Context) error { 308 | return nil 309 | } 310 | func (r *TestItemResourceRevenant) Properties() types.Properties { 311 | return r.Props 312 | } 313 | 314 | func Test_ItemRevenant(t *testing.T) { 315 | i := &Item{ 316 | Resource: &TestItemResourceRevenant{ 317 | Props: types.NewProperties().Set("CreatedAt", time.Now().UTC()), 318 | }, 319 | State: ItemStateNew, 320 | Reason: "brand new", 321 | Type: "TestResource", 322 | } 323 | 324 | j := &Item{ 325 | Resource: &TestItemResourceRevenant{ 326 | Props: types.NewProperties().Set("CreatedAt", time.Now().UTC().Add(4*time.Minute)), 327 | }, 328 | State: ItemStateNew, 329 | Reason: "brand new", 330 | Type: "TestResource", 331 | } 332 | 333 | assert.False(t, j.Equals(i.Resource)) 334 | } 335 | 336 | // ------------------------------------------------------------------------ 337 | 338 | type TestGlobalHook struct { 339 | t *testing.T 340 | tf func(t *testing.T, e *logrus.Entry) 341 | } 342 | 343 | func (h *TestGlobalHook) Levels() []logrus.Level { 344 | return logrus.AllLevels 345 | } 346 | 347 | func (h *TestGlobalHook) Fire(e *logrus.Entry) error { 348 | if h.tf != nil { 349 | h.tf(h.t, e) 350 | } 351 | 352 | return nil 353 | } 354 | 355 | type TestItemResourceLogger struct{} 356 | 357 | func (r *TestItemResourceLogger) String() string { 358 | return "test" 359 | } 360 | 361 | func (r *TestItemResourceLogger) Remove(_ context.Context) error { 362 | return nil 363 | } 364 | 365 | func Test_ItemLoggerDefault(t *testing.T) { 366 | i := &Item{ 367 | Resource: &TestItemResourceLogger{}, 368 | State: ItemStateNew, 369 | Reason: "brand new", 370 | Type: "TestResource", 371 | Owner: "us-east-1", 372 | } 373 | 374 | i.Print() 375 | } 376 | 377 | func Test_ItemLoggerCustom(t *testing.T) { 378 | logger := logrus.New() 379 | defer func() { 380 | logger.ReplaceHooks(make(logrus.LevelHooks)) 381 | }() 382 | 383 | hookCalled := false 384 | logger.AddHook(&TestGlobalHook{ 385 | t: t, 386 | tf: func(t *testing.T, e *logrus.Entry) { 387 | hookCalled = true 388 | assert.Equal(t, "us-east-1", e.Data["owner"]) 389 | assert.Equal(t, "TestResource", e.Data["type"]) 390 | assert.Equal(t, 0, e.Data["state_code"]) 391 | assert.Equal(t, "would remove", e.Message) 392 | }, 393 | }) 394 | 395 | i := &Item{ 396 | Resource: &TestItemResourceLogger{}, 397 | State: ItemStateNew, 398 | Reason: "brand new", 399 | Type: "TestResource", 400 | Owner: "us-east-1", 401 | Logger: logger, 402 | } 403 | 404 | i.Print() 405 | 406 | assert.True(t, hookCalled) 407 | } 408 | -------------------------------------------------------------------------------- /pkg/queue/queue.go: -------------------------------------------------------------------------------- 1 | // Package queue provides a simple list mechanism with some helper functions to determine current counts based on 2 | // resource type or state. 3 | package queue 4 | 5 | type IQueue interface { 6 | Total() int 7 | Count(states ...ItemState) int 8 | } 9 | 10 | // Queue provides a very simple interface for queuing Item for processing 11 | type Queue struct { 12 | Items []*Item 13 | } 14 | 15 | // New creates a new Queue 16 | func New() *Queue { 17 | return &Queue{ 18 | Items: []*Item{}, 19 | } 20 | } 21 | 22 | // GetItems returns all the items currently in the Queue 23 | func (q Queue) GetItems() []*Item { 24 | return q.Items 25 | } 26 | 27 | // Total returns the total number of items in the Queue 28 | func (q Queue) Total() int { 29 | return len(q.Items) 30 | } 31 | 32 | // Count returns the total number of items in a specific ItemState from the Queue 33 | func (q Queue) Count(states ...ItemState) int { 34 | count := 0 35 | for _, i := range q.Items { 36 | for _, state := range states { 37 | if i.GetState() == state { 38 | count++ 39 | break 40 | } 41 | } 42 | } 43 | return count 44 | } 45 | 46 | // CountByType returns the total number of items that match a ResourceType and specific ItemState from the Queue 47 | func (q Queue) CountByType(resourceType string, states ...ItemState) int { 48 | count := 0 49 | for _, i := range q.Items { 50 | if i.Type == resourceType { 51 | for _, state := range states { 52 | if i.GetState() == state { 53 | count++ 54 | break 55 | } 56 | } 57 | } 58 | } 59 | return count 60 | } 61 | -------------------------------------------------------------------------------- /pkg/queue/queue_test.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test_Queue(t *testing.T) { 10 | q := New() 11 | 12 | items := []*Item{ 13 | { 14 | Type: "type1", 15 | State: ItemStateNew, 16 | }, 17 | { 18 | Type: "type2", 19 | State: ItemStateFailed, 20 | }, 21 | } 22 | 23 | q.Items = make([]*Item, 0) 24 | q.Items = append(q.Items, items...) 25 | 26 | assert.Len(t, q.GetItems(), 2) 27 | assert.Equal(t, 2, q.Total()) 28 | assert.Equal(t, 1, q.Count(ItemStateNew)) 29 | assert.Equal(t, 1, q.Count(ItemStateFailed)) 30 | assert.Equal(t, 0, q.Count(ItemStateFiltered)) 31 | 32 | assert.Equal(t, 1, q.CountByType("type1", ItemStateNew)) 33 | assert.Equal(t, 0, q.CountByType("type1", ItemStatePending)) 34 | 35 | assert.Equal(t, 1, q.CountByType("type2", ItemStateFailed)) 36 | assert.Equal(t, 0, q.CountByType("type2", ItemStatePending)) 37 | } 38 | -------------------------------------------------------------------------------- /pkg/registry/registry.go: -------------------------------------------------------------------------------- 1 | // Package registry provides a way to register resources and their listers and obtain them after the fact. The registry 2 | // is currently deeply embedded with the other packages and how they access specific aspects of a resource. 3 | package registry 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "sort" 9 | 10 | "github.com/mb0/glob" 11 | "github.com/sirupsen/logrus" 12 | "github.com/stevenle/topsort" 13 | 14 | "github.com/ekristen/libnuke/pkg/resource" 15 | ) 16 | 17 | // Scope is a string in which resources are grouped against, this is meant for upstream tools to define their 18 | // own scopes if the DefaultScope is not sufficient. For example Azure has multiple levels of scoping for resources, 19 | // whereas AWS does not. 20 | type Scope string 21 | 22 | // DefaultScope is the default scope which resources are registered against if no other scope is provided 23 | const DefaultScope Scope = "default" 24 | 25 | // Registrations is a map of resource type to registration 26 | type Registrations map[string]*Registration 27 | 28 | // Listers is a map of resource type to lister 29 | type Listers map[string]Lister 30 | 31 | // resourceListers is a global variable of all registered resource listers 32 | var resourceListers = make(Listers) 33 | 34 | // registrations is a global variable of all registrations for resources 35 | var registrations = make(Registrations) 36 | 37 | // alternatives is a global variable of all alternative resource types 38 | var alternatives = make(map[string]string) 39 | 40 | // graph is a global variable of the graph of resource dependencies 41 | var graph = topsort.NewGraph() 42 | 43 | // Registration is a struct that contains the information needed to register a resource lister 44 | type Registration struct { 45 | // Name is the name of the resource type 46 | Name string 47 | 48 | // Scope is the scope of the resource type, if left empty it'll default to DefaultScope. It's simple a string 49 | // designed to group resource types together. The primary use case is for Azure, it needs resources scoped to 50 | // different levels, whereas AWS has simply Account level. 51 | Scope Scope 52 | 53 | // Resource is the resource type that the lister is going to list. This is a struct that implements the Resource 54 | // interface. This is primarily used to generate documentation by parsing the structs properties and generating 55 | // markdown documentation. 56 | // Note: it is a interface{} because we are going to inspect it, we do not need to actually call any methods on it. 57 | Resource interface{} 58 | 59 | // Lister is the lister for the resource type, it is a struct with a method called List that returns a slice 60 | // of resources. The lister is responsible for filtering out any resources that should not be deleted because they 61 | // are ineligible for deletion. For example, built in resources that cannot be deleted. 62 | Lister Lister 63 | 64 | // Settings allows for resources to define settings that can be configured by the calling tool to change the 65 | // behavior of the resource. For example, EC2 and RDS Instances have Deletion Protection, this allows the resource 66 | // to define a setting that can be configured by the calling tool to enable/disable deletion protection. 67 | Settings []string 68 | 69 | // DependsOn is a list of resource types that this resource type depends on. This is used to determine 70 | // the order in which resources are deleted. For example, a VPC depends on subnets, so we want to delete 71 | // the subnets before we delete the VPC. This is a Resource level dependency, not a resource instance (i.e. all 72 | // subnets must be deleted before any VPC can be deleted, not just the subnets that are associated with the VPC). 73 | DependsOn []string 74 | 75 | // DeprecatedAliases is a list of deprecated aliases for the resource type, usually misspellings or old names 76 | // that have been replaced with a new resource type. This is used to map the old resource type to the new 77 | // resource type. This is used in the config package to resolve any deprecated resource types and provide 78 | // notifications to the user. 79 | DeprecatedAliases []string 80 | 81 | // AlternativeResource is used to determine if there's an alternative resource type to use. The primary use case 82 | // for this is AWS Cloud Control API, where we want to use the Cloud Control API resource type instead of the 83 | // default resource type. However, any resource that uses a different API to manage the same resource can use this 84 | // field. 85 | AlternativeResource string 86 | } 87 | 88 | // Lister is an interface that represents a resource that can be listed 89 | type Lister interface { 90 | List(ctx context.Context, opts interface{}) ([]resource.Resource, error) 91 | } 92 | 93 | // ListerWithClose is an interface that represents a lister that can be closed. Use Case: GCP clients need to be closed. 94 | type ListerWithClose interface { 95 | Close() 96 | } 97 | 98 | // RegisterOption is a function that can be used to manipulate the lister for a given resource type at 99 | // registration time 100 | type RegisterOption func(name string, lister Lister) 101 | 102 | // Register registers a resource lister with the registry 103 | func Register(r *Registration) { 104 | if r.Scope == "" { 105 | r.Scope = DefaultScope 106 | } 107 | 108 | if _, exists := registrations[r.Name]; exists { 109 | panic(fmt.Sprintf("a resource with the name %s already exists", r.Name)) 110 | } 111 | 112 | if r.AlternativeResource != "" { 113 | if _, exists := alternatives[r.AlternativeResource]; exists { 114 | panic(fmt.Sprintf("an alternative resource mapping for %s already exists", r.AlternativeResource)) 115 | } 116 | 117 | alternatives[r.AlternativeResource] = r.Name 118 | } 119 | 120 | logrus.WithField("name", r.Name).Trace("registered resource lister") 121 | 122 | registrations[r.Name] = r 123 | resourceListers[r.Name] = r.Lister 124 | 125 | graph.AddNode(r.Name) 126 | if len(r.DependsOn) == 0 { 127 | // Note: AddEdge will never through an error 128 | _ = graph.AddEdge("root", r.Name) 129 | } 130 | for _, dep := range r.DependsOn { 131 | // Note: AddEdge will never through an error 132 | _ = graph.AddEdge(dep, r.Name) 133 | } 134 | } 135 | 136 | // GetRegistrations returns all registrations 137 | func GetRegistrations() Registrations { 138 | return registrations 139 | } 140 | 141 | // ClearRegistry clears the registry of all registrations 142 | // Designed for use for unit tests, not for production code. Only use if you know what you are doing. 143 | func ClearRegistry() { 144 | registrations = make(Registrations) 145 | resourceListers = make(Listers) 146 | graph = topsort.NewGraph() 147 | } 148 | 149 | func GetListers() (listers Listers) { 150 | listers = make(Listers) 151 | for name, r := range registrations { 152 | listers[name] = r.Lister 153 | } 154 | return listers 155 | } 156 | 157 | // GetRegistration returns the registration for the given resource type 158 | func GetRegistration(name string) *Registration { 159 | return registrations[name] 160 | } 161 | 162 | // GetListersV2 returns a map of listers based on graph top sort order 163 | func GetListersV2() (listers Listers) { 164 | listers = make(Listers) 165 | sorted, err := graph.TopSort("root") 166 | if err != nil { 167 | panic(err) 168 | } 169 | for _, name := range sorted { 170 | if name == "root" { 171 | continue 172 | } 173 | r := registrations[name] 174 | listers[name] = r.Lister 175 | } 176 | 177 | return listers 178 | } 179 | 180 | // GetListersForScope returns a map of listers for a particular scope that they've been grouped by 181 | func GetListersForScope(scope Scope) (listers Listers) { 182 | listers = make(Listers) 183 | for name, r := range registrations { 184 | if r.Scope == scope { 185 | listers[name] = r.Lister 186 | } 187 | } 188 | return listers 189 | } 190 | 191 | // GetNames provides a string slice of all lister names that have been registered 192 | func GetNames() []string { 193 | var names []string 194 | for resourceType := range GetListersV2() { 195 | names = append(names, resourceType) 196 | } 197 | 198 | return names 199 | } 200 | 201 | // ExpandNames takes a list of names and expands them based on a wildcard and returns all the names that match 202 | func ExpandNames(names []string) []string { 203 | var expandedNames []string 204 | registeredNames := GetNames() 205 | 206 | for _, name := range names { 207 | matches, _ := glob.GlobStrings(registeredNames, name) 208 | if matches == nil { 209 | logrus. 210 | WithField("handler", "ExpandNames"). 211 | WithField("name", name). 212 | Trace("no expansion for name") 213 | 214 | expandedNames = append(expandedNames, name) 215 | continue 216 | } 217 | 218 | logrus. 219 | WithField("handler", "ExpandNames"). 220 | WithField("name", name). 221 | WithField("matches", matches). 222 | Trace("expanded name") 223 | 224 | expandedNames = append(expandedNames, matches...) 225 | } 226 | 227 | // Ensure predictable order 228 | sort.Strings(expandedNames) 229 | 230 | return expandedNames 231 | } 232 | 233 | // GetNamesForScope provides a string slice of all listers for a particular scope 234 | func GetNamesForScope(scope Scope) []string { 235 | var names []string 236 | for resourceType := range GetListersForScope(scope) { 237 | names = append(names, resourceType) 238 | } 239 | return names 240 | } 241 | 242 | // GetLister gets a specific lister by name 243 | func GetLister(name string) Lister { 244 | return resourceListers[name] 245 | } 246 | 247 | // GetAlternativeResourceTypeMapping returns a map of resource types to their alternative resource type. The primary 248 | // use case is used to map resource types to their alternative AWS Cloud Control resource type. This allows each 249 | // resource to define what it's alternative resource type is instead of trying to track them in a single place. 250 | func GetAlternativeResourceTypeMapping() map[string]string { 251 | mapping := make(map[string]string) 252 | for _, r := range registrations { 253 | if r.AlternativeResource != "" { 254 | mapping[r.AlternativeResource] = r.Name 255 | } 256 | } 257 | return mapping 258 | } 259 | 260 | // GetDeprecatedResourceTypeMapping returns a map of deprecated resource types to their replacement. The primary use 261 | // case is used to map deprecated resource types to their replacement in the config package. This allows us to 262 | // provide notifications to the user that they are using a deprecated resource type and should update their config. 263 | // This allow allows each resource to define it's DeprecatedAliases instead of trying to track them in a single place. 264 | func GetDeprecatedResourceTypeMapping() map[string]string { 265 | mapping := make(map[string]string) 266 | for _, r := range registrations { 267 | for _, alias := range r.DeprecatedAliases { 268 | mapping[alias] = r.Name 269 | } 270 | } 271 | return mapping 272 | } 273 | -------------------------------------------------------------------------------- /pkg/registry/registry_test.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/ekristen/libnuke/pkg/resource" 10 | ) 11 | 12 | type TestLister struct{} 13 | 14 | func (l TestLister) List(_ context.Context, o interface{}) ([]resource.Resource, error) { 15 | return nil, nil 16 | } 17 | func (l TestLister) Close() {} 18 | 19 | func Test_RegisterNoScope(t *testing.T) { 20 | ClearRegistry() 21 | 22 | Register(&Registration{ 23 | Name: "test", 24 | Lister: TestLister{}, 25 | }) 26 | 27 | assert.Len(t, registrations, 1) 28 | 29 | reg := GetRegistration("test") 30 | assert.Equal(t, DefaultScope, reg.Scope) 31 | assert.Equal(t, "test", reg.Name) 32 | } 33 | 34 | func Test_RegisterResources(t *testing.T) { 35 | ClearRegistry() 36 | 37 | Register(&Registration{ 38 | Name: "test", 39 | Scope: "test", 40 | Lister: TestLister{}, 41 | DeprecatedAliases: []string{ 42 | "test2", 43 | }, 44 | }) 45 | 46 | if len(registrations) != 1 { 47 | t.Errorf("expected 1 registration, got %d", len(registrations)) 48 | } 49 | 50 | listers := GetListers() 51 | assert.Len(t, listers, 1) 52 | 53 | scopeListers := GetListersForScope("test") 54 | assert.Len(t, scopeListers, 1) 55 | 56 | names := GetNamesForScope("test") 57 | assert.Len(t, names, 1) 58 | 59 | deprecatedMapping := GetDeprecatedResourceTypeMapping() 60 | assert.Len(t, deprecatedMapping, 1) 61 | assert.Equal(t, "test", deprecatedMapping["test2"]) 62 | } 63 | 64 | func Test_RegisterResourcesDouble(t *testing.T) { 65 | defer func() { 66 | if r := recover(); r == nil { 67 | t.Errorf("The code did not panic") 68 | } 69 | }() 70 | 71 | Register(&Registration{ 72 | Name: "test", 73 | Scope: "test", 74 | Lister: TestLister{}, 75 | }) 76 | 77 | Register(&Registration{ 78 | Name: "test", 79 | Scope: "test", 80 | Lister: TestLister{}, 81 | }) 82 | } 83 | 84 | func Test_Sorted(t *testing.T) { 85 | Register(&Registration{ 86 | Name: "Second", 87 | Scope: "test", 88 | Lister: TestLister{}, 89 | DependsOn: []string{ 90 | "First", 91 | }, 92 | }) 93 | 94 | Register(&Registration{ 95 | Name: "First", 96 | Scope: "test", 97 | Lister: TestLister{}, 98 | }) 99 | 100 | Register(&Registration{ 101 | Name: "Third", 102 | Scope: "test", 103 | Lister: TestLister{}, 104 | DependsOn: []string{ 105 | "Second", 106 | }, 107 | }) 108 | 109 | Register(&Registration{ 110 | Name: "Fourth", 111 | Scope: "test", 112 | Lister: TestLister{}, 113 | DependsOn: []string{ 114 | "First", 115 | }, 116 | }) 117 | 118 | names := GetNames() 119 | assert.Len(t, names, 5) 120 | } 121 | 122 | func Test_RegisterResourcesWithAlternative(t *testing.T) { 123 | ClearRegistry() 124 | 125 | Register(&Registration{ 126 | Name: "test", 127 | Scope: "test", 128 | Lister: TestLister{}, 129 | AlternativeResource: "test2", 130 | }) 131 | 132 | Register(&Registration{ 133 | Name: "test2", 134 | Scope: "test", 135 | Lister: TestLister{}, 136 | }) 137 | 138 | assert.Len(t, registrations, 2) 139 | 140 | deprecatedMapping := GetAlternativeResourceTypeMapping() 141 | assert.Len(t, deprecatedMapping, 1) 142 | assert.Equal(t, "test", deprecatedMapping["test2"]) 143 | } 144 | 145 | func Test_RegisterResourcesWithDuplicateAlternative(t *testing.T) { 146 | ClearRegistry() 147 | 148 | // Note: this is necessary to test the panic when using coverage and multiple tests 149 | defer func() { 150 | if r := recover(); r != nil { 151 | t.Logf("Recovered from panic: %v", r) 152 | } 153 | }() 154 | 155 | Register(&Registration{ 156 | Name: "test", 157 | Scope: "test", 158 | Lister: TestLister{}, 159 | AlternativeResource: "test2", 160 | }) 161 | 162 | assert.PanicsWithValue(t, `an alternative resource mapping for test2 already exists`, func() { 163 | Register(&Registration{ 164 | Name: "test2", 165 | Scope: "test", 166 | Lister: TestLister{}, 167 | AlternativeResource: "test2", 168 | }) 169 | }) 170 | } 171 | 172 | func Test_GetRegistrations(t *testing.T) { 173 | ClearRegistry() 174 | 175 | Register(&Registration{ 176 | Name: "test", 177 | Scope: "test", 178 | Lister: TestLister{}, 179 | }) 180 | 181 | regs := GetRegistrations() 182 | assert.Len(t, regs, 1) 183 | } 184 | 185 | func Test_GetLister(t *testing.T) { 186 | ClearRegistry() 187 | 188 | Register(&Registration{ 189 | Name: "test", 190 | Scope: "test", 191 | Lister: TestLister{}, 192 | }) 193 | 194 | l := GetLister("test") 195 | assert.NotNil(t, l) 196 | } 197 | 198 | func Test_GetListersV2_CircularDependency(t *testing.T) { 199 | ClearRegistry() 200 | 201 | // Note: this is necessary to test the panic when using coverage and multiple tests 202 | defer func() { 203 | if r := recover(); r != nil { 204 | t.Logf("Recovered from panic: %v", r) 205 | } 206 | }() 207 | 208 | Register(&Registration{ 209 | Name: "A", 210 | Scope: "test", 211 | Lister: TestLister{}, 212 | DependsOn: []string{ 213 | "B", 214 | "C", 215 | }, 216 | }) 217 | 218 | Register(&Registration{ 219 | Name: "B", 220 | Scope: "test", 221 | Lister: TestLister{}, 222 | DependsOn: []string{ 223 | "A", 224 | "C", 225 | }, 226 | }) 227 | 228 | Register(&Registration{ 229 | Name: "C", 230 | Scope: "test", 231 | Lister: TestLister{}, 232 | }) 233 | 234 | assert.Panics(t, func() { 235 | GetListersV2() 236 | }) 237 | } 238 | 239 | func TestExpandNames(t *testing.T) { 240 | ClearRegistry() 241 | 242 | // Note: this is necessary to test the panic when using coverage and multiple tests 243 | defer func() { 244 | if r := recover(); r != nil { 245 | t.Logf("Recovered from panic: %v", r) 246 | } 247 | }() 248 | 249 | rs := []string{"OpsOne", "OpsTwo", "TestingOne", "TestingTwo"} 250 | 251 | for _, r := range rs { 252 | Register(&Registration{ 253 | Name: r, 254 | Scope: "test", 255 | Lister: TestLister{}, 256 | }) 257 | } 258 | 259 | cases := []struct { 260 | name string 261 | expected []string 262 | }{ 263 | { 264 | name: "Ops*", 265 | expected: []string{"OpsOne", "OpsTwo"}, 266 | }, 267 | { 268 | name: "OpsOne", 269 | expected: []string{"OpsOne"}, 270 | }, 271 | { 272 | name: "OpsThree", 273 | expected: []string{"OpsThree"}, 274 | }, 275 | { 276 | name: "Ops* Testing*", 277 | expected: []string{"Ops* Testing*"}, 278 | }, 279 | } 280 | 281 | for _, c := range cases { 282 | t.Run(c.name, func(t *testing.T) { 283 | expanded := ExpandNames([]string{c.name}) 284 | 285 | assert.Equal(t, c.expected, expanded) 286 | }) 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /pkg/resource/resource.go: -------------------------------------------------------------------------------- 1 | // Package resource provides a way to interact with resources. This provides multiple interfaces to test against 2 | // as resources can optionally implement these interfaces. 3 | package resource 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/ekristen/libnuke/pkg/settings" 9 | "github.com/ekristen/libnuke/pkg/types" 10 | ) 11 | 12 | type Resource interface { 13 | Remove(ctx context.Context) error 14 | } 15 | 16 | type Filter interface { 17 | Resource 18 | Filter() error 19 | } 20 | 21 | type LegacyStringer interface { 22 | Resource 23 | String() string 24 | } 25 | 26 | type PropertyGetter interface { 27 | Resource 28 | Properties() types.Properties 29 | } 30 | 31 | type SettingsGetter interface { 32 | Resource 33 | Settings(setting *settings.Setting) 34 | } 35 | 36 | // HandleWaitHook is an interface that allows a resource to handle waiting for a resource to be deleted. 37 | // This is useful for resources that may take a while to delete, typically where the delete operation happens 38 | // asynchronously from the initial delete command. This allows libnuke to not block during the delete operation. 39 | type HandleWaitHook interface { 40 | Resource 41 | HandleWait(context.Context) error 42 | } 43 | 44 | // QueueItemHook is an interface that allows a resource to modify the queue item to which it belongs to. 45 | // For advanced use only, please use with caution! 46 | type QueueItemHook interface { 47 | Resource 48 | BeforeEnqueue(interface{}) 49 | } 50 | -------------------------------------------------------------------------------- /pkg/resource/resource_test.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/ekristen/libnuke/pkg/settings" 11 | "github.com/ekristen/libnuke/pkg/types" 12 | ) 13 | 14 | type TestResource struct { 15 | settings *settings.Setting 16 | } 17 | 18 | func (r *TestResource) Remove(_ context.Context) error { 19 | return fmt.Errorf("remove called") 20 | } 21 | 22 | func (r *TestResource) Filter() error { 23 | return fmt.Errorf("filter called") 24 | } 25 | 26 | func (r *TestResource) String() string { 27 | return "just-a-string" 28 | } 29 | 30 | func (r *TestResource) Properties() types.Properties { 31 | props := types.NewProperties() 32 | props.Set("test", "example") 33 | return props 34 | } 35 | 36 | func (r *TestResource) Settings(sts *settings.Setting) { 37 | r.settings = sts 38 | } 39 | 40 | func TestInterfaceResource(t *testing.T) { 41 | r := TestResource{} 42 | err := r.Remove(context.TODO()) 43 | assert.Error(t, err) 44 | assert.Equal(t, "remove called", err.Error()) 45 | } 46 | 47 | func TestInterfaceFilter(t *testing.T) { 48 | r := TestResource{} 49 | err := r.Filter() 50 | assert.Error(t, err) 51 | assert.Equal(t, "filter called", err.Error()) 52 | } 53 | 54 | func TestInterfaceLegacyStringer(t *testing.T) { 55 | r := TestResource{} 56 | s := r.String() 57 | assert.Equal(t, "just-a-string", s) 58 | } 59 | 60 | func TestInterfacePropertyGetter(t *testing.T) { 61 | r := TestResource{} 62 | props := r.Properties() 63 | assert.Equal(t, "example", props.Get("test")) 64 | } 65 | 66 | func TestInterface_SettingsGetter(t *testing.T) { 67 | s := &settings.Settings{} 68 | s.Set("TestResource", &settings.Setting{ 69 | "DisableDeletionProtection": true, 70 | }) 71 | 72 | r := TestResource{} 73 | r.Settings(s.Get("TestResource")) 74 | 75 | assert.Equal(t, true, r.settings.Get("DisableDeletionProtection")) 76 | } 77 | -------------------------------------------------------------------------------- /pkg/scanner/scanner.go: -------------------------------------------------------------------------------- 1 | // Package scanner provides a mechanism for scanning resources and adding them to the item queue for processing. The 2 | // scope of the scanner is determined by the resource types that are passed to it. The scanner will then run the lister 3 | // for each resource type and add the resources to the item queue for processing. 4 | package scanner 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | "runtime/debug" 11 | 12 | "github.com/sirupsen/logrus" 13 | "golang.org/x/sync/semaphore" 14 | 15 | liberrors "github.com/ekristen/libnuke/pkg/errors" 16 | 17 | "github.com/ekristen/libnuke/pkg/queue" 18 | "github.com/ekristen/libnuke/pkg/registry" 19 | "github.com/ekristen/libnuke/pkg/resource" 20 | "github.com/ekristen/libnuke/pkg/utils" 21 | ) 22 | 23 | // DefaultParallelQueries is the number of parallel queries to run at any given time for a scanner. 24 | const DefaultParallelQueries = 16 25 | 26 | // DefaultQueueSize is the default size of the item queue for a scanner. 27 | const DefaultQueueSize = 50000 28 | 29 | // Scanner is collection of resource types that will be scanned for existing resources and added to the 30 | // item queue for processing. These items will be filtered and then processed. 31 | type Scanner struct { 32 | Items chan *queue.Item `hash:"ignore"` 33 | semaphore *semaphore.Weighted `hash:"ignore"` 34 | ResourceTypes []string 35 | Options interface{} 36 | Owner string 37 | mutateOptsFunc MutateOptsFunc `hash:"ignore"` 38 | parallelQueries int64 39 | logger *logrus.Logger 40 | } 41 | 42 | // MutateOptsFunc is a function that can mutate the Options for a given resource type. This is useful for when you 43 | // need to pass in a different set of Options for a given resource type. For example, AWS nuke needs to be able to 44 | // populate the region and session for a given resource type give that it might only exist in us-east-1. 45 | type MutateOptsFunc func(opts interface{}, resourceType string) interface{} 46 | 47 | // Config is the configuration for a scanner. 48 | type Config struct { 49 | Owner string 50 | ResourceTypes []string 51 | Opts interface{} 52 | QueueSize int 53 | ParallelQueries int64 54 | Logger *logrus.Logger 55 | } 56 | 57 | // New creates a new scanner for the given resource types. 58 | func New(cfg *Config) (*Scanner, error) { 59 | if cfg.Owner == "" { 60 | return nil, fmt.Errorf("owner must be set") 61 | } 62 | if cfg.QueueSize == 0 { 63 | cfg.QueueSize = DefaultQueueSize 64 | } 65 | if cfg.ParallelQueries == 0 { 66 | cfg.ParallelQueries = DefaultParallelQueries 67 | } 68 | if cfg.Logger == nil { 69 | cfg.Logger = logrus.StandardLogger() 70 | } 71 | 72 | return &Scanner{ 73 | Items: make(chan *queue.Item, cfg.QueueSize), 74 | semaphore: semaphore.NewWeighted(cfg.ParallelQueries), 75 | ResourceTypes: cfg.ResourceTypes, 76 | Options: cfg.Opts, 77 | Owner: cfg.Owner, 78 | parallelQueries: cfg.ParallelQueries, 79 | logger: cfg.Logger, 80 | }, nil 81 | } 82 | 83 | type IScanner interface { 84 | Run(resourceTypes []string) 85 | list(resourceType string) 86 | } 87 | 88 | // RegisterMutateOptsFunc registers a mutate Options function for the scanner. The mutate Options function is called 89 | // for each resource type that is being scanned. This allows you to mutate the Options for a given resource type. 90 | func (s *Scanner) RegisterMutateOptsFunc(morph MutateOptsFunc) error { 91 | if s.mutateOptsFunc != nil { 92 | return fmt.Errorf("mutateOptsFunc already registered") 93 | } 94 | s.mutateOptsFunc = morph 95 | return nil 96 | } 97 | 98 | // SetParallelQueries changes the number of parallel queries to run at any given time from the default for the scanner. 99 | func (s *Scanner) SetParallelQueries(parallelQueries int64) { 100 | s.parallelQueries = parallelQueries 101 | s.semaphore = semaphore.NewWeighted(s.parallelQueries) 102 | } 103 | 104 | // SetLogger sets the logger for the scanner. 105 | func (s *Scanner) SetLogger(logger *logrus.Logger) { 106 | s.logger = logger 107 | } 108 | 109 | // Run starts the scanner and runs the lister for each resource type. 110 | func (s *Scanner) Run(ctx context.Context) error { 111 | for _, resourceType := range s.ResourceTypes { 112 | if err := s.semaphore.Acquire(ctx, 1); err != nil { 113 | return err 114 | } 115 | 116 | opts := s.Options 117 | if s.mutateOptsFunc != nil { 118 | opts = s.mutateOptsFunc(opts, resourceType) 119 | } 120 | 121 | go s.list(ctx, s.Owner, resourceType, opts) 122 | } 123 | 124 | // Wait for all routines to finish. 125 | if err := s.semaphore.Acquire(ctx, s.parallelQueries); err != nil { 126 | return err 127 | } 128 | 129 | close(s.Items) 130 | 131 | return nil 132 | } 133 | 134 | func (s *Scanner) list(ctx context.Context, owner, resourceType string, opts interface{}) { 135 | ctx, cancel := context.WithCancel(ctx) 136 | defer cancel() 137 | 138 | logger := logrus.WithField("resource_type", resourceType).WithField("owner", owner) 139 | 140 | defer func() { 141 | if r := recover(); r != nil { 142 | err := fmt.Errorf("%v\n\n%s", r.(error), string(debug.Stack())) 143 | dump := utils.Indent(fmt.Sprintf("%v", err), " ") 144 | logger.Errorf("listing failed:\n%s", dump) 145 | } 146 | }() 147 | 148 | defer s.semaphore.Release(1) 149 | 150 | lister := registry.GetLister(resourceType) 151 | var rs []resource.Resource 152 | 153 | if lister == nil { 154 | logger.Error("lister for resource type not found") 155 | return 156 | } 157 | 158 | logger.Debug("attempting to run lister") 159 | 160 | rs, err := lister.List(ctx, opts) 161 | if err != nil { 162 | var errSkipRequest liberrors.ErrSkipRequest 163 | ok := errors.As(err, &errSkipRequest) 164 | if ok { 165 | logger.Debugf("skipping request: %v", err) 166 | return 167 | } 168 | 169 | var errUnknownEndpoint liberrors.ErrUnknownEndpoint 170 | ok = errors.As(err, &errUnknownEndpoint) 171 | if ok { 172 | logger.Debugf("skipping request: %v", err) 173 | return 174 | } 175 | 176 | dump := utils.Indent(fmt.Sprintf("%v", err), " ") 177 | logger.WithError(err).Errorf("listing failed:\n%s", dump) 178 | return 179 | } 180 | 181 | logger.WithField("count", len(rs)).Debugf("listing complete") 182 | 183 | for _, r := range rs { 184 | i := &queue.Item{ 185 | Resource: r, 186 | State: queue.ItemStateNew, 187 | Type: resourceType, 188 | Owner: owner, 189 | Opts: opts, 190 | Logger: s.logger, 191 | } 192 | 193 | itemHook, ok := r.(resource.QueueItemHook) 194 | if ok { 195 | itemHook.BeforeEnqueue(i) 196 | } 197 | 198 | s.Items <- i 199 | } 200 | 201 | logger.Debugf("resources enqueue complete") 202 | } 203 | -------------------------------------------------------------------------------- /pkg/scanner/scanner_test.go: -------------------------------------------------------------------------------- 1 | package scanner 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "sync" 7 | "testing" 8 | "time" 9 | 10 | "github.com/sirupsen/logrus" 11 | "github.com/stretchr/testify/assert" 12 | 13 | "github.com/ekristen/libnuke/pkg/registry" 14 | ) 15 | 16 | func Test_NewScannerWithoutOwner(t *testing.T) { 17 | scanner, err := New(&Config{}) 18 | assert.Error(t, err) 19 | assert.Nil(t, scanner) 20 | } 21 | 22 | func Test_NewScannerWithMorphOpts(t *testing.T) { 23 | registry.ClearRegistry() 24 | registry.Register(testResourceRegistration) 25 | 26 | opts := TestOpts{ 27 | SessionOne: "testing", 28 | } 29 | 30 | morphOpts := func(o interface{}, resourceType string) interface{} { 31 | o1 := o.(TestOpts) 32 | o1.SessionTwo = o1.SessionOne + "-" + resourceType 33 | return o1 34 | } 35 | 36 | scanner, err := New(&Config{ 37 | Owner: "Owner", 38 | ResourceTypes: []string{testResourceType}, 39 | Opts: opts, 40 | }) 41 | assert.NoError(t, err) 42 | mutateErr := scanner.RegisterMutateOptsFunc(morphOpts) 43 | assert.NoError(t, mutateErr) 44 | 45 | scanner.SetParallelQueries(8) 46 | 47 | err = scanner.Run(context.TODO()) 48 | assert.NoError(t, err) 49 | 50 | assert.Len(t, scanner.Items, 1) 51 | 52 | for item := range scanner.Items { 53 | assert.Equal(t, "testing", item.Opts.(TestOpts).SessionOne) 54 | assert.Equal(t, "testing-testResourceType", item.Opts.(TestOpts).SessionTwo) 55 | assert.Equal(t, "OwnerModded", item.Owner) 56 | } 57 | } 58 | 59 | func Test_NewScannerWithDuplicateMorphOpts(t *testing.T) { 60 | registry.ClearRegistry() 61 | registry.Register(testResourceRegistration) 62 | 63 | opts := TestOpts{ 64 | SessionOne: "testing", 65 | } 66 | 67 | morphOpts := func(o interface{}, resourceType string) interface{} { 68 | o1 := o.(TestOpts) 69 | o1.SessionTwo = o1.SessionOne + "-" + resourceType 70 | return o1 71 | } 72 | 73 | scanner, err := New(&Config{ 74 | Owner: "Owner", 75 | ResourceTypes: []string{testResourceType}, 76 | Opts: opts, 77 | }) 78 | assert.NoError(t, err) 79 | optErr := scanner.RegisterMutateOptsFunc(morphOpts) 80 | assert.NoError(t, optErr) 81 | 82 | optErr = scanner.RegisterMutateOptsFunc(morphOpts) 83 | assert.Error(t, optErr) 84 | } 85 | 86 | func Test_NewScannerWithResourceListerError(t *testing.T) { 87 | registry.ClearRegistry() 88 | logrus.AddHook(&TestGlobalHook{ 89 | t: t, 90 | tf: func(t *testing.T, e *logrus.Entry) { 91 | if strings.HasSuffix(e.Caller.File, "pkg/registry/registry.go") { 92 | return 93 | } 94 | 95 | if e.Level == logrus.ErrorLevel { 96 | assert.Equal(t, e.Data["resource_type"], testResourceType) 97 | assert.Equal(t, "listing failed:\n assert.AnError general error for testing", e.Message) 98 | } 99 | }, 100 | }) 101 | defer logrus.StandardLogger().ReplaceHooks(make(logrus.LevelHooks)) 102 | 103 | registry.Register(testResourceRegistration) 104 | 105 | opts := TestOpts{ 106 | SessionOne: "testing", 107 | ThrowError: true, 108 | } 109 | 110 | scanner, err := New(&Config{ 111 | Owner: "Owner", 112 | ResourceTypes: []string{testResourceType}, 113 | Opts: opts, 114 | }) 115 | assert.NoError(t, err) 116 | 117 | err = scanner.Run(context.TODO()) 118 | assert.NoError(t, err) 119 | 120 | assert.Len(t, scanner.Items, 0) 121 | } 122 | 123 | func Test_NewScannerWithInvalidResourceListerError(t *testing.T) { 124 | registry.ClearRegistry() 125 | logrus.AddHook(&TestGlobalHook{ 126 | t: t, 127 | tf: func(t *testing.T, e *logrus.Entry) { 128 | if strings.HasSuffix(e.Caller.File, "pkg/registry/registry.go") { 129 | return 130 | } 131 | 132 | if e.Level == logrus.ErrorLevel { 133 | assert.Equal(t, e.Data["resource_type"], "does-not-exist") 134 | assert.Equal(t, "lister for resource type not found", e.Message) 135 | } 136 | }, 137 | }) 138 | defer logrus.StandardLogger().ReplaceHooks(make(logrus.LevelHooks)) 139 | 140 | registry.Register(testResourceRegistration) 141 | 142 | opts := TestOpts{ 143 | SessionOne: "testing", 144 | ThrowError: true, 145 | } 146 | 147 | scanner, err := New(&Config{ 148 | Owner: "Owner", 149 | ResourceTypes: []string{"does-not-exist"}, 150 | Opts: opts, 151 | }) 152 | assert.NoError(t, err) 153 | 154 | err = scanner.Run(context.TODO()) 155 | assert.NoError(t, err) 156 | 157 | assert.Len(t, scanner.Items, 0) 158 | } 159 | 160 | func Test_NewScannerWithResourceListerErrorSkip(t *testing.T) { 161 | registry.ClearRegistry() 162 | logrus.AddHook(&TestGlobalHook{ 163 | t: t, 164 | tf: func(t *testing.T, e *logrus.Entry) { 165 | if strings.HasSuffix(e.Caller.File, "pkg/registry/registry.go") { 166 | assert.Equal(t, logrus.TraceLevel, e.Level) 167 | assert.Equal(t, "registered resource lister", e.Message) 168 | return 169 | } 170 | 171 | if strings.HasSuffix(e.Caller.File, "pkg/nuke/scan.go") { 172 | assert.Equal(t, logrus.DebugLevel, e.Level) 173 | assert.Equal(t, "skipping request: skip request error for testing", e.Message) 174 | } 175 | }, 176 | }) 177 | defer logrus.StandardLogger().ReplaceHooks(make(logrus.LevelHooks)) 178 | 179 | registry.Register(testResourceRegistration) 180 | 181 | opts := TestOpts{ 182 | SessionOne: "testing", 183 | ThrowSkipError: true, 184 | } 185 | 186 | scanner, err := New(&Config{ 187 | Owner: "Owner", 188 | ResourceTypes: []string{testResourceType}, 189 | Opts: opts, 190 | }) 191 | assert.NoError(t, err) 192 | 193 | err = scanner.Run(context.TODO()) 194 | assert.NoError(t, err) 195 | 196 | assert.Len(t, scanner.Items, 0) 197 | } 198 | 199 | func Test_NewScannerWithResourceListerErrorUnknownEndpoint(t *testing.T) { 200 | registry.ClearRegistry() 201 | logrus.AddHook(&TestGlobalHook{ 202 | t: t, 203 | tf: func(t *testing.T, e *logrus.Entry) { 204 | if strings.HasSuffix(e.Caller.File, "pkg/registry/registry.go") { 205 | assert.Equal(t, logrus.TraceLevel, e.Level) 206 | assert.Equal(t, "registered resource lister", e.Message) 207 | return 208 | } 209 | 210 | if strings.HasSuffix(e.Caller.File, "pkg/nuke/scan.go") { 211 | assert.Equal(t, logrus.DebugLevel, e.Level) 212 | assert.Equal(t, "skipping request: unknown endpoint error for testing", e.Message) 213 | } 214 | }, 215 | }) 216 | defer logrus.StandardLogger().ReplaceHooks(make(logrus.LevelHooks)) 217 | 218 | registry.Register(testResourceRegistration) 219 | 220 | opts := TestOpts{ 221 | SessionOne: "testing", 222 | ThrowEndpointError: true, 223 | } 224 | 225 | scanner, err := New(&Config{ 226 | Owner: "Owner", 227 | ResourceTypes: []string{testResourceType}, 228 | Opts: opts, 229 | }) 230 | assert.NoError(t, err) 231 | 232 | err = scanner.Run(context.TODO()) 233 | assert.NoError(t, err) 234 | 235 | assert.Len(t, scanner.Items, 0) 236 | } 237 | 238 | func TestRunSemaphoreFirstAcquireError(t *testing.T) { 239 | // Create a new scanner 240 | scanner, err := New(&Config{ 241 | Owner: "owner", 242 | ResourceTypes: []string{testResourceType}, 243 | }) 244 | assert.NoError(t, err) 245 | 246 | scanner.SetParallelQueries(0) 247 | 248 | // Create a context that will be canceled immediately 249 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) 250 | defer cancel() 251 | 252 | // Run the scanner 253 | err = scanner.Run(ctx) 254 | assert.Error(t, err) 255 | } 256 | 257 | func TestRunSemaphoreSecondAcquireError(t *testing.T) { 258 | registry.ClearRegistry() 259 | registry.Register(testResourceRegistration) 260 | // Create a new scanner 261 | scanner, err := New(&Config{ 262 | Owner: "owner", 263 | ResourceTypes: []string{testResourceType}, 264 | Opts: TestOpts{ 265 | Sleep: 45 * time.Second, 266 | }, 267 | }) 268 | assert.NoError(t, err) 269 | 270 | // Create a context that will be canceled immediately 271 | ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) 272 | defer cancel() 273 | 274 | // Run the scanner 275 | err = scanner.Run(ctx) 276 | assert.Error(t, err) 277 | } 278 | 279 | func Test_NewScannerWithResourceListerPanic(t *testing.T) { 280 | var wg sync.WaitGroup 281 | 282 | wg.Add(2) 283 | 284 | panicCaught := false 285 | 286 | registry.ClearRegistry() 287 | defer func() { 288 | logrus.StandardLogger().ReplaceHooks(make(logrus.LevelHooks)) 289 | }() 290 | logrus.AddHook(&TestGlobalHook{ 291 | t: t, 292 | tf: func(t *testing.T, e *logrus.Entry) { 293 | if strings.HasSuffix(e.Caller.File, "pkg/registry/registry.go") { 294 | assert.Equal(t, "registered resource lister", e.Message) 295 | wg.Done() 296 | return 297 | } 298 | 299 | if e.Level == logrus.ErrorLevel && strings.HasSuffix(e.Caller.File, "pkg/scanner/scanner.go") { 300 | assert.Contains(t, e.Message, "listing failed") 301 | assert.Contains(t, e.Message, "panic error for testing") 302 | logrus.StandardLogger().ReplaceHooks(make(logrus.LevelHooks)) 303 | panicCaught = true 304 | wg.Done() 305 | return 306 | } 307 | }, 308 | }) 309 | 310 | registry.Register(testResourceRegistration) 311 | 312 | opts := TestOpts{ 313 | SessionOne: "testing", 314 | Panic: true, 315 | } 316 | 317 | scanner, err := New(&Config{ 318 | Owner: "Owner", 319 | ResourceTypes: []string{testResourceType}, 320 | Opts: opts, 321 | }) 322 | assert.NoError(t, err) 323 | 324 | scanner.SetLogger(logrus.StandardLogger()) 325 | _ = scanner.Run(context.TODO()) 326 | 327 | if waitTimeout(&wg, 10*time.Second) { 328 | t.Fatal("Wait group timed out") 329 | return 330 | } 331 | 332 | assert.True(t, panicCaught) 333 | } 334 | 335 | func waitTimeout(wg *sync.WaitGroup, timeout time.Duration) bool { 336 | c := make(chan struct{}) 337 | go func() { 338 | defer close(c) 339 | wg.Wait() 340 | }() 341 | select { 342 | case <-c: 343 | return false // completed normally 344 | case <-time.After(timeout): 345 | return true // timed out 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /pkg/scanner/testsuite_test.go: -------------------------------------------------------------------------------- 1 | package scanner 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "testing" 9 | "time" 10 | 11 | "github.com/sirupsen/logrus" 12 | "github.com/stretchr/testify/assert" 13 | 14 | "github.com/ekristen/libnuke/pkg/errors" 15 | "github.com/ekristen/libnuke/pkg/queue" 16 | "github.com/ekristen/libnuke/pkg/registry" 17 | "github.com/ekristen/libnuke/pkg/resource" 18 | "github.com/ekristen/libnuke/pkg/settings" 19 | "github.com/ekristen/libnuke/pkg/types" 20 | ) 21 | 22 | func init() { 23 | if flag.Lookup("test.v") != nil { 24 | logrus.SetOutput(io.Discard) 25 | } 26 | logrus.SetLevel(logrus.TraceLevel) 27 | logrus.SetReportCaller(true) 28 | } 29 | 30 | var ( 31 | testResourceType = "testResourceType" 32 | testResourceRegistration = ®istry.Registration{ 33 | Name: testResourceType, 34 | Scope: "account", 35 | Lister: &TestResourceLister{}, 36 | } 37 | ) 38 | 39 | type TestResource struct { 40 | Filtered bool 41 | RemoveError bool 42 | } 43 | 44 | func (r *TestResource) Filter() error { 45 | if r.Filtered { 46 | return fmt.Errorf("cannot remove default") 47 | } 48 | 49 | return nil 50 | } 51 | 52 | func (r *TestResource) Remove(_ context.Context) error { 53 | if r.RemoveError { 54 | return fmt.Errorf("remove error") 55 | } 56 | return nil 57 | } 58 | 59 | func (r *TestResource) Settings(setting *settings.Setting) { 60 | 61 | } 62 | 63 | func (r *TestResource) BeforeEnqueue(item interface{}) { 64 | i := item.(*queue.Item) 65 | i.Owner = "OwnerModded" 66 | } 67 | 68 | type TestResource2 struct { 69 | Filtered bool 70 | RemoveError bool 71 | } 72 | 73 | func (r *TestResource2) Filter() error { 74 | if r.Filtered { 75 | return fmt.Errorf("cannot remove default") 76 | } 77 | 78 | return nil 79 | } 80 | 81 | func (r *TestResource2) Remove(_ context.Context) error { 82 | if r.RemoveError { 83 | return fmt.Errorf("remove error") 84 | } 85 | return nil 86 | } 87 | 88 | func (r *TestResource2) Properties() types.Properties { 89 | props := types.NewProperties() 90 | props.Set("test", "testing") 91 | return props 92 | } 93 | 94 | type TestResourceLister struct { 95 | Filtered bool 96 | RemoveError bool 97 | } 98 | 99 | func (l TestResourceLister) List(_ context.Context, o interface{}) ([]resource.Resource, error) { 100 | opts := o.(TestOpts) 101 | 102 | if opts.ThrowError { 103 | return nil, assert.AnError 104 | } 105 | 106 | if opts.ThrowSkipError { 107 | return nil, errors.ErrSkipRequest("skip request error for testing") 108 | } 109 | 110 | if opts.ThrowEndpointError { 111 | return nil, errors.ErrUnknownEndpoint("unknown endpoint error for testing") 112 | } 113 | 114 | if opts.Panic { 115 | panic(fmt.Errorf("panic error for testing")) 116 | } 117 | 118 | if opts.Sleep > 0 { 119 | time.Sleep(opts.Sleep) 120 | } 121 | 122 | if opts.SecondResource { 123 | return []resource.Resource{ 124 | &TestResource2{ 125 | Filtered: l.Filtered, 126 | RemoveError: l.RemoveError, 127 | }, 128 | }, nil 129 | } 130 | 131 | return []resource.Resource{ 132 | &TestResource{ 133 | Filtered: l.Filtered, 134 | RemoveError: l.RemoveError, 135 | }, 136 | }, nil 137 | } 138 | 139 | type TestOpts struct { 140 | Test *testing.T 141 | SessionOne string 142 | SessionTwo string 143 | ThrowError bool 144 | ThrowSkipError bool 145 | ThrowEndpointError bool 146 | Panic bool 147 | SecondResource bool 148 | Sleep time.Duration 149 | } 150 | 151 | type TestGlobalHook struct { 152 | t *testing.T 153 | tf func(t *testing.T, e *logrus.Entry) 154 | } 155 | 156 | func (h *TestGlobalHook) Levels() []logrus.Level { 157 | return logrus.AllLevels 158 | } 159 | 160 | func (h *TestGlobalHook) Fire(e *logrus.Entry) error { 161 | if h.tf != nil { 162 | h.tf(h.t, e) 163 | } 164 | 165 | return nil 166 | } 167 | -------------------------------------------------------------------------------- /pkg/settings/settings.go: -------------------------------------------------------------------------------- 1 | // Package settings provides a way to store and retrieve settings for resources. 2 | package settings 3 | 4 | type Settings map[string]*Setting 5 | 6 | func (s *Settings) Get(key string) *Setting { 7 | if s == nil { 8 | return nil 9 | } 10 | 11 | set, ok := (*s)[key] 12 | if !ok { 13 | return &Setting{} 14 | } 15 | 16 | return set 17 | } 18 | 19 | func (s *Settings) Set(key string, value *Setting) { 20 | existing, ok := (*s)[key] 21 | if ok { 22 | for k, v := range *value { 23 | (*existing)[k] = v 24 | } 25 | 26 | return 27 | } 28 | 29 | (*s)[key] = value 30 | } 31 | 32 | type Setting map[string]interface{} 33 | 34 | // Get returns the value of a key in the Setting 35 | // Deprecated: use GetBool, GetString, or GetInt instead 36 | func (s *Setting) Get(key string) interface{} { 37 | value, ok := (*s)[key] 38 | if !ok { 39 | return nil 40 | } 41 | 42 | switch value.(type) { 43 | case string: 44 | return value 45 | case int: 46 | return value 47 | case bool: 48 | return value 49 | default: 50 | return value 51 | } 52 | } 53 | 54 | // GetBool returns the boolean value of a key in the Setting 55 | func (s *Setting) GetBool(key string) bool { 56 | value, ok := (*s)[key] 57 | if !ok { 58 | return false 59 | } 60 | 61 | return value.(bool) 62 | } 63 | 64 | // GetString returns the string value of a key in the Setting 65 | func (s *Setting) GetString(key string) string { 66 | value, ok := (*s)[key] 67 | if !ok { 68 | return "" 69 | } 70 | 71 | return value.(string) 72 | } 73 | 74 | // GetInt returns the integer value of a key in the Setting 75 | func (s *Setting) GetInt(key string) int { 76 | value, ok := (*s)[key] 77 | if !ok { 78 | return -1 79 | } 80 | 81 | return value.(int) 82 | } 83 | 84 | // Set sets a key value pair in the Setting 85 | func (s *Setting) Set(key string, value interface{}) { 86 | (*s)[key] = value 87 | } 88 | -------------------------------------------------------------------------------- /pkg/settings/settings_test.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "gopkg.in/yaml.v2" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | type Config struct { 13 | Settings Settings `yaml:"settings"` 14 | } 15 | 16 | func TestSettings_ParseYAML(t *testing.T) { 17 | var cfg Config 18 | 19 | data, err := os.ReadFile("testdata/settings.yaml") 20 | assert.NoError(t, err) 21 | 22 | err = yaml.Unmarshal(data, &cfg) 23 | assert.NoError(t, err) 24 | 25 | ec2Settings := cfg.Settings.Get("EC2Instance") 26 | assert.NotNil(t, ec2Settings) 27 | 28 | assert.Equal(t, true, ec2Settings.Get("DisableDeletionProtection")) 29 | assert.Equal(t, "true", ec2Settings.Get("DisableStopProtection")) 30 | assert.Nil(t, ec2Settings.Get("ForceDeleteLightsailAddOns")) 31 | 32 | invalidSettings := cfg.Settings.Get("OtherInstance") 33 | assert.NotNil(t, invalidSettings) 34 | 35 | typeSettings := cfg.Settings.Get("Types") 36 | assert.NotNil(t, typeSettings) 37 | assert.Equal(t, 1, typeSettings.Get("Integer")) 38 | assert.Equal(t, "string", typeSettings.Get("String")) 39 | assert.NotNil(t, typeSettings.Get("Nested")) 40 | } 41 | 42 | func TestSettings_ParseYAMLInvalid(t *testing.T) { 43 | var cfg Config 44 | 45 | data, err := os.ReadFile("testdata/settings-invalid.yaml") 46 | assert.NoError(t, err) 47 | 48 | err = yaml.Unmarshal(data, &cfg) 49 | assert.Error(t, err) 50 | } 51 | 52 | func TestSettings_NotNil(t *testing.T) { 53 | s := Settings{} 54 | assert.NotNil(t, s.Get("EC2Instance")) 55 | } 56 | 57 | func TestSettings_Set(t *testing.T) { 58 | s := Settings{} 59 | s.Set("EC2Instance", &Setting{ 60 | "DisableDeletionProtection": true, 61 | }) 62 | s.Set("EC2Instance", &Setting{ 63 | "DisableStopProtection": true, 64 | }) 65 | assert.Equal(t, true, s.Get("EC2Instance").Get("DisableDeletionProtection")) 66 | assert.Equal(t, true, s.Get("EC2Instance").Get("DisableStopProtection")) 67 | assert.Nil(t, s.Get("EC2Instance").Get("ForceDeleteLightsailAddOns")) 68 | } 69 | 70 | func TestSettings_SetSetting(t *testing.T) { 71 | s := Setting{} 72 | s.Set("DisableDeletionProtection", true) 73 | assert.Equal(t, true, s.Get("DisableDeletionProtection")) 74 | assert.Nil(t, s.Get("DisableStopProtection")) 75 | assert.Nil(t, s.Get("ForceDeleteLightsailAddOns")) 76 | } 77 | 78 | func TestSettings_GetNil(t *testing.T) { 79 | var s *Settings = nil 80 | set := s.Get("key") 81 | assert.Nil(t, set) 82 | } 83 | 84 | func TestSetting_GetString(t *testing.T) { 85 | s := Setting{} 86 | s.Set("TestSetting", "test") 87 | assert.Equal(t, "test", s.GetString("TestSetting")) 88 | assert.Equal(t, "", s.GetString("InvalidSetting")) 89 | } 90 | 91 | func TestSetting_GetInt(t *testing.T) { 92 | s := Setting{} 93 | s.Set("TestSetting", 123) 94 | assert.Equal(t, 123, s.GetInt("TestSetting")) 95 | assert.Equal(t, -1, s.GetInt("InvalidSetting")) 96 | } 97 | 98 | func TestSetting_GetBool(t *testing.T) { 99 | s := Setting{} 100 | s.Set("TestSetting", true) 101 | assert.Equal(t, true, s.GetBool("TestSetting")) 102 | assert.Equal(t, false, s.GetBool("InvalidSetting")) 103 | } 104 | -------------------------------------------------------------------------------- /pkg/settings/testdata/settings-invalid.yaml: -------------------------------------------------------------------------------- 1 | settings: 2 | - something 3 | EC2Instance: 4 | DisableDeletionProtection: true 5 | DisableStopProtection: "true" 6 | RDSInstance: 7 | DisableDeletionProtection: "true" 8 | -------------------------------------------------------------------------------- /pkg/settings/testdata/settings.yaml: -------------------------------------------------------------------------------- 1 | settings: 2 | EC2Instance: 3 | DisableDeletionProtection: true 4 | DisableStopProtection: "true" 5 | RDSInstance: 6 | DisableDeletionProtection: "true" 7 | Types: 8 | Integer: 1 9 | String: "string" 10 | Boolean: true 11 | Nested: 12 | NotSupported: true -------------------------------------------------------------------------------- /pkg/slices/chunk.go: -------------------------------------------------------------------------------- 1 | package slices 2 | 3 | // Chunk splits a slice into chunks of the given size. 4 | // Original Source: https://github.com/rebuy-de/aws-nuke/blob/c3ae17932f058f1867aab382182ecd837090961a/resources/util.go#L40 5 | func Chunk[T any](slice []T, size int) [][]T { 6 | var chunks [][]T 7 | for i := 0; i < len(slice); { 8 | // Clamp the last chunk to the slice bound as necessary. 9 | end := size 10 | if l := len(slice[i:]); l < size { 11 | end = l 12 | } 13 | 14 | // Set the capacity of each chunk so that appending to a chunk does not 15 | // modify the original slice. 16 | chunks = append(chunks, slice[i:i+end:i+end]) 17 | i += end 18 | } 19 | 20 | return chunks 21 | } 22 | -------------------------------------------------------------------------------- /pkg/slices/chunk_test.go: -------------------------------------------------------------------------------- 1 | package slices 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestChunk(t *testing.T) { 10 | type args struct { 11 | slice []int 12 | size int 13 | } 14 | tests := []struct { 15 | name string 16 | args args 17 | want [][]int 18 | }{ 19 | { 20 | name: "empty", 21 | args: args{ 22 | slice: []int{}, 23 | size: 1, 24 | }, 25 | want: nil, 26 | }, 27 | { 28 | name: "one", 29 | args: args{ 30 | slice: []int{1}, 31 | size: 1, 32 | }, 33 | want: [][]int{{1}}, 34 | }, 35 | { 36 | name: "two", 37 | args: args{ 38 | slice: []int{1, 2}, 39 | size: 1, 40 | }, 41 | want: [][]int{{1}, {2}}, 42 | }, 43 | { 44 | name: "two", 45 | args: args{ 46 | slice: []int{1, 2}, 47 | size: 2, 48 | }, 49 | want: [][]int{{1, 2}}, 50 | }, 51 | { 52 | name: "three", 53 | args: args{ 54 | slice: []int{1, 2, 3}, 55 | size: 2, 56 | }, 57 | want: [][]int{{1, 2}, {3}}, 58 | }, 59 | { 60 | name: "four", 61 | args: args{ 62 | slice: []int{1, 2, 3, 4}, 63 | size: 2, 64 | }, 65 | want: [][]int{{1, 2}, {3, 4}}, 66 | }, 67 | { 68 | name: "five", 69 | args: args{ 70 | slice: []int{1, 2, 3, 4, 5}, 71 | size: 2, 72 | }, 73 | want: [][]int{{1, 2}, {3, 4}, {5}}, 74 | }, 75 | { 76 | name: "six", 77 | args: args{ 78 | slice: []int{1, 2, 3, 4, 5, 6}, 79 | size: 2, 80 | }, 81 | want: [][]int{{1, 2}, {3, 4}, {5, 6}}, 82 | }, 83 | } 84 | 85 | for _, tt := range tests { 86 | t.Run(tt.name, func(t *testing.T) { 87 | got := Chunk(tt.args.slice, tt.args.size) 88 | assert.Equal(t, tt.want, got) 89 | }) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /pkg/types/collection.go: -------------------------------------------------------------------------------- 1 | // Package types provides common types used by libnuke. Primarily it provides the Collection type which is used to 2 | // represent a collection of strings. Additionally, it provides the Properties type which is used to add properties 3 | // to a resource. 4 | package types 5 | 6 | import ( 7 | "github.com/mb0/glob" 8 | ) 9 | 10 | // Collection is a collection of strings 11 | type Collection []string 12 | 13 | // Expand returns a collection by using the Collection which may contain glob patterns and match to the source 14 | // and returns the expanded collection, if there are no matches, it includes the original element from the collection. 15 | func (c Collection) Expand(base []string) Collection { 16 | var expanded Collection 17 | for _, sc := range c { 18 | matches, _ := glob.GlobStrings(base, sc) 19 | 20 | if matches == nil { 21 | expanded = append(expanded, sc) 22 | continue 23 | } 24 | 25 | expanded = append(expanded, matches...) 26 | } 27 | 28 | return expanded 29 | } 30 | 31 | // Intersect returns the intersection of two collections 32 | func (c Collection) Intersect(o Collection) Collection { 33 | mo := o.toMap() 34 | 35 | result := Collection{} 36 | for _, t := range c { 37 | if mo[t] { 38 | result = append(result, t) 39 | } 40 | } 41 | 42 | return result 43 | } 44 | 45 | // Remove returns the difference of two collections 46 | func (c Collection) Remove(o Collection) Collection { 47 | mo := o.toMap() 48 | 49 | result := Collection{} 50 | for _, t := range c { 51 | if !mo[t] { 52 | result = append(result, t) 53 | } 54 | } 55 | 56 | return result 57 | } 58 | 59 | // Union returns the union of two collections 60 | func (c Collection) Union(o Collection) Collection { 61 | ms := c.toMap() 62 | 63 | result := []string(c) 64 | for _, oi := range o { 65 | if !ms[oi] { 66 | result = append(result, oi) 67 | } 68 | } 69 | 70 | return result 71 | } 72 | 73 | // toMap converts a collection to a map 74 | func (c Collection) toMap() map[string]bool { 75 | m := map[string]bool{} 76 | for _, t := range c { 77 | m[t] = true 78 | } 79 | return m 80 | } 81 | -------------------------------------------------------------------------------- /pkg/types/collection_test.go: -------------------------------------------------------------------------------- 1 | package types_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/ekristen/libnuke/pkg/types" 8 | ) 9 | 10 | func TestSetIntersect(t *testing.T) { 11 | s1 := types.Collection{"a", "b", "c"} 12 | s2 := types.Collection{"b", "a", "d"} 13 | 14 | r := s1.Intersect(s2) 15 | 16 | want := fmt.Sprint([]string{"a", "b"}) 17 | have := fmt.Sprint(r) 18 | 19 | if want != have { 20 | t.Errorf("Wrong result. Want: %s. Have: %s", want, have) 21 | } 22 | } 23 | 24 | func TestSetRemove(t *testing.T) { 25 | s1 := types.Collection{"a", "b", "c"} 26 | s2 := types.Collection{"b", "a", "d"} 27 | 28 | r := s1.Remove(s2) 29 | 30 | want := fmt.Sprint([]string{"c"}) 31 | have := fmt.Sprint(r) 32 | 33 | if want != have { 34 | t.Errorf("Wrong result. Want: %s. Have: %s", want, have) 35 | } 36 | } 37 | 38 | func TestSetUnion(t *testing.T) { 39 | s1 := types.Collection{"a", "b", "c"} 40 | s2 := types.Collection{"b", "a", "d"} 41 | 42 | r := s1.Union(s2) 43 | 44 | want := fmt.Sprint([]string{"a", "b", "c", "d"}) 45 | have := fmt.Sprint(r) 46 | 47 | if want != have { 48 | t.Errorf("Wrong result. Want: %s. Have: %s", want, have) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /pkg/types/properties.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | // Properties is a map of key-value pairs. 11 | type Properties map[string]string 12 | 13 | // NewProperties creates a new Properties map. 14 | func NewProperties() Properties { 15 | props := make(Properties) 16 | props.SetTagPrefix("tag") 17 | return props 18 | } 19 | 20 | // NewPropertiesFromStruct creates a new Properties map from a struct. 21 | func NewPropertiesFromStruct(data interface{}) Properties { 22 | return NewProperties().SetFromStruct(data) 23 | } 24 | 25 | func (p Properties) SetTagPrefix(prefix string) Properties { 26 | p["_tagPrefix"] = prefix 27 | return p 28 | } 29 | 30 | // String returns a string representation of the Properties map. 31 | func (p Properties) String() string { 32 | var parts []string 33 | for k, v := range p { 34 | if strings.HasPrefix(k, "_") { 35 | continue 36 | } 37 | 38 | parts = append(parts, fmt.Sprintf(`%s: "%v"`, k, v)) 39 | } 40 | 41 | return fmt.Sprintf("[%s]", strings.Join(parts, ", ")) 42 | } 43 | 44 | // Get returns the value of a key in the Properties map. 45 | func (p Properties) Get(key string) string { 46 | value, ok := p[key] 47 | if !ok { 48 | return "" 49 | } 50 | 51 | return value 52 | } 53 | 54 | // Set sets a key-value pair in the Properties map. 55 | func (p Properties) Set(key string, value interface{}) Properties { //nolint:gocyclo 56 | if value == nil { 57 | return p 58 | } 59 | 60 | switch v := value.(type) { 61 | case *string: 62 | if v == nil { 63 | return p 64 | } 65 | p[key] = *v 66 | case string: 67 | p[key] = v 68 | case []byte: 69 | p[key] = string(v) 70 | case *bool: 71 | if v == nil { 72 | return p 73 | } 74 | p[key] = fmt.Sprint(*v) 75 | case bool: 76 | p[key] = fmt.Sprint(v) 77 | case *int64: 78 | if v == nil { 79 | return p 80 | } 81 | p[key] = fmt.Sprint(*v) 82 | case int64: 83 | p[key] = fmt.Sprint(v) 84 | case *int: 85 | if v == nil { 86 | return p 87 | } 88 | p[key] = fmt.Sprint(*v) 89 | case int: 90 | p[key] = fmt.Sprint(v) 91 | case time.Time: 92 | p[key] = v.Format(time.RFC3339) 93 | default: 94 | // Fallback to Stringer interface. This produces gibberish on pointers, 95 | // but is the only way to avoid reflection. 96 | p[key] = fmt.Sprint(value) 97 | } 98 | 99 | return p 100 | } 101 | 102 | // SetWithPrefix sets a key-value pair in the Properties map with a prefix. 103 | func (p Properties) SetWithPrefix(prefix, key string, value interface{}) Properties { 104 | key = strings.TrimSpace(key) 105 | prefix = strings.TrimSpace(prefix) 106 | 107 | if key == "" { 108 | return p 109 | } 110 | 111 | if prefix != "" { 112 | key = fmt.Sprintf("%s:%s", prefix, key) 113 | } 114 | 115 | return p.Set(key, value) 116 | } 117 | 118 | // SetTag sets a tag key-value pair in the Properties map. 119 | func (p Properties) SetTag(tagKey *string, tagValue interface{}) Properties { 120 | return p.SetTagWithPrefix("", tagKey, tagValue) 121 | } 122 | 123 | // SetTagWithPrefix sets a tag key-value pair in the Properties map with a prefix. 124 | func (p Properties) SetTagWithPrefix(prefix string, tagKey *string, tagValue interface{}) Properties { 125 | if tagKey == nil { 126 | return p 127 | } 128 | 129 | keyStr := strings.TrimSpace(*tagKey) 130 | prefix = strings.TrimSpace(prefix) 131 | 132 | if keyStr == "" { 133 | return p 134 | } 135 | 136 | if prefix != "" { 137 | keyStr = fmt.Sprintf("%s:%s", prefix, keyStr) 138 | } 139 | 140 | keyStr = fmt.Sprintf("%s:%s", p.Get("_tagPrefix"), keyStr) 141 | 142 | return p.Set(keyStr, tagValue) 143 | } 144 | 145 | // Equals compares two Properties maps. 146 | func (p Properties) Equals(o Properties) bool { 147 | if p == nil && o == nil { 148 | return true 149 | } 150 | 151 | if p == nil || o == nil { 152 | return false 153 | } 154 | 155 | if len(p) != len(o) { 156 | return false 157 | } 158 | 159 | for k, pv := range p { 160 | ov, ok := o[k] 161 | if !ok { 162 | return false 163 | } 164 | 165 | if pv != ov { 166 | return false 167 | } 168 | } 169 | 170 | return true 171 | } 172 | 173 | // SetFromStruct sets the Properties map from a struct by reading the structs fields 174 | func (p Properties) SetFromStruct(data interface{}) Properties { //nolint:funlen,gocyclo 175 | v := reflect.ValueOf(data) 176 | if v.Kind() == reflect.Ptr { 177 | v = v.Elem() 178 | } 179 | t := v.Type() 180 | 181 | for i := 0; i < t.NumField(); i++ { 182 | field := t.Field(i) 183 | value := v.Field(i) 184 | 185 | if !field.IsExported() { 186 | continue 187 | } 188 | 189 | isSet := false 190 | 191 | switch value.Kind() { 192 | case reflect.Ptr, reflect.Slice, reflect.Map, reflect.Interface, reflect.Chan: 193 | isSet = !value.IsNil() 194 | default: 195 | isSet = value.Interface() != reflect.Zero(value.Type()).Interface() 196 | } 197 | 198 | if !isSet { 199 | continue 200 | } 201 | 202 | propertyTag := field.Tag.Get("property") 203 | options := strings.Split(propertyTag, ",") 204 | name := field.Name 205 | prefix := "" 206 | tagPrefix := "" 207 | keyFieldName := "" 208 | valueFieldName := "" 209 | inline := false 210 | 211 | if options[0] == "-" { 212 | continue 213 | } 214 | 215 | if len(options) == 2 && options[1] == "inline" { 216 | inline = true 217 | } 218 | 219 | for _, option := range options { 220 | parts := strings.Split(option, "=") 221 | if len(parts) != 2 { 222 | continue 223 | } 224 | switch parts[0] { 225 | case "name": 226 | name = parts[1] 227 | case "prefix": 228 | prefix = parts[1] 229 | case "tagPrefix": 230 | tagPrefix = parts[1] 231 | case "keyField": 232 | keyFieldName = parts[1] 233 | case "valueField": 234 | valueFieldName = parts[1] 235 | } 236 | } 237 | 238 | if inline { 239 | p.SetFromStruct(value.Interface()) 240 | continue 241 | } 242 | 243 | if tagPrefix != "" { 244 | p.SetTagPrefix(tagPrefix) 245 | } 246 | 247 | if value.Kind() == reflect.Ptr { 248 | value = value.Elem() 249 | } 250 | 251 | switch value.Kind() { 252 | case reflect.Struct: 253 | if value.Type().String() == "time.Time" { 254 | p.SetWithPrefix(prefix, name, value.Interface()) 255 | } 256 | case reflect.Map: 257 | for _, key := range value.MapKeys() { 258 | val := value.MapIndex(key) 259 | if key.Kind() == reflect.Ptr { 260 | key = key.Elem() 261 | } 262 | if val.Kind() == reflect.Ptr { 263 | val = val.Elem() 264 | } 265 | name = key.String() 266 | p.SetTagWithPrefix(prefix, &name, val.Interface()) 267 | } 268 | case reflect.Slice: 269 | for j := 0; j < value.Len(); j++ { 270 | sliceValue := value.Index(j) 271 | if sliceValue.Kind() == reflect.Ptr { 272 | sliceValue = sliceValue.Elem() 273 | } 274 | if sliceValue.Kind() == reflect.Struct { 275 | sliceValueV := reflect.ValueOf(sliceValue.Interface()) 276 | 277 | var keyField, valueField reflect.Value 278 | if keyFieldName != "" { 279 | keyField = sliceValueV.FieldByName(keyFieldName) 280 | } else { 281 | keyField = sliceValueV.FieldByName("Key") 282 | if !keyField.IsValid() { 283 | keyField = sliceValueV.FieldByName("TagKey") 284 | } 285 | } 286 | 287 | if valueFieldName != "" { 288 | valueField = sliceValueV.FieldByName(valueFieldName) 289 | } else { 290 | valueField = sliceValueV.FieldByName("Value") 291 | if !valueField.IsValid() { 292 | valueField = sliceValueV.FieldByName("TagValue") 293 | } 294 | } 295 | 296 | if keyField.Kind() == reflect.Ptr { 297 | keyField = keyField.Elem() 298 | } 299 | if valueField.Kind() == reflect.Ptr { 300 | valueField = valueField.Elem() 301 | } 302 | 303 | if keyField.IsValid() && valueField.IsValid() { 304 | p.SetTagWithPrefix(prefix, &[]string{keyField.Interface().(string)}[0], valueField.Interface()) 305 | } 306 | } 307 | } 308 | default: 309 | p.SetWithPrefix(prefix, name, value.Interface()) 310 | } 311 | } 312 | 313 | return p 314 | } 315 | -------------------------------------------------------------------------------- /pkg/types/utils.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // ResolveResourceTypes resolves the resource types based on the provided includes, excludes, and alternatives. 4 | // The alternatives are a list of resource types that are to be used instead of the default resource. The primary use 5 | // case for this is AWS Cloud Control API resources. If a resource has been registered with the Cloud Control API. 6 | // Includes, Excludes, and Alternatives are []Collection because they are a combination of runtime, global and account 7 | // level configuration. 8 | func ResolveResourceTypes( 9 | base Collection, 10 | includes, excludes, alternatives []Collection, 11 | alternativeMappings map[string]string) Collection { 12 | // Loop over the alternatives and build a list of the old style resource types 13 | for _, cl := range alternatives { 14 | expandedCl := cl.Expand(base) 15 | oldStyle := Collection{} 16 | for _, c := range expandedCl { 17 | os, found := alternativeMappings[c] 18 | if found { 19 | oldStyle = append(oldStyle, os) 20 | } 21 | } 22 | 23 | base = base.Union(expandedCl) 24 | base = base.Remove(oldStyle) 25 | } 26 | 27 | for _, i := range includes { 28 | expandedI := i.Expand(base) 29 | if len(i) > 0 { 30 | base = base.Intersect(expandedI) 31 | } 32 | } 33 | 34 | for _, e := range excludes { 35 | expandedE := e.Expand(base) 36 | base = base.Remove(expandedE) 37 | } 38 | 39 | return base 40 | } 41 | -------------------------------------------------------------------------------- /pkg/types/utils_test.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | var emptyCollection []Collection 10 | var emptyMapping = map[string]string{} 11 | var baseCollection = Collection{"ResourceA", "ResourceB", "ResourceC", "ResourceD", "ResourceE", "ResourceF"} 12 | var baseGlobCollection = Collection{"ServiceA1", "ServiceA2", "ServiceA3", 13 | "ServiceB1", "ServiceB2", "ServiceB3", "ServiceC1", "ServiceC2", "ServiceC3"} 14 | 15 | func TestResolveResourceTypes(t *testing.T) { 16 | cases := []struct { 17 | name string 18 | base Collection 19 | includes []Collection 20 | excludes []Collection 21 | alternatives []Collection 22 | mapping map[string]string 23 | expected Collection 24 | }{ 25 | { 26 | name: "empty", 27 | base: Collection{}, 28 | includes: emptyCollection, 29 | excludes: emptyCollection, 30 | alternatives: emptyCollection, 31 | mapping: emptyMapping, 32 | expected: Collection{}, 33 | }, 34 | { 35 | name: "base", 36 | base: baseCollection, 37 | includes: emptyCollection, 38 | excludes: emptyCollection, 39 | alternatives: emptyCollection, 40 | mapping: emptyMapping, 41 | expected: baseCollection, 42 | }, 43 | { 44 | name: "includes", 45 | base: baseCollection, 46 | includes: []Collection{{"ResourceA", "ResourceB", "ResourceC"}}, 47 | excludes: emptyCollection, 48 | alternatives: emptyCollection, 49 | mapping: emptyMapping, 50 | expected: Collection{"ResourceA", "ResourceB", "ResourceC"}, 51 | }, 52 | { 53 | name: "excludes", 54 | base: baseCollection, 55 | includes: emptyCollection, 56 | excludes: []Collection{{"ResourceA", "ResourceB", "ResourceC"}}, 57 | alternatives: emptyCollection, 58 | mapping: emptyMapping, 59 | expected: Collection{"ResourceD", "ResourceE", "ResourceF"}, 60 | }, 61 | { 62 | name: "alternatives", 63 | base: Collection{ 64 | "ResourceA", 65 | "ResourceB", 66 | "ResourceC", 67 | "ResourceD", 68 | "ResourceE", 69 | "ResourceF", 70 | "AlternativeA", 71 | "AlternativeC", 72 | "AlternativeE", 73 | }, 74 | includes: emptyCollection, 75 | excludes: emptyCollection, 76 | alternatives: []Collection{{"AlternativeA", "AlternativeC", "AlternativeE"}}, 77 | mapping: map[string]string{ 78 | "AlternativeA": "ResourceA", 79 | "AlternativeC": "ResourceC", 80 | "AlternativeE": "ResourceE", 81 | }, 82 | expected: Collection{"ResourceB", "ResourceD", "ResourceF", "AlternativeA", "AlternativeC", "AlternativeE"}, 83 | }, 84 | { 85 | name: "includes and excludes", 86 | base: baseCollection, 87 | includes: []Collection{ 88 | {"ResourceA", "ResourceB", "ResourceC"}, 89 | }, 90 | excludes: []Collection{ 91 | {"ResourceA", "ResourceB", "ResourceC"}, 92 | }, 93 | alternatives: emptyCollection, 94 | mapping: emptyMapping, 95 | expected: Collection{}, 96 | }, 97 | { 98 | name: "excludes and alternatives", 99 | base: baseCollection, 100 | includes: emptyCollection, 101 | excludes: []Collection{ 102 | {"ResourceB", "ResourceC"}, 103 | }, 104 | alternatives: []Collection{ 105 | {"AlternativeA", "AlternativeC", "AlternativeE"}, 106 | }, 107 | mapping: map[string]string{ 108 | "AlternativeA": "ResourceA", 109 | "AlternativeC": "ResourceC", 110 | "AlternativeE": "ResourceE", 111 | }, 112 | expected: Collection{"ResourceD", "ResourceF", "AlternativeA", "AlternativeC", "AlternativeE"}, 113 | }, 114 | { 115 | name: "includes and excludes with globs", 116 | base: baseGlobCollection, 117 | includes: emptyCollection, 118 | excludes: []Collection{ 119 | {"ServiceB*"}, 120 | }, 121 | expected: Collection{"ServiceA1", "ServiceA2", "ServiceA3", "ServiceC1", "ServiceC2", "ServiceC3"}, 122 | }, 123 | { 124 | name: "excludes and includes with globs", 125 | base: baseGlobCollection, 126 | includes: []Collection{ 127 | {"ServiceA*"}, 128 | }, 129 | excludes: []Collection{ 130 | {"ServiceA2", "ServiceA3"}, 131 | }, 132 | expected: Collection{"ServiceA1"}, 133 | }, 134 | { 135 | name: "excludes and includes with globs variant", 136 | base: baseGlobCollection, 137 | includes: []Collection{ 138 | {"ServiceA*", "ServiceB*"}, 139 | }, 140 | excludes: []Collection{ 141 | {"ServiceA2", "ServiceA3", "ServiceB2", "ServiceB3"}, 142 | }, 143 | expected: Collection{"ServiceA1", "ServiceB1"}, 144 | }, 145 | } 146 | 147 | for _, tc := range cases { 148 | t.Run(tc.name, func(t *testing.T) { 149 | actual := ResolveResourceTypes(tc.base, tc.includes, tc.excludes, tc.alternatives, tc.mapping) 150 | assert.Equal(t, tc.expected, actual) 151 | }) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /pkg/utils/indent.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | func Indent(s, prefix string) string { 4 | return string(IndentBytes([]byte(s), []byte(prefix))) 5 | } 6 | 7 | func IndentBytes(b, prefix []byte) []byte { 8 | var res []byte 9 | bol := true 10 | for _, c := range b { 11 | if bol && c != '\n' { 12 | res = append(res, prefix...) 13 | } 14 | res = append(res, c) 15 | bol = c == '\n' 16 | } 17 | return res 18 | } 19 | -------------------------------------------------------------------------------- /pkg/utils/indent_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestIndent(t *testing.T) { 10 | // Generated by chat.openai.com 11 | input := `In the quiet town of Willowbrook, nestled between rolling hills and lush greenery, life 12 | unfolded at a leisurely pace. The townsfolk, with their friendly smiles and welcoming demeanor 13 | went about their daily routines. The aroma of freshly baked bread wafted through the air from 14 | the local bakery, while children played in the park, their laughter echoing against the backdrop 15 | of a clear blue sky. Willowbrook, a place where time seemed to slow down, embraced a sense of 16 | tranquility that captivated anyone lucky enough to experience its charm.` 17 | 18 | cases := []struct { 19 | name string 20 | input string 21 | prefix string 22 | want string 23 | }{ 24 | { 25 | name: "no-prefix", 26 | input: input, 27 | prefix: "", 28 | want: input, 29 | }, 30 | { 31 | name: "string-prefix", 32 | input: input, 33 | prefix: ">", 34 | want: `>In the quiet town of Willowbrook, nestled between rolling hills and lush greenery, life 35 | >unfolded at a leisurely pace. The townsfolk, with their friendly smiles and welcoming demeanor 36 | >went about their daily routines. The aroma of freshly baked bread wafted through the air from 37 | >the local bakery, while children played in the park, their laughter echoing against the backdrop 38 | >of a clear blue sky. Willowbrook, a place where time seemed to slow down, embraced a sense of 39 | >tranquility that captivated anyone lucky enough to experience its charm.`, 40 | }, 41 | { 42 | name: "tab-prefix", 43 | input: input, 44 | prefix: ">\t", 45 | want: `> In the quiet town of Willowbrook, nestled between rolling hills and lush greenery, life 46 | > unfolded at a leisurely pace. The townsfolk, with their friendly smiles and welcoming demeanor 47 | > went about their daily routines. The aroma of freshly baked bread wafted through the air from 48 | > the local bakery, while children played in the park, their laughter echoing against the backdrop 49 | > of a clear blue sky. Willowbrook, a place where time seemed to slow down, embraced a sense of 50 | > tranquility that captivated anyone lucky enough to experience its charm.`, 51 | }, 52 | } 53 | 54 | for _, tc := range cases { 55 | t.Run(tc.name, func(t *testing.T) { 56 | actual := Indent(tc.input, tc.prefix) 57 | assert.Equal(t, tc.want, actual) 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /pkg/utils/util.go: -------------------------------------------------------------------------------- 1 | // Package utils provides several helper functions used throughout the library or useful to the upstream tools 2 | // that implement the primary parts of the library 3 | package utils 4 | 5 | import ( 6 | "bufio" 7 | "bytes" 8 | "fmt" 9 | "math/rand" 10 | "os" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | const Base62Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 16 | const UniqueIDLength = 6 // Should be good for 62^6 = 56+ billion combinations 17 | 18 | // UniqueID - Returns a unique (ish) id we can attach to resources and tfstate files, so they don't conflict 19 | // with each other. Uses base 62 to generate a 6 character string that's unlikely to collide with the handful 20 | // of tests we run in parallel. Based on code here: http://stackoverflow.com/a/9543797/483528 21 | func UniqueID() string { 22 | var out bytes.Buffer 23 | 24 | randVal := rand.New(rand.NewSource(time.Now().UnixNano())) //nolint: gosec 25 | for i := 0; i < UniqueIDLength; i++ { 26 | out.WriteByte(Base62Chars[randVal.Intn(len(Base62Chars))]) 27 | } 28 | 29 | return out.String() 30 | } 31 | 32 | // Prompt creates a prompt for direct user interaction to receive input 33 | func Prompt(expect string) error { 34 | fmt.Print("> ") 35 | reader := bufio.NewReader(os.Stdin) 36 | text, err := reader.ReadString('\n') 37 | if err != nil { 38 | return err 39 | } 40 | 41 | if strings.TrimSpace(text) != expect { 42 | return fmt.Errorf("aborted") 43 | } 44 | fmt.Println() 45 | 46 | return nil 47 | } 48 | 49 | func IsTrue(s string) bool { 50 | return strings.TrimSpace(strings.ToLower(s)) == "true" 51 | } 52 | -------------------------------------------------------------------------------- /pkg/utils/util_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/ekristen/libnuke/pkg/utils" 11 | ) 12 | 13 | func TestUniqueID(t *testing.T) { 14 | id := utils.UniqueID() 15 | assert.Len(t, id, utils.UniqueIDLength) 16 | } 17 | 18 | func TestPrompt(t *testing.T) { 19 | cases := []struct { 20 | name string 21 | want string 22 | }{ 23 | { 24 | name: "simple", 25 | want: "simple", 26 | }, 27 | { 28 | name: "with-spaces", 29 | want: "simple prompt", 30 | }, 31 | { 32 | name: "with\ttabs", 33 | want: "with\ttabs", 34 | }, 35 | { 36 | name: "with-special-chars", 37 | want: "another prompt with $ chars #", 38 | }, 39 | } 40 | 41 | for _, tc := range cases { 42 | t.Run(tc.name, func(t *testing.T) { 43 | // Create a pipe 44 | r, w, _ := os.Pipe() 45 | 46 | // Replace the standard input with our pipe 47 | oldStdin := os.Stdin 48 | defer func() { os.Stdin = oldStdin }() // Restore original Stdin 49 | os.Stdin = r 50 | 51 | // Write our input into the pipe 52 | _, _ = fmt.Fprintf(w, "%s\n", tc.want) 53 | _ = w.Close() 54 | 55 | // Call the function 56 | err := utils.Prompt(tc.want) 57 | 58 | // Check the result 59 | if err != nil { 60 | t.Errorf("Prompt returned an error: %v", err) 61 | } 62 | }) 63 | } 64 | } 65 | 66 | func TestPromptError(t *testing.T) { 67 | // Create a pipe 68 | r, w, _ := os.Pipe() 69 | 70 | // Replace the standard input with our pipe 71 | oldStdin := os.Stdin 72 | defer func() { os.Stdin = oldStdin }() // Restore original Stdin 73 | os.Stdin = r 74 | 75 | // Close the write end of the pipe to simulate an error 76 | _ = w.Close() 77 | 78 | // Call the function 79 | err := utils.Prompt("expected input") 80 | assert.Error(t, err) 81 | } 82 | 83 | func TestPromptTrimSpace(t *testing.T) { 84 | // Create a pipe 85 | r, w, _ := os.Pipe() 86 | 87 | // Replace the standard input with our pipe 88 | oldStdin := os.Stdin 89 | defer func() { os.Stdin = oldStdin }() // Restore original Stdin 90 | os.Stdin = r 91 | 92 | // Write our input into the pipe 93 | _, _ = w.WriteString("a expected input \n") 94 | _ = w.Close() 95 | 96 | // Call the function 97 | err := utils.Prompt("expected input") 98 | assert.Error(t, err) 99 | assert.Equal(t, "aborted", err.Error()) 100 | } 101 | 102 | func TestIsTrue(t *testing.T) { 103 | falseStrings := []string{"", "false", "treu", "foo"} 104 | for _, fs := range falseStrings { 105 | if utils.IsTrue(fs) { 106 | t.Fatalf("IsTrue falsely returned 'true' for: %s", fs) 107 | } 108 | } 109 | 110 | trueStrings := []string{"true", " true", "true ", " TrUe "} 111 | for _, ts := range trueStrings { 112 | if !utils.IsTrue(ts) { 113 | t.Fatalf("IsTrue falsely returned 'false' for: %s", ts) 114 | } 115 | } 116 | } 117 | --------------------------------------------------------------------------------