├── .dockerignore ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── ci.yaml │ └── golangci-lint.yaml ├── .gitignore ├── BUILD.md ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── NOTICE ├── README.md ├── THIRD_PARTY_LICENSES ├── cmd ├── examples │ └── example1.go └── main.go ├── go.mod ├── go.sum ├── pkg ├── awsapi │ └── selectorec2.go ├── bytequantity │ ├── bytequantity.go │ └── bytequantity_test.go ├── cli │ ├── cli.go │ ├── cli_internal_test.go │ ├── cli_test.go │ ├── flags.go │ ├── flags_test.go │ ├── types.go │ └── types_test.go ├── ec2pricing │ ├── ec2pricing.go │ ├── ec2pricing_test.go │ ├── odpricing.go │ └── spotpricing.go ├── env │ └── env.go ├── instancetypes │ └── instancetypes.go ├── selector │ ├── aggregates.go │ ├── aggregates_test.go │ ├── comparators.go │ ├── comparators_internal_test.go │ ├── emr.go │ ├── emr_test.go │ ├── outputs │ │ ├── bubbletea.go │ │ ├── bubbletea_internal_test.go │ │ ├── outputs.go │ │ ├── outputs_test.go │ │ ├── sortingView.go │ │ ├── tableView.go │ │ └── verboseView.go │ ├── selector.go │ ├── selector_test.go │ ├── services.go │ ├── services_test.go │ ├── types.go │ └── types_test.go ├── sorter │ ├── sorter.go │ └── sorter_test.go └── test │ └── helpers.go ├── scripts ├── build-binaries ├── build-docker-images ├── create-local-tag-for-release ├── prepare-for-release ├── push-docker-images ├── sync-readme-to-dockerhub ├── sync-to-aws-homebrew-tap └── upload-resources-to-github └── test ├── e2e └── run-test ├── license-test ├── Dockerfile ├── check-licenses.sh ├── gen-license-report.sh ├── license-config.hcl └── run-license-test.sh ├── readme-test ├── readme-codeblocks.go ├── run-readme-codeblocks ├── run-readme-spellcheck ├── rundoc-Dockerfile └── spellcheck-Dockerfile ├── shellcheck └── run-shellcheck └── static ├── DescribeAvailabilityZones └── us-east-2.json ├── DescribeInstanceTypeOfferings ├── empty.json ├── us-east-2a.json ├── us-east-2a_only_c5d12x.json └── us-east-2b.json ├── DescribeInstanceTypes ├── 25_instances.json ├── c4_2xlarge.json ├── c4_large.json ├── empty.json ├── g2_2xlarge.json ├── g2_2xlarge_group.json ├── m4_xlarge.json ├── pv_instances.json ├── t3_micro.json └── t3_micro_and_p3_16xl.json ├── DescribeSpotPriceHistory └── m5_large.json ├── FilterVerbose ├── 1_instance.json ├── 3_instances.json ├── 4_special_cases.json └── g3_16xlarge.json ├── GetProducts └── m5_large.json └── GithubEKSAMIRelease └── amazon-eks-ami-20210125.zip /.dockerignore: -------------------------------------------------------------------------------- 1 | config/* 2 | build/* -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Issue #, if available: 2 | 3 | Description of changes: 4 | 5 | By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: EC2 Instance Selector CI and Release 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | env: 6 | GITHUB_USERNAME: ${{ secrets.EC2_BOT_GITHUB_USERNAME }} 7 | GITHUB_TOKEN: ${{ secrets.EC2_BOT_GITHUB_TOKEN }} 8 | DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} 9 | DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} 10 | DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} 11 | 12 | jobs: 13 | 14 | buildAndTest: 15 | name: Build and Test 16 | runs-on: ubuntu-20.04 17 | steps: 18 | - name: Check out code into the Go module directory 19 | uses: actions/checkout@v4 20 | 21 | - name: Set up Go 1.x 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version-file: 'go.mod' 25 | check-latest: true 26 | cache-dependency-path: '**/go.sum' 27 | 28 | - name: Unit Tests 29 | run: make unit-test 30 | 31 | - name: Lints 32 | run: make spellcheck shellcheck 33 | 34 | - name: Brew Sync Dry run 35 | run: make homebrew-sync-dry-run 36 | 37 | - name: License Test 38 | run: make license-test 39 | 40 | - name: Build Binaries 41 | run: make build-binaries 42 | 43 | - name: Build Docker Images 44 | run: make build-docker-images 45 | 46 | - name: Integration Tests 47 | if: ${{ github.event_name == 'push' && !contains(github.ref, 'dependabot') }} 48 | run: make integ-test 49 | env: 50 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 51 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 52 | AWS_SESSION_TOKEN: ${{ secrets.AWS_SESSION_TOKEN }} 53 | AWS_REGION: ${{ secrets.AWS_REGION }} 54 | 55 | release: 56 | name: Release 57 | runs-on: ubuntu-20.04 58 | needs: [buildAndTest] 59 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 60 | steps: 61 | - name: Check out code into the Go module directory 62 | uses: actions/checkout@v4 63 | 64 | - name: Set up Go 1.x 65 | uses: actions/setup-go@v5 66 | with: 67 | go-version-file: 'go.mod' 68 | check-latest: true 69 | cache-dependency-path: '**/go.sum' 70 | 71 | - name: Release Assets 72 | run: make release 73 | 74 | - name: Sync to Homebrew 75 | run: make homebrew-sync 76 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yaml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | permissions: 9 | contents: read 10 | pull-requests: read 11 | 12 | jobs: 13 | golangci: 14 | name: lint 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-go@v5 19 | with: 20 | go-version-file: 'go.mod' 21 | check-latest: true 22 | cache-dependency-path: '**/go.sum' 23 | - name: golangci-lint 24 | uses: golangci/golangci-lint-action@v6 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Go ### 2 | # IDE 3 | .idea 4 | 5 | # Binaries for programs and plugins 6 | *.exe 7 | *.exe~ 8 | *.dll 9 | *.so 10 | *.dylib 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | ### Go Patch ### 19 | /vendor/ 20 | /Godeps/ 21 | /build/ 22 | /.terraform/ 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /BUILD.md: -------------------------------------------------------------------------------- 1 | # Instance Selector: Build Instructions 2 | 3 | ## Install Go version 1.13+ 4 | 5 | There are several options for installing go: 6 | 7 | 1. If you're on mac, you can simply `brew install go` 8 | 2. If you'd like a flexible go installation manager consider using gvm https://github.com/moovweb/gvm 9 | 3. For all other situations use the official go getting started guide: https://golang.org/doc/install 10 | 11 | ## Compile 12 | 13 | This project uses `make` to organize compilation, build, and test targets. 14 | 15 | To compile cmd/main.go, which will build the full static binary and pull in depedent packages, run: 16 | ``` 17 | $ make compile 18 | ``` 19 | 20 | The resulting binary will be in the generated `build/` dir 21 | 22 | ``` 23 | $ make compile 24 | /Users/$USER/git/amazon-ec2-instance-selector/ 25 | go build -a -ldflags "-X main.versionID=v0.9.0" -tags="aeislinux" -o /Users/$USER/git/amazon-ec2-instance-selector/build/ec2-instance-selector /Users/$USER/git/amazon-ec2-instance-selector/cmd/main.go 26 | 27 | $ ls build/ 28 | ec2-instance-selector 29 | ``` 30 | 31 | ## Test 32 | 33 | You can execute the unit tests for the instance selector with `make`: 34 | 35 | ``` 36 | $ make unit-test 37 | ``` 38 | 39 | ### Install Docker 40 | 41 | The full test suite requires Docker to be installed. You can install docker from here: https://docs.docker.com/get-docker/ 42 | 43 | ### Run All Tests 44 | 45 | The full suite includes license-test, go-report-card, and more. See the full list in the [makefile](https://github.com/aws/amazon-ec2-instance-selector/blob/main/Makefile). NOTE: some tests require AWS Credentials to be configured on the system: 46 | 47 | ``` 48 | $ make test 49 | ``` 50 | 51 | ## Format 52 | 53 | To keep our code readable with go conventions, we use `goimports` to format the source code. 54 | Make sure to run `goimports` before you submit a PR or you'll be caught by our tests! 55 | 56 | You can use the `make fmt` target as a convenience 57 | ``` 58 | $ make fmt 59 | ``` 60 | 61 | ## Generate all Platform Binaries 62 | 63 | To generate binaries for all supported platforms (linx/amd64, linux/arm64, windows/amd64, etc.) run: 64 | 65 | ``` 66 | $ make build-binaries 67 | ``` 68 | 69 | The binaries are built using a docker container and are then `cp`'d out of the container and placed in `build/bin` 70 | 71 | ``` 72 | $ ls build/bin 73 | ec2-instance-selector-darwin-amd64 ec2-instance-selector-linux-amd64 ec2-instance-selector-linux-arm ec2-instance-selector-linux-arm64 ec2-instance-selector-windows-amd64 74 | ``` 75 | 76 | ## See All Make Targets 77 | 78 | To see all possible make targets and dependent targets, run: 79 | 80 | ``` 81 | $ make help 82 | build-binaries: create-build-dir 83 | build-docker-images: 84 | build: create-build-dir compile 85 | clean: 86 | compile: 87 | create-build-dir: 88 | docker-build: 89 | docker-push: 90 | docker-run: 91 | fmt: 92 | go-report-card-test: 93 | help: 94 | image: 95 | license-test: 96 | output-validation-test: create-build-dir 97 | push-docker-images: 98 | readme-codeblock-test: 99 | release: create-build-dir build-binaries build-docker-images push-docker-images upload-resources-to-github 100 | spellcheck: 101 | sync-readme-to-dockerhub: 102 | test: spellcheck unit-test license-test go-report-card-test output-validation-test readme-codeblock-test 103 | unit-test: create-build-dir 104 | upload-resources-to-github: 105 | version: 106 | ``` 107 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Require approvals from someone in the owner team before merging 2 | # More information here: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners 3 | 4 | * @aws/ec2-guacamole 5 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23 as builder 2 | 3 | ## GOLANG env 4 | ARG GOPROXY="https://proxy.golang.org|direct" 5 | ARG GO111MODULE="on" 6 | 7 | # Copy go.mod and download dependencies 8 | WORKDIR /amazon-ec2-instance-selector 9 | COPY go.mod . 10 | COPY go.sum . 11 | RUN go mod download 12 | 13 | ARG CGO_ENABLED=0 14 | ARG GOOS=linux 15 | ARG GOARCH=amd64 16 | 17 | # Build 18 | COPY . . 19 | RUN make build 20 | # In case the target is build for testing: 21 | # $ docker build --target=builder -t test . 22 | CMD ["/amazon-ec2-instance-selector/build/ec2-instance-selector"] 23 | 24 | # Copy the binary into a thin image 25 | FROM amazonlinux:2 as amazonlinux 26 | FROM scratch 27 | WORKDIR / 28 | COPY --from=builder /amazon-ec2-instance-selector/build/ec2-instance-selector . 29 | COPY --from=amazonlinux /etc/ssl/certs/ca-bundle.crt /etc/ssl/certs/ 30 | COPY THIRD_PARTY_LICENSES . 31 | USER 1000 32 | ENTRYPOINT ["/ec2-instance-selector"] 33 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION ?= $(shell git describe --tags --always --dirty) 2 | BIN ?= ec2-instance-selector 3 | IMG ?= amazon/amazon-ec2-instance-selector 4 | REPO_FULL_NAME ?= aws/amazon-ec2-instance-selector 5 | IMG_TAG ?= ${VERSION} 6 | IMG_W_TAG = ${IMG}:${IMG_TAG} 7 | DOCKERHUB_USERNAME ?= "" 8 | DOCKERHUB_TOKEN ?= "" 9 | GOOS ?= $(uname | tr '[:upper:]' '[:lower:]') 10 | GOARCH ?= amd64 11 | GOPROXY ?= "https://proxy.golang.org,direct" 12 | MAKEFILE_PATH = $(dir $(realpath -s $(firstword $(MAKEFILE_LIST)))) 13 | BUILD_DIR_PATH = ${MAKEFILE_PATH}/build 14 | SUPPORTED_PLATFORMS ?= "windows/amd64,darwin/amd64,darwin/arm64,linux/amd64,linux/arm64,linux/arm" 15 | SELECTOR_PKG_VERSION_VAR=github.com/aws/amazon-ec2-instance-selector/v3/pkg/selector.versionID 16 | LATEST_RELEASE_TAG=$(shell git describe --tags --abbrev=0) 17 | PREVIOUS_RELEASE_TAG=$(shell git describe --abbrev=0 --tags `git rev-list --tags --skip=1 --max-count=1`) 18 | 19 | $(shell mkdir -p ${BUILD_DIR_PATH} && touch ${BUILD_DIR_PATH}/_go.mod) 20 | 21 | repo-full-name: 22 | @echo ${REPO_FULL_NAME} 23 | 24 | compile: 25 | go build -a -ldflags "-s -w -X main.versionID=${VERSION} -X ${SELECTOR_PKG_VERSION_VAR}=${VERSION}" -tags="aeis${GOOS}" -o ${BUILD_DIR_PATH}/${BIN} ${MAKEFILE_PATH}/cmd/main.go 26 | 27 | clean: 28 | rm -rf ${BUILD_DIR_PATH}/ && go clean -testcache ./... 29 | 30 | fmt: 31 | goimports -w ./ && gofmt -s -w ./ 32 | 33 | docker-build: 34 | ${MAKEFILE_PATH}/scripts/build-docker-images -p ${GOOS}/${GOARCH} -r ${IMG} -v ${VERSION} 35 | 36 | docker-run: 37 | docker run ${IMG_W_TAG} 38 | 39 | docker-push: 40 | @docker login -u ${DOCKERHUB_USERNAME} -p="${DOCKERHUB_TOKEN}" 41 | docker push ${IMG_W_TAG} 42 | 43 | build-docker-images: 44 | ${MAKEFILE_PATH}/scripts/build-docker-images -p ${SUPPORTED_PLATFORMS} -r ${IMG} -v ${VERSION} 45 | 46 | push-docker-images: 47 | @docker login -u ${DOCKERHUB_USERNAME} -p="${DOCKERHUB_TOKEN}" 48 | ${MAKEFILE_PATH}/scripts/push-docker-images -p ${SUPPORTED_PLATFORMS} -r ${IMG} -v ${VERSION} -m 49 | 50 | version: 51 | @echo ${VERSION} 52 | 53 | latest-release-tag: 54 | @echo ${LATEST_RELEASE_TAG} 55 | 56 | previous-release-tag: 57 | @echo ${PREVIOUS_RELEASE_TAG} 58 | 59 | image: 60 | @echo ${IMG_W_TAG} 61 | 62 | license-test: 63 | ${MAKEFILE_PATH}/test/license-test/run-license-test.sh 64 | 65 | spellcheck: 66 | ${MAKEFILE_PATH}/test/readme-test/run-readme-spellcheck 67 | 68 | shellcheck: 69 | ${MAKEFILE_PATH}/test/shellcheck/run-shellcheck 70 | 71 | ## requires aws credentials 72 | readme-codeblock-test: 73 | ${MAKEFILE_PATH}/test/readme-test/run-readme-codeblocks 74 | 75 | 76 | build-binaries: 77 | ${MAKEFILE_PATH}/scripts/build-binaries -p ${SUPPORTED_PLATFORMS} -v ${VERSION} 78 | 79 | ## requires a github token 80 | upload-resources-to-github: 81 | ${MAKEFILE_PATH}/scripts/upload-resources-to-github 82 | 83 | ## requires a dockerhub token 84 | sync-readme-to-dockerhub: 85 | ${MAKEFILE_PATH}/scripts/sync-readme-to-dockerhub 86 | 87 | unit-test: 88 | go test -bench=. ./... -v -coverprofile=coverage.out -covermode=atomic -outputdir=${BUILD_DIR_PATH} 89 | 90 | ## requires aws credentials 91 | e2e-test: build 92 | ${MAKEFILE_PATH}/test/e2e/run-test 93 | 94 | ## requires aws credentials 95 | integ-test: e2e-test readme-codeblock-test 96 | 97 | homebrew-sync-dry-run: 98 | ${MAKEFILE_PATH}/scripts/sync-to-aws-homebrew-tap -d -b ${BIN} -r ${REPO_FULL_NAME} -p ${SUPPORTED_PLATFORMS} -v ${LATEST_RELEASE_TAG} 99 | 100 | homebrew-sync: 101 | ${MAKEFILE_PATH}/scripts/sync-to-aws-homebrew-tap -b ${BIN} -r ${REPO_FULL_NAME} -p ${SUPPORTED_PLATFORMS} 102 | 103 | build: compile 104 | 105 | release: build-binaries upload-resources-to-github 106 | 107 | test: spellcheck shellcheck unit-test license-test e2e-test readme-codeblock-test 108 | 109 | help: 110 | @grep -E '^[a-zA-Z_-]+:.*$$' $(MAKEFILE_LIST) | sort 111 | 112 | ## Targets intended to be run in preparation for a new release 113 | create-local-release-tag-major: 114 | ${MAKEFILE_PATH}/scripts/create-local-tag-for-release -m 115 | 116 | create-local-release-tag-minor: 117 | ${MAKEFILE_PATH}/scripts/create-local-tag-for-release -i 118 | 119 | create-local-release-tag-patch: 120 | ${MAKEFILE_PATH}/scripts/create-local-tag-for-release -p 121 | 122 | create-release-prep-pr: 123 | ${MAKEFILE_PATH}/scripts/prepare-for-release 124 | 125 | create-release-prep-pr-draft: 126 | ${MAKEFILE_PATH}/scripts/prepare-for-release -d 127 | 128 | release-prep-major: create-local-release-tag-major create-release-prep-pr 129 | 130 | release-prep-minor: create-local-release-tag-minor create-release-prep-pr 131 | 132 | release-prep-patch: create-local-release-tag-patch create-release-prep-pr 133 | 134 | release-prep-custom: # Run make NEW_VERSION=v1.2.3 release-prep-custom to prep for a custom release version 135 | ifdef NEW_VERSION 136 | $(shell echo "${MAKEFILE_PATH}/scripts/create-local-tag-for-release -v $(NEW_VERSION) && echo && make create-release-prep-pr") 137 | endif 138 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /cmd/examples/example1.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/aws/amazon-ec2-instance-selector/v3/pkg/bytequantity" 8 | "github.com/aws/amazon-ec2-instance-selector/v3/pkg/selector" 9 | "github.com/aws/aws-sdk-go-v2/config" 10 | ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" 11 | ) 12 | 13 | func main() { 14 | // Initialize a context for the application 15 | ctx := context.Background() 16 | 17 | // Load an AWS session by looking at shared credentials or environment variables 18 | // https://aws.github.io/aws-sdk-go-v2/docs/configuring-sdk 19 | cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("us-east-2")) 20 | if err != nil { 21 | fmt.Printf("Oh no, AWS session credentials cannot be found: %v", err) 22 | return 23 | } 24 | 25 | // Instantiate a new instance of a selector with the AWS session 26 | instanceSelector, err := selector.New(ctx, cfg) 27 | if err != nil { 28 | fmt.Printf("Oh no, there was an error :( %v", err) 29 | return 30 | } 31 | 32 | // Instantiate an int range filter to specify min and max vcpus 33 | vcpusRange := selector.Int32RangeFilter{ 34 | LowerBound: 2, 35 | UpperBound: 4, 36 | } 37 | // Instantiate a byte quantity range filter to specify min and max memory in GiB 38 | memoryRange := selector.ByteQuantityRangeFilter{ 39 | LowerBound: bytequantity.FromGiB(2), 40 | UpperBound: bytequantity.FromGiB(4), 41 | } 42 | // Create a variable for the CPU Architecture so that it can be passed as a pointer 43 | // when creating the Filter struct 44 | cpuArch := ec2types.ArchitectureTypeX8664 45 | 46 | // Create a Filter struct with criteria you would like to filter 47 | // The full struct definition can be found here for all of the supported filters: 48 | // https://github.com/aws/amazon-ec2-instance-selector/blob/main/pkg/selector/types.go 49 | filters := selector.Filters{ 50 | VCpusRange: &vcpusRange, 51 | MemoryRange: &memoryRange, 52 | CPUArchitecture: &cpuArch, 53 | } 54 | 55 | // Pass the Filter struct to the Filter function of your selector instance 56 | instanceTypesSlice, err := instanceSelector.Filter(ctx, filters) 57 | if err != nil { 58 | fmt.Printf("Oh no, there was an error :( %v", err) 59 | return 60 | } 61 | // Print the returned instance types slice 62 | fmt.Println(instanceTypesSlice) 63 | } 64 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/aws/amazon-ec2-instance-selector/v3 2 | 3 | go 1.23 4 | 5 | require ( 6 | dario.cat/mergo v1.0.1 7 | github.com/aws/aws-sdk-go-v2 v1.36.3 8 | github.com/aws/aws-sdk-go-v2/config v1.29.7 9 | github.com/aws/aws-sdk-go-v2/service/ec2 v1.203.1 10 | github.com/aws/aws-sdk-go-v2/service/pricing v1.34.3 11 | github.com/blang/semver/v4 v4.0.0 12 | github.com/charmbracelet/bubbles v0.20.0 13 | github.com/charmbracelet/bubbletea v1.3.3 14 | github.com/charmbracelet/lipgloss v1.0.0 15 | github.com/evertras/bubble-table v0.17.1 16 | github.com/mitchellh/go-homedir v1.1.0 17 | github.com/muesli/termenv v0.16.0 18 | github.com/oliveagle/jsonpath v0.0.0-20180606110733-2e52cf6e6852 19 | github.com/patrickmn/go-cache v2.1.0+incompatible 20 | github.com/samber/lo v1.47.0 21 | github.com/spf13/cobra v1.9.1 22 | github.com/spf13/pflag v1.0.6 23 | go.uber.org/multierr v1.11.0 24 | ) 25 | 26 | require ( 27 | github.com/atotto/clipboard v0.1.4 // indirect 28 | github.com/aws/aws-sdk-go-v2/credentials v1.17.60 // indirect 29 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.29 // indirect 30 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect 31 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect 32 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect 33 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect 34 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.14 // indirect 35 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.16 // indirect 36 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.15 // indirect 37 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.15 // indirect 38 | github.com/aws/smithy-go v1.22.2 // indirect 39 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 40 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 41 | github.com/charmbracelet/x/term v0.2.1 // indirect 42 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 43 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 44 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 45 | github.com/mattn/go-isatty v0.0.20 // indirect 46 | github.com/mattn/go-localereader v0.0.1 // indirect 47 | github.com/mattn/go-runewidth v0.0.16 // indirect 48 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 49 | github.com/muesli/cancelreader v0.2.2 // indirect 50 | github.com/muesli/reflow v0.3.0 // indirect 51 | github.com/rivo/uniseg v0.4.7 // indirect 52 | github.com/sahilm/fuzzy v0.1.1 // indirect 53 | golang.org/x/sync v0.11.0 // indirect 54 | golang.org/x/sys v0.30.0 // indirect 55 | golang.org/x/text v0.22.0 // indirect 56 | ) 57 | -------------------------------------------------------------------------------- /pkg/awsapi/selectorec2.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | package awsapi 15 | 16 | import ( 17 | "context" 18 | 19 | "github.com/aws/aws-sdk-go-v2/service/ec2" 20 | ) 21 | 22 | type SelectorInterface interface { 23 | ec2.DescribeInstanceTypeOfferingsAPIClient 24 | ec2.DescribeInstanceTypesAPIClient 25 | DescribeAvailabilityZones(ctx context.Context, params *ec2.DescribeAvailabilityZonesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeAvailabilityZonesOutput, error) 26 | } 27 | -------------------------------------------------------------------------------- /pkg/bytequantity/bytequantity.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // You may obtain a copy of the License at 4 | // 5 | // http://www.apache.org/licenses/LICENSE-2.0 6 | // 7 | // Unless required by applicable law or agreed to in writing, software 8 | // distributed under the License is distributed on an "AS IS" BASIS, 9 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | // See the License for the specific language governing permissions and 11 | // limitations under the License. 12 | 13 | package bytequantity 14 | 15 | import ( 16 | "fmt" 17 | "math" 18 | "regexp" 19 | "strconv" 20 | "strings" 21 | ) 22 | 23 | const ( 24 | /// Examples: 1mb, 1 gb, 1.0tb, 1mib, 2g, 2.001 t. 25 | byteQuantityRegex = `^([0-9]+\.?[0-9]{0,3})[ ]?(mi?b?|gi?b?|ti?b?)?$` 26 | mib = "MiB" 27 | gib = "GiB" 28 | tib = "TiB" 29 | gbConvert = 1 << 10 30 | tbConvert = gbConvert << 10 31 | maxGiB = math.MaxUint64 / gbConvert 32 | maxTiB = math.MaxUint64 / tbConvert 33 | ) 34 | 35 | // ByteQuantity is a data type representing a byte quantity. 36 | type ByteQuantity struct { 37 | Quantity uint64 38 | } 39 | 40 | // ParseToByteQuantity parses a string representation of a byte quantity to a ByteQuantity type. 41 | // A unit can be appended such as 16 GiB. If no unit is appended, GiB is assumed. 42 | func ParseToByteQuantity(byteQuantityStr string) (ByteQuantity, error) { 43 | bqRegexp := regexp.MustCompile(byteQuantityRegex) 44 | matches := bqRegexp.FindStringSubmatch(strings.ToLower(byteQuantityStr)) 45 | if len(matches) < 2 { 46 | return ByteQuantity{}, fmt.Errorf("%s is not a valid byte quantity", byteQuantityStr) 47 | } 48 | 49 | quantityStr := matches[1] 50 | unit := gib 51 | if len(matches) > 2 && matches[2] != "" { 52 | unit = matches[2] 53 | } 54 | quantity := uint64(0) 55 | switch strings.ToLower(string(unit[0])) { 56 | // mib 57 | case "m": 58 | inputDecSplit := strings.Split(quantityStr, ".") 59 | if len(inputDecSplit) == 2 { 60 | d, err := strconv.Atoi(inputDecSplit[1]) 61 | if err != nil { 62 | return ByteQuantity{}, err 63 | } 64 | if d != 0 { 65 | return ByteQuantity{}, fmt.Errorf("cannot accept floating point MB value, only integers are accepted") 66 | } 67 | } 68 | // need error here so that this quantity doesn't bind in the local scope 69 | var err error 70 | quantity, err = strconv.ParseUint(inputDecSplit[0], 10, 64) 71 | if err != nil { 72 | return ByteQuantity{}, err 73 | } 74 | // gib 75 | case "g": 76 | quantityDec, err := strconv.ParseFloat(quantityStr, 64) 77 | if err != nil { 78 | return ByteQuantity{}, err 79 | } 80 | if quantityDec > maxGiB { 81 | return ByteQuantity{}, fmt.Errorf("error GiB value is too large") 82 | } 83 | quantity = uint64(quantityDec * gbConvert) 84 | // tib 85 | case "t": 86 | quantityDec, err := strconv.ParseFloat(quantityStr, 64) 87 | if err != nil { 88 | return ByteQuantity{}, err 89 | } 90 | if quantityDec > maxTiB { 91 | return ByteQuantity{}, fmt.Errorf("error TiB value is too large") 92 | } 93 | quantity = uint64(quantityDec * tbConvert) 94 | default: 95 | return ByteQuantity{}, fmt.Errorf("error unit %s is not supported", unit) 96 | } 97 | 98 | return ByteQuantity{ 99 | Quantity: quantity, 100 | }, nil 101 | } 102 | 103 | // FromTiB returns a byte quantity of the passed in tebibytes quantity. 104 | func FromTiB(tib uint64) ByteQuantity { 105 | return ByteQuantity{ 106 | Quantity: tib * tbConvert, 107 | } 108 | } 109 | 110 | // FromGiB returns a byte quantity of the passed in gibibytes quantity. 111 | func FromGiB(gib uint64) ByteQuantity { 112 | return ByteQuantity{ 113 | Quantity: gib * gbConvert, 114 | } 115 | } 116 | 117 | // FromMiB returns a byte quantity of the passed in mebibytes quantity. 118 | func FromMiB(mib uint64) ByteQuantity { 119 | return ByteQuantity{ 120 | Quantity: mib, 121 | } 122 | } 123 | 124 | // StringMiB returns a byte quantity in a mebibytes string representation. 125 | func (bq ByteQuantity) StringMiB() string { 126 | return fmt.Sprintf("%.0f %s", bq.MiB(), mib) 127 | } 128 | 129 | // StringGiB returns a byte quantity in a gibibytes string representation. 130 | func (bq ByteQuantity) StringGiB() string { 131 | return fmt.Sprintf("%.3f %s", bq.GiB(), gib) 132 | } 133 | 134 | // StringTiB returns a byte quantity in a tebibytes string representation. 135 | func (bq ByteQuantity) StringTiB() string { 136 | return fmt.Sprintf("%.3f %s", bq.TiB(), tib) 137 | } 138 | 139 | // MiB returns a byte quantity in mebibytes. 140 | func (bq ByteQuantity) MiB() float64 { 141 | return float64(bq.Quantity) 142 | } 143 | 144 | // GiB returns a byte quantity in gibibytes. 145 | func (bq ByteQuantity) GiB() float64 { 146 | return float64(bq.Quantity) * 1 / gbConvert 147 | } 148 | 149 | // TiB returns a byte quantity in tebibytes. 150 | func (bq ByteQuantity) TiB() float64 { 151 | return float64(bq.Quantity) * 1 / tbConvert 152 | } 153 | -------------------------------------------------------------------------------- /pkg/bytequantity/bytequantity_test.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | package bytequantity_test 15 | 16 | import ( 17 | "fmt" 18 | "testing" 19 | 20 | "github.com/aws/amazon-ec2-instance-selector/v3/pkg/bytequantity" 21 | h "github.com/aws/amazon-ec2-instance-selector/v3/pkg/test" 22 | ) 23 | 24 | func TestParseToByteQuantity(t *testing.T) { 25 | for _, testQuantity := range []string{"10mb", "10 mb", "10.0 mb", "10.0mb", "10m", "10mib", "10 M", "10.000 MiB"} { 26 | expectationVal := uint64(10) 27 | bq, err := bytequantity.ParseToByteQuantity(testQuantity) 28 | h.Ok(t, err) 29 | h.Assert(t, bq.Quantity == expectationVal, "quantity should have been %d, got %d instead on string %s", expectationVal, bq.Quantity, testQuantity) 30 | } 31 | 32 | for _, testQuantity := range []string{"4", "4.0", "4gb", "4 gb", "4.0 gb", "4.0gb", "4g", "4gib", "4 G", "4.000 GiB"} { 33 | expectationVal := uint64(4096) 34 | bq, err := bytequantity.ParseToByteQuantity(testQuantity) 35 | h.Ok(t, err) 36 | h.Assert(t, bq.Quantity == expectationVal, "quantity should have been %d, got %d instead on string %s", expectationVal, bq.Quantity, testQuantity) 37 | } 38 | 39 | for _, testQuantity := range []string{"109tb", "109 tb", "109.0 tb", "109.0tb", "109t", "109tib", "109 T", "109.000 TiB"} { 40 | expectationVal := uint64(114294784) 41 | bq, err := bytequantity.ParseToByteQuantity(testQuantity) 42 | h.Ok(t, err) 43 | h.Assert(t, bq.Quantity == expectationVal, "quantity should have been %d, got %d instead on string %s", expectationVal, bq.Quantity, testQuantity) 44 | } 45 | 46 | expectationVal := uint64(1025) 47 | testQuantity := "1.001 gb" 48 | bq, err := bytequantity.ParseToByteQuantity(testQuantity) 49 | h.Ok(t, err) 50 | h.Assert(t, bq.Quantity == expectationVal, "quantity should have been %d, got %d instead on string %s", expectationVal, bq.Quantity, testQuantity) 51 | 52 | // Only supports 3 decimal places 53 | bq, err = bytequantity.ParseToByteQuantity("109.0001") 54 | h.Nok(t, err) 55 | 56 | // Only support decimals on GiB and TiB 57 | bq, err = bytequantity.ParseToByteQuantity("109.001 mib") 58 | h.Nok(t, err) 59 | 60 | // Overflow a uint64 61 | overflow := "18446744073709551616" 62 | bq, err = bytequantity.ParseToByteQuantity(fmt.Sprintf("%s mib", overflow)) 63 | h.Nok(t, err) 64 | 65 | bq, err = bytequantity.ParseToByteQuantity(fmt.Sprintf("%s gib", overflow)) 66 | h.Nok(t, err) 67 | 68 | bq, err = bytequantity.ParseToByteQuantity(fmt.Sprintf("%s tib", overflow)) 69 | h.Nok(t, err) 70 | 71 | // Unit not supported 72 | bq, err = bytequantity.ParseToByteQuantity("1 NS") 73 | h.Nok(t, err) 74 | } 75 | 76 | func TestStringGiB(t *testing.T) { 77 | expectedVal := "0.098 GiB" 78 | testVal := uint64(100) 79 | bq := bytequantity.ByteQuantity{Quantity: testVal} 80 | h.Assert(t, bq.StringGiB() == expectedVal, "%d MiB should equal %s, instead got %s", testVal, expectedVal, bq.StringGiB()) 81 | 82 | expectedVal = "1.000 GiB" 83 | testVal = uint64(1024) 84 | bq = bytequantity.ByteQuantity{Quantity: 1024} 85 | h.Assert(t, bq.StringGiB() == expectedVal, "%d MiB should equal %s, instead got %s", testVal, expectedVal, bq.StringGiB()) 86 | } 87 | 88 | func TestStringTiB(t *testing.T) { 89 | expectedVal := "1.000 TiB" 90 | testVal := uint64(1048576) 91 | bq := bytequantity.ByteQuantity{Quantity: testVal} 92 | h.Assert(t, bq.StringTiB() == expectedVal, "%d MiB should equal %s, instead got %s", testVal, expectedVal, bq.StringTiB()) 93 | 94 | expectedVal = "0.005 TiB" 95 | testVal = uint64(5240) 96 | bq = bytequantity.ByteQuantity{Quantity: testVal} 97 | h.Assert(t, bq.StringTiB() == expectedVal, "%d MiB should equal %s, instead got %s", testVal, expectedVal, bq.StringTiB()) 98 | } 99 | 100 | func TestStringMiB(t *testing.T) { 101 | expectedVal := "1 MiB" 102 | testVal := uint64(1) 103 | bq := bytequantity.ByteQuantity{Quantity: testVal} 104 | h.Assert(t, bq.StringMiB() == expectedVal, "%d MiB should equal %s, instead got %s", testVal, expectedVal, bq.StringMiB()) 105 | 106 | expectedVal = "2 MiB" 107 | testVal = uint64(2) 108 | bq = bytequantity.ByteQuantity{Quantity: testVal} 109 | h.Assert(t, bq.StringMiB() == expectedVal, "%d MiB should equal %s, instead got %s", testVal, expectedVal, bq.StringMiB()) 110 | } 111 | 112 | func TestFromMiB(t *testing.T) { 113 | expectedVal := uint64(1) 114 | bq := bytequantity.FromMiB(expectedVal) 115 | h.Assert(t, bq.MiB() == float64(expectedVal), "%d MiB should equal %d, instead got %s", expectedVal, expectedVal, bq.StringMiB()) 116 | } 117 | 118 | func TestFromGiB(t *testing.T) { 119 | expectedVal := float64(1.0) 120 | testVal := uint64(1) 121 | bq := bytequantity.FromGiB(testVal) 122 | h.Assert(t, bq.GiB() == expectedVal, "%d GiB should equal %d, instead got %s", expectedVal, expectedVal, bq.StringGiB()) 123 | } 124 | 125 | func TestFromTiB(t *testing.T) { 126 | expectedVal := float64(1.0) 127 | testVal := uint64(1) 128 | bq := bytequantity.FromTiB(testVal) 129 | h.Assert(t, bq.TiB() == expectedVal, "%d TiB should equal %d, instead got %s", expectedVal, expectedVal, bq.StringTiB()) 130 | } 131 | -------------------------------------------------------------------------------- /pkg/cli/cli_internal_test.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // You may obtain a copy of the License at 4 | // 5 | // http://www.apache.org/licenses/LICENSE-2.0 6 | // 7 | // Unless required by applicable law or agreed to in writing, software 8 | // distributed under the License is distributed on an "AS IS" BASIS, 9 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | // See the License for the specific language governing permissions and 11 | // limitations under the License. 12 | 13 | package cli 14 | 15 | import ( 16 | "os" 17 | "testing" 18 | 19 | "github.com/spf13/pflag" 20 | 21 | h "github.com/aws/amazon-ec2-instance-selector/v3/pkg/test" 22 | ) 23 | 24 | // Tests 25 | 26 | func TestRemoveIntersectingArgs(t *testing.T) { 27 | flagSet := pflag.NewFlagSet("test-flag-set", pflag.ContinueOnError) 28 | flagSet.Bool("test-bool", false, "test usage") 29 | os.Args = []string{"ec2-instance-selector", "--test-bool", "--this-should-stay"} 30 | newArgs := removeIntersectingArgs(flagSet) 31 | h.Assert(t, len(newArgs) == 2, "NewArgs should only include the bin name and one argument after removing intersections") 32 | } 33 | 34 | func TestRemoveIntersectingArgs_NextArg(t *testing.T) { 35 | flagSet := pflag.NewFlagSet("test-flag-set", pflag.ContinueOnError) 36 | flagSet.String("test-str", "", "test usage") 37 | os.Args = []string{"ec2-instance-selector", "--test-str", "somevalue", "--this-should-stay", "valuetostay"} 38 | newArgs := removeIntersectingArgs(flagSet) 39 | h.Assert(t, len(newArgs) == 3, "NewArgs should only include the bin name and a flag + input after removing intersections") 40 | } 41 | 42 | func TestRemoveIntersectingArgs_ShorthandArg(t *testing.T) { 43 | flagSet := pflag.NewFlagSet("test-flag-set", pflag.ContinueOnError) 44 | flagSet.StringP("test-str", "t", "", "test usage") 45 | os.Args = []string{"ec2-instance-selector", "--test-str", "somevalue", "--this-should-stay", "valuetostay", "-t", "test"} 46 | newArgs := removeIntersectingArgs(flagSet) 47 | h.Assert(t, len(newArgs) == 3, "NewArgs should only include the bin name and a flag + input after removing intersections") 48 | } 49 | -------------------------------------------------------------------------------- /pkg/cli/types.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // You may obtain a copy of the License at 4 | // 5 | // http://www.apache.org/licenses/LICENSE-2.0 6 | // 7 | // Unless required by applicable law or agreed to in writing, software 8 | // distributed under the License is distributed on an "AS IS" BASIS, 9 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | // See the License for the specific language governing permissions and 11 | // limitations under the License. 12 | 13 | // Package cli provides functions to build the selector command line interface 14 | package cli 15 | 16 | import ( 17 | "log" 18 | "regexp" 19 | 20 | "github.com/spf13/cobra" 21 | "github.com/spf13/pflag" 22 | 23 | "github.com/aws/amazon-ec2-instance-selector/v3/pkg/bytequantity" 24 | "github.com/aws/amazon-ec2-instance-selector/v3/pkg/selector" 25 | ) 26 | 27 | const ( 28 | // Usage Template to run on --help. 29 | usageTemplate = `Usage:{{if .Runnable}} 30 | {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} 31 | {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} 32 | 33 | Aliases: 34 | {{.NameAndAliases}}{{end}}{{if .HasExample}} 35 | 36 | Examples: 37 | {{.Example}}{{end}}{{if .HasAvailableSubCommands}} 38 | 39 | Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}} 40 | {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} 41 | 42 | Filter Flags: 43 | {{.LocalNonPersistentFlags.FlagUsages | trimTrailingWhitespaces}} 44 | %s 45 | Global Flags: 46 | {{.PersistentFlags.FlagUsages | trimTrailingWhitespaces}} 47 | 48 | {{end}}` 49 | ) 50 | 51 | // validator defines the function for providing validation on a flag. 52 | type validator = func(val interface{}) error 53 | 54 | // processor defines the function for providing mutating processing on a flag. 55 | type processor = func(val interface{}) error 56 | 57 | // CommandLineInterface is a type to group CLI funcs and state. 58 | type CommandLineInterface struct { 59 | Command *cobra.Command 60 | Flags map[string]interface{} 61 | nilDefaults map[string]bool 62 | rangeFlags map[string]bool 63 | validators map[string]validator 64 | processors map[string]processor 65 | suiteFlags *pflag.FlagSet 66 | } 67 | 68 | // Float64Me takes an interface and returns a pointer to a float64 value 69 | // If the underlying interface kind is not float64 or *float64 then nil is returned. 70 | func (*CommandLineInterface) Float64Me(i interface{}) *float64 { 71 | if i == nil { 72 | return nil 73 | } 74 | switch v := i.(type) { 75 | case *float64: 76 | return v 77 | case float64: 78 | return &v 79 | default: 80 | log.Printf("%s cannot be converted to a float64", i) 81 | return nil 82 | } 83 | } 84 | 85 | // IntMe takes an interface and returns a pointer to an int value 86 | // If the underlying interface kind is not int or *int then nil is returned. 87 | func (*CommandLineInterface) IntMe(i interface{}) *int { 88 | if i == nil { 89 | return nil 90 | } 91 | switch v := i.(type) { 92 | case *int: 93 | return v 94 | case int: 95 | return &v 96 | case *int32: 97 | val := int(*v) 98 | return &val 99 | case int32: 100 | val := int(v) 101 | return &val 102 | default: 103 | log.Printf("%s cannot be converted to an int", i) 104 | return nil 105 | } 106 | } 107 | 108 | // Int32Me takes an interface and returns a pointer to an int value 109 | // If the underlying interface kind is not int or *int then nil is returned. 110 | func (*CommandLineInterface) Int32Me(i interface{}) *int32 { 111 | if i == nil { 112 | return nil 113 | } 114 | switch v := i.(type) { 115 | case *int: 116 | val := int32(*v) 117 | return &val 118 | case int: 119 | val := int32(v) 120 | return &val 121 | case *int32: 122 | return v 123 | case int32: 124 | return &v 125 | default: 126 | log.Printf("%s cannot be converted to an int32", i) 127 | return nil 128 | } 129 | } 130 | 131 | // IntRangeMe takes an interface and returns a pointer to an IntRangeFilter value 132 | // If the underlying interface kind is not IntRangeFilter or *IntRangeFilter then nil is returned. 133 | func (*CommandLineInterface) IntRangeMe(i interface{}) *selector.IntRangeFilter { 134 | if i == nil { 135 | return nil 136 | } 137 | switch v := i.(type) { 138 | case *selector.IntRangeFilter: 139 | return v 140 | case selector.IntRangeFilter: 141 | return &v 142 | default: 143 | log.Printf("%s cannot be converted to an IntRange", i) 144 | return nil 145 | } 146 | } 147 | 148 | // Int32RangeMe takes an interface and returns a pointer to an Int32RangeFilter value 149 | // If the underlying interface kind is not Int32RangeFilter or *Int32RangeFilter then nil is returned. 150 | func (*CommandLineInterface) Int32RangeMe(i interface{}) *selector.Int32RangeFilter { 151 | if i == nil { 152 | return nil 153 | } 154 | switch v := i.(type) { 155 | case *selector.Int32RangeFilter: 156 | return v 157 | case selector.Int32RangeFilter: 158 | return &v 159 | default: 160 | log.Printf("%s cannot be converted to an Int32Range", i) 161 | return nil 162 | } 163 | } 164 | 165 | // ByteQuantityRangeMe takes an interface and returns a pointer to a ByteQuantityRangeFilter value 166 | // If the underlying interface kind is not ByteQuantityRangeFilter or *ByteQuantityRangeFilter then nil is returned. 167 | func (*CommandLineInterface) ByteQuantityRangeMe(i interface{}) *selector.ByteQuantityRangeFilter { 168 | if i == nil { 169 | return nil 170 | } 171 | switch v := i.(type) { 172 | case *selector.ByteQuantityRangeFilter: 173 | return v 174 | case selector.ByteQuantityRangeFilter: 175 | return &v 176 | default: 177 | log.Printf("%s cannot be converted to a ByteQuantityRange", i) 178 | return nil 179 | } 180 | } 181 | 182 | // Float64RangeMe takes an interface and returns a pointer to a Float64RangeFilter value 183 | // If the underlying interface kind is not Float64RangeFilter or *Float64RangeFilter then nil is returned. 184 | func (*CommandLineInterface) Float64RangeMe(i interface{}) *selector.Float64RangeFilter { 185 | if i == nil { 186 | return nil 187 | } 188 | switch v := i.(type) { 189 | case *selector.Float64RangeFilter: 190 | return v 191 | case selector.Float64RangeFilter: 192 | return &v 193 | default: 194 | log.Printf("%s cannot be converted to a Float64Range", i) 195 | return nil 196 | } 197 | } 198 | 199 | // StringMe takes an interface and returns a pointer to a string value 200 | // If the underlying interface kind is not string or *string then nil is returned. 201 | func (*CommandLineInterface) StringMe(i interface{}) *string { 202 | if i == nil { 203 | return nil 204 | } 205 | switch v := i.(type) { 206 | case *string: 207 | return v 208 | case string: 209 | return &v 210 | default: 211 | log.Printf("%s cannot be converted to a string", i) 212 | return nil 213 | } 214 | } 215 | 216 | // BoolMe takes an interface and returns a pointer to a bool value 217 | // If the underlying interface kind is not bool or *bool then nil is returned. 218 | func (*CommandLineInterface) BoolMe(i interface{}) *bool { 219 | if i == nil { 220 | return nil 221 | } 222 | switch v := i.(type) { 223 | case *bool: 224 | return v 225 | case bool: 226 | return &v 227 | default: 228 | log.Printf("%s cannot be converted to a bool", i) 229 | return nil 230 | } 231 | } 232 | 233 | // StringSliceMe takes an interface and returns a pointer to a string slice 234 | // If the underlying interface kind is not []string or *[]string then nil is returned. 235 | func (*CommandLineInterface) StringSliceMe(i interface{}) *[]string { 236 | if i == nil { 237 | return nil 238 | } 239 | switch v := i.(type) { 240 | case *[]string: 241 | return v 242 | case []string: 243 | return &v 244 | default: 245 | log.Printf("%s cannot be converted to a string list", i) 246 | return nil 247 | } 248 | } 249 | 250 | // RegexMe takes an interface and returns a pointer to a regex 251 | // If the underlying interface kind is not regexp.Regexp or *regexp.Regexp then nil is returned. 252 | func (*CommandLineInterface) RegexMe(i interface{}) *regexp.Regexp { 253 | if i == nil { 254 | return nil 255 | } 256 | switch v := i.(type) { 257 | case *regexp.Regexp: 258 | return v 259 | case regexp.Regexp: 260 | return &v 261 | default: 262 | log.Printf("%s cannot be converted to a regexp", i) 263 | return nil 264 | } 265 | } 266 | 267 | // ByteQuantityMe takes an interface and returns a pointer to a ByteQuantity 268 | // If the underlying interface kind is not bytequantity.ByteQuantity or *bytequantity.ByteQuantity then nil is returned. 269 | func (*CommandLineInterface) ByteQuantityMe(i interface{}) *bytequantity.ByteQuantity { 270 | if i == nil { 271 | return nil 272 | } 273 | switch v := i.(type) { 274 | case *bytequantity.ByteQuantity: 275 | return v 276 | case bytequantity.ByteQuantity: 277 | return &v 278 | default: 279 | log.Printf("%s cannot be converted to a byte quantity", i) 280 | return nil 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /pkg/cli/types_test.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // You may obtain a copy of the License at 4 | // 5 | // http://www.apache.org/licenses/LICENSE-2.0 6 | // 7 | // Unless required by applicable law or agreed to in writing, software 8 | // distributed under the License is distributed on an "AS IS" BASIS, 9 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | // See the License for the specific language governing permissions and 11 | // limitations under the License. 12 | 13 | package cli_test 14 | 15 | import ( 16 | "reflect" 17 | "regexp" 18 | "testing" 19 | 20 | "github.com/aws/amazon-ec2-instance-selector/v3/pkg/bytequantity" 21 | "github.com/aws/amazon-ec2-instance-selector/v3/pkg/selector" 22 | h "github.com/aws/amazon-ec2-instance-selector/v3/pkg/test" 23 | ) 24 | 25 | // Tests 26 | 27 | func TestBoolMe(t *testing.T) { 28 | cli := getTestCLI() 29 | boolTrue := true 30 | val := cli.BoolMe(boolTrue) 31 | h.Assert(t, *val == true, "Should return true from passed in value bool") 32 | val = cli.BoolMe(&boolTrue) 33 | h.Assert(t, *val == true, "Should return true from passed in pointer bool") 34 | val = cli.BoolMe(7) 35 | h.Assert(t, val == nil, "Should return nil from other data type passed in") 36 | val = cli.BoolMe(nil) 37 | h.Assert(t, val == nil, "Should return nil if nil is passed in") 38 | } 39 | 40 | func TestStringMe(t *testing.T) { 41 | cli := getTestCLI() 42 | stringVal := "test" 43 | val := cli.StringMe(stringVal) 44 | h.Assert(t, *val == stringVal, "Should return %s from passed in string value", stringVal) 45 | val = cli.StringMe(&stringVal) 46 | h.Assert(t, *val == stringVal, "Should return %s from passed in string pointer", stringVal) 47 | val = cli.StringMe(7) 48 | h.Assert(t, val == nil, "Should return nil from other data type passed in") 49 | val = cli.StringMe(nil) 50 | h.Assert(t, val == nil, "Should return nil if nil is passed in") 51 | } 52 | 53 | func TestStringSliceMe(t *testing.T) { 54 | cli := getTestCLI() 55 | stringSliceVal := []string{"test"} 56 | val := cli.StringSliceMe(stringSliceVal) 57 | h.Assert(t, reflect.DeepEqual(*val, stringSliceVal), "Should return %s from passed in string slice value", stringSliceVal) 58 | val = cli.StringSliceMe(&stringSliceVal) 59 | h.Assert(t, reflect.DeepEqual(*val, stringSliceVal), "Should return %s from passed in string slicepointer", stringSliceVal) 60 | val = cli.StringSliceMe(7) 61 | h.Assert(t, val == nil, "Should return nil from other data type passed in") 62 | val = cli.StringSliceMe(nil) 63 | h.Assert(t, val == nil, "Should return nil if nil is passed in") 64 | } 65 | 66 | func TestIntMe(t *testing.T) { 67 | cli := getTestCLI() 68 | intVal := 10 69 | int32Val := int32(intVal) 70 | val := cli.IntMe(intVal) 71 | h.Assert(t, *val == intVal, "Should return %s from passed in int value", intVal) 72 | val = cli.IntMe(&intVal) 73 | h.Assert(t, *val == intVal, "Should return %s from passed in int pointer", intVal) 74 | val = cli.IntMe(int32Val) 75 | h.Assert(t, *val == intVal, "Should return %s from passed in int32 value", intVal) 76 | val = cli.IntMe(&int32Val) 77 | h.Assert(t, *val == intVal, "Should return %s from passed in int32 pointer", intVal) 78 | val = cli.IntMe(true) 79 | h.Assert(t, val == nil, "Should return nil from other data type passed in") 80 | val = cli.IntMe(nil) 81 | h.Assert(t, val == nil, "Should return nil if nil is passed in") 82 | } 83 | 84 | func TestFloat64Me(t *testing.T) { 85 | cli := getTestCLI() 86 | fVal := 10.01 87 | val := cli.Float64Me(fVal) 88 | h.Assert(t, *val == fVal, "Should return %s from passed in float64 value", fVal) 89 | val = cli.Float64Me(&fVal) 90 | h.Assert(t, *val == fVal, "Should return %s from passed in float64 pointer", fVal) 91 | val = cli.Float64Me(true) 92 | h.Assert(t, val == nil, "Should return nil from other data type passed in") 93 | val = cli.Float64Me(nil) 94 | h.Assert(t, val == nil, "Should return nil if nil is passed in") 95 | } 96 | 97 | func TestIntRangeMe(t *testing.T) { 98 | cli := getTestCLI() 99 | intRangeVal := selector.IntRangeFilter{LowerBound: 1, UpperBound: 2} 100 | val := cli.IntRangeMe(intRangeVal) 101 | h.Assert(t, *val == intRangeVal, "Should return %s from passed in int range value", intRangeVal) 102 | val = cli.IntRangeMe(&intRangeVal) 103 | h.Assert(t, *val == intRangeVal, "Should return %s from passed in range pointer", intRangeVal) 104 | val = cli.IntRangeMe(true) 105 | h.Assert(t, val == nil, "Should return nil from other data type passed in") 106 | val = cli.IntRangeMe(nil) 107 | h.Assert(t, val == nil, "Should return nil if nil is passed in") 108 | } 109 | 110 | func TestByteQuantityRangeMe(t *testing.T) { 111 | cli := getTestCLI() 112 | bq1 := bytequantity.ByteQuantity{Quantity: 1} 113 | bqRangeVal := selector.ByteQuantityRangeFilter{LowerBound: bq1, UpperBound: bq1} 114 | val := cli.ByteQuantityRangeMe(bqRangeVal) 115 | h.Assert(t, *val == bqRangeVal, "Should return %s from passed in byte quantity range value", bqRangeVal) 116 | val = cli.ByteQuantityRangeMe(&bqRangeVal) 117 | h.Assert(t, *val == bqRangeVal, "Should return %s from passed in range pointer", bqRangeVal) 118 | val = cli.ByteQuantityRangeMe(true) 119 | h.Assert(t, val == nil, "Should return nil from other data type passed in") 120 | val = cli.ByteQuantityRangeMe(nil) 121 | h.Assert(t, val == nil, "Should return nil if nil is passed in") 122 | } 123 | 124 | func TestRegexMe(t *testing.T) { 125 | cli := getTestCLI() 126 | regexVal, err := regexp.Compile("c4.*") 127 | h.Ok(t, err) 128 | val := cli.RegexMe(*regexVal) 129 | h.Assert(t, val.String() == regexVal.String(), "Should return %s from passed in regex value", regexVal) 130 | val = cli.RegexMe(regexVal) 131 | h.Assert(t, val.String() == regexVal.String(), "Should return %s from passed in regex pointer", regexVal) 132 | val = cli.RegexMe(true) 133 | h.Assert(t, val == nil, "Should return nil from other data type passed in") 134 | val = cli.RegexMe(nil) 135 | h.Assert(t, val == nil, "Should return nil if nil is passed in") 136 | } 137 | 138 | func TestFloat64RangeMe(t *testing.T) { 139 | cli := getTestCLI() 140 | float64RangeVal := selector.Float64RangeFilter{LowerBound: 1.0, UpperBound: 2.1} 141 | val := cli.Float64RangeMe(float64RangeVal) 142 | h.Assert(t, *val == float64RangeVal, "Should return %s from passed in float64 range value", float64RangeVal) 143 | val = cli.Float64RangeMe(&float64RangeVal) 144 | h.Assert(t, *val == float64RangeVal, "Should return %s from passed in range pointer", float64RangeVal) 145 | val = cli.Float64RangeMe(true) 146 | h.Assert(t, val == nil, "Should return nil from other data type passed in") 147 | val = cli.Float64RangeMe(nil) 148 | h.Assert(t, val == nil, "Should return nil if nil is passed in") 149 | } 150 | -------------------------------------------------------------------------------- /pkg/ec2pricing/ec2pricing.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // You may obtain a copy of the License at 4 | // 5 | // http://www.apache.org/licenses/LICENSE-2.0 6 | // 7 | // Unless required by applicable law or agreed to in writing, software 8 | // distributed under the License is distributed on an "AS IS" BASIS, 9 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | // See the License for the specific language governing permissions and 11 | // limitations under the License. 12 | 13 | package ec2pricing 14 | 15 | import ( 16 | "context" 17 | "fmt" 18 | "log" 19 | "time" 20 | 21 | "github.com/aws/aws-sdk-go-v2/aws" 22 | "github.com/aws/aws-sdk-go-v2/service/ec2" 23 | ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" 24 | "github.com/aws/aws-sdk-go-v2/service/pricing" 25 | "go.uber.org/multierr" 26 | ) 27 | 28 | const ( 29 | productDescription = "Linux/UNIX (Amazon VPC)" 30 | serviceCode = "AmazonEC2" 31 | ) 32 | 33 | var DefaultSpotDaysBack = 30 34 | 35 | // EC2Pricing is the public struct to interface with AWS pricing APIs. 36 | type EC2Pricing struct { 37 | ODPricing *OnDemandPricing 38 | SpotPricing *SpotPricing 39 | logger *log.Logger 40 | } 41 | 42 | // EC2PricingIface is the EC2Pricing interface mainly used to mock out ec2pricing during testing. 43 | type EC2PricingIface interface { 44 | GetOnDemandInstanceTypeCost(ctx context.Context, instanceType ec2types.InstanceType) (float64, error) 45 | GetSpotInstanceTypeNDayAvgCost(ctx context.Context, instanceType ec2types.InstanceType, availabilityZones []string, days int) (float64, error) 46 | RefreshOnDemandCache(ctx context.Context) error 47 | RefreshSpotCache(ctx context.Context, days int) error 48 | OnDemandCacheCount() int 49 | SpotCacheCount() int 50 | Save() error 51 | SetLogger(*log.Logger) 52 | } 53 | 54 | // use us-east-1 since pricing only has endpoints in us-east-1 and ap-south-1 55 | // TODO: In the future we may want to allow the client to select which endpoint is used through some mechanism 56 | // 57 | // but that would likely happen through overriding this entire function as its signature is fixed 58 | func modifyPricingRegion(opt *pricing.Options) { 59 | opt.Region = "us-east-1" 60 | } 61 | 62 | // New creates an instance of instance-selector EC2Pricing. 63 | func New(ctx context.Context, cfg aws.Config) (*EC2Pricing, error) { 64 | return NewWithCache(ctx, cfg, 0, "") 65 | } 66 | 67 | func NewWithCache(ctx context.Context, cfg aws.Config, ttl time.Duration, cacheDir string) (*EC2Pricing, error) { 68 | pricingClient := pricing.NewFromConfig(cfg, modifyPricingRegion) 69 | ec2Client := ec2.NewFromConfig(cfg) 70 | odPricingCache, err := LoadODCacheOrNew(ctx, pricingClient, cfg.Region, ttl, cacheDir) 71 | if err != nil { 72 | return nil, fmt.Errorf("unable to initialize the OD pricing cache: %w", err) 73 | } 74 | spotPricingCache, err := LoadSpotCacheOrNew(ctx, ec2Client, cfg.Region, ttl, cacheDir, DefaultSpotDaysBack) 75 | if err != nil { 76 | return nil, fmt.Errorf("unable to initialize the spot pricing cache: %w", err) 77 | } 78 | return &EC2Pricing{ 79 | ODPricing: odPricingCache, 80 | SpotPricing: spotPricingCache, 81 | }, nil 82 | } 83 | 84 | func (p *EC2Pricing) SetLogger(logger *log.Logger) { 85 | p.logger = logger 86 | p.ODPricing.SetLogger(logger) 87 | p.SpotPricing.SetLogger(logger) 88 | } 89 | 90 | // OnDemandCacheCount returns the number of items in the OD cache. 91 | func (p *EC2Pricing) OnDemandCacheCount() int { 92 | return p.ODPricing.Count() 93 | } 94 | 95 | // SpotCacheCount returns the number of items in the spot cache. 96 | func (p *EC2Pricing) SpotCacheCount() int { 97 | return p.SpotPricing.Count() 98 | } 99 | 100 | // GetSpotInstanceTypeNDayAvgCost retrieves the spot price history for a given AZ from the past N days and averages the price 101 | // Passing an empty list for availabilityZones will retrieve avg cost for all AZs in the current AWSSession's region. 102 | func (p *EC2Pricing) GetSpotInstanceTypeNDayAvgCost(ctx context.Context, instanceType ec2types.InstanceType, availabilityZones []string, days int) (float64, error) { 103 | if len(availabilityZones) == 0 { 104 | return p.SpotPricing.Get(ctx, instanceType, "", days) 105 | } 106 | costs := []float64{} 107 | var errs error 108 | for _, zone := range availabilityZones { 109 | cost, err := p.SpotPricing.Get(ctx, instanceType, zone, days) 110 | if err != nil { 111 | errs = multierr.Append(errs, err) 112 | } 113 | costs = append(costs, cost) 114 | } 115 | 116 | if len(multierr.Errors(errs)) == len(availabilityZones) { 117 | return -1, errs 118 | } 119 | return costs[0], nil 120 | } 121 | 122 | // GetOnDemandInstanceTypeCost retrieves the on-demand hourly cost for the specified instance type. 123 | func (p *EC2Pricing) GetOnDemandInstanceTypeCost(ctx context.Context, instanceType ec2types.InstanceType) (float64, error) { 124 | return p.ODPricing.Get(ctx, instanceType) 125 | } 126 | 127 | // RefreshOnDemandCache makes a bulk request to the pricing api to retrieve all instance type pricing and stores them in a local cache. 128 | func (p *EC2Pricing) RefreshOnDemandCache(ctx context.Context) error { 129 | return p.ODPricing.Refresh(ctx) 130 | } 131 | 132 | // RefreshSpotCache makes a bulk request to the ec2 api to retrieve all spot instance type pricing and stores them in a local cache. 133 | func (p *EC2Pricing) RefreshSpotCache(ctx context.Context, days int) error { 134 | return p.SpotPricing.Refresh(ctx, days) 135 | } 136 | 137 | func (p *EC2Pricing) Save() error { 138 | return multierr.Append(p.ODPricing.Save(), p.SpotPricing.Save()) 139 | } 140 | -------------------------------------------------------------------------------- /pkg/ec2pricing/ec2pricing_test.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // You may obtain a copy of the License at 4 | // 5 | // http://www.apache.org/licenses/LICENSE-2.0 6 | // 7 | // Unless required by applicable law or agreed to in writing, software 8 | // distributed under the License is distributed on an "AS IS" BASIS, 9 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | // See the License for the specific language governing permissions and 11 | // limitations under the License. 12 | 13 | package ec2pricing_test 14 | 15 | import ( 16 | "context" 17 | "encoding/json" 18 | "fmt" 19 | "os" 20 | "testing" 21 | 22 | "github.com/aws/aws-sdk-go-v2/service/ec2" 23 | ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" 24 | "github.com/aws/aws-sdk-go-v2/service/pricing" 25 | "github.com/samber/lo" 26 | 27 | "github.com/aws/amazon-ec2-instance-selector/v3/pkg/ec2pricing" 28 | h "github.com/aws/amazon-ec2-instance-selector/v3/pkg/test" 29 | ) 30 | 31 | const ( 32 | getProducts = "GetProducts" 33 | describeSpotPriceHistory = "DescribeSpotPriceHistory" 34 | mockFilesPath = "../../test/static" 35 | ) 36 | 37 | // Mocking helpers 38 | 39 | type mockedPricing struct { 40 | pricing.GetProductsAPIClient 41 | GetProductsResp pricing.GetProductsOutput 42 | GetProductsErr error 43 | } 44 | 45 | func (m mockedPricing) GetProducts(_ context.Context, input *pricing.GetProductsInput, optFns ...func(*pricing.Options)) (*pricing.GetProductsOutput, error) { 46 | return &m.GetProductsResp, m.GetProductsErr 47 | } 48 | 49 | type mockedSpotEC2 struct { 50 | ec2.DescribeSpotPriceHistoryAPIClient 51 | DescribeSpotPriceHistoryPagesResp ec2.DescribeSpotPriceHistoryOutput 52 | DescribeSpotPriceHistoryPagesErr error 53 | } 54 | 55 | func (m mockedSpotEC2) DescribeSpotPriceHistory(_ context.Context, input *ec2.DescribeSpotPriceHistoryInput, optFns ...func(*ec2.Options)) (*ec2.DescribeSpotPriceHistoryOutput, error) { 56 | return &m.DescribeSpotPriceHistoryPagesResp, m.DescribeSpotPriceHistoryPagesErr 57 | } 58 | 59 | func setupOdMock(t *testing.T, api string, file string) mockedPricing { 60 | mockFilename := fmt.Sprintf("%s/%s/%s", mockFilesPath, api, file) 61 | mockFile, err := os.ReadFile(mockFilename) 62 | h.Assert(t, err == nil, "Error reading mock file "+mockFilename) 63 | switch api { 64 | case getProducts: 65 | priceList := []string{string(mockFile)} 66 | productsOutput := pricing.GetProductsOutput{ 67 | PriceList: priceList, 68 | } 69 | return mockedPricing{ 70 | GetProductsResp: productsOutput, 71 | } 72 | 73 | default: 74 | h.Assert(t, false, "Unable to mock the provided API type "+api) 75 | } 76 | return mockedPricing{} 77 | } 78 | 79 | func setupEc2Mock(t *testing.T, api string, file string) mockedSpotEC2 { 80 | mockFilename := fmt.Sprintf("%s/%s/%s", mockFilesPath, api, file) 81 | mockFile, err := os.ReadFile(mockFilename) 82 | h.Assert(t, err == nil, "Error reading mock file "+mockFilename) 83 | switch api { 84 | case describeSpotPriceHistory: 85 | dspho := ec2.DescribeSpotPriceHistoryOutput{} 86 | err = json.Unmarshal(mockFile, &dspho) 87 | h.Assert(t, err == nil, "Error parsing mock json file contents"+mockFilename) 88 | return mockedSpotEC2{ 89 | DescribeSpotPriceHistoryPagesResp: dspho, 90 | } 91 | 92 | default: 93 | h.Assert(t, false, "Unable to mock the provided API type "+api) 94 | } 95 | return mockedSpotEC2{} 96 | } 97 | 98 | func TestGetOndemandInstanceTypeCost_m5large(t *testing.T) { 99 | pricingMock := setupOdMock(t, getProducts, "m5_large.json") 100 | ctx := context.Background() 101 | ec2pricingClient := ec2pricing.EC2Pricing{ 102 | ODPricing: lo.Must(ec2pricing.LoadODCacheOrNew(ctx, pricingMock, "us-east-1", 0, "")), 103 | } 104 | price, err := ec2pricingClient.GetOnDemandInstanceTypeCost(ctx, ec2types.InstanceTypeM5Large) 105 | h.Ok(t, err) 106 | h.Equals(t, float64(0.096), price) 107 | } 108 | 109 | func TestRefreshOnDemandCache(t *testing.T) { 110 | pricingMock := setupOdMock(t, getProducts, "m5_large.json") 111 | ctx := context.Background() 112 | ec2pricingClient := ec2pricing.EC2Pricing{ 113 | ODPricing: lo.Must(ec2pricing.LoadODCacheOrNew(ctx, pricingMock, "us-east-1", 0, "")), 114 | } 115 | err := ec2pricingClient.RefreshOnDemandCache(ctx) 116 | h.Ok(t, err) 117 | 118 | price, err := ec2pricingClient.GetOnDemandInstanceTypeCost(ctx, ec2types.InstanceTypeM5Large) 119 | h.Ok(t, err) 120 | h.Equals(t, float64(0.096), price) 121 | } 122 | 123 | func TestGetSpotInstanceTypeNDayAvgCost(t *testing.T) { 124 | ec2Mock := setupEc2Mock(t, describeSpotPriceHistory, "m5_large.json") 125 | ctx := context.Background() 126 | ec2pricingClient := ec2pricing.EC2Pricing{ 127 | SpotPricing: lo.Must(ec2pricing.LoadSpotCacheOrNew(ctx, ec2Mock, "us-east-1", 0, "", 30)), 128 | } 129 | price, err := ec2pricingClient.GetSpotInstanceTypeNDayAvgCost(ctx, ec2types.InstanceTypeM5Large, []string{"us-east-1a"}, 30) 130 | h.Ok(t, err) 131 | h.Equals(t, float64(0.041486231229302666), price) 132 | } 133 | 134 | func TestRefreshSpotCache(t *testing.T) { 135 | ec2Mock := setupEc2Mock(t, describeSpotPriceHistory, "m5_large.json") 136 | ctx := context.Background() 137 | ec2pricingClient := ec2pricing.EC2Pricing{ 138 | SpotPricing: lo.Must(ec2pricing.LoadSpotCacheOrNew(ctx, ec2Mock, "us-east-1", 0, "", 30)), 139 | } 140 | err := ec2pricingClient.RefreshSpotCache(ctx, 30) 141 | h.Ok(t, err) 142 | 143 | price, err := ec2pricingClient.GetSpotInstanceTypeNDayAvgCost(ctx, ec2types.InstanceTypeM5Large, []string{"us-east-1a"}, 30) 144 | h.Ok(t, err) 145 | h.Equals(t, float64(0.041486231229302666), price) 146 | } 147 | -------------------------------------------------------------------------------- /pkg/env/env.go: -------------------------------------------------------------------------------- 1 | /* 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | */ 14 | 15 | package env 16 | 17 | import ( 18 | "os" 19 | "strconv" 20 | ) 21 | 22 | // WithDefaultInt returns the int value of the supplied environment variable or, if not present, 23 | // the supplied default value. If the int conversion fails, returns the default. 24 | func WithDefaultInt(key string, def int) *int { 25 | val, ok := os.LookupEnv(key) 26 | if !ok { 27 | return &def 28 | } 29 | i, err := strconv.Atoi(val) 30 | if err != nil { 31 | return &def 32 | } 33 | return &i 34 | } 35 | 36 | // WithDefaultString returns the string value of the supplied environment variable or, if not present, 37 | // the supplied default value. 38 | func WithDefaultString(key string, def string) *string { 39 | val, ok := os.LookupEnv(key) 40 | if !ok { 41 | return &def 42 | } 43 | return &val 44 | } 45 | -------------------------------------------------------------------------------- /pkg/instancetypes/instancetypes.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // You may obtain a copy of the License at 4 | // 5 | // http://www.apache.org/licenses/LICENSE-2.0 6 | // 7 | // Unless required by applicable law or agreed to in writing, software 8 | // distributed under the License is distributed on an "AS IS" BASIS, 9 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | // See the License for the specific language governing permissions and 11 | // limitations under the License. 12 | 13 | package instancetypes 14 | 15 | import ( 16 | "context" 17 | "encoding/json" 18 | "errors" 19 | "fmt" 20 | "io" 21 | "log" 22 | "os" 23 | "path/filepath" 24 | "time" 25 | 26 | "github.com/aws/aws-sdk-go-v2/service/ec2" 27 | ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" 28 | "github.com/mitchellh/go-homedir" 29 | "github.com/patrickmn/go-cache" 30 | ) 31 | 32 | var CacheFileName = "ec2-instance-types.json" 33 | 34 | // Details hold all the information on an ec2 instance type. 35 | type Details struct { 36 | ec2types.InstanceTypeInfo 37 | OndemandPricePerHour *float64 38 | SpotPrice *float64 39 | } 40 | 41 | type Provider struct { 42 | Region string 43 | DirectoryPath string 44 | FullRefreshTTL time.Duration 45 | lastFullRefresh *time.Time 46 | ec2Client ec2.DescribeInstanceTypesAPIClient 47 | cache *cache.Cache 48 | logger *log.Logger 49 | } 50 | 51 | // NewProvider creates a new Instance Types provider used to fetch Instance Type information from EC2. 52 | func NewProvider(region string, ec2Client ec2.DescribeInstanceTypesAPIClient) *Provider { 53 | return &Provider{ 54 | Region: region, 55 | DirectoryPath: "", 56 | FullRefreshTTL: 0, 57 | ec2Client: ec2Client, 58 | cache: cache.New(0, 0), 59 | logger: log.New(io.Discard, "", 0), 60 | } 61 | } 62 | 63 | // NewProvider creates a new Instance Types provider used to fetch Instance Type information from EC2 and optionally cache. 64 | func LoadFromOrNew(directoryPath string, region string, ttl time.Duration, ec2Client ec2.DescribeInstanceTypesAPIClient) (*Provider, error) { 65 | expandedDirPath, err := homedir.Expand(directoryPath) 66 | if err != nil { 67 | return nil, fmt.Errorf("unable to load instance-type cache directory %s: %w", expandedDirPath, err) 68 | } 69 | if ttl <= 0 { 70 | provider := NewProvider(region, ec2Client) 71 | if err := provider.Clear(); err != nil { 72 | return nil, err 73 | } 74 | return provider, nil 75 | } 76 | itCache, err := loadFrom(ttl, region, expandedDirPath) 77 | if err != nil && !os.IsNotExist(err) { 78 | return nil, fmt.Errorf("unable to load instance-type cache from %s: %w", expandedDirPath, err) 79 | } 80 | if err != nil { 81 | itCache = cache.New(0, 0) 82 | } 83 | return &Provider{ 84 | Region: region, 85 | DirectoryPath: expandedDirPath, 86 | ec2Client: ec2Client, 87 | cache: itCache, 88 | logger: log.New(io.Discard, "", 0), 89 | }, nil 90 | } 91 | 92 | func loadFrom(ttl time.Duration, region string, expandedDirPath string) (*cache.Cache, error) { 93 | itemTTL := ttl + time.Second 94 | cacheBytes, err := os.ReadFile(getCacheFilePath(region, expandedDirPath)) 95 | if err != nil { 96 | return nil, err 97 | } 98 | itCache := &map[string]cache.Item{} 99 | if err := json.Unmarshal(cacheBytes, itCache); err != nil { 100 | return nil, err 101 | } 102 | return cache.NewFrom(itemTTL, itemTTL, *itCache), nil 103 | } 104 | 105 | func getCacheFilePath(region string, expandedDirPath string) string { 106 | return filepath.Join(expandedDirPath, fmt.Sprintf("%s-%s", region, CacheFileName)) 107 | } 108 | 109 | func (p *Provider) SetLogger(logger *log.Logger) { 110 | p.logger = logger 111 | } 112 | 113 | func (p *Provider) Get(ctx context.Context, instanceTypes []ec2types.InstanceType) ([]*Details, error) { 114 | p.logger.Printf("Getting instance types %v", instanceTypes) 115 | start := time.Now() 116 | calls := 0 117 | defer func() { 118 | p.logger.Printf("Took %s and %d calls to collect Instance Types", time.Since(start), calls) 119 | }() 120 | instanceTypeDetails := []*Details{} 121 | describeInstanceTypeOpts := &ec2.DescribeInstanceTypesInput{} 122 | if len(instanceTypes) != 0 { 123 | for _, it := range instanceTypes { 124 | if cachedIT, ok := p.cache.Get(string(it)); ok { 125 | instanceTypeDetails = append(instanceTypeDetails, cachedIT.(*Details)) 126 | } else { 127 | // need to reassign, so we're not sharing the loop iterators memory space 128 | instanceType := it 129 | describeInstanceTypeOpts.InstanceTypes = append(describeInstanceTypeOpts.InstanceTypes, instanceType) 130 | } 131 | } 132 | // if we were able to retrieve all from cache, return here, else continue to do a remote lookup 133 | if len(describeInstanceTypeOpts.InstanceTypes) == 0 { 134 | return instanceTypeDetails, nil 135 | } 136 | } else if p.lastFullRefresh != nil && !p.isFullRefreshNeeded() { 137 | for _, item := range p.cache.Items() { 138 | instanceTypeDetails = append(instanceTypeDetails, item.Object.(*Details)) 139 | } 140 | return instanceTypeDetails, nil 141 | } 142 | 143 | s := ec2.NewDescribeInstanceTypesPaginator(p.ec2Client, describeInstanceTypeOpts) 144 | 145 | for s.HasMorePages() { 146 | calls++ 147 | instanceTypeOutput, err := s.NextPage(ctx) 148 | if err != nil { 149 | return nil, fmt.Errorf("failed to get next instance types page, %w", err) 150 | } 151 | for _, instanceTypeInfo := range instanceTypeOutput.InstanceTypes { 152 | itDetails := &Details{InstanceTypeInfo: instanceTypeInfo} 153 | instanceTypeDetails = append(instanceTypeDetails, itDetails) 154 | p.cache.SetDefault(string(instanceTypeInfo.InstanceType), itDetails) 155 | } 156 | } 157 | 158 | if len(instanceTypes) == 0 { 159 | now := time.Now().UTC() 160 | p.lastFullRefresh = &now 161 | if err := p.Save(); err != nil { 162 | return instanceTypeDetails, err 163 | } 164 | } 165 | return instanceTypeDetails, nil 166 | } 167 | 168 | func (p *Provider) isFullRefreshNeeded() bool { 169 | return time.Since(*p.lastFullRefresh) > p.FullRefreshTTL 170 | } 171 | 172 | func (p *Provider) Save() error { 173 | if p.FullRefreshTTL <= 0 || p.cache.ItemCount() == 0 { 174 | return nil 175 | } 176 | cacheBytes, err := json.Marshal(p.cache.Items()) 177 | if err != nil { 178 | return err 179 | } 180 | if err := os.Mkdir(p.DirectoryPath, 0o755); err != nil && !errors.Is(err, os.ErrExist) { 181 | return err 182 | } 183 | return os.WriteFile(getCacheFilePath(p.Region, p.DirectoryPath), cacheBytes, 0600) 184 | } 185 | 186 | func (p *Provider) Clear() error { 187 | p.cache.Flush() 188 | if err := os.Remove(getCacheFilePath(p.Region, p.DirectoryPath)); err != nil && !os.IsNotExist(err) { 189 | return err 190 | } 191 | return nil 192 | } 193 | 194 | func (p *Provider) CacheCount() int { 195 | return p.cache.ItemCount() 196 | } 197 | -------------------------------------------------------------------------------- /pkg/selector/aggregates.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | package selector 15 | 16 | import ( 17 | "context" 18 | "fmt" 19 | "regexp" 20 | 21 | "github.com/aws/aws-sdk-go-v2/service/ec2" 22 | ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" 23 | 24 | "github.com/aws/amazon-ec2-instance-selector/v3/pkg/bytequantity" 25 | ) 26 | 27 | const ( 28 | // AggregateLowPercentile is the default lower percentile for resource ranges on similar instance type comparisons. 29 | AggregateLowPercentile = 0.9 30 | // AggregateHighPercentile is the default upper percentile for resource ranges on similar instance type comparisons. 31 | AggregateHighPercentile = 1.2 32 | ) 33 | 34 | var baseAllowedInstanceTypesRE = regexp.MustCompile(`^[cmr][3-9][agi]?\..*$|^t[2-9][gi]?\..*$`) 35 | 36 | // FiltersTransform can be implemented to provide custom transforms. 37 | type FiltersTransform interface { 38 | Transform(context.Context, Filters) (Filters, error) 39 | } 40 | 41 | // TransformFn is the func type definition for a FiltersTransform. 42 | type TransformFn func(context.Context, Filters) (Filters, error) 43 | 44 | // Transform implements FiltersTransform interface on TransformFn 45 | // This allows any TransformFn to be passed into funcs accepting FiltersTransform interface. 46 | func (fn TransformFn) Transform(ctx context.Context, filters Filters) (Filters, error) { 47 | return fn(ctx, filters) 48 | } 49 | 50 | // TransformBaseInstanceType transforms lower level filters based on the instanceTypeBase specs. 51 | func (itf Selector) TransformBaseInstanceType(ctx context.Context, filters Filters) (Filters, error) { 52 | if filters.InstanceTypeBase == nil { 53 | return filters, nil 54 | } 55 | instanceTypesOutput, err := itf.EC2.DescribeInstanceTypes(ctx, &ec2.DescribeInstanceTypesInput{ 56 | InstanceTypes: []ec2types.InstanceType{ 57 | ec2types.InstanceType(*filters.InstanceTypeBase), 58 | }, 59 | }) 60 | if err != nil { 61 | return filters, err 62 | } 63 | if len(instanceTypesOutput.InstanceTypes) == 0 { 64 | return filters, fmt.Errorf("error instance type %s is not a valid instance type", *filters.InstanceTypeBase) 65 | } 66 | instanceTypeInfo := instanceTypesOutput.InstanceTypes[0] 67 | if filters.BareMetal == nil { 68 | filters.BareMetal = instanceTypeInfo.BareMetal 69 | } 70 | if filters.CPUArchitecture == nil && len(instanceTypeInfo.ProcessorInfo.SupportedArchitectures) == 1 { 71 | filters.CPUArchitecture = &instanceTypeInfo.ProcessorInfo.SupportedArchitectures[0] 72 | } 73 | if filters.Fpga == nil { 74 | isFpgaSupported := instanceTypeInfo.FpgaInfo != nil 75 | filters.Fpga = &isFpgaSupported 76 | } 77 | if filters.GpusRange == nil { 78 | gpuCount := int32(0) 79 | if instanceTypeInfo.GpuInfo != nil { 80 | gpuCount = *getTotalGpusCount(instanceTypeInfo.GpuInfo) 81 | } 82 | filters.GpusRange = &Int32RangeFilter{LowerBound: gpuCount, UpperBound: gpuCount} 83 | } 84 | if filters.MemoryRange == nil { 85 | lowerBound := bytequantity.ByteQuantity{Quantity: uint64(float64(*instanceTypeInfo.MemoryInfo.SizeInMiB) * AggregateLowPercentile)} 86 | upperBound := bytequantity.ByteQuantity{Quantity: uint64(float64(*instanceTypeInfo.MemoryInfo.SizeInMiB) * AggregateHighPercentile)} 87 | filters.MemoryRange = &ByteQuantityRangeFilter{LowerBound: lowerBound, UpperBound: upperBound} 88 | } 89 | if filters.VCpusRange == nil { 90 | lowerBound := int32(float32(*instanceTypeInfo.VCpuInfo.DefaultVCpus) * AggregateLowPercentile) 91 | upperBound := int32(float32(*instanceTypeInfo.VCpuInfo.DefaultVCpus) * AggregateHighPercentile) 92 | filters.VCpusRange = &Int32RangeFilter{LowerBound: lowerBound, UpperBound: upperBound} 93 | } 94 | if filters.VirtualizationType == nil && len(instanceTypeInfo.SupportedVirtualizationTypes) == 1 { 95 | filters.VirtualizationType = &instanceTypeInfo.SupportedVirtualizationTypes[0] 96 | } 97 | filters.InstanceTypeBase = nil 98 | 99 | return filters, nil 100 | } 101 | 102 | // TransformFlexible transforms lower level filters based on a set of opinions. 103 | func (itf Selector) TransformFlexible(ctx context.Context, filters Filters) (Filters, error) { 104 | if filters.Flexible == nil { 105 | return filters, nil 106 | } 107 | if filters.CPUArchitecture == nil { 108 | defaultArchitecture := ec2types.ArchitectureTypeX8664 109 | filters.CPUArchitecture = &defaultArchitecture 110 | } 111 | if filters.BareMetal == nil { 112 | bareMetalDefault := false 113 | filters.BareMetal = &bareMetalDefault 114 | } 115 | if filters.Fpga == nil { 116 | fpgaDefault := false 117 | filters.Fpga = &fpgaDefault 118 | } 119 | 120 | if filters.AllowList == nil { 121 | filters.AllowList = baseAllowedInstanceTypesRE 122 | } 123 | 124 | if filters.VCpusRange == nil && filters.MemoryRange == nil { 125 | defaultVcpus := int32(4) 126 | filters.VCpusRange = &Int32RangeFilter{LowerBound: defaultVcpus, UpperBound: defaultVcpus} 127 | } 128 | 129 | return filters, nil 130 | } 131 | 132 | // TransformForService transforms lower level filters based on the service. 133 | func (itf Selector) TransformForService(ctx context.Context, filters Filters) (Filters, error) { 134 | return itf.ServiceRegistry.ExecuteTransforms(filters) 135 | } 136 | -------------------------------------------------------------------------------- /pkg/selector/aggregates_test.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // You may obtain a copy of the License at 4 | // 5 | // http://www.apache.org/licenses/LICENSE-2.0 6 | // 7 | // Unless required by applicable law or agreed to in writing, software 8 | // distributed under the License is distributed on an "AS IS" BASIS, 9 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | // See the License for the specific language governing permissions and 11 | // limitations under the License. 12 | 13 | package selector_test 14 | 15 | import ( 16 | "context" 17 | "testing" 18 | 19 | "github.com/aws/amazon-ec2-instance-selector/v3/pkg/selector" 20 | h "github.com/aws/amazon-ec2-instance-selector/v3/pkg/test" 21 | ) 22 | 23 | // Tests 24 | 25 | func TestTransformBaseInstanceType(t *testing.T) { 26 | ec2Mock := mockedEC2{ 27 | DescribeInstanceTypesResp: setupMock(t, describeInstanceTypes, "c4_large.json").DescribeInstanceTypesResp, 28 | DescribeInstanceTypeOfferingsResp: setupMock(t, describeInstanceTypeOfferings, "us-east-2a.json").DescribeInstanceTypeOfferingsResp, 29 | } 30 | itf := selector.Selector{ 31 | EC2: ec2Mock, 32 | } 33 | instanceTypeBase := "c4.large" 34 | filters := selector.Filters{ 35 | InstanceTypeBase: &instanceTypeBase, 36 | } 37 | ctx := context.Background() 38 | filters, err := itf.TransformBaseInstanceType(ctx, filters) 39 | h.Ok(t, err) 40 | h.Assert(t, *filters.BareMetal == false, " should filter out bare metal instances") 41 | h.Assert(t, *filters.Fpga == false, "should filter out FPGA instances") 42 | h.Assert(t, *filters.CPUArchitecture == "x86_64", "should only return x86_64 instance types") 43 | h.Assert(t, filters.GpusRange.LowerBound == 0 && filters.GpusRange.UpperBound == 0, "should only return non-gpu instance types") 44 | } 45 | 46 | func TestTransformBaseInstanceTypeWithGPU(t *testing.T) { 47 | ec2Mock := mockedEC2{ 48 | DescribeInstanceTypesResp: setupMock(t, describeInstanceTypes, "g2_2xlarge.json").DescribeInstanceTypesResp, 49 | DescribeInstanceTypeOfferingsResp: setupMock(t, describeInstanceTypeOfferings, "us-east-2a.json").DescribeInstanceTypeOfferingsResp, 50 | } 51 | itf := selector.Selector{ 52 | EC2: ec2Mock, 53 | } 54 | instanceTypeBase := "g2.2xlarge" 55 | filters := selector.Filters{ 56 | InstanceTypeBase: &instanceTypeBase, 57 | } 58 | ctx := context.Background() 59 | filters, err := itf.TransformBaseInstanceType(ctx, filters) 60 | h.Ok(t, err) 61 | h.Assert(t, *filters.BareMetal == false, " should filter out bare metal instances") 62 | h.Assert(t, *filters.Fpga == false, "should filter out FPGA instances") 63 | h.Assert(t, *filters.CPUArchitecture == "x86_64", "should only return x86_64 instance types") 64 | h.Assert(t, filters.GpusRange.LowerBound == 1 && filters.GpusRange.UpperBound == 1, "should only return gpu instance types") 65 | } 66 | 67 | func TestTransformFamilyFlexibile(t *testing.T) { 68 | itf := selector.Selector{} 69 | flexible := true 70 | filters := selector.Filters{ 71 | Flexible: &flexible, 72 | } 73 | ctx := context.Background() 74 | filters, err := itf.TransformFlexible(ctx, filters) 75 | h.Ok(t, err) 76 | h.Assert(t, *filters.BareMetal == false, " should filter out bare metal instances") 77 | h.Assert(t, *filters.Fpga == false, "should filter out FPGA instances") 78 | h.Assert(t, *filters.CPUArchitecture == "x86_64", "should only return x86_64 instance types") 79 | } 80 | -------------------------------------------------------------------------------- /pkg/selector/emr_test.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // You may obtain a copy of the License at 4 | // 5 | // http://www.apache.org/licenses/LICENSE-2.0 6 | // 7 | // Unless required by applicable law or agreed to in writing, software 8 | // distributed under the License is distributed on an "AS IS" BASIS, 9 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | // See the License for the specific language governing permissions and 11 | // limitations under the License. 12 | 13 | package selector_test 14 | 15 | import ( 16 | "testing" 17 | 18 | "github.com/aws/amazon-ec2-instance-selector/v3/pkg/selector" 19 | h "github.com/aws/amazon-ec2-instance-selector/v3/pkg/test" 20 | ) 21 | 22 | // Tests. 23 | var emr = "emr" 24 | 25 | func TestEMRDefaultService(t *testing.T) { 26 | registry := selector.NewRegistry() 27 | registry.Register("emr", &selector.EMR{}) 28 | 29 | filters := selector.Filters{ 30 | Service: &emr, 31 | } 32 | 33 | transformedFilters, err := registry.ExecuteTransforms(filters) 34 | h.Ok(t, err) 35 | h.Assert(t, transformedFilters != filters, " Filters should have been modified") 36 | h.Assert(t, *transformedFilters.RootDeviceType == "ebs", "emr should only supports ebs") 37 | h.Assert(t, *transformedFilters.VirtualizationType == "hvm", "emr should only support hvm") 38 | 39 | emrWithVersion := "emr-" + "5.20.0" 40 | filters.Service = &emrWithVersion 41 | transformedFilters, err = registry.ExecuteTransforms(filters) 42 | h.Ok(t, err) 43 | h.Assert(t, transformedFilters != filters, " Filters should have been modified") 44 | h.Assert(t, *transformedFilters.RootDeviceType == "ebs", "emr should only supports ebs") 45 | h.Assert(t, *transformedFilters.VirtualizationType == "hvm", "emr should only support hvm") 46 | } 47 | 48 | func TestFilters_Version5_33_0(t *testing.T) { 49 | registry := selector.NewRegistry() 50 | registry.Register("emr", &selector.EMR{}) 51 | 52 | filters := selector.Filters{ 53 | Service: &emr, 54 | } 55 | 56 | emrWithVersion := "emr-" + "5.33.0" 57 | filters.Service = &emrWithVersion 58 | transformedFilters, err := registry.ExecuteTransforms(filters) 59 | h.Ok(t, err) 60 | h.Assert(t, transformedFilters != filters, " Filters should have been modified") 61 | h.Assert(t, *transformedFilters.RootDeviceType == "ebs", "emr should only supports ebs") 62 | h.Assert(t, *transformedFilters.VirtualizationType == "hvm", "emr should only support hvm") 63 | h.Assert(t, contains(*transformedFilters.InstanceTypes, "m6gd.xlarge"), "emr version 5.33.0 should include m6gd.xlarge") 64 | } 65 | 66 | func TestFilters_Version5_25_0(t *testing.T) { 67 | registry := selector.NewRegistry() 68 | registry.Register("emr", &selector.EMR{}) 69 | 70 | filters := selector.Filters{ 71 | Service: &emr, 72 | } 73 | 74 | emrWithVersion := "emr-" + "5.25.0" 75 | filters.Service = &emrWithVersion 76 | transformedFilters, err := registry.ExecuteTransforms(filters) 77 | h.Ok(t, err) 78 | h.Assert(t, transformedFilters != filters, " Filters should have been modified") 79 | h.Assert(t, *transformedFilters.RootDeviceType == "ebs", "emr should only supports ebs") 80 | h.Assert(t, *transformedFilters.VirtualizationType == "hvm", "emr should only support hvm") 81 | h.Assert(t, contains(*transformedFilters.InstanceTypes, "i3en.xlarge"), "emr version 5.25.0 should include i3en.xlarge") 82 | } 83 | 84 | func TestFilters_Version5_15_0(t *testing.T) { 85 | registry := selector.NewRegistry() 86 | registry.Register("emr", &selector.EMR{}) 87 | 88 | filters := selector.Filters{ 89 | Service: &emr, 90 | } 91 | 92 | emrWithVersion := "emr-" + "5.15.0" 93 | filters.Service = &emrWithVersion 94 | transformedFilters, err := registry.ExecuteTransforms(filters) 95 | h.Ok(t, err) 96 | h.Assert(t, transformedFilters != filters, " Filters should have been modified") 97 | h.Assert(t, *transformedFilters.RootDeviceType == "ebs", "emr should only supports ebs") 98 | h.Assert(t, *transformedFilters.VirtualizationType == "hvm", "emr should only support hvm") 99 | h.Assert(t, !contains(*transformedFilters.InstanceTypes, "c1.medium"), "emr version 5.15.0 should not include c1.medium") 100 | } 101 | 102 | func TestFilters_Version5_13_0(t *testing.T) { 103 | registry := selector.NewRegistry() 104 | registry.Register("emr", &selector.EMR{}) 105 | 106 | filters := selector.Filters{ 107 | Service: &emr, 108 | } 109 | 110 | emrWithVersion := "emr-" + "5.13.0" 111 | filters.Service = &emrWithVersion 112 | transformedFilters, err := registry.ExecuteTransforms(filters) 113 | h.Ok(t, err) 114 | h.Assert(t, transformedFilters != filters, " Filters should have been modified") 115 | h.Assert(t, *transformedFilters.RootDeviceType == "ebs", "emr should only supports ebs") 116 | h.Assert(t, *transformedFilters.VirtualizationType == "hvm", "emr should only support hvm") 117 | h.Assert(t, !contains(*transformedFilters.InstanceTypes, "m5a.xlarge"), "emr version 5.13.0 should not include m5a.xlarge") 118 | } 119 | 120 | func TestFilters_Version5_9_0(t *testing.T) { 121 | registry := selector.NewRegistry() 122 | registry.Register("emr", &selector.EMR{}) 123 | 124 | filters := selector.Filters{ 125 | Service: &emr, 126 | } 127 | 128 | emrWithVersion := "emr-" + "5.9.0" 129 | filters.Service = &emrWithVersion 130 | transformedFilters, err := registry.ExecuteTransforms(filters) 131 | h.Ok(t, err) 132 | h.Assert(t, transformedFilters != filters, " Filters should have been modified") 133 | h.Assert(t, *transformedFilters.RootDeviceType == "ebs", "emr should only supports ebs") 134 | h.Assert(t, *transformedFilters.VirtualizationType == "hvm", "emr should only support hvm") 135 | h.Assert(t, !contains(*transformedFilters.InstanceTypes, "m5a.xlarge"), "emr version 5.9.0 should not include m5a.xlarge") 136 | } 137 | 138 | func TestFilters_Version5_8_0(t *testing.T) { 139 | registry := selector.NewRegistry() 140 | registry.Register("emr", &selector.EMR{}) 141 | 142 | filters := selector.Filters{ 143 | Service: &emr, 144 | } 145 | 146 | emrWithVersion := "emr-" + "5.8.0" 147 | filters.Service = &emrWithVersion 148 | transformedFilters, err := registry.ExecuteTransforms(filters) 149 | h.Ok(t, err) 150 | h.Assert(t, transformedFilters != filters, " Filters should have been modified") 151 | h.Assert(t, *transformedFilters.RootDeviceType == "ebs", "emr should only supports ebs") 152 | h.Assert(t, *transformedFilters.VirtualizationType == "hvm", "emr should only support hvm") 153 | h.Assert(t, !contains(*transformedFilters.InstanceTypes, "i3.xlarge"), "emr version 5.8.0 should not include i3.xlarge") 154 | } 155 | 156 | func contains(arr []string, input string) bool { 157 | for _, entry := range arr { 158 | if entry == input { 159 | return true 160 | } 161 | } 162 | return false 163 | } 164 | -------------------------------------------------------------------------------- /pkg/selector/outputs/bubbletea.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // You may obtain a copy of the License at 4 | // 5 | // http://www.apache.org/licenses/LICENSE-2.0 6 | // 7 | // Unless required by applicable law or agreed to in writing, software 8 | // distributed under the License is distributed on an "AS IS" BASIS, 9 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | // See the License for the specific language governing permissions and 11 | // limitations under the License. 12 | 13 | package outputs 14 | 15 | import ( 16 | tea "github.com/charmbracelet/bubbletea" 17 | "github.com/charmbracelet/lipgloss" 18 | "github.com/muesli/termenv" 19 | 20 | "github.com/aws/amazon-ec2-instance-selector/v3/pkg/instancetypes" 21 | "github.com/aws/amazon-ec2-instance-selector/v3/pkg/sorter" 22 | ) 23 | 24 | const ( 25 | // can't get terminal dimensions on startup, so use this. 26 | initialDimensionVal = 30 27 | 28 | instanceTypeKey = "instance type" 29 | selectedKey = "selected" 30 | ) 31 | 32 | const ( 33 | // table states. 34 | stateTable = "table" 35 | stateVerbose = "verbose" 36 | stateSorting = "sorting" 37 | ) 38 | 39 | var controlsStyle = lipgloss.NewStyle().Faint(true) 40 | 41 | // BubbleTeaModel is used to hold the state of the bubble tea TUI. 42 | type BubbleTeaModel struct { 43 | // holds the output currentState of the model 44 | currentState string 45 | 46 | // the model for the table view 47 | tableModel tableModel 48 | 49 | // holds state for the verbose view 50 | verboseModel verboseModel 51 | 52 | // holds the state for the sorting view 53 | sortingModel sortingModel 54 | } 55 | 56 | // NewBubbleTeaModel initializes a new bubble tea Model which represents 57 | // a stylized table to display instance types. 58 | func NewBubbleTeaModel(instanceTypes []*instancetypes.Details) BubbleTeaModel { 59 | return BubbleTeaModel{ 60 | currentState: stateTable, 61 | tableModel: *initTableModel(instanceTypes), 62 | verboseModel: *initVerboseModel(), 63 | sortingModel: *initSortingModel(instanceTypes), 64 | } 65 | } 66 | 67 | // Init is used by bubble tea to initialize a bubble tea table. 68 | func (m BubbleTeaModel) Init() tea.Cmd { 69 | return nil 70 | } 71 | 72 | // Update is used by bubble tea to update the state of the bubble 73 | // tea model based on user input. 74 | func (m BubbleTeaModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 75 | switch msg := msg.(type) { 76 | case tea.KeyMsg: 77 | // don't listen for input if currently typing into text field 78 | if m.tableModel.filterTextInput.Focused() { 79 | break 80 | } else if m.sortingModel.sortTextInput.Focused() { 81 | // see if we should sort and switch states to table 82 | if m.currentState == stateSorting && msg.String() == "enter" { 83 | jsonPath := m.sortingModel.sortTextInput.Value() 84 | 85 | sortDirection := sorter.SortAscending 86 | if m.sortingModel.isDescending { 87 | sortDirection = sorter.SortDescending 88 | } 89 | 90 | var err error 91 | m.tableModel, err = m.tableModel.sortTable(jsonPath, sortDirection) 92 | if err != nil { 93 | m.sortingModel.sortTextInput.SetValue(jsonPathError) 94 | break 95 | } 96 | 97 | m.currentState = stateTable 98 | 99 | m.sortingModel.sortTextInput.Blur() 100 | } 101 | 102 | break 103 | } 104 | 105 | // check for quit or change in state 106 | switch msg.String() { 107 | case "ctrl+c", "q": 108 | return m, tea.Quit 109 | case "e": 110 | // switch from table state to verbose state 111 | if m.currentState == stateTable { 112 | // get focused instance type 113 | focusedRow := m.tableModel.table.HighlightedRow() 114 | focusedInstance, ok := focusedRow.Data[instanceTypeKey].(*instancetypes.Details) 115 | if !ok { 116 | break 117 | } 118 | 119 | // set content of view 120 | m.verboseModel.focusedInstanceName = focusedInstance.InstanceType 121 | m.verboseModel.viewport.SetContent(VerboseInstanceTypeOutput([]*instancetypes.Details{focusedInstance})[0]) 122 | 123 | // move viewport to top of printout 124 | m.verboseModel.viewport.SetYOffset(0) 125 | 126 | // switch from table state to verbose state 127 | m.currentState = stateVerbose 128 | } 129 | case "s": 130 | // switch from table view to sorting view 131 | if m.currentState == stateTable { 132 | m.currentState = stateSorting 133 | } 134 | case "enter": 135 | // sort and switch states to table 136 | if m.currentState == stateSorting { 137 | sortFilter := string(m.sortingModel.shorthandList.SelectedItem().(item)) 138 | 139 | sortDirection := sorter.SortAscending 140 | if m.sortingModel.isDescending { 141 | sortDirection = sorter.SortDescending 142 | } 143 | 144 | var err error 145 | m.tableModel, err = m.tableModel.sortTable(sortFilter, sortDirection) 146 | if err != nil { 147 | m.sortingModel.sortTextInput.SetValue("INVALID SHORTHAND VALUE") 148 | break 149 | } 150 | 151 | m.currentState = stateTable 152 | 153 | m.sortingModel.sortTextInput.Blur() 154 | } 155 | case "esc": 156 | // switch from sorting state or verbose state to table state 157 | if m.currentState == stateSorting || m.currentState == stateVerbose { 158 | m.currentState = stateTable 159 | } 160 | } 161 | case tea.WindowSizeMsg: 162 | // This is needed to handle a bug with bubble tea 163 | // where resizing causes misprints (https://github.com/Evertras/bubble-table/issues/121) 164 | termenv.ClearScreen() //nolint:staticcheck 165 | 166 | // handle screen resizing 167 | m.tableModel = m.tableModel.resizeView(msg) 168 | m.verboseModel = m.verboseModel.resizeView(msg) 169 | m.sortingModel = m.sortingModel.resizeView(msg) 170 | } 171 | 172 | var cmd tea.Cmd 173 | // update currently active state 174 | switch m.currentState { 175 | case stateTable: 176 | m.tableModel, cmd = m.tableModel.update(msg) 177 | case stateVerbose: 178 | m.verboseModel, cmd = m.verboseModel.update(msg) 179 | case stateSorting: 180 | m.sortingModel, cmd = m.sortingModel.update(msg) 181 | } 182 | 183 | return m, cmd 184 | } 185 | 186 | // View is used by bubble tea to render the bubble tea model. 187 | func (m BubbleTeaModel) View() string { 188 | switch m.currentState { 189 | case stateTable: 190 | return m.tableModel.view() 191 | case stateVerbose: 192 | return m.verboseModel.view() 193 | case stateSorting: 194 | return m.sortingModel.view() 195 | } 196 | 197 | return "" 198 | } 199 | -------------------------------------------------------------------------------- /pkg/selector/outputs/bubbletea_internal_test.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // You may obtain a copy of the License at 4 | // 5 | // http://www.apache.org/licenses/LICENSE-2.0 6 | // 7 | // Unless required by applicable law or agreed to in writing, software 8 | // distributed under the License is distributed on an "AS IS" BASIS, 9 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | // See the License for the specific language governing permissions and 11 | // limitations under the License. 12 | 13 | package outputs 14 | 15 | import ( 16 | "encoding/json" 17 | "fmt" 18 | "os" 19 | "strings" 20 | "testing" 21 | 22 | "github.com/evertras/bubble-table/table" 23 | 24 | "github.com/aws/amazon-ec2-instance-selector/v3/pkg/instancetypes" 25 | h "github.com/aws/amazon-ec2-instance-selector/v3/pkg/test" 26 | ) 27 | 28 | const ( 29 | mockFilesPath = "../../../test/static" 30 | ) 31 | 32 | // helpers 33 | 34 | // getInstanceTypeDetails unmarshalls the json file in the given testing folder 35 | // and returns a list of instance type details. 36 | func getInstanceTypeDetails(t *testing.T, file string) []*instancetypes.Details { 37 | folder := "FilterVerbose" 38 | mockFilename := fmt.Sprintf("%s/%s/%s", mockFilesPath, folder, file) 39 | mockFile, err := os.ReadFile(mockFilename) 40 | h.Assert(t, err == nil, "Error reading mock file "+mockFilename) 41 | 42 | instanceTypes := []*instancetypes.Details{} 43 | err = json.Unmarshal(mockFile, &instanceTypes) 44 | h.Assert(t, err == nil, fmt.Sprintf("Error parsing mock json file contents %s. Error: %v", mockFilename, err)) 45 | return instanceTypes 46 | } 47 | 48 | // getRowsInstances reformats the given table rows into a list of instance type names. 49 | func getRowsInstances(rows []table.Row) string { 50 | instances := []string{} 51 | 52 | for _, row := range rows { 53 | instances = append(instances, fmt.Sprintf("%v", row.Data["Instance Type"])) 54 | } 55 | 56 | return strings.Join(instances, ", ") 57 | } 58 | 59 | // tests 60 | 61 | func TestNewBubbleTeaModel_Hypervisor(t *testing.T) { 62 | instanceTypes := getInstanceTypeDetails(t, "g3_16xlarge.json") 63 | 64 | // test non nil Hypervisor 65 | model := NewBubbleTeaModel(instanceTypes) 66 | rows := model.tableModel.table.GetVisibleRows() 67 | expectedHypervisor := "xen" 68 | actualHypervisor := rows[0].Data["Hypervisor"] 69 | 70 | h.Assert(t, actualHypervisor == expectedHypervisor, fmt.Sprintf("Hypervisor should be %s but instead is %s", expectedHypervisor, actualHypervisor)) 71 | } 72 | 73 | func TestNewBubbleTeaModel_CPUArchitectures(t *testing.T) { 74 | instanceTypes := getInstanceTypeDetails(t, "g3_16xlarge.json") 75 | model := NewBubbleTeaModel(instanceTypes) 76 | rows := model.tableModel.table.GetVisibleRows() 77 | 78 | actualGPUArchitectures := "x86_64" 79 | expectedGPUArchitectures := rows[0].Data["CPU Arch"] 80 | 81 | h.Assert(t, actualGPUArchitectures == expectedGPUArchitectures, "CPU architecture should be (%s), but actually (%s)", expectedGPUArchitectures, actualGPUArchitectures) 82 | } 83 | 84 | func TestNewBubbleTeaModel_GPU(t *testing.T) { 85 | instanceTypes := getInstanceTypeDetails(t, "g3_16xlarge.json") 86 | model := NewBubbleTeaModel(instanceTypes) 87 | rows := model.tableModel.table.GetVisibleRows() 88 | 89 | // test GPU count 90 | expectedGPUCount := "4" 91 | actualGPUCount := fmt.Sprintf("%v", rows[0].Data["GPUs"]) 92 | 93 | h.Assert(t, expectedGPUCount == actualGPUCount, "GPU count should be %s, but is actually %s", expectedGPUCount, actualGPUCount) 94 | 95 | // test GPU memory 96 | expectedGPUMemory := "32" 97 | actualGPUMemory := rows[0].Data["GPU Mem (GiB)"] 98 | 99 | h.Assert(t, expectedGPUMemory == actualGPUMemory, "GPU memory should be %s, but is actually %s", expectedGPUMemory, actualGPUMemory) 100 | 101 | // test GPU info 102 | expectedGPUInfo := "NVIDIA M60" 103 | actualGPUInfo := rows[0].Data["GPU Info"] 104 | 105 | h.Assert(t, expectedGPUInfo == actualGPUInfo, "GPU info should be (%s), but is actually (%s)", expectedGPUInfo, actualGPUInfo) 106 | } 107 | 108 | func TestNewBubbleTeaModel_ODPricing(t *testing.T) { 109 | instanceTypes := getInstanceTypeDetails(t, "g3_16xlarge.json") 110 | 111 | // test non nil OD price 112 | model := NewBubbleTeaModel(instanceTypes) 113 | rows := model.tableModel.table.GetVisibleRows() 114 | expectedODPrice := "$4.56" 115 | actualODPrice := fmt.Sprintf("%v", rows[0].Data["On-Demand Price/Hr"]) 116 | 117 | h.Assert(t, actualODPrice == expectedODPrice, "Actual OD price should be %s, but is actually %s", expectedODPrice, actualODPrice) 118 | 119 | // test nil OD price 120 | instanceTypes[0].OndemandPricePerHour = nil 121 | model = NewBubbleTeaModel(instanceTypes) 122 | rows = model.tableModel.table.GetVisibleRows() 123 | expectedODPrice = "-Not Fetched-" 124 | actualODPrice = fmt.Sprintf("%v", rows[0].Data["On-Demand Price/Hr"]) 125 | 126 | h.Assert(t, actualODPrice == expectedODPrice, "Actual OD price should be %s, but is actually %s", expectedODPrice, actualODPrice) 127 | } 128 | 129 | func TestNewBubbleTeaModel_SpotPricing(t *testing.T) { 130 | instanceTypes := getInstanceTypeDetails(t, "g3_16xlarge.json") 131 | 132 | // test non nil spot price 133 | model := NewBubbleTeaModel(instanceTypes) 134 | rows := model.tableModel.table.GetVisibleRows() 135 | expectedODPrice := "$1.368" 136 | actualODPrice := fmt.Sprintf("%v", rows[0].Data["Spot Price/Hr"]) 137 | 138 | h.Assert(t, actualODPrice == expectedODPrice, "Actual spot price should be %s, but is actually %s", expectedODPrice, actualODPrice) 139 | 140 | // test nil spot price 141 | instanceTypes[0].SpotPrice = nil 142 | model = NewBubbleTeaModel(instanceTypes) 143 | rows = model.tableModel.table.GetVisibleRows() 144 | expectedODPrice = "-Not Fetched-" 145 | actualODPrice = fmt.Sprintf("%v", rows[0].Data["Spot Price/Hr"]) 146 | 147 | h.Assert(t, actualODPrice == expectedODPrice, "Actual spot price should be %s, but is actually %s", expectedODPrice, actualODPrice) 148 | } 149 | 150 | func TestNewBubbleTeaModel_Rows(t *testing.T) { 151 | instanceTypes := getInstanceTypeDetails(t, "3_instances.json") 152 | model := NewBubbleTeaModel(instanceTypes) 153 | rows := model.tableModel.table.GetVisibleRows() 154 | 155 | h.Assert(t, len(rows) == len(instanceTypes), "Number of rows should be %d, but is actually %d", len(instanceTypes), len(rows)) 156 | 157 | // test that order of instance types is retained 158 | for i := range instanceTypes { 159 | currInstanceName := instanceTypes[i].InstanceType 160 | currRowName := rows[i].Data["Instance Type"] 161 | 162 | h.Assert(t, string(currInstanceName) == currRowName, "Rows should be in following order: %s. Actual order: [%s]", OneLineOutput(instanceTypes), getRowsInstances(rows)) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /pkg/selector/outputs/outputs_test.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // You may obtain a copy of the License at 4 | // 5 | // http://www.apache.org/licenses/LICENSE-2.0 6 | // 7 | // Unless required by applicable law or agreed to in writing, software 8 | // distributed under the License is distributed on an "AS IS" BASIS, 9 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | // See the License for the specific language governing permissions and 11 | // limitations under the License. 12 | 13 | package outputs_test 14 | 15 | import ( 16 | "encoding/json" 17 | "fmt" 18 | "os" 19 | "strings" 20 | "testing" 21 | 22 | "github.com/aws/aws-sdk-go-v2/service/ec2" 23 | 24 | "github.com/aws/amazon-ec2-instance-selector/v3/pkg/instancetypes" 25 | "github.com/aws/amazon-ec2-instance-selector/v3/pkg/selector/outputs" 26 | h "github.com/aws/amazon-ec2-instance-selector/v3/pkg/test" 27 | ) 28 | 29 | const ( 30 | describeInstanceTypes = "DescribeInstanceTypes" 31 | mockFilesPath = "../../../test/static" 32 | ) 33 | 34 | func getInstanceTypes(t *testing.T, file string) []*instancetypes.Details { 35 | mockFilename := fmt.Sprintf("%s/%s/%s", mockFilesPath, describeInstanceTypes, file) 36 | mockFile, err := os.ReadFile(mockFilename) 37 | h.Assert(t, err == nil, "Error reading mock file "+string(mockFilename)) 38 | dito := ec2.DescribeInstanceTypesOutput{} 39 | err = json.Unmarshal(mockFile, &dito) 40 | h.Assert(t, err == nil, "Error parsing mock json file contents"+mockFilename) 41 | instanceTypesDetails := []*instancetypes.Details{} 42 | for _, it := range dito.InstanceTypes { 43 | odPrice := float64(0.53) 44 | instanceTypesDetails = append(instanceTypesDetails, &instancetypes.Details{InstanceTypeInfo: it, OndemandPricePerHour: &odPrice}) 45 | } 46 | return instanceTypesDetails 47 | } 48 | 49 | func TestSimpleInstanceTypeOutput(t *testing.T) { 50 | instanceTypes := getInstanceTypes(t, "t3_micro.json") 51 | instanceTypeOut := outputs.SimpleInstanceTypeOutput(instanceTypes) 52 | h.Assert(t, len(instanceTypeOut) == len(instanceTypes), "Should return the same number of instance types as the data passed in") 53 | h.Assert(t, instanceTypeOut[0] == "t3.micro", "Should only return t3.micro") 54 | 55 | instanceTypeOut = outputs.SimpleInstanceTypeOutput([]*instancetypes.Details{}) 56 | h.Assert(t, len(instanceTypeOut) == 0, "Should return 0 instance types when passed empty slice") 57 | 58 | instanceTypeOut = outputs.SimpleInstanceTypeOutput(nil) 59 | h.Assert(t, len(instanceTypeOut) == 0, "Should return 0 instance types when passed nil") 60 | } 61 | 62 | func TestVerboseInstanceTypeOutput(t *testing.T) { 63 | instanceTypes := getInstanceTypes(t, "t3_micro.json") 64 | outputExpectation, err := json.MarshalIndent(instanceTypes, "", " ") 65 | h.Ok(t, err) 66 | 67 | instanceTypeOut := outputs.VerboseInstanceTypeOutput(instanceTypes) 68 | h.Assert(t, len(instanceTypeOut) == len(instanceTypes), "Should return the same number of instance types as the data passed in") 69 | h.Assert(t, instanceTypeOut[0] == string(outputExpectation), "Should only return t3.micro") 70 | 71 | instanceTypeOut = outputs.VerboseInstanceTypeOutput([]*instancetypes.Details{}) 72 | h.Assert(t, len(instanceTypeOut) == 0, "Should return 0 instance types when passed empty slice") 73 | 74 | instanceTypeOut = outputs.VerboseInstanceTypeOutput(nil) 75 | h.Assert(t, len(instanceTypeOut) == 0, "Should return 0 instance types when passed nil") 76 | } 77 | 78 | func TestTableOutputShort(t *testing.T) { 79 | instanceTypes := getInstanceTypes(t, "t3_micro.json") 80 | instanceTypeOut := outputs.TableOutputShort(instanceTypes) 81 | outputStr := strings.Join(instanceTypeOut, "") 82 | lines := strings.Split(outputStr, "\n") 83 | h.Assert(t, len(lines) == 3, "table should include a 2 header lines and 1 instance type result line") 84 | h.Assert(t, strings.Contains(outputStr, "t3.micro"), "short table should include instance type") 85 | } 86 | 87 | func TestTableOutputWide(t *testing.T) { 88 | instanceTypes := getInstanceTypes(t, "g2_2xlarge.json") 89 | instanceTypeOut := outputs.TableOutputWide(instanceTypes) 90 | outputStr := strings.Join(instanceTypeOut, "") 91 | lines := strings.Split(outputStr, "\n") 92 | h.Assert(t, len(lines) == 3, "table should include a 2 header lines and 1 instance type result line") 93 | h.Assert(t, strings.Contains(outputStr, "g2.2xlarge"), "table should include instance type") 94 | h.Assert(t, strings.Contains(outputStr, "Moderate"), "wide table should include network performance") 95 | h.Assert(t, strings.Contains(outputStr, "NVIDIA K520"), "wide table should include GPU Info") 96 | } 97 | 98 | func TestTableOutput_MBtoGB(t *testing.T) { 99 | instanceTypes := getInstanceTypes(t, "g2_2xlarge.json") 100 | instanceTypeOut := outputs.TableOutputWide(instanceTypes) 101 | outputStr := strings.Join(instanceTypeOut, "") 102 | h.Assert(t, strings.Contains(outputStr, "15"), "table should include 15 GB of memory") 103 | h.Assert(t, strings.Contains(outputStr, "4"), "wide table should include 4 GB of gpu memory") 104 | 105 | instanceTypeOut = outputs.TableOutputShort(instanceTypes) 106 | outputStr = strings.Join(instanceTypeOut, "") 107 | h.Assert(t, strings.Contains(outputStr, "15"), "table should include 15 GB of memory") 108 | } 109 | 110 | func TestOneLineOutput(t *testing.T) { 111 | instanceTypes := getInstanceTypes(t, "t3_micro_and_p3_16xl.json") 112 | instanceTypeOut := outputs.OneLineOutput(instanceTypes) 113 | h.Assert(t, len(instanceTypeOut) == 1, "Should always return 1 line") 114 | h.Assert(t, instanceTypeOut[0] == "t3.micro,p3.16xlarge", "Should return both instance types separated by a comma") 115 | 116 | instanceTypeOut = outputs.OneLineOutput([]*instancetypes.Details{}) 117 | h.Assert(t, len(instanceTypeOut) == 0, "Should return 0 instance types when passed empty slice") 118 | 119 | instanceTypeOut = outputs.OneLineOutput(nil) 120 | h.Assert(t, len(instanceTypeOut) == 0, "Should return 0 instance types when passed nil") 121 | } 122 | -------------------------------------------------------------------------------- /pkg/selector/outputs/sortingView.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // You may obtain a copy of the License at 4 | // 5 | // http://www.apache.org/licenses/LICENSE-2.0 6 | // 7 | // Unless required by applicable law or agreed to in writing, software 8 | // distributed under the License is distributed on an "AS IS" BASIS, 9 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | // See the License for the specific language governing permissions and 11 | // limitations under the License. 12 | 13 | package outputs 14 | 15 | import ( 16 | "fmt" 17 | "io" 18 | "strings" 19 | 20 | "github.com/charmbracelet/bubbles/key" 21 | "github.com/charmbracelet/bubbles/list" 22 | "github.com/charmbracelet/bubbles/textinput" 23 | tea "github.com/charmbracelet/bubbletea" 24 | "github.com/charmbracelet/lipgloss" 25 | 26 | "github.com/aws/amazon-ec2-instance-selector/v3/pkg/instancetypes" 27 | "github.com/aws/amazon-ec2-instance-selector/v3/pkg/sorter" 28 | ) 29 | 30 | const ( 31 | // formatting. 32 | sortDirectionPadding = 2 33 | sortingTitlePadding = 3 34 | sortingFooterPadding = 2 35 | 36 | // controls. 37 | sortingListControls = "Controls: ↑/↓ - up/down • enter - select filter • tab - toggle direction • esc - return to table • q - quit" 38 | sortingTextControls = "Controls: ↑/↓ - up/down • tab - toggle direction • enter - enter json path" 39 | 40 | // sort direction text. 41 | ascendingText = "ASCENDING" 42 | descendingText = "DESCENDING" 43 | ) 44 | 45 | // sortingModel holds the state for the sorting view. 46 | type sortingModel struct { 47 | // list which holds the available shorting shorthands 48 | shorthandList list.Model 49 | 50 | // text input for json paths 51 | sortTextInput textinput.Model 52 | 53 | instanceTypes []*instancetypes.Details 54 | 55 | isDescending bool 56 | } 57 | 58 | // format styles. 59 | var ( 60 | // list. 61 | listTitleStyle = lipgloss.NewStyle().Bold(true).Underline(true) 62 | listItemStyle = lipgloss.NewStyle().PaddingLeft(4) 63 | selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170")) 64 | 65 | // text. 66 | descendingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#0096FF")) 67 | ascendingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#DAF7A6")) 68 | sortDirectionStyle = lipgloss.NewStyle().Bold(true).Underline(true).PaddingLeft(2) 69 | ) 70 | 71 | // implement Item interface for list. 72 | type item string 73 | 74 | func (i item) FilterValue() string { return "" } 75 | func (i item) Title() string { return string(i) } 76 | func (i item) Description() string { return "" } 77 | 78 | // implement ItemDelegate for list. 79 | type itemDelegate struct{} 80 | 81 | func (d itemDelegate) Height() int { return 1 } 82 | func (d itemDelegate) Spacing() int { return 0 } 83 | func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } 84 | func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { 85 | i, ok := listItem.(item) 86 | if !ok { 87 | return 88 | } 89 | 90 | str := fmt.Sprintf("%d. %s", index+1, i) 91 | 92 | fn := listItemStyle.Render 93 | if index == m.Index() { 94 | fn = func(s ...string) string { 95 | t := make([]string, 0, len(s)+1) 96 | t = append(t, "> ") 97 | t = append(t, s...) 98 | return selectedItemStyle.Render(t...) 99 | } 100 | } 101 | 102 | fmt.Fprint(w, fn(str)) 103 | } 104 | 105 | // initSortingModel initializes and returns a new tableModel based on the given 106 | // instance type details. 107 | func initSortingModel(instanceTypes []*instancetypes.Details) *sortingModel { 108 | shorthandList := list.New(*createListItems(), itemDelegate{}, initialDimensionVal, initialDimensionVal) 109 | shorthandList.Title = "Select sorting filter:" 110 | shorthandList.Styles.Title = listTitleStyle 111 | shorthandList.SetFilteringEnabled(false) 112 | shorthandList.SetShowStatusBar(false) 113 | shorthandList.SetShowHelp(false) 114 | shorthandList.SetShowPagination(false) 115 | shorthandList.KeyMap = createListKeyMap() 116 | 117 | sortTextInput := textinput.New() 118 | sortTextInput.Prompt = "JSON Path: " 119 | sortTextInput.PromptStyle = lipgloss.NewStyle().Bold(true) 120 | 121 | return &sortingModel{ 122 | shorthandList: shorthandList, 123 | sortTextInput: sortTextInput, 124 | instanceTypes: instanceTypes, 125 | isDescending: false, 126 | } 127 | } 128 | 129 | // createListKeyMap creates a KeyMap with the controls for the shorthand list. 130 | func createListKeyMap() list.KeyMap { 131 | return list.KeyMap{ 132 | CursorDown: key.NewBinding( 133 | key.WithKeys("down"), 134 | ), 135 | CursorUp: key.NewBinding( 136 | key.WithKeys("up"), 137 | ), 138 | } 139 | } 140 | 141 | // createListItems creates a list item for shorthand sorting flag. 142 | func createListItems() *[]list.Item { 143 | shorthandFlags := []string{ 144 | sorter.GPUCountField, 145 | sorter.InferenceAcceleratorsField, 146 | sorter.VCPUs, 147 | sorter.Memory, 148 | sorter.GPUMemoryTotal, 149 | sorter.NetworkInterfaces, 150 | sorter.SpotPrice, 151 | sorter.ODPrice, 152 | sorter.InstanceStorage, 153 | sorter.EBSOptimizedBaselineBandwidth, 154 | sorter.EBSOptimizedBaselineThroughput, 155 | sorter.EBSOptimizedBaselineIOPS, 156 | } 157 | 158 | items := []list.Item{} 159 | 160 | for _, flag := range shorthandFlags { 161 | items = append(items, item(flag)) 162 | } 163 | 164 | return &items 165 | } 166 | 167 | // resizeSortingView will change the dimensions of the sorting view 168 | // in order to accommodate the new window dimensions represented by 169 | // the given tea.WindowSizeMsg. 170 | func (m sortingModel) resizeView(msg tea.WindowSizeMsg) sortingModel { 171 | shorthandList := &m.shorthandList 172 | shorthandList.SetWidth(msg.Width) 173 | // ensure that text input is right below last option 174 | if msg.Height >= len(shorthandList.Items())+sortingTitlePadding+sortingFooterPadding { 175 | shorthandList.SetHeight(len(shorthandList.Items()) + sortingTitlePadding) 176 | } else if msg.Height-sortingFooterPadding-sortDirectionPadding > 0 { 177 | shorthandList.SetHeight(msg.Height - sortingFooterPadding - sortDirectionPadding) 178 | } else { 179 | shorthandList.SetHeight(1) 180 | } 181 | 182 | // ensure cursor of list is still hidden after resize 183 | if m.sortTextInput.Focused() { 184 | shorthandList.Select(len(m.shorthandList.Items())) 185 | } 186 | 187 | m.shorthandList = *shorthandList 188 | 189 | return m 190 | } 191 | 192 | // update updates the state of the sortingModel. 193 | func (m sortingModel) update(msg tea.Msg) (sortingModel, tea.Cmd) { 194 | var cmd tea.Cmd 195 | var cmds []tea.Cmd 196 | 197 | switch msg := msg.(type) { 198 | case tea.KeyMsg: 199 | switch msg.String() { 200 | case "down": 201 | if m.shorthandList.Index() == len(m.shorthandList.Items())-1 { 202 | // focus text input and hide cursor in shorthand list 203 | m.shorthandList.Select(len(m.shorthandList.Items())) 204 | m.sortTextInput.Focus() 205 | } 206 | case "up": 207 | if m.sortTextInput.Focused() { 208 | // go back to list from text input 209 | m.shorthandList.Select(len(m.shorthandList.Items())) 210 | m.sortTextInput.Blur() 211 | } 212 | case "tab": 213 | m.isDescending = !m.isDescending 214 | } 215 | 216 | if m.sortTextInput.Focused() { 217 | m.sortTextInput, cmd = m.sortTextInput.Update(msg) 218 | cmds = append(cmds, cmd) 219 | } 220 | } 221 | 222 | if !m.sortTextInput.Focused() { 223 | m.shorthandList, cmd = m.shorthandList.Update(msg) 224 | cmds = append(cmds, cmd) 225 | } 226 | 227 | return m, tea.Batch(cmds...) 228 | } 229 | 230 | // view returns a string representing the sorting view. 231 | func (m sortingModel) view() string { 232 | outputStr := strings.Builder{} 233 | 234 | // draw sort direction 235 | outputStr.WriteString(sortDirectionStyle.Render("Sort Direction:")) 236 | outputStr.WriteString(" ") 237 | if m.isDescending { 238 | outputStr.WriteString(descendingStyle.Render(descendingText)) 239 | } else { 240 | outputStr.WriteString(ascendingStyle.Render(ascendingText)) 241 | } 242 | outputStr.WriteString("\n\n") 243 | 244 | // draw list 245 | outputStr.WriteString(m.shorthandList.View()) 246 | outputStr.WriteString("\n") 247 | 248 | // draw text input 249 | outputStr.WriteString(m.sortTextInput.View()) 250 | outputStr.WriteString("\n") 251 | 252 | // draw controls 253 | if m.sortTextInput.Focused() { 254 | outputStr.WriteString(controlsStyle.Render(sortingTextControls)) 255 | } else { 256 | outputStr.WriteString(controlsStyle.Render(sortingListControls)) 257 | } 258 | 259 | return outputStr.String() 260 | } 261 | -------------------------------------------------------------------------------- /pkg/selector/outputs/verboseView.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // You may obtain a copy of the License at 4 | // 5 | // http://www.apache.org/licenses/LICENSE-2.0 6 | // 7 | // Unless required by applicable law or agreed to in writing, software 8 | // distributed under the License is distributed on an "AS IS" BASIS, 9 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | // See the License for the specific language governing permissions and 11 | // limitations under the License. 12 | 13 | package outputs 14 | 15 | import ( 16 | "fmt" 17 | "math" 18 | "strings" 19 | 20 | ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" 21 | "github.com/charmbracelet/bubbles/viewport" 22 | tea "github.com/charmbracelet/bubbletea" 23 | "github.com/charmbracelet/lipgloss" 24 | ) 25 | 26 | const ( 27 | // verbose view formatting. 28 | outlinePadding = 8 29 | 30 | // controls. 31 | verboseControls = "Controls: ↑/↓ - up/down • esc - return to table • q - quit" 32 | ) 33 | 34 | // verboseModel represents the current state of the verbose view. 35 | type verboseModel struct { 36 | // model for verbose output viewport 37 | viewport viewport.Model 38 | 39 | // the instance which the verbose output is focused on 40 | focusedInstanceName ec2types.InstanceType 41 | } 42 | 43 | // styling for viewport. 44 | var ( 45 | titleStyle = func() lipgloss.Style { 46 | b := lipgloss.RoundedBorder() 47 | b.Right = "├" 48 | return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) 49 | }() 50 | 51 | infoStyle = func() lipgloss.Style { 52 | b := lipgloss.RoundedBorder() 53 | b.Left = "┤" 54 | return titleStyle.BorderStyle(b) 55 | }() 56 | ) 57 | 58 | // initVerboseModel initializes and returns a new verboseModel based on the given 59 | // instance type details. 60 | func initVerboseModel() *verboseModel { 61 | viewportModel := viewport.New(initialDimensionVal, initialDimensionVal) 62 | viewportModel.MouseWheelEnabled = true 63 | 64 | return &verboseModel{ 65 | viewport: viewportModel, 66 | } 67 | } 68 | 69 | // resizeView will change the dimensions of the verbose viewport in order to accommodate 70 | // the new window dimensions represented by the given tea.WindowSizeMsg. 71 | func (m verboseModel) resizeView(msg tea.WindowSizeMsg) verboseModel { 72 | // handle width changes 73 | m.viewport.Width = msg.Width 74 | 75 | // handle height changes 76 | if outlinePadding >= msg.Height { 77 | // height too short to fit viewport 78 | m.viewport.Height = 0 79 | } else { 80 | newHeight := msg.Height - outlinePadding 81 | m.viewport.Height = newHeight 82 | } 83 | 84 | return m 85 | } 86 | 87 | // update updates the state of the verboseModel. 88 | func (m verboseModel) update(msg tea.Msg) (verboseModel, tea.Cmd) { 89 | var cmd tea.Cmd 90 | m.viewport, cmd = m.viewport.Update(msg) 91 | return m, cmd 92 | } 93 | 94 | func (m verboseModel) view() string { 95 | outputStr := strings.Builder{} 96 | 97 | // format header for viewport 98 | instanceName := titleStyle.Render(string(m.focusedInstanceName)) 99 | line := strings.Repeat("─", int(math.Max(0, float64(m.viewport.Width-lipgloss.Width(instanceName))))) 100 | outputStr.WriteString(lipgloss.JoinHorizontal(lipgloss.Center, instanceName, line)) 101 | outputStr.WriteString("\n") 102 | 103 | outputStr.WriteString(m.viewport.View()) 104 | outputStr.WriteString("\n") 105 | 106 | // format footer for viewport 107 | pagePercentage := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) 108 | line = strings.Repeat("─", int(math.Max(0, float64(m.viewport.Width-lipgloss.Width(pagePercentage))))) 109 | outputStr.WriteString(lipgloss.JoinHorizontal(lipgloss.Center, line, pagePercentage)) 110 | outputStr.WriteString("\n") 111 | 112 | // controls 113 | outputStr.WriteString(controlsStyle.Render(verboseControls)) 114 | outputStr.WriteString("\n") 115 | 116 | return outputStr.String() 117 | } 118 | -------------------------------------------------------------------------------- /pkg/selector/services.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // You may obtain a copy of the License at 4 | // 5 | // http://www.apache.org/licenses/LICENSE-2.0 6 | // 7 | // Unless required by applicable law or agreed to in writing, software 8 | // distributed under the License is distributed on an "AS IS" BASIS, 9 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | // See the License for the specific language governing permissions and 11 | // limitations under the License. 12 | 13 | package selector 14 | 15 | import ( 16 | "fmt" 17 | "strings" 18 | 19 | "dario.cat/mergo" 20 | ) 21 | 22 | // Service is used to write custom service filter transforms. 23 | type Service interface { 24 | Filters(version string) (Filters, error) 25 | } 26 | 27 | // ServiceFiltersFn is the func type definition for the Service interface. 28 | type ServiceFiltersFn func(version string) (Filters, error) 29 | 30 | // Filters implements the Service interface on ServiceFiltersFn 31 | // This allows any ServiceFiltersFn to be passed into funcs accepting the Service interface. 32 | func (fn ServiceFiltersFn) Filters(version string) (Filters, error) { 33 | return fn(version) 34 | } 35 | 36 | // ServiceRegistry is used to register service filter transforms. 37 | type ServiceRegistry struct { 38 | services map[string]*Service 39 | } 40 | 41 | // NewRegistry creates a new instance of a ServiceRegistry. 42 | func NewRegistry() ServiceRegistry { 43 | return ServiceRegistry{ 44 | services: make(map[string]*Service), 45 | } 46 | } 47 | 48 | // Register takes a service name and Service implementation that will be executed on an ExecuteTransforms call. 49 | func (sr *ServiceRegistry) Register(name string, service Service) { 50 | if sr.services == nil { 51 | sr.services = make(map[string]*Service) 52 | } 53 | if name == "" { 54 | return 55 | } 56 | sr.services[name] = &service 57 | } 58 | 59 | // RegisterAWSServices registers the built-in AWS service filter transforms. 60 | func (sr *ServiceRegistry) RegisterAWSServices() { 61 | sr.Register("emr", &EMR{}) 62 | } 63 | 64 | // ExecuteTransforms will execute the ServiceRegistry's registered service filter transforms 65 | // Filters.Service will be parsed as - and passed to Service.Filters. 66 | func (sr *ServiceRegistry) ExecuteTransforms(filters Filters) (Filters, error) { 67 | if filters.Service == nil || *filters.Service == "" || *filters.Service == "eks" { 68 | return filters, nil 69 | } 70 | serviceAndVersion := strings.ToLower(*filters.Service) 71 | versionParts := strings.Split(serviceAndVersion, "-") 72 | serviceName := versionParts[0] 73 | version := "" 74 | if len(versionParts) >= 2 { 75 | version = strings.Join(versionParts[1:], "-") 76 | } 77 | service, ok := sr.services[serviceName] 78 | if !ok { 79 | return filters, fmt.Errorf("Service %s is not registered", serviceName) 80 | } 81 | 82 | serviceFilters, err := (*service).Filters(version) 83 | if err != nil { 84 | return filters, err 85 | } 86 | if err := mergo.Merge(&filters, serviceFilters); err != nil { 87 | return filters, err 88 | } 89 | return filters, nil 90 | } 91 | -------------------------------------------------------------------------------- /pkg/selector/services_test.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // You may obtain a copy of the License at 4 | // 5 | // http://www.apache.org/licenses/LICENSE-2.0 6 | // 7 | // Unless required by applicable law or agreed to in writing, software 8 | // distributed under the License is distributed on an "AS IS" BASIS, 9 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | // See the License for the specific language governing permissions and 11 | // limitations under the License. 12 | 13 | package selector_test 14 | 15 | import ( 16 | "testing" 17 | 18 | "github.com/aws/aws-sdk-go-v2/aws" 19 | 20 | "github.com/aws/amazon-ec2-instance-selector/v3/pkg/selector" 21 | h "github.com/aws/amazon-ec2-instance-selector/v3/pkg/test" 22 | ) 23 | 24 | // Tests 25 | 26 | func TestDefaultRegistry(t *testing.T) { 27 | registry := selector.NewRegistry() 28 | registry.RegisterAWSServices() 29 | 30 | emr := "emr" 31 | filters := selector.Filters{ 32 | Service: &emr, 33 | } 34 | 35 | transformedFilters, err := registry.ExecuteTransforms(filters) 36 | h.Ok(t, err) 37 | h.Assert(t, transformedFilters != filters, " Filters should have been modified") 38 | } 39 | 40 | func TestRegister_LazyInit(t *testing.T) { 41 | registry := selector.ServiceRegistry{} 42 | registry.RegisterAWSServices() 43 | 44 | emr := "emr" 45 | filters := selector.Filters{ 46 | Service: &emr, 47 | } 48 | 49 | transformedFilters, err := registry.ExecuteTransforms(filters) 50 | h.Ok(t, err) 51 | h.Assert(t, transformedFilters != filters, " Filters should have been modified") 52 | } 53 | 54 | func TestExecuteTransforms_OnUnrecognizedService(t *testing.T) { 55 | registry := selector.NewRegistry() 56 | registry.RegisterAWSServices() 57 | 58 | nes := "nonexistentservice" 59 | filters := selector.Filters{ 60 | Service: &nes, 61 | } 62 | 63 | _, err := registry.ExecuteTransforms(filters) 64 | h.Nok(t, err) 65 | } 66 | 67 | func TestRegister_CustomService(t *testing.T) { 68 | registry := selector.NewRegistry() 69 | customServiceFn := func(version string) (filters selector.Filters, err error) { 70 | filters.BareMetal = aws.Bool(true) 71 | return filters, nil 72 | } 73 | 74 | registry.Register("myservice", selector.ServiceFiltersFn(customServiceFn)) 75 | 76 | myService := "myservice" 77 | filters := selector.Filters{ 78 | Service: &myService, 79 | } 80 | 81 | transformedFilters, err := registry.ExecuteTransforms(filters) 82 | h.Ok(t, err) 83 | h.Assert(t, *transformedFilters.BareMetal == true, "custom service should have transformed BareMetal to true") 84 | } 85 | 86 | func TestExecuteTransforms_ShortCircuitOnEmptyService(t *testing.T) { 87 | registry := selector.NewRegistry() 88 | registry.RegisterAWSServices() 89 | 90 | emr := "" 91 | filters := selector.Filters{ 92 | Service: &emr, 93 | } 94 | 95 | transformedFilters, err := registry.ExecuteTransforms(filters) 96 | h.Ok(t, err) 97 | h.Assert(t, transformedFilters == filters, " Filters should not be modified") 98 | } 99 | 100 | func TestExecuteTransforms_ValidVersionParsing(t *testing.T) { 101 | registry := selector.NewRegistry() 102 | customServiceFn := func(version string) (filters selector.Filters, err error) { 103 | h.Assert(t, version == "myversion", "version should have been parsed as myversion but got %s", version) 104 | return filters, nil 105 | } 106 | 107 | registry.Register("myservice", selector.ServiceFiltersFn(customServiceFn)) 108 | 109 | myService := "myservice-myversion" 110 | filters := selector.Filters{ 111 | Service: &myService, 112 | } 113 | 114 | _, err := registry.ExecuteTransforms(filters) 115 | h.Ok(t, err) 116 | } 117 | 118 | func TestExecuteTransforms_LongVersionWithExtraDash(t *testing.T) { 119 | registry := selector.NewRegistry() 120 | customServiceFn := func(version string) (filters selector.Filters, err error) { 121 | h.Assert(t, version == "myversion-test", "version should have been parsed as myversion-test but got %s", version) 122 | return filters, nil 123 | } 124 | 125 | registry.Register("myservice", selector.ServiceFiltersFn(customServiceFn)) 126 | 127 | myService := "myservice-myversion-test" 128 | filters := selector.Filters{ 129 | Service: &myService, 130 | } 131 | 132 | _, err := registry.ExecuteTransforms(filters) 133 | h.Ok(t, err) 134 | } 135 | -------------------------------------------------------------------------------- /pkg/selector/types_test.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // You may obtain a copy of the License at 4 | // 5 | // http://www.apache.org/licenses/LICENSE-2.0 6 | // 7 | // Unless required by applicable law or agreed to in writing, software 8 | // distributed under the License is distributed on an "AS IS" BASIS, 9 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | // See the License for the specific language governing permissions and 11 | // limitations under the License. 12 | 13 | package selector_test 14 | 15 | import ( 16 | "regexp" 17 | "strings" 18 | "testing" 19 | 20 | ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" 21 | 22 | "github.com/aws/amazon-ec2-instance-selector/v3/pkg/selector" 23 | h "github.com/aws/amazon-ec2-instance-selector/v3/pkg/test" 24 | ) 25 | 26 | // Tests 27 | 28 | func TestMarshalIndent(t *testing.T) { 29 | cpuArch := ec2types.ArchitectureTypeX8664 30 | allowRegex := "^abc$" 31 | denyRegex := "^zyx$" 32 | 33 | filters := selector.Filters{ 34 | AllowList: regexp.MustCompile(allowRegex), 35 | DenyList: regexp.MustCompile(denyRegex), 36 | CPUArchitecture: &cpuArch, 37 | } 38 | out, err := filters.MarshalIndent("", " ") 39 | outStr := string(out) 40 | h.Ok(t, err) 41 | h.Assert(t, strings.Contains(outStr, "AllowList") && strings.Contains(outStr, allowRegex), "Does not include AllowList regex string") 42 | h.Assert(t, strings.Contains(outStr, "DenyList") && strings.Contains(outStr, denyRegex), "Does not include DenyList regex string") 43 | } 44 | 45 | func TestMarshalIndent_nil(t *testing.T) { 46 | denyRegex := "^zyx$" 47 | 48 | filters := selector.Filters{ 49 | AllowList: nil, 50 | DenyList: regexp.MustCompile(denyRegex), 51 | } 52 | out, err := filters.MarshalIndent("", " ") 53 | outStr := string(out) 54 | h.Ok(t, err) 55 | h.Assert(t, strings.Contains(outStr, "AllowList") && strings.Contains(outStr, "null"), "Does not include AllowList null entry") 56 | h.Assert(t, strings.Contains(outStr, "DenyList") && strings.Contains(outStr, denyRegex), "Does not include DenyList regex string") 57 | } 58 | -------------------------------------------------------------------------------- /pkg/test/helpers.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // You may obtain a copy of the License at 4 | // 5 | // http://www.apache.org/licenses/LICENSE-2.0 6 | // 7 | // Unless required by applicable law or agreed to in writing, software 8 | // distributed under the License is distributed on an "AS IS" BASIS, 9 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | // See the License for the specific language governing permissions and 11 | // limitations under the License. 12 | 13 | package test 14 | 15 | import ( 16 | "fmt" 17 | "path/filepath" 18 | "reflect" 19 | "runtime" 20 | "testing" 21 | ) 22 | 23 | // Assert fails the test if the condition is false. 24 | func Assert(tb testing.TB, condition bool, msg string, v ...interface{}) { 25 | if !condition { 26 | _, file, line, _ := runtime.Caller(1) 27 | fmt.Printf("\033[31m%s:%d: "+msg+"\033[39m\n\n", append([]interface{}{filepath.Base(file), line}, v...)...) 28 | tb.FailNow() 29 | } 30 | } 31 | 32 | // Ok fails the test if an err is not nil. 33 | func Ok(tb testing.TB, err error) { 34 | if err != nil { 35 | _, file, line, _ := runtime.Caller(1) 36 | fmt.Printf("\033[31m%s:%d: unexpected error: %s\033[39m\n\n", filepath.Base(file), line, err.Error()) 37 | tb.FailNow() 38 | } 39 | } 40 | 41 | // Nok fails the test if an err is nil. 42 | func Nok(tb testing.TB, err error) { 43 | if err == nil { 44 | _, file, line, _ := runtime.Caller(1) 45 | fmt.Printf("\033[31m%s:%d: unexpected success \033[39m\n\n", filepath.Base(file), line) 46 | tb.FailNow() 47 | } 48 | } 49 | 50 | // Equals fails the test if exp is not equal to act. 51 | func Equals(tb testing.TB, exp, act interface{}) { 52 | if !reflect.DeepEqual(exp, act) { 53 | _, file, line, _ := runtime.Caller(1) 54 | fmt.Printf("\033[31m%s:%d:\n\n\texp: %#v\n\n\tgot: %#v\033[39m\n\n", filepath.Base(file), line, exp, act) 55 | tb.FailNow() 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /scripts/build-binaries: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" 5 | 6 | REPO_ROOT_PATH="${SCRIPTPATH}/../" 7 | MAKE_FILE_PATH="${REPO_ROOT_PATH}/Makefile" 8 | BIN_DIR="${SCRIPTPATH}/../build/bin" 9 | mkdir -p "${BIN_DIR}" 10 | 11 | VERSION=$(make -s -f ${MAKE_FILE_PATH} version) 12 | PLATFORMS=("linux/amd64") 13 | 14 | USAGE=$(cat << 'EOM' 15 | Usage: build-binaries [-p ] 16 | Builds static binaries for the platform pairs passed in 17 | 18 | Example: build-binaries -p "linux/amd64,linux/arm" 19 | Optional: 20 | -p Platform pair list (os/architecture) [DEFAULT: linux/amd64] 21 | -v VERSION: The application version of the docker image [DEFAULT: output of `make version`] 22 | EOM 23 | ) 24 | 25 | # Process our input arguments 26 | while getopts "dp:v:" opt; do 27 | case ${opt} in 28 | p ) # Platform Pairs 29 | IFS=',' read -ra PLATFORMS <<< "$OPTARG" 30 | ;; 31 | v ) # Image Version 32 | VERSION="$OPTARG" 33 | ;; 34 | \? ) 35 | echo "$USAGE" 1>&2 36 | exit 37 | ;; 38 | esac 39 | done 40 | 41 | for os_arch in "${PLATFORMS[@]}"; do 42 | os=$(echo $os_arch | cut -d'/' -f1) 43 | arch=$(echo $os_arch | cut -d'/' -f2) 44 | container_name="extract-aeis-$os-$arch" 45 | repo_name="aeis-bin" 46 | base_bin_name="ec2-instance-selector" 47 | bin_name="${base_bin_name}-${os}-${arch}" 48 | 49 | docker container rm $container_name || : 50 | $SCRIPTPATH/build-docker-images -p $os_arch -v $VERSION -r $repo_name 51 | docker container create --rm --name $container_name "$repo_name:$VERSION-$os-$arch" 52 | docker container cp $container_name:/ec2-instance-selector $BIN_DIR/$bin_name 53 | 54 | cp ${BIN_DIR}/${bin_name} ${BIN_DIR}/${base_bin_name} 55 | tar -zcvf ${BIN_DIR}/${bin_name}.tar.gz -C ${BIN_DIR} ${base_bin_name} 56 | rm -f ${BIN_DIR}/${base_bin_name} 57 | done 58 | -------------------------------------------------------------------------------- /scripts/build-docker-images: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" 5 | 6 | REPO_ROOT_PATH=$SCRIPTPATH/../ 7 | MAKE_FILE_PATH=$REPO_ROOT_PATH/Makefile 8 | 9 | VERSION=$(make -s -f $MAKE_FILE_PATH version) 10 | PLATFORMS=("linux/amd64") 11 | GOPROXY="direct|https://proxy.golang.org" 12 | 13 | 14 | USAGE=$(cat << 'EOM' 15 | Usage: build-docker-images [-p ] 16 | Builds docker images for the platform pair 17 | 18 | Example: build-docker-images -p "linux/amd64,linux/arm" 19 | Optional: 20 | -p Platform pair list (os/architecture) [DEFAULT: linux/amd64] 21 | -r IMAGE REPO: set the docker image repo 22 | -v VERSION: The application version of the docker image [DEFAULT: output of `make version`] 23 | EOM 24 | ) 25 | 26 | # Process our input arguments 27 | while getopts "dp:r:v:" opt; do 28 | case ${opt} in 29 | p ) # Platform Pairs 30 | IFS=',' read -ra PLATFORMS <<< "$OPTARG" 31 | ;; 32 | r ) # Image Repo 33 | IMAGE_REPO="$OPTARG" 34 | ;; 35 | v ) # Image Version 36 | VERSION="$OPTARG" 37 | ;; 38 | \? ) 39 | echo "$USAGE" 1>&2 40 | exit 41 | ;; 42 | esac 43 | done 44 | 45 | for os_arch in "${PLATFORMS[@]}"; do 46 | os=$(echo $os_arch | cut -d'/' -f1) 47 | arch=$(echo $os_arch | cut -d'/' -f2) 48 | 49 | img_tag="$IMAGE_REPO:$VERSION-$os-$arch" 50 | 51 | docker build \ 52 | --build-arg GOOS=${os} \ 53 | --build-arg GOARCH=${arch} \ 54 | --build-arg GOPROXY=${GOPROXY} \ 55 | -t ${img_tag} \ 56 | ${REPO_ROOT_PATH} 57 | done -------------------------------------------------------------------------------- /scripts/create-local-tag-for-release: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to create a new local tag in preparation for a release 4 | # This script is idempotent i.e. it always fetches remote tags to create the new tag. 5 | # E.g. If the current remote release tag is v1.0.0, 6 | ## 1) running `create-local-tag-for-release -p` will create a new tag v1.0.1 7 | ## 2) immediately running `create-local-tag-for-release -m` will create a new tag v2.0.0 8 | 9 | set -euo pipefail 10 | 11 | REPO_ROOT_PATH="$( cd "$(dirname "$0")"; cd ../; pwd -P )" 12 | MAKEFILE_PATH=$REPO_ROOT_PATH/Makefile 13 | TAG_REGEX="^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z]*)?$" 14 | 15 | HELP=$(cat << 'EOM' 16 | Create a new local tag in preparation for a release. This script is idempotent i.e. it always fetches remote tags to create the new tag. 17 | 18 | Usage: create-local-tag-for-release [options] 19 | 20 | Options: 21 | -v new tag / version number. The script relies on the user to specify a valid and accurately incremented tag. 22 | -m increment major version 23 | -i increment minor version 24 | -p increment patch version 25 | -h help 26 | 27 | Examples: 28 | create-local-tag-for-release -v v1.0.0 Create local tag for new version v1.0.0 29 | create-local-tag-for-release -i Create local tag for new version by incrementing minor version only (previous tag=v1.0.0, new tag=v1.1.0) 30 | EOM 31 | ) 32 | 33 | MAJOR_INC=false 34 | MINOR_INC=false 35 | PATCH_INC=false 36 | NEW_TAG="" 37 | CURR_REMOTE_RELEASE_TAG="" 38 | 39 | process_args() { 40 | while getopts "hmipv:" opt; do 41 | case ${opt} in 42 | h ) 43 | echo -e "$HELP" 1>&2 44 | exit 0 45 | ;; 46 | m ) 47 | MAJOR_INC=true 48 | ;; 49 | i ) 50 | MINOR_INC=true 51 | ;; 52 | p ) 53 | PATCH_INC=true 54 | ;; 55 | v ) 56 | NEW_TAG="${OPTARG}" 57 | ;; 58 | \? ) 59 | echo "$HELP" 1>&2 60 | exit 0 61 | ;; 62 | esac 63 | done 64 | } 65 | 66 | validate_args() { 67 | if [[ ! -z $NEW_TAG ]]; then 68 | if ! [[ $NEW_TAG =~ $TAG_REGEX ]]; then 69 | echo "❌ Invalid new tag specified $NEW_TAG. Examples: v1.2.3, v1.2.3-dirty" 70 | exit 1 71 | fi 72 | 73 | echo "🥑 Using the new tag specified with -v flag. All other flags, if specified, will be ignored." 74 | echo " NOTE:The script relies on the user to specify a valid and accurately incremented tag." 75 | return 76 | fi 77 | 78 | if ($MAJOR_INC && $MINOR_INC) || ($MAJOR_INC && $PATCH_INC) || ($MINOR_INC && $PATCH_INC); then 79 | echo "❌ Invalid arguments passed. Specify only one of 3 tag parts to increment for the new tag: -m (major) or -i (minor) or -p (patch)." 80 | exit 1 81 | fi 82 | 83 | if $MAJOR_INC || $MINOR_INC || $PATCH_INC; then 84 | return 85 | fi 86 | 87 | echo -e "❌ Invalid arguments passed. Specify atleast one argument.\n$HELP" 88 | exit 1 89 | } 90 | 91 | sync_local_tags_from_remote() { 92 | # setup remote upstream tracking to fetch tags 93 | git remote add the-real-upstream https://github.com/aws/amazon-ec2-instance-selector.git &> /dev/null || true 94 | git fetch the-real-upstream 95 | 96 | # delete all local tags 97 | git tag -l | xargs git tag -d 98 | 99 | # fetch remote tags 100 | git fetch the-real-upstream --tags 101 | 102 | # record the latest release tag in remote, before creating a new tag 103 | CURR_REMOTE_RELEASE_TAG=$(get_latest_tag) 104 | 105 | # clean up tracking 106 | git remote remove the-real-upstream 107 | } 108 | 109 | create_tag() { 110 | git tag $NEW_TAG 111 | echo -e "\n✅ Created new tag $NEW_TAG (Current latest release tag in remote: v$CURR_REMOTE_RELEASE_TAG)\n" 112 | exit 0 113 | } 114 | 115 | get_latest_tag() { 116 | make -s -f $MAKEFILE_PATH latest-release-tag | cut -b 2- 117 | } 118 | 119 | main() { 120 | process_args "$@" 121 | validate_args 122 | 123 | sync_local_tags_from_remote 124 | 125 | # if new tag is specified, create it 126 | if [[ ! -z $NEW_TAG ]]; then 127 | create_tag 128 | fi 129 | 130 | # increment version 131 | if $MAJOR_INC || $MINOR_INC || $PATCH_INC; then 132 | curr_major_v=$(echo $CURR_REMOTE_RELEASE_TAG | tr '.' '\n' | head -1) 133 | curr_minor_v=$(echo $CURR_REMOTE_RELEASE_TAG | tr '.' '\n' | head -2 | tail -1) 134 | curr_patch_v=$(echo $CURR_REMOTE_RELEASE_TAG | tr '.' '\n' | tail -1) 135 | 136 | if [[ $MAJOR_INC == true ]]; then 137 | new_major_v=$(echo $(($curr_major_v + 1))) 138 | NEW_TAG=$(echo v$new_major_v.0.0) 139 | elif [[ $MINOR_INC == true ]]; then 140 | new_minor_v=$(echo $(($curr_minor_v + 1))) 141 | NEW_TAG=$(echo v$curr_major_v.$new_minor_v.0) 142 | elif [[ $PATCH_INC == true ]]; then 143 | new_patch_v=$(echo $(($curr_patch_v + 1))) 144 | NEW_TAG=$(echo v$curr_major_v.$curr_minor_v.$new_patch_v) 145 | fi 146 | create_tag 147 | fi 148 | } 149 | 150 | main "$@" 151 | -------------------------------------------------------------------------------- /scripts/prepare-for-release: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to: 4 | ## 1) create and checkout a new branch with the latest tag name 5 | ## 2) update instance-selector versions 6 | ## 3) commit release prep changes to new branch 7 | ## 4) create a PR from the new branch to upstream/main 8 | 9 | set -euo pipefail 10 | 11 | REPO_ROOT_PATH="$( cd "$(dirname "$0")"; cd ../; pwd -P )" 12 | MAKEFILE_PATH=$REPO_ROOT_PATH/Makefile 13 | LATEST_VERSION=$(make -s -f $MAKEFILE_PATH latest-release-tag | cut -b 2- ) 14 | PREVIOUS_VERSION=$(make -s -f $MAKEFILE_PATH previous-release-tag | cut -b 2- ) 15 | 16 | # files with versions, to update 17 | REPO_README=$REPO_ROOT_PATH/README.md 18 | FILES=("$REPO_README") 19 | FILES_CHANGED=() 20 | 21 | # release prep 22 | LATEST_TAG="v$LATEST_VERSION" 23 | NEW_BRANCH="pr/$LATEST_TAG-release" 24 | COMMIT_MESSAGE="🥑🤖 $LATEST_TAG release prep 🤖🥑" 25 | 26 | # PR details 27 | DEFAULT_REPO_FULL_NAME=$(make -s -f $MAKEFILE_PATH repo-full-name) 28 | PR_BASE=main # target 29 | PR_TITLE="🥑🤖 $LATEST_TAG release prep" 30 | PR_BODY="🥑🤖 Auto-generated PR for $LATEST_TAG release. Updating release versions in repo." 31 | PR_LABEL_1="release-prep" 32 | PR_LABEL_2="🤖 auto-generated🤖" 33 | 34 | HELP=$(cat << 'EOM' 35 | Update repo with the new release version and create a pr from a new release prep branch. 36 | This script prompts the user with complete details about the PR before pushing the new local branch to remote and creating the PR. 37 | The new release version is the latest local git tag. 38 | Note: The local tag creation for a new release is separated from this script. A new tag must be created before this script is run which is automated when this script is run via make targets. 39 | 40 | Usage: prepare-for-release [options] 41 | 42 | Options: 43 | -d create a draft pr 44 | -r target repo full name for the pr (default: aws/amazon-ec2-instance-selector) 45 | -h help 46 | 47 | Examples: 48 | prepare-for-release -d update release version in repo and create a draft pr against aws/amazon-ec2-instance-selector 49 | prepare-for-release -r username/amazon-ec2-instance-selector update release version in repo and create a pr against username/amazon-ec2-instance-selector 50 | EOM 51 | ) 52 | 53 | DRAFT=false 54 | REPO_FULL_NAME="" 55 | NEED_ROLLBACK=true 56 | 57 | process_args() { 58 | while getopts "hdr:" opt; do 59 | case ${opt} in 60 | h ) 61 | echo -e "$HELP" 1>&2 62 | exit 0 63 | ;; 64 | d ) 65 | DRAFT=true 66 | ;; 67 | r ) 68 | # todo: validate $REPO_FULL_NAME 69 | REPO_FULL_NAME="${OPTARG}" 70 | ;; 71 | \? ) 72 | echo "$HELP" 1>&2 73 | exit 0 74 | ;; 75 | esac 76 | done 77 | 78 | # set repo full name to the default value if unset 79 | if [ -z $REPO_FULL_NAME ]; then 80 | REPO_FULL_NAME=$DEFAULT_REPO_FULL_NAME 81 | fi 82 | } 83 | 84 | # output formatting 85 | export TERM="xterm" 86 | RED=$(tput setaf 1) 87 | MAGENTA=$(tput setaf 5) 88 | RESET_FMT=$(tput sgr 0) 89 | BOLD=$(tput bold) 90 | 91 | # verify origin tracking before creating and pushing new branches 92 | verify_origin_tracking() { 93 | origin=$(git remote get-url origin 2>&1) || true 94 | 95 | if [[ $origin == "fatal: No such remote 'origin'" ]] || [[ $origin == "https://github.com/aws/amazon-ec2-instance-selector.git" ]]; then 96 | echo -e "❌ ${RED}Expected remote 'origin' to be tracking fork but found \"$origin\". Set it up before running this script again.${RESET_FMT}" 97 | NEED_ROLLBACK=false 98 | exit 1 99 | fi 100 | } 101 | 102 | create_release_branch() { 103 | exists=$(git checkout -b $NEW_BRANCH 2>&1) || true 104 | 105 | if [[ $exists == "fatal: A branch named '$NEW_BRANCH' already exists." ]]; then 106 | echo -e "❌ ${RED}$exists${RESET_FMT}" 107 | NEED_ROLLBACK=false 108 | exit 1 109 | fi 110 | echo -e "✅ ${BOLD}Created new release branch $NEW_BRANCH\n\n${RESET_FMT}" 111 | } 112 | 113 | update_versions() { 114 | # update release version for release prep 115 | echo -e "🥑 Attempting to update instance-selector release version in preparation for a new release." 116 | 117 | for f in "${FILES[@]}"; do 118 | has_incorrect_version=$(cat $f | grep $PREVIOUS_VERSION) 119 | if [[ ! -z $has_incorrect_version ]]; then 120 | sed -i '' "s/$PREVIOUS_VERSION/$LATEST_VERSION/g" $f 121 | FILES_CHANGED+=("$f") 122 | fi 123 | done 124 | 125 | if [[ ${#FILES_CHANGED[@]} -eq 0 ]]; then 126 | echo -e "\nNo files were modified. Either all files already use git the latest release version $LATEST_VERSION or the files don't currently have the previous version $PREVIOUS_VERSION." 127 | else 128 | echo -e "✅✅ ${BOLD}Updated versions from $PREVIOUS_VERSION to $LATEST_VERSION in files: \n$(echo "${FILES_CHANGED[@]}" | tr ' ' '\n')" 129 | echo -e "To see changes, run \`git diff HEAD^ HEAD\`${RESET_FMT}" 130 | fi 131 | echo 132 | } 133 | 134 | commit_changes() { 135 | echo -e "\n🥑 Adding and committing release version changes." 136 | git add "${FILES_CHANGED[@]}" 137 | git commit -m"$COMMIT_MESSAGE" 138 | echo -e "✅✅✅ ${BOLD}Committed release prep changes to new branch $NEW_BRANCH with commit message '$COMMIT_MESSAGE'\n\n${RESET_FMT}" 139 | } 140 | 141 | confirm_with_user_and_create_pr(){ 142 | git checkout $NEW_BRANCH # checkout new branch before printing git diff 143 | 144 | echo -e "\n🥑${BOLD}The following PR will be created:\n" 145 | cat << EOM 146 | PR draft mode: $DRAFT 147 | PR target repository: $REPO_FULL_NAME 148 | PR source branch: $NEW_BRANCH 149 | PR target branch: $REPO_FULL_NAME/$PR_BASE 150 | PR title: $PR_TITLE 151 | PR body: $PR_BODY 152 | PR labels: $PR_LABEL_1, $PR_LABEL_2 153 | Changes in $NEW_BRANCH: 154 | ${MAGENTA}$(git diff HEAD^ HEAD)${RESET_FMT} 155 | EOM 156 | while true; do 157 | echo -e "🥑${BOLD}Do you wish to create the release prep PR? Enter y/n" 158 | read -p "" yn 159 | case $yn in 160 | [Yy]* ) create_pr; break;; 161 | [Nn]* ) rollback; exit;; 162 | * ) echo "🥑Please answer yes or no.";; 163 | esac 164 | done 165 | echo "${RESET_FMT}" 166 | } 167 | 168 | create_pr() { 169 | git push -u origin $NEW_BRANCH # sets source branch for PR to NEW_BRANCH on the fork or origin 170 | git checkout $NEW_BRANCH # checkout new branch before creating a pr 171 | 172 | if [[ $DRAFT == true ]]; then 173 | gh pr create \ 174 | --repo "$REPO_FULL_NAME" \ 175 | --base "$PR_BASE" \ 176 | --title "$PR_TITLE" \ 177 | --body "$PR_BODY" \ 178 | --label "$PR_LABEL_1" --label "$PR_LABEL_2" \ 179 | --draft 180 | else 181 | gh pr create \ 182 | --repo "$REPO_FULL_NAME" \ 183 | --base "$PR_BASE" \ 184 | --title "$PR_TITLE" \ 185 | --body "$PR_BODY" \ 186 | --label "$PR_LABEL_1" --label "$PR_LABEL_2" 187 | fi 188 | 189 | if [[ $? == 0 ]]; then 190 | echo -e "✅✅✅✅ ${BOLD}Created $LATEST_TAG release prep PR\n${RESET_FMT}" 191 | else 192 | echo -e "❌ ${RED}PR creation failed.${RESET_FMT}❌" 193 | exit 1 194 | fi 195 | } 196 | 197 | # rollback partial changes to make this script atomic, iff the current execution of the script created a new branch and made changes 198 | rollback() { 199 | if [[ $NEED_ROLLBACK == true ]]; then 200 | echo "🥑${BOLD}Rolling back" 201 | 202 | # checkout of current branch to main 203 | git checkout main 204 | 205 | # delete local and remote release branch only if current execution of the script created them 206 | git branch -D $NEW_BRANCH 207 | git push origin --delete $NEW_BRANCH 208 | fi 209 | echo "${RESET_FMT}" 210 | } 211 | 212 | handle_errors() { 213 | # error handling 214 | if [ $1 != "0" ]; then 215 | FAILED_COMMAND=${*:2} 216 | echo -e "\n❌ ${RED}Error occurred while running command '$FAILED_COMMAND'.${RESET_FMT}❌" 217 | rollback 218 | exit 1 219 | fi 220 | exit $1 221 | } 222 | 223 | main() { 224 | process_args "$@" 225 | trap 'handle_errors $? $BASH_COMMAND' EXIT 226 | 227 | verify_origin_tracking 228 | 229 | echo -e "🥑 Attempting to create a release prep branch and PR with release version updates.\n Previous version: $PREVIOUS_VERSION ---> Latest version: $LATEST_VERSION" 230 | create_release_branch 231 | update_versions 232 | commit_changes 233 | confirm_with_user_and_create_pr 234 | } 235 | 236 | main "$@" 237 | -------------------------------------------------------------------------------- /scripts/push-docker-images: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" 5 | 6 | REPO_ROOT_PATH=$SCRIPTPATH/../ 7 | MAKE_FILE_PATH=$REPO_ROOT_PATH/Makefile 8 | 9 | VERSION=$(make -s -f $MAKE_FILE_PATH version) 10 | PLATFORMS=("linux/amd64") 11 | MANIFEST_IMAGES="" 12 | MANIFEST="" 13 | DOCKER_CLI_CONFIG="${HOME}/.docker/config.json" 14 | 15 | USAGE=$(cat << 'EOM' 16 | Usage: push-docker-images [-p ] 17 | Pushes docker images for the platform pairs passed in w/ a dockerhub manifest 18 | 19 | Example: push-docker-images -p "linux/amd64,linux/arm" 20 | Optional: 21 | -p Platform pair list (os/architecture) [DEFAULT: linux/amd64] 22 | -r IMAGE REPO: set the docker image repo 23 | -v VERSION: The application version of the docker image [DEFAULT: output of `make version`] 24 | -m Create a docker manifest 25 | EOM 26 | ) 27 | 28 | # Process our input arguments 29 | while getopts "mp:r:v:" opt; do 30 | case ${opt} in 31 | p ) # Platform Pairs 32 | IFS=',' read -ra PLATFORMS <<< "$OPTARG" 33 | ;; 34 | r ) # Image Repo 35 | IMAGE_REPO="$OPTARG" 36 | ;; 37 | v ) # Image Version 38 | VERSION="$OPTARG" 39 | ;; 40 | m ) # Docker manifest 41 | MANIFEST="true" 42 | ;; 43 | \? ) 44 | echo "$USAGE" 1>&2 45 | exit 46 | ;; 47 | esac 48 | done 49 | 50 | if [[ ${#PLATFORMS[@]} -gt 1 && $MANIFEST != "true" ]]; then 51 | echo "Only one platform can be pushed if you do not create a manifest." 52 | echo "Try again with the -m option" 53 | exit 1 54 | fi 55 | 56 | for os_arch in "${PLATFORMS[@]}"; do 57 | os=$(echo $os_arch | cut -d'/' -f1) 58 | arch=$(echo $os_arch | cut -d'/' -f2) 59 | 60 | img_tag_w_platform="$IMAGE_REPO:$VERSION-$os-$arch" 61 | 62 | if [[ $MANIFEST == "true" ]]; then 63 | img_tag=$img_tag_w_platform 64 | else 65 | img_tag="$IMAGE_REPO:$VERSION" 66 | docker tag $img_tag_w_platform $img_tag 67 | fi 68 | 69 | docker push $img_tag 70 | MANIFEST_IMAGES="$MANIFEST_IMAGES $img_tag" 71 | done 72 | 73 | if [[ $MANIFEST == "true" ]]; then 74 | if [[ ! -f $DOCKER_CLI_CONFIG ]]; then 75 | echo '{"experimental":"enabled"}' > $DOCKER_CLI_CONFIG 76 | echo "Created docker config file" 77 | fi 78 | cat <<< $(jq '.+{"experimental":"enabled"}' $DOCKER_CLI_CONFIG) > $DOCKER_CLI_CONFIG 79 | echo "Enabled experimental CLI features to create the docker manifest" 80 | docker manifest create $IMAGE_REPO:$VERSION $MANIFEST_IMAGES 81 | 82 | for os_arch in "${PLATFORMS[@]}"; do 83 | os=$(echo $os_arch | cut -d'/' -f1) 84 | arch=$(echo $os_arch | cut -d'/' -f2) 85 | 86 | img_tag="$IMAGE_REPO:$VERSION-$os-$arch" 87 | 88 | docker manifest annotate $IMAGE_REPO:$VERSION $img_tag --arch $arch --os $os 89 | done 90 | 91 | docker manifest push $IMAGE_REPO:$VERSION 92 | fi -------------------------------------------------------------------------------- /scripts/sync-readme-to-dockerhub: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" 5 | 6 | image="amazon/amazon-ec2-instance-selector" 7 | 8 | if git --no-pager diff --name-only HEAD^ HEAD | grep 'README.md'; then 9 | token=$(curl -s -X POST \ 10 | -H "Content-Type: application/json" \ 11 | -d '{"username": "'"${DOCKERHUB_USERNAME}"'", "password": "'"${DOCKERHUB_PASSWORD}"'"}' \ 12 | https://hub.docker.com/v2/users/login/ | jq -r .token) 13 | 14 | rcode=$(jq -n --arg msg "$(<$SCRIPTPATH/../README.md)" \ 15 | '{"registry":"registry-1.docker.io","full_description": $msg }' | 16 | curl -s -o /dev/stderr -L -w "%{http_code}" \ 17 | https://hub.docker.com/v2/repositories/"${image}"/ \ 18 | -d @- \ 19 | -X PATCH \ 20 | -H "Content-Type: application/json" \ 21 | -H "Authorization: JWT ${token}") 22 | 23 | if [[ $rcode -ge 200 && $rcode -lt 300 ]]; then 24 | echo "README sync to dockerhub completed successfully" 25 | else 26 | echo "README sync to dockerhub failed" 27 | exit 1 28 | fi 29 | else 30 | echo "README.md did not change in the last commit. Not taking any action." 31 | fi 32 | -------------------------------------------------------------------------------- /scripts/sync-to-aws-homebrew-tap: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" 5 | BUILD_DIR="${SCRIPTPATH}/../build" 6 | BUILD_ID=$(uuidgen | cut -d'-' -f1 | tr '[:upper:]' '[:lower:]') 7 | 8 | TAP_REPO="aws/homebrew-tap" 9 | TAP_NAME=$(echo ${TAP_REPO} | cut -d'/' -f2) 10 | 11 | SYNC_DIR="${BUILD_DIR}/homebrew-sync" 12 | FORK_DIR="${SYNC_DIR}/${TAP_NAME}" 13 | DOWNLOAD_DIR="${BUILD_DIR}/downloads" 14 | BREW_CONFIG_DIR="${BUILD_DIR}/brew-config" 15 | 16 | REPO=$(make -s -f "${SCRIPTPATH}/../Makefile" repo-full-name) 17 | BINARY_BASE="" 18 | PLATFORMS=("darwin/amd64" "linux/amd64") 19 | DRY_RUN=0 20 | 21 | GH_CLI_VERSION="0.10.1" 22 | GH_CLI_CONFIG_PATH="${HOME}/.config/gh/config.yml" 23 | KERNEL=$(uname -s | tr '[:upper:]' '[:lower:]') 24 | OS="${KERNEL}" 25 | if [[ "${KERNEL}" == "darwin" ]]; then 26 | OS="macOS" 27 | fi 28 | 29 | VERSION_REGEX="^v[0-9]+\.[0-9]+\.[0-9]+\$" 30 | VERSION=$(make -s -f "${SCRIPTPATH}/../Makefile" version) 31 | 32 | USAGE=$(cat << EOM 33 | Usage: sync-to-aws-homebrew-tap -r -b -p 34 | Syncs tar.gz\'d binaries to the aws/homebrew-tap 35 | 36 | Example: sync-to-aws-homebrew-tap -r "aws/amazon-ec2-instance-selector" 37 | Required: 38 | -b Binary basename (i.e. -b "ec2-instance-selector") 39 | 40 | Optional: 41 | -r Github repo to sync to in the form of "org/name" (i.e. -r "aws/amazon-ec2-instance-selector") [DEFAULT: output of \`make repo-full-name\`] 42 | -v VERSION: The application version of the docker image [DEFAULT: output of \`make version\`] 43 | -p Platform pair list (os/architecture) [DEFAULT: linux/amd64] 44 | -d Dry-Run will do all steps except pushing to git and opening the sync PR 45 | EOM 46 | ) 47 | 48 | # Process our input arguments 49 | while getopts "p:b:r:v:d" opt; do 50 | case ${opt} in 51 | r ) # Github repo 52 | REPO="$OPTARG" 53 | ;; 54 | b ) # binary basename 55 | BINARY_BASE="$OPTARG" 56 | ;; 57 | p ) # Supported Platforms 58 | IFS=',' read -ra PLATFORMS <<< "$OPTARG" 59 | ;; 60 | v ) # App Version 61 | VERSION="$OPTARG" 62 | ;; 63 | d ) # Dry Run 64 | DRY_RUN=1 65 | ;; 66 | \? ) 67 | echo "$USAGE" 1>&2 68 | exit 69 | ;; 70 | esac 71 | done 72 | 73 | if [[ -z "${BINARY_BASE}" ]]; then 74 | echo "Binary Basename (-b) must be specified" 75 | exit 3 76 | fi 77 | 78 | if [[ ! "${VERSION}" =~ $VERSION_REGEX ]]; then 79 | echo "🙈 Not on a current release, so not syncing with tap $TAP_REPO" 80 | exit 3 81 | fi 82 | 83 | if [[ -z "${REPO}" ]]; then 84 | echo "Repo (-r) must be specified if no \"make repo-full-name\" target exists" 85 | fi 86 | 87 | if [[ -z $(command -v gh) ]] || [[ ! $(gh --version) =~ $GH_CLI_VERSION ]]; then 88 | mkdir -p ${BUILD_DIR}/gh 89 | curl -Lo ${BUILD_DIR}/gh/gh.tar.gz "https://github.com/cli/cli/releases/download/v${GH_CLI_VERSION}/gh_${GH_CLI_VERSION}_${OS}_amd64.tar.gz" 90 | tar -C ${BUILD_DIR}/gh -xvf "${BUILD_DIR}/gh/gh.tar.gz" 91 | export PATH="${BUILD_DIR}/gh/gh_${GH_CLI_VERSION}_${OS}_amd64/bin:$PATH" 92 | if [[ ! $(gh --version) =~ $GH_CLI_VERSION ]]; then 93 | echo "❌ Failed install of github cli" 94 | exit 4 95 | fi 96 | fi 97 | 98 | function restore_gh_config() { 99 | mv -f "${GH_CLI_CONFIG_PATH}.bkup" "${GH_CLI_CONFIG_PATH}" || : 100 | } 101 | 102 | if [[ -n $(env | grep GITHUB_TOKEN) ]] && [[ -n "${GITHUB_TOKEN}" ]]; then 103 | trap restore_gh_config EXIT INT TERM ERR 104 | mkdir -p "${HOME}/.config/gh" 105 | cp -f "${GH_CLI_CONFIG_PATH}" "${GH_CLI_CONFIG_PATH}.bkup" || : 106 | cat << EOF > "${GH_CLI_CONFIG_PATH}" 107 | hosts: 108 | github.com: 109 | oauth_token: ${GITHUB_TOKEN} 110 | user: ${GITHUB_USERNAME} 111 | EOF 112 | fi 113 | 114 | VERSION_NUM=$(echo "${VERSION}" | cut -c 2- | tr -d '\n') 115 | 116 | function fail() { 117 | echo "❌ Homebrew sync failed" 118 | exit 5 119 | } 120 | 121 | trap fail ERR TERM INT 122 | 123 | rm -rf "${DOWNLOAD_DIR}" "${SYNC_DIR}" "${BREW_CONFIG_DIR}" 124 | mkdir -p "${DOWNLOAD_DIR}" "${SYNC_DIR}" "${BREW_CONFIG_DIR}" 125 | 126 | BASE_ASSET_URL="https://github.com/${REPO}/releases/download/${VERSION}/${BINARY_BASE}" 127 | MAC_HASH="" 128 | MAC_ARM64_HASH="" 129 | LINUX_HASH="" 130 | LINUX_ARM64_HASH="" 131 | 132 | function hash_file() { 133 | local file="${1}" 134 | echo "$(openssl dgst -sha256 "${file}" | cut -d' ' -f2 | tr -d '\n')" 135 | } 136 | 137 | for os_arch in "${PLATFORMS[@]}"; do 138 | os=$(echo "${os_arch}" | cut -d'/' -f1) 139 | arch=$(echo "${os_arch}" | cut -d'/' -f2) 140 | 141 | ## Windows is not supported with homebrew 142 | if [[ "${os}" == "windows" ]]; then 143 | continue 144 | fi 145 | 146 | asset_url="${BASE_ASSET_URL}-${os}-${arch}.tar.gz" 147 | asset_file="${BINARY_BASE}-${os}-${arch}.tar.gz" 148 | asset_file_path="${DOWNLOAD_DIR}/${asset_file}" 149 | 150 | curl -H 'Cache-Control: no-cache' -Lo "${asset_file_path}" "${asset_url}?$(date +%s)" 151 | 152 | asset_file_size=$(du -k "${asset_file_path}" | cut -f1) 153 | if [[ "${asset_file_size}" -lt 100 ]]; then 154 | ## If we cannot download and dry-run is set, build it locally 155 | if [[ "${DRY_RUN}" -eq 1 ]]; then 156 | ${SCRIPTPATH}/build-binaries -d -v "${VERSION}" -p "${os_arch}" 157 | cp "${BUILD_DIR}/bin/${asset_file}" ${asset_file_path} 158 | else 159 | echo "❗️${asset_file_path} is empty, skipping" 160 | continue 161 | fi 162 | fi 163 | 164 | if [[ "${os}" == "darwin" && "${arch}" == "amd64" ]]; then 165 | MAC_HASH=$(hash_file "${asset_file_path}") 166 | elif [[ "${os}" == "darwin" && "${arch}" == "arm64" ]]; then 167 | MAC_ARM64_HASH=$(hash_file "${asset_file_path}") 168 | elif [[ "${os}" == "linux" && "${arch}" == "amd64" ]]; then 169 | LINUX_HASH=$(hash_file "${asset_file_path}") 170 | elif [[ "${os}" == "linux" && "${arch}" == "arm64" ]]; then 171 | LINUX_ARM64_HASH=$(hash_file "${asset_file_path}") 172 | fi 173 | 174 | done 175 | 176 | cat << EOM > "${BREW_CONFIG_DIR}/${BINARY_BASE}.json" 177 | { 178 | "name": "${BINARY_BASE}", 179 | "version": "${VERSION_NUM}", 180 | "bin": "${BINARY_BASE}", 181 | "bottle": { 182 | "root_url": "${BASE_ASSET_URL}", 183 | "sha256": { 184 | "arm64_big_sur": "${MAC_ARM64_HASH}", 185 | "sierra": "${MAC_HASH}", 186 | "linux": "${LINUX_HASH}", 187 | "linux_arm": "${LINUX_ARM64_HASH}" 188 | } 189 | } 190 | } 191 | EOM 192 | 193 | if [[ "${DRY_RUN}" -eq 0 ]]; then 194 | if [[ -z "${MAC_HASH}" ]] && [[ -z "${MAC_ARM64_HASH}" ]] && [[ -z "${LINUX_HASH}" ]] && [[ -z "${LINUX_ARM64_HASH}" ]]; then 195 | echo "❌ No hashes were calculated and dry-run is NOT engaged. Bailing out so we don't open a bad PR to the tap." 196 | exit 4 197 | fi 198 | cd "${SYNC_DIR}" 199 | gh repo fork $TAP_REPO --clone --remote 200 | cd "${FORK_DIR}" 201 | git remote set-url origin https://${GITHUB_USERNAME}:${GITHUB_TOKEN}@github.com/${GITHUB_USERNAME}/${TAP_NAME}.git 202 | DEFAULT_BRANCH=$(git rev-parse --abbrev-ref HEAD | tr -d '\n') 203 | 204 | git config user.name "ec2-bot 🤖" 205 | git config user.email "ec2-bot@users.noreply.github.com" 206 | 207 | # Sync the fork 208 | git pull upstream "${DEFAULT_BRANCH}" 209 | git push -u origin "${DEFAULT_BRANCH}" 210 | 211 | FORK_RELEASE_BRANCH="${BINARY_BASE}-${VERSION}-${BUILD_ID}" 212 | git checkout -b "${FORK_RELEASE_BRANCH}" upstream/${DEFAULT_BRANCH} 213 | 214 | cp "${BREW_CONFIG_DIR}/${BINARY_BASE}.json" "${FORK_DIR}/bottle-configs/${BINARY_BASE}.json" 215 | 216 | git add "bottle-configs/${BINARY_BASE}.json" 217 | git commit -m "${BINARY_BASE} update to version ${VERSION_NUM}" 218 | 219 | RELEASE_ID=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \ 220 | https://api.github.com/repos/${REPO}/releases | \ 221 | jq --arg VERSION "$VERSION" '.[] | select(.tag_name==$VERSION) | .id') 222 | 223 | RELEASE_NOTES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \ 224 | https://api.github.com/repos/${REPO}/releases/${RELEASE_ID} | \ 225 | jq -r '.body') 226 | 227 | PR_BODY=$(cat << EOM 228 | ## ${BINARY_BASE} ${VERSION} Automated Release! 🤖🤖 229 | 230 | ### Release Notes 📝: 231 | 232 | ${RELEASE_NOTES} 233 | EOM 234 | ) 235 | 236 | git push -u origin "${FORK_RELEASE_BRANCH}" 237 | gh pr create --title "🥳 ${BINARY_BASE} ${VERSION} Automated Release! 🥑" \ 238 | --body "${PR_BODY}" 239 | fi 240 | 241 | echo "✅ Homebrew sync complete" 242 | -------------------------------------------------------------------------------- /scripts/upload-resources-to-github: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" 5 | VERSION=$(make -s -f $SCRIPTPATH/../Makefile version) 6 | 7 | RELEASE_ID=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \ 8 | https://api.github.com/repos/aws/amazon-ec2-instance-selector/releases | \ 9 | jq --arg VERSION "$VERSION" '.[] | select(.tag_name==$VERSION) | .id') 10 | 11 | for binary in $SCRIPTPATH/../build/bin/*; do 12 | curl \ 13 | -H "Authorization: token $GITHUB_TOKEN" \ 14 | -H "Content-Type: $(file -b --mime-type $binary)" \ 15 | --data-binary @$binary \ 16 | "https://uploads.github.com/repos/aws/amazon-ec2-instance-selector/releases/$RELEASE_ID/assets?name=$(basename $binary)" 17 | done 18 | 19 | -------------------------------------------------------------------------------- /test/e2e/run-test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | SCRIPTPATH="$(cd "$(dirname "$0")"; pwd -P)" 5 | BUILD_DIR="${SCRIPTPATH}/../../build" 6 | TEST_FAILURES_LOG="${BUILD_DIR}/test-failures.log" 7 | 8 | AEIS="${BUILD_DIR}/ec2-instance-selector" 9 | GLOBAL_PARAMS="--max-results=40" 10 | 11 | ## Print to stderr 12 | function echoerr() { 13 | echo "$@" 1>&2; 14 | } 15 | 16 | ## Sort a bash array 17 | function sort_array() { 18 | local input=( "$@" ) 19 | IFS=$'\n' 20 | local sorted=($(sort <<<"${input[*]}")) 21 | unset IFS 22 | echo "${sorted[*]}" 23 | } 24 | 25 | ## Checks if expected items (consumed as an array in args) 26 | ## are all contained in the actual list (consumed on stdin) 27 | function assert_contains_instance_types() { 28 | local expected=( "$@" ) 29 | local actual=() 30 | while read actual_input; do 31 | actual+=($actual_input) 32 | done 33 | [[ 0 -eq "${#actual[@]}" ]] && return 1 34 | local actual_sorted=($(sort_array "${actual[@]}")) 35 | local expected_sorted=($(sort_array "${expected[@]}")) 36 | 37 | for expectation in "${expected_sorted[@]}"; do 38 | if [[ ! " ${actual_sorted[*]} " =~ ${expectation} ]]; then 39 | echoerr -e "\t🔺ACTUAL: ${actual_sorted[*]}\n\t🔺Expected: ${expected_sorted[*]}" 40 | return 1 41 | fi 42 | done 43 | } 44 | 45 | ## Executes an expected vs actual test execution of amazon-ec2-instance-selector 46 | ## Test success or failure is output to stdout and failures are also logged to a file ($TEST_FAILURES_LOG) 47 | ## $1 = test name string 48 | ## shift; $@ = params to amazon-ec2-instance-selector (i.e. --vcpus=2) 49 | ## STDIN = expected list 50 | function execute_test() { 51 | local test_name=$1 52 | shift 53 | local params=( "$@" ) 54 | local expected=() 55 | while read expected_input; do 56 | expected+=($expected_input) 57 | done 58 | [[ 0 -eq "${#expected[@]}" ]] && return 1 59 | 60 | echo "=========================== Test: ${test_name} ===========================" 61 | 62 | for p in "${params[@]}"; do 63 | if $AEIS ${p} ${GLOBAL_PARAMS} | assert_contains_instance_types "${expected[*]}"; then 64 | echo "✅ ${test_name} \"$p\" passed!" 65 | else 66 | echo "❌ Failed ${test_name} \"$p\"" | tee "${TEST_FAILURES_LOG}" 67 | fi 68 | done 69 | echo -e "========================= End Test: ${test_name} ===========================\n\n" 70 | } 71 | 72 | ## Clean up previous test failures 73 | rm -f "${TEST_FAILURES_LOG}" 74 | 75 | ################################################ TESTS ################################################ 76 | 77 | expected=(t3a.micro t3.micro t2.micro) 78 | params=( 79 | "--memory=1" 80 | "--memory=1GiB" 81 | "--memory=1 GiB" 82 | "--memory=1gb" 83 | "--memory=1.0" 84 | "--memory-min=1 --memory-max=1" 85 | "--memory=1024m" 86 | ) 87 | echo "${expected[*]}" | execute_test "Memory 1 GiB" "${params[@]}" 88 | 89 | expected=(i3en.6xlarge inf1.6xlarge z1d.6xlarge) 90 | params=( 91 | "--vcpus=24" 92 | "--vcpus-min=24 --vcpus-max=24" 93 | ) 94 | echo "${expected[*]}" | execute_test "24 VCPUs" "${params[@]}" 95 | 96 | expected=(g4ad.16xlarge g4dn.12xlarge g5.12xlarge g5.24xlarge g6.12xlarge g6.24xlarge g6e.12xlarge g6e.24xlarge p3.8xlarge) 97 | params=( 98 | "--gpus=4" 99 | "--gpus-min=4 --gpus-max=4" 100 | ) 101 | echo "${expected[*]}" | execute_test "4 GPUs" "${params[@]}" 102 | 103 | 104 | expected=(p2.16xlarge) 105 | params=( 106 | "--vcpus-to-memory-ratio=1:12" 107 | ) 108 | echo "${expected[*]}" | execute_test "1:12 vcpus-to-memory-ratio" "${params[@]}" 109 | 110 | 111 | expected=(p2.8xlarge) 112 | params=( 113 | "--gpu-memory-total=96" 114 | "--gpu-memory-total=96gb" 115 | "--gpu-memory-total=96GiB" 116 | "--gpu-memory-total=98304m" 117 | "--gpu-memory-total-min=96 --gpu-memory-total-max=96" 118 | ) 119 | echo "${expected[*]}" | execute_test "96 GiB gpu-memory-total" "${params[@]}" 120 | 121 | 122 | expected=(a1.large c3.large c4.large c5.large c5a.large c5ad.large c5d.large c5n.large c6a.large c6g.large c6gd.large \ 123 | c6gn.large c6i.large c6id.large c6in.large c7a.large c7g.large c7gd.large c7gn.large c7i-flex.large c7i.large \ 124 | c8g.large d3.8xlarge d3en.12xlarge g4ad.4xlarge g4dn.2xlarge g4dn.4xlarge g4dn.xlarge i3.large i3en.large i4g.large \ 125 | i4i.large i7ie.large i8g.large im4gn.large is4gen.large m1.large m3.large m5.large m5a.large) 126 | params=( 127 | "--network-interfaces=3" 128 | "--network-interfaces-min=3 --network-interfaces-max=3" 129 | "--network-interfaces 3 --memory-min=1 --vcpus-min=1" 130 | ) 131 | echo "${expected[*]}" | execute_test "3 network interfaces" "${params[@]}" 132 | 133 | 134 | expected=(c5n.18xlarge c5n.metal g4dn.metal i3en.24xlarge i3en.metal inf1.24xlarge m5dn.24xlarge m5n.24xlarge p3dn.24xlarge r5dn.24xlarge r5n.24xlarge) 135 | params=( 136 | "--network-performance=100" 137 | "--network-performance-min=100 --network-performance-max=100" 138 | "--network-performance=100 --vcpus-min 1 --memory-min=1" 139 | ) 140 | echo "${expected[*]}" | execute_test "100 Gib/s Networking Performance" "${params[@]}" 141 | 142 | expected=(t3.micro) 143 | params=( 144 | "--allow-list=^t3\.micro$" 145 | "--allow-list=t3.micro" 146 | "--allow-list=t[03].mic" 147 | "--allow-list=t3.mi" 148 | ) 149 | echo "${expected[*]}" | execute_test "Allow List" "${params[@]}" 150 | 151 | expected=(t1.micro t2.micro t3.micro t3a.micro) 152 | params=( 153 | "--deny-list=^[a-z].*\.[0-9]*(sm|me|la|na|xl).*" 154 | "--deny-list=^[a-z].*\.[0-9]*(sm|me|la|na|xl).* --allow-list=t.*" 155 | ) 156 | echo "${expected[*]}" | execute_test "Deny List" "${params[@]}" 157 | 158 | 159 | expected=(t2.micro t2.nano t2.small) 160 | params=( 161 | "--burst-support --vcpus-max=1" 162 | "--burst-support --vcpus-max=1 --hypervisor=xen" 163 | "--burst-support --vcpus-max=1 --hibernation-support" 164 | "--burst-support --vcpus-max=1 --usage-class=on-demand" 165 | ) 166 | echo "${expected[*]}" | execute_test "Burst Support" "${params[@]}" 167 | 168 | 169 | expected=(f1.16xlarge f1.2xlarge f1.4xlarge) 170 | params=( 171 | "--fpga-support" 172 | "--fpga-support --hypervisor=xen" 173 | "--fpga-support --cpu-architecture=x86_64" 174 | "--fpga-support --vcpus-min 1" 175 | ) 176 | echo "${expected[*]}" | execute_test "FPGAs" "${params[@]}" 177 | 178 | 179 | 180 | if [[ -f "${TEST_FAILURES_LOG}" ]]; then 181 | echo -e "\n\n\n=========================== FAILURE SUMMARY ===========================\n" 182 | cat "${TEST_FAILURES_LOG}" 183 | echo -e "\n========================= END FAILURE SUMMARY =========================" 184 | exit 1 185 | fi 186 | -------------------------------------------------------------------------------- /test/license-test/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/docker/library/golang:1.23 2 | 3 | WORKDIR /app 4 | 5 | COPY license-config.hcl . 6 | ARG GOPROXY="https://proxy.golang.org,direct" 7 | RUN GO111MODULE=on go install github.com/mitchellh/golicense@v0.2.0 8 | 9 | CMD $GOPATH/bin/golicense 10 | -------------------------------------------------------------------------------- /test/license-test/check-licenses.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | ROOT_PATH="$( cd "$(dirname "$0")" ; pwd -P )/../../" 5 | EXIT_CODE=0 6 | 7 | while read pkg_license_tuple; do 8 | pkg=$(echo $pkg_license_tuple | tr -s " " | cut -d" " -f1) 9 | license=$(echo $pkg_license_tuple | tr -s " " | cut -d" " -f2-) 10 | if [[ "$(grep -c ${pkg} ${ROOT_PATH}/THIRD_PARTY_LICENSES)" -ge 1 ]]; then 11 | echo "✅ FOUND ${pkg} (${license})" 12 | else 13 | echo "🔴 MISSING for ${pkg} (${license})" 14 | EXIT_CODE=1 15 | fi 16 | done < "${1:-/dev/stdin}" 17 | 18 | echo "=================================================" 19 | if [[ "${EXIT_CODE}" -eq 0 ]]; then 20 | echo "✅ TEST PASSED" 21 | else 22 | echo "🔴 TEST FAILED" 23 | fi 24 | 25 | exit $EXIT_CODE 26 | -------------------------------------------------------------------------------- /test/license-test/gen-license-report.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" 5 | BUILD_DIR="$SCRIPTPATH/../../build" 6 | mkdir -p $BUILD_DIR 7 | GOBIN=$(go env GOPATH | sed 's+:+/bin+g')/bin 8 | export PATH="$PATH:$GOBIN" 9 | 10 | go install github.com/mitchellh/golicense@v0.2.0 11 | go build -o $BUILD_DIR/aeis $SCRIPTPATH/../../. 12 | golicense -out-xlsx=$BUILD_DIR/report.xlsx $SCRIPTPATH/license-config.hcl $BUILD_DIR/aeis 13 | 14 | -------------------------------------------------------------------------------- /test/license-test/license-config.hcl: -------------------------------------------------------------------------------- 1 | allow = [ 2 | "MIT", 3 | "Apache-2.0", 4 | "BSD-3-Clause", 5 | "BSD-2-Clause", 6 | "ISC", 7 | "MPL-2.0" 8 | ] 9 | deny = [ 10 | "GNU General Public License v2.0" 11 | ] 12 | translate = { 13 | 14 | } 15 | override = { 16 | "sigs.k8s.io/yaml" = "MIT", 17 | "github.com/gogo/protobuf" = "BSD-3-Clause" 18 | "github.com/russross/blackfriday" = "BSD-2-Clause" 19 | "github.com/ghodss/yaml" = "MIT" 20 | "github.com/aws/amazon-ec2-instance-selector" = "Apache-2.0" 21 | } 22 | -------------------------------------------------------------------------------- /test/license-test/run-license-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" 5 | BUILD_PATH="$SCRIPTPATH/../../build" 6 | BUILD_BIN="$BUILD_PATH/bin" 7 | 8 | BINARY_NAME="ec2-instance-selector-linux-amd64" 9 | LICENSE_TEST_TAG="aeis-license-test" 10 | LICENSE_REPORT_FILE="$BUILD_PATH/license-report" 11 | GOPROXY="direct|https://proxy.golang.org" 12 | 13 | SUPPORTED_PLATFORMS_LINUX="linux/amd64" make -s -f $SCRIPTPATH/../../Makefile build-binaries 14 | docker buildx build --load --build-arg=GOPROXY=${GOPROXY} -t $LICENSE_TEST_TAG $SCRIPTPATH/ 15 | docker run -i -e GITHUB_TOKEN --rm -v $SCRIPTPATH/:/test -v $BUILD_BIN/:/aeis-bin $LICENSE_TEST_TAG golicense /test/license-config.hcl /aeis-bin/$BINARY_NAME | tee $LICENSE_REPORT_FILE 16 | $SCRIPTPATH/check-licenses.sh $LICENSE_REPORT_FILE 17 | -------------------------------------------------------------------------------- /test/readme-test/readme-codeblocks.go: -------------------------------------------------------------------------------- 1 | // Licensed under the Apache License, Version 2.0 (the "License"); 2 | // you may not use this file except in compliance with the License. 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "bufio" 19 | "encoding/json" 20 | "flag" 21 | "fmt" 22 | "log" 23 | "os" 24 | "strings" 25 | ) 26 | 27 | // CodeBlock models the rundoc codeblock output. 28 | type CodeBlock struct { 29 | Code string `json:"code"` 30 | Interpreter string `json:"interpreter"` 31 | Runs []string `json:"Runs"` 32 | Tags []string `json:"tags"` 33 | } 34 | 35 | // RunDoc is the outer model for rundocs output. 36 | type RunDoc struct { 37 | CodeBlocks []CodeBlock `json:"code_blocks"` 38 | } 39 | 40 | // main takes a rundoc style report parsed from a README file and compares against actual file contents from the tag. 41 | // This is useful to ensure that code examples in the readme are up-to-date with actual example go source files 42 | // If they are in-sync, the source files are executed to make sure the functionality also works. 43 | func main() { 44 | currentDir := flag.String("current-dir", "", "The current dir this script is called from") 45 | flag.Parse() 46 | scanner := bufio.NewScanner(os.Stdin) 47 | var cb strings.Builder 48 | cb.Grow(32) 49 | for scanner.Scan() { 50 | fmt.Fprintf(&cb, "%s", scanner.Text()) 51 | } 52 | if err := scanner.Err(); err != nil { 53 | log.Println(err) 54 | } 55 | codeBlocksJSON := cb.String() 56 | runDoc := RunDoc{} 57 | if err := json.Unmarshal([]byte(codeBlocksJSON), &runDoc); err != nil { 58 | log.Fatal(err) 59 | } 60 | for _, codeBlock := range runDoc.CodeBlocks { 61 | code := codeBlock.Code 62 | tags := removeFromSlice(codeBlock.Tags, []string{codeBlock.Interpreter}) 63 | codeFileDir := fmt.Sprintf("%s/../../%s", *currentDir, tags[0]) 64 | 65 | switch codeBlock.Interpreter { 66 | case "go": 67 | if !compareBlockWithFile(code, codeFileDir) { 68 | log.Fatalf("Code Block found in README.md does not match corresponding source file: %s", codeFileDir) 69 | } 70 | } 71 | } 72 | } 73 | 74 | func compareBlockWithFile(codeBlock string, codePath string) bool { 75 | fileContents, err := os.ReadFile(codePath) 76 | if err != nil { 77 | log.Fatalf("Unable to read file contents at %s", codePath) 78 | } 79 | fileContentStr := removeWhitespace(string(fileContents)) 80 | codeBlock = removeWhitespace(string(codeBlock)) 81 | return fileContentStr == codeBlock 82 | } 83 | 84 | func removeFromSlice(original []string, removals []string) []string { 85 | newSlice := []string{} 86 | for i, element := range original { 87 | for _, removal := range removals { 88 | if removal == element { 89 | newSlice = append(original[:i], original[i+1:]...) 90 | } 91 | } 92 | } 93 | return newSlice 94 | } 95 | 96 | func removeWhitespace(original string) string { 97 | removed := strings.ReplaceAll(original, " ", "") 98 | removed = strings.ReplaceAll(removed, "\t", "") 99 | return strings.ReplaceAll(removed, "\n", "") 100 | } 101 | -------------------------------------------------------------------------------- /test/readme-test/run-readme-codeblocks: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo errexit 3 | 4 | SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" 5 | BUILD_DIR="$SCRIPTPATH/../../build" 6 | OS=$(uname | tr '[:upper:]' '[:lower:]') 7 | 8 | function exit_and_fail() { 9 | echo "❌ Test Failed! README is not consistent with code examples." 10 | exit 1 11 | } 12 | trap exit_and_fail INT ERR 13 | docker build --target=builder -t codeblocks -f $SCRIPTPATH/../../Dockerfile $SCRIPTPATH/../../ 14 | docker build -t rundoc -f $SCRIPTPATH/rundoc-Dockerfile $SCRIPTPATH/ 15 | function rd() { 16 | docker run -i --rm -v $SCRIPTPATH/../../:/aeis rundoc rundoc "$@" 17 | } 18 | 19 | rundoc_output=$(rd list-blocks /aeis/README.md) 20 | example_files=($(echo $rundoc_output | jq -r '.code_blocks[] | .tags[1]')) 21 | interpreters=($(echo $rundoc_output | jq -r '.code_blocks[] | .interpreter')) 22 | 23 | ## Execute --help check which compares the help codeblock in the README to the actual output of the binary 24 | rd list-blocks -T "bash#help" /aeis/README.md | jq -r '.code_blocks[0] .code' > $BUILD_DIR/readme_help.out 25 | docker run -t --rm codeblocks build/ec2-instance-selector --help | perl -pe 's/\e\[?.*?[a-zA-Z]//g' > $BUILD_DIR/actual_help.out 26 | diff --ignore-all-space --ignore-blank-lines --ignore-trailing-space "$BUILD_DIR/actual_help.out" "$BUILD_DIR/readme_help.out" 27 | echo "✅ README help section matches actual binary output!" 28 | 29 | ## Execute go codeblocks example tests which checks the go codeblocks in the readme with a source file path 30 | echo $rundoc_output | docker run -i --rm codeblocks go run test/readme-test/readme-codeblocks.go --current-dir /amazon-ec2-instance-selector/test/readme-test/ 31 | echo "✅ Codeblocks match source files" 32 | 33 | for i in "${!example_files[@]}"; do 34 | if [[ "${interpreters[$i]}" == "go" ]]; then 35 | example_file="${example_files[$i]}" 36 | example_bin=$(echo $example_file | cut -d'.' -f1) 37 | mkdir -p $BUILD_DIR/examples 38 | docker run -i -e GOOS=$OS -e GOARCH=amd64 -e CGO_ENABLED=0 -v $BUILD_DIR:/amazon-ec2-instance-selector/build --rm codeblocks go build -o build/examples/$example_bin $example_file 39 | $BUILD_DIR/examples/$example_bin 40 | echo "✅ $example_file Executed Successfully!" 41 | fi 42 | done 43 | -------------------------------------------------------------------------------- /test/readme-test/run-readme-spellcheck: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" 5 | 6 | function exit_and_fail() { 7 | echo "❌ Test Failed! Found a markdown file with spelling errors." 8 | exit 1 9 | } 10 | trap exit_and_fail INT ERR TERM 11 | 12 | docker build -t misspell -f $SCRIPTPATH/spellcheck-Dockerfile $SCRIPTPATH/ 13 | docker run -i --rm -v $SCRIPTPATH/../../:/aeis misspell /bin/bash -c 'find /aeis/ -type f -name "*.md" -not -path "build" | grep -v "/build/" | xargs misspell -error -debug' 14 | echo "✅ Markdown file spell check passed!" 15 | -------------------------------------------------------------------------------- /test/readme-test/rundoc-Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | 3 | 4 | RUN pip3 install rundoc 5 | 6 | RUN touch /bin/go && chmod +x /bin/go 7 | 8 | CMD [ "rundoc" "--help" ] 9 | -------------------------------------------------------------------------------- /test/readme-test/spellcheck-Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23 2 | 3 | RUN go install github.com/client9/misspell/cmd/misspell@v0.3.4 4 | 5 | CMD [ "/go/bin/misspell" ] -------------------------------------------------------------------------------- /test/shellcheck/run-shellcheck: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" 6 | BUILD_DIR="${SCRIPTPATH}/../../build" 7 | 8 | KERNEL=$(uname -s | tr '[:upper:]' '[:lower:]') 9 | SHELLCHECK_VERSION="0.7.1" 10 | 11 | function exit_and_fail() { 12 | echo "❌ Test Failed! Found a shell script with errors." 13 | exit 1 14 | } 15 | trap exit_and_fail INT ERR TERM 16 | 17 | curl -Lo ${BUILD_DIR}/shellcheck.tar.xz https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.${KERNEL}.x86_64.tar.xz 18 | tar -C ${BUILD_DIR} -xvf "${BUILD_DIR}/shellcheck.tar.xz" 19 | export PATH="${BUILD_DIR}/shellcheck-v${SHELLCHECK_VERSION}:$PATH" 20 | 21 | shellcheck -S error $(grep -Rn -e '#!.*/bin/bash' -e '#!.*/usr/bin/env bash' ${SCRIPTPATH}/../../ | grep ":[1-5]:" | cut -d':' -f1) 22 | 23 | echo "✅ All shell scripts look good! 😎" 24 | -------------------------------------------------------------------------------- /test/static/DescribeAvailabilityZones/us-east-2.json: -------------------------------------------------------------------------------- 1 | { 2 | "AvailabilityZones": [ 3 | { 4 | "State": "available", 5 | "OptInStatus": "opt-in-not-required", 6 | "Messages": [], 7 | "RegionName": "us-east-2", 8 | "ZoneName": "us-east-2a", 9 | "ZoneId": "use2-az1", 10 | "GroupName": "us-east-2", 11 | "NetworkBorderGroup": "us-east-2" 12 | }, 13 | { 14 | "State": "available", 15 | "OptInStatus": "opt-in-not-required", 16 | "Messages": [], 17 | "RegionName": "us-east-2", 18 | "ZoneName": "us-east-2b", 19 | "ZoneId": "use2-az2", 20 | "GroupName": "us-east-2", 21 | "NetworkBorderGroup": "us-east-2" 22 | }, 23 | { 24 | "State": "available", 25 | "OptInStatus": "opt-in-not-required", 26 | "Messages": [], 27 | "RegionName": "us-east-2", 28 | "ZoneName": "us-east-2c", 29 | "ZoneId": "use2-az3", 30 | "GroupName": "us-east-2", 31 | "NetworkBorderGroup": "us-east-2" 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /test/static/DescribeInstanceTypeOfferings/empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "InstanceTypeOfferings": [] 3 | } -------------------------------------------------------------------------------- /test/static/DescribeInstanceTypeOfferings/us-east-2a_only_c5d12x.json: -------------------------------------------------------------------------------- 1 | { 2 | "InstanceTypeOfferings": [ 3 | { 4 | "LocationType": "availability-zone", 5 | "InstanceType": "c5d.12xlarge", 6 | "Location": "us-east-2a" 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /test/static/DescribeInstanceTypeOfferings/us-east-2b.json: -------------------------------------------------------------------------------- 1 | { 2 | "InstanceTypeOfferings": [ 3 | { 4 | "LocationType": "availability-zone", 5 | "InstanceType": "m5.12xlarge", 6 | "Location": "us-east-2z" 7 | }, 8 | { 9 | "LocationType": "availability-zone", 10 | "InstanceType": "r4.xlarge", 11 | "Location": "us-east-2z" 12 | }, 13 | { 14 | "LocationType": "availability-zone", 15 | "InstanceType": "r5ad.xlarge", 16 | "Location": "us-east-2z" 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /test/static/DescribeInstanceTypes/c4_2xlarge.json: -------------------------------------------------------------------------------- 1 | { 2 | "InstanceTypes": [ 3 | { 4 | "AutoRecoverySupported": true, 5 | "BareMetal": false, 6 | "BurstablePerformanceSupported": false, 7 | "CurrentGeneration": true, 8 | "DedicatedHostsSupported": true, 9 | "EbsInfo": { 10 | "EbsOptimizedInfo": { 11 | "BaselineBandwidthInMbps": 1000, 12 | "BaselineIops": 8000, 13 | "BaselineThroughputInMBps": 125, 14 | "MaximumBandwidthInMbps": 1000, 15 | "MaximumIops": 8000, 16 | "MaximumThroughputInMBps": 125 17 | }, 18 | "EbsOptimizedSupport": "default", 19 | "EncryptionSupport": "supported" 20 | }, 21 | "FpgaInfo": null, 22 | "FreeTierEligible": false, 23 | "GpuInfo": null, 24 | "HibernationSupported": true, 25 | "Hypervisor": "xen", 26 | "InferenceAcceleratorInfo": null, 27 | "InstanceStorageInfo": null, 28 | "InstanceStorageSupported": false, 29 | "InstanceType": "c4.2xlarge", 30 | "MemoryInfo": { 31 | "SizeInMiB": 15360 32 | }, 33 | "NetworkInfo": { 34 | "EfaSupported": false, 35 | "EnaSupport": "unsupported", 36 | "Ipv4AddressesPerInterface": 15, 37 | "Ipv6AddressesPerInterface": 15, 38 | "Ipv6Supported": true, 39 | "MaximumNetworkInterfaces": 4, 40 | "NetworkPerformance": "High" 41 | }, 42 | "PlacementGroupInfo": { 43 | "SupportedStrategies": [ 44 | "cluster", 45 | "partition", 46 | "spread" 47 | ] 48 | }, 49 | "ProcessorInfo": { 50 | "SupportedArchitectures": [ 51 | "x86_64" 52 | ], 53 | "SustainedClockSpeedInGhz": 2.9 54 | }, 55 | "SupportedRootDeviceTypes": [ 56 | "ebs" 57 | ], 58 | "SupportedUsageClasses": [ 59 | "on-demand", 60 | "spot" 61 | ], 62 | "SupportedVirtualizationTypes": [ 63 | "hvm" 64 | ], 65 | "VCpuInfo": { 66 | "DefaultCores": 4, 67 | "DefaultThreadsPerCore": 2, 68 | "DefaultVCpus": 8, 69 | "ValidCores": [ 70 | 1, 71 | 2, 72 | 3, 73 | 4 74 | ], 75 | "ValidThreadsPerCore": [ 76 | 1, 77 | 2 78 | ] 79 | } 80 | } 81 | ] 82 | } -------------------------------------------------------------------------------- /test/static/DescribeInstanceTypes/c4_large.json: -------------------------------------------------------------------------------- 1 | { 2 | "InstanceTypes": [ 3 | { 4 | "AutoRecoverySupported": true, 5 | "BareMetal": false, 6 | "BurstablePerformanceSupported": false, 7 | "CurrentGeneration": true, 8 | "DedicatedHostsSupported": true, 9 | "EbsInfo": { 10 | "EbsOptimizedSupport": "default", 11 | "EncryptionSupport": "supported" 12 | }, 13 | "FpgaInfo": null, 14 | "FreeTierEligible": false, 15 | "GpuInfo": null, 16 | "HibernationSupported": true, 17 | "Hypervisor": "xen", 18 | "InferenceAcceleratorInfo": null, 19 | "InstanceStorageInfo": null, 20 | "InstanceStorageSupported": false, 21 | "InstanceType": "c4.large", 22 | "MemoryInfo": { 23 | "SizeInMiB": 3840 24 | }, 25 | "NetworkInfo": { 26 | "EnaSupport": "unsupported", 27 | "Ipv4AddressesPerInterface": 10, 28 | "Ipv6AddressesPerInterface": 10, 29 | "Ipv6Supported": true, 30 | "MaximumNetworkInterfaces": 3, 31 | "NetworkPerformance": "Moderate" 32 | }, 33 | "PlacementGroupInfo": { 34 | "SupportedStrategies": [ 35 | "cluster", 36 | "partition", 37 | "spread" 38 | ] 39 | }, 40 | "ProcessorInfo": { 41 | "SupportedArchitectures": [ 42 | "x86_64" 43 | ], 44 | "SustainedClockSpeedInGhz": 2.9 45 | }, 46 | "SupportedRootDeviceTypes": [ 47 | "ebs" 48 | ], 49 | "SupportedUsageClasses": [ 50 | "on-demand", 51 | "spot" 52 | ], 53 | "VCpuInfo": { 54 | "DefaultCores": 1, 55 | "DefaultThreadsPerCore": 2, 56 | "DefaultVCpus": 2, 57 | "ValidCores": [ 58 | 1 59 | ], 60 | "ValidThreadsPerCore": [ 61 | 1, 62 | 2 63 | ] 64 | } 65 | } 66 | ] 67 | } -------------------------------------------------------------------------------- /test/static/DescribeInstanceTypes/empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "InstanceTypes": [] 3 | } -------------------------------------------------------------------------------- /test/static/DescribeInstanceTypes/g2_2xlarge.json: -------------------------------------------------------------------------------- 1 | { 2 | "InstanceTypes": [ 3 | { 4 | "AutoRecoverySupported": false, 5 | "BareMetal": false, 6 | "BurstablePerformanceSupported": false, 7 | "CurrentGeneration": false, 8 | "DedicatedHostsSupported": true, 9 | "EbsInfo": { 10 | "EbsOptimizedSupport": "supported", 11 | "EncryptionSupport": "supported" 12 | }, 13 | "FpgaInfo": null, 14 | "FreeTierEligible": false, 15 | "GpuInfo": { 16 | "Gpus": [ 17 | { 18 | "Count": 1, 19 | "Manufacturer": "NVIDIA", 20 | "MemoryInfo": { 21 | "SizeInMiB": 4096 22 | }, 23 | "Name": "K520" 24 | } 25 | ], 26 | "TotalGpuMemoryInMiB": 4096 27 | }, 28 | "HibernationSupported": false, 29 | "Hypervisor": "xen", 30 | "InferenceAcceleratorInfo": null, 31 | "InstanceStorageInfo": { 32 | "Disks": [ 33 | { 34 | "Count": 1, 35 | "SizeInGB": 60, 36 | "Type": "ssd" 37 | } 38 | ], 39 | "TotalSizeInGB": 60 40 | }, 41 | "InstanceStorageSupported": true, 42 | "InstanceType": "g2.2xlarge", 43 | "MemoryInfo": { 44 | "SizeInMiB": 15360 45 | }, 46 | "NetworkInfo": { 47 | "EnaSupport": "unsupported", 48 | "Ipv4AddressesPerInterface": 15, 49 | "Ipv6AddressesPerInterface": 0, 50 | "Ipv6Supported": false, 51 | "MaximumNetworkInterfaces": 4, 52 | "NetworkPerformance": "Moderate" 53 | }, 54 | "PlacementGroupInfo": { 55 | "SupportedStrategies": [ 56 | "cluster", 57 | "partition", 58 | "spread" 59 | ] 60 | }, 61 | "ProcessorInfo": { 62 | "SupportedArchitectures": [ 63 | "x86_64" 64 | ], 65 | "SustainedClockSpeedInGhz": 2.6 66 | }, 67 | "SupportedRootDeviceTypes": [ 68 | "ebs", 69 | "instance-store" 70 | ], 71 | "SupportedUsageClasses": [ 72 | "on-demand" 73 | ], 74 | "VCpuInfo": { 75 | "DefaultCores": 4, 76 | "DefaultThreadsPerCore": 2, 77 | "DefaultVCpus": 8, 78 | "ValidCores": [ 79 | 1, 80 | 2, 81 | 3, 82 | 4 83 | ], 84 | "ValidThreadsPerCore": [ 85 | 1, 86 | 2 87 | ] 88 | } 89 | } 90 | ] 91 | } -------------------------------------------------------------------------------- /test/static/DescribeInstanceTypes/m4_xlarge.json: -------------------------------------------------------------------------------- 1 | { 2 | "InstanceTypes": [ 3 | { 4 | "FreeTierEligible": false, 5 | "InstanceStorageSupported": false, 6 | "Hypervisor": "xen", 7 | "PlacementGroupInfo": { 8 | "SupportedStrategies": [ 9 | "cluster", 10 | "partition", 11 | "spread" 12 | ] 13 | }, 14 | "SupportedUsageClasses": [ 15 | "on-demand", 16 | "spot" 17 | ], 18 | "MemoryInfo": { 19 | "SizeInMiB": 16384 20 | }, 21 | "CurrentGeneration": true, 22 | "DedicatedHostsSupported": true, 23 | "VCpuInfo": { 24 | "ValidThreadsPerCore": [ 25 | 1, 26 | 2 27 | ], 28 | "DefaultCores": 2, 29 | "DefaultVCpus": 4, 30 | "ValidCores": [ 31 | 1, 32 | 2 33 | ], 34 | "DefaultThreadsPerCore": 2 35 | }, 36 | "ProcessorInfo": { 37 | "SupportedArchitectures": [ 38 | "x86_64" 39 | ], 40 | "SustainedClockSpeedInGhz": 2.4 41 | }, 42 | "BareMetal": false, 43 | "AutoRecoverySupported": true, 44 | "NetworkInfo": { 45 | "NetworkPerformance": "High", 46 | "MaximumNetworkInterfaces": 4, 47 | "Ipv6Supported": true, 48 | "Ipv6AddressesPerInterface": 15, 49 | "EnaSupport": "unsupported", 50 | "Ipv4AddressesPerInterface": 15 51 | }, 52 | "SupportedRootDeviceTypes": [ 53 | "ebs" 54 | ], 55 | "EbsInfo": { 56 | "EbsOptimizedSupport": "default", 57 | "EncryptionSupport": "supported" 58 | }, 59 | "HibernationSupported": true, 60 | "BurstablePerformanceSupported": false, 61 | "InstanceType": "m4.xlarge" 62 | } 63 | ] 64 | } -------------------------------------------------------------------------------- /test/static/DescribeInstanceTypes/t3_micro.json: -------------------------------------------------------------------------------- 1 | { 2 | "InstanceTypes": [ 3 | { 4 | "FreeTierEligible": false, 5 | "InstanceStorageSupported": false, 6 | "Hypervisor": "nitro", 7 | "PlacementGroupInfo": { 8 | "SupportedStrategies": [ 9 | "partition", 10 | "spread" 11 | ] 12 | }, 13 | "SupportedUsageClasses": [ 14 | "on-demand", 15 | "spot" 16 | ], 17 | "MemoryInfo": { 18 | "SizeInMiB": 1024 19 | }, 20 | "CurrentGeneration": true, 21 | "DedicatedHostsSupported": true, 22 | "VCpuInfo": { 23 | "ValidThreadsPerCore": [ 24 | 1, 25 | 2 26 | ], 27 | "DefaultCores": 1, 28 | "DefaultVCpus": 2, 29 | "ValidCores": [ 30 | 1 31 | ], 32 | "DefaultThreadsPerCore": 2 33 | }, 34 | "ProcessorInfo": { 35 | "SupportedArchitectures": [ 36 | "x86_64" 37 | ], 38 | "SustainedClockSpeedInGhz": 2.5 39 | }, 40 | "BareMetal": false, 41 | "AutoRecoverySupported": true, 42 | "NetworkInfo": { 43 | "NetworkPerformance": "Up to 5 Gigabit", 44 | "MaximumNetworkInterfaces": 2, 45 | "Ipv6Supported": true, 46 | "Ipv6AddressesPerInterface": 2, 47 | "EnaSupport": "required", 48 | "Ipv4AddressesPerInterface": 2 49 | }, 50 | "SupportedRootDeviceTypes": [ 51 | "ebs" 52 | ], 53 | "EbsInfo": { 54 | "EbsOptimizedSupport": "default", 55 | "EncryptionSupport": "supported" 56 | }, 57 | "HibernationSupported": false, 58 | "BurstablePerformanceSupported": true, 59 | "InstanceType": "t3.micro" 60 | } 61 | ] 62 | } -------------------------------------------------------------------------------- /test/static/DescribeInstanceTypes/t3_micro_and_p3_16xl.json: -------------------------------------------------------------------------------- 1 | { 2 | "InstanceTypes": [ 3 | { 4 | "FreeTierEligible": false, 5 | "InstanceStorageSupported": false, 6 | "Hypervisor": "nitro", 7 | "PlacementGroupInfo": { 8 | "SupportedStrategies": [ 9 | "partition", 10 | "spread" 11 | ] 12 | }, 13 | "SupportedUsageClasses": [ 14 | "on-demand", 15 | "spot" 16 | ], 17 | "MemoryInfo": { 18 | "SizeInMiB": 1024 19 | }, 20 | "CurrentGeneration": true, 21 | "DedicatedHostsSupported": true, 22 | "VCpuInfo": { 23 | "ValidThreadsPerCore": [ 24 | 1, 25 | 2 26 | ], 27 | "DefaultCores": 1, 28 | "DefaultVCpus": 2, 29 | "ValidCores": [ 30 | 1 31 | ], 32 | "DefaultThreadsPerCore": 2 33 | }, 34 | "ProcessorInfo": { 35 | "SupportedArchitectures": [ 36 | "x86_64" 37 | ], 38 | "SustainedClockSpeedInGhz": 2.5 39 | }, 40 | "BareMetal": false, 41 | "AutoRecoverySupported": true, 42 | "NetworkInfo": { 43 | "NetworkPerformance": "Up to 5 Gigabit", 44 | "MaximumNetworkInterfaces": 2, 45 | "Ipv6Supported": true, 46 | "Ipv6AddressesPerInterface": 2, 47 | "EnaSupport": "required", 48 | "Ipv4AddressesPerInterface": 2 49 | }, 50 | "SupportedRootDeviceTypes": [ 51 | "ebs" 52 | ], 53 | "EbsInfo": { 54 | "EbsOptimizedSupport": "default", 55 | "EncryptionSupport": "supported" 56 | }, 57 | "HibernationSupported": false, 58 | "BurstablePerformanceSupported": true, 59 | "InstanceType": "t3.micro" 60 | }, 61 | { 62 | "FreeTierEligible": false, 63 | "InstanceStorageSupported": false, 64 | "Hypervisor": "xen", 65 | "PlacementGroupInfo": { 66 | "SupportedStrategies": [ 67 | "cluster", 68 | "partition", 69 | "spread" 70 | ] 71 | }, 72 | "SupportedUsageClasses": [ 73 | "on-demand", 74 | "spot" 75 | ], 76 | "MemoryInfo": { 77 | "SizeInMiB": 499712 78 | }, 79 | "CurrentGeneration": true, 80 | "GpuInfo": { 81 | "Gpus": [ 82 | { 83 | "Count": 8, 84 | "MemoryInfo": { 85 | "SizeInMiB": 16384 86 | }, 87 | "Name": "V100", 88 | "Manufacturer": "NVIDIA" 89 | } 90 | ], 91 | "TotalGpuMemoryInMiB": 131072 92 | }, 93 | "VCpuInfo": { 94 | "ValidThreadsPerCore": [ 95 | 1, 96 | 2 97 | ], 98 | "DefaultCores": 32, 99 | "DefaultVCpus": 64, 100 | "ValidCores": [ 101 | 2, 102 | 4, 103 | 6, 104 | 8, 105 | 10, 106 | 12, 107 | 14, 108 | 16, 109 | 18, 110 | 20, 111 | 22, 112 | 24, 113 | 26, 114 | 28, 115 | 30, 116 | 32 117 | ], 118 | "DefaultThreadsPerCore": 2 119 | }, 120 | "ProcessorInfo": { 121 | "SupportedArchitectures": [ 122 | "x86_64" 123 | ], 124 | "SustainedClockSpeedInGhz": 2.7 125 | }, 126 | "BareMetal": false, 127 | "AutoRecoverySupported": true, 128 | "NetworkInfo": { 129 | "NetworkPerformance": "25 Gigabit", 130 | "MaximumNetworkInterfaces": 8, 131 | "Ipv6Supported": true, 132 | "Ipv6AddressesPerInterface": 30, 133 | "EnaSupport": "supported", 134 | "Ipv4AddressesPerInterface": 30 135 | }, 136 | "SupportedRootDeviceTypes": [ 137 | "ebs" 138 | ], 139 | "EbsInfo": { 140 | "EbsOptimizedSupport": "default", 141 | "EncryptionSupport": "supported" 142 | }, 143 | "HibernationSupported": false, 144 | "DedicatedHostsSupported": true, 145 | "BurstablePerformanceSupported": false, 146 | "InstanceType": "p3.16xlarge" 147 | } 148 | ] 149 | } -------------------------------------------------------------------------------- /test/static/FilterVerbose/1_instance.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "AutoRecoverySupported": true, 4 | "BareMetal": false, 5 | "BurstablePerformanceSupported": false, 6 | "CurrentGeneration": false, 7 | "DedicatedHostsSupported": true, 8 | "EbsInfo": { 9 | "EbsOptimizedInfo": { 10 | "BaselineBandwidthInMbps": 1750, 11 | "BaselineIops": 10000, 12 | "BaselineThroughputInMBps": 218.75, 13 | "MaximumBandwidthInMbps": 3500, 14 | "MaximumIops": 20000, 15 | "MaximumThroughputInMBps": 437.5 16 | }, 17 | "EbsOptimizedSupport": "default", 18 | "EncryptionSupport": "supported", 19 | "NvmeSupport": "required" 20 | }, 21 | "FpgaInfo": null, 22 | "FreeTierEligible": false, 23 | "GpuInfo": null, 24 | "HibernationSupported": false, 25 | "Hypervisor": "nitro", 26 | "InferenceAcceleratorInfo": null, 27 | "InstanceStorageInfo": null, 28 | "InstanceStorageSupported": false, 29 | "InstanceType": "a1.2xlarge", 30 | "MemoryInfo": { 31 | "SizeInMiB": 16384 32 | }, 33 | "NetworkInfo": { 34 | "DefaultNetworkCardIndex": 0, 35 | "EfaInfo": null, 36 | "EfaSupported": false, 37 | "EnaSupport": "required", 38 | "EncryptionInTransitSupported": false, 39 | "Ipv4AddressesPerInterface": 15, 40 | "Ipv6AddressesPerInterface": 15, 41 | "Ipv6Supported": true, 42 | "MaximumNetworkCards": 1, 43 | "MaximumNetworkInterfaces": 4, 44 | "NetworkCards": [ 45 | { 46 | "MaximumNetworkInterfaces": 4, 47 | "NetworkCardIndex": 0, 48 | "NetworkPerformance": "Up to 10 Gigabit" 49 | } 50 | ], 51 | "NetworkPerformance": "Up to 10 Gigabit" 52 | }, 53 | "PlacementGroupInfo": { 54 | "SupportedStrategies": [ 55 | "cluster", 56 | "partition", 57 | "spread" 58 | ] 59 | }, 60 | "ProcessorInfo": { 61 | "SupportedArchitectures": [ 62 | "arm64" 63 | ], 64 | "SustainedClockSpeedInGhz": 2.3 65 | }, 66 | "SupportedBootModes": [ 67 | "uefi" 68 | ], 69 | "SupportedRootDeviceTypes": [ 70 | "ebs" 71 | ], 72 | "SupportedUsageClasses": [ 73 | "on-demand", 74 | "spot" 75 | ], 76 | "SupportedVirtualizationTypes": [ 77 | "hvm" 78 | ], 79 | "VCpuInfo": { 80 | "DefaultCores": 8, 81 | "DefaultThreadsPerCore": 1, 82 | "DefaultVCpus": 8, 83 | "ValidCores": null, 84 | "ValidThreadsPerCore": null 85 | }, 86 | "OndemandPricePerHour": 0.204, 87 | "SpotPrice": 0.03939999999999999 88 | } 89 | ] 90 | -------------------------------------------------------------------------------- /test/static/FilterVerbose/3_instances.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "AutoRecoverySupported": true, 4 | "BareMetal": false, 5 | "BurstablePerformanceSupported": false, 6 | "CurrentGeneration": false, 7 | "DedicatedHostsSupported": true, 8 | "EbsInfo": { 9 | "EbsOptimizedInfo": { 10 | "BaselineBandwidthInMbps": 1750, 11 | "BaselineIops": 10000, 12 | "BaselineThroughputInMBps": 218.75, 13 | "MaximumBandwidthInMbps": 3500, 14 | "MaximumIops": 20000, 15 | "MaximumThroughputInMBps": 437.5 16 | }, 17 | "EbsOptimizedSupport": "default", 18 | "EncryptionSupport": "supported", 19 | "NvmeSupport": "required" 20 | }, 21 | "FpgaInfo": null, 22 | "FreeTierEligible": false, 23 | "GpuInfo": null, 24 | "HibernationSupported": false, 25 | "Hypervisor": "nitro", 26 | "InferenceAcceleratorInfo": null, 27 | "InstanceStorageInfo": null, 28 | "InstanceStorageSupported": false, 29 | "InstanceType": "a1.2xlarge", 30 | "MemoryInfo": { 31 | "SizeInMiB": 16384 32 | }, 33 | "NetworkInfo": { 34 | "DefaultNetworkCardIndex": 0, 35 | "EfaInfo": null, 36 | "EfaSupported": false, 37 | "EnaSupport": "required", 38 | "EncryptionInTransitSupported": false, 39 | "Ipv4AddressesPerInterface": 15, 40 | "Ipv6AddressesPerInterface": 15, 41 | "Ipv6Supported": true, 42 | "MaximumNetworkCards": 1, 43 | "MaximumNetworkInterfaces": 4, 44 | "NetworkCards": [ 45 | { 46 | "MaximumNetworkInterfaces": 4, 47 | "NetworkCardIndex": 0, 48 | "NetworkPerformance": "Up to 10 Gigabit" 49 | } 50 | ], 51 | "NetworkPerformance": "Up to 10 Gigabit" 52 | }, 53 | "PlacementGroupInfo": { 54 | "SupportedStrategies": [ 55 | "cluster", 56 | "partition", 57 | "spread" 58 | ] 59 | }, 60 | "ProcessorInfo": { 61 | "SupportedArchitectures": [ 62 | "arm64" 63 | ], 64 | "SustainedClockSpeedInGhz": 2.3 65 | }, 66 | "SupportedBootModes": [ 67 | "uefi" 68 | ], 69 | "SupportedRootDeviceTypes": [ 70 | "ebs" 71 | ], 72 | "SupportedUsageClasses": [ 73 | "on-demand", 74 | "spot" 75 | ], 76 | "SupportedVirtualizationTypes": [ 77 | "hvm" 78 | ], 79 | "VCpuInfo": { 80 | "DefaultCores": 8, 81 | "DefaultThreadsPerCore": 1, 82 | "DefaultVCpus": 8, 83 | "ValidCores": null, 84 | "ValidThreadsPerCore": null 85 | }, 86 | "OndemandPricePerHour": 0.204, 87 | "SpotPrice": 0.03939999999999999 88 | }, 89 | { 90 | "AutoRecoverySupported": true, 91 | "BareMetal": false, 92 | "BurstablePerformanceSupported": false, 93 | "CurrentGeneration": false, 94 | "DedicatedHostsSupported": true, 95 | "EbsInfo": { 96 | "EbsOptimizedInfo": { 97 | "BaselineBandwidthInMbps": 3500, 98 | "BaselineIops": 20000, 99 | "BaselineThroughputInMBps": 437.5, 100 | "MaximumBandwidthInMbps": 3500, 101 | "MaximumIops": 20000, 102 | "MaximumThroughputInMBps": 437.5 103 | }, 104 | "EbsOptimizedSupport": "default", 105 | "EncryptionSupport": "supported", 106 | "NvmeSupport": "required" 107 | }, 108 | "FpgaInfo": null, 109 | "FreeTierEligible": false, 110 | "GpuInfo": null, 111 | "HibernationSupported": true, 112 | "Hypervisor": "nitro", 113 | "InferenceAcceleratorInfo": null, 114 | "InstanceStorageInfo": null, 115 | "InstanceStorageSupported": false, 116 | "InstanceType": "a1.4xlarge", 117 | "MemoryInfo": { 118 | "SizeInMiB": 32768 119 | }, 120 | "NetworkInfo": { 121 | "DefaultNetworkCardIndex": 0, 122 | "EfaInfo": null, 123 | "EfaSupported": false, 124 | "EnaSupport": "required", 125 | "EncryptionInTransitSupported": false, 126 | "Ipv4AddressesPerInterface": 30, 127 | "Ipv6AddressesPerInterface": 30, 128 | "Ipv6Supported": true, 129 | "MaximumNetworkCards": 1, 130 | "MaximumNetworkInterfaces": 8, 131 | "NetworkCards": [ 132 | { 133 | "MaximumNetworkInterfaces": 8, 134 | "NetworkCardIndex": 0, 135 | "NetworkPerformance": "Up to 10 Gigabit" 136 | } 137 | ], 138 | "NetworkPerformance": "Up to 10 Gigabit" 139 | }, 140 | "PlacementGroupInfo": { 141 | "SupportedStrategies": [ 142 | "cluster", 143 | "partition", 144 | "spread" 145 | ] 146 | }, 147 | "ProcessorInfo": { 148 | "SupportedArchitectures": [ 149 | "arm64" 150 | ], 151 | "SustainedClockSpeedInGhz": 2.3 152 | }, 153 | "SupportedBootModes": [ 154 | "uefi" 155 | ], 156 | "SupportedRootDeviceTypes": [ 157 | "ebs" 158 | ], 159 | "SupportedUsageClasses": [ 160 | "on-demand", 161 | "spot" 162 | ], 163 | "SupportedVirtualizationTypes": [ 164 | "hvm" 165 | ], 166 | "VCpuInfo": { 167 | "DefaultCores": 16, 168 | "DefaultThreadsPerCore": 1, 169 | "DefaultVCpus": 16, 170 | "ValidCores": null, 171 | "ValidThreadsPerCore": null 172 | }, 173 | "OndemandPricePerHour": 0.408, 174 | "SpotPrice": null 175 | }, 176 | { 177 | "AutoRecoverySupported": true, 178 | "BareMetal": false, 179 | "BurstablePerformanceSupported": false, 180 | "CurrentGeneration": false, 181 | "DedicatedHostsSupported": true, 182 | "EbsInfo": { 183 | "EbsOptimizedInfo": { 184 | "BaselineBandwidthInMbps": 525, 185 | "BaselineIops": 4000, 186 | "BaselineThroughputInMBps": 65.625, 187 | "MaximumBandwidthInMbps": 3500, 188 | "MaximumIops": 20000, 189 | "MaximumThroughputInMBps": 437.5 190 | }, 191 | "EbsOptimizedSupport": "default", 192 | "EncryptionSupport": "supported", 193 | "NvmeSupport": "required" 194 | }, 195 | "FpgaInfo": null, 196 | "FreeTierEligible": false, 197 | "GpuInfo": null, 198 | "HibernationSupported": false, 199 | "Hypervisor": "nitro", 200 | "InferenceAcceleratorInfo": null, 201 | "InstanceStorageInfo": null, 202 | "InstanceStorageSupported": false, 203 | "InstanceType": "a1.large", 204 | "MemoryInfo": { 205 | "SizeInMiB": 4096 206 | }, 207 | "NetworkInfo": { 208 | "DefaultNetworkCardIndex": 0, 209 | "EfaInfo": null, 210 | "EfaSupported": false, 211 | "EnaSupport": "required", 212 | "EncryptionInTransitSupported": false, 213 | "Ipv4AddressesPerInterface": 10, 214 | "Ipv6AddressesPerInterface": 10, 215 | "Ipv6Supported": true, 216 | "MaximumNetworkCards": 1, 217 | "MaximumNetworkInterfaces": 3, 218 | "NetworkCards": [ 219 | { 220 | "MaximumNetworkInterfaces": 3, 221 | "NetworkCardIndex": 0, 222 | "NetworkPerformance": "Up to 10 Gigabit" 223 | } 224 | ], 225 | "NetworkPerformance": "Up to 10 Gigabit" 226 | }, 227 | "PlacementGroupInfo": { 228 | "SupportedStrategies": [ 229 | "cluster", 230 | "partition", 231 | "spread" 232 | ] 233 | }, 234 | "ProcessorInfo": { 235 | "SupportedArchitectures": [ 236 | "arm64" 237 | ], 238 | "SustainedClockSpeedInGhz": 2.3 239 | }, 240 | "SupportedBootModes": [ 241 | "uefi" 242 | ], 243 | "SupportedRootDeviceTypes": [ 244 | "ebs" 245 | ], 246 | "SupportedUsageClasses": [ 247 | "on-demand", 248 | "spot" 249 | ], 250 | "SupportedVirtualizationTypes": [ 251 | "hvm" 252 | ], 253 | "VCpuInfo": { 254 | "DefaultCores": 2, 255 | "DefaultThreadsPerCore": 1, 256 | "DefaultVCpus": 2, 257 | "ValidCores": null, 258 | "ValidThreadsPerCore": null 259 | }, 260 | "OndemandPricePerHour": 0.051, 261 | "SpotPrice": 0.009819123023512438 262 | } 263 | ] 264 | -------------------------------------------------------------------------------- /test/static/FilterVerbose/g3_16xlarge.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "AutoRecoverySupported": true, 4 | "BareMetal": false, 5 | "BurstablePerformanceSupported": false, 6 | "CurrentGeneration": true, 7 | "DedicatedHostsSupported": true, 8 | "EbsInfo": { 9 | "EbsOptimizedInfo": { 10 | "BaselineBandwidthInMbps": 14000, 11 | "BaselineIops": 80000, 12 | "BaselineThroughputInMBps": 1750, 13 | "MaximumBandwidthInMbps": 14000, 14 | "MaximumIops": 80000, 15 | "MaximumThroughputInMBps": 1750 16 | }, 17 | "EbsOptimizedSupport": "default", 18 | "EncryptionSupport": "supported", 19 | "NvmeSupport": "unsupported" 20 | }, 21 | "FpgaInfo": null, 22 | "FreeTierEligible": false, 23 | "GpuInfo": { 24 | "Gpus": [ 25 | { 26 | "Count": 4, 27 | "Manufacturer": "NVIDIA", 28 | "MemoryInfo": { 29 | "SizeInMiB": 8192 30 | }, 31 | "Name": "M60" 32 | } 33 | ], 34 | "TotalGpuMemoryInMiB": 32768 35 | }, 36 | "HibernationSupported": false, 37 | "Hypervisor": "xen", 38 | "InferenceAcceleratorInfo": null, 39 | "InstanceStorageInfo": null, 40 | "InstanceStorageSupported": false, 41 | "InstanceType": "g3.16xlarge", 42 | "MemoryInfo": { 43 | "SizeInMiB": 499712 44 | }, 45 | "NetworkInfo": { 46 | "DefaultNetworkCardIndex": 0, 47 | "EfaInfo": null, 48 | "EfaSupported": false, 49 | "EnaSupport": "supported", 50 | "EncryptionInTransitSupported": false, 51 | "Ipv4AddressesPerInterface": 50, 52 | "Ipv6AddressesPerInterface": 50, 53 | "Ipv6Supported": true, 54 | "MaximumNetworkCards": 1, 55 | "MaximumNetworkInterfaces": 15, 56 | "NetworkCards": [ 57 | { 58 | "MaximumNetworkInterfaces": 15, 59 | "NetworkCardIndex": 0, 60 | "NetworkPerformance": "25 Gigabit" 61 | } 62 | ], 63 | "NetworkPerformance": "25 Gigabit" 64 | }, 65 | "PlacementGroupInfo": { 66 | "SupportedStrategies": [ 67 | "cluster", 68 | "partition", 69 | "spread" 70 | ] 71 | }, 72 | "ProcessorInfo": { 73 | "SupportedArchitectures": [ 74 | "x86_64" 75 | ], 76 | "SustainedClockSpeedInGhz": 2.3 77 | }, 78 | "SupportedBootModes": [ 79 | "legacy-bios" 80 | ], 81 | "SupportedRootDeviceTypes": [ 82 | "ebs" 83 | ], 84 | "SupportedUsageClasses": [ 85 | "on-demand", 86 | "spot" 87 | ], 88 | "SupportedVirtualizationTypes": [ 89 | "hvm" 90 | ], 91 | "VCpuInfo": { 92 | "DefaultCores": 32, 93 | "DefaultThreadsPerCore": 2, 94 | "DefaultVCpus": 64, 95 | "ValidCores": [ 96 | 2, 97 | 4, 98 | 6, 99 | 8, 100 | 10, 101 | 12, 102 | 14, 103 | 16, 104 | 18, 105 | 20, 106 | 22, 107 | 24, 108 | 26, 109 | 28, 110 | 30, 111 | 32 112 | ], 113 | "ValidThreadsPerCore": [ 114 | 1, 115 | 2 116 | ] 117 | }, 118 | "OndemandPricePerHour": 4.56, 119 | "SpotPrice": 1.368 120 | } 121 | ] -------------------------------------------------------------------------------- /test/static/GithubEKSAMIRelease/amazon-eks-ami-20210125.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws/amazon-ec2-instance-selector/e37bb0f2adb34c33b0364fc04a81a8d6fc801c88/test/static/GithubEKSAMIRelease/amazon-eks-ami-20210125.zip --------------------------------------------------------------------------------