├── .codeclimate.yml ├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── build.yml │ ├── codeql-analysis.yml │ └── push_to_s3.yml ├── .gitignore ├── BINARY_LICENSE ├── CHANGELOG.md ├── CODEDEPLOY.md ├── CONTRIBUTING.md ├── CUSTOM_BUILDS.md ├── CloudFormation_and_StackSets.md ├── Dockerfile ├── Dockerfile.build ├── Dockerfile.marketplace ├── FAQ.md ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── START.md ├── TECHNICAL_DETAILS.md ├── THIRDPARTY ├── _includes └── youtube.html ├── autospotting.go ├── build └── .keep ├── cloudformation ├── Makefile └── stacks │ └── AutoSpotting │ ├── parameters.yaml │ ├── regional_template.yaml │ └── template.yaml ├── core ├── action.go ├── autoscaling.go ├── autoscaling_configuration.go ├── autoscaling_configuration_test.go ├── autoscaling_test.go ├── beanstalk.go ├── beanstalk_test.go ├── cloudtrail.go ├── config.go ├── config_test.go ├── connections.go ├── connections_test.go ├── instance.go ├── instance_actions.go ├── instance_actions_test.go ├── instance_conversion.go ├── instance_conversion_test.go ├── instance_events.go ├── instance_events_test.go ├── instance_manager.go ├── instance_manager_test.go ├── instance_queries.go ├── instance_queries_test.go ├── launch_configuration.go ├── launch_configuration_test.go ├── launch_template.go ├── launch_template_test.go ├── main.go ├── main_test.go ├── marketplace_metering.go ├── mock_test.go ├── region.go ├── region_test.go ├── schedule.go ├── schedule_test.go ├── spot_price.go ├── spot_price_test.go ├── spot_termination.go ├── spot_termination_test.go ├── util.go └── util_test.go ├── docker-compose.yaml ├── go.mod ├── go.sum ├── kubernetes └── autospotting-cron.yaml.example ├── logo.png ├── test_data ├── beanstalk_userdata_example.txt └── beanstalk_userdata_wrapped_example.txt └── tools.go /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | # golang 4 | gofmt: 5 | enabled: true 6 | golint: 7 | enabled: true 8 | config: 9 | min_confidence: 0.9 10 | govet: 11 | enabled: true 12 | # python, for the lambda wrapper 13 | pep8: 14 | enabled: true 15 | radon: 16 | enabled: true 17 | config: 18 | python_version: 2 19 | duplication: 20 | enabled: true 21 | config: 22 | languages: 23 | - python 24 | # misc 25 | markdownlint: 26 | enabled: true 27 | fixme: 28 | enabled: true 29 | ratings: 30 | paths: 31 | - "**.go" 32 | - "**.py" 33 | - "**.md" 34 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | charset = utf-8 9 | end_of_line = lf 10 | indent_style = space 11 | indent_size = 2 12 | insert_final_newline = true 13 | 14 | # Tab indentation is needed on Makefiles 15 | [Makefile*] 16 | indent_style = tab 17 | indent_size = 4 18 | 19 | [*.py] 20 | indent_size = 4 21 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [cristim] 2 | patreon: cristim 3 | custom: ["https://www.paypal.me/cristim"] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Github issue # 2 | 3 | 7 | 8 | ## Issue type ## 9 | 10 | 11 | 12 | - Bug Report 13 | - Feature Idea 14 | - Documentation Report 15 | 16 | ## Build number # 17 | 18 | 26 | 27 | ```text 28 | ``` 29 | 30 | ## Configuration ## 31 | 32 | 36 | 37 | ## Environment ## 38 | 39 | - AWS region 40 | - Type of environment: (VPC, EC2Classic or DefaultVPC) 41 | - Anonymized launch configuration: 42 | 43 | ```text 44 | ``` 45 | 46 | ## Summary ## 47 | 48 | 49 | 50 | ## Steps to reproduce ## 51 | 52 | 57 | 58 | 59 | 60 | ## Expected results ## 61 | 62 | 63 | 64 | ## Actual results ## 65 | 66 | 67 | 68 | 71 | 72 | ```text 73 | ``` 74 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Issue Type 2 | 3 | 9 | 10 | - Feature Pull Request 11 | - Bugfix Pull Request 12 | - Documentation Pull Request 13 | 14 | ## Summary 15 | 16 | 35 | 36 | ## Code contribution checklist 37 | 38 | 41 | 42 | 1. [ ] I hereby allow the Copyright holder the rights to distribute this piece of 43 | code under any software license. 44 | 1. [ ] The contribution fixes a single existing github issue, and it is linked 45 | to it. 46 | 1. [ ] The code is as simple as possible, readable and follows the idiomatic Go 47 | [guidelines](https://golang.org/doc/effective_go.html). 48 | 1. [ ] All new functionality is covered by automated test cases so the overall 49 | test coverage doesn't decrease. 50 | 1. [ ] No issues are reported when running `make full-test`. 51 | 1. [ ] Functionality not applicable to all users should be configurable. 52 | 1. [ ] Configurations should be exposed through Lambda function environment 53 | variables which are also passed as parameters to the 54 | [CloudFormation](https://github.com/cristim/autospotting/blob/master/cloudformation/stacks/AutoSpotting/template.yaml) 55 | and 56 | [Terraform](https://github.com/autospotting/terraform-aws-autospotting/main.tf) 57 | stacks defined as infrastructure code. 58 | 1. [ ] Global configurations set from the infrastructure stack level should also 59 | support per-group overrides using tags. 60 | 1. [ ] Tags names and expected values should be similar to the other existing 61 | configurations. 62 | 1. [ ] Both global and tag-based configuration mechanisms should be tested and 63 | proven to work using log output from various test runs. 64 | 1. [ ] The logs should be kept as clean as possible (use log levels as 65 | appropriate) and formatted consistently to the existing log output. 66 | 1. [ ] The documentation is updated to cover the new behavior, as well as the 67 | new configuration options for both stack parameters and tag overrides. 68 | 1. [ ] A code reviewer reproduced the problem and can confirm the code 69 | contribution actually resolves it. 70 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build code 2 | 3 | on: [push] 4 | 5 | jobs: 6 | 7 | build: 8 | name: Build 9 | runs-on: ubuntu-latest 10 | steps: 11 | 12 | - name: Set up Go 1.x 13 | uses: actions/setup-go@v2 14 | with: 15 | go-version: ^1.15.4 16 | 17 | - name: Check out code into the Go module directory 18 | uses: actions/checkout@v2 19 | 20 | - name: Build 21 | run: FLAVOR=nightly make ci 22 | 23 | - name: Archive build artifacts 24 | uses: actions/upload-artifact@v2 25 | with: 26 | name: build 27 | path: | 28 | build/s3 29 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '20 1 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'go' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /.github/workflows/push_to_s3.yml: -------------------------------------------------------------------------------- 1 | name: Push nightly to S3 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | 9 | build: 10 | name: Build 11 | runs-on: ubuntu-latest 12 | steps: 13 | 14 | - name: Set up Go 1.x 15 | uses: actions/setup-go@v2 16 | with: 17 | go-version: ^1.15.4 18 | 19 | - name: Check out code into the Go module directory 20 | uses: actions/checkout@v2 21 | 22 | - name: Build 23 | run: FLAVOR=nightly make ci 24 | 25 | - name: Push to S3 26 | uses: shallwefootball/s3-upload-action@master 27 | with: 28 | aws_key_id: ${{ secrets.AWS_KEY_ID }} 29 | aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY}} 30 | aws_bucket: cloudprowess 31 | source_dir: build/s3 32 | destination_dir: '' 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | debug 3 | /autospotting 4 | /AutoSpotting 5 | coverage.out 6 | .vscode/ 7 | handler.zip 8 | *.coverprofile 9 | .idea/ 10 | Makefile.lambda 11 | .history 12 | .DS_Store 13 | -------------------------------------------------------------------------------- /BINARY_LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | 3 | Permission is hereby granted, free of charge, to any person (the "User") 4 | obtaining a copy of the official AutoSpotting binaries and associated 5 | documentation files (the "Software"), to use it for up to 14 days for evaluation 6 | purposes. 7 | 8 | Further use of the Software requires paying a licensing fee. The actual amount 9 | and payment methods are stated in the online documentation of the Software. 10 | 11 | The copyright owner may exempt certain Users from paying the licensing fee if 12 | they contributed significantly to the Software development effort. 13 | 14 | The automated recurrent installation of the Software is only permitted to paying 15 | Users. 16 | 17 | This license allows the Users to copy and distribute the Software, as long as 18 | this license document is included in all copies of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 22 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 23 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 24 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 25 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # ChangeLog 2 | 3 | 4 | 5 | Since 2019 the release notes, potential breaking changes and mitigation 6 | procedures have only been communicated via private channels to the Patreon 7 | subscribers when releasing a new stable version. 8 | 9 | Sign up on [Patreon](https://www.patreon.com/cristim) in order to receive these 10 | release notifications, as well as to get access to these supported stable 11 | versions. In the future certain premium features will only be available in the 12 | stable builds. 13 | 14 | ## 19 September 2018, build 942 15 | 16 | Yet another really beefy entry, but most of this was also captured in much more 17 | detail in this blog 18 | [post](https://mcristi.wordpress.com/2018/07/14/new-autospotting-version/). 19 | 20 | Many thanks to HERE Technologies for supporting a lot of the development effort, 21 | to the Patreon supporters and to everyone who contributed code to this release. 22 | 23 | ### New features since the last update 24 | 25 | - The method by which AutoSpotting terminates existing Auto Scaling Group 26 | instances has changed. By default, AutoSpotting now uses the EC2 27 | `TerminateInstanceInAutoScalingGroup` API. This API call ensures that any 28 | Termination Lifecycle Hooks that might be configured on the Group are 29 | respected, which was not the case in previous AutoSpotting versions. Users who 30 | depend upon the legacy behavior, which was to detach the instance from the 31 | Auto Scaling Group and terminate it, can set `instance_termination_method` to 32 | `detach` in their deployment configurations. 33 | - Better handling of out of capacity situations. 34 | - Better handling of VPC, DefaultVPC and EC2 Classic security groups. 35 | - Support running in opt-out mode. 36 | - Tagging launched spot instances with `launched-by-autospotting=true`. 37 | - Obey the scale-in protection and termination protection for on-demand 38 | instances that previously were being replaced. 39 | - Terraform module in the Terraform Registry. 40 | - Documentation for running as a Kubernetes cron job instead of Lambda. 41 | - Instance type updates and regional expansions. Note: some recent instance 42 | types are still not supported yet due to missing upstream pricing information. 43 | 44 | ### Under the hood changes 45 | 46 | - AutoSpotting now launches spot instances using the `RunInstances` API call, 47 | which allows us to simplify the logic considerably and fix a number of bugs. 48 | - The spot bidding engine was heavily refactored, using less memory and being 49 | much more scalable on large installations. 50 | - Fix compilation of macOS. 51 | - Smaller binaries. 52 | - Build AutoSpotting using Go 1.11 53 | 54 | ## 24 January 2018, build 622 55 | 56 | A lot of time passed since the previous Changelog update, so this is a really 57 | beefy entry, we should definitely do this more often. 58 | 59 | There were a lot of contributions from many individuals, thank you all for 60 | helping improve this project! 61 | 62 | Special thanks once again to @xlr-8 who contributed a lot throughout the 63 | previous year, and is now a very active co-maintainer of the project. 64 | 65 | ### New features since the last update 66 | 67 | - Consider GPUs in the instance compatbility checks 68 | - Instance type updates and regional expansions 69 | - Aggressive bidding strategy based on the current spot price, by @kartik894 70 | - Prevent termination of last instance, by @universam1 71 | - Support volume discounts (think Reserved Instance), by @masneyb 72 | - Instance type whitelisting, by @raravena80 73 | - Instance type blacklisting, by @binarylogic 74 | - Consider the EBS pricing surcharge, by @vecchp 75 | - Deploy using Terraform, by @xlr-8 76 | 77 | ### Under the hood changes 78 | 79 | - Significant refactorings, especially done by @xlr-8 80 | - We're now using the native Lambda Go runtime, again thanks to @xlr-8 81 | - Scalability fixes: concurrency by @thebigjc, API pagination by @ahaverbuch, 82 | support handling more than 50 AutoScaling groups concurrently, by @chaner 83 | - Much more unit test coverage, thanks to @xlr-8 and @artemnikitin 84 | - Buildsystem changes, we now also have automated lint and vet checks 85 | - Dependency vendoring, by @xlr-8 86 | - Lots of bugfixes, special thanks to @xlr-8 87 | - Countless documentation updates by too many individuals to mention here 88 | 89 | ### Breaking changes 90 | 91 | - The CloudFormation stack needs to be updated after build 633, because of the 92 | change to the Go Lambda runtime 93 | - Some of the AutoScaling group tags used to override the global configuration 94 | were renamed to be more consistent, please refer to the current state of the 95 | documentation. 96 | 97 | ~Cristian 98 | 99 | ## 29 December 2016, build 158 100 | 101 | I forgot to update this in a while, so this is a quite big changelog entry. 102 | 103 | I got the first major code contributions by other developers, so from now on the 104 | changelog entries will be split by author. It may also have a header like this 105 | one in which it will contain a short summary or a message from the author. 106 | 107 | Special thanks to Hugo Rosnet, who contributed a lot of code that implemented a 108 | number of major features, helped me with multiple code reviews and kept me 109 | motivated enough to constantly work on this project. 110 | 111 | Also thanks to Jay Wineinger who contributed a non-trivial piece of code, and to 112 | the other folks who contributed documentation, raised or discussed various 113 | Github issues. 114 | 115 | ~Cristian 116 | 117 | ### Changes by author 118 | 119 | #### @cristim 120 | 121 | - Big code refactoring to make the code more maintainable and testable. 122 | - Buildsystem improvements (and regressions, since fixed). 123 | - Updated regional and instance type coverage, thanks to ec2instances.info. 124 | - Support restricting the execution to a given set of regions. 125 | - Expose all configuration options also as CloudFormation stack parameters. 126 | - Documentation updates and improvements. 127 | - Random small cleanups. 128 | 129 | #### @xlr-8 130 | 131 | - Update Lambda function's IAM permissions. 132 | - The algorithm now supports keeping on-demand instances in each AutoScaling 133 | group. 134 | - The algorithm is now configurable using tags set on the group and based on 135 | flags when executing it locally as a CLI tool. 136 | - Significant test coverage increases. 137 | - Significant clean-up and refactoring of the core algorithm. 138 | - Documentation improvements. 139 | 140 | #### @jwineinger 141 | 142 | - Pagination fix, making it work for users having many ASGs. 143 | 144 | #### @roeyazroel 145 | 146 | - Documentation for Elastic Beanstalk. 147 | 148 | ## 14 November 2016, build 79 149 | 150 | Major, breaking compatibility, packaging update: now using eawsy/aws-lambda-go 151 | for packaging of the Lambda function 152 | 153 | - Switch to the golang-native eawsy/aws-lambda-go for packaging of 154 | the Lambda function code. 155 | - This is a breaking change, updating already running CloudFormation 156 | stacks will also need a template update. 157 | - Add versioning for the CloudFormation template. 158 | - Buildsystem updates (both on Makefile and Travis CI configuration). 159 | - Change build dependencies: now building Lambda code in Docker, use 160 | wget instead of curl in order not to download data unnecessarily. 161 | - Remove the Python Lambda wrapper, it is no longer needed. 162 | - Start using go-bindata for shipping static files, instead of packaging 163 | them in the Lambda zip file. 164 | - Introduce a configuration object for the main functionality, not in 165 | use yet. 166 | - Documentation updates and better formatting. 167 | 168 | ## 2 November 2016, build 74 169 | 170 | - Test and fix support for EC2 Classic 171 | - Fix corner case in handling of ephemeral storage 172 | - Earlier spot request tagging 173 | 174 | ## 26 October 26, build 65 175 | 176 | - Regional expansion for R3 and D2 instances 177 | 178 | ## 23 October 2016, Travis CI build 63 179 | 180 | - Add support for the new Ohio AWS region 181 | - Add support in all the regions for the newly released instance types: 182 | m4.16xlarge, p2.xlarge, p2.8xlarge, p2.16xlarge and x1.16xlarge 183 | 184 | ## Older change log entries 185 | 186 | Before this file was created, change logs used to be posted as blog posts: 187 | 188 | - [recent changes as of October 2016](http://blog.cloudprowess.com/aws/ec2/spot/2016/10/24/autospotting-now-supports-the-new-ohio-aws-region-and-newly-released-instance-types.html) 189 | - in the initial phase of the project they were posted at the end of the [first 190 | announcement blog post](http://blog.cloudprowess.com/autoscaling/aws/ec2/spot/2016/04/21/my-approach-at-making-aws-ec2-affordable-automatic-replacement-of-autoscaling-nodes-with-equivalent-spot-instances.html) 191 | -------------------------------------------------------------------------------- /CODEDEPLOY.md: -------------------------------------------------------------------------------- 1 | # Use AutoSpotting with AWS CodeDeploy 2 | 3 | ## CodeDeploy Limitations 4 | 5 | - Doesn't work on spot instances natively 6 | - Doesn't work on instances that aren't booted by the autoscaling group 7 | 8 | ## Why this method 9 | 10 | This method is to allow for AutoSpotting and spot instances to work around the 11 | limitations of CodeDeploy and get our code on newly booted spot instances 12 | 13 | ## CodeDeploy Console 14 | 15 | - Setup the AWS CodeDeploy Deployment Groups to use Tag Groups 16 | - Groups should be based around the autoscaling groups you plan to use 17 | - For example: 18 | - Environment:staging 19 | - Product:nginx 20 | - Role:web 21 | 22 | ## Instance AMI Scripts 23 | 24 | ### get-meta 25 | 26 | - This file will be sourced into our deployment script 27 | - [get-meta](https://gist.github.com/cristim/82fc6bfe56c67a22ee264a0e3b655df5) 28 | - Save this file to /usr/bin/get-meta on the AMI to be used 29 | 30 | ### check-codedeploy 31 | 32 | - A simple version of a deployment script that is ran on-boot 33 | - This file will need to be deployed to the same AMI 34 | - [check-codedeploy](https://gist.github.com/cristim/7e9cd403fbf38aee18c4fb6a30bcef0a) 35 | - If you are using Amazon Linux saving this file to `/etc/rc3.d/S99deploycode` 36 | - This will make run it after all networking components are available 37 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines # 2 | 3 | [![Chat on Gitter](https://badges.gitter.im/AutoSpotting/AutoSpotting.svg)](https://gitter.im/cristim/autospotting) 4 | 5 | The usual GitHub contribution model applies, but if you would like to [raise an 6 | issue](https://github.com/AutoSpotting/AutoSpotting/issues/new) or start working 7 | on a [pull request](https://github.com/AutoSpotting/AutoSpotting/pulls), please 8 | get in touch on [gitter](https://gitter.im/cristim/autospotting) to discuss it 9 | first so we make sure everything is clear and that nobody else is already 10 | working on it. 11 | 12 | Any random questions are also better asked there. 13 | 14 | ## Feature requests ## 15 | 16 | The use case should be presented in detail in the issue, and should also 17 | be discussed on gitter to make sure nothing was lost in translation. 18 | 19 | ## Bug reports ## 20 | 21 | Bug reports should contain enough details to be reproduced by a developer. 22 | 23 | The commonly required information is already pre-filled when creating any GitHub 24 | issue, but be prepared to provide more when asked, either in the issue comments 25 | or on gitter. 26 | 27 | ## Pull requests ## 28 | 29 | Pull requests will need to pass code review by the project maintainers before 30 | they can be merged, in order to ensure the high quality of the software and the 31 | maintainability of the codebase. 32 | 33 | As part of the review process the maintainers will often just verify that the 34 | patch meets the requirements listed in the pull request 35 | [checklist](.github/PULL_REQUEST_TEMPLATE.md), but they may also suggest other 36 | changes deemed appropriate. 37 | 38 | Anyone is more than welcome to review the content of any issues labelled as 39 | 'review wanted', but only project maintainers can approve reviews for being 40 | merged. 41 | 42 | You can usually make the process faster by submitting smaller changes, larger 43 | reviews can also be sped up by asking for review on Gitter, we try to be as 44 | responsive as possible, but be prepared to iterate your pull request a number of 45 | times until it is ready to be approved. This may take a while for big 46 | contributions, so don't be discouraged if this may take longer than you 47 | initially expected. 48 | 49 | You can submit iterations as additional commits to make the review process 50 | easier, but the reviewer may squash them into a single big commit at merge time, 51 | in order to clean up the mainline commit history. 52 | -------------------------------------------------------------------------------- /CUSTOM_BUILDS.md: -------------------------------------------------------------------------------- 1 | # AutoSpotting Setup # 2 | 3 | It's usually recommended to use the provided binaries available as Docker 4 | images, but in some cases you may need to customize AutoSpotting for your own 5 | environment. 6 | 7 | ## Docker ## 8 | 9 | Pre-built Docker images for the latest evaluation builds are also available on 10 | the Docker Hub at 11 | [AutoSpotting/AutoSpotting](https://hub.docker.com/r/AutoSpotting/AutoSpotting/) 12 | 13 | ``` shell 14 | docker run autospotting/autospotting 15 | ``` 16 | 17 | They might be useful for quick tests, otherwise you might need to build your own 18 | docker images. 19 | 20 | The repository contains a `Dockerfile` and `docker-compose` configuration that 21 | allows you to build AutoSpotting Docker container images and run them 22 | conveniently on your local machine without installing the Go build environment 23 | usually required for local development(which is also documented below). 24 | 25 | This can be useful for trying it out locally or even for running it on a 26 | container hosting solution such as Kubernetes. They won't support the full 27 | functionality that relies on CloudWatch Events but it's probably enough for some 28 | people. 29 | 30 | If you have `docker` and `docker-compose` installed, it's as simple as running 31 | 32 | ``` shell 33 | docker-compose run autospotting 34 | ``` 35 | 36 | This also accepts all the AutoSpotting command-line arguments, including 37 | `-help` which explains all the other available options. 38 | 39 | The usual AWS credential environment variables listed in the 40 | `docker-compose.yaml` configuration file are passed to the running container and 41 | will need to be set for it to actually work. 42 | 43 | ## Using your own Docker images in AWS Lambda ## 44 | 45 | AutoSpotting uses a Lambda function configured to use a Docker image. Such a 46 | configuration [currently](https://github.com/aws/containers-roadmap/issues/1281) 47 | requires the Docker image to be stored in an ECR from your own account. 48 | 49 | AutoSpotting trunk currently builds and runs by default as ARM binaries. 50 | Building it locally most probably requires a `docker buildx` setup, as per the 51 | official Docker 52 | [documentation](https://docs.docker.com/buildx/working-with-buildx/). The 53 | Marketplace version is currently available only at x86 binaries. 54 | 55 | Building it as Intel binaries is still possible, but the infrastructure code 56 | will currently expect ARM binaries. 57 | 58 | In order to support the AWS Marketplace setup, which relies on an ECR repository 59 | hosted in another AWS-managed account, the current CloudFormation template uses 60 | a custom resource that copies the Docker image from a source ECR (by default the 61 | Marketplace ECR) into an ECR created inside the CloudFormation stack. This adds 62 | some complexity but has the nice side effect of being able to push the image to 63 | any arbitrary ECR in another account/region, offering more flexibility for 64 | customers who may want to manage custom deployments at scale. 65 | 66 | You'll therefore need to build an ARM Docker image, upload it to an ECR 67 | repository in your AWS account and configure your CloudFormation or Terraform 68 | stack to use this new image as a source image. 69 | 70 | 1. Set up an ECR repository in your AWS account that will host your custom 71 | Docker images. 72 | 73 | 2. The build system can use a `DOCKER_IMAGE` variable that tells it where to 74 | upload the image. Set it into your environment to the name of your ECR 75 | repository. When unset you'll attempt to push to the Marketplace ECR and 76 | you'll receive permission errors. 77 | 78 | ``` shell 79 | export DOCKER_IMAGE=1234567890123.dkr.ecr..amazonaws.com/ 80 | export DOCKER_IMAGE_VERSION=1.0.2 # it's strongly recommended versioning images 81 | ``` 82 | 83 | 3. Define some AWS credentials or profile information into your 84 | [environment](http://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html#cli-environment). 85 | 86 | 4. Authenticate to your ECR repository 87 | 88 | ```shell 89 | make docker-login 90 | ``` 91 | 92 | 5. Build and upload your Docker image to your ECR and configure a CloudFormation 93 | template to use your ECR 94 | 95 | ``` shell 96 | make docker-push-artifacts 97 | ``` 98 | 99 | 6. Use the CloudFormation template from the `build` directory to create the 100 | resources. Make sure you set the parameters `SourceECR` and `SourceImage` to 101 | point to your ECR repository (`SourceECR` should be set to contain the 102 | hostname part of your ECR repository, before the first `/` character and 103 | `SourceImage` should contain the rest). The version number will be set 104 | automatically based on the value you defined earlier. 105 | 106 | AutoSpotting should now be running against the binaries you built locally and 107 | uploaded to your own ECR repository. 108 | 109 | The same process can be used for updating AutoSpotting to a newer version. 110 | 111 | ## Maintaining your own fork ## 112 | 113 | It is recommended to contribute your changes into the mainline version of the 114 | project whenever possible, so that others can benefit from your enhancements and 115 | bug fixes, but for some reasons you may still want to run your own fork. 116 | 117 | Unfortunately the golang import paths can make it tricky, but there is a nice 118 | [article](http://code.openark.org/blog/development/forking-golang-repositories-on-github-and-managing-the-import-path) 119 | which documents the problem in detail and gives a couple of possible 120 | workarounds. 121 | 122 | ## Make directives ## 123 | 124 | The Makefile from the root of the git repository contains a number of useful 125 | directives, they're not documented here as they might change over time, so you 126 | may want to have a look at it. 127 | 128 | ## Local Development setup ## 129 | 130 | AutoSpotting is written in Go so for local development you need a Go toolchain. 131 | You can probably also use docker-compose for this to avoid it as mentioned above 132 | but I prefer the native Go tooling which offers faster feedback for local 133 | development. 134 | 135 | ### Dependencies ## 136 | 137 | 1. Install [Go](https://golang.org/dl/), [git](https://git-scm.com/downloads), 138 | [Docker](https://www.docker.com/) and the [AWS command-line 139 | tool](https://aws.amazon.com/cli/). You may use the official binaries or your 140 | usual package manager, whatever you prefer is fine. 141 | 142 | 1. Verify that they were properly installed. 143 | 144 | `go version`, should be at least 1.7 145 | 146 | `git version` 147 | 148 | `docker version` 149 | 150 | `aws --version` 151 | 152 | ### Compiling the binaries locally ## 153 | 154 | 1. Set up a directory for your Go development. I'm using `go` in my home 155 | directory for this example. 156 | 157 | 1. Set the `GOPATH` environment variable to point at your `go` directory: 158 | 159 | `export GOPATH=$HOME/go` 160 | 161 | Optionally add this line to your .bash_profile to persist across console 162 | sessions. 163 | 164 | 1. Run the following command to install the AutoSpotting project into your 165 | GOPATH directory: 166 | 167 | `go get github.com/AutoSpotting/AutoSpotting` 168 | 169 | This downloads the source from GitHub, pulls in all necessary dependencies, 170 | builds it for local execution and deploys the binary into the golang binary 171 | directory which you may also want to append to your PATH. 172 | 173 | 1. Navigate to the root of the AutoSpotting repository: 174 | 175 | `cd $GOPATH/src/github.com/AutoSpotting/AutoSpotting` 176 | 177 | 1. (Optional) You may want to make a minor change to the source code so you can 178 | tell when the tool is running your own custom-built version. If so, add a 179 | line like this to the `autospotting.go` file's `main()` function: 180 | 181 | `fmt.Println("Running binaries")` 182 | 183 | 1. (Optional) Try building and running the test suite locally to make sure 184 | everything works correctly: 185 | 186 | `make test` 187 | 188 | 1. Build the code again: 189 | 190 | `make build` 191 | 192 | ### Running locally ### 193 | 194 | 1. Run the code, assuming you have AWS credentials defined in your environment 195 | or in the default AWS credentials profile: 196 | 197 | `./AutoSpotting` 198 | 199 | You may also pass some command line flags, see the `--help` output for more 200 | information on the available options. 201 | 202 | When you are happy with how your custom build behaves, you can generate a 203 | 204 | build for AWS Lambda using the Docker method documented above. 205 | 206 | [Back to the main Readme](./README.md) 207 | -------------------------------------------------------------------------------- /CloudFormation_and_StackSets.md: -------------------------------------------------------------------------------- 1 | # CloudFormation and StackSets 2 | 3 | StackSets are a very powerful way to deploy software at scale across multiple 4 | AWS accounts and also to multiple regions within a single account. 5 | 6 | AutoSpotting supports being deployed using StackSets across multiple accounts, 7 | and also leverages them internally to deploy some of its components across 8 | multiple regions within each account. 9 | 10 | This document explains the way the current CloudFormation deployment method of 11 | AutoSpotting uses CloudFormation StackSets internally, what consequences it has 12 | on existing StackSet environments or when AutoSpotting is installed repeatedly 13 | within an AWS account, and a few workarounds on how to deploy AutoSpotting on 14 | such environments. 15 | 16 | ## Background 17 | 18 | - The current AutoSpotting architecture deploys a few central resources in a 19 | main AWS region but requires additional resources to be deployed in other 20 | regions to enable certain advanced behaviors such as handling of spot instance 21 | termination or rebalancing events, immediate replacement of on-demand 22 | instances with spot and startup lifecycle hook emulation. Without these 23 | regional resources AutoSpotting will only run in the basic/legacy Cron mode, 24 | which is suboptimal. 25 | - These regional resources have been historically deployed using a second 26 | regional CloudFormation template, deployed by a custom Lambda-backed 27 | CloudFormation resource across all available AWS regions. This was error-prone 28 | in particular when having multiple installations of AutoSpotting side by side, 29 | especially when some of them were uninstalled. 30 | - There was also another installation mode in which the same main CloudFormation 31 | template could itself be deployed using a StackSet, deploying only the main 32 | resources in the main region and only the regional resources in the other 33 | regions based on some parameters. This duplicated a lot of infrastructure code 34 | between the two CloudFormation templates and many conditionals that 35 | overcomplicated the implementation of the main template enough that it became 36 | almost unmanageable, so we decided to simplify it. 37 | - The current implementation uses a StackSet to deploy the regional resources 38 | instead of the custom Lambda-backed resource, and has been simplified greatly by 39 | removing all the conditionals and duplicated code that enabled it to use a 40 | StackSet for deploying the regional resources with the same template code. 41 | - To keep the user-friendly single-click installation support, the main AutoSpotting 42 | CloudFormation template currently also deploys out of the box the required IAM 43 | resources needed for self-managed StackSet permissions that enable it to 44 | deploy the regional template as a StackSet, and that's why it's conflicting 45 | with self-managed StackSet permissions you may already have in your account or 46 | if they were created by another installation of AutoSpotting. If you run into 47 | any such installation issues, you can see the Workarounds section below. 48 | 49 | ## Instructions on how to install AutoSpotting across an Organization using a StackSet 50 | 51 | 1. Grant permissions for using StackSets at AWS Organization level using these 52 | [instructions](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/StackSets-orgs-enable-trusted-access.html). 53 | See Note 1 below for further information on this topic. 54 | 1. Use the same CloudFormation 55 | [template](https://s3.amazonaws.com/cloudprowess/nightly/template.yaml) and 56 | parameters used for deploying AutoSpotting in stand-alone mode within a 57 | single AWS account. 58 | 1. Create the StackSet only in the "Main" region. For the official binaries you 59 | will need to use `us-east-1`, otherwise installation fails. See Note 2 below 60 | in case you run a custom build hosted in another region. 61 | 1. Set the OrganizationUnit where you want to deploy AutoSpotting and complete 62 | the installation wizard. You can also use the Organization root to install 63 | AutoSpotting across an entire AWS Organization. 64 | 1. For faster installation you can allow a 50% failure percentage, otherwise the 65 | StackSet will deploy AutoSpotting only one account at a time which can be 66 | slow on large organizations. 67 | 68 | ## Notes 69 | 70 | 1. Self-managed StackSet permissions are not supported out of the box and will 71 | break the default installation of AutoSpotting if you have them configured in 72 | the account. See below the workaround for this issue if you run into it. 73 | 1. If you run a custom build use the region where you created the S3 bucket 74 | hosting the code of the customized AutoSpotting Lambda functions. 75 | 76 | ## Workarounds 77 | 78 | - In case the installation fails because of the conflict with pre-existing IAM 79 | resources created for self-managed StackSet permissions or by an existing 80 | AutoSpotting installation, you'll need to configure the AutoSpotting Stack 81 | parameters to not deploy any regional resources. You can do it by setting the 82 | `DeployRegionalResourcesStackSet` parameter to `false`. 83 | - This which will render the current AutoSpotting installation to run in the 84 | legacy cron mode, also lacking termination event handling and lifecycle hooks 85 | emulation. In order to re-enable the event-based execution mode and other 86 | advanced features, you will then need to deploy those regional resources 87 | yourself with a second AutoSpotting regional StackSet deployed to the regions 88 | you want to run AutoSpotting against. For this you will need to use the 89 | regional CloudFormation 90 | [template](https://s3.amazonaws.com/cloudprowess/nightly/regional_template.yaml). 91 | - This regional StackSet will need a couple of parameters that enable it to send 92 | events to the main Lambda function: the ARN of the main Lambda function and 93 | the ARN of the regional execution IAM role created by the main AutoSpotting 94 | Stack. 95 | - These would be set automatically when installing the main AutoSpotting 96 | CloudFormation template with the default parameters, but they need to be 97 | manually set if the regional stack is installed manually. You can get these 98 | values from the Outputs of the main AutoSpotting CloudFormation Stack that 99 | corresponds to the regional Stack you want to install, in particular the 100 | `AutoSpottingLambdaARN` and `LambdaRegionalStackExecutionRoleARN` output 101 | values. 102 | 103 | ## Known issues 104 | 105 | - As mentioned above, the current StackSet implementation requires manual 106 | workarounds inc certain situations, such as when the StackSet self-managed 107 | StackSet permissions already exist in the account or when AutoSpotting is 108 | installed multiple times within an account. The above Workarounds may help in 109 | such situations. 110 | - Parallel installations using StackSets will require the installation in legacy 111 | mode by setting the `DeployRegionalResourcesStackSet` parameter to `false` on 112 | all but the first StackSet, and performing the same workarounds on each target 113 | account where the subsequest StackSets are deployed. 114 | 115 | ## Support 116 | 117 | As always, if you need Enterprise support for more exotic or large 118 | configurations, you can get in touch on [gitter](https://gitter.im/cristim). 119 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.18-alpine as golang 2 | RUN apk add -U --no-cache ca-certificates git make 3 | 4 | COPY go.mod go.sum /src/ 5 | # Download dependencies 6 | WORKDIR /src 7 | RUN GOPROXY=direct go mod download 8 | 9 | COPY . /src 10 | 11 | ARG flavor=custom 12 | RUN FLAVOR="$flavor" CGO_ENABLED=0 GOPROXY=direct make 13 | 14 | FROM scratch 15 | COPY LICENSE BINARY_LICENSE THIRDPARTY / 16 | COPY --from=golang /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 17 | COPY --from=golang /src/AutoSpotting . 18 | ENTRYPOINT ["./AutoSpotting"] 19 | -------------------------------------------------------------------------------- /Dockerfile.build: -------------------------------------------------------------------------------- 1 | FROM golang:1.18-alpine 2 | 3 | ARG flavor 4 | 5 | RUN apk add -U --no-cache ca-certificates git make 6 | 7 | COPY . /src 8 | WORKDIR /src 9 | 10 | RUN CGO_ENABLED=0 FLAVOR="$flavor" make ci 11 | -------------------------------------------------------------------------------- /Dockerfile.marketplace: -------------------------------------------------------------------------------- 1 | FROM golang:1.18-alpine as golang 2 | ARG savings_cut 3 | 4 | RUN apk add -U --no-cache ca-certificates git make 5 | 6 | COPY go.mod go.sum /src/ 7 | # Download dependencies 8 | WORKDIR /src 9 | RUN GOPROXY=direct go mod download 10 | 11 | COPY . /src 12 | 13 | RUN FLAVOR=stable CGO_ENABLED=0 GOPROXY=direct SAVINGS_CUT=$savings_cut make 14 | 15 | FROM alpine:latest 16 | COPY LICENSE BINARY_LICENSE THIRDPARTY / 17 | COPY --from=golang /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 18 | COPY --from=golang /src/AutoSpotting . 19 | ENTRYPOINT ["./AutoSpotting"] 20 | -------------------------------------------------------------------------------- /FAQ.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions 2 | 3 | Please refer to the FAQ section on [autospotting.org](https://autospotting.org) 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Open Software License ("OSL") v 3.0 2 | 3 | This Open Software License (the "License") applies to any original work of 4 | authorship (the "Original Work") whose owner (the "Licensor") has placed the 5 | following licensing notice adjacent to the copyright notice for the Original 6 | Work: 7 | 8 | Licensed under the Open Software License version 3.0 9 | 10 | 1) Grant of Copyright License. Licensor grants You a worldwide, royalty-free, 11 | non-exclusive, sublicensable license, for the duration of the copyright, to do 12 | the following: 13 | 14 | a) to reproduce the Original Work in copies, either alone or as part of a 15 | collective work; 16 | 17 | b) to translate, adapt, alter, transform, modify, or arrange the Original 18 | Work, thereby creating derivative works ("Derivative Works") based upon the 19 | Original Work; 20 | 21 | c) to distribute or communicate copies of the Original Work and Derivative 22 | Works to the public, with the proviso that copies of Original Work or 23 | Derivative Works that You distribute or communicate shall be licensed under 24 | this Open Software License; 25 | 26 | d) to perform the Original Work publicly; and 27 | 28 | e) to display the Original Work publicly. 29 | 30 | 2) Grant of Patent License. Licensor grants You a worldwide, royalty-free, 31 | non-exclusive, sublicensable license, under patent claims owned or controlled 32 | by the Licensor that are embodied in the Original Work as furnished by the 33 | Licensor, for the duration of the patents, to make, use, sell, offer for sale, 34 | have made, and import the Original Work and Derivative Works. 35 | 36 | 3) Grant of Source Code License. The term "Source Code" means the preferred 37 | form of the Original Work for making modifications to it and all available 38 | documentation describing how to modify the Original Work. Licensor agrees to 39 | provide a machine-readable copy of the Source Code of the Original Work along 40 | with each copy of the Original Work that Licensor distributes. Licensor 41 | reserves the right to satisfy this obligation by placing a machine-readable 42 | copy of the Source Code in an information repository reasonably calculated to 43 | permit inexpensive and convenient access by You for as long as Licensor 44 | continues to distribute the Original Work. 45 | 46 | 4) Exclusions From License Grant. Neither the names of Licensor, nor the names 47 | of any contributors to the Original Work, nor any of their trademarks or 48 | service marks, may be used to endorse or promote products derived from this 49 | Original Work without express prior permission of the Licensor. Except as 50 | expressly stated herein, nothing in this License grants any license to 51 | Licensor's trademarks, copyrights, patents, trade secrets or any other 52 | intellectual property. No patent license is granted to make, use, sell, offer 53 | for sale, have made, or import embodiments of any patent claims other than the 54 | licensed claims defined in Section 2. No license is granted to the trademarks 55 | of Licensor even if such marks are included in the Original Work. Nothing in 56 | this License shall be interpreted to prohibit Licensor from licensing under 57 | terms different from this License any Original Work that Licensor otherwise 58 | would have a right to license. 59 | 60 | 5) External Deployment. The term "External Deployment" means the use, 61 | distribution, or communication of the Original Work or Derivative Works in any 62 | way such that the Original Work or Derivative Works may be used by anyone 63 | other than You, whether those works are distributed or communicated to those 64 | persons or made available as an application intended for use over a network. 65 | As an express condition for the grants of license hereunder, You must treat 66 | any External Deployment by You of the Original Work or a Derivative Work as a 67 | distribution under section 1(c). 68 | 69 | 6) Attribution Rights. You must retain, in the Source Code of any Derivative 70 | Works that You create, all copyright, patent, or trademark notices from the 71 | Source Code of the Original Work, as well as any notices of licensing and any 72 | descriptive text identified therein as an "Attribution Notice." You must cause 73 | the Source Code for any Derivative Works that You create to carry a prominent 74 | Attribution Notice reasonably calculated to inform recipients that You have 75 | modified the Original Work. 76 | 77 | 7) Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that 78 | the copyright in and to the Original Work and the patent rights granted herein 79 | by Licensor are owned by the Licensor or are sublicensed to You under the 80 | terms of this License with the permission of the contributor(s) of those 81 | copyrights and patent rights. Except as expressly stated in the immediately 82 | preceding sentence, the Original Work is provided under this License on an "AS 83 | IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without 84 | limitation, the warranties of non-infringement, merchantability or fitness for 85 | a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK 86 | IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this 87 | License. No license to the Original Work is granted by this License except 88 | under this disclaimer. 89 | 90 | 8) Limitation of Liability. Under no circumstances and under no legal theory, 91 | whether in tort (including negligence), contract, or otherwise, shall the 92 | Licensor be liable to anyone for any indirect, special, incidental, or 93 | consequential damages of any character arising as a result of this License or 94 | the use of the Original Work including, without limitation, damages for loss 95 | of goodwill, work stoppage, computer failure or malfunction, or any and all 96 | other commercial damages or losses. This limitation of liability shall not 97 | apply to the extent applicable law prohibits such limitation. 98 | 99 | 9) Acceptance and Termination. If, at any time, You expressly assented to this 100 | License, that assent indicates your clear and irrevocable acceptance of this 101 | License and all of its terms and conditions. If You distribute or communicate 102 | copies of the Original Work or a Derivative Work, You must make a reasonable 103 | effort under the circumstances to obtain the express assent of recipients to 104 | the terms of this License. This License conditions your rights to undertake 105 | the activities listed in Section 1, including your right to create Derivative 106 | Works based upon the Original Work, and doing so without honoring these terms 107 | and conditions is prohibited by copyright law and international treaty. 108 | Nothing in this License is intended to affect copyright exceptions and 109 | limitations (including "fair use" or "fair dealing"). This License shall 110 | terminate immediately and You may no longer exercise any of the rights granted 111 | to You by this License upon your failure to honor the conditions in Section 112 | 1(c). 113 | 114 | 10) Termination for Patent Action. This License shall terminate automatically 115 | and You may no longer exercise any of the rights granted to You by this 116 | License as of the date You commence an action, including a cross-claim or 117 | counterclaim, against Licensor or any licensee alleging that the Original Work 118 | infringes a patent. This termination provision shall not apply for an action 119 | alleging patent infringement by combinations of the Original Work with other 120 | software or hardware. 121 | 122 | 11) Jurisdiction, Venue and Governing Law. Any action or suit relating to this 123 | License may be brought only in the courts of a jurisdiction wherein the 124 | Licensor resides or in which Licensor conducts its primary business, and under 125 | the laws of that jurisdiction excluding its conflict-of-law provisions. The 126 | application of the United Nations Convention on Contracts for the 127 | International Sale of Goods is expressly excluded. Any use of the Original 128 | Work outside the scope of this License or after its termination shall be 129 | subject to the requirements and penalties of copyright or patent law in the 130 | appropriate jurisdiction. This section shall survive the termination of this 131 | License. 132 | 133 | 12) Attorneys' Fees. In any action to enforce the terms of this License or 134 | seeking damages relating thereto, the prevailing party shall be entitled to 135 | recover its costs and expenses, including, without limitation, reasonable 136 | attorneys' fees and costs incurred in connection with such action, including 137 | any appeal of such action. This section shall survive the termination of this 138 | License. 139 | 140 | 13) Miscellaneous. If any provision of this License is held to be 141 | unenforceable, such provision shall be reformed only to the extent necessary 142 | to make it enforceable. 143 | 144 | 14) Definition of "You" in This License. "You" throughout this License, 145 | whether in upper or lower case, means an individual or a legal entity 146 | exercising rights under, and complying with all of the terms of, this License. 147 | For legal entities, "You" includes any entity that controls, is controlled by, 148 | or is under common control with you. For purposes of this definition, 149 | "control" means (i) the power, direct or indirect, to cause the direction or 150 | management of such entity, whether by contract or otherwise, or (ii) ownership 151 | of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial 152 | ownership of such entity. 153 | 154 | 15) Right to Use. You may use the Original Work in all ways not otherwise 155 | restricted or conditioned by this License or by law, and Licensor promises not 156 | to interfere with or be responsible for such uses by You. 157 | 158 | 16) Modification of This License. This License is Copyright © 2005 Lawrence 159 | Rosen. Permission is granted to copy, distribute, or communicate this License 160 | without modification. Nothing in this License permits You to modify this 161 | License as applied to the Original Work or to Derivative Works. However, You 162 | may modify the text of this License and copy, distribute or communicate your 163 | modified version (the "Modified License") and apply it to other original works 164 | of authorship subject to the following conditions: (i) You may not indicate in 165 | any way that your Modified License is the "Open Software License" or "OSL" and 166 | you may not use those names in the name of your Modified License; (ii) You 167 | must replace the notice specified in the first paragraph above with the notice 168 | "Licensed under " or with a notice of your own 169 | that is not confusingly similar to the notice in this License; and (iii) You 170 | may not claim that your original works are open source software unless your 171 | Modified License has been approved by Open Source Initiative (OSI) and You 172 | comply with its license review and certification process. 173 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DEPS := "wget git go docker golint" 2 | 3 | BINARY := AutoSpotting 4 | 5 | COVER_PROFILE := /tmp/coverage.out 6 | BUCKET_NAME ?= cloudprowess 7 | FLAVOR ?= custom 8 | LOCAL_PATH := build/s3/$(FLAVOR) 9 | LICENSE_FILES := LICENSE THIRDPARTY 10 | 11 | # the default is used for pushing to the AWS Marketplace ECR. Set this as an 12 | # environment variable to push to your own ECR repository instead. 13 | AWS_REGION ?= us-east-1 14 | 15 | DOCKER_ECR_ACCOUNT ?= 709825985650 16 | DOCKER_ECR_REGION ?= us-east-1 17 | 18 | DOCKER_ECR ?= $(DOCKER_ECR_ACCOUNT).dkr.ecr.$(DOCKER_ECR_REGION).amazonaws.com 19 | DOCKER_IMAGE ?= cloudutil/autospotting 20 | DOCKER_IMAGE_TAG ?= $(DOCKER_ECR)/$(DOCKER_IMAGE):$(DOCKER_IMAGE_VERSION) 21 | 22 | DOCKER_IMAGE_VERSION ?= 1.0 23 | 24 | SHA := $(shell git rev-parse HEAD | cut -c 1-7) 25 | BUILD := $(DOCKER_IMAGE_VERSION)-$(FLAVOR)-$(SHA) 26 | SAVINGS_CUT ?= 5 27 | 28 | GOARCH ?= arm64 29 | 30 | ifneq ($(FLAVOR), custom) 31 | LICENSE_FILES += BINARY_LICENSE 32 | endif 33 | 34 | LDFLAGS="-X main.Version=$(BUILD) -X main.SavingsCut=$(SAVINGS_CUT) -s -w" 35 | 36 | all: fmt-check vet-check build test ## Build the code 37 | .PHONY: all 38 | 39 | clean: ## Remove installed packages/temporary files 40 | go clean ./... 41 | rm -rf $(BINDATA_DIR) $(LOCAL_PATH) 42 | .PHONY: clean 43 | 44 | check_deps: ## Verify the system has all dependencies installed 45 | @for DEP in "$(DEPS)"; do \ 46 | if ! command -v "$$DEP" >/dev/null ; then echo "Error: dependency '$$DEP' is absent" ; exit 1; fi; \ 47 | done 48 | @echo "all dependencies satisifed: $(DEPS)" 49 | .PHONY: check_deps 50 | 51 | build_deps: ## Install all dependencies specified in tools.go 52 | @grep _ tools.go | cut -d '"' -f 2 | xargs go install 53 | .PHONY: build_deps 54 | 55 | update_deps: ## Update all dependencies 56 | @go get -u 57 | @go mod tidy 58 | .PHONY: update_deps 59 | 60 | build: ## Build the AutoSpotting binary 61 | go build -ldflags=$(LDFLAGS) -o $(BINARY) 62 | .PHONY: build 63 | 64 | artifacts: ## Create CloudFormation artifacts to be uploaded to S3 65 | @rm -rf $(LOCAL_PATH) 66 | @mkdir -p $(LOCAL_PATH) 67 | @cp -f cloudformation/stacks/AutoSpotting/template.yaml $(LOCAL_PATH)/template_build_$(BUILD).yaml 68 | @cp -f cloudformation/stacks/AutoSpotting/regional_template.yaml $(LOCAL_PATH)/ 69 | @sed -e "s#1.0.1#$(DOCKER_IMAGE_VERSION)#" $(LOCAL_PATH)/template_build_$(BUILD).yaml > $(LOCAL_PATH)/template_build_$(BUILD).yaml.new 70 | @mv -- $(LOCAL_PATH)/template_build_$(BUILD).yaml.new $(LOCAL_PATH)/template_build_$(BUILD).yaml 71 | @cp -f $(LOCAL_PATH)/template_build_$(BUILD).yaml $(LOCAL_PATH)/template.yaml 72 | 73 | .PHONY: artifacts 74 | 75 | docker: ## Build a Docker image, currently only supports x86 hosts 76 | docker build --build-arg flavor=$(FLAVOR) --platform=linux/$(GOARCH) --load -t $(DOCKER_IMAGE_TAG) . 77 | docker push $(DOCKER_IMAGE_TAG) 78 | .PHONY: docker 79 | 80 | docker-login: 81 | aws ecr get-login-password --region $(AWS_REGION) | docker login --username AWS --password-stdin $(DOCKER_ECR) 82 | 83 | docker-push-artifacts: docker artifacts 84 | .PHONY: docker-push-artifacts 85 | 86 | docker-marketplace: 87 | docker build -f Dockerfile.marketplace --platform=linux/$(GOARCH) --load -t $(DOCKER_IMAGE_TAG) --build-arg savings_cut=${SAVINGS_CUT} . 88 | docker push $(DOCKER_IMAGE_TAG) 89 | .PHONY: docker-marketplace 90 | 91 | docker-marketplace-push-artifacts: docker-marketplace artifacts 92 | .PHONY: docker-marketplace-push-artifacts 93 | 94 | upload: artifacts ## Upload data to S3 95 | aws s3 sync build/s3/ s3://$(BUCKET_NAME)/ 96 | .PHONY: upload 97 | 98 | vet-check: ## Verify vet compliance 99 | @go vet -all ./... 100 | .PHONY: vet-check 101 | 102 | fmt-check: ## Verify fmt compliance 103 | @sh -c 'test -z "$$(gofmt -l -s -d . | tee /dev/stderr)"' 104 | .PHONY: fmt-check 105 | 106 | module-check: build_deps ## Verify that all changes to go.mod and go.sum are checked in, and fail otherwise 107 | @go mod tidy -v 108 | git diff --exit-code HEAD -- go.mod go.sum 109 | .PHONY: module-check 110 | 111 | test: ## Test go code and coverage 112 | @go test -covermode=count -coverprofile=$(COVER_PROFILE) ./... 113 | .PHONY: test 114 | 115 | lint: build_deps 116 | @golint -set_exit_status ./... 117 | .PHONY: lint 118 | 119 | full-test: fmt-check vet-check test lint ## Pass test / fmt / vet / lint 120 | .PHONY: full-test 121 | 122 | html-cover: test ## Display coverage in HTML 123 | @go tool cover -html=$(COVER_PROFILE) 124 | .PHONY: html-cover 125 | 126 | ci-cover: html-cover ## Test & generate coverage in the TravisCI format, fails unless executed from TravisCI 127 | ifdef COVERALLS_TOKEN 128 | @goveralls -coverprofile=$(COVER_PROFILE) -service=travis-ci -repotoken=$(COVERALLS_TOKEN) 129 | endif 130 | .PHONY: ci-cover 131 | 132 | ci-checks: fmt-check vet-check module-check test lint ## Pass fmt / vet & lint format 133 | .PHONY: ci-checks 134 | 135 | ci: build artifacts ci-checks ci-cover ## Executes inside the CI Docker builder 136 | .PHONY: ci 137 | 138 | ci-docker: ## Executed by CI 139 | @docker-compose up --build --abort-on-container-exit --exit-code-from autospotting 140 | .PHONY: ci-docker 141 | 142 | help: ## Show this help 143 | @printf "Rules:\n" 144 | @fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//' 145 | .PHONY: help 146 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | The current and previous stable version released to the Patreon backers. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | To report a vulnerability use gitter.im/cristim or the Patreon chat function. 10 | 11 | I will release a fix ASAP. 12 | -------------------------------------------------------------------------------- /THIRDPARTY: -------------------------------------------------------------------------------- 1 | This software builds upon the following components: 2 | 3 | - github.com/cristim/ec2-instances-info 4 | 5 | The non-generated source code from this repo can be considered under the public domain. 6 | 7 | The data taken from ec2instances.info should be distributed according to their license 8 | 9 | - Instance information database, as compiled by the ec2instances.info project. 10 | 11 | Copyright (c) 2013 Garret Heaton (powdahound.com) 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining 14 | a copy of this software and associated documentation files (the 15 | "Software"), to deal in the Software without restriction, including 16 | without limitation the rights to use, copy, modify, merge, publish, 17 | distribute, sublicense, and/or sell copies of the Software, and to 18 | permit persons to whom the Software is furnished to do so, subject to 19 | the following conditions: 20 | 21 | The above copyright notice and this permission notice shall be 22 | included in all copies or substantial portions of the Software. 23 | 24 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 25 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 26 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 27 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 28 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 29 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 30 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 31 | 32 | - AWS SDK for Go 33 | 34 | Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. 35 | Copyright 2014-2015 Stripe, Inc. 36 | 37 | Distributed under these license terms: 38 | https://github.com/aws/aws-sdk-go/blob/master/LICENSE.txt 39 | 40 | - github.com/aws/aws-lambda-go 41 | 42 | Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. 43 | 44 | Lambda functions are made available under a modified MIT license: 45 | 46 | MIT No Attribution 47 | 48 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 49 | software and associated documentation files (the "Software"), to deal in the Software 50 | without restriction, including without limitation the rights to use, copy, modify, 51 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 52 | permit persons to whom the Software is furnished to do so. 53 | 54 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 55 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 56 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 57 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 58 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 59 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 60 | 61 | - github.com/robfig/cron 62 | 63 | Copyright (C) 2012 Rob Figueiredo 64 | All Rights Reserved. 65 | 66 | MIT LICENSE 67 | 68 | Permission is hereby granted, free of charge, to any person obtaining a copy of 69 | this software and associated documentation files (the "Software"), to deal in 70 | the Software without restriction, including without limitation the rights to 71 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 72 | the Software, and to permit persons to whom the Software is furnished to do so, 73 | subject to the following conditions: 74 | 75 | The above copyright notice and this permission notice shall be included in all 76 | copies or substantial portions of the Software. 77 | 78 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 79 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 80 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 81 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 82 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 83 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 84 | 85 | - github.com/davecgh/go-spew 86 | 87 | ISC License 88 | 89 | Copyright (c) 2012-2016 Dave Collins 90 | 91 | Permission to use, copy, modify, and/or distribute this software for any 92 | purpose with or without fee is hereby granted, provided that the above 93 | copyright notice and this permission notice appear in all copies. 94 | 95 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 96 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 97 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 98 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 99 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 100 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 101 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE 102 | 103 | - github.com/namsral/flag 104 | 105 | Copyright (c) 2012 The Go Authors. All rights reserved. 106 | 107 | Redistribution and use in source and binary forms, with or without 108 | modification, are permitted provided that the following conditions are 109 | met: 110 | 111 | * Redistributions of source code must retain the above copyright 112 | notice, this list of conditions and the following disclaimer. 113 | * Redistributions in binary form must reproduce the above 114 | copyright notice, this list of conditions and the following disclaimer 115 | in the documentation and/or other materials provided with the 116 | distribution. 117 | * Neither the name of Google Inc. nor the names of its 118 | contributors may be used to endorse or promote products derived from 119 | this software without specific prior written permission. 120 | 121 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 122 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 123 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 124 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 125 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 126 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 127 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 128 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 129 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 130 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 131 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 132 | 133 | - github.com/pkg/errors 134 | 135 | Copyright (c) 2015, Dave Cheney 136 | All rights reserved. 137 | 138 | Redistribution and use in source and binary forms, with or without 139 | modification, are permitted provided that the following conditions are met: 140 | 141 | * Redistributions of source code must retain the above copyright notice, this 142 | list of conditions and the following disclaimer. 143 | 144 | * Redistributions in binary form must reproduce the above copyright notice, 145 | this list of conditions and the following disclaimer in the documentation 146 | and/or other materials provided with the distribution. 147 | 148 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 149 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 150 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 151 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 152 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 153 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 154 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 155 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 156 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 157 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 158 | 159 | - github.com/stretchr/testify 160 | 161 | MIT License 162 | 163 | Copyright (c) 2012-2018 Mat Ryer and Tyler Bunnell 164 | 165 | Permission is hereby granted, free of charge, to any person obtaining a copy 166 | of this software and associated documentation files (the "Software"), to deal 167 | in the Software without restriction, including without limitation the rights 168 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 169 | copies of the Software, and to permit persons to whom the Software is 170 | furnished to do so, subject to the following conditions: 171 | 172 | The above copyright notice and this permission notice shall be included in all 173 | copies or substantial portions of the Software. 174 | 175 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 176 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 177 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 178 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 179 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 180 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 181 | SOFTWARE. 182 | -------------------------------------------------------------------------------- /_includes/youtube.html: -------------------------------------------------------------------------------- 1 |
2 | 5 |
6 | 24 | -------------------------------------------------------------------------------- /autospotting.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "io/ioutil" 10 | "log" 11 | "strconv" 12 | 13 | autospotting "github.com/AutoSpotting/AutoSpotting/core" 14 | "github.com/aws/aws-lambda-go/lambda" 15 | ) 16 | 17 | var as *autospotting.AutoSpotting 18 | var conf autospotting.Config 19 | 20 | // Version represents the build version being used 21 | var Version = "number missing" 22 | 23 | // SavingsCut is populated at build time and controls the percentage of the savings charged for the stable builds 24 | var SavingsCut = "0" 25 | 26 | var eventFile string 27 | 28 | func main() { 29 | eventFile = conf.EventFile 30 | 31 | if autospotting.RunningFromLambda() { 32 | lambda.Start(Handler) 33 | } else if eventFile != "" { 34 | parseEvent, err := ioutil.ReadFile(eventFile) 35 | if err != nil { 36 | log.Fatal(err) 37 | } 38 | Handler(context.TODO(), parseEvent) 39 | } else { 40 | eventHandler(nil) 41 | } 42 | } 43 | 44 | func eventHandler(event *json.RawMessage) { 45 | 46 | log.Println("Starting autospotting agent, build ", Version, "charging", SavingsCut, "percent of savings via AWS Marketplace") 47 | 48 | log.Printf("Configuration flags: %#v", conf) 49 | 50 | as.EventHandler(event) 51 | log.Println("Execution completed, nothing left to do") 52 | } 53 | 54 | // this is the equivalent of a main for when running from Lambda, but on Lambda 55 | // the runFromCronEvent() is executed within the handler function every time we have an event 56 | func init() { 57 | as = &autospotting.AutoSpotting{} 58 | 59 | sc, err := strconv.ParseFloat(SavingsCut, 64) 60 | if err != nil { 61 | log.Printf("Failed to convert savings cut %s to float\n", SavingsCut) 62 | } 63 | 64 | conf = autospotting.Config{ 65 | Version: Version, 66 | SavingsCut: sc, 67 | } 68 | 69 | autospotting.ParseConfig(&conf) 70 | as.Init(&conf) 71 | } 72 | 73 | // Handler implements the AWS Lambda handler interface 74 | func Handler(ctx context.Context, rawEvent json.RawMessage) { 75 | eventHandler(&rawEvent) 76 | } 77 | -------------------------------------------------------------------------------- /build/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanerCloud/AutoSpotting/9bc88ee72f9587a2de187cbd333fc06452b4e846/build/.keep -------------------------------------------------------------------------------- /cloudformation/Makefile: -------------------------------------------------------------------------------- 1 | all: upload 2 | 3 | install: 4 | cp stacks/AutoSpotting/template.yaml ../build/s3/nightly/ 5 | 6 | upload: install 7 | aws s3 sync ../build/s3/ s3://cloudprowess/ 8 | 9 | create: 10 | clouds --region us-east-1 update -c AutoSpotting -ew 11 | 12 | update: 13 | clouds --region us-east-1 update AutoSpotting -ew 14 | 15 | -------------------------------------------------------------------------------- /cloudformation/stacks/AutoSpotting/parameters.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Note: this is just an example on how the stack can be executed in a test 3 | # setting, you may want to change it for your production use case. 4 | 5 | # This file is handled automatically using the clouds-aws tool. It can be 6 | # installed using pip, or from https://github.com/elias5000/clouds-aws. The same 7 | # values can be passed manually when you run the stack from the AWS console. 8 | 9 | # DeployRegionalResourcesStackSet: 'true' 10 | # SQSQueueName: AutoSpotting.fifo 11 | 12 | # Default value: rate(30 minutes) 13 | ExecutionFrequency: rate(2 minutes) 14 | 15 | # Tag to be applied on the Lambda function 16 | LambdaFunctionTagKey: Name 17 | LambdaFunctionTagValue: autospotting 18 | 19 | # You may need to change this on large installations if you run into out of 20 | # memory situations. 21 | LambdaMemorySize: '1024' 22 | 23 | # Default value: 0 24 | MinOnDemandNumber: '0' 25 | 26 | # Valid choices: 27 | # - for EC2 Classic environments: Linux/UNIX | SUSE Linux | Windows 28 | # - for VPC: Linux/UNIX (Amazon VPC) | SUSE Linux (Amazon VPC) | 29 | # Windows (Amazon VPC) 30 | SpotProductDescription: Linux/UNIX (Amazon VPC) 31 | 32 | # Valid choices: opt-in (default), opt-out 33 | TagFilteringMode: opt-out 34 | 35 | SourceImageTag: 1.0.9-rc7 36 | -------------------------------------------------------------------------------- /cloudformation/stacks/AutoSpotting/regional_template.yaml: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | # Licensed under the Open Software License version 3.0 3 | 4 | AWSTemplateFormatVersion: "2010-09-09" 5 | Description: > 6 | "Implements support for triggering the main AutoSpotting Lambda function on 7 | regional events such as instance launches or imminent spot terminations that 8 | can only be detected from within a given region" 9 | Parameters: 10 | AutoSpottingLambdaARN: 11 | Description: "The ARN of the main AutoSpotting Lambda function" 12 | Type: "String" 13 | LambdaRegionalExecutionRoleARN: 14 | Description: "Execution Role ARN for Regional Lambda" 15 | Type: "String" 16 | Resources: 17 | EventHandler: 18 | Type: AWS::Lambda::Function 19 | Properties: 20 | Description: > 21 | "Regional Lambda function that invokes the main AutoSpotting Lambda 22 | function on events such as instance launches or imminent spot instance 23 | terminations" 24 | Handler: "index.handler" 25 | Runtime: "python3.8" 26 | Timeout: 300 27 | Environment: 28 | Variables: 29 | AUTOSPOTTING_LAMBDA_ARN: 30 | Ref: "AutoSpottingLambdaARN" 31 | Role: 32 | Ref: "LambdaRegionalExecutionRoleARN" 33 | Code: 34 | ZipFile: | 35 | from base64 import b64decode 36 | from boto3 import client 37 | from json import dumps 38 | from os import environ 39 | from sys import exc_info 40 | from traceback import print_exc 41 | 42 | lambda_arn = (environ['AUTOSPOTTING_LAMBDA_ARN']) 43 | 44 | def parse_region_from_arn(arn): 45 | return arn.split(':')[3] 46 | 47 | def handler(event, context): 48 | print("Running Lambda function", lambda_arn) 49 | try: 50 | svc = client('lambda', region_name=parse_region_from_arn(lambda_arn)) 51 | response = svc.invoke( 52 | FunctionName=lambda_arn, 53 | LogType='Tail', 54 | Payload=dumps(event), 55 | ) 56 | print("Invoked funcion log tail:\n", b64decode(response["LogResult"]).decode('utf-8')) 57 | except: 58 | print_exc() 59 | print("Unexpected error:", exc_info()[0]) 60 | SpotTerminationLambdaPermission: 61 | Type: "AWS::Lambda::Permission" 62 | Properties: 63 | Action: "lambda:InvokeFunction" 64 | FunctionName: 65 | Ref: "EventHandler" 66 | Principal: "events.amazonaws.com" 67 | SourceArn: 68 | Fn::GetAtt: 69 | - "SpotTerminationEventRule" 70 | - "Arn" 71 | SpotTerminationEventRule: 72 | Type: "AWS::Events::Rule" 73 | Properties: 74 | Description: > 75 | "This rule is triggered 2 minutes before AWS terminates a spot 76 | instance or when AWS send a Rebalance Recommendation" 77 | EventPattern: 78 | detail-type: 79 | - "EC2 Spot Instance Interruption Warning" 80 | - "EC2 Instance Rebalance Recommendation" 81 | source: 82 | - "aws.ec2" 83 | State: "ENABLED" 84 | Targets: 85 | - 86 | Id: "SpotTerminationEventGenerator" 87 | Arn: 88 | Fn::GetAtt: 89 | - "EventHandler" 90 | - "Arn" 91 | InstanceRunningLambdaPermission: 92 | Type: "AWS::Lambda::Permission" 93 | Properties: 94 | Action: "lambda:InvokeFunction" 95 | FunctionName: 96 | Ref: "EventHandler" 97 | Principal: "events.amazonaws.com" 98 | SourceArn: 99 | Fn::GetAtt: 100 | - "InstanceRunningEventRule" 101 | - "Arn" 102 | InstanceRunningEventRule: 103 | Type: "AWS::Events::Rule" 104 | Properties: 105 | Description: > 106 | "This rule is triggered after EC2 launched a new instance" 107 | EventPattern: 108 | detail-type: 109 | - "EC2 Instance State-change Notification" 110 | source: 111 | - "aws.ec2" 112 | detail: 113 | state: 114 | - "running" 115 | State: "ENABLED" 116 | Targets: 117 | - 118 | Id: "InstanceRunningEventGenerator" 119 | Arn: 120 | Fn::GetAtt: 121 | - "EventHandler" 122 | - "Arn" 123 | LifecycleHookLambdaPermission: 124 | Type: "AWS::Lambda::Permission" 125 | Properties: 126 | Action: "lambda:InvokeFunction" 127 | FunctionName: 128 | Ref: "EventHandler" 129 | Principal: "events.amazonaws.com" 130 | SourceArn: 131 | Fn::GetAtt: 132 | - "LifecycleHookEventRule" 133 | - "Arn" 134 | LifecycleHookEventRule: 135 | Type: "AWS::Events::Rule" 136 | Properties: 137 | Description: > 138 | "This rule is triggered after we failed to complete a lifecycle hook" 139 | EventPattern: 140 | detail-type: 141 | - "AWS API Call via CloudTrail" 142 | source: 143 | - "aws.autoscaling" 144 | detail: 145 | eventName: 146 | - "CompleteLifecycleAction" 147 | errorCode: 148 | - "ValidationException" 149 | requestParameters: 150 | lifecycleActionResult: 151 | - "CONTINUE" 152 | State: "ENABLED" 153 | Targets: 154 | - 155 | Id: "LifecycleHookEventGenerator" 156 | Arn: 157 | Fn::GetAtt: 158 | - "EventHandler" 159 | - "Arn" 160 | -------------------------------------------------------------------------------- /core/action.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | type target struct { 7 | autospotting *AutoSpotting 8 | onDemandInstance *instance 9 | } 10 | 11 | type runer interface { 12 | run() 13 | } 14 | 15 | // No-op run 16 | type skipRun struct { 17 | reason string 18 | } 19 | 20 | func (s skipRun) run() {} 21 | 22 | // terminates a random spot instance after enabling the event-based logic 23 | type replaceAndTerminateInstance struct { 24 | target target 25 | } 26 | 27 | func (tsi replaceAndTerminateInstance) run() { 28 | autospotting := tsi.target.autospotting 29 | autospotting.handleNewOnDemandInstanceLaunch(tsi.target.onDemandInstance.region, tsi.target.onDemandInstance) 30 | } 31 | -------------------------------------------------------------------------------- /core/beanstalk.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | import ( 7 | "encoding/base64" 8 | "strings" 9 | ) 10 | 11 | // Beanstalk UserData wrappers for CloudFormation Helper scripts 12 | // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-helper-scripts-reference.html 13 | // 14 | // `cfn-init`, `cfn-get-metadata` and `cfn-signal` are wrapped by adding the 15 | // instance role to the original code as `--role instance-role` 16 | // `cfn-hup` does not accept a `--role` param so we write the role into the config file 17 | // /etc/cfn/cfn-hup.conf 18 | var beanstalkUserDataCFNWrappers = `---- modify CloudFormation helpers ---- 19 | # Modify cfn-init to use --role by default 20 | echo -e '#!/bin/bash\nINROLE=$(curl -s 169.254.169.254/latest/meta-data/iam/security-credentials/)\n/opt/aws/bin/cfn-init-2 --role $INROLE "$@" \nexit $?' > /opt/aws/bin/cfn-init.tmp 21 | mv /opt/aws/bin/cfn-init /opt/aws/bin/cfn-init-2 22 | mv /opt/aws/bin/cfn-init.tmp /opt/aws/bin/cfn-init 23 | chmod +x /opt/aws/bin/cfn-init 24 | 25 | # Modify cfn-get-metadata to use --role by default 26 | echo -e '#!/bin/bash\nINROLE=$(curl -s 169.254.169.254/latest/meta-data/iam/security-credentials/)\n/opt/aws/bin/cfn-get-metadata-2 --role $INROLE "$@" \nexit $?' > /opt/aws/bin/cfn-get-metadata.tmp 27 | mv /opt/aws/bin/cfn-get-metadata /opt/aws/bin/cfn-get-metadata-2 28 | mv /opt/aws/bin/cfn-get-metadata.tmp /opt/aws/bin/cfn-get-metadata 29 | chmod +x /opt/aws/bin/cfn-get-metadata 30 | 31 | # Modify cfn-signal to use --role by default 32 | echo -e '#!/bin/bash\nINROLE=$(curl -s 169.254.169.254/latest/meta-data/iam/security-credentials/)\n/opt/aws/bin/cfn-signal-2 --role $INROLE "$@" \nexit $?' > /opt/aws/bin/cfn-signal.tmp 33 | mv /opt/aws/bin/cfn-signal /opt/aws/bin/cfn-signal-2 34 | mv /opt/aws/bin/cfn-signal.tmp /opt/aws/bin/cfn-signal 35 | chmod +x /opt/aws/bin/cfn-signal 36 | 37 | # Modify cfn-hup to use --role by default 38 | echo -e '#!/bin/bash\nprintf "role=$(curl -s 169.254.169.254/latest/meta-data/iam/security-credentials/)" >> /etc/cfn/cfn-hup.conf\n/opt/aws/bin/cfn-hup-2 "$@" \nexit $?' > /opt/aws/bin/cfn-hup.tmp 39 | mv /opt/aws/bin/cfn-hup /opt/aws/bin/cfn-hup-2 40 | mv /opt/aws/bin/cfn-hup.tmp /opt/aws/bin/cfn-hup 41 | chmod +x /opt/aws/bin/cfn-hup 42 | ---- modify CloudFormation helpers ---- 43 | 44 | ` 45 | 46 | func decodeUserData(userData *string) *string { 47 | // UserData is sometimes encoded as base64 ; decoded it if needed 48 | decodedUserData, err := base64.StdEncoding.DecodeString(*userData) 49 | 50 | if err != nil { 51 | // This is not Base64-encoded, return the original string 52 | return userData 53 | } 54 | 55 | // This was Base64-encoded, return the decoded string 56 | decodedUserDataString := string(decodedUserData) 57 | return &decodedUserDataString 58 | } 59 | 60 | func encodeUserData(userData *string) *string { 61 | // Encode UserData string to base64 62 | encodedUserData := base64.StdEncoding.EncodeToString([]byte(*userData)) 63 | 64 | return &encodedUserData 65 | } 66 | 67 | func getPatchedUserDataForBeanstalk(userData *string) *string { 68 | // Decode the UserData 69 | decodedUserData := decodeUserData(userData) 70 | 71 | // Patch the UserData if possible 72 | if strings.Contains(*decodedUserData, "ebbootstrap") { 73 | // Force set the role for calling CloudFormation helpers to be the instance role 74 | // The UserData created by Beanstalk is encoded as a Mime Multi Part Archive 75 | // with Cloud Init User-Data format (https://cloudinit.readthedocs.io/en/latest/topics/format.html) 76 | // We can't simply append our extra code to it, we need to add it to the correct mime part 77 | // Hence, we replace the first `#!/bin/bash` with our wrapper 78 | patchedUserData := strings.Replace(*decodedUserData, "#!/bin/bash\n", "#!/bin/bash\n"+beanstalkUserDataCFNWrappers, 1) 79 | return encodeUserData(&patchedUserData) 80 | } 81 | 82 | return userData 83 | } 84 | -------------------------------------------------------------------------------- /core/beanstalk_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | package autospotting 4 | 5 | import ( 6 | "encoding/base64" 7 | "reflect" 8 | "testing" 9 | ) 10 | 11 | func TestDecodeUserData(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | userData string 15 | want string 16 | }{ 17 | { 18 | name: "returns plain user data as is", 19 | userData: "userDataPlain", 20 | want: "userDataPlain", 21 | }, 22 | { 23 | name: "decodes base64 data", 24 | userData: base64.StdEncoding.EncodeToString([]byte("userData")), 25 | want: "userData", 26 | }, 27 | } 28 | for _, tt := range tests { 29 | t.Run(tt.name, func(t *testing.T) { 30 | got := decodeUserData(&tt.userData) 31 | 32 | if !reflect.DeepEqual(*got, tt.want) { 33 | t.Errorf("decodeUserData() = %v, want %v", *got, tt.want) 34 | } 35 | }) 36 | } 37 | } 38 | 39 | func TestEncodeUserData(t *testing.T) { 40 | tests := []struct { 41 | name string 42 | userData string 43 | want string 44 | }{ 45 | { 46 | name: "encodes data to base64", 47 | userData: "userData", 48 | want: base64.StdEncoding.EncodeToString([]byte("userData")), 49 | }, 50 | } 51 | for _, tt := range tests { 52 | t.Run(tt.name, func(t *testing.T) { 53 | got := encodeUserData(&tt.userData) 54 | 55 | if !reflect.DeepEqual(*got, tt.want) { 56 | t.Errorf("encodeUserData() = %v, want %v", *got, tt.want) 57 | } 58 | }) 59 | } 60 | } 61 | 62 | func TestGetPatchedUserDataForBeanstalk(t *testing.T) { 63 | tests := []struct { 64 | name string 65 | userData string 66 | want string 67 | }{ 68 | { 69 | name: "does nothing if the user data does not belong to Beanstalk", 70 | userData: "userData", 71 | want: "userData", 72 | }, 73 | { 74 | name: "decodes base64 data and does nothing if the user data does not belong to Beanstalk", 75 | userData: base64.StdEncoding.EncodeToString([]byte("userData")), 76 | want: base64.StdEncoding.EncodeToString([]byte("userData")), 77 | }, 78 | { 79 | name: "adds wrappers", 80 | userData: "ebbootstrap\n#!/bin/bash\nscript", 81 | want: base64.StdEncoding.EncodeToString([]byte("ebbootstrap\n#!/bin/bash\n" + beanstalkUserDataCFNWrappers + "script")), 82 | }, 83 | { 84 | name: "adds wrappers", 85 | userData: base64.StdEncoding.EncodeToString([]byte("ebbootstrap\n#!/bin/bash\nscript")), 86 | want: base64.StdEncoding.EncodeToString([]byte("ebbootstrap\n#!/bin/bash\n" + beanstalkUserDataCFNWrappers + "script")), 87 | }, 88 | } 89 | for _, tt := range tests { 90 | t.Run(tt.name, func(t *testing.T) { 91 | got := getPatchedUserDataForBeanstalk(&tt.userData) 92 | 93 | if !reflect.DeepEqual(*got, tt.want) { 94 | t.Errorf("getPatchedUserDataForBeanstalk() = %v, want %v", *got, tt.want) 95 | } 96 | }) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /core/cloudtrail.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | // CloudTrailEvent s used to unmarshal a CloudTrail Event from the Detail field 7 | // of a CloudWatch event 8 | type CloudTrailEvent struct { 9 | EventName string `json:"eventName"` 10 | AwsRegion string `json:"awsRegion"` 11 | ErrorCode string `json:"errorCode"` 12 | ErrorMessage string `json:"errorMessage"` 13 | RequestParameters RequestParameters `json:"requestParameters"` 14 | } 15 | 16 | // RequestParameters is used to unmarshal the parameters of a CloudTrail event 17 | type RequestParameters struct { 18 | LifecycleHookName string `json:"lifecycleHookName"` 19 | InstanceID string `json:"instanceId"` 20 | LifecycleActionResult string `json:"lifecycleActionResult"` 21 | AutoScalingGroupName string `json:"autoScalingGroupName"` 22 | } 23 | -------------------------------------------------------------------------------- /core/config.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "log" 10 | "os" 11 | "time" 12 | 13 | "github.com/aws/aws-sdk-go/aws/endpoints" 14 | ec2instancesinfo "github.com/cristim/ec2-instances-info" 15 | "github.com/namsral/flag" 16 | ) 17 | 18 | const ( 19 | // AutoScalingTerminationMethod uses the TerminateInstanceInAutoScalingGroup 20 | // API method to terminate instances. This method is recommended because it 21 | // will require termination Lifecycle Hooks that have been configured on the 22 | // Auto Scaling Group to be invoked before terminating the instance. It's 23 | // also safe even if there are no such hooks configured. 24 | AutoScalingTerminationMethod = "autoscaling" 25 | 26 | // DetachTerminationMethod detaches the instance from the Auto Scaling Group 27 | // and then terminates it. This method exists for historical reasons and is 28 | // no longer recommended. 29 | DetachTerminationMethod = "detach" 30 | 31 | // TerminateTerminationNotificationAction terminate the spot instance, which will be terminated 32 | // by AWS in 2 minutes, without reducing the ASG capacity, so that a new instance will 33 | // be launched. LifeCycle Hooks are triggered. 34 | TerminateTerminationNotificationAction = "terminate" 35 | 36 | // DetachTerminationNotificationAction detach the spot instance, which will be terminated 37 | // by AWS in 2 minutes, without reducing the ASG capacity, so that a new instance will 38 | // be launched. LifeCycle Hooks are not triggered. 39 | DetachTerminationNotificationAction = "detach" 40 | 41 | // AutoTerminationNotificationAction if ASG has a LifeCycleHook with LifecycleTransition = EC2_INSTANCE_TERMINATING 42 | // terminate the spot instance (as TerminateTerminationNotificationAction), if not detach it. 43 | AutoTerminationNotificationAction = "auto" 44 | 45 | // DefaultCronSchedule is the default value for the execution schedule in 46 | // simplified Cron-style definition the cron format only accepts the hour and 47 | // day of week fields, for example "9-18 1-5" would define the working week 48 | // hours. AutoSpotting will only run inside this time interval. The action can 49 | // also be reverted using the CronScheduleState parameter, so in order to run 50 | // outside this interval set the CronScheduleState to "off" either globally or 51 | // on a per-group override. 52 | DefaultCronSchedule = "* *" 53 | 54 | // Spot stores the string "spot" to avoid typos as it's used in various places 55 | Spot = "spot" 56 | // OnDemand stores the string "on-demand" to avoid typos as it's used in various places 57 | OnDemand = "on-demand" 58 | // DefaultGP2ConversionThreshold is the size under which GP3 is more performant than GP2 for both throughput and IOPS 59 | DefaultGP2ConversionThreshold = 170 60 | ) 61 | 62 | // Config extends the AutoScalingConfig struct and in addition contains a 63 | // number of global flags. 64 | type Config struct { 65 | AutoScalingConfig 66 | 67 | // Static data fetched from ec2instances.info 68 | InstanceData *ec2instancesinfo.InstanceData 69 | 70 | // Logging 71 | LogFile io.Writer 72 | LogFlag int 73 | 74 | // The regions where it should be running, given as a single CSV-string 75 | Regions string 76 | 77 | // The region where the Lambda function is deployed 78 | MainRegion string 79 | 80 | // This is only here for tests, where we want to be able to somehow mock 81 | // time.Sleep without actually sleeping. While testing it defaults to 0 (which won't sleep at all), in 82 | // real-world usage it's expected to be set to 1 83 | SleepMultiplier time.Duration 84 | 85 | // Filter on ASG tags 86 | // for example: spot-enabled=true,environment=dev,team=interactive 87 | FilterByTags string 88 | // Controls how are the tags used to filter the groups. 89 | // Available options: 'opt-in' and 'opt-out', default: 'opt-in' 90 | TagFilteringMode string 91 | 92 | // The AutoSpotting version 93 | Version string 94 | 95 | // The percentage of the savings 96 | SavingsCut float64 97 | 98 | // The license of this AutoSpotting build - obsolete 99 | LicenseType string 100 | 101 | // Controls whether AutoSpotting patches Elastic Beanstalk UserData scripts to use 102 | // the instance role when calling CloudFormation helpers instead of the standard CloudFormation 103 | // authentication method 104 | PatchBeanstalkUserdata bool 105 | 106 | // JSON file containing event data used for locally simulating execution from Lambda. 107 | EventFile string 108 | 109 | // Final Recap String Array to show actions taken by ScheduleRun on ASGs 110 | FinalRecap map[string][]string 111 | 112 | // SQS Queue URl 113 | SQSQueueURL string 114 | 115 | // SQS MessageID 116 | sqsReceiptHandle string 117 | 118 | // DisableEventBasedInstanceReplacement forces execution in cron mode only 119 | DisableEventBasedInstanceReplacement bool 120 | 121 | // DisableInstanceRebalanceRecommendation disable the handling of Instance Rebalance Recommendation events. 122 | DisableInstanceRebalanceRecommendation bool 123 | 124 | // BillingOnly - only billing related actions will be taken, no instance replacement will be performed. 125 | BillingOnly bool 126 | } 127 | 128 | // ParseConfig loads configuration from command line flags, environments variables, and config files. 129 | func ParseConfig(conf *Config) { 130 | 131 | // The use of FlagSet allows us to parse config multiple times, which is useful for unit tests. 132 | flagSet := flag.NewFlagSet("AutoSpotting", flag.ExitOnError) 133 | 134 | var region string 135 | 136 | if r := os.Getenv("AWS_REGION"); r != "" { 137 | region = r 138 | } else { 139 | region = endpoints.UsEast1RegionID 140 | } 141 | 142 | conf.LogFile = os.Stdout 143 | conf.LogFlag = log.Ldate | log.Ltime | log.Lshortfile 144 | 145 | log.SetOutput(conf.LogFile) 146 | log.SetFlags(conf.LogFlag) 147 | 148 | conf.MainRegion = region 149 | conf.SleepMultiplier = 1 150 | conf.sqsReceiptHandle = "" 151 | 152 | flagSet.StringVar(&conf.AllowedInstanceTypes, "allowed_instance_types", "", 153 | "\n\tIf specified, the spot instances will be searched only among these types.\n\tIf missing, any instance type is allowed.\n"+ 154 | "\tAccepts a list of comma or whitespace separated instance types (supports globs).\n"+ 155 | "\tExample: ./AutoSpotting -allowed_instance_types 'c5.*,c4.xlarge'\n") 156 | 157 | flagSet.StringVar(&conf.BiddingPolicy, "bidding_policy", DefaultBiddingPolicy, 158 | "\n\tPolicy choice for spot bid. If set to 'normal', we bid at the on-demand price(times the multiplier).\n"+ 159 | "\tIf set to 'aggressive', we bid at a percentage value above the spot price \n"+ 160 | "\tconfigurable using the spot_price_buffer_percentage.\n") 161 | 162 | flagSet.StringVar(&conf.DisallowedInstanceTypes, "disallowed_instance_types", "", 163 | "\n\tIf specified, the spot instances will _never_ be of these types.\n"+ 164 | "\tAccepts a list of comma or whitespace separated instance types (supports globs).\n"+ 165 | "\tExample: ./AutoSpotting -disallowed_instance_types 't2.*,c4.xlarge'\n") 166 | 167 | flagSet.StringVar(&conf.InstanceTerminationMethod, "instance_termination_method", DefaultInstanceTerminationMethod, 168 | "\n\tInstance termination method. Must be one of '"+DefaultInstanceTerminationMethod+"' (default),\n"+ 169 | "\t or 'detach' (compatibility mode, not recommended)\n") 170 | 171 | flagSet.StringVar(&conf.TerminationNotificationAction, "termination_notification_action", DefaultTerminationNotificationAction, 172 | "\n\tTermination Notification Action.\n"+ 173 | "\tValid choices:\n"+ 174 | "\t'"+DefaultTerminationNotificationAction+ 175 | "' (terminate if lifecyclehook else detach) | 'terminate' (lifecyclehook triggered)"+ 176 | " | 'detach' (lifecyclehook not triggered)\n") 177 | 178 | flagSet.Int64Var(&conf.MinOnDemandNumber, "min_on_demand_number", DefaultMinOnDemandValue, 179 | "\n\tNumber of on-demand nodes to be kept running in each of the groups.\n\t"+ 180 | "Can be overridden on a per-group basis using the tag "+OnDemandNumberLong+".\n") 181 | 182 | flagSet.Float64Var(&conf.MinOnDemandPercentage, "min_on_demand_percentage", 0.0, 183 | "\n\tPercentage of the total number of instances in each group to be kept on-demand\n\t"+ 184 | "Can be overridden on a per-group basis using the tag "+OnDemandPercentageTag+ 185 | "\n\tIt is ignored if min_on_demand_number is also set.\n") 186 | 187 | flagSet.Float64Var(&conf.OnDemandPriceMultiplier, "on_demand_price_multiplier", DefaultOnDemandPriceMultiplier, 188 | "\n\tMultiplier for the on-demand price. Numbers less than 1.0 are useful for volume discounts.\n"+ 189 | "The tag "+OnDemandPriceMultiplierTag+" can be used to override this on a group level.\n"+ 190 | "\tExample: ./AutoSpotting -on_demand_price_multiplier 0.6 will have the on-demand price "+ 191 | "considered at 60% of the actual value.\n") 192 | 193 | flagSet.StringVar(&conf.Regions, "regions", "", 194 | "\n\tRegions where it should be activated (separated by comma or whitespace, also supports globs).\n"+ 195 | "\tBy default it runs on all regions.\n"+ 196 | "\tExample: ./AutoSpotting -regions 'eu-*,us-east-1'\n") 197 | 198 | flagSet.Float64Var(&conf.SpotPriceBufferPercentage, "spot_price_buffer_percentage", DefaultSpotPriceBufferPercentage, 199 | "\n\tBid a given percentage above the current spot price.\n\tProtects the group from running spot"+ 200 | "instances that got significantly more expensive than when they were initially launched\n"+ 201 | "\tThe tag "+SpotPriceBufferPercentageTag+" can be used to override this on a group level.\n"+ 202 | "\tIf the bid exceeds the on-demand price, we place a bid at on-demand price itself.\n") 203 | 204 | flagSet.StringVar(&conf.SpotProductDescription, "spot_product_description", DefaultSpotProductDescription, 205 | "\n\tThe Spot Product to use when looking up spot price history in the market.\n"+ 206 | "\tValid choices: Linux/UNIX | SUSE Linux | Windows | Linux/UNIX (Amazon VPC) | \n"+ 207 | "\tSUSE Linux (Amazon VPC) | Windows (Amazon VPC) | Red Hat Enterprise Linux\n\tDefault value: "+DefaultSpotProductDescription+"\n") 208 | 209 | flagSet.Float64Var(&conf.SpotProductPremium, "spot_product_premium", DefaultSpotProductPremium, 210 | "\n\tThe Product Premium to apply to the on demand price to improve spot selection and savings calculations\n"+ 211 | "\twhen using a premium instance type such as RHEL.") 212 | 213 | flagSet.StringVar(&conf.TagFilteringMode, "tag_filtering_mode", "opt-in", "\n\tControls the behavior of the tag_filters option.\n"+ 214 | "\tValid choices: opt-in | opt-out\n\tDefault value: 'opt-in'\n\tExample: ./AutoSpotting --tag_filtering_mode opt-out\n") 215 | 216 | flagSet.StringVar(&conf.FilterByTags, "tag_filters", "", "\n\tSet of tags to filter the ASGs on.\n"+ 217 | "\tDefault if no value is set will be the equivalent of -tag_filters 'spot-enabled=true'\n"+ 218 | "\tIn case the tag_filtering_mode is set to opt-out, it defaults to 'spot-enabled=false'\n"+ 219 | "\tExample: ./AutoSpotting --tag_filters 'spot-enabled=true,Environment=dev,Team=vision'\n") 220 | 221 | flagSet.StringVar(&conf.CronSchedule, "cron_schedule", DefaultCronSchedule, "\n\tCron-like schedule in which to"+ 222 | "\tperform(or not) spot replacement actions. Format: hour day-of-week\n"+ 223 | "\tExample: ./AutoSpotting --cron_schedule '9-18 1-5' # workdays during the office hours \n") 224 | 225 | flagSet.StringVar(&conf.CronTimezone, "cron_timezone", "UTC", "\n\tTimezone to"+ 226 | "\tperform(or not) spot replacement actions. Format: timezone\n"+ 227 | "\tExample: ./AutoSpotting --cron_timezone 'Europe/London' \n") 228 | 229 | flagSet.StringVar(&conf.CronScheduleState, "cron_schedule_state", "on", "\n\tControls whether to take actions "+ 230 | "inside or outside the schedule defined by cron_schedule. Allowed values: on|off\n"+ 231 | "\tExample: ./AutoSpotting --cron_schedule_state='off' --cron_schedule '9-18 1-5' # would only take action outside the defined schedule\n") 232 | 233 | flagSet.StringVar(&conf.LicenseType, "license", "evaluation", "\n\t - obsoleted, kept for compatibility only\n"+ 234 | "\tExample: ./AutoSpotting --license evaluation\n") 235 | 236 | flagSet.StringVar(&conf.EventFile, "event_file", "", "\n\tJSON file containing event data, "+ 237 | "used for locally simulating execution from Lambda. AutoSpotting now expects to be "+ 238 | "triggered by events and won't do anything if no event is passed either as result of "+ 239 | "AWS instance state notifications or simulated manually using this flag.\n") 240 | 241 | flagSet.StringVar(&conf.SQSQueueURL, "sqs_queue_url", "", "\n\tThe Url of the SQS fifo queue used to manage spot replacement actions. "+ 242 | "This needs to exist in the same region as the main AutoSpotting Lambda function"+ 243 | "\tExample: ./AutoSpotting --sqs_queue_url https://sqs.{AwsRegion}.amazonaws.com/{AccountId}/AutoSpotting.fifo\n") 244 | 245 | flagSet.BoolVar(&conf.PatchBeanstalkUserdata, "patch_beanstalk_userdata", false, 246 | "\n\tControls whether AutoSpotting patches Elastic Beanstalk UserData scripts to use the "+ 247 | "instance role when calling CloudFormation helpers instead of the standard CloudFormation "+ 248 | "authentication method\n"+ 249 | "\tExample: ./AutoSpotting --patch_beanstalk_userdata true\n") 250 | 251 | flagSet.Int64Var(&conf.GP2ConversionThreshold, "ebs_gp2_conversion_threshold", DefaultGP2ConversionThreshold, 252 | "\n\tThe EBS volume size below which to automatically replace GP2 EBS volumes to the newer GP3 "+ 253 | "volume type, that's 20% cheaper and more performant than GP2 for smaller sizes, but it's not "+ 254 | "getting more performant wth size as GP2 does. Over 170 GB GP2 gets better throughput, and at "+ 255 | "1TB GP2 also has better IOPS than a baseline GP3 volume.\n"+ 256 | "\tExample: ./AutoSpotting --ebs_gp2_conversion_threshold 170\n") 257 | 258 | flagSet.BoolVar(&conf.DisableEventBasedInstanceReplacement, "disable_event_based_instance_replacement", false, 259 | "\n\tDisables the event based instance replacement, forcing the legacy cron mode.\n"+ 260 | "\tExample: ./AutoSpotting --disable_event_based_instance_replacement=true\n") 261 | 262 | flagSet.BoolVar(&conf.DisableInstanceRebalanceRecommendation, "disable_instance_rebalance_recommendation", false, 263 | "\n\tDisables handling of instance rebalance recommendation events.\n"+ 264 | "\tExample: ./AutoSpotting --disable_instance_rebalance_recommendation=true\n") 265 | 266 | flagSet.StringVar(&conf.SpotAllocationStrategy, "spot_allocation_strategy", "capacity-optimized-prioritized", 267 | "\n\tControls the Spot allocation strategy for launching Spot instances. Allowed options: \n"+ 268 | "\t'capacity-optimized-prioritized' (default), 'capacity-optimized', 'lowest-price'.\n"+ 269 | "\tFurther information on this is available at "+ 270 | "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-fleet-allocation-strategy.html\n"+ 271 | "\tExample: ./AutoSpotting --spot_allocation_strategy capacity-optimized-prioritized\n") 272 | 273 | flagSet.BoolVar(&conf.BillingOnly, "billing_only", false, 274 | "\n\tControls whether AutoSpotting only does the Marketplace billing without taking any further\n"+ 275 | "replacement actions when executed in cron mode\n"+ 276 | "\tExample: ./AutoSpotting --billing_only true\n") 277 | 278 | flagSet.StringVar(&conf.PrioritizedInstanceTypesBias, "prioritized_instance_types_bias", "lower_cost", 279 | "\n\tControls the ordering of instance types when using the capacity-optimized-prioritized\n"+ 280 | "\tSpot allocation strategy. By default, using the 'lower_cost' bias it sorts instances by Spot price\n"+ 281 | "\tAlternatively, you can bias towards newer instance types by using the 'prefer_newer_generations' bias\n"+ 282 | "\tExample: ./AutoSpotting --prioritized_instance_types_bias lower_cost\n") 283 | 284 | printVersion := flagSet.Bool("version", false, "Print version number and exit.\n") 285 | 286 | if err := flagSet.Parse(os.Args[1:]); err != nil { 287 | fmt.Printf("Error parsing config: %s\n", err.Error()) 288 | } 289 | 290 | if *printVersion { 291 | fmt.Println("AutoSpotting build:", conf.Version) 292 | os.Exit(0) 293 | } 294 | 295 | data, err := ec2instancesinfo.Data() 296 | if err != nil { 297 | log.Fatal(err.Error()) 298 | } 299 | conf.InstanceData = data 300 | 301 | conf.FinalRecap = make(map[string][]string) 302 | } 303 | -------------------------------------------------------------------------------- /core/config_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | import ( 7 | "os" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "gotest.tools/v3/assert" 13 | ) 14 | 15 | func TestParseConfig(t *testing.T) { 16 | tests := []struct { 17 | name string 18 | environment map[string]string 19 | }{ 20 | { 21 | name: "default settings", 22 | }, 23 | { 24 | name: "with AWS_REGION set", 25 | environment: map[string]string{ 26 | "AWS_REGION": "us-west-2", 27 | }, 28 | }, 29 | { 30 | name: "with LICENSE set", 31 | environment: map[string]string{ 32 | "LICENSE": "I_built_it_from_source_code", 33 | }, 34 | }, 35 | } 36 | 37 | // save copy of environment before we run any tests 38 | envVars := make(map[string]string) 39 | for _, item := range os.Environ() { 40 | e := strings.SplitN(item, "=", 2) 41 | envVars[e[0]] = e[1] 42 | } 43 | 44 | for _, tt := range tests { 45 | if tt.environment != nil { 46 | for key, value := range tt.environment { 47 | os.Setenv(key, value) 48 | } 49 | } 50 | 51 | t.Run(tt.name, func(t *testing.T) { 52 | config := Config{} 53 | ParseConfig(&config) 54 | 55 | if tt.environment != nil { 56 | if tt.environment["AWS_REGION"] != "" { 57 | assert.Equal(t, config.MainRegion, tt.environment["AWS_REGION"]) 58 | } else { 59 | assert.Equal(t, config.MainRegion, "us-east-1", "MainRegion should default to us-east-1") 60 | } 61 | 62 | if tt.environment["LICENSE"] != "" { 63 | assert.Equal(t, config.LicenseType, tt.environment["LICENSE"]) 64 | } 65 | } 66 | 67 | assert.Equal(t, config.LogFile, os.Stdout) 68 | assert.Equal(t, config.SleepMultiplier, time.Duration(1)) 69 | assert.Assert(t, config.InstanceData != nil, "expected InstanceData to be initialized") 70 | }) 71 | 72 | // reset environment variables 73 | if tt.environment != nil { 74 | for name := range tt.environment { 75 | os.Setenv(name, envVars[name]) 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /core/connections.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | // This stores a bunch of sessions to various AWS APIs, in order to avoid 5 | // connecting to them over and over again. 6 | 7 | package autospotting 8 | 9 | import ( 10 | "github.com/aws/aws-sdk-go/aws" 11 | "github.com/aws/aws-sdk-go/aws/session" 12 | "github.com/aws/aws-sdk-go/service/autoscaling" 13 | "github.com/aws/aws-sdk-go/service/autoscaling/autoscalingiface" 14 | "github.com/aws/aws-sdk-go/service/cloudformation" 15 | "github.com/aws/aws-sdk-go/service/cloudformation/cloudformationiface" 16 | "github.com/aws/aws-sdk-go/service/codedeploy" 17 | "github.com/aws/aws-sdk-go/service/codedeploy/codedeployiface" 18 | "github.com/aws/aws-sdk-go/service/ec2" 19 | "github.com/aws/aws-sdk-go/service/ec2/ec2iface" 20 | "github.com/aws/aws-sdk-go/service/lambda" 21 | "github.com/aws/aws-sdk-go/service/lambda/lambdaiface" 22 | "github.com/aws/aws-sdk-go/service/sqs" 23 | "github.com/aws/aws-sdk-go/service/sqs/sqsiface" 24 | ) 25 | 26 | type connections struct { 27 | session *session.Session 28 | autoScaling autoscalingiface.AutoScalingAPI 29 | ec2 ec2iface.EC2API 30 | cloudFormation cloudformationiface.CloudFormationAPI 31 | lambda lambdaiface.LambdaAPI 32 | sqs sqsiface.SQSAPI 33 | codedeploy codedeployiface.CodeDeployAPI 34 | region string 35 | } 36 | 37 | func (c *connections) setSession(region string) { 38 | c.session = session.Must( 39 | session.NewSession(&aws.Config{Region: aws.String(region)})) 40 | } 41 | 42 | func (c *connections) connect(region, mainRegion string) { 43 | 44 | debug.Println("Creating service connections in", region) 45 | 46 | if c.session == nil { 47 | c.setSession(region) 48 | } 49 | 50 | asConn := make(chan *autoscaling.AutoScaling) 51 | ec2Conn := make(chan *ec2.EC2) 52 | cloudformationConn := make(chan *cloudformation.CloudFormation) 53 | lambdaConn := make(chan *lambda.Lambda) 54 | sqsConn := make(chan *sqs.SQS) 55 | codedeployConn := make(chan *codedeploy.CodeDeploy) 56 | 57 | go func() { asConn <- autoscaling.New(c.session) }() 58 | go func() { ec2Conn <- ec2.New(c.session) }() 59 | go func() { lambdaConn <- lambda.New(c.session) }() 60 | go func() { cloudformationConn <- cloudformation.New(c.session) }() 61 | go func() { codedeployConn <- codedeploy.New(c.session) }() 62 | go func() { sqsConn <- sqs.New(c.session, aws.NewConfig().WithRegion(mainRegion)) }() 63 | 64 | c.autoScaling, c.ec2, c.cloudFormation, c.lambda, c.sqs, c.codedeploy, c.region = <-asConn, <-ec2Conn, <-cloudformationConn, <-lambdaConn, <-sqsConn, <-codedeployConn, region 65 | 66 | debug.Println("Created service connections in", region) 67 | } 68 | -------------------------------------------------------------------------------- /core/connections_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | import ( 7 | "testing" 8 | ) 9 | 10 | func Test_connections_connect(t *testing.T) { 11 | 12 | tests := []struct { 13 | name string 14 | region string 15 | match bool 16 | }{ 17 | { 18 | name: "connect to region foo", 19 | region: "foo", 20 | match: true, 21 | }, 22 | } 23 | for _, tt := range tests { 24 | t.Run(tt.name, func(t *testing.T) { 25 | c := &connections{} 26 | c.connect(tt.region, "bar") 27 | if (c.region == tt.region) != tt.match { 28 | t.Errorf("connections.connect() c.region = %v, expected %v", 29 | c.region, tt.region) 30 | } 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /core/instance.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | import "github.com/aws/aws-sdk-go/service/ec2" 7 | 8 | type instance struct { 9 | *ec2.Instance 10 | typeInfo instanceTypeInformation 11 | price float64 12 | region *region 13 | protected bool 14 | asg *autoScalingGroup 15 | } 16 | -------------------------------------------------------------------------------- /core/instance_actions.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | "log" 10 | 11 | "github.com/aws/aws-sdk-go/service/ec2" 12 | ) 13 | 14 | // instance_actions.go contains functions that act on instances, altering their state. 15 | 16 | func (i *instance) handleInstanceStates() (bool, error) { 17 | log.Printf("%s Found instance %s in state %s", 18 | i.region.name, *i.InstanceId, *i.State.Name) 19 | 20 | if *i.State.Name != "running" { 21 | log.Printf("%s Instance %s is not in the running state", 22 | i.region.name, *i.InstanceId) 23 | return true, errors.New("instance not in running state") 24 | } 25 | 26 | unattached := i.isUnattachedSpotInstanceLaunchedForAnEnabledASG() 27 | if !unattached { 28 | log.Printf("%s Instance %s is already attached to an ASG, skipping it", 29 | i.region.name, *i.InstanceId) 30 | return true, nil 31 | } 32 | return false, nil 33 | } 34 | 35 | // returns an instance ID or error 36 | func (i *instance) launchSpotReplacement() (*string, error) { 37 | 38 | ltData, err := i.createLaunchTemplateData() 39 | 40 | debug.Printf("Launch template data: %+#v", ltData) 41 | 42 | if err != nil { 43 | log.Println("failed to create LaunchTemplate data,", err.Error()) 44 | return nil, err 45 | } 46 | 47 | lt, err := i.createFleetLaunchTemplate(ltData) 48 | 49 | debug.Printf("Fleet Launch Template: %+#v", lt) 50 | 51 | if err != nil { 52 | log.Println(i.region, i.asg.name, "createFleetLaunchTemplate() failure:", err.Error()) 53 | return nil, err 54 | } 55 | 56 | defer i.deleteLaunchTemplate(lt) 57 | instanceTypes, err := i.getCompatibleSpotInstanceTypesList( 58 | i.asg.config.PrioritizedInstanceTypesBias, 59 | i.asg.getAllowedInstanceTypes(i), 60 | i.asg.getDisallowedInstanceTypes(i)) 61 | 62 | if err != nil { 63 | log.Println("Couldn't determine the list of compatible spot instance types") 64 | return nil, err 65 | } 66 | 67 | cfi := i.createFleetInput(lt, instanceTypes) 68 | 69 | debug.Printf("Fleet Input: %+#v", cfi) 70 | 71 | resp, err := i.region.services.ec2.CreateFleet(cfi) 72 | 73 | if err != nil { 74 | log.Println(i.region, i.asg.name, "CreateFleet() failure:", err.Error()) 75 | return nil, err 76 | } 77 | 78 | if resp != nil && len(resp.Instances) > 0 && resp.Instances[0] != nil && len(resp.Instances[0].InstanceIds) > 0 { 79 | return resp.Instances[0].InstanceIds[0], nil 80 | } 81 | 82 | return nil, fmt.Errorf("couldn't launch spot instance replacement") 83 | } 84 | 85 | func (i *instance) swapWithGroupMember(asg *autoScalingGroup) (*instance, error) { 86 | 87 | odInstance, err := i.getSwapCandidate() 88 | if err != nil { 89 | log.Printf("Couldn't find suitable OnDemand swap candidate: %s", err.Error()) 90 | return nil, err 91 | } 92 | 93 | asg.suspendProcesses() 94 | 95 | desiredCapacity, maxSize := *asg.DesiredCapacity, *asg.MaxSize 96 | 97 | // temporarily increase AutoScaling group in case the desired capacity reaches the max size, 98 | // otherwise attachSpotInstance might fail 99 | if desiredCapacity == maxSize { 100 | log.Println(asg.name, "Temporarily increasing MaxSize") 101 | asg.setAutoScalingMaxSize(maxSize + 1) 102 | defer asg.setAutoScalingMaxSize(maxSize) 103 | } 104 | 105 | log.Printf("Attaching spot instance %s to the group %s", 106 | *i.InstanceId, asg.name) 107 | err = asg.attachSpotInstance(*i.InstanceId, true) 108 | 109 | if err != nil { 110 | log.Printf("Spot instance %s couldn't be attached to the group %s, terminating it...", 111 | *i.InstanceId, asg.name) 112 | i.terminate() 113 | return nil, fmt.Errorf("couldn't attach spot instance %s ", *i.InstanceId) 114 | } 115 | 116 | log.Printf("Terminating on-demand instance %s from the group %s", 117 | *odInstance.InstanceId, asg.name) 118 | if err := asg.terminateInstanceInAutoScalingGroup(odInstance.Instance.InstanceId, true, true); err != nil { 119 | log.Printf("On-demand instance %s couldn't be terminated, re-trying...", 120 | *odInstance.InstanceId) 121 | return nil, fmt.Errorf("couldn't terminate on-demand instance %s", 122 | *odInstance.InstanceId) 123 | } 124 | 125 | return odInstance, nil 126 | } 127 | 128 | func (i *instance) getSwapCandidate() (*instance, error) { 129 | odInstanceID := i.getReplacementTargetInstanceID() 130 | if odInstanceID == nil { 131 | log.Println("Couldn't find target on-demand instance of", *i.InstanceId) 132 | return nil, fmt.Errorf("couldn't find target instance for %s", *i.InstanceId) 133 | } 134 | 135 | if err := i.region.scanInstance(odInstanceID); err != nil { 136 | log.Printf("Couldn't describe the target on-demand instance %s", *odInstanceID) 137 | return nil, fmt.Errorf("target instance %s couldn't be described", *odInstanceID) 138 | } 139 | 140 | odInstance := i.region.instances.get(*odInstanceID) 141 | if odInstance == nil { 142 | log.Printf("Target on-demand instance %s couldn't be found", *odInstanceID) 143 | return nil, fmt.Errorf("target instance %s is missing", *odInstanceID) 144 | } 145 | 146 | if !odInstance.shouldBeReplacedWithSpot() { 147 | log.Printf("Target on-demand instance %s shouldn't be replaced", *odInstanceID) 148 | i.terminate() 149 | return nil, fmt.Errorf("target instance %s should not be replaced with spot", 150 | *odInstanceID) 151 | } 152 | return odInstance, nil 153 | } 154 | 155 | func (i *instance) terminate() error { 156 | var err error 157 | log.Printf("Instance: %v\n", i) 158 | 159 | log.Printf("Terminating %v", *i.InstanceId) 160 | svc := i.region.services.ec2 161 | 162 | if !i.canTerminate() { 163 | log.Printf("Can't terminate %v, current state: %s", 164 | *i.InstanceId, *i.State.Name) 165 | return fmt.Errorf("can't terminate %s", *i.InstanceId) 166 | } 167 | 168 | _, err = svc.TerminateInstances(&ec2.TerminateInstancesInput{ 169 | InstanceIds: []*string{i.InstanceId}, 170 | }) 171 | 172 | if err != nil { 173 | log.Printf("Issue while terminating %v: %v", *i.InstanceId, err.Error()) 174 | } 175 | 176 | return err 177 | } 178 | 179 | func (i *instance) deleteLaunchTemplate(ltName *string) { 180 | _, err := i.region.services.ec2.DeleteLaunchTemplate(&ec2.DeleteLaunchTemplateInput{ 181 | LaunchTemplateName: ltName, 182 | }) 183 | 184 | if err != nil { 185 | log.Printf("Issue while deleting launch template %v, error: %v", *ltName, err.Error()) 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /core/instance_actions_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | package autospotting 4 | 5 | import ( 6 | "errors" 7 | "testing" 8 | 9 | "github.com/aws/aws-sdk-go/aws" 10 | "github.com/aws/aws-sdk-go/service/autoscaling" 11 | "github.com/aws/aws-sdk-go/service/ec2" 12 | ) 13 | 14 | func TestTerminate(t *testing.T) { 15 | tests := []struct { 16 | name string 17 | tags []*ec2.Tag 18 | inst *instance 19 | expected error 20 | }{ 21 | { 22 | name: "no issue with terminate", 23 | tags: []*ec2.Tag{}, 24 | inst: &instance{ 25 | Instance: &ec2.Instance{ 26 | InstanceId: aws.String("id1"), 27 | State: &ec2.InstanceState{ 28 | Name: aws.String(ec2.InstanceStateNameRunning), 29 | }, 30 | }, 31 | region: ®ion{ 32 | services: connections{ 33 | ec2: mockEC2{ 34 | tierr: nil, 35 | }, 36 | }, 37 | }, 38 | }, 39 | expected: nil, 40 | }, 41 | { 42 | name: "issue with terminate", 43 | tags: []*ec2.Tag{}, 44 | inst: &instance{ 45 | Instance: &ec2.Instance{ 46 | InstanceId: aws.String("id1"), 47 | State: &ec2.InstanceState{ 48 | Name: aws.String(ec2.InstanceStateNameRunning), 49 | }, 50 | }, 51 | region: ®ion{ 52 | services: connections{ 53 | ec2: mockEC2{ 54 | tierr: errors.New(""), 55 | }, 56 | }, 57 | }, 58 | }, 59 | expected: errors.New(""), 60 | }, 61 | } 62 | for _, tt := range tests { 63 | ret := tt.inst.terminate() 64 | if ret != nil && ret.Error() != tt.expected.Error() { 65 | t.Errorf("error actual: %s, expected: %s", ret.Error(), tt.expected.Error()) 66 | } 67 | } 68 | } 69 | 70 | func Test_instance_handleInstanceStates(t *testing.T) { 71 | 72 | tests := []struct { 73 | name string 74 | instance instance 75 | want bool 76 | wantErr bool 77 | }{ 78 | { 79 | name: "not running instance", 80 | instance: instance{ 81 | Instance: &ec2.Instance{ 82 | InstanceId: aws.String("i-dummy"), 83 | State: &ec2.InstanceState{ 84 | Name: aws.String("stopped"), 85 | }, 86 | }, 87 | region: ®ion{ 88 | name: "dummy", 89 | }, 90 | }, 91 | want: true, 92 | wantErr: true, 93 | }, 94 | { 95 | name: "running instance", 96 | instance: instance{ 97 | Instance: &ec2.Instance{ 98 | InstanceId: aws.String("i-dummy"), 99 | State: &ec2.InstanceState{ 100 | Name: aws.String("running"), 101 | }, 102 | }, 103 | region: ®ion{ 104 | name: "dummy", 105 | }, 106 | }, 107 | want: true, 108 | wantErr: false, 109 | }, 110 | } 111 | for _, tt := range tests { 112 | t.Run(tt.name, func(t *testing.T) { 113 | i := &instance{ 114 | Instance: tt.instance.Instance, 115 | typeInfo: tt.instance.typeInfo, 116 | price: tt.instance.price, 117 | region: tt.instance.region, 118 | protected: tt.instance.protected, 119 | asg: tt.instance.asg, 120 | } 121 | got, err := i.handleInstanceStates() 122 | if (err != nil) != tt.wantErr { 123 | t.Errorf("instance.handleInstanceStates() error = %v, wantErr %v", err, tt.wantErr) 124 | return 125 | } 126 | if got != tt.want { 127 | t.Errorf("instance.handleInstanceStates() = %v, want %v", got, tt.want) 128 | } 129 | }) 130 | } 131 | } 132 | 133 | func Test_instance_launchSpotReplacement(t *testing.T) { 134 | tests := []struct { 135 | name string 136 | instance instance 137 | want *string 138 | wantErr bool 139 | }{ 140 | { 141 | name: "happy-path-no-errors", 142 | instance: instance{ 143 | Instance: &ec2.Instance{ 144 | InstanceId: aws.String("i-dummy"), 145 | VirtualizationType: aws.String("paravirtual"), 146 | Placement: &ec2.Placement{ 147 | AvailabilityZone: aws.String("eu-central-1"), 148 | }, 149 | }, 150 | 151 | typeInfo: instanceTypeInformation{ 152 | instanceType: "typeX", 153 | PhysicalProcessor: "Intel", 154 | vCPU: 10, 155 | memory: 2.5, 156 | instanceStoreDeviceCount: 1, 157 | instanceStoreDeviceSize: 50.0, 158 | instanceStoreIsSSD: false, 159 | pricing: prices{ 160 | onDemand: 1.2, 161 | }, 162 | }, 163 | price: 0.75, 164 | asg: &autoScalingGroup{ 165 | Group: &autoscaling.Group{ 166 | DesiredCapacity: aws.Int64(4), 167 | }, 168 | instances: makeInstancesWithCatalog( 169 | instanceMap{ 170 | "id-1": { 171 | Instance: &ec2.Instance{ 172 | InstanceId: aws.String("id-1"), 173 | InstanceType: aws.String("typeX"), 174 | Placement: &ec2.Placement{AvailabilityZone: aws.String("eu-west-1")}, 175 | InstanceLifecycle: aws.String(Spot), 176 | }, 177 | }, 178 | }, 179 | ), 180 | config: AutoScalingConfig{ 181 | OnDemandPriceMultiplier: 1.0, 182 | }, 183 | region: ®ion{ 184 | conf: &Config{ 185 | AutoScalingConfig: AutoScalingConfig{ 186 | AllowedInstanceTypes: "", 187 | }, 188 | }, 189 | }, 190 | }, 191 | region: ®ion{ 192 | instanceTypeInformation: map[string]instanceTypeInformation{ 193 | "1": { 194 | instanceType: "type1", // cheapest, cheaper than ondemand 195 | pricing: prices{ 196 | spot: map[string]float64{ 197 | "eu-central-1": 0.5, 198 | "eu-west-1": 1.0, 199 | "eu-west-2": 2.0, 200 | }, 201 | }, 202 | vCPU: 10, 203 | PhysicalProcessor: "Intel", 204 | memory: 2.5, 205 | instanceStoreDeviceCount: 1, 206 | instanceStoreDeviceSize: 50.0, 207 | instanceStoreIsSSD: false, 208 | virtualizationTypes: []string{"PV", "else"}, 209 | }, 210 | "2": { 211 | instanceType: "type2", // less cheap, but cheaper than ondemand 212 | pricing: prices{ 213 | spot: map[string]float64{ 214 | "eu-central-1": 0.7, 215 | "eu-west-1": 1.0, 216 | "eu-west-2": 2.0, 217 | }, 218 | }, 219 | vCPU: 10, 220 | PhysicalProcessor: "Intel", 221 | memory: 2.5, 222 | instanceStoreDeviceCount: 1, 223 | instanceStoreDeviceSize: 50.0, 224 | instanceStoreIsSSD: false, 225 | virtualizationTypes: []string{"PV", "else"}, 226 | }, 227 | "3": { 228 | instanceType: "type3", // more expensive than ondemand 229 | pricing: prices{ 230 | spot: map[string]float64{ 231 | "eu-central-1": 0.8, 232 | "eu-west-1": 1.0, 233 | "eu-west-2": 2.0, 234 | }, 235 | }, 236 | vCPU: 10, 237 | PhysicalProcessor: "Intel", 238 | memory: 2.5, 239 | instanceStoreDeviceCount: 1, 240 | instanceStoreDeviceSize: 50.0, 241 | instanceStoreIsSSD: false, 242 | virtualizationTypes: []string{"PV", "else"}, 243 | }, 244 | }, 245 | services: connections{ 246 | ec2: mockEC2{ 247 | cferr: nil, 248 | cfo: &ec2.CreateFleetOutput{ 249 | Instances: []*ec2.CreateFleetInstance{ 250 | { 251 | InstanceIds: []*string{ 252 | aws.String("i-dummy-spot-instance-id"), 253 | }, 254 | }, 255 | }, 256 | }, 257 | damierr: nil, 258 | damio: &ec2.DescribeImagesOutput{}, 259 | }, 260 | }, 261 | }, 262 | }, 263 | want: aws.String("i-dummy-spot-instance-id"), 264 | }, 265 | } 266 | for _, tt := range tests { 267 | t.Run(tt.name, func(t *testing.T) { 268 | i := &instance{ 269 | Instance: tt.instance.Instance, 270 | typeInfo: tt.instance.typeInfo, 271 | price: tt.instance.price, 272 | region: tt.instance.region, 273 | protected: tt.instance.protected, 274 | asg: tt.instance.asg, 275 | } 276 | got, err := i.launchSpotReplacement() 277 | if (err != nil) != tt.wantErr { 278 | t.Errorf("instance.launchSpotReplacement() error = %v, wantErr %v", err, tt.wantErr) 279 | return 280 | } 281 | if got != nil && tt.want != nil && *got != *tt.want { 282 | t.Errorf("instance.launchSpotReplacement() = %v, want %v", *got, *tt.want) 283 | } 284 | }) 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /core/instance_events.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | import ( 7 | "encoding/json" 8 | "errors" 9 | "log" 10 | 11 | "github.com/aws/aws-lambda-go/events" 12 | ) 13 | 14 | const ( 15 | // InstanceStateChangeNotificationMessage store detail-type of the CloudWatch Event for 16 | // the Amazon EC2 State Change Events 17 | InstanceStateChangeNotificationMessage = "EC2 Instance State-change Notification" 18 | 19 | // InstanceStateChangeNotificationCode store the 3 letter code used to identify 20 | // the Amazon EC2 State Change Events 21 | InstanceStateChangeNotificationCode = "ISC" 22 | 23 | // SpotInstanceInterruptionWarningMessage store detail-type of the CloudWatch Event for 24 | // Amazon EC2 Spot Instance Interruption Events 25 | SpotInstanceInterruptionWarningMessage = "EC2 Spot Instance Interruption Warning" 26 | 27 | // SpotInstanceInterruptionWarningCode store the 3 letter code used to identify 28 | // Amazon EC2 Spot Instance Interruption Events 29 | SpotInstanceInterruptionWarningCode = "SII" 30 | 31 | // InstanceRebalanceRecommendationMessage store detail-type of the CloudWatch Event for 32 | // Amazon EC2 Instance Rebalance Recommendation Events 33 | InstanceRebalanceRecommendationMessage = "EC2 Instance Rebalance Recommendation" 34 | 35 | // InstanceRebalanceRecommendationCode store the 3 letter code used to identify 36 | // Amazon EC2 Instance Rebalance Recommendation Events 37 | InstanceRebalanceRecommendationCode = "IRR" 38 | 39 | // AWSAPICallCloudTrailMessage store detail-type of the CloudWatch Event for 40 | // Events Delivered Via CloudTrail 41 | AWSAPICallCloudTrailMessage = "AWS API Call via CloudTrail" 42 | 43 | // AWSAPICallCloudTrailCode store the 3 letter code used to identify 44 | // Events Delivered Via CloudTrail 45 | AWSAPICallCloudTrailCode = "ACC" 46 | 47 | // ScheduledEventMessage store detail-type of the CloudWatch Event for 48 | // Amazon CloudWatch Events Scheduled Events 49 | ScheduledEventMessage = "Scheduled Event" 50 | 51 | // ScheduledEventCode store the 3 letter code used to identify 52 | // Amazon CloudWatch Events Scheduled Events 53 | ScheduledEventCode = "SCE" 54 | ) 55 | 56 | // InstanceData represents JSON structure of the Detail property of CloudWatch event when a spot instance is terminated 57 | // Reference = https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/spot-interruptions.html#spot-instance-termination-notices 58 | type instanceData struct { 59 | InstanceID *string `json:"instance-id"` 60 | InstanceAction *string `json:"instance-action"` 61 | State *string `json:"state"` 62 | } 63 | 64 | // returns the InstanceID, State or an error 65 | func parseEventData(event events.CloudWatchEvent) (string, *string, *string, error) { 66 | var detailData instanceData 67 | var eventTypeCode string 68 | var instanceID *string 69 | var instanceState *string 70 | var result error 71 | 72 | if err := json.Unmarshal(event.Detail, &detailData); err != nil { 73 | log.Println(err.Error()) 74 | return "", nil, nil, err 75 | } 76 | eventType := event.DetailType 77 | 78 | // Amazon EC2 State Change Events 79 | if eventType == InstanceStateChangeNotificationMessage && 80 | detailData.InstanceID != nil && 81 | detailData.State != nil { 82 | eventTypeCode = InstanceStateChangeNotificationCode 83 | instanceID = detailData.InstanceID 84 | instanceState = detailData.State 85 | } 86 | 87 | // Amazon EC2 Spot Instance Interruption Events 88 | if eventType == SpotInstanceInterruptionWarningMessage && 89 | detailData.InstanceAction != nil && 90 | *detailData.InstanceAction != "" { 91 | eventTypeCode = SpotInstanceInterruptionWarningCode 92 | instanceID = detailData.InstanceID 93 | } 94 | 95 | // Amazon EC2 Instance Rebalance Recommendation Events 96 | if eventType == InstanceRebalanceRecommendationMessage && 97 | detailData.InstanceID != nil && 98 | *detailData.InstanceID != "" { 99 | eventTypeCode = InstanceRebalanceRecommendationCode 100 | instanceID = detailData.InstanceID 101 | } 102 | 103 | // Events Delivered Via CloudTrail 104 | if eventType == AWSAPICallCloudTrailMessage { 105 | eventTypeCode = AWSAPICallCloudTrailCode 106 | } 107 | 108 | // Amazon CloudWatch Events Scheduled Events 109 | if eventType == ScheduledEventMessage { 110 | eventTypeCode = ScheduledEventCode 111 | } 112 | 113 | // This code shouldn't be reachable 114 | if len(eventTypeCode) == 0 { 115 | log.Printf("This code shouldn't be reachable, received event: %+v \n", event) 116 | result = errors.New("this code shouldn't be reached") 117 | } 118 | 119 | return eventTypeCode, instanceID, instanceState, result 120 | } 121 | -------------------------------------------------------------------------------- /core/instance_events_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | import ( 7 | "encoding/json" 8 | "errors" 9 | "testing" 10 | 11 | "github.com/aws/aws-lambda-go/events" 12 | "github.com/aws/aws-sdk-go/aws" 13 | ) 14 | 15 | func TestParseEventData(t *testing.T) { 16 | 17 | expectedInstanceID := "i-123456" 18 | expectedNotMatchedError := errors.New("this code shoudn't be reached") 19 | instanceAction := aws.String(TerminateTerminationNotificationAction) 20 | instanceState := "running" 21 | 22 | tests := []struct { 23 | name string 24 | eventType string 25 | cloudWatchEvent events.CloudWatchEvent 26 | expectedInstanceID *string 27 | expectedInstanceState *string 28 | expectedError error 29 | }{ 30 | { 31 | name: "Invalid Detail in CloudWatch event", 32 | cloudWatchEvent: events.CloudWatchEvent{ 33 | Detail: []byte(""), 34 | }, 35 | }, 36 | { 37 | name: "DetailType is not matched", 38 | cloudWatchEvent: events.CloudWatchEvent{ 39 | DetailType: "not matching", 40 | Detail: []byte("{}"), 41 | }, 42 | expectedError: expectedNotMatchedError, 43 | }, 44 | { 45 | name: "Detail is Amazon EC2 State Change Events with no instanceID and no State", 46 | cloudWatchEvent: events.CloudWatchEvent{ 47 | DetailType: InstanceStateChangeNotificationMessage, 48 | Detail: func() json.RawMessage { 49 | data, _ := json.Marshal(instanceData{}) 50 | return data 51 | }(), 52 | }, 53 | expectedInstanceID: nil, 54 | expectedInstanceState: nil, 55 | expectedError: expectedNotMatchedError, 56 | }, 57 | { 58 | name: "Detail is Amazon EC2 State Change Events with instanceID and instanceState", 59 | cloudWatchEvent: events.CloudWatchEvent{ 60 | DetailType: InstanceStateChangeNotificationMessage, 61 | Detail: func() json.RawMessage { 62 | data, _ := json.Marshal(instanceData{ 63 | InstanceID: aws.String(expectedInstanceID), 64 | State: &instanceState, 65 | }) 66 | return data 67 | }(), 68 | }, 69 | expectedInstanceID: &expectedInstanceID, 70 | expectedInstanceState: aws.String("running"), 71 | expectedError: nil, 72 | }, 73 | { 74 | name: "Detail is Amazon EC2 Spot Instance Interruption Events with no InstanceAction", 75 | cloudWatchEvent: events.CloudWatchEvent{ 76 | DetailType: SpotInstanceInterruptionWarningMessage, 77 | Detail: func() json.RawMessage { 78 | data, _ := json.Marshal(instanceData{ 79 | InstanceID: aws.String(expectedInstanceID), 80 | }) 81 | return data 82 | }(), 83 | }, 84 | expectedInstanceID: nil, 85 | expectedInstanceState: nil, 86 | expectedError: expectedNotMatchedError, 87 | }, 88 | { 89 | name: "Detail is Amazon EC2 Spot Instance Interruption Events with InstanceID and InstanceAction", 90 | cloudWatchEvent: events.CloudWatchEvent{ 91 | DetailType: SpotInstanceInterruptionWarningMessage, 92 | Detail: func() json.RawMessage { 93 | data, _ := json.Marshal(instanceData{ 94 | InstanceID: aws.String(expectedInstanceID), 95 | InstanceAction: instanceAction, 96 | }) 97 | return data 98 | }(), 99 | }, 100 | expectedInstanceID: &expectedInstanceID, 101 | expectedInstanceState: nil, 102 | expectedError: nil, 103 | }, 104 | { 105 | name: "Detail is Amazon EC2 Instance Rebalance Recommendation Events with no InstanceID", 106 | cloudWatchEvent: events.CloudWatchEvent{ 107 | DetailType: InstanceRebalanceRecommendationMessage, 108 | Detail: func() json.RawMessage { 109 | data, _ := json.Marshal(instanceData{}) 110 | return data 111 | }(), 112 | }, 113 | expectedInstanceID: nil, 114 | expectedInstanceState: nil, 115 | expectedError: expectedNotMatchedError, 116 | }, 117 | { 118 | name: "Detail is Amazon EC2 Spot Instance Interruption Events with InstanceID", 119 | cloudWatchEvent: events.CloudWatchEvent{ 120 | DetailType: InstanceRebalanceRecommendationMessage, 121 | Detail: func() json.RawMessage { 122 | data, _ := json.Marshal(instanceData{ 123 | InstanceID: aws.String(expectedInstanceID), 124 | }) 125 | return data 126 | }(), 127 | }, 128 | expectedInstanceID: &expectedInstanceID, 129 | expectedInstanceState: nil, 130 | expectedError: nil, 131 | }, 132 | { 133 | name: "Detail is Events Delivered Via CloudTrail", 134 | cloudWatchEvent: events.CloudWatchEvent{ 135 | DetailType: AWSAPICallCloudTrailMessage, 136 | Detail: func() json.RawMessage { 137 | data, _ := json.Marshal(instanceData{}) 138 | return data 139 | }(), 140 | }, 141 | expectedInstanceID: nil, 142 | expectedInstanceState: nil, 143 | expectedError: nil, 144 | }, 145 | { 146 | name: "Detail is Amazon CloudWatch Events Scheduled Events", 147 | cloudWatchEvent: events.CloudWatchEvent{ 148 | DetailType: ScheduledEventMessage, 149 | Detail: func() json.RawMessage { 150 | data, _ := json.Marshal(instanceData{}) 151 | return data 152 | }(), 153 | }, 154 | expectedInstanceID: nil, 155 | expectedInstanceState: nil, 156 | expectedError: nil, 157 | }, 158 | } 159 | 160 | for _, tc := range tests { 161 | t.Run(tc.name, func(t *testing.T) { 162 | eventTypeCode, instanceID, instanceState, _ := parseEventData(tc.cloudWatchEvent) 163 | 164 | if eventTypeCode == "" && (instanceID != nil || 165 | instanceState != nil) { 166 | t.Errorf("Expected nil instanceID and InstanceState, actual: %s, %s", *instanceID, *instanceState) 167 | } 168 | 169 | if eventTypeCode == InstanceStateChangeNotificationCode && (*tc.expectedInstanceID != *instanceID || *tc.expectedInstanceState != *instanceState) { 170 | t.Errorf("InstanceID expected: %v\nactual: %v", tc.expectedInstanceID, instanceID) 171 | t.Errorf("InstanceState expected: %v\nactual: %v", tc.expectedInstanceState, instanceID) 172 | } 173 | if (eventTypeCode == SpotInstanceInterruptionWarningCode || 174 | eventTypeCode == InstanceRebalanceRecommendationCode) && *tc.expectedInstanceID != *instanceID { 175 | t.Errorf("InstanceID expected: %v\nactual: %v", tc.expectedInstanceID, instanceID) 176 | } 177 | if (eventTypeCode == AWSAPICallCloudTrailCode || 178 | eventTypeCode == ScheduledEventCode) && tc.expectedInstanceID != instanceID { 179 | t.Errorf("InstanceID expected: %v\nactual: %v", tc.expectedInstanceID, instanceID) 180 | } 181 | }) 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /core/instance_manager.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | import ( 7 | "sync" 8 | 9 | "github.com/davecgh/go-spew/spew" 10 | ) 11 | 12 | // The key in this map is the instance ID, useful for quick retrieval of 13 | // instance attributes. 14 | type instanceMap map[string]*instance 15 | 16 | type instanceManager struct { 17 | sync.RWMutex 18 | catalog instanceMap 19 | } 20 | 21 | type instances interface { 22 | add(inst *instance) 23 | get(string) *instance 24 | count() int 25 | count64() int64 26 | make() 27 | instances() <-chan *instance 28 | dump() string 29 | } 30 | 31 | type acceptableInstance struct { 32 | instanceTI instanceTypeInformation 33 | price float64 34 | generationDelta int64 35 | } 36 | 37 | type instanceTypeInformation struct { 38 | instanceType string 39 | vCPU int 40 | PhysicalProcessor string 41 | GPU int 42 | pricing prices 43 | memory float32 44 | virtualizationTypes []string 45 | hasInstanceStore bool 46 | instanceStoreDeviceSize float32 47 | instanceStoreDeviceCount int 48 | instanceStoreIsSSD bool 49 | hasEBSOptimization bool 50 | EBSThroughput float32 51 | generationDelta int64 52 | } 53 | 54 | func makeInstances() instances { 55 | return &instanceManager{catalog: instanceMap{}} 56 | } 57 | 58 | func makeInstancesWithCatalog(catalog instanceMap) instances { 59 | return &instanceManager{catalog: catalog} 60 | } 61 | 62 | func (is *instanceManager) dump() string { 63 | is.RLock() 64 | defer is.RUnlock() 65 | return spew.Sdump(is.catalog) 66 | } 67 | func (is *instanceManager) make() { 68 | is.Lock() 69 | is.catalog = make(instanceMap) 70 | is.Unlock() 71 | } 72 | 73 | func (is *instanceManager) add(inst *instance) { 74 | if inst == nil { 75 | return 76 | } 77 | 78 | is.Lock() 79 | defer is.Unlock() 80 | is.catalog[*inst.InstanceId] = inst 81 | } 82 | 83 | func (is *instanceManager) get(id string) (inst *instance) { 84 | is.RLock() 85 | defer is.RUnlock() 86 | return is.catalog[id] 87 | } 88 | 89 | func (is *instanceManager) count() int { 90 | is.RLock() 91 | defer is.RUnlock() 92 | 93 | return len(is.catalog) 94 | } 95 | 96 | func (is *instanceManager) count64() int64 { 97 | return int64(is.count()) 98 | } 99 | 100 | func (is *instanceManager) instances() <-chan *instance { 101 | retC := make(chan *instance) 102 | go func() { 103 | is.RLock() 104 | defer is.RUnlock() 105 | defer close(retC) 106 | for _, i := range is.catalog { 107 | retC <- i 108 | } 109 | }() 110 | 111 | return retC 112 | } 113 | -------------------------------------------------------------------------------- /core/instance_manager_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | import ( 7 | "reflect" 8 | "testing" 9 | 10 | "github.com/aws/aws-sdk-go/aws" 11 | "github.com/aws/aws-sdk-go/service/ec2" 12 | ) 13 | 14 | func TestMake(t *testing.T) { 15 | expected := instanceMap{} 16 | is := &instanceManager{} 17 | 18 | is.make() 19 | if !reflect.DeepEqual(is.catalog, expected) { 20 | t.Errorf("Catalog's type: '%s' expected: '%s'", 21 | reflect.TypeOf(is.catalog).String(), 22 | reflect.TypeOf(expected).String()) 23 | } 24 | } 25 | 26 | func TestAdd(t *testing.T) { 27 | tests := []struct { 28 | name string 29 | catalog instanceMap 30 | expected instanceMap 31 | }{ 32 | {name: "map contains a nil pointer", 33 | catalog: instanceMap{ 34 | "inst1": {Instance: &ec2.Instance{InstanceId: aws.String("1")}}, 35 | "inst2": nil, 36 | }, 37 | expected: instanceMap{ 38 | "1": {Instance: &ec2.Instance{InstanceId: aws.String("1")}}, 39 | }, 40 | }, 41 | {name: "map has 1 instance", 42 | catalog: instanceMap{ 43 | "inst1": {Instance: &ec2.Instance{InstanceId: aws.String("1")}}, 44 | }, 45 | expected: instanceMap{ 46 | "1": {Instance: &ec2.Instance{InstanceId: aws.String("1")}}, 47 | }, 48 | }, 49 | {name: "map has several instances", 50 | catalog: instanceMap{ 51 | "inst1": {Instance: &ec2.Instance{InstanceId: aws.String("1")}}, 52 | "inst2": {Instance: &ec2.Instance{InstanceId: aws.String("2")}}, 53 | "inst3": {Instance: &ec2.Instance{InstanceId: aws.String("3")}}, 54 | }, 55 | expected: instanceMap{ 56 | "1": {Instance: &ec2.Instance{InstanceId: aws.String("1")}}, 57 | "2": {Instance: &ec2.Instance{InstanceId: aws.String("2")}}, 58 | "3": {Instance: &ec2.Instance{InstanceId: aws.String("3")}}, 59 | }, 60 | }, 61 | } 62 | 63 | for _, tt := range tests { 64 | t.Run(tt.name, func(t *testing.T) { 65 | is := &instanceManager{} 66 | is.make() 67 | for _, c := range tt.catalog { 68 | is.add(c) 69 | } 70 | if !reflect.DeepEqual(tt.expected, is.catalog) { 71 | t.Errorf("Value received: %v expected %v", is.catalog, tt.expected) 72 | } 73 | }) 74 | } 75 | } 76 | 77 | func TestGet(t *testing.T) { 78 | tests := []struct { 79 | name string 80 | catalog instanceMap 81 | idToGet string 82 | expected *instance 83 | }{ 84 | {name: "map contains the required instance", 85 | catalog: instanceMap{ 86 | "inst1": {Instance: &ec2.Instance{InstanceId: aws.String("1")}}, 87 | "inst2": {Instance: &ec2.Instance{InstanceId: aws.String("2")}}, 88 | "inst3": {Instance: &ec2.Instance{InstanceId: aws.String("3")}}, 89 | }, 90 | idToGet: "inst2", 91 | expected: &instance{Instance: &ec2.Instance{InstanceId: aws.String("2")}}, 92 | }, 93 | {name: "catalog doesn't contain the instance", 94 | catalog: instanceMap{ 95 | "inst1": {Instance: &ec2.Instance{InstanceId: aws.String("1")}}, 96 | "inst2": {Instance: &ec2.Instance{InstanceId: aws.String("2")}}, 97 | "inst3": {Instance: &ec2.Instance{InstanceId: aws.String("3")}}, 98 | }, 99 | idToGet: "7", 100 | expected: nil, 101 | }, 102 | } 103 | 104 | for _, tt := range tests { 105 | t.Run(tt.name, func(t *testing.T) { 106 | is := &instanceManager{} 107 | is.make() 108 | is.catalog = tt.catalog 109 | retInstance := is.get(tt.idToGet) 110 | if !reflect.DeepEqual(tt.expected, retInstance) { 111 | t.Errorf("Value received: %v expected %v", retInstance, tt.expected) 112 | } 113 | }) 114 | } 115 | } 116 | 117 | func TestCount(t *testing.T) { 118 | tests := []struct { 119 | name string 120 | catalog instanceMap 121 | expected int 122 | }{ 123 | {name: "map is nil", 124 | catalog: nil, 125 | expected: 0, 126 | }, 127 | {name: "map is empty", 128 | catalog: instanceMap{}, 129 | expected: 0, 130 | }, 131 | {name: "map has 1 instance", 132 | catalog: instanceMap{ 133 | "id-1": {}, 134 | }, 135 | expected: 1, 136 | }, 137 | {name: "map has several instances", 138 | catalog: instanceMap{ 139 | "id-1": {}, 140 | "id-2": {}, 141 | "id-3": {}, 142 | }, 143 | expected: 3, 144 | }, 145 | } 146 | 147 | for _, tt := range tests { 148 | t.Run(tt.name, func(t *testing.T) { 149 | is := &instanceManager{} 150 | is.catalog = tt.catalog 151 | ret := is.count() 152 | if ret != tt.expected { 153 | t.Errorf("Value received: '%d' expected %d", ret, tt.expected) 154 | } 155 | }) 156 | } 157 | } 158 | 159 | func TestCount64(t *testing.T) { 160 | tests := []struct { 161 | name string 162 | catalog instanceMap 163 | expected int64 164 | }{ 165 | {name: "map is nil", 166 | catalog: nil, 167 | expected: 0, 168 | }, 169 | {name: "map is empty", 170 | catalog: instanceMap{}, 171 | expected: 0, 172 | }, 173 | {name: "map has 1 instance", 174 | catalog: instanceMap{ 175 | "id-1": {}, 176 | }, 177 | expected: 1, 178 | }, 179 | {name: "map has several instances", 180 | catalog: instanceMap{ 181 | "id-1": {}, 182 | "id-2": {}, 183 | "id-3": {}, 184 | }, 185 | expected: 3, 186 | }, 187 | } 188 | 189 | for _, tt := range tests { 190 | t.Run(tt.name, func(t *testing.T) { 191 | is := &instanceManager{} 192 | is.catalog = tt.catalog 193 | ret := is.count64() 194 | if ret != tt.expected { 195 | t.Errorf("Value received: '%d' expected %d", ret, tt.expected) 196 | } 197 | }) 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /core/launch_configuration.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | import ( 7 | "log" 8 | "strings" 9 | 10 | "github.com/aws/aws-sdk-go/service/autoscaling" 11 | ) 12 | 13 | type launchConfiguration struct { 14 | *autoscaling.LaunchConfiguration 15 | } 16 | 17 | func (lc *launchConfiguration) countLaunchConfigEphemeralVolumes() int { 18 | count := 0 19 | 20 | if lc == nil || lc.BlockDeviceMappings == nil { 21 | return count 22 | } 23 | 24 | for _, mapping := range lc.BlockDeviceMappings { 25 | if mapping.VirtualName != nil && 26 | strings.Contains(*mapping.VirtualName, "ephemeral") { 27 | debug.Println("Found ephemeral device mapping", *mapping.VirtualName) 28 | count++ 29 | } 30 | } 31 | 32 | log.Printf("Launch configuration would attach %d ephemeral volumes if available", count) 33 | 34 | return count 35 | } 36 | -------------------------------------------------------------------------------- /core/launch_configuration_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/aws/aws-sdk-go/aws" 10 | "github.com/aws/aws-sdk-go/service/autoscaling" 11 | ) 12 | 13 | func Test_countLaunchConfigEphemeralVolumes(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | lc *launchConfiguration 17 | count int 18 | }{ 19 | { 20 | name: "empty launchConfiguration", 21 | lc: &launchConfiguration{ 22 | LaunchConfiguration: &autoscaling.LaunchConfiguration{ 23 | BlockDeviceMappings: nil, 24 | }, 25 | }, 26 | count: 0, 27 | }, 28 | { 29 | name: "empty BlockDeviceMappings", 30 | lc: &launchConfiguration{ 31 | LaunchConfiguration: &autoscaling.LaunchConfiguration{ 32 | BlockDeviceMappings: []*autoscaling.BlockDeviceMapping{ 33 | {}, 34 | }, 35 | }, 36 | }, 37 | count: 0, 38 | }, 39 | { 40 | name: "mix of valid and invalid configuration", 41 | lc: &launchConfiguration{ 42 | LaunchConfiguration: &autoscaling.LaunchConfiguration{ 43 | BlockDeviceMappings: []*autoscaling.BlockDeviceMapping{ 44 | {VirtualName: aws.String("ephemeral")}, 45 | {}, 46 | }, 47 | }, 48 | }, 49 | count: 1, 50 | }, 51 | { 52 | name: "valid configuration", 53 | lc: &launchConfiguration{ 54 | LaunchConfiguration: &autoscaling.LaunchConfiguration{ 55 | BlockDeviceMappings: []*autoscaling.BlockDeviceMapping{ 56 | {VirtualName: aws.String("ephemeral")}, 57 | {VirtualName: aws.String("ephemeral")}, 58 | }, 59 | }, 60 | }, 61 | count: 2, 62 | }, 63 | } 64 | 65 | for _, tc := range tests { 66 | t.Run(tc.name, func(t *testing.T) { 67 | count := tc.lc.countLaunchConfigEphemeralVolumes() 68 | if count != tc.count { 69 | t.Errorf("count expected: %d, actual: %d", tc.count, count) 70 | } 71 | }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /core/launch_template.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | import ( 7 | "log" 8 | "strings" 9 | 10 | "github.com/aws/aws-sdk-go/service/ec2" 11 | ) 12 | 13 | type launchTemplate struct { 14 | *ec2.LaunchTemplateVersion 15 | *ec2.Image 16 | } 17 | 18 | func (lt *launchTemplate) countLaunchTemplateEphemeralVolumes() int { 19 | count := 0 20 | 21 | if lt == nil || lt.Image == nil || lt.Image.BlockDeviceMappings == nil { 22 | return count 23 | } 24 | 25 | for _, mapping := range lt.Image.BlockDeviceMappings { 26 | if mapping.VirtualName != nil && 27 | strings.Contains(*mapping.VirtualName, "ephemeral") { 28 | debug.Println("Found ephemeral device mapping", *mapping.VirtualName) 29 | count++ 30 | } 31 | } 32 | 33 | log.Printf("Launch template version would attach %d ephemeral volumes if available", count) 34 | 35 | return count 36 | } 37 | -------------------------------------------------------------------------------- /core/launch_template_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/aws/aws-sdk-go/aws" 10 | "github.com/aws/aws-sdk-go/service/ec2" 11 | ) 12 | 13 | func Test_countLaunchTemplateEphemeralVolumes(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | lt *launchTemplate 17 | count int 18 | }{ 19 | { 20 | name: "empty LaunchTemplate", 21 | lt: &launchTemplate{}, 22 | count: 0, 23 | }, 24 | { 25 | name: "empty BlockDeviceMappings", 26 | lt: &launchTemplate{ 27 | LaunchTemplateVersion: &ec2.LaunchTemplateVersion{}, 28 | Image: &ec2.Image{ 29 | BlockDeviceMappings: []*ec2.BlockDeviceMapping{ 30 | {}, 31 | }, 32 | }, 33 | }, 34 | count: 0, 35 | }, 36 | { 37 | name: "mix of valid and invalid configuration", 38 | lt: &launchTemplate{ 39 | LaunchTemplateVersion: &ec2.LaunchTemplateVersion{}, 40 | Image: &ec2.Image{ 41 | BlockDeviceMappings: []*ec2.BlockDeviceMapping{ 42 | {VirtualName: aws.String("ephemeral")}, 43 | {}, 44 | }, 45 | }, 46 | }, 47 | count: 1, 48 | }, 49 | { 50 | name: "valid configuration", 51 | lt: &launchTemplate{ 52 | LaunchTemplateVersion: &ec2.LaunchTemplateVersion{}, 53 | Image: &ec2.Image{ 54 | BlockDeviceMappings: []*ec2.BlockDeviceMapping{ 55 | {VirtualName: aws.String("ephemeral")}, 56 | {VirtualName: aws.String("ephemeral")}, 57 | }, 58 | }, 59 | }, 60 | count: 2, 61 | }, 62 | } 63 | 64 | for _, tc := range tests { 65 | t.Run(tc.name, func(t *testing.T) { 66 | count := tc.lt.countLaunchTemplateEphemeralVolumes() 67 | if count != tc.count { 68 | t.Errorf("count expected: %d, actual: %d", tc.count, count) 69 | } 70 | }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /core/main_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "log" 11 | "os" 12 | "reflect" 13 | "testing" 14 | 15 | "github.com/aws/aws-sdk-go/aws" 16 | "github.com/aws/aws-sdk-go/service/ec2" 17 | ) 18 | 19 | func TestMain(m *testing.M) { 20 | a := &AutoSpotting{} 21 | 22 | a.Init(&Config{ 23 | MainRegion: "us-east-1", 24 | }) 25 | 26 | var logOutput io.Writer 27 | 28 | if os.Getenv("AUTOSPOTTING_DEBUG") == "true" { 29 | logOutput = os.Stdout 30 | } else { 31 | logOutput = ioutil.Discard 32 | } 33 | 34 | log.SetOutput(logOutput) 35 | debug = log.New(logOutput, "", 0) 36 | 37 | os.Exit(m.Run()) 38 | } 39 | 40 | func Test_getRegions(t *testing.T) { 41 | 42 | tests := []struct { 43 | name string 44 | ec2conn mockEC2 45 | want []string 46 | wantErr error 47 | }{{ 48 | name: "return some regions", 49 | ec2conn: mockEC2{ 50 | dro: &ec2.DescribeRegionsOutput{ 51 | Regions: []*ec2.Region{ 52 | {RegionName: aws.String("foo")}, 53 | {RegionName: aws.String("bar")}, 54 | }, 55 | }, 56 | drerr: nil, 57 | }, 58 | want: []string{"foo", "bar"}, 59 | wantErr: nil, 60 | }, 61 | { 62 | name: "return an error", 63 | ec2conn: mockEC2{ 64 | 65 | dro: &ec2.DescribeRegionsOutput{ 66 | Regions: []*ec2.Region{ 67 | {RegionName: aws.String("foo")}, 68 | {RegionName: aws.String("bar")}, 69 | }, 70 | }, 71 | drerr: fmt.Errorf("fooErr"), 72 | }, 73 | want: nil, 74 | wantErr: fmt.Errorf("fooErr"), 75 | }, 76 | } 77 | for _, tt := range tests { 78 | t.Run(tt.name, func(t *testing.T) { 79 | as.mainEC2Conn = tt.ec2conn 80 | 81 | got, err := as.getRegions() 82 | CheckErrors(t, err, tt.wantErr) 83 | 84 | if !reflect.DeepEqual(got, tt.want) { 85 | t.Errorf("getRegions() = %v, want %v", got, tt.want) 86 | } 87 | }) 88 | } 89 | } 90 | 91 | func Test_spotEnabledIsAddedByDefault(t *testing.T) { 92 | 93 | tests := []struct { 94 | name string 95 | config Config 96 | want string 97 | }{ 98 | { 99 | name: "Default No ASG Tags", 100 | config: Config{}, 101 | want: "spot-enabled=true", 102 | }, 103 | { 104 | name: "Specified ASG Tags", 105 | config: Config{ 106 | FilterByTags: "environment=dev", 107 | }, 108 | want: "environment=dev", 109 | }, 110 | { 111 | name: "Specified ASG that is just whitespace", 112 | config: Config{ 113 | FilterByTags: " ", 114 | }, 115 | want: "spot-enabled=true", 116 | }, 117 | { 118 | name: "Default No ASG Tags", 119 | config: Config{TagFilteringMode: "opt-out"}, 120 | want: "spot-enabled=false", 121 | }, 122 | } 123 | 124 | for _, tt := range tests { 125 | t.Run(tt.name, func(t *testing.T) { 126 | 127 | tt.config.addDefaultFilter() 128 | 129 | if !reflect.DeepEqual(tt.config.FilterByTags, tt.want) { 130 | t.Errorf("addDefaultFilter() = %v, want %v", tt.config.FilterByTags, tt.want) 131 | } 132 | }) 133 | } 134 | } 135 | 136 | func Test_addDefaultFilterMode(t *testing.T) { 137 | tests := []struct { 138 | name string 139 | cfg Config 140 | want string 141 | }{ 142 | { 143 | name: "Missing FilterMode", 144 | cfg: Config{TagFilteringMode: ""}, 145 | want: "opt-in", 146 | }, 147 | { 148 | name: "Opt-in FilterMode", 149 | cfg: Config{ 150 | TagFilteringMode: "opt-in", 151 | }, 152 | want: "opt-in", 153 | }, 154 | { 155 | name: "Opt-out FilterMode", 156 | cfg: Config{ 157 | TagFilteringMode: "opt-out", 158 | }, 159 | want: "opt-out", 160 | }, 161 | { 162 | name: "Anything else gives the opt-in FilterMode", 163 | cfg: Config{ 164 | TagFilteringMode: "whatever", 165 | }, 166 | want: "opt-in", 167 | }, 168 | } 169 | 170 | for _, tt := range tests { 171 | t.Run(tt.name, func(t *testing.T) { 172 | tt.cfg.addDefaultFilteringMode() 173 | if !reflect.DeepEqual(tt.cfg.TagFilteringMode, tt.want) { 174 | t.Errorf("addDefaultFilteringMode() = %v, want %v", 175 | tt.cfg.TagFilteringMode, tt.want) 176 | } 177 | }) 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /core/marketplace_metering.go: -------------------------------------------------------------------------------- 1 | package autospotting 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "time" 7 | 8 | "github.com/aws/aws-sdk-go/aws" 9 | "github.com/aws/aws-sdk-go/aws/session" 10 | "github.com/aws/aws-sdk-go/service/marketplacemetering" 11 | "github.com/aws/aws-sdk-go/service/ssm" 12 | ) 13 | 14 | // SSMParameterName stores the name of the SSM parameter that stores the success status of the latest metering call 15 | const SSMParameterName = "autospotting-metering" 16 | 17 | func meterMarketplaceUsage(savings float64) error { 18 | 19 | // Metering is supposed to be done from Fargate, but we check it here and return an error in case it failed before 20 | if RunningFromLambda() { 21 | log.Println("Running from Lambda") 22 | if failedFromFargate() { 23 | log.Println("Metering failed previously, exiting...") 24 | return errors.New("metering previously failed") 25 | } 26 | log.Println("Metering succeeded previously from Fargate, moving on...") 27 | return nil 28 | } 29 | 30 | mySession := session.Must(session.NewSession()) 31 | 32 | // Create a MarketplaceMetering client with additional configuration 33 | svc := marketplacemetering.New(mySession, aws.NewConfig()) 34 | 35 | charge := savings * 0.01 * as.config.SavingsCut 36 | units := int64(charge * 1000) 37 | 38 | log.Printf("Billing %v units for $%v saved/hour (%v%% of the generated savings of $%v/hour)", 39 | units, charge, as.config.SavingsCut, savings) 40 | 41 | res, err := svc.MeterUsage(&marketplacemetering.MeterUsageInput{ 42 | ProductCode: aws.String("9e5m3z5f5hlwdqcrv16xdi040"), 43 | Timestamp: aws.Time(time.Now()), 44 | UsageDimension: aws.String("SavingsCut"), 45 | UsageQuantity: aws.Int64(units), 46 | }) 47 | 48 | if err != nil { 49 | log.Printf("Error submitting AWS Marketplace metering data: %v, received response: %v\n", err.Error(), res.String()) 50 | markAsFailingFromFargate() 51 | return err 52 | } 53 | 54 | markAsSuccessfulFromFargate() 55 | return nil 56 | } 57 | 58 | func putSSMParameter(status string) { 59 | mySession := session.Must(session.NewSession()) 60 | 61 | // Create a SSM client 62 | svc := ssm.New(mySession, aws.NewConfig().WithRegion("us-east-1")) 63 | 64 | _, err := svc.PutParameter(&ssm.PutParameterInput{ 65 | Name: aws.String(SSMParameterName), 66 | Overwrite: aws.Bool(true), 67 | Type: aws.String("String"), 68 | Value: aws.String(status), 69 | }) 70 | if err != nil { 71 | log.Printf("Error persisting marketplace metering status(%s) to SSM: %s", status, err.Error()) 72 | } 73 | } 74 | 75 | func markAsSuccessfulFromFargate() { 76 | putSSMParameter("success") 77 | } 78 | 79 | func markAsFailingFromFargate() { 80 | putSSMParameter("failure") 81 | } 82 | 83 | func failedFromFargate() bool { 84 | mySession := session.Must(session.NewSession()) 85 | // Create a SSM client 86 | svc := ssm.New(mySession, aws.NewConfig().WithRegion("us-east-1")) 87 | res, err := svc.GetParameter(&ssm.GetParameterInput{ 88 | Name: aws.String(SSMParameterName), 89 | }) 90 | 91 | if err != nil { 92 | log.Printf("Error reading marketplace metering status from SSM") 93 | if _, ok := err.(*ssm.ParameterNotFound); ok { 94 | log.Printf("Parameter not found: %v", err.Error()) 95 | return false 96 | } 97 | log.Printf("Encountered error: %v", err.Error()) 98 | return true 99 | } 100 | 101 | status := *res.Parameter.Value 102 | log.Printf("Retrieved marketplace metering status from SSM: %s", status) 103 | return status == "failure" 104 | } 105 | -------------------------------------------------------------------------------- /core/mock_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | import ( 7 | "strings" 8 | "testing" 9 | 10 | "github.com/aws/aws-sdk-go/service/autoscaling" 11 | "github.com/aws/aws-sdk-go/service/autoscaling/autoscalingiface" 12 | "github.com/aws/aws-sdk-go/service/cloudformation" 13 | "github.com/aws/aws-sdk-go/service/cloudformation/cloudformationiface" 14 | "github.com/aws/aws-sdk-go/service/ec2" 15 | "github.com/aws/aws-sdk-go/service/ec2/ec2iface" 16 | "github.com/aws/aws-sdk-go/service/sqs" 17 | "github.com/aws/aws-sdk-go/service/sqs/sqsiface" 18 | ) 19 | 20 | func CheckErrors(t *testing.T, err error, expected error) { 21 | if err != nil && expected != nil && !strings.Contains(err.Error(), expected.Error()) { 22 | t.Errorf("Error received: '%v' expected '%v'", 23 | err.Error(), expected.Error()) 24 | } 25 | } 26 | 27 | // All fields are composed of the abbreviation of their method 28 | // This is useful when methods are doing multiple calls to AWS API 29 | type mockEC2 struct { 30 | ec2iface.EC2API 31 | 32 | // CreateLaunchTemplate Output and error 33 | clto *ec2.CreateLaunchTemplateOutput 34 | clterr error 35 | 36 | // CreateFleet input/error 37 | cfo *ec2.CreateFleetOutput 38 | cferr error 39 | 40 | // DescribeSpotPriceHistoryPages output 41 | dsphpo []*ec2.DescribeSpotPriceHistoryOutput 42 | dsphperr error 43 | 44 | // DescribeInstancesOutput 45 | dio *ec2.DescribeInstancesOutput 46 | 47 | // DescribeInstancesPages error 48 | diperr error 49 | 50 | // DescribeInstanceAttribute 51 | diao *ec2.DescribeInstanceAttributeOutput 52 | diaerr error 53 | 54 | // DescribeImagesOutput 55 | damio *ec2.DescribeImagesOutput 56 | damierr error 57 | 58 | // Terminate Instance 59 | tio *ec2.TerminateInstancesOutput 60 | tierr error 61 | 62 | // DeleteLaunchTemplate 63 | dlto *ec2.DeleteLaunchTemplateOutput 64 | dlterr error 65 | 66 | // Describe Regions 67 | dro *ec2.DescribeRegionsOutput 68 | drerr error 69 | 70 | // Delete Tags 71 | dto *ec2.DeleteTagsOutput 72 | dterr error 73 | 74 | // DescribeLaunchTemplateVersionsOutput 75 | dltvo *ec2.DescribeLaunchTemplateVersionsOutput 76 | dltverr error 77 | 78 | // WaitUntilInstanceRunning error 79 | wuirerr error 80 | } 81 | 82 | func (m mockEC2) CreateFleet(in *ec2.CreateFleetInput) (*ec2.CreateFleetOutput, error) { 83 | return m.cfo, m.cferr 84 | } 85 | 86 | func (m mockEC2) CreateLaunchTemplate(in *ec2.CreateLaunchTemplateInput) (*ec2.CreateLaunchTemplateOutput, error) { 87 | return m.clto, m.clterr 88 | } 89 | 90 | func (m mockEC2) DeleteLaunchTemplate(*ec2.DeleteLaunchTemplateInput) (*ec2.DeleteLaunchTemplateOutput, error) { 91 | return m.dlto, m.dlterr 92 | } 93 | 94 | func (m mockEC2) DescribeSpotPriceHistoryPages(in *ec2.DescribeSpotPriceHistoryInput, f func(*ec2.DescribeSpotPriceHistoryOutput, bool) bool) error { 95 | for i, page := range m.dsphpo { 96 | f(page, i == len(m.dsphpo)-1) 97 | } 98 | return m.dsphperr 99 | } 100 | 101 | func (m mockEC2) DescribeInstancesPages(in *ec2.DescribeInstancesInput, f func(*ec2.DescribeInstancesOutput, bool) bool) error { 102 | f(m.dio, true) 103 | return m.diperr 104 | } 105 | 106 | func (m mockEC2) DescribeInstanceAttribute(in *ec2.DescribeInstanceAttributeInput) (*ec2.DescribeInstanceAttributeOutput, error) { 107 | return m.diao, m.diaerr 108 | } 109 | 110 | func (m mockEC2) DescribeImages(in *ec2.DescribeImagesInput) (*ec2.DescribeImagesOutput, error) { 111 | return m.damio, m.damierr 112 | } 113 | 114 | func (m mockEC2) TerminateInstances(*ec2.TerminateInstancesInput) (*ec2.TerminateInstancesOutput, error) { 115 | return m.tio, m.tierr 116 | } 117 | 118 | func (m mockEC2) DescribeRegions(*ec2.DescribeRegionsInput) (*ec2.DescribeRegionsOutput, error) { 119 | return m.dro, m.drerr 120 | } 121 | 122 | func (m mockEC2) DeleteTags(*ec2.DeleteTagsInput) (*ec2.DeleteTagsOutput, error) { 123 | return m.dto, m.dterr 124 | } 125 | 126 | func (m mockEC2) DescribeLaunchTemplateVersions(*ec2.DescribeLaunchTemplateVersionsInput) (*ec2.DescribeLaunchTemplateVersionsOutput, error) { 127 | return m.dltvo, m.dltverr 128 | } 129 | 130 | func (m mockEC2) WaitUntilInstanceRunning(*ec2.DescribeInstancesInput) error { 131 | return m.wuirerr 132 | } 133 | 134 | // All fields are composed of the abbreviation of their method 135 | // This is useful when methods are doing multiple calls to AWS API 136 | type mockASG struct { 137 | autoscalingiface.AutoScalingAPI 138 | // Detach Instances 139 | dio *autoscaling.DetachInstancesOutput 140 | dierr error 141 | // Terminate Instances 142 | tiiasgo *autoscaling.TerminateInstanceInAutoScalingGroupOutput 143 | tiiasgerr error 144 | // Attach Instances 145 | aio *autoscaling.AttachInstancesOutput 146 | aierr error 147 | // Describe Launch Config 148 | dlco *autoscaling.DescribeLaunchConfigurationsOutput 149 | dlcerr error 150 | // Update AutoScaling Group 151 | uasgo *autoscaling.UpdateAutoScalingGroupOutput 152 | uasgerr error 153 | // Describe Tags 154 | dto *autoscaling.DescribeTagsOutput 155 | 156 | // Describe AutoScaling Group 157 | dasgo *autoscaling.DescribeAutoScalingGroupsOutput 158 | dasgerr error 159 | 160 | // Describe AutoScalingInstances 161 | dasio *autoscaling.DescribeAutoScalingInstancesOutput 162 | dasierr error 163 | 164 | // DescribeLifecycleHooks 165 | dlho *autoscaling.DescribeLifecycleHooksOutput 166 | dlherr error 167 | 168 | // CreateOrUpdateTags 169 | couto *autoscaling.CreateOrUpdateTagsOutput 170 | couterr error 171 | } 172 | 173 | func (m mockASG) DetachInstances(*autoscaling.DetachInstancesInput) (*autoscaling.DetachInstancesOutput, error) { 174 | return m.dio, m.dierr 175 | } 176 | 177 | func (m mockASG) TerminateInstanceInAutoScalingGroup(*autoscaling.TerminateInstanceInAutoScalingGroupInput) (*autoscaling.TerminateInstanceInAutoScalingGroupOutput, error) { 178 | return m.tiiasgo, m.tiiasgerr 179 | } 180 | 181 | func (m mockASG) AttachInstances(*autoscaling.AttachInstancesInput) (*autoscaling.AttachInstancesOutput, error) { 182 | return m.aio, m.aierr 183 | } 184 | 185 | func (m mockASG) DescribeLaunchConfigurations(*autoscaling.DescribeLaunchConfigurationsInput) (*autoscaling.DescribeLaunchConfigurationsOutput, error) { 186 | return m.dlco, m.dlcerr 187 | } 188 | 189 | func (m mockASG) UpdateAutoScalingGroup(*autoscaling.UpdateAutoScalingGroupInput) (*autoscaling.UpdateAutoScalingGroupOutput, error) { 190 | return m.uasgo, m.uasgerr 191 | } 192 | 193 | func (m mockASG) DescribeTagsPages(input *autoscaling.DescribeTagsInput, function func(*autoscaling.DescribeTagsOutput, bool) bool) error { 194 | function(m.dto, true) 195 | return nil 196 | } 197 | 198 | func (m mockASG) DescribeAutoScalingGroups(input *autoscaling.DescribeAutoScalingGroupsInput) (*autoscaling.DescribeAutoScalingGroupsOutput, error) { 199 | return m.dasgo, m.dasgerr 200 | } 201 | 202 | func (m mockASG) DescribeAutoScalingGroupsPages(input *autoscaling.DescribeAutoScalingGroupsInput, function func(*autoscaling.DescribeAutoScalingGroupsOutput, bool) bool) error { 203 | function(m.dasgo, true) 204 | return nil 205 | } 206 | 207 | func (m mockASG) DescribeAutoScalingInstances(input *autoscaling.DescribeAutoScalingInstancesInput) (*autoscaling.DescribeAutoScalingInstancesOutput, error) { 208 | return m.dasio, m.dasierr 209 | } 210 | 211 | func (m mockASG) DescribeLifecycleHooks(*autoscaling.DescribeLifecycleHooksInput) (*autoscaling.DescribeLifecycleHooksOutput, error) { 212 | return m.dlho, m.dlherr 213 | } 214 | 215 | func (m mockASG) CreateOrUpdateTags(*autoscaling.CreateOrUpdateTagsInput) (*autoscaling.CreateOrUpdateTagsOutput, error) { 216 | return m.couto, m.couterr 217 | } 218 | 219 | // All fields are composed of the abbreviation of their method 220 | // This is useful when methods are doing multiple calls to AWS API 221 | type mockCloudFormation struct { 222 | cloudformationiface.CloudFormationAPI 223 | // DescribeStacks 224 | dso *cloudformation.DescribeStacksOutput 225 | dserr error 226 | } 227 | 228 | func (m mockCloudFormation) DescribeStacks(*cloudformation.DescribeStacksInput) (*cloudformation.DescribeStacksOutput, error) { 229 | return m.dso, m.dserr 230 | } 231 | 232 | // All fields are composed of the abbreviation of their method 233 | // This is useful when methods are doing multiple calls to AWS API 234 | type mockSQS struct { 235 | sqsiface.SQSAPI 236 | // SendMessage 237 | smo *sqs.SendMessageOutput 238 | smerr error 239 | 240 | //DeleteMessage 241 | dmo *sqs.DeleteMessageOutput 242 | dmerr error 243 | } 244 | 245 | func (m mockSQS) SendMessage(*sqs.SendMessageInput) (*sqs.SendMessageOutput, error) { 246 | return m.smo, m.smerr 247 | } 248 | 249 | func (m mockSQS) DeleteMessage(*sqs.DeleteMessageInput) (*sqs.DeleteMessageOutput, error) { 250 | return m.dmo, m.dmerr 251 | } 252 | 253 | // utility function for checking if error messages are matching 254 | func errorMatches(got error, wanted error) bool { 255 | if got == nil { 256 | return wanted == nil 257 | } 258 | if wanted == nil { 259 | return false 260 | } 261 | return strings.Contains(got.Error(), wanted.Error()) 262 | } 263 | -------------------------------------------------------------------------------- /core/schedule.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | import ( 7 | "log" 8 | "time" 9 | 10 | "github.com/robfig/cron/v3" 11 | ) 12 | 13 | // insideSchedule returns true if the time given in the t parameter is matching 14 | // the implified cronrab-like interval restricted to only hours and days of the 15 | // week. Because the cron library be use only supports the local time, the 16 | // crontab entry will have to be created accoringly. When executed in Lambda the 17 | // runtime's local time will always be UTC, so users have to be made aware of 18 | // this through the documentation. 19 | func insideSchedule(t time.Time, crontab string, timezone string) (bool, error) { 20 | // Get the timezone, will cause an error if timezone is incorrect 21 | tz, err := time.LoadLocation(timezone) 22 | 23 | if err != nil { 24 | log.Println(err) 25 | return false, err 26 | } 27 | 28 | // Create a new cron job runner using the location details and a custom parser 29 | c := cron.New(cron.WithLocation(tz), cron.WithParser(cron.NewParser(cron.Hour|cron.Dow))) 30 | // Schedule an empty job based on out crontab 31 | entry, err := c.AddFunc(crontab, nil) 32 | 33 | if err != nil { 34 | log.Println(err) 35 | return false, err 36 | } 37 | 38 | // Grab the schedule for the job created above to use for calculations below. 39 | sched := c.Entry(entry).Schedule 40 | 41 | // When inside the cron interval, the next event from exactly an hour ago and the 42 | // next event from now are exactly one hour apart 43 | prev := sched.Next(t.In(tz).Add(-1 * time.Hour)) 44 | next := sched.Next(t.In(tz)) 45 | 46 | if next == prev.Add(1*time.Hour) { 47 | return true, nil 48 | } 49 | return false, nil 50 | } 51 | 52 | // returns true if the schedule is "on" and we're inside the interval also 53 | // returns true if the schedule is "off" and we're outside the interval returns 54 | // false in case of cron parsing error and other schedule parameter combinations 55 | func cronRunAction(t time.Time, crontab string, timezone string, scheduleType string) bool { 56 | inside, err := insideSchedule(t, crontab, timezone) 57 | debug.Println("Inside schedule for", crontab, ":", inside) 58 | 59 | if err != nil { 60 | return false 61 | } 62 | 63 | if (inside && scheduleType == CronScheduleStateOn) || (!inside && scheduleType == "off") { 64 | return true 65 | } 66 | 67 | return false 68 | } 69 | -------------------------------------------------------------------------------- /core/schedule_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | import ( 7 | "errors" 8 | "strings" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | func Test_insideSchedule(t *testing.T) { 14 | 15 | tests := []struct { 16 | name string 17 | t time.Time 18 | timezone string 19 | crontab string 20 | want bool 21 | wantErr error 22 | }{ 23 | { 24 | name: "All the time", 25 | crontab: DefaultCronSchedule, 26 | t: time.Date(2019, time.May, 9, 10, 0, 0, 0, time.UTC), 27 | timezone: "UTC", 28 | want: true, 29 | wantErr: nil, 30 | }, 31 | { 32 | name: "Inside business week", 33 | crontab: "9-18 1-5", 34 | t: time.Date(2019, time.May, 9, 10, 0, 0, 0, time.UTC), 35 | timezone: "UTC", 36 | want: true, 37 | wantErr: nil, 38 | }, 39 | { 40 | name: "Inside business week, before interval start", 41 | crontab: "9-18 1-5", 42 | t: time.Date(2019, time.May, 9, 4, 0, 0, 0, time.UTC), 43 | timezone: "UTC", 44 | want: false, 45 | wantErr: nil, 46 | }, 47 | { 48 | name: "Inside business week, after interval end", 49 | crontab: "9-18 1-5", 50 | t: time.Date(2019, time.May, 9, 21, 0, 0, 0, time.UTC), 51 | timezone: "UTC", 52 | want: false, 53 | wantErr: nil, 54 | }, 55 | 56 | { 57 | name: "Inside business week, One minute before interval start", 58 | crontab: "9-18 1-5", 59 | t: time.Date(2019, time.May, 9, 8, 59, 0, 0, time.UTC), 60 | timezone: "UTC", 61 | want: false, 62 | wantErr: nil, 63 | }, 64 | { 65 | name: "Inside business week, One minute after interval start", 66 | crontab: "9-18 1-5", 67 | t: time.Date(2019, time.May, 9, 9, 1, 0, 0, time.UTC), 68 | timezone: "UTC", 69 | want: true, 70 | wantErr: nil, 71 | }, 72 | { 73 | name: "Inside business week, One minute before interval end", 74 | crontab: "9-18 1-5", 75 | t: time.Date(2019, time.May, 9, 17, 59, 0, 0, time.UTC), 76 | timezone: "UTC", 77 | want: true, 78 | wantErr: nil, 79 | }, 80 | { 81 | name: "Inside business week, One minute after interval end", 82 | crontab: "9-18 1-5", 83 | t: time.Date(2019, time.May, 9, 18, 1, 0, 0, time.UTC), 84 | timezone: "UTC", 85 | want: false, 86 | wantErr: nil, 87 | }, 88 | { 89 | name: "During the weekend", 90 | crontab: "9-18 1-5", 91 | t: time.Date(2019, time.May, 11, 18, 0, 0, 0, time.UTC), 92 | timezone: "UTC", 93 | want: false, 94 | wantErr: nil, 95 | }, 96 | { 97 | name: "During the weekend, but incorrect crontab", 98 | crontab: "9- 1-5", 99 | t: time.Date(2019, time.May, 11, 18, 0, 0, 0, time.UTC), 100 | timezone: "UTC", 101 | want: false, 102 | wantErr: errors.New("invalid syntax"), 103 | }, 104 | { 105 | name: "Inside business week, inside in timezone, inside in UTC", 106 | crontab: "9-18 1-5", 107 | t: time.Date(2019, time.May, 9, 9, 1, 0, 0, time.UTC), 108 | timezone: "Europe/London", 109 | want: true, 110 | wantErr: nil, 111 | }, 112 | { 113 | name: "Inside business week, inside in timezone, outside in UTC", 114 | crontab: "9-18 1-5", 115 | t: time.Date(2019, time.May, 9, 8, 1, 0, 0, time.UTC), 116 | timezone: "Europe/London", 117 | want: true, 118 | wantErr: nil, 119 | }, 120 | } 121 | for _, tt := range tests { 122 | t.Run(tt.name, func(t *testing.T) { 123 | if got, err := insideSchedule(tt.t, tt.crontab, tt.timezone); got != tt.want || 124 | // the err is checked for matching wantErr, doesn't need to be identical 125 | !(err == tt.wantErr || strings.Contains(err.Error(), tt.wantErr.Error())) { 126 | t.Errorf("insideSchedule() = %v, %v want %v, %v", got, err, tt.want, tt.wantErr) 127 | } 128 | }) 129 | } 130 | } 131 | 132 | func Test_runAction(t *testing.T) { 133 | 134 | tests := []struct { 135 | name string 136 | crontab string 137 | t time.Time 138 | timezone string 139 | scheduleType string 140 | want bool 141 | }{ 142 | { 143 | name: "On inside interval and currently in the interval", 144 | crontab: "9-18 1-5", 145 | t: time.Date(2019, time.May, 9, 10, 0, 0, 0, time.UTC), 146 | timezone: "UTC", 147 | scheduleType: "on", 148 | want: true, 149 | }, { 150 | name: "On inside interval, but currently outside interval", 151 | crontab: "9-18 1-5", 152 | t: time.Date(2019, time.May, 9, 20, 0, 0, 0, time.UTC), 153 | timezone: "UTC", 154 | scheduleType: "on", 155 | want: false, 156 | }, 157 | { 158 | name: "On inside interval, but inside timezone, outside UTC", 159 | crontab: "9-18 1-5", 160 | t: time.Date(2019, time.May, 9, 8, 1, 0, 0, time.UTC), 161 | timezone: "Europe/London", 162 | scheduleType: "on", 163 | want: true, 164 | }, 165 | { 166 | name: "Off inside interval, and currently in the interval", 167 | crontab: "9-18 1-5", 168 | t: time.Date(2019, time.May, 9, 10, 0, 0, 0, time.UTC), 169 | timezone: "UTC", 170 | scheduleType: "off", 171 | want: false, 172 | }, { 173 | name: "Off inside interval, and currently outside the interval", 174 | crontab: "9-18 1-5", 175 | t: time.Date(2019, time.May, 9, 20, 0, 0, 0, time.UTC), 176 | timezone: "UTC", 177 | scheduleType: "off", 178 | want: true, 179 | }, 180 | { 181 | name: "Off inside interval, but inside timezone, outside UTC", 182 | crontab: "9-18 1-5", 183 | t: time.Date(2019, time.May, 9, 8, 1, 0, 0, time.UTC), 184 | timezone: "Europe/London", 185 | scheduleType: "off", 186 | want: false, 187 | }, 188 | { 189 | name: "Incorrect cron rule", 190 | crontab: "-18 1-5", 191 | t: time.Date(2019, time.May, 9, 20, 0, 0, 0, time.UTC), 192 | timezone: "UTC", 193 | scheduleType: "off", 194 | want: false, 195 | }, 196 | } 197 | for _, tt := range tests { 198 | t.Run(tt.name, func(t *testing.T) { 199 | if got := cronRunAction(tt.t, tt.crontab, tt.timezone, tt.scheduleType); got != tt.want { 200 | t.Errorf("runAction() = %v, want %v", got, tt.want) 201 | } 202 | }) 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /core/spot_price.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | import ( 7 | "log" 8 | "time" 9 | 10 | "github.com/aws/aws-sdk-go/aws" 11 | "github.com/aws/aws-sdk-go/service/ec2" 12 | ) 13 | 14 | type spotPrices struct { 15 | data []*ec2.SpotPrice 16 | conn connections 17 | } 18 | 19 | // fetch queries all spot prices in the current region 20 | func (s *spotPrices) fetch(product string, 21 | duration time.Duration, 22 | availabilityZone *string, 23 | instanceTypes []*string) error { 24 | 25 | log.Println(s.conn.region, "Requesting spot prices") 26 | 27 | ec2Conn := s.conn.ec2 28 | params := &ec2.DescribeSpotPriceHistoryInput{ 29 | ProductDescriptions: []*string{ 30 | aws.String(product), 31 | }, 32 | StartTime: aws.Time(time.Now().Add(-1 * duration)), 33 | EndTime: aws.Time(time.Now()), 34 | AvailabilityZone: availabilityZone, 35 | InstanceTypes: instanceTypes, 36 | } 37 | 38 | data := []*ec2.SpotPrice{} 39 | err := ec2Conn.DescribeSpotPriceHistoryPages(params, func(page *ec2.DescribeSpotPriceHistoryOutput, lastPage bool) bool { 40 | data = append(data, page.SpotPriceHistory...) 41 | debug.Printf("DescribeSpotPriceHistory lastPage: %v", lastPage) 42 | return true 43 | }) 44 | 45 | if err != nil { 46 | log.Println(s.conn.region, "Failed requesting spot prices:", err.Error()) 47 | return err 48 | } 49 | 50 | s.data = data 51 | 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /core/spot_price_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | import ( 7 | "errors" 8 | "testing" 9 | "time" 10 | 11 | "github.com/aws/aws-sdk-go/aws" 12 | "github.com/aws/aws-sdk-go/service/ec2" 13 | ) 14 | 15 | func Test_fetch(t *testing.T) { 16 | tests := []struct { 17 | name string 18 | config *spotPrices 19 | product string 20 | duration time.Duration 21 | availabilityZone *string 22 | instanceTypes []*string 23 | data []*ec2.SpotPrice 24 | err error 25 | }{ 26 | { 27 | name: "error", 28 | config: &spotPrices{ 29 | data: []*ec2.SpotPrice{}, 30 | conn: connections{ 31 | ec2: mockEC2{ 32 | dsphpo: []*ec2.DescribeSpotPriceHistoryOutput{ 33 | { 34 | SpotPriceHistory: []*ec2.SpotPrice{}, 35 | }, 36 | }, 37 | dsphperr: errors.New("error"), 38 | }, 39 | }, 40 | }, 41 | data: []*ec2.SpotPrice{}, 42 | err: errors.New("error"), 43 | }, 44 | { 45 | name: "ok", 46 | config: &spotPrices{ 47 | data: []*ec2.SpotPrice{}, 48 | conn: connections{ 49 | ec2: mockEC2{ 50 | dsphpo: []*ec2.DescribeSpotPriceHistoryOutput{ 51 | { 52 | SpotPriceHistory: []*ec2.SpotPrice{ 53 | {SpotPrice: aws.String("1")}, 54 | }, 55 | }, 56 | }, 57 | }, 58 | }, 59 | }, 60 | data: []*ec2.SpotPrice{ 61 | {SpotPrice: aws.String("1")}, 62 | }, 63 | err: errors.New(""), 64 | }, 65 | { 66 | name: "paginated output", 67 | config: &spotPrices{ 68 | data: []*ec2.SpotPrice{}, 69 | conn: connections{ 70 | ec2: mockEC2{ 71 | dsphpo: []*ec2.DescribeSpotPriceHistoryOutput{ 72 | { 73 | SpotPriceHistory: []*ec2.SpotPrice{ 74 | {SpotPrice: aws.String("1")}, 75 | }, 76 | }, 77 | { 78 | SpotPriceHistory: []*ec2.SpotPrice{ 79 | {SpotPrice: aws.String("2")}, 80 | }, 81 | }, 82 | }, 83 | dsphperr: nil, 84 | }, 85 | }, 86 | }, 87 | data: []*ec2.SpotPrice{ 88 | {SpotPrice: aws.String("1")}, 89 | {SpotPrice: aws.String("2")}, 90 | }, 91 | }, 92 | } 93 | 94 | for _, tc := range tests { 95 | t.Run(tc.name, func(t *testing.T) { 96 | err := tc.config.fetch(tc.product, tc.duration, tc.availabilityZone, tc.instanceTypes) 97 | if len(tc.data) != len(tc.config.data) { 98 | t.Errorf("Price data actual: %v\nexpected: %v", tc.config.data, tc.data) 99 | } 100 | if len(tc.data) > 0 { 101 | str1 := *tc.data[0].SpotPrice 102 | str2 := *tc.config.data[0].SpotPrice 103 | if str1 != str2 { 104 | t.Errorf("Price actual: %s, expected: %s", str2, str1) 105 | } 106 | } 107 | if err != nil && err.Error() != tc.err.Error() { 108 | t.Errorf("error expected: %s, actual: %s", tc.err.Error(), err.Error()) 109 | } 110 | }) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /core/spot_termination.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | // Licensed under the Open Software License version 3.0 3 | 4 | package autospotting 5 | 6 | import ( 7 | "errors" 8 | "log" 9 | "strings" 10 | "time" 11 | 12 | "github.com/aws/aws-sdk-go/aws" 13 | "github.com/aws/aws-sdk-go/aws/session" 14 | "github.com/aws/aws-sdk-go/service/autoscaling" 15 | "github.com/aws/aws-sdk-go/service/autoscaling/autoscalingiface" 16 | "github.com/aws/aws-sdk-go/service/ec2" 17 | "github.com/aws/aws-sdk-go/service/ec2/ec2iface" 18 | ) 19 | 20 | const ( 21 | // DefaultTerminationNotificationAction is the default value for the termination notification 22 | // action configuration option 23 | DefaultTerminationNotificationAction = AutoTerminationNotificationAction 24 | ) 25 | 26 | // SpotTermination is used to detach an instance, used when a spot instance is due for termination 27 | type SpotTermination struct { 28 | asSvc autoscalingiface.AutoScalingAPI 29 | ec2Svc ec2iface.EC2API 30 | SleepMultiplier time.Duration 31 | } 32 | 33 | func newSpotTermination(region string) SpotTermination { 34 | 35 | log.Println("Connection to region ", region) 36 | 37 | session := session.Must( 38 | session.NewSession(&aws.Config{Region: aws.String(region)})) 39 | 40 | return SpotTermination{ 41 | 42 | asSvc: autoscaling.New(session), 43 | ec2Svc: ec2.New(session), 44 | SleepMultiplier: 1, 45 | } 46 | } 47 | 48 | // DetachInstance detaches the instance from autoscaling group without decrementing the desired capacity 49 | // This makes sure that the autoscaling group spawns a new instance as soon as this instance is detached 50 | func (s *SpotTermination) detachInstance(instanceID *string, asgName string, eventType string) error { 51 | 52 | log.Println(asgName, 53 | "Detaching instance:", 54 | *instanceID) 55 | 56 | detachParams := autoscaling.DetachInstancesInput{ 57 | AutoScalingGroupName: aws.String(asgName), 58 | InstanceIds: []*string{ 59 | instanceID, 60 | }, 61 | ShouldDecrementDesiredCapacity: aws.Bool(false), 62 | } 63 | if _, detachErr := s.asSvc.DetachInstances(&detachParams); detachErr != nil { 64 | log.Println(detachErr.Error()) 65 | return detachErr 66 | } 67 | 68 | log.Printf("Detached instance %s successfully", *instanceID) 69 | 70 | if eventType != InstanceRebalanceRecommendationCode { 71 | s.deleteTagInstanceLaunchedForAsg(instanceID) 72 | s.delayedTermination(instanceID, 14) 73 | } 74 | 75 | return nil 76 | } 77 | 78 | // delayedTermination is used to terminate instances that were marked as being in danger of being terminated. 79 | func (s *SpotTermination) delayedTermination(instanceID *string, minutes time.Duration) error { 80 | 81 | log.Printf("Terminating instance %s with %d minutes delay, sleeping...\n", 82 | *instanceID, minutes) 83 | 84 | time.Sleep(minutes * time.Minute * s.SleepMultiplier) 85 | 86 | log.Println("Terminating instance", *instanceID) 87 | // terminate the spot instance 88 | terminateParams := ec2.TerminateInstancesInput{ 89 | InstanceIds: []*string{instanceID}, 90 | } 91 | 92 | if _, err := s.ec2Svc.TerminateInstances(&terminateParams); err != nil { 93 | log.Println(err.Error()) 94 | return err 95 | } 96 | return nil 97 | } 98 | 99 | // TerminateInstance terminate the instance from autoscaling group without decrementing the desired capacity 100 | // This makes sure that any LifeCycle Hook configured is triggered and the autoscaling group spawns a new instance 101 | // as soon as this instance begin terminating. 102 | func (s *SpotTermination) terminateInstance(instanceID *string, asgName string) error { 103 | 104 | log.Println(asgName, 105 | "Terminating instance:", 106 | *instanceID) 107 | // terminate the spot instance 108 | terminateParams := autoscaling.TerminateInstanceInAutoScalingGroupInput{ 109 | InstanceId: instanceID, 110 | ShouldDecrementDesiredCapacity: aws.Bool(false), 111 | } 112 | 113 | if _, err := s.asSvc.TerminateInstanceInAutoScalingGroup(&terminateParams); err != nil { 114 | log.Println(err.Error()) 115 | return err 116 | } 117 | return nil 118 | } 119 | 120 | func (s *SpotTermination) getAsgName(instanceID *string) (string, error) { 121 | asParams := autoscaling.DescribeAutoScalingInstancesInput{ 122 | InstanceIds: []*string{instanceID}, 123 | } 124 | 125 | result, err := s.asSvc.DescribeAutoScalingInstances(&asParams) 126 | if err != nil { 127 | return "", err 128 | } else if len(result.AutoScalingInstances) == 0 { 129 | return "", nil 130 | } 131 | 132 | return *result.AutoScalingInstances[0].AutoScalingGroupName, nil 133 | } 134 | 135 | // ExecuteAction execute the proper termination action (terminate|detach) based on the value of 136 | // terminationNotificationAction and the presence of a LifecycleHook on ASG. 137 | func (s *SpotTermination) executeAction(instanceID *string, terminationNotificationAction string, eventType string) error { 138 | if s.asSvc == nil { 139 | return errors.New("AutoScaling service not defined. Please use NewSpotTermination()") 140 | } 141 | 142 | asgName, err := s.getAsgName(instanceID) 143 | 144 | if err != nil { 145 | log.Printf("Failed get ASG name for %s with err: %s\n", *instanceID, err.Error()) 146 | return err 147 | } else if asgName == "" { 148 | log.Println("Instance", instanceID, "does not belong to an autoscaling group") 149 | return nil 150 | } 151 | 152 | switch terminationNotificationAction { 153 | case "detach": 154 | s.detachInstance(instanceID, asgName, eventType) 155 | case "terminate": 156 | s.terminateInstance(instanceID, asgName) 157 | default: 158 | if s.asgHasTerminationLifecycleHook(&asgName) { 159 | s.terminateInstance(instanceID, asgName) 160 | } else { 161 | s.detachInstance(instanceID, asgName, eventType) 162 | } 163 | } 164 | 165 | return nil 166 | } 167 | 168 | func (s *SpotTermination) deleteTagInstanceLaunchedForAsg(instanceID *string) error { 169 | ec2Params := ec2.DeleteTagsInput{ 170 | Resources: []*string{ 171 | aws.String(*instanceID), 172 | }, 173 | Tags: []*ec2.Tag{ 174 | { 175 | Key: aws.String("launched-for-asg"), 176 | }, 177 | }, 178 | } 179 | _, err := s.ec2Svc.DeleteTags(&ec2Params) 180 | 181 | if err != nil { 182 | log.Printf("Failed to delete Tag 'launched-for-asg' from spot instance %s with err: %s\n", *instanceID, err.Error()) 183 | return err 184 | } 185 | 186 | log.Printf("Tag 'launched-for-asg' deleted from spot instance %s", *instanceID) 187 | 188 | return nil 189 | } 190 | 191 | func (s *SpotTermination) asgHasTerminationLifecycleHook(autoScalingGroupName *string) bool { 192 | asParams := autoscaling.DescribeLifecycleHooksInput{ 193 | AutoScalingGroupName: autoScalingGroupName, 194 | } 195 | 196 | result, err := s.asSvc.DescribeLifecycleHooks(&asParams) 197 | 198 | if err != nil { 199 | log.Println(err.Error()) 200 | return false 201 | } 202 | 203 | var hasHook = false 204 | for _, lfh := range result.LifecycleHooks { 205 | if *lfh.LifecycleTransition == "autoscaling:EC2_INSTANCE_TERMINATING" { 206 | hasHook = true 207 | log.Println("Found Hook", *lfh.LifecycleHookName) 208 | break 209 | } 210 | } 211 | 212 | return hasHook 213 | } 214 | 215 | // IsInAutoSpottingASG checks to see whether an instance is in an AutoSpotting ASG as defined by its tags. 216 | // If the ASG does not have the required tags, it is not an AutoSpotting ASG and should be left alone. 217 | func (s *SpotTermination) IsInAutoSpottingASG(instanceID *string, tagFilteringMode string, filterByTags string) bool { 218 | var optInFilterMode = (tagFilteringMode != "opt-out") 219 | 220 | asgName, err := s.getAsgName(instanceID) 221 | 222 | if err != nil { 223 | log.Printf("Failed get ASG name for %s with err: %s\n", *instanceID, err.Error()) 224 | return false 225 | } else if asgName == "" { 226 | log.Println("Instance", *instanceID, "is not in an autoscaling group") 227 | return false 228 | } 229 | 230 | asgGroupsOutput, err := s.asSvc.DescribeAutoScalingGroups(&autoscaling.DescribeAutoScalingGroupsInput{ 231 | AutoScalingGroupNames: []*string{ 232 | &asgName, 233 | }, 234 | }) 235 | 236 | if err != nil { 237 | log.Printf("Failed to get ASG using ASG name %s with err: %s\n", asgName, err.Error()) 238 | return false 239 | } 240 | 241 | filters := replaceWhitespace(filterByTags) 242 | 243 | var tagsToMatch = []Tag{} 244 | 245 | for _, tagWithValue := range strings.Split(filters, ",") { 246 | tag := splitTagAndValue(tagWithValue) 247 | if tag != nil { 248 | tagsToMatch = append(tagsToMatch, *tag) 249 | } 250 | } 251 | 252 | isInASG := optInFilterMode == isASGWithMatchingTags(asgGroupsOutput.AutoScalingGroups[0], tagsToMatch) 253 | 254 | if !isInASG { 255 | log.Printf("Skipping group %s because its tags, the currently "+ 256 | "configured filtering mode (%s) and tag filters do not align\n", 257 | asgName, tagFilteringMode) 258 | } 259 | 260 | return isInASG 261 | } 262 | -------------------------------------------------------------------------------- /core/util.go: -------------------------------------------------------------------------------- 1 | package autospotting 2 | 3 | func min(x, y int) int { 4 | if x < y { 5 | return x 6 | } 7 | return y 8 | } 9 | 10 | func max(x, y int) int { 11 | if x > y { 12 | return x 13 | } 14 | return y 15 | } 16 | 17 | func itemInSlice(search string, items []string) bool { 18 | for _, item := range items { 19 | if search == item { 20 | return true 21 | } 22 | } 23 | return false 24 | } 25 | -------------------------------------------------------------------------------- /core/util_test.go: -------------------------------------------------------------------------------- 1 | package autospotting 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func Test_min(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | x int 11 | y int 12 | want int 13 | }{ 14 | { 15 | name: "xy", 22 | x: 3, 23 | y: 2, 24 | want: 2, 25 | }, 26 | { 27 | name: "x=y", 28 | x: 3, 29 | y: 3, 30 | want: 3, 31 | }, 32 | } 33 | for _, tt := range tests { 34 | t.Run(tt.name, func(t *testing.T) { 35 | if got := min(tt.x, tt.y); got != tt.want { 36 | t.Errorf("min() = %v, want %v", got, tt.want) 37 | } 38 | }) 39 | } 40 | } 41 | func Test_max(t *testing.T) { 42 | tests := []struct { 43 | name string 44 | x int 45 | y int 46 | want int 47 | }{ 48 | { 49 | name: "xy", 56 | x: 3, 57 | y: 2, 58 | want: 3, 59 | }, 60 | { 61 | name: "x=y", 62 | x: 3, 63 | y: 3, 64 | want: 3, 65 | }, 66 | } 67 | for _, tt := range tests { 68 | t.Run(tt.name, func(t *testing.T) { 69 | if got := max(tt.x, tt.y); got != tt.want { 70 | t.Errorf("max() = %v, want %v", got, tt.want) 71 | } 72 | }) 73 | } 74 | } 75 | 76 | func Test_itemInSlice(t *testing.T) { 77 | tests := []struct { 78 | name string 79 | search string 80 | items []string 81 | want bool 82 | }{ 83 | { 84 | name: "item in slice", 85 | search: "b", 86 | items: []string{"a", "b", "c"}, 87 | want: true, 88 | }, 89 | { 90 | name: "item not in slice", 91 | search: "d", 92 | items: []string{"a", "b", "c"}, 93 | want: false, 94 | }, 95 | { 96 | name: "empty slice", 97 | search: "a", 98 | items: []string{}, 99 | want: false, 100 | }, 101 | } 102 | for _, tt := range tests { 103 | t.Run(tt.name, func(t *testing.T) { 104 | if got := itemInSlice(tt.search, tt.items); got != tt.want { 105 | t.Errorf("itemInSlice() = %v, want %v", got, tt.want) 106 | } 107 | }) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | autospotting: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile.build 7 | args: 8 | flavor: nightly 9 | environment: 10 | - AWS_ACCESS_KEY_ID 11 | - AWS_SECRET_ACCESS_KEY 12 | - AWS_SESSION_TOKEN 13 | entrypoint: 14 | - ./AutoSpotting 15 | volumes: 16 | - type: bind 17 | source: ./build 18 | target: /src/build 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/AutoSpotting/AutoSpotting 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/aws/aws-lambda-go v1.32.0 7 | github.com/aws/aws-sdk-go v1.44.44 8 | github.com/cristim/ec2-instances-info v0.0.0-20220623102241-067009cd38ea 9 | github.com/davecgh/go-spew v1.1.1 10 | github.com/mattn/goveralls v0.0.11 11 | github.com/namsral/flag v0.0.0-20170814194028-67f268f20922 12 | github.com/robfig/cron/v3 v3.0.1 13 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 14 | golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b // indirect 15 | golang.org/x/tools v0.1.11 16 | gopkg.in/yaml.v2 v2.4.0 // indirect 17 | gotest.tools/v3 v3.0.0 18 | ) 19 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-lambda-go v1.32.0 h1:i8MflawW1hoyYp85GMH7LhvAs4cqzL7LOS6fSv8l2KM= 2 | github.com/aws/aws-lambda-go v1.32.0/go.mod h1:IF5Q7wj4VyZyUFnZ54IQqeWtctHQ9tz+KhcbDenr220= 3 | github.com/aws/aws-sdk-go v1.44.44 h1:XLEcUxILvVBYO/frO+TTCd8NIxklX/ZOzSJSBZ+b7B8= 4 | github.com/aws/aws-sdk-go v1.44.44/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= 5 | github.com/cristim/ec2-instances-info v0.0.0-20220623102241-067009cd38ea h1:Q74hCjyozEfdzSouIiTxiHFva6PbtM14UftQ8TUrCJY= 6 | github.com/cristim/ec2-instances-info v0.0.0-20220623102241-067009cd38ea/go.mod h1:0yCjO4zBzlwWSGh/zGfW2Zq1NX605qCYVBHD1fPXKNs= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/go-bindata/go-bindata v3.1.2+incompatible/go.mod h1:xK8Dsgwmeed+BBsSy2XTopBn/8uK2HWuGSnA11C3Joo= 11 | github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= 12 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 13 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 14 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 15 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 16 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 17 | github.com/mattn/goveralls v0.0.11 h1:eJXea6R6IFlL1QMKNMzDvvHv/hwGrnvyig4N+0+XiMM= 18 | github.com/mattn/goveralls v0.0.11/go.mod h1:gU8SyhNswsJKchEV93xRQxX6X3Ei4PJdQk/6ZHvrvRk= 19 | github.com/namsral/flag v0.0.0-20170814194028-67f268f20922 h1:dRRQLGaXoPysHledlqbOa53vGxt0WjaVtdCexlWiRjA= 20 | github.com/namsral/flag v0.0.0-20170814194028-67f268f20922/go.mod h1:OXldTctbM6SWH1K899kPZcf65KxJiD7MsceFUpB5yDo= 21 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 22 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 23 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 24 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 25 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 26 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= 27 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 28 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 29 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 30 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 31 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 32 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 33 | github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 34 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 35 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 36 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 37 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= 38 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 39 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 40 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 41 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= 42 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 43 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 44 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 45 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 46 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 47 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 48 | golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 49 | golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd h1:O7DYs+zxREGLKzKoMQrtrEacpb0ZVXA5rIwylE2Xchk= 50 | golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 51 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 52 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 53 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 54 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 55 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 56 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 57 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 58 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 59 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 60 | golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 61 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 62 | golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b h1:2n253B2r0pYSmEV+UNCQoPfU/FiaizQEK5Gu4Bq4JE8= 63 | golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 64 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 65 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 66 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 67 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 68 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 69 | golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= 70 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 71 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 72 | golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 73 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 74 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 75 | golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 76 | golang.org/x/tools v0.1.11 h1:loJ25fNOEhSXfHrpoGj91eCUThwdNX6u24rO1xnNteY= 77 | golang.org/x/tools v0.1.11/go.mod h1:SgwaegtQh8clINPpECJMqnxLv9I09HLqnW3RMqW0CA4= 78 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 79 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 80 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 81 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 82 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 83 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 84 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 85 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 86 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 87 | gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ= 88 | gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 89 | gotest.tools/v3 v3.0.0 h1:d+tVGRu6X0ZBQ+kyAR8JKi6AXhTP2gmQaoIYaGFz634= 90 | gotest.tools/v3 v3.0.0/go.mod h1:TUP+/YtXl/dp++T+SZ5v2zUmLVBHmptSb/ajDLCJ+3c= 91 | -------------------------------------------------------------------------------- /kubernetes/autospotting-cron.yaml.example: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-2022 Cristian Măgherușan-Stanciu 2 | # Licensed under the Open Software License version 3.0 3 | 4 | apiVersion: batch/v1beta1 5 | kind: CronJob 6 | metadata: 7 | name: autospotting 8 | spec: 9 | schedule: "*/5 * * * *" # run every 30 minutes 10 | startingDeadlineSeconds: 10 # skip if it hasn't started in this many seconds 11 | concurrencyPolicy: Forbid # either allow|forbid|replace 12 | successfulJobsHistoryLimit: 3 # how many completed jobs should be kept 13 | failedJobsHistoryLimit: 1 # how many failed jobs should be kept 14 | jobTemplate: 15 | spec: 16 | template: 17 | spec: 18 | restartPolicy: Never 19 | containers: 20 | - name: autospotting-cron-job 21 | image: autospotting/autospotting:latest 22 | # Environment variables for the AutoSpotting pod 23 | # Feel free to configure them to suit your needs 24 | env: 25 | # These hardcoded credentials could be removed if using a secret 26 | # object or Kube2IAM 27 | # (patches always welcome if you get this working otherwise) 28 | - name: AWS_ACCESS_KEY_ID 29 | value: "AKIA..." 30 | - name: AWS_SECRET_ACCESS_KEY 31 | value: "" 32 | - name: AWS_SESSION_TOKEN 33 | value: "" 34 | - name: ALLOWED_INSTANCE_TYPES 35 | value: "*" 36 | - name: BIDDING_POLICY 37 | value: "normal" 38 | - name: DISALLOWED_INSTANCE_TYPES 39 | value: "t1.*" 40 | - name: INSTANCE_TERMINATION_METHOD 41 | value: "autoscaling" 42 | - name: MIN_ON_DEMAND_NUMBER 43 | value: "0" 44 | - name: MIN_ON_DEMAND_PERCENTAGE 45 | value: "0.0" 46 | - name: ON_DEMAND_PRICE_MULTIPLIER 47 | value: "1.0" 48 | - name: REGIONS 49 | value: "us-east-1,eu-west-1" 50 | - name: SPOT_PRICE_BUFFER_PERCENTAGE 51 | value: "10.0" 52 | - name: PATCH_BEANSTALK_USERDATA 53 | value: "false" 54 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeanerCloud/AutoSpotting/9bc88ee72f9587a2de187cbd333fc06452b4e846/logo.png -------------------------------------------------------------------------------- /test_data/beanstalk_userdata_example.txt: -------------------------------------------------------------------------------- 1 | Content-Type: multipart/mixed; boundary="===============5189065377222898407==" 2 | MIME-Version: 1.0 3 | 4 | --===============5189065377222898407== 5 | Content-Type: text/cloud-config; charset="us-ascii" 6 | MIME-Version: 1.0 7 | Content-Transfer-Encoding: 7bit 8 | Content-Disposition: attachment; filename="cloud-config.txt" 9 | 10 | #cloud-config 11 | repo_upgrade: none 12 | repo_releasever: 2018.03 13 | cloud_final_modules: 14 | - [scripts-user, always] 15 | 16 | --===============5189065377222898407== 17 | Content-Type: text/x-shellscript; charset="us-ascii" 18 | MIME-Version: 1.0 19 | Content-Transfer-Encoding: 7bit 20 | Content-Disposition: attachment; filename="user-data.txt" 21 | 22 | #!/bin/bash 23 | exec > >(tee -a /var/log/eb-cfn-init.log|logger -t [eb-cfn-init] -s 2>/dev/console) 2>&1 24 | echo [`date -u +"%Y-%m-%dT%H:%M:%SZ"`] Started EB User Data 25 | set -x 26 | 27 | 28 | function sleep_delay 29 | { 30 | if (( $SLEEP_TIME < $SLEEP_TIME_MAX )); then 31 | echo Sleeping $SLEEP_TIME 32 | sleep $SLEEP_TIME 33 | SLEEP_TIME=$(($SLEEP_TIME * 2)) 34 | else 35 | echo Sleeping $SLEEP_TIME_MAX 36 | sleep $SLEEP_TIME_MAX 37 | fi 38 | } 39 | 40 | # Executing bootstrap script 41 | SLEEP_TIME=10 42 | SLEEP_TIME_MAX=3600 43 | while true; do 44 | curl https://s3.dualstack.eu-central-1.amazonaws.com/elasticbeanstalk-env-resources-eu-central-1/stalks/eb_docker_ecs_4.2.1.201222.0_1567548290/lib/UserDataScript.sh > /tmp/ebbootstrap.sh 45 | RESULT=$? 46 | if [[ "$RESULT" -ne 0 ]]; then 47 | sleep_delay 48 | else 49 | /bin/bash /tmp/ebbootstrap.sh 'https://s3.dualstack.eu-central-1.amazonaws.com/elasticbeanstalk-env-resources-eu-central-1/stalks/eb_docker_ecs_4.2.1.201222.0_1567548290/lib/aws-elasticbeanstalk-tools-1.20-1.noarch.rpm' 'https://s3.dualstack.eu-central-1.amazonaws.com/elasticbeanstalk-env-resources-eu-central-1/stalks/eb_docker_ecs_4.2.1.201222.0_1567548290/lib/awseb-ruby-2.2.4-x86_64-20160503_1008.tar.gz https://s3.dualstack.eu-central-1.amazonaws.com/elasticbeanstalk-env-resources-eu-central-1/stalks/eb_docker_ecs_4.2.1.201222.0_1567548290/lib/basehooks.tar.gz' 'https://s3.dualstack.eu-central-1.amazonaws.com/elasticbeanstalk-env-resources-eu-central-1/stalks/eb_docker_ecs_4.2.1.201222.0_1567548290/lib/beanstalk-core-2.12.gem https://s3.dualstack.eu-central-1.amazonaws.com/elasticbeanstalk-env-resources-eu-central-1/stalks/eb_docker_ecs_4.2.1.201222.0_1567548290/lib/beanstalk-core-healthd-1.1.gem https://s3.dualstack.eu-central-1.amazonaws.com/elasticbeanstalk-env-resources-eu-central-1/stalks/eb_docker_ecs_4.2.1.201222.0_1567548290/lib/executor-1.2.gem' 'https://cloudformation-waitcondition-eu-central-1.s3.eu-central-1.amazonaws.com/arn%3Aaws%3Acloudformation%3Aeu-central-1%3A341666126358%3Astack/awseb-e-5vjzp38mtm-stack/dcd4caf0-6f1c-11e7-b89a-50a68a770c82/AWSEBInstanceLaunchWaitHandle?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20170722T203225Z&X-Amz-SignedHeaders=host&X-Amz-Expires=86399&X-Amz-Credential=AKIAJAZKDI3C4AV3A2CQ%2F20170722%2Feu-central-1%2Fs3%2Faws4_request&X-Amz-Signature=166cfdbc363f64b19cb54f7c0d1f77e1d45c829912657541b4be5311d1d9c6de' 'arn:aws:cloudformation:eu-central-1:341666126358:stack/awseb-e-5vjzp38mtm-stack/dcd4caf0-6f1c-11e7-b89a-50a68a770c82' 'eu-central-1' '672223b9fc09' '3540eff5-b46c-4e45-9bb0-dd6e92b29d69' '' '' '' && 50 | exit 0 51 | fi 52 | done 53 | 54 | --===============5189065377222898407==-- ##Ec2InstanceReplacementRequest=806ce155-4b00-4007-9734-edc1a8cebe64 55 | -------------------------------------------------------------------------------- /test_data/beanstalk_userdata_wrapped_example.txt: -------------------------------------------------------------------------------- 1 | Content-Type: multipart/mixed; boundary="===============5189065377222898407==" 2 | MIME-Version: 1.0 3 | 4 | --===============5189065377222898407== 5 | Content-Type: text/cloud-config; charset="us-ascii" 6 | MIME-Version: 1.0 7 | Content-Transfer-Encoding: 7bit 8 | Content-Disposition: attachment; filename="cloud-config.txt" 9 | 10 | #cloud-config 11 | repo_upgrade: none 12 | repo_releasever: 2018.03 13 | cloud_final_modules: 14 | - [scripts-user, always] 15 | 16 | --===============5189065377222898407== 17 | Content-Type: text/x-shellscript; charset="us-ascii" 18 | MIME-Version: 1.0 19 | Content-Transfer-Encoding: 7bit 20 | Content-Disposition: attachment; filename="user-data.txt" 21 | 22 | #!/bin/bash 23 | ---- modify CloudFormation helpers ---- 24 | # Modify cfn-init to use --role by default 25 | echo -e '#!/bin/bash\nINROLE=$(curl -s 169.254.169.254/latest/meta-data/iam/security-credentials/)\n/opt/aws/bin/cfn-init-2 --role $INROLE "$@" \nexit $?' > /opt/aws/bin/cfn-init.tmp 26 | mv /opt/aws/bin/cfn-init /opt/aws/bin/cfn-init-2 27 | mv /opt/aws/bin/cfn-init.tmp /opt/aws/bin/cfn-init 28 | chmod +x /opt/aws/bin/cfn-init 29 | 30 | # Modify cfn-get-metadata to use --role by default 31 | echo -e '#!/bin/bash\nINROLE=$(curl -s 169.254.169.254/latest/meta-data/iam/security-credentials/)\n/opt/aws/bin/cfn-get-metadata-2 --role $INROLE "$@" \nexit $?' > /opt/aws/bin/cfn-get-metadata.tmp 32 | mv /opt/aws/bin/cfn-get-metadata /opt/aws/bin/cfn-get-metadata-2 33 | mv /opt/aws/bin/cfn-get-metadata.tmp /opt/aws/bin/cfn-get-metadata 34 | chmod +x /opt/aws/bin/cfn-get-metadata 35 | 36 | # Modify cfn-signal to use --role by default 37 | echo -e '#!/bin/bash\nINROLE=$(curl -s 169.254.169.254/latest/meta-data/iam/security-credentials/)\n/opt/aws/bin/cfn-signal-2 --role $INROLE "$@" \nexit $?' > /opt/aws/bin/cfn-signal.tmp 38 | mv /opt/aws/bin/cfn-signal /opt/aws/bin/cfn-signal-2 39 | mv /opt/aws/bin/cfn-signal.tmp /opt/aws/bin/cfn-signal 40 | chmod +x /opt/aws/bin/cfn-signal 41 | 42 | # Modify cfn-hup to use --role by default 43 | echo -e '#!/bin/bash\nprintf "role=$(curl -s 169.254.169.254/latest/meta-data/iam/security-credentials/)" >> /etc/cfn/cfn-hup.conf\n/opt/aws/bin/cfn-hup-2 "$@" \nexit $?' > /opt/aws/bin/cfn-hup.tmp 44 | mv /opt/aws/bin/cfn-hup /opt/aws/bin/cfn-hup-2 45 | mv /opt/aws/bin/cfn-hup.tmp /opt/aws/bin/cfn-hup 46 | chmod +x /opt/aws/bin/cfn-hup 47 | ---- modify CloudFormation helpers ---- 48 | 49 | exec > >(tee -a /var/log/eb-cfn-init.log|logger -t [eb-cfn-init] -s 2>/dev/console) 2>&1 50 | echo [`date -u +"%Y-%m-%dT%H:%M:%SZ"`] Started EB User Data 51 | set -x 52 | 53 | 54 | function sleep_delay 55 | { 56 | if (( $SLEEP_TIME < $SLEEP_TIME_MAX )); then 57 | echo Sleeping $SLEEP_TIME 58 | sleep $SLEEP_TIME 59 | SLEEP_TIME=$(($SLEEP_TIME * 2)) 60 | else 61 | echo Sleeping $SLEEP_TIME_MAX 62 | sleep $SLEEP_TIME_MAX 63 | fi 64 | } 65 | 66 | # Executing bootstrap script 67 | SLEEP_TIME=10 68 | SLEEP_TIME_MAX=3600 69 | while true; do 70 | curl https://s3.dualstack.eu-central-1.amazonaws.com/elasticbeanstalk-env-resources-eu-central-1/stalks/eb_docker_ecs_4.2.1.201222.0_1567548290/lib/UserDataScript.sh > /tmp/ebbootstrap.sh 71 | RESULT=$? 72 | if [[ "$RESULT" -ne 0 ]]; then 73 | sleep_delay 74 | else 75 | /bin/bash /tmp/ebbootstrap.sh 'https://s3.dualstack.eu-central-1.amazonaws.com/elasticbeanstalk-env-resources-eu-central-1/stalks/eb_docker_ecs_4.2.1.201222.0_1567548290/lib/aws-elasticbeanstalk-tools-1.20-1.noarch.rpm' 'https://s3.dualstack.eu-central-1.amazonaws.com/elasticbeanstalk-env-resources-eu-central-1/stalks/eb_docker_ecs_4.2.1.201222.0_1567548290/lib/awseb-ruby-2.2.4-x86_64-20160503_1008.tar.gz https://s3.dualstack.eu-central-1.amazonaws.com/elasticbeanstalk-env-resources-eu-central-1/stalks/eb_docker_ecs_4.2.1.201222.0_1567548290/lib/basehooks.tar.gz' 'https://s3.dualstack.eu-central-1.amazonaws.com/elasticbeanstalk-env-resources-eu-central-1/stalks/eb_docker_ecs_4.2.1.201222.0_1567548290/lib/beanstalk-core-2.12.gem https://s3.dualstack.eu-central-1.amazonaws.com/elasticbeanstalk-env-resources-eu-central-1/stalks/eb_docker_ecs_4.2.1.201222.0_1567548290/lib/beanstalk-core-healthd-1.1.gem https://s3.dualstack.eu-central-1.amazonaws.com/elasticbeanstalk-env-resources-eu-central-1/stalks/eb_docker_ecs_4.2.1.201222.0_1567548290/lib/executor-1.2.gem' 'https://cloudformation-waitcondition-eu-central-1.s3.eu-central-1.amazonaws.com/arn%3Aaws%3Acloudformation%3Aeu-central-1%3A341666126358%3Astack/awseb-e-5vjzp38mtm-stack/dcd4caf0-6f1c-11e7-b89a-50a68a770c82/AWSEBInstanceLaunchWaitHandle?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20170722T203225Z&X-Amz-SignedHeaders=host&X-Amz-Expires=86399&X-Amz-Credential=AKIAJAZKDI3C4AV3A2CQ%2F20170722%2Feu-central-1%2Fs3%2Faws4_request&X-Amz-Signature=166cfdbc363f64b19cb54f7c0d1f77e1d45c829912657541b4be5311d1d9c6de' 'arn:aws:cloudformation:eu-central-1:341666126358:stack/awseb-e-5vjzp38mtm-stack/dcd4caf0-6f1c-11e7-b89a-50a68a770c82' 'eu-central-1' '672223b9fc09' '3540eff5-b46c-4e45-9bb0-dd6e92b29d69' '' '' '' && 76 | exit 0 77 | fi 78 | done 79 | 80 | --===============5189065377222898407==-- ##Ec2InstanceReplacementRequest=806ce155-4b00-4007-9734-edc1a8cebe64 81 | -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | // +build tools 3 | 4 | package main 5 | 6 | import ( 7 | _ "github.com/mattn/goveralls" 8 | _ "golang.org/x/lint/golint" 9 | _ "golang.org/x/tools/cmd/cover" 10 | ) 11 | --------------------------------------------------------------------------------