diff --git a/.github/renovate-repo-config.js b/.github/renovate-repo-config.js new file mode 100644 index 00000000..ae404f10 --- /dev/null +++ b/.github/renovate-repo-config.js @@ -0,0 +1,8 @@ +// A Javascript file is used instead of JSON so that environment variables can be pulled in +// via `process.env.VARIABLE_NAME` if needed. This allows secrets to be stored in Github +// then provided to the Renovate config here. +module.exports = { + $schema: "https://docs.renovatebot.com/renovate-schema.json", + // This file is empty for now, but it may be useful to be able to add private registry + // authentication (i.e. ECR) or secrets at a later point. +}; diff --git a/.github/renovate.json5 b/.github/renovate.json5 new file mode 100644 index 00000000..3d3d3d83 --- /dev/null +++ b/.github/renovate.json5 @@ -0,0 +1,63 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":enableRenovate", + ":semanticCommits", + // TODO enable these after extensive testing + // ":automergeDigest", + // ":automergeMinor", + // ":automergeAll", + // ":automergeRequireAllStatusChecks", + ":enableVulnerabilityAlerts", + ":ignoreUnstable", + ":label(dependency-update)", + ":prConcurrentLimitNone", + ":prHourlyLimitNone", + ":prImmediately", + ":rebaseStalePrs", + ":renovatePrefix", + "helpers:pinGitHubActionDigests", // This ensures that underlying tags are not replaced with a separate commit (tags are immutable but commits are not) + "preview:dockerVersions", + "regexManagers:githubActionsVersions", // See https://docs.renovatebot.com/presets-regexManagers/#regexmanagersgithubactionsversions for how to use this + "github>gravitational/shared-workflows//.github/renovate/commitMessage.json5", + "github>gravitational/shared-workflows//.github/renovate/labels.json5", + "github>gravitational/shared-workflows//.github/renovate/languages.json5", + "github>gravitational/shared-workflows//.github/renovate/renovate.json5", + // Presets for each project + "github>gravitational/shared-workflows//tools/ami-cleanup/renovate.json5" + ], + "useBaseBranchConfig": "merge", // This is set to allow for some degree of testing PRs, see https://github.com/renovatebot/renovate/discussions/16108 + "pinDigest": { + // TODO enable this after extensive testing + // "automerge": true + }, + // This is used so that not everything in the entire repo is setup for Renovate at once + // Initially this configuration is designed to only support the new GHA EKS clusters + "enabledManagers": [ + "github-actions", + "custom.regex", + "gomod" + ], + // Unfortunatly Renovate can only override manager defaults via a blacklist instead of a whitelist + "ignorePaths": [ + // These predate RFD 0001 and are managed by dependabot + "bot/**", + "github/workflows/codeql.yml", + "github/workflows/csv-lint.yaml", + "github/workflows/dependency-review.yaml", + "github/workflows/github-action-lint.yaml", + "github/workflows/govulncheck.yaml", + "github/workflows/json-lint.yaml", + "github/workflows/terraform-lint.yaml", + "github/workflows/trivy.yaml" + ], + "vulnerabilityAlerts": { + "dependencyDashboardApproval": true, + "rangeStrategy": "auto", + "commitMessageSuffix": "[SECURITY]", + "branchTopic": "{{{datasource}}}-{{{depName}}}-vulnerability", + "prCreation": "immediate", + "enabled": true + } +} diff --git a/.github/renovate/commitMessage.json5 b/.github/renovate/commitMessage.json5 new file mode 100644 index 00000000..2a3e18df --- /dev/null +++ b/.github/renovate/commitMessage.json5 @@ -0,0 +1,14 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + // See https://docs.renovatebot.com/configuration-options/#commitmessage for details + "commitMessageTopic": "{{depName}}", + "commitMessageExtra": "to {{newVersion}}", + "packageRules": [ + { + "matchManagers": [ + "github-actions" + ], + "commitMessageTopic": "action {{depName}}" + } + ] +} diff --git a/.github/renovate/labels.json5 b/.github/renovate/labels.json5 new file mode 100644 index 00000000..beebc417 --- /dev/null +++ b/.github/renovate/labels.json5 @@ -0,0 +1,86 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "packageRules": [ + // Labels for specific version change types + { + "matchUpdateTypes": [ + "major" + ], + "addLabels": [ + "renovate/type/major" + ] + }, + { + "matchUpdateTypes": [ + "minor" + ], + "addLabels": [ + "renovate/type/minor" + ] + }, + { + "matchUpdateTypes": [ + "patch" + ], + "addLabels": [ + "renovate/type/patch" + ] + }, + { + "matchUpdateTypes": [ + "digest" + ], + "addLabels": [ + "renovate/type/digest" + ] + }, + // Labels for specific artifact types + { + "matchDatasources": [ + "github-releases", + "github-tags" + ], + "addLabels": [ + "renovate/github-release" + ] + }, + { + "matchManagers": [ + "github-actions" + ], + "addLabels": [ + "renovate/github-action" + ] + }, + { + "matchManagers": [ + "gomod" + ], + "addLabels": [ + "renovate/golang" + ] + }, + // Labels for specific directories + { + "description": "Label Github workflow PRs", + "matchFileNames": [ + "**/workflows/*.yml", + "**/workflows/*.yaml" + ], + "addLabels": [ + "renovate/workflow/{{{ replace '\\.ya?ml$' '' (replace '^\\.github\\/workflows\\/' '' packageFile) }}}" + ] + }, + { + "description": "Label Renovate PRs", + "matchFileNames": [ + ".github/renovate*", + ".github/renovate/**", + "renovate.json5" + ], + "addLabels": [ + "renovate/config/{{{ replace '\\.js(?:on5?)?$' '' (replace '^\\.github\\/' '' packageFile) }}}" + ] + } + ] +} diff --git a/.github/renovate/languages.json5 b/.github/renovate/languages.json5 new file mode 100644 index 00000000..8044d5dc --- /dev/null +++ b/.github/renovate/languages.json5 @@ -0,0 +1,54 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "gomod": { + "postUpdateOptions": [ + "gomodTidy", + "gomodUpdateImportPaths" + ] + }, + "customManagers": [ + // Earthfiles + { + "description": "Earthly language version - Earthfiles", + "customType": "regex", + "fileMatch": [ + "Earthfile$" + ], + "matchStrings": [ + "^\\s*VERSION(?:.*(?:\\\\\\n)?)*(?\\d+\\.\\d)+$" + ], + "datasourceTemplate": "github-releases", + "depNameTemplate": "earthly/earthly", + "packageNameTemplate": "earthly/earthly", + "versioningTemplate": "semver-coerced", + // Extract the major and minor version from the latest GH release + "extractVersionTemplate": "^v?(?\\d+\\.\\d+).*$" + }, + { + "description": "Earthly version - setup action in GHA workflows", + "customType": "regex", + "fileMatch": [ + "^.*/workflows/[^/].ya?ml$" + ], + "matchStrings": [ + "# renovate: earthly-version\\s*\\n\\s*version: \\s*(?.*?)\\s*\\n" + ], + "datasourceTemplate": "github-releases", + "depNameTemplate": "earthly/earthly", + "packageNameTemplate": "earthly/earthly", + "versioningTemplate": "semver-coerced" + }, + { + "description": "Container images - Earthfiles", + "customType": "regex", + "fileMatch": [ + "Earthfile$" + ], + "matchStrings": [ + "\\s*FROM \\s*.*?(?\\S+):(?[^\\s\\$]+)" + ], + "datasourceTemplate": "docker", + "versioningTemplate": "docker" + } + ] +} diff --git a/.github/renovate/renovate.json5 b/.github/renovate/renovate.json5 new file mode 100644 index 00000000..b5cf33d0 --- /dev/null +++ b/.github/renovate/renovate.json5 @@ -0,0 +1,19 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "github-actions": { + "fileMatch": [ + "^\\.github/workflows/renovate(?:-bypass)?\\.ya?ml$" + ] + }, + "packageRules": [ + { + "description": "Assign to maintainer", + "matchFileNames": [ + ".github/workflows/renovate*.yaml" + ], + "extends": [ + ":assignAndReview(fheinecke)", + ] + } + ] +} diff --git a/.github/workflows/ami-cleanup-cd.yaml b/.github/workflows/ami-cleanup-cd.yaml new file mode 120000 index 00000000..4e4554a4 --- /dev/null +++ b/.github/workflows/ami-cleanup-cd.yaml @@ -0,0 +1 @@ +../../tools/ami-cleanup/workflows/cd.yaml \ No newline at end of file diff --git a/.github/workflows/ami-cleanup-ci.yaml b/.github/workflows/ami-cleanup-ci.yaml new file mode 120000 index 00000000..81761bf7 --- /dev/null +++ b/.github/workflows/ami-cleanup-ci.yaml @@ -0,0 +1 @@ +../../tools/ami-cleanup/workflows/ci.yaml \ No newline at end of file diff --git a/.github/workflows/renovate.yaml b/.github/workflows/renovate.yaml new file mode 100644 index 00000000..e07ff2fe --- /dev/null +++ b/.github/workflows/renovate.yaml @@ -0,0 +1,152 @@ +--- +# This should eventually moved to a reusable workflow within this repo +# This was originally written for cloud-terraform, and later ported to +# shared-workflows +name: Update dependencies with Renovate + +on: + workflow_dispatch: + inputs: + dry-run: + description: "True to test changes without applying them, false otherwise" + default: false + required: false + type: boolean + log-level: + description: "Log severity level" + default: "debug" + required: false + type: choice + options: + - fatal + - error + - warn + - info + - debug + - trace # Warning: this will generate a >512MB log! + schedule: + - cron: "0 15 * * 1-5" # 15:00 UTC is 8:00 PST, 1-5 is Monday-Friday + push: + branches: + - main + paths: + - .github/workflows/renovate.yaml + - .github/renovate-repo-config.js + - .github/renovate.json5 + - .github/renovate/**.json5 + - '**/renovate.json5' + pull_request: + paths: + - .github/workflows/renovate.yaml + - .github/renovate-repo-config.js + - .github/renovate.json5 + - .github/renovate/**.json5 + - '**/renovate.json5' + +# There shouldn't ever be a need to run this concurrently and it may avoid +# some problems +concurrency: + cancel-in-progress: true + group: Only allow one "${{ github.workflow }}" on ${{ github.ref }} run at a time + +env: + # Default values for inputs when the trigger is not `workflow_dispatch` + DRY_RUN_DEFAULT: false + LOG_LEVEL_DEFAULT: debug + +jobs: + run-renovate: + name: Update repo dependencies + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Set PR testing variables + if: contains(fromJSON('["pull_request", "merge_group"]'), github.event_name) + env: + PR_BRANCH: ${{ github.head_ref }} + run: | + echo "PR detected, testing Renovate with a dry run targeting the PR branch" + echo "DRY_RUN_DEFAULT=true" | tee -a "$GITHUB_ENV" + echo "RENOVATE_BASE_BRANCHES=$PR_BRANCH" | tee -a "$GITHUB_ENV" + # This script/action will be moved to a separate action in my work + # immediately following this project. For now it lives here to + # avoid scope creep. + # + # Github can be notoriously difficult to authenticate and talk with. + # There are four different types of authentication. This step + # generates an app JWT token, and an app installation token, for + # other steps that need a specific one. + - name: Install NPM dependencies + run: npm install '@octokit/auth-app' '@actions/github' + - name: Generate Github access tokens + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + id: generate-tokens + env: + APP_ID: ${{ vars.RENOVATE_GHA_APP_ID }} + PRIVATE_KEY: ${{ secrets.RENOVATE_GHA_PRIVATE_KEY }} + with: + script: | + const { createAppAuth } = require("@octokit/auth-app"); + const { getOctokit } = require("@actions/github"); + + // App authentication, which uses a JWT + const appAuthFunction = createAppAuth({appId: process.env.APP_ID, privateKey: process.env.PRIVATE_KEY}); + const appAuth = await appAuthFunction({ type: "app" }); + // TODO export token via `appAuth.token` + core.setSecret(appAuth.token) + core.setOutput("app-jwt-token", appAuth.token) + const appOctokit = getOctokit(appAuth.token); + + // Installation authentication, which uses an installation token + let installationId = process.env["INSTALLATION_ID"]; + if (installationId === undefined) { + try { + // Repo can be specified via `GITHUB_REPOSITORY` env variable + installationId = (await appOctokit.rest.apps.getRepoInstallation(context.repo)).data.id; + } catch (error) { + throw new Error( + "Could not get repo installation to find ID. Is the app installed on this repo?", + { cause: error }, + ); + } + } + const installationToken = (await appOctokit.rest.apps.createInstallationAccessToken({installation_id: installationId})).data.token; + core.setSecret(installationToken) + core.setOutput("app-installation-token", installationToken) + # These two actions will also be moved out to a separate repo after this project is complete + - name: Get app JWT information + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + id: app-jwt-info + with: + github-token: ${{ steps.generate-tokens.outputs.app-jwt-token }} + script: | + const appSlug = (await github.rest.apps.getAuthenticated()).data.slug; + const appUserName = `${appSlug}[bot]` + core.setOutput("app-username", appUserName); + - name: Get app installation information + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + id: app-installation-info + env: + APP_USERNAME: ${{ steps.app-jwt-info.outputs.app-username }} + with: + github-token: ${{ steps.generate-tokens.outputs.app-installation-token }} + script: | + const userId = (await github.rest.users.getByUsername({username: process.env.APP_USERNAME})).data.id + core.setOutput("user-id", userId); + core.setOutput("user-email", `${userId}+${process.env.APP_USERNAME}@users.noreply.github.com`); + - name: Renovate + uses: renovatebot/github-action@b8ce565a2e98de1fec9696a76fba7beb01ec29b2 # v39.2.3 + env: + # Config values + RENOVATE_DRY_RUN: ${{ inputs.dry-run || env.DRY_RUN_DEFAULT }} + RENOVATE_LOG_FILE_LEVEL: ${{ inputs.log-level || env.LOG_LEVEL_DEFAULT }} + LOG_LEVEL: ${{ inputs.log-level || env.LOG_LEVEL_DEFAULT }} + LOG_FORMAT: "text" # Any value but "json" will pretty-print + RENOVATE_USERNAME: ${{ steps.app-jwt-info.outputs.app-username }} + RENOVATE_GIT_AUTHOR: "${{ steps.app-jwt-info.outputs.app-username }} <${{ steps.app-installation-info.outputs.user-email }}>" + RENOVATE_REPOSITORIES: ${{ github.repository }} + # This is the config for Renovate itself, not the repo-specific config + RENOVATE_CONFIG_FILE: .github/renovate-repo-config.js + with: + token: ${{ steps.generate-tokens.outputs.app-installation-token }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..a35ac6eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.vscode/launch.json +*.log +.tmp* diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..968e1332 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "earthly.earthfile-syntax-highlighting", + "golang.go", + "github.vscode-github-actions" + ] +} diff --git a/go.work b/go.work new file mode 100644 index 00000000..89308ad4 --- /dev/null +++ b/go.work @@ -0,0 +1,6 @@ +go 1.21.6 + +use ( + ./bot + ./tools/ami-cleanup/src +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 00000000..9f7f0d1d --- /dev/null +++ b/go.work.sum @@ -0,0 +1,82 @@ +cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg= +cloud.google.com/go/compute v1.20.1 h1:6aKEtlUiwEpJzM001l0yFkpXmUVXaN8W+fbkb2AZNbg= +cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= +cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= +github.com/aws/aws-sdk-go-v2 v1.22.1 h1:sjnni/AuoTXxHitsIdT0FwmqUuNUuHtufcVDErVFT9U= +github.com/aws/aws-sdk-go-v2 v1.22.1/go.mod h1:Kd0OJtkW3Q0M0lUWGszapWjEvrXDzRW+D21JNsroB+c= +github.com/aws/aws-sdk-go-v2/config v1.22.2 h1:fuDAlqkXcf7taDK4i1ejaAzDKajnlvHRQldqz649DeY= +github.com/aws/aws-sdk-go-v2/config v1.22.2/go.mod h1:cBBFBM39pRUzw4dCLuRYkTDeIcscOtfFQNbQcgWnbL4= +github.com/aws/aws-sdk-go-v2/credentials v1.15.1 h1:hmf6lAm9hk7uLCfapZn/jL05lm6Uwdbn1B0fgjyuf4M= +github.com/aws/aws-sdk-go-v2/credentials v1.15.1/go.mod h1:QTcHga3ZbQOneJuxmGBOCxiClxmp+TlvmjFexAnJ790= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.2 h1:gIeH4+o1MN/caGBWjoGQTUTIu94xD6fI5B2+TcwBf70= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.2/go.mod h1:wLyMIo/zPOhQhPXTddpfdkSleyigtFi8iMnC+2m/SK4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.1 h1:fi1ga6WysOyYb5PAf3Exd6B5GiSNpnZim4h1rhlBqx0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.1/go.mod h1:V5CY8wNurvPUibTi9mwqUqpiFZ5LnioKWIFUDtIzdI8= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.1 h1:ZpaV/j48RlPc4AmOZuPv22pJliXjXq8/reL63YzyFnw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.1/go.mod h1:R8aXraabD2e3qv1csxM14/X9WF4wFMIY0kH4YEtYD5M= +github.com/aws/aws-sdk-go-v2/internal/ini v1.5.1 h1:6zMMQmHFW0F+2bnK2Y66lleMjrmvPU6sbhKVqNcqCMg= +github.com/aws/aws-sdk-go-v2/internal/ini v1.5.1/go.mod h1:VV/Kbw9Mg1GWJOT9WK+oTL3cWZiXtapnNvDSRqTZLsg= +github.com/aws/aws-sdk-go-v2/service/account v1.13.0 h1:+Y0mbmsQ7/nsCOryE4rcH0FmivJzX0nkRLaUSa23YP0= +github.com/aws/aws-sdk-go-v2/service/account v1.13.0/go.mod h1:La5Mft3NPk+RBq/EK/mY2UXBQwlPj4SBDnPtTrGJxT8= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.130.0 h1:a7CPCX/m+owAiAqcK8W9/SoB7EA4QUE4BddYdFyEGco= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.130.0/go.mod h1:EJlGVMO5zynmSDdvwJfFa2RzAZoHI4gVJER0h82/dYk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.1 h1:2OXw3ppu1XsB6rqKEMV4tnecTjIY3PRV2U6IP6KPJQo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.1/go.mod h1:FZB4AdakIqW/yERVdGJA6Z9jraax1beXfhBBnK2wwR8= +github.com/aws/aws-sdk-go-v2/service/sso v1.17.0 h1:I/Oh3IxGPfHXiGnwM54TD6hNr/8TlUrBXAtTyGhR+zw= +github.com/aws/aws-sdk-go-v2/service/sso v1.17.0/go.mod h1:H6NCMvDBqA+CvIaXzaSqM6LWtzv9BzZrqBOqz+PzRF8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.19.0 h1:irbXQkfVYIRaewYSXcu4yVk0m2T+JzZd0dkop7FjmO0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.19.0/go.mod h1:4wPNCkM22+oRe71oydP66K50ojDUC33XutSMi2pEF/M= +github.com/aws/aws-sdk-go-v2/service/sts v1.25.0 h1:sYIFy8tm1xQwRvVQ4CRuBGXKIg9sHNuG6+3UAQuoujk= +github.com/aws/aws-sdk-go-v2/service/sts v1.25.0/go.mod h1:S/LOQUeYDfJeJpFCIJDMjy7dwL4aA33HUdVi+i7uH8k= +github.com/aws/smithy-go v1.16.0 h1:gJZEH/Fqh+RsvlJ1Zt4tVAtV6bKkp3cC+R6FCZMNzik= +github.com/aws/smithy-go v1.16.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= +github.com/census-instrumentation/opencensus-proto v0.2.1 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk= +github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= +github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 h1:hzAQntlaYRkVSFEfj9OTWlVV1H155FMD8BTKktLv0QI= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1 h1:zH8ljVhhq7yC0MIeUL/IviMtY8hx2mK8cN9wEYb8ggw= +github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021 h1:fP+fF0up6oPY49OrjPrhIJ8yQfdIM85NXMLkMg1EXVs= +github.com/envoyproxy/protoc-gen-validate v0.1.0 h1:EQciDnbrYxy13PgWoY8AqoxGiPrpgBZ1R8UNe3ddc+A= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= +github.com/golang/mock v1.1.1 h1:G5FRp8JnTd7RQH5kemVNlMeyXQAztQ3mOWV95KxsXH8= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= +github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCbXsO+nc9n1hCxHcGA3Zx3Eo+UHZoInFGUIXNM= +github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= +go.opentelemetry.io/proto/otlp v0.7.0 h1:rwOQPCuKAKmwGKq2aVNnYIibI6wnV7EvzgfTCzcdGg8= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= +golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4 h1:c2HOrn5iMezYjSlGPncknSEr/8x5LELb/ilJbXi9DEA= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3 h1:XQyxROzUlZH+WIQwySDgnISgOivlhjIEwaQaJEJrrN0= +golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= +golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8= +golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= +golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= +golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 h1:+kGHl1aib/qcwaRi1CbqBZ1rk19r85MNUf8HaBghugY= +google.golang.org/grpc v1.43.0 h1:Eeu7bZtDZ2DpRCsLhUlcrLnvYaMK1Gz86a+hMVvELmM= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc h1:/hemPrYIhOhy8zYrNj+069zDB68us2sMGsfkFJO0iZs= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= diff --git a/tools/ami-cleanup/.gitignore b/tools/ami-cleanup/.gitignore new file mode 100644 index 00000000..03567fc4 --- /dev/null +++ b/tools/ami-cleanup/.gitignore @@ -0,0 +1 @@ +outputs diff --git a/tools/ami-cleanup/CHANGELOG.md b/tools/ami-cleanup/CHANGELOG.md new file mode 100644 index 00000000..6cd96194 --- /dev/null +++ b/tools/ami-cleanup/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] +### Added +- Initial release. AMI cleanup tool can remove dev AMIs from all regions in a given account that are more than 30 days old. diff --git a/tools/ami-cleanup/Earthfile b/tools/ami-cleanup/Earthfile new file mode 100644 index 00000000..859a669f --- /dev/null +++ b/tools/ami-cleanup/Earthfile @@ -0,0 +1,177 @@ +# These args will not be needed upon 0.8 release, which is scheduled for late January/ +# early February 2024. +VERSION --arg-scope-and-set --git-refs --global-cache 0.7 + +ARG --global GIT_TAG +ARG --global BINARY_NAME="ami-cleanup" +ARG --global IMAGE_NAME="ami-cleanup" + +# This target is used to setup a common Go environment used for both builds and tests. +go-environment: + # Environment variables + ARG --required TARGETOS + ARG --required TARGETARCH + # Native arch is required because otherewise images will default to TARGETARCH, + # which is overridden by `--platform`. + ARG --required NATIVEARCH + + # This keeps the Go version set in a single place + # A container is used to pin the `sed` dependency. `LOCALLY` could be used instead, but is + # disallowed by the `--strict` Earthly flag which is used to help enfore reproducability. + FROM --platform="linux/$NATIVEARCH" alpine:3.19.0 + WORKDIR /gomod + COPY src/go.mod . + LET GO_VERSION=$(sed -rn 's/^go (.*)$/\1/p' go.mod) + + # Run on the native architecture, but setup for cross compilation. + FROM --platform="linux/$NATIVEARCH" "golang:$GO_VERSION" + ENV GOOS=$TARGETOS + ENV GOARCH=$TARGETARCH + WORKDIR /go/src + CACHE --sharing shared $(go env GOMODCACHE) + + # Load the source and download modules + COPY src/ . + RUN go mod download -x + +# Produces a single executable binary file for the target platform. +# This should generally be called as `earthly --platform= +binary`. +binary: + ARG --required TARGETPLATFORM + FROM --platform "$TARGETPLATFORM" +go-environment + # Caches are specific to a given target, so the GOCACHE is declared here as it + # is updated when builds run + CACHE --sharing shared --id gocache $(go env GOCACHE) + + # Setup for the build + LET LINKER_FLAGS="-s -w" + IF [ -n "$GIT_TAG" ] + ARG EARTHLY_GIT_SHORT_HASH + SET LINKER_FLAGS="$LINKER_FLAGS -X 'main.Version=$GIT_TAG+$EARTHLY_GIT_SHORT_HASH'" + END + LET BINARY_OUTPUT_PATH="../$BINARY_NAME" + + # Do the actual build + RUN go build -o "$BINARY_OUTPUT_PATH" -ldflags="$LINKER_FLAGS" cmd/main.go + + # Process the outputs + SAVE ARTIFACT "$BINARY_OUTPUT_PATH" AS LOCAL "outputs/$TARGETPLATFORM/$BINARY_NAME" + +# Same as `binary`, except the platform defaults to the local host. +local-binary: + # This is a workaround for the default TARGETOS value being the buildkit OS (linux), + # which is wrong when running on MacOS. Unfortunately this is not fixed by specifying + # TARGETOS under LOCALLY, because then it cannot be overridden via the platform arg. + ARG --required USERPLATFORM + BUILD --platform="$USERPLATFORM" +binary + +# Produces a container image and multiarch manifest. These are automatically loaded into the +# local Docker image cache. If multiple platforms are specified, then they are all added +# under the same image. +container-image: + # Build args + ARG --required TARGETARCH + ARG --required NATIVEARCH + ARG CONTAINER_REGISTRY="" + + # Setup for build + # `IF` statements essentially run as shell `if` statements, so a build context must be declared + # for them. + FROM --platform="linux/$NATIVEARCH" alpine:3.19.0 + LET IMAGE_TAG="latest" + IF [ -n "$GIT_TAG" ] + SET IMAGE_TAG="$GIT_TAG" + END + + # Do the actual build + RUN echo "FULL IMAGE NAME: $CONTAINER_REGISTRY$IMAGE_NAME:$IMAGE_TAG" + FROM --platform="linux/$TARGETARCH" scratch + COPY --platform="linux/$TARGETARCH" +binary/* / + # Unfortunately arg expansion is not supported here, see https://github.com/earthly/earthly/issues/1846 + ENTRYPOINT [ "/ami-cleanup" ] + + # Process the outputs + SAVE IMAGE --push "$CONTAINER_REGISTRY$IMAGE_NAME:$IMAGE_TAG" + +# Same as `binary`, but wraps the output in a tarball. +tarball: + ARG --required TARGETOS + ARG --required TARGETARCH + ARG --required NATIVEARCH + ARG TARBALL_NAME="$BINARY_NAME-$TARGETOS-$TARGETARCH.tar.gz" + + FROM --platform="linux/$NATIVEARCH" alpine:3.19.0 + WORKDIR /tarball + COPY --platform="$TARGETOS/$TARGETARCH" +binary/* . + RUN tar -czvf "$TARBALL_NAME" * + SAVE ARTIFACT $TARBALL_NAME AS LOCAL outputs/$TARGETOS/$TARGETARCH/$TARBALL_NAME + +local-tarball: + ARG --required USERPLATFORM + BUILD --platform="$USERPLATFORM" +tarball + +all: + BUILD +local-binary + BUILD +local-tarball + BUILD +container-image + +# Runs the project's Go tests. +test: + # Probably not needed, but this supports running tests on different architectures. + ARG --required TARGETPLATFORM + # For options, see + # https://github.com/gotestyourself/gotestsum?tab=readme-ov-file#output-format + ARG OUTPUT_FORMAT="pkgname-and-test-fails" + + FROM --platform "$TARGETPLATFORM" +go-environment + WORKDIR /go/src + CACHE --sharing shared $(go env GOMODCACHE) + CACHE --sharing shared --id gocache $(go env GOCACHE) + RUN go install gotest.tools/gotestsum@latest + RUN gotestsum --format "$OUTPUT_FORMAT" ./... -- -shuffle on -timeout 2m -race + +lint: + # For options, see https://golangci-lint.run/usage/configuration/#command-line-options + ARG OUTPUT_FORMAT="colored-line-number" + + # Setup the linter and configure the environment + FROM +go-environment + WORKDIR /go/src + ENV GOLANGCI_LINT_CACHE=/golangci-lint-cache + CACHE $GOLANGCI_LINT_CACHE + CACHE --sharing shared $(go env GOMODCACHE) + CACHE --sharing shared --id gocache $(go env GOCACHE) + RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.55.2 + + # Run the linter + RUN golangci-lint run ./... --out-format "$OUTPUT_FORMAT" + +# Removes local file and container image artifacts. +clean: + LOCALLY + + # Delete output files + RUN rm -rf "outputs/" + + # Delete container images + FOR IMAGE IN $(docker image ls --filter "reference=$IMAGE_NAME" --quiet | sort | uniq) + RUN docker image rm --force "$IMAGE" + END + +# Cuts a new GH release and pushes file assets to it. Also pushes container images. +release: + ARG --required GIT_TAG # This global var is redeclared here to ensure that it is set via `--required` + ARG CONTAINER_REGISTRY="ghcr.io/gravitational/shared-workflows/" + + # Create GH release and upload artifact(s) + FROM alpine:3.19.0 + # Unfortunately GH does not release a container image for their CLI, see https://github.com/cli/cli/issues/2027 + RUN apk add github-cli + WORKDIR /release_artifacts + COPY --platform=linux/amd64 --platform=linux/arm64 --platform=darwin/arm64 +tarball/* . + COPY CHANGELOG.md /CHANGELOG.md + # Run commands with "--push" set will only run when the "--push" arg is provided via CLI + RUN --push gh release create --draft --verify-tag --notes-file "/CHANGELOG.md" --prerelease "$GIT_TAG" "./*" + + # Build container images and push them + BUILD --platform=linux/amd64 --platform=linux/arm64 +container-image --CONTAINER_REGISTRY="$CONTAINER_REGISTRY" diff --git a/tools/ami-cleanup/LICENSE b/tools/ami-cleanup/LICENSE new file mode 100644 index 00000000..61bc9878 --- /dev/null +++ b/tools/ami-cleanup/LICENSE @@ -0,0 +1,662 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + + Copyright (C) 2023 Gravitational, Inc. + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/tools/ami-cleanup/README.md b/tools/ami-cleanup/README.md new file mode 100644 index 00000000..37ec5824 --- /dev/null +++ b/tools/ami-cleanup/README.md @@ -0,0 +1,33 @@ +# AMI cleanup tool + +This tool deregisters/deletes Amazon Machine Images (AMIs) for dev tags that are over a month old. Dev tag images are defined as any image matching the name filter `*name*`. This tool runs over all AWS regions enabled in the account. + +## Usage: +The local machine must be authenticated with the target AWS account prior to running any of the below commands. + +CLI: `ami-cleanup [--dry-run]` +Docker: `docker run --rm --env ghcr.io/gravitational/shared-workflows/ami-cleanup: [--dry-run]` +GHA: +```yaml +- uses: gravitational/shared-workflows/tools/ami-cleanup@ + with: + dry-run: +``` + +## Building: +This tool requires [Earthly](https://earthly.dev/) to build, used as an alternative to Makefiles. Installation instructions for Earthly are available [here](https://earthly.dev/get-earthly). + +To build locally, the following commands are available: +* `earthly +local-binary` +* `earthly +local-tarball` +* `earthly +container-image` +* `earthly +all` - produces all of the above three outputs + +To build and cut a release, run `earthly --push --ci +release --GIT_TAG=`. Omit the `--push` arg for a dry run that will not affect production resources. + +## Future work +* Make the name filter configurable +* Make the AMI age configurable +* If possible, check if the AMI is in use anywhere prior to deletion +* Add reporting options/table output that lists deleted images +* Mark GH releases as pre-releases if semver shows that they should be diff --git a/tools/ami-cleanup/action.yaml b/tools/ami-cleanup/action.yaml new file mode 100644 index 00000000..c0e2f3fe --- /dev/null +++ b/tools/ami-cleanup/action.yaml @@ -0,0 +1,19 @@ +--- +name: Cleanup old AMI images +description: Deletes dev tag AMI images more than 30 days old from all AWS regions + +inputs: + dry-run: + description: "true to actually clean up images, false to perform a dry run" + default: "false" + required: false + +branding: + icon: type + color: purple + +runs: + using: docker + image: ghcr.io/gravitational/shared-workflows/ami-cleanup + args: + - ${{ inputs.dry-run == 'true' && '--dry-run' || --no-dry-run}} diff --git a/tools/ami-cleanup/log-dependency-change.sh b/tools/ami-cleanup/log-dependency-change.sh new file mode 100755 index 00000000..a7e4921e --- /dev/null +++ b/tools/ami-cleanup/log-dependency-change.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -euo pipefail + +# This eventually needs to move to a separate project along side the Renovate workflow. +# This script update changelogs ever time there is a dependency update, which Renovate +# then includes in the update PR. + +if [ "$#" -lt 2 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +UPDATED_FILE_PATH="$1" +CHANGELOG_ENTRY="${*:2}" + +search_upwards_for_changelog() { + # Based on https://superuser.com/a/1752082 with some changes + SEARCH_DIR=$(realpath "$(dirname "$UPDATED_FILE_PATH")") + while RESULT=$(find "$SEARCH_DIR"/ -maxdepth 1 -type f -name "CHANGELOG.md") + # If result not found and not at the repo root (which contains the .git directory) + [ -z "$RESULT" ] && [ -z "$(find "$SEARCH_DIR"/ -maxdepth 1 -type d -name '.git')" ] + do SEARCH_DIR=$(dirname "$SEARCH_DIR"); done + + realpath "$RESULT" +} + +CHANGELOG_PATH=$(search_upwards_for_changelog) + +if [ -z "$CHANGELOG_PATH" ]; then + echo "No changelog found in a directory above $UPDATED_FILE_PATH, skipping update" >&2 + exit +fi + +if grep --quiet "$CHANGELOG_ENTRY" "$CHANGELOG_PATH"; then + echo "Changelog entry '$CHANGELOG_ENTRY' already found in changelog at $CHANGELOG_PATH, skipping update" >&2 + exit +fi + +if ! command -v "chan"; then + echo "chan NPM module not found, installing" >&2 + # TODO manage this with Renovate once this script and the action are pulled into a separate project + npm install --global '@geut/chan@3.2.9' +fi + +CHANGELOG_DIRECTORY=$(dirname "$CHANGELOG_PATH") + +echo "Updating changelog $CHANGELOG_PATH with entry '$CHANGELOG_ENTRY'" +cd "$CHANGELOG_DIRECTORY" && chan changed -g "Dependency Updates" "$CHANGELOG_ENTRY" diff --git a/tools/ami-cleanup/renovate.json5 b/tools/ami-cleanup/renovate.json5 new file mode 100644 index 00000000..6c3a3d5d --- /dev/null +++ b/tools/ami-cleanup/renovate.json5 @@ -0,0 +1,53 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "github-actions": { + "fileMatch": [ + "^tools/ami-cleanup/workflows/.*\\.ya?ml$" + ] + }, + "gomod": { + "fileMatch": [ + "^tools/ami-cleanup/src/go\\.mod$" + ] + }, + "packageRules": [ + { + "description": "Assign to maintainer", + "matchFileNames": [ + "tools/ami-cleanup" + ], + "extends": [ + ":assignAndReview(fheinecke)" + ] + }, + { + "description": "Group AMI cleanup tool changes", + "matchFileNames": [ + "tools/ami-cleanup" + ], + "additionalBranchPrefix": "ami-cleanup-", + "groupName": "ami-cleanup", + "groupSlug": "ami-cleanup", + "group": { + "commitMessageTopic": "{{{groupName}}} group" + } + }, + { + "description": "Update AMI cleanup tool changelog", + "matchFileNames": [ + "tools/ami-cleanup" + ], + "postUpgradeTasks": { + "commands": [ + "update_changelog \"{{{packageFile}}}\" \"{{{depName}}} from {{{currentVersion}}} to {{{newVersion}}}\"" + ], + "fileFilters": [ + "**/CHANGELOG.md" + ], + "executionMode": "update" + } + } + ], + // Required for changelog updating + "allowScripts": true +} diff --git a/tools/ami-cleanup/src/cmd/main.go b/tools/ami-cleanup/src/cmd/main.go new file mode 100644 index 00000000..fc1c92a1 --- /dev/null +++ b/tools/ami-cleanup/src/cmd/main.go @@ -0,0 +1,44 @@ +/* + * AMI cleanup tool + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package main + +import ( + "context" + "log" + + "github.com/alecthomas/kingpin/v2" + + "github.com/gravitational/shared-workflows/tools/ami-cleanup/internal" +) + +var ( + Version = "0.0.0-dev" +) + +func main() { + kingpin.Version(Version) + dryRun := kingpin.Flag("dry-run", "set to only report the expected changes without actually performing them").Bool() + kingpin.Parse() + + ctx := context.Background() + err := internal.NewApplicationInstance(*dryRun).Run(ctx) + if err != nil { + log.Fatalf("%v", err) + } +} diff --git a/tools/ami-cleanup/src/cmd/main_test.go b/tools/ami-cleanup/src/cmd/main_test.go new file mode 100644 index 00000000..59afa1d8 --- /dev/null +++ b/tools/ami-cleanup/src/cmd/main_test.go @@ -0,0 +1,30 @@ +/* + * AMI cleanup tool + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/mod/semver" +) + +func TestVersion(t *testing.T) { + require.True(t, semver.IsValid(Version), "the version string is not a valid semver") +} diff --git a/tools/ami-cleanup/src/go.mod b/tools/ami-cleanup/src/go.mod new file mode 100644 index 00000000..5f66db94 --- /dev/null +++ b/tools/ami-cleanup/src/go.mod @@ -0,0 +1,46 @@ +module github.com/gravitational/shared-workflows/tools/ami-cleanup + +go 1.21.6 + +require ( + github.com/aws/aws-sdk-go-v2/config v1.26.3 + github.com/aws/aws-sdk-go-v2/service/account v1.14.6 + github.com/aws/aws-sdk-go-v2/service/ec2 v1.143.0 + github.com/gravitational/trace v1.3.1 + github.com/relvacode/iso8601 v1.3.0 + github.com/stretchr/testify v1.8.4 + golang.org/x/exp v0.0.0-20240110193028-0dcbfd608b1e + golang.org/x/mod v0.14.0 +) + +require ( + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/xhit/go-str2duration/v2 v2.1.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +require ( + github.com/alecthomas/kingpin/v2 v2.4.0 + github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 // indirect + github.com/aws/aws-sdk-go-v2 v1.24.1 + github.com/aws/aws-sdk-go-v2/credentials v1.16.14 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.18.6 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // indirect + github.com/aws/smithy-go v1.19.0 + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/jonboulle/clockwork v0.4.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + golang.org/x/crypto v0.18.0 // indirect + golang.org/x/net v0.20.0 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/term v0.16.0 // indirect +) diff --git a/tools/ami-cleanup/src/go.sum b/tools/ami-cleanup/src/go.sum new file mode 100644 index 00000000..4c83aac6 --- /dev/null +++ b/tools/ami-cleanup/src/go.sum @@ -0,0 +1,215 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= +github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= +github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 h1:ez/4by2iGztzR4L0zgAOR8lTQK9VlyBVVd7G4omaOQs= +github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU= +github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= +github.com/aws/aws-sdk-go-v2/config v1.26.3 h1:dKuc2jdp10y13dEEvPqWxqLoc0vF3Z9FC45MvuQSxOA= +github.com/aws/aws-sdk-go-v2/config v1.26.3/go.mod h1:Bxgi+DeeswYofcYO0XyGClwlrq3DZEXli0kLf4hkGA0= +github.com/aws/aws-sdk-go-v2/credentials v1.16.14 h1:mMDTwwYO9A0/JbOCOG7EOZHtYM+o7OfGWfu0toa23VE= +github.com/aws/aws-sdk-go-v2/credentials v1.16.14/go.mod h1:cniAUh3ErQPHtCQGPT5ouvSAQ0od8caTO9OOuufZOAE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= +github.com/aws/aws-sdk-go-v2/service/account v1.14.6 h1:RXoRrZTIL6dvImOOWvPSBNjB9UWAYH4NlKrFath1aBs= +github.com/aws/aws-sdk-go-v2/service/account v1.14.6/go.mod h1:7MYwRJM9vSCKQapaQlPOTZ15R6G5NBndPCuiaK8bJOE= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.143.0 h1:ZAO4y7MSRqU74ZFCA+HC6Ek5fI7dsTdwJg88s72I/gE= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.143.0/go.mod h1:hIsHE0PaWAQakLCshKS7VKWMGXaqrAFp4m95s2W9E6c= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10/go.mod h1:wohMUQiFdzo0NtxbBg0mSRGZ4vL3n0dKjLTINdcIino= +github.com/aws/aws-sdk-go-v2/service/sso v1.18.6 h1:dGrs+Q/WzhsiUKh82SfTVN66QzyulXuMDTV/G8ZxOac= +github.com/aws/aws-sdk-go-v2/service/sso v1.18.6/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.6 h1:Yf2MIo9x+0tyv76GljxzqA3WtC5mw7NmazD2chwjxE4= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.6/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8= +github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0= +github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U= +github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= +github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gravitational/trace v1.3.1 h1:jwZEuRtCYpLhUtqHo+JH+lu2qM0LB98UagqHtvdKuLI= +github.com/gravitational/trace v1.3.1/go.mod h1:E61mn73aro7Zg9gZheZaeUsK6gjUMbCLazY76xuYAVA= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= +github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/relvacode/iso8601 v1.3.0 h1:HguUjsGpIMh/zsTczGN3DVJFxTU/GX+MMmzcKoMO7ko= +github.com/relvacode/iso8601 v1.3.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= +github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20240110193028-0dcbfd608b1e h1:723BNChdd0c2Wk6WOE320qGBiPtYx0F0Bbm1kriShfE= +golang.org/x/exp v0.0.0-20240110193028-0dcbfd608b1e/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.43.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/tools/ami-cleanup/src/internal/aws.go b/tools/ami-cleanup/src/internal/aws.go new file mode 100644 index 00000000..9c48ca20 --- /dev/null +++ b/tools/ami-cleanup/src/internal/aws.go @@ -0,0 +1,133 @@ +/* + * AMI cleanup tool + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// TODO move this under /libs at some point, however it is nowhere near +// large enough to justify separate module today +package internal + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/aws/transport/http" + "github.com/aws/aws-sdk-go-v2/service/account" + "github.com/aws/aws-sdk-go-v2/service/ec2" + "github.com/aws/smithy-go" +) + +const DryRunErrorCode = "DryRunOperation" // found via experimentation, there is no const in the SDK for this + +func IsDryRunError(err error) bool { + if err == nil { + return false + } + + // This is awful but it is the only way that I could find to check + // if the AWS API returned a dry run error + if operationError, ok := err.(*smithy.OperationError); ok { + err = operationError.Unwrap() + if responseError, ok := err.(*http.ResponseError); ok { + err = responseError.Err + if genericError, ok := err.(*smithy.GenericAPIError); ok { + return genericError.Code == DryRunErrorCode + } + } + } + + return false +} + +// These are primarily used for mocks while testing, and follows a similar pattern to +// github.com/gravitational/cloud/pkg/aws +// TODO consider writing a `go generate` program for this. This will quickly get out +// of hand if used frequently. + +// region Account API +type IAccountApi interface { + ListRegions(ctx context.Context, params *account.ListRegionsInput, optFns ...func(*account.Options)) (*account.ListRegionsOutput, error) +} + +type AccountApi struct { + cfg *aws.Config + *account.Client +} + +func NewAccountApi(cfg *aws.Config) IAccountApi { + return &AccountApi{ + cfg: cfg, + Client: account.NewFromConfig(*cfg), + } +} + +type MockAccountAPI struct { + MockListRegions func(ctx context.Context, params *account.ListRegionsInput, optFns ...func(*account.Options)) (*account.ListRegionsOutput, error) +} + +func (maa *MockAccountAPI) ListRegions(ctx context.Context, params *account.ListRegionsInput, optFns ...func(*account.Options)) (*account.ListRegionsOutput, error) { + return runMock(maa.MockListRegions, ctx, params, optFns) +} + +// endregion + +// region EC2 API +type IEc2Api interface { + DescribeImages(ctx context.Context, params *ec2.DescribeImagesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) + DeregisterImage(ctx context.Context, params *ec2.DeregisterImageInput, optFns ...func(*ec2.Options)) (*ec2.DeregisterImageOutput, error) + DeleteSnapshot(ctx context.Context, params *ec2.DeleteSnapshotInput, optFns ...func(*ec2.Options)) (*ec2.DeleteSnapshotOutput, error) +} + +type Ec2Api struct { + cfg *aws.Config + *ec2.Client +} + +func NewEc2Api(cfg *aws.Config) IEc2Api { + return &Ec2Api{ + cfg: cfg, + Client: ec2.NewFromConfig(*cfg), + } +} + +type MockEc2API struct { + MockDescribeImages func(ctx context.Context, params *ec2.DescribeImagesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) + MockDeregisterImage func(ctx context.Context, params *ec2.DeregisterImageInput, optFns ...func(*ec2.Options)) (*ec2.DeregisterImageOutput, error) + MockDeleteSnapshot func(ctx context.Context, params *ec2.DeleteSnapshotInput, optFns ...func(*ec2.Options)) (*ec2.DeleteSnapshotOutput, error) +} + +func (mea *MockEc2API) DescribeImages(ctx context.Context, params *ec2.DescribeImagesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) { + return runMock(mea.MockDescribeImages, ctx, params, optFns) +} + +func (mea *MockEc2API) DeregisterImage(ctx context.Context, params *ec2.DeregisterImageInput, optFns ...func(*ec2.Options)) (*ec2.DeregisterImageOutput, error) { + return runMock(mea.MockDeregisterImage, ctx, params, optFns) +} + +func (mea *MockEc2API) DeleteSnapshot(ctx context.Context, params *ec2.DeleteSnapshotInput, optFns ...func(*ec2.Options)) (*ec2.DeleteSnapshotOutput, error) { + return runMock(mea.MockDeleteSnapshot, ctx, params, optFns) +} + +// endregion + +// Do common error checking for every mock and then run it +func runMock[TParameters, TInvocationOptions, TResult any](mock func(context.Context, TParameters, ...TInvocationOptions) (TResult, error), + ctx context.Context, params TParameters, optFns []TInvocationOptions) (TResult, error) { + if mock == nil { + panic("Mock API function was called but not implemented") + } + return mock(ctx, params, optFns...) +} diff --git a/tools/ami-cleanup/src/internal/aws_test.go b/tools/ami-cleanup/src/internal/aws_test.go new file mode 100644 index 00000000..05c21084 --- /dev/null +++ b/tools/ami-cleanup/src/internal/aws_test.go @@ -0,0 +1,70 @@ +/* + * AMI cleanup tool + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package internal + +import ( + "errors" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws/transport/http" + "github.com/aws/smithy-go" + smithyhttp "github.com/aws/smithy-go/transport/http" + "github.com/stretchr/testify/require" +) + +func TestIsDryRunError(t *testing.T) { + tests := []struct { + desc string + testError error + expectedResult bool + }{ + { + desc: "no match if nil", + testError: nil, + expectedResult: false, + }, + { + desc: "no match on generic error", + testError: errors.New(""), + expectedResult: false, + }, + { + desc: "match when valid dry run error", + testError: &smithy.OperationError{ + Err: &http.ResponseError{ + ResponseError: &smithyhttp.ResponseError{ + Err: &smithy.GenericAPIError{ + Code: "DryRunOperation", + }, + }, + }, + }, + expectedResult: true, + }, + } + + for _, test := range tests { + testCap := test // Capture the loop var, unnecessary in upcoming go 1.22 + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + result := IsDryRunError(testCap.testError) + require.Equal(t, testCap.expectedResult, result) + }) + } +} diff --git a/tools/ami-cleanup/src/internal/cleanup.go b/tools/ami-cleanup/src/internal/cleanup.go new file mode 100644 index 00000000..5aad55f1 --- /dev/null +++ b/tools/ami-cleanup/src/internal/cleanup.go @@ -0,0 +1,241 @@ +/* + * AMI cleanup tool + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package internal + +import ( + "context" + "log" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/account" + accountTypes "github.com/aws/aws-sdk-go-v2/service/account/types" + "github.com/aws/aws-sdk-go-v2/service/ec2" + ec2Types "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/gravitational/trace" + "github.com/relvacode/iso8601" +) + +type ApplicationInstance struct { + shouldDoDryRun bool + + // Dependency injection for testing + accountClientGenerator func(cfg *aws.Config) IAccountApi + ec2ClientGenerator func(cfg *aws.Config) IEc2Api +} + +// Creates a new instance of the tool +func NewApplicationInstance(doDryRun bool) *ApplicationInstance { + return &ApplicationInstance{ + shouldDoDryRun: doDryRun, + accountClientGenerator: NewAccountApi, + ec2ClientGenerator: NewEc2Api, + } +} + +// Performs cleanup +func (ai *ApplicationInstance) Run(ctx context.Context) error { + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + trace.Wrap(err, "failed to load AWS credentials") + } + + enabledRegions, err := ai.getEnabledRegions(ctx, ai.accountClientGenerator(&cfg)) + if err != nil { + return trace.Wrap(err, "failed to get enabled regions") + } + + if len(enabledRegions) == 0 { + return nil + } + + totalSpaceRecovered := int32(0) + totalImagesDeleted := 0 + // This would probably run much faster with channels/concurrency, but there isn't much + // need with how often this is expected to be ran + for _, enabledRegion := range enabledRegions { + spaceRecovered, imagesDeleted, err := ai.cleanupRegion(ctx, cfg, *enabledRegion.RegionName) + if err != nil { + return trace.Wrap(err, "failed to clean up images in region %q", *enabledRegion.RegionName) + } + + totalSpaceRecovered += spaceRecovered + totalImagesDeleted += imagesDeleted + } + + log.Printf("Deleted %d GiB of %d images across %d regions", totalSpaceRecovered, totalImagesDeleted, len(enabledRegions)) + return nil +} + +// Cleans up the images in the given region. Returns the total amount of space recovered, number of images deleted, +// and any error. +func (ai ApplicationInstance) cleanupRegion(ctx context.Context, cfg aws.Config, regionName string) (int32, int, error) { + // A new client must be created for each region + cfg.Region = regionName + ec2Client := ai.ec2ClientGenerator(&cfg) + + log.Printf("Deleting images in %q", regionName) + devImages, err := ai.getDevImagesInRegion(ctx, ec2Client) + if err != nil { + return 0, 0, trace.Wrap(err, "failed to get a list of dev images for %q", regionName) + } + + var totalSpaceRecovered int32 + totalImagesDeleted := 0 + totalSnapshotCount := 0 + for _, devImage := range devImages { + imageSpace, err := ai.cleanupImageIfOld(ctx, ec2Client, devImage) + if err != nil { + return 0, 0, trace.Wrap(err, "failed to cleanup dev image %q", devImage.Name) + } + + if imageSpace == 0 { + continue + } + + totalSpaceRecovered += imageSpace + totalImagesDeleted++ + totalSnapshotCount += len(devImage.BlockDeviceMappings) + } + + return totalSpaceRecovered, totalImagesDeleted, nil +} + +func (ai *ApplicationInstance) getEnabledRegions(ctx context.Context, client IAccountApi) ([]accountTypes.Region, error) { + return getAllWithPagination( + func(previousToken *string) (*string, []accountTypes.Region, error) { + results, err := client.ListRegions(ctx, &account.ListRegionsInput{ + RegionOptStatusContains: []accountTypes.RegionOptStatus{ + accountTypes.RegionOptStatusEnabled, + accountTypes.RegionOptStatusEnabledByDefault, + }, + NextToken: previousToken, + }) + if err != nil { + return nil, nil, trace.Wrap(err, "failed to request enabled regions") + } + + return results.NextToken, results.Regions, nil + }, + ) +} + +func (ai *ApplicationInstance) getDevImagesInRegion(ctx context.Context, client IEc2Api) ([]ec2Types.Image, error) { + // These are a weird language workaround for getting a pointer to a boolean literal + includeTrue := true + nameFilterName := "name" + stateFilterName := "state" + requestInput := &ec2.DescribeImagesInput{ + Filters: []ec2Types.Filter{ + { + Name: &nameFilterName, + Values: []string{"*dev*"}, + }, + { + Name: &stateFilterName, + Values: []string{string(ec2Types.ImageStateAvailable)}, + }, + }, + IncludeDeprecated: &includeTrue, + IncludeDisabled: &includeTrue, + Owners: []string{"self"}, + } + + return getAllWithPagination( + func(previousToken *string) (*string, []ec2Types.Image, error) { + + requestInput.NextToken = previousToken + results, err := client.DescribeImages(ctx, requestInput) + if err != nil { + return nil, nil, trace.Wrap(err, "failed to request a dev images") + } + + return results.NextToken, results.Images, nil + }, + ) +} + +// Repeatedly calls `action` until the returned `nextToken` is null, and accumulates the results. +// Useful for AWS API calls who's results may be paginated. +func getAllWithPagination[T any](action func(previousToken *string) (nextToken *string, results []T, err error)) ([]T, error) { + var previousToken *string + var results []T + for { + nextToken, newResults, err := action(previousToken) + if err != nil { + return results, trace.Wrap(err, "failed to get the next set of results") + } + + results = append(results, newResults...) + if nextToken == nil { + return results, nil + } + previousToken = nextToken + } +} + +func (ai *ApplicationInstance) cleanupImageIfOld(ctx context.Context, client IEc2Api, image ec2Types.Image) (int32, error) { + creationDate, err := iso8601.ParseString(*image.CreationDate) + if err != nil { + return 0, trace.Wrap(err, "failed to parse image %q creation timestamp %q as an ISO 8601 value", *image.Name, *image.CreationDate) + } + + // If the image is less than a month old, don't do anything + imageAge := time.Since(creationDate) + if imageAge <= 30*24*time.Hour { + return 0, nil + } + + log.Printf("\tDeleting %s old AMI %q", imageAge.String(), *image.Name) + _, err = client.DeregisterImage(ctx, &ec2.DeregisterImageInput{ + ImageId: image.ImageId, + DryRun: &ai.shouldDoDryRun, + }) + if err != nil && (!IsDryRunError(err) || !ai.shouldDoDryRun) { + return 0, trace.Wrap(err, "failed to deregister image %q", *image.Name) + } + + deletedImageSize, err := ai.deleteSnapshotsForImage(ctx, client, image) + if err != nil { + return 0, trace.Wrap(err, "failed to delete all snapshots for image %q", *image.Name) + } + return deletedImageSize, nil +} + +// Deletes all the snapshots for the given image, returning their cumulative snapshot size in GiB. +func (ai *ApplicationInstance) deleteSnapshotsForImage(ctx context.Context, client IEc2Api, image ec2Types.Image) (int32, error) { + deletedImageSize := int32(0) + for _, blockDevice := range image.BlockDeviceMappings { + log.Printf("\t\tDeleting snapshot %q for AMI %q", *blockDevice.Ebs.SnapshotId, *image.Name) + _, err := client.DeleteSnapshot(ctx, &ec2.DeleteSnapshotInput{ + SnapshotId: blockDevice.Ebs.SnapshotId, + DryRun: &ai.shouldDoDryRun, + }) + + if err != nil && (!IsDryRunError(err) || !ai.shouldDoDryRun) { + return 0, trace.Wrap(err, "failed to delete snapshot %q for AMI %q, please check for hanging snapshots", + *blockDevice.Ebs.SnapshotId, *image.Name) + } + + deletedImageSize += *blockDevice.Ebs.VolumeSize + } + + return deletedImageSize, nil +} diff --git a/tools/ami-cleanup/src/internal/cleanup_test.go b/tools/ami-cleanup/src/internal/cleanup_test.go new file mode 100644 index 00000000..f4541753 --- /dev/null +++ b/tools/ami-cleanup/src/internal/cleanup_test.go @@ -0,0 +1,669 @@ +/* + * AMI cleanup tool + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package internal + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/account" + accountTypes "github.com/aws/aws-sdk-go-v2/service/account/types" + "github.com/aws/aws-sdk-go-v2/service/ec2" + ec2Types "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/gravitational/trace" + "github.com/stretchr/testify/require" + "golang.org/x/exp/slices" +) + +func TestGetEnabledRegions(t *testing.T) { + // These are defined as separate variables so that pointers can reference the values + usEast1 := "us-east-1" + usEast2 := "us-east-2" + usWest1 := "us-west-1" + usWest2 := "us-west-2" + expectedRegions := []accountTypes.Region{ + { + RegionName: &usEast1, + RegionOptStatus: accountTypes.RegionOptStatusEnabled, + }, + { + RegionName: &usEast2, + RegionOptStatus: accountTypes.RegionOptStatusEnabledByDefault, + }, + { + RegionName: &usWest1, + RegionOptStatus: accountTypes.RegionOptStatusEnabledByDefault, + }, + { + RegionName: &usWest2, + RegionOptStatus: accountTypes.RegionOptStatusEnabled, + }, + } + + tests := []struct { + desc string + mockListRegions func(ctx context.Context, params *account.ListRegionsInput, optFns ...func(*account.Options)) (*account.ListRegionsOutput, error) + shouldError bool + expectedRegions []accountTypes.Region + }{ + { + desc: "fail if API call errors", + mockListRegions: func(ctx context.Context, params *account.ListRegionsInput, optFns ...func(*account.Options)) (*account.ListRegionsOutput, error) { + return nil, errors.New("some API call error") + }, + shouldError: true, + }, + { + desc: "no error when API call does not error", + mockListRegions: func(ctx context.Context, params *account.ListRegionsInput, optFns ...func(*account.Options)) (*account.ListRegionsOutput, error) { + return &account.ListRegionsOutput{}, nil + }, + shouldError: false, + }, + { + desc: "only enabled regions requested", + mockListRegions: func(ctx context.Context, params *account.ListRegionsInput, optFns ...func(*account.Options)) (*account.ListRegionsOutput, error) { + enabledStatuses := []accountTypes.RegionOptStatus{accountTypes.RegionOptStatusEnabled, accountTypes.RegionOptStatusEnabledByDefault} + slices.Sort(enabledStatuses) + slices.Sort(params.RegionOptStatusContains) + if slices.Compare(enabledStatuses, params.RegionOptStatusContains) != 0 { + return nil, trace.Errorf("the requested region statuses %#v did not match the expected region statuses %#v", params.RegionOptStatusContains, enabledStatuses) + } + return &account.ListRegionsOutput{}, nil + }, + shouldError: false, + }, + { + desc: "all results are returned", + mockListRegions: func(ctx context.Context, params *account.ListRegionsInput, optFns ...func(*account.Options)) (*account.ListRegionsOutput, error) { + return &account.ListRegionsOutput{ + Regions: expectedRegions, + NextToken: nil, + }, nil + }, + expectedRegions: expectedRegions, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + // Setup the application instance + application := &ApplicationInstance{ + accountClientGenerator: func(cfg *aws.Config) IAccountApi { + return &MockAccountAPI{ + MockListRegions: test.mockListRegions, + } + }, + } + + // Run the function under test + regions, err := application.getEnabledRegions(context.Background(), application.accountClientGenerator(nil)) + + // Verify the results + checkError(t, test.shouldError, err) + require.ElementsMatch(t, regions, test.expectedRegions, "the returned regions did not match the expected regions") + }) + } +} + +func TestGetAllWithPagination(t *testing.T) { + actionSuppliedResults := [][]string{ + { + "result 1", + "result 2", + }, + { + "result 3", + "result 4", + }, + } + allResults := make([]string, 0) + for _, actionSuppliedResult := range actionSuppliedResults { + allResults = append(allResults, actionSuppliedResult...) + } + + tests := []struct { + desc string + action func(previousToken *string) (nextToken *string, results []string, err error) + expectedResults []string + shouldError bool + }{ + { + desc: "fail if action errors", + action: func(previousToken *string) (nextToken *string, results []string, err error) { + return nil, nil, trace.Errorf("some action error") + }, + shouldError: true, + }, + { + desc: "first token is nil", + action: func(previousToken *string) (nextToken *string, results []string, err error) { + if nextToken != nil { + return nil, nil, trace.Errorf("the first token was %q, expected nil", *previousToken) + } + + return nil, nil, nil + }, + }, + { + desc: "all results returned when there is a single page of results", + action: func(previousToken *string) (nextToken *string, results []string, err error) { + return nil, actionSuppliedResults[0], nil + }, + expectedResults: actionSuppliedResults[0], + }, + { + desc: "all results returned for multiple pages of results", + action: func(previousToken *string) (nextToken *string, results []string, err error) { + // Resolve the token returned by the previous result to an index in the action result array + pageRegionIndex := 0 + if previousToken != nil { + var err error + pageRegionIndex, err = strconv.Atoi(*previousToken) + if err != nil { + return nil, nil, trace.Errorf("failed to convert next token %q to integer", *previousToken) + } + } + + if pageRegionIndex > len(actionSuppliedResults)-1 { + return nil, nil, trace.Errorf("requested more responses (page %d) than were available", pageRegionIndex) + } + + var responseToken *string + if pageRegionIndex != len(actionSuppliedResults)-1 { + // When not the last page, return the next page as the response token + nextPageToken := strconv.Itoa(pageRegionIndex + 1) + responseToken = &nextPageToken + } + + return responseToken, actionSuppliedResults[pageRegionIndex], nil + }, + expectedResults: allResults, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + results, err := getAllWithPagination(test.action) + checkError(t, test.shouldError, err) + require.ElementsMatch(t, results, test.expectedResults, "the returned results did not match the expected results") + }) + } +} + +func TestGetDevImagesInRegion(t *testing.T) { + tests := []struct { + desc string + mockDescribeImages func(ctx context.Context, params *ec2.DescribeImagesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) + shouldError bool + expectedImages []ec2Types.Image + doDryRun bool + }{ + { + desc: "fail if API call errors", + mockDescribeImages: func(ctx context.Context, params *ec2.DescribeImagesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) { + return nil, trace.Errorf("some API call error") + }, + shouldError: true, + }, + { + desc: "no error when API call does not error", + mockDescribeImages: func(ctx context.Context, params *ec2.DescribeImagesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) { + return &ec2.DescribeImagesOutput{}, nil + }, + shouldError: false, + }, + { + desc: "request only available images", + mockDescribeImages: func(ctx context.Context, params *ec2.DescribeImagesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) { + foundMatchingFilter := false + for _, filter := range params.Filters { + if *filter.Name != "state" { + continue + } + + if foundMatchingFilter { + return nil, trace.Errorf("found multiple image state filters") + } + + stateFilterCount := len(filter.Values) + if stateFilterCount != 1 { + return nil, trace.Errorf("expected one image state filter, found %d", stateFilterCount) + } + + if filter.Values[0] != "available" { + return nil, trace.Errorf("image state filter found, but was set to %q", filter.Values[0]) + } + + foundMatchingFilter = true + } + + if !foundMatchingFilter { + return nil, trace.Errorf("did not find any image state filters in request") + } + + return &ec2.DescribeImagesOutput{}, nil + }, + }, + { + desc: "request only dev images", + mockDescribeImages: func(ctx context.Context, params *ec2.DescribeImagesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) { + foundMatchingFilter := false + for _, filter := range params.Filters { + if *filter.Name != "name" { + continue + } + + if foundMatchingFilter { + return nil, trace.Errorf("found multiple image name filters") + } + + nameFilterCount := len(filter.Values) + if nameFilterCount != 1 { + return nil, trace.Errorf("expected one image state name, found %d", nameFilterCount) + } + + nameFilter := filter.Values[0] + if !strings.Contains(nameFilter, "dev") { + // This is not strictly true but should cover the majority of cases. + // Filtering details are available at + // https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/Using_Filtering.html#Filtering_Resources_CLI + // At the time of writing it is not worth the development effort required for building a filter + // parser to validate this further. + return nil, trace.Errorf("the name filter %q is not limited to dev images", nameFilter) + } + + foundMatchingFilter = true + } + + return &ec2.DescribeImagesOutput{}, nil + }, + }, + { + desc: "requests deprecated images", + mockDescribeImages: func(ctx context.Context, params *ec2.DescribeImagesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) { + if !*params.IncludeDeprecated { + return nil, trace.Errorf("API call did not request deprecated images") + } + + return &ec2.DescribeImagesOutput{}, nil + }, + }, + { + desc: "requests disabled images", + mockDescribeImages: func(ctx context.Context, params *ec2.DescribeImagesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) { + if !*params.IncludeDisabled { + return nil, trace.Errorf("API call did not request disabled images") + } + + return &ec2.DescribeImagesOutput{}, nil + }, + }, + { + desc: "only requests self-owned images", + mockDescribeImages: func(ctx context.Context, params *ec2.DescribeImagesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) { + ownerCount := len(params.Owners) + if ownerCount != 1 { + return nil, trace.Errorf("expected one image owner in the API call, got %d", ownerCount) + } + + if params.Owners[0] != "self" { + return nil, trace.Errorf("requested images owned by %q instead of self", params.Owners[0]) + } + + return &ec2.DescribeImagesOutput{}, nil + }, + }, + { + desc: "requests not as a dry run even when set in the application", + mockDescribeImages: func(ctx context.Context, params *ec2.DescribeImagesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) { + if params.DryRun != nil && *params.DryRun { + return nil, trace.Errorf("API call was set to a do dry run") + } + + return &ec2.DescribeImagesOutput{}, nil + }, + doDryRun: true, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + // Setup the application instance + application := &ApplicationInstance{ + shouldDoDryRun: test.doDryRun, + ec2ClientGenerator: func(cfg *aws.Config) IEc2Api { + return &MockEc2API{ + MockDescribeImages: test.mockDescribeImages, + } + }, + } + + // Run the function under test + images, err := application.getDevImagesInRegion(context.Background(), application.ec2ClientGenerator(nil)) + + // Verify the results + checkError(t, test.shouldError, err) + require.ElementsMatch(t, images, test.expectedImages, "the returned images did not match the expected images") + }) + } +} + +func TestDeleteSnapshotsForImage(t *testing.T) { + singleSnapshotImage := generateImageFixture("single snapshot image", "", 1) + multipleSnapshotImage := generateImageFixture("multiple snapshot image", "", 3) + + tests := []struct { + desc string + image imageFixture + mockDeleteSnapshot func(ctx context.Context, params *ec2.DeleteSnapshotInput, optFns ...func(*ec2.Options)) (*ec2.DeleteSnapshotOutput, error) + shouldError bool + doDryRun bool + }{ + { + desc: "fail if API call errors", + image: singleSnapshotImage, + mockDeleteSnapshot: func(ctx context.Context, params *ec2.DeleteSnapshotInput, optFns ...func(*ec2.Options)) (*ec2.DeleteSnapshotOutput, error) { + return nil, trace.Errorf("some API call error") + }, + shouldError: true, + }, + { + desc: "no error when API call does not error", + mockDeleteSnapshot: func(ctx context.Context, params *ec2.DeleteSnapshotInput, optFns ...func(*ec2.Options)) (*ec2.DeleteSnapshotOutput, error) { + return &ec2.DeleteSnapshotOutput{}, nil + }, + shouldError: false, + }, + { + desc: "correct recovered space reported when single snapshot", + image: singleSnapshotImage, + mockDeleteSnapshot: func(ctx context.Context, params *ec2.DeleteSnapshotInput, optFns ...func(*ec2.Options)) (*ec2.DeleteSnapshotOutput, error) { + return &ec2.DeleteSnapshotOutput{}, nil + }, + }, + // This also checks to ensure that all snapshots were deleted + { + desc: "correct recovered space reported when multiple snapshot", + image: multipleSnapshotImage, + mockDeleteSnapshot: func(ctx context.Context, params *ec2.DeleteSnapshotInput, optFns ...func(*ec2.Options)) (*ec2.DeleteSnapshotOutput, error) { + return &ec2.DeleteSnapshotOutput{}, nil + }, + }, + { + desc: "requests as a dry run when set in the application", + mockDeleteSnapshot: func(ctx context.Context, params *ec2.DeleteSnapshotInput, optFns ...func(*ec2.Options)) (*ec2.DeleteSnapshotOutput, error) { + if !*params.DryRun { + return nil, trace.Errorf("API call was not set to a do dry run") + } + + return &ec2.DeleteSnapshotOutput{}, nil + }, + doDryRun: true, + }, + { + desc: "requests as not a dry run when not set in the application", + mockDeleteSnapshot: func(ctx context.Context, params *ec2.DeleteSnapshotInput, optFns ...func(*ec2.Options)) (*ec2.DeleteSnapshotOutput, error) { + if params.DryRun != nil && *params.DryRun { + return nil, trace.Errorf("API call was set to a do dry run") + } + + return &ec2.DeleteSnapshotOutput{}, nil + }, + doDryRun: false, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + // Setup the application instance + application := &ApplicationInstance{ + shouldDoDryRun: test.doDryRun, + ec2ClientGenerator: func(cfg *aws.Config) IEc2Api { + return &MockEc2API{ + MockDeleteSnapshot: test.mockDeleteSnapshot, + } + }, + } + + // Run the function under test + recoveredSpace, err := application.deleteSnapshotsForImage(context.Background(), application.ec2ClientGenerator(nil), test.image.Image) + + // Verify the results + checkError(t, test.shouldError, err) + if !test.shouldError { + require.Equal(t, test.image.totalSize, recoveredSpace, "the recovered space did not match the expected recovered space") + } + }) + } +} + +func TestCleanupImageIfOld(t *testing.T) { + oldImage := generateImageFixture("old image", "2021-09-29T11:04:43.305Z", 2) // Creation time is a random value pulled from the AWS DescribeImage docs + + tests := []struct { + desc string + shouldError bool + doDryRun bool + image imageFixture + mockDeregisterImage func(ctx context.Context, params *ec2.DeregisterImageInput, optFns ...func(*ec2.Options)) (*ec2.DeregisterImageOutput, error) + shouldIgnoreSpaceCheck bool + }{ + { + desc: "fail if API call errors", + image: oldImage, + mockDeregisterImage: func(ctx context.Context, params *ec2.DeregisterImageInput, optFns ...func(*ec2.Options)) (*ec2.DeregisterImageOutput, error) { + return nil, trace.Errorf("some API call error") + + }, + shouldError: true, + }, + { + desc: "no error when API call does not error", + image: oldImage, + mockDeregisterImage: func(ctx context.Context, params *ec2.DeregisterImageInput, optFns ...func(*ec2.Options)) (*ec2.DeregisterImageOutput, error) { + return &ec2.DeregisterImageOutput{}, nil + }, + shouldError: false, + }, + { + desc: "requests as a dry run when set in the application", + image: oldImage, + mockDeregisterImage: func(ctx context.Context, params *ec2.DeregisterImageInput, optFns ...func(*ec2.Options)) (*ec2.DeregisterImageOutput, error) { + if !*params.DryRun { + return nil, trace.Errorf("API call was not set to a do dry run") + } + + return &ec2.DeregisterImageOutput{}, nil + }, + doDryRun: true, + }, + { + desc: "requests as not a dry run when not set in the application", + image: oldImage, + mockDeregisterImage: func(ctx context.Context, params *ec2.DeregisterImageInput, optFns ...func(*ec2.Options)) (*ec2.DeregisterImageOutput, error) { + if params.DryRun != nil && *params.DryRun { + return nil, trace.Errorf("API call was set to a do dry run") + } + + return &ec2.DeregisterImageOutput{}, nil + }, + doDryRun: false, + }, + { + desc: "do not remove new images", + image: generateImageFixture("new image", time.Now().AddDate(0, 0, -29).Format(time.RFC3339), 1), + mockDeregisterImage: func(ctx context.Context, params *ec2.DeregisterImageInput, optFns ...func(*ec2.Options)) (*ec2.DeregisterImageOutput, error) { + return nil, trace.Errorf("The new image was deleted but should not have been") + }, + shouldIgnoreSpaceCheck: true, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + // Setup the application instance + application := &ApplicationInstance{ + shouldDoDryRun: test.doDryRun, + ec2ClientGenerator: func(cfg *aws.Config) IEc2Api { + return &MockEc2API{ + MockDeleteSnapshot: func(ctx context.Context, params *ec2.DeleteSnapshotInput, optFns ...func(*ec2.Options)) (*ec2.DeleteSnapshotOutput, error) { + // Do nothing, just don't error + return &ec2.DeleteSnapshotOutput{}, nil + }, + MockDeregisterImage: test.mockDeregisterImage, + } + }, + } + + // Run the function under test + recoveredSpace, err := application.cleanupImageIfOld(context.Background(), application.ec2ClientGenerator(nil), test.image.Image) + + // Verify the results + checkError(t, test.shouldError, err) + if !test.shouldError && !test.shouldIgnoreSpaceCheck { + require.Equal(t, test.image.totalSize, recoveredSpace, "the recovered space did not match the expected recovered space") + } + }) + } +} + +func TestCleanupRegion(t *testing.T) { + tests := []struct { + desc string + imageFixtures []imageFixture + regionName string + }{ + { + desc: "passes with no old images", + regionName: "us-east-1", + }, + { + desc: "passes with one old image", + imageFixtures: []imageFixture{ + generateImageFixture("image 1", "2021-09-29T11:04:43.305Z", 1), + }, + regionName: "us-east-2", + }, + { + desc: "passes with many old images", + imageFixtures: []imageFixture{ + generateImageFixture("image 1", "2021-09-29T11:04:43.305Z", 1), + generateImageFixture("image 2", "2021-09-29T11:04:43.305Z", 2), + generateImageFixture("image 3", "2021-09-29T11:04:43.305Z", 3), + generateImageFixture("image 4", "2021-09-29T11:04:43.305Z", 4), + generateImageFixture("image 5", "2021-09-29T11:04:43.305Z", 5), + generateImageFixture("image 6", "2021-09-29T11:04:43.305Z", 6), + }, + regionName: "us-west-1", + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + var ec2ClientProvidedConfig *aws.Config + + // Setup the for the test + application := &ApplicationInstance{ + ec2ClientGenerator: func(cfg *aws.Config) IEc2Api { + ec2ClientProvidedConfig = cfg + + return &MockEc2API{ + MockDescribeImages: func(ctx context.Context, params *ec2.DescribeImagesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) { + images := make([]ec2Types.Image, 0, len(test.imageFixtures)) + for _, imageFixture := range test.imageFixtures { + images = append(images, imageFixture.Image) + } + + return &ec2.DescribeImagesOutput{ + Images: images, + }, nil + }, + MockDeregisterImage: func(ctx context.Context, params *ec2.DeregisterImageInput, optFns ...func(*ec2.Options)) (*ec2.DeregisterImageOutput, error) { + return &ec2.DeregisterImageOutput{}, nil + }, + MockDeleteSnapshot: func(ctx context.Context, params *ec2.DeleteSnapshotInput, optFns ...func(*ec2.Options)) (*ec2.DeleteSnapshotOutput, error) { + return &ec2.DeleteSnapshotOutput{}, nil + }, + } + }, + } + + sizeOfAllImages := int32(0) + for _, image := range test.imageFixtures { + sizeOfAllImages += image.totalSize + } + + // Run the function under test + recoveredSpace, imagesDeleted, err := application.cleanupRegion(context.Background(), aws.Config{}, test.regionName) + + // Verify the results + checkError(t, false, err) + require.Equal(t, sizeOfAllImages, recoveredSpace, "the recovered space did not match the expected recovered space") + require.Equal(t, len(test.imageFixtures), imagesDeleted, "the number of deleted images did not match the expected number of deleted images") + require.NotNil(t, ec2ClientProvidedConfig, "the function did not provide the AWS configuration to the client generator") + require.Equal(t, ec2ClientProvidedConfig.Region, test.regionName, "the region was not set to the expected value on the AWS configuration") + }) + } +} + +type imageFixture struct { + ec2Types.Image + totalSize int32 +} + +func generateImageFixture(name, creationDate string, snapshotCount int) imageFixture { + image := imageFixture{ + Image: ec2Types.Image{ + Name: &name, + }, + } + + if creationDate != "" { + image.CreationDate = &creationDate + } + + for i := 0; i < snapshotCount; i++ { + snapshotId := fmt.Sprintf("snap-%0x", i) + snapshotSize := int32(1) << i + image.BlockDeviceMappings = append(image.BlockDeviceMappings, ec2Types.BlockDeviceMapping{ + Ebs: &ec2Types.EbsBlockDevice{ + SnapshotId: &snapshotId, + VolumeSize: &snapshotSize, + }, + }) + image.totalSize += snapshotSize + } + + return image +} + +func checkError(t *testing.T, shouldError bool, err error) { + if shouldError { + require.Error(t, err) + } else { + require.NoError(t, err) + } +} diff --git a/tools/ami-cleanup/workflows/cd.yaml b/tools/ami-cleanup/workflows/cd.yaml new file mode 100644 index 00000000..ad02f036 --- /dev/null +++ b/tools/ami-cleanup/workflows/cd.yaml @@ -0,0 +1,41 @@ +name: AMI cleanup tool CD +on: + push: + tags: + - "ami-cleanup-v[0-9]+.[0-9]+.[0-9]+**" + +concurrency: + group: "Only run one instance of AMI cleanup CD for ${{ github.ref_name }}" + +jobs: + cut-release: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4.1.1 + - name: Install Earthly + uses: earthly/actions-setup@v1.0.8 + with: + # renovate: earthly-version + version: v0.7.23 + - name: Determine actual release semver + env: + # This is copy/pasted from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + SEMVER_TAG_REGEX: ^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$ + GIT_TAG: ${{ github.ref_name }} + run: | + # Remove the tool name from the front of the tag + SEMVER_TAG=$(echo "$GIT_TAG" | sed 's/^ami-cleanup-v//') + echo "Extracted $SEMVER_TAG from git tag" + + # Check if the extracted version is a valid semver + if ! $(echo "$SEMVER_TAG" | grep --perl-regexp --quiet "$SEMVER_TAG_REGEX"); then + echo "Extracted version $SEMVER_TAG is not a valid semver" >&2 + exit 1 + fi + + echo "SEMVER_TAG=$SEMVER_TAG" >> $GITHUB_OUTPUT + - name: Cut a new release for ${{ env.SEMVER_TAG }} + env: + GIT_TAG: ${{ github.ref_name }} + run: earthly -ci +release --GIT_TAG="$SEMVER_TAG" diff --git a/tools/ami-cleanup/workflows/ci.yaml b/tools/ami-cleanup/workflows/ci.yaml new file mode 100644 index 00000000..3c703e5c --- /dev/null +++ b/tools/ami-cleanup/workflows/ci.yaml @@ -0,0 +1,41 @@ +name: AMI cleanup tool CI +on: + pull_request: + branches: + - main + +concurrency: + cancel-in-progress: true + group: "Only run one instance of AMI cleanup CI for PR #${{ github.event.number }}" + +jobs: + check-if-should-run: + runs-on: ubuntu-latest + permissions: + pull-requests: read + outputs: + should-verify-pr: ${{ steps.filter.outputs.changed }} # True if the AMI cleanup tool changed, false otherwise + steps: + - name: Filter out unrelated changes + uses: dorny/paths-filter@v2.11.1 + id: filter + with: + filters: | + changed: "tools/ami-cleanup/**" + verify-pr: + runs-on: ubuntu-latest + needs: + - check-if-should-run + if: ${{ needs.check-if-should-run.outputs.should-verify-pr }} + steps: + - name: Checkout repo + uses: actions/checkout@v4.1.1 + - name: Install Earthly + uses: earthly/actions-setup@v1.0.8 + with: + # renovate: earthly-version + version: v0.7.23 + - name: Lint Go code + run: earthly -ci +lint --OUTPUT_FORMAT=github-actions + - name: Run Go tests + run: earthly -ci +test --OUTPUT_FORMAT=github-actions