diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile deleted file mode 100644 index 2619a04..0000000 --- a/.devcontainer/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.245.0/containers/ubuntu/.devcontainer/base.Dockerfile - -# [Choice] Ubuntu version (use ubuntu-22.04 or ubuntu-18.04 on local arm64/Apple Silicon): ubuntu-22.04, ubuntu-20.04, ubuntu-18.04 -ARG VARIANT="jammy" -FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT} - -# [Optional] Uncomment this section to install additional OS packages. -# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ -# && apt-get -y install --no-install-recommends - - diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b2bb6e2..41e1409 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,24 +1,60 @@ // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: -// https://github.com/microsoft/vscode-dev-containers/tree/v0.245.0/containers/ubuntu +// https://github.com/microsoft/vscode-dev-containers/tree/v0.245.0/containers/docker-existing-dockerfile { - "name": "Ubuntu", "build": { "dockerfile": "Dockerfile", // Update 'VARIANT' to pick an Ubuntu version: jammy / ubuntu-22.04, focal / ubuntu-20.04, bionic /ubuntu-18.04 // Use ubuntu-22.04 or ubuntu-18.04 on local arm64/Apple Silicon. - "args": { "VARIANT": "ubuntu-22.04" } + "args": { + "VARIANT": "ubuntu-22.04" + } }, - + + // Sets the run context to one level up instead of the .devcontainer folder. + "context": "..", + + // Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename. + "dockerFile": "../Dockerfile", + // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - - // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "uname -a", - + "forwardPorts": [3000], + + // Uncomment the next line to run commands after the container is created - for example installing curl. + // "postCreateCommand": "apt-get update && apt-get install -y curl", + + // Uncomment when using a ptrace-based debugger like C++, Go, and Rust + // "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ], + + // Uncomment to use the Docker CLI from inside the container. See https://aka.ms/vscode-remote/samples/docker-from-docker. + // "mounts": [ "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" ], + // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. "remoteUser": "vscode", + "features": { "terraform": "1.0", "aws-cli": "latest" - } + }, + + // Set *default* container specific settings.json values on container create. + "settings": { + "python.linting.pylintEnabled": false, + "python.linting.flake8Enabled": true, + "python.linting.enabled": true, + "terraform.languageServer.args": [ + "-port 3000" + ], + "terraform.languageServer.tcp.port": 3000 + }, + + "extensions": [ + "hashicorp.terraform", + "redhat.vscode-yaml", + "ms-python.python", + "mutantdino.resourcemonitor" + ], + + // Hack for terraform-ls + // "postStartCommand": "nohup bash -c '/home/vscode/.vscode-server/extensions/hashicorp.terraform-2.24.1-linux-arm64/bin/terraform-ls serve -port 3000 &'" + } diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..823fcd3 --- /dev/null +++ b/.flake8 @@ -0,0 +1,6 @@ +[flake8] +max-line-length = 90 +max-complexity = 10 +exclude = + __pycache__ + .venv diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..314766e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +*.{cmd,[cC][mM][dD]} text eol=crlf +*.{bat,[bB][aA][tT]} text eol=crlf diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 77f2569..ac7ad3e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,5 +1,7 @@ # Contributing +Contributions are welcome. + When contributing to this repository, please first discuss the change you wish to make via issue, email, or any other method with the owners of this repository before making a change. @@ -8,15 +10,16 @@ Please note we have a code of conduct, please follow it in all your interactions ## Pull Request Process 1. Update the README.md with details of changes including example hcl blocks and [example files](./examples) if appropriate. -2. Run pre-commit hooks `pre-commit run -a`. -3. Once all outstanding comments and checklist items have been addressed, your contribution will be merged! Merged PRs will be included in the next release. The terraform-aws-vpc maintainers take care of updating the CHANGELOG as they merge. +2. Add appropriate tests. +3. Run pre-commit hooks `pre-commit run -a`. +4. Once all outstanding comments and checklist items have been addressed, your contribution will be merged! Merged PRs will be included in the next release. The terraform-aws-vpc maintainers take care of updating the CHANGELOG as they merge. ## Checklists for contributions - [ ] Add [semantics prefix](#semantic-pull-requests) to your PR or Commits (at least one of your commit groups) - [ ] CI tests are passing -- [ ] README.md has been updated after any changes to variables and outputs. See https://github.com/cloudandthings/terraform-aws-clickops-notifer/#doc-generation -- [ ] ~~Run pre-commit hooks `pre-commit run -a`~~ TODO +- [ ] README.md has been updated after any changes to variables and outputs. +- [ ] Run pre-commit hooks `pre-commit run -a` ## Semantic Pull Requests @@ -31,4 +34,4 @@ To generate changelog, Pull Requests or Commits must have semantic and must foll - `ci:` for CI purpose - `chore:` for chores stuff -The `chore` prefix skipped during changelog generation. It can be used for `chore: update changelog` commit message by example. \ No newline at end of file +The `chore` prefix skipped during changelog generation. It can be used for `chore: update changelog` commit message by example. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..1f5897f --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,64 @@ +name: main + +on: [workflow_dispatch, pull_request] + +env: + AWS_REGION: eu-west-1 + AWS_ROLE: cat-genrl-prd-infra-gh-tf-aws-github-runners + +permissions: + id-token: write + contents: read + +jobs: + python_lint: + name: 🐍 Python file formatting + runs-on: self-hosted + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + - run: | + python -m venv .venv + source .venv/bin/activate + pip install -r requirements.txt + flake8 + black . + + terraform_lint: + name: πŸ— Terraform file format + runs-on: self-hosted + steps: + - uses: hashicorp/setup-terraform@v2 + - uses: dflook/terraform-fmt-check@v1 + with: + path: . + + testing: + needs: [python_lint, terraform_lint] + name: βœ… Testing + runs-on: self-hosted + steps: + - uses: actions/checkout@v2 + - uses: aws-actions/configure-aws-credentials@v1 + with: + aws-region: ${{ env.AWS_REGION }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + role-to-assume: ${{ env.AWS_ROLE }} + role-duration-seconds: 3600 + - uses: actions/setup-python@v2 + - uses: hashicorp/setup-terraform@v2 + - name: Install tests + run: | + python -m venv .venv + source .venv/bin/activate + pip install -r requirements.txt + pre-commit install + - name: Execute tests + run: | + source .venv/bin/activate + pre-commit run + pytest --run-id ${{ github.run_id }} + env: + PYTEST_ADDOPTS: "--color=yes" + timeout-minutes: 30 diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index 937c793..2a4cc25 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -10,7 +10,7 @@ on: jobs: main: name: Validate PR title - runs-on: ubuntu-latest + runs-on: self-hosted steps: # Please look up the latest version from # https://github.com/amannn/action-semantic-pull-request/releases @@ -49,4 +49,4 @@ jobs: # will suggest using that commit message instead of the PR title for the # merge commit, and it's easy to commit this by mistake. Enable this option # to also validate the commit message for one commit PRs. - validateSingleCommit: false \ No newline at end of file + validateSingleCommit: false diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml deleted file mode 100644 index a2c7260..0000000 --- a/.github/workflows/pre-commit.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: Pre-Commit - -on: - pull_request: - branches: - - main - - master - -env: - TERRAFORM_DOCS_VERSION: v0.16.0 - -jobs: - collectInputs: - name: Collect workflow inputs - runs-on: ubuntu-latest - outputs: - directories: ${{ steps.dirs.outputs.directories }} - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Get root directories - id: dirs - uses: clowdhaus/terraform-composite-actions/directories@v1.3.0 - - preCommitMinVersions: - name: Min TF pre-commit - needs: collectInputs - runs-on: ubuntu-latest - strategy: - matrix: - directory: ${{ fromJson(needs.collectInputs.outputs.directories) }} - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Terraform min/max versions - id: minMax - uses: clowdhaus/terraform-min-max@v1.0.3 - with: - directory: ${{ matrix.directory }} - - - name: Pre-commit Terraform ${{ steps.minMax.outputs.minVersion }} - # Run only validate pre-commit check on min version supported - if: ${{ matrix.directory != '.' }} - uses: clowdhaus/terraform-composite-actions/pre-commit@v1.3.0 - with: - terraform-version: ${{ steps.minMax.outputs.minVersion }} - args: 'terraform_validate --color=always --show-diff-on-failure --files ${{ matrix.directory }}/*' - - - name: Pre-commit Terraform ${{ steps.minMax.outputs.minVersion }} - # Run only validate pre-commit check on min version supported - if: ${{ matrix.directory == '.' }} - uses: clowdhaus/terraform-composite-actions/pre-commit@v1.3.0 - with: - terraform-version: ${{ steps.minMax.outputs.minVersion }} - args: 'terraform_validate --color=always --show-diff-on-failure --files $(ls *.tf)' - - preCommitMaxVersion: - name: Max TF pre-commit - runs-on: ubuntu-latest - needs: collectInputs - steps: - - name: Checkout - uses: actions/checkout@v2 - with: - ref: ${{ github.event.pull_request.head.ref }} - repository: ${{github.event.pull_request.head.repo.full_name}} - - - name: Terraform min/max versions - id: minMax - uses: clowdhaus/terraform-min-max@v1.0.3 - - - name: Pre-commit Terraform ${{ steps.minMax.outputs.maxVersion }} - uses: clowdhaus/terraform-composite-actions/pre-commit@v1.3.0 - with: - terraform-version: ${{ steps.minMax.outputs.maxVersion }} - terraform-docs-version: ${{ env.TERRAFORM_DOCS_VERSION }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 587e4d6..032123e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ on: jobs: release: name: Release - runs-on: ubuntu-latest + runs-on: self-hosted # Skip running release workflow on forks if: github.repository_owner == 'cloudandthings' steps: @@ -37,4 +37,4 @@ jobs: @semantic-release/git@10.0.0 conventional-changelog-conventionalcommits@4.6.3 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml deleted file mode 100644 index 884c7f9..0000000 --- a/.github/workflows/terraform.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Check terraform file formatting - -on: [push, pull_request] - -jobs: - check_format: - runs-on: ubuntu-latest - name: Check terraform file are formatted correctly - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: terraform fmt - uses: dflook/terraform-fmt-check@v1 - with: - path: . \ No newline at end of file diff --git a/.gitignore b/.gitignore index c97fe81..c89b5b8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ *.tfstate *.tfstate.* +# TF cache +.tfcache + # Crash log files crash.log @@ -12,7 +15,7 @@ crash.log # .tfvars files are managed as part of configuration and so should be included in # version control. # -terraform.tfvars +# terraform.tfvars # Ignore override files as they are usually used to override resources locally and so # are not checked in @@ -29,5 +32,17 @@ override.tf.json # example: *tfplan* .terraform.lock.hcl +# Python +__pycache__ + +# Testing artefacts +tf_resources.json +test-rsa.* +.out +*.tar.gz + # VSCode -*.code-workspace \ No newline at end of file +*.code-workspace + +# Apple +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a9dafd0..93fef89 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,8 +1,31 @@ # https://pre-commit.com/ # brew install pre-commit # brew install tflint +# pre-commit autoupdate # pre-commit install +# pre-commit run --all-files repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-merge-conflict + - id: detect-private-key + - id: end-of-file-fixer + - id: mixed-line-ending + - id: name-tests-test + # Causes PRs to main to fail + #- id: no-commit-to-branch + # args: [--branch, develop, --branch, main] + - id: requirements-txt-fixer + - repo: local + hooks: + - id: terraform init + name: terraform init + entry: terraform init + language: system + pass_filenames: false - repo: https://github.com/antonbabenko/pre-commit-terraform rev: v1.74.1 hooks: @@ -27,12 +50,19 @@ repos: - '--args=--only=terraform_required_providers' - '--args=--only=terraform_standard_module_structure' - '--args=--only=terraform_workspace_remote' + - repo: https://github.com/psf/black + rev: 22.6.0 + hooks: + - id: black - repo: https://github.com/pycqa/flake8 - rev: 3.7.9 + rev: 5.0.4 hooks: - id: flake8 - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + - repo: local hooks: - - id: check-merge-conflict - - id: end-of-file-fixer \ No newline at end of file + - id: pytest + name: pytest + entry: bash -c 'pytest -m "not slow"' + language: system + always_run: true + pass_filenames: false diff --git a/.tfdocs-config.yaml b/.tfdocs-config.yml similarity index 78% rename from .tfdocs-config.yaml rename to .tfdocs-config.yml index 2aab226..2bb296c 100644 --- a/.tfdocs-config.yaml +++ b/.tfdocs-config.yml @@ -2,8 +2,8 @@ formatter: "markdown table" # this is required version: ">= 0.13.0, < 1.0.0" -# header-from: main.tf -# footer-from: "" +header-from: main.tf +footer-from: "" # recursive: # enabled: false @@ -16,11 +16,19 @@ sections: content: |- ## Module Docs - ### Examples + ### Basic Example ```hcl {{ include "examples/basic/main.tf" }} ``` + ### Advanced Example + ```hcl + {{ include "examples/advanced/main.tf" }} + ``` + ### Software packs + ```hcl + {{ include "modules/software/software_packs.tf" }} + ``` ---- {{ .Inputs }} ---- @@ -34,9 +42,6 @@ content: |- ---- {{ .Resources }} ---- - ### Default excluded scoped actions - ```hcl - {{ include "/excluded_scoped_actions.tf" }} ``` @@ -70,4 +75,4 @@ settings: read-comments: true required: true sensitive: true - type: true \ No newline at end of file + type: true diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..163c984 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.formatting.provider": "black" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 6524b2c..d7ee8f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,4 +5,3 @@ All notable changes to this project will be documented in this file. ### Features ### Bug Fixes - diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e95b6e2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.245.0/containers/ubuntu/.devcontainer/base.Dockerfile + +# [Choice] Ubuntu version (use ubuntu-22.04 or ubuntu-18.04 on local arm64/Apple Silicon): ubuntu-22.04, ubuntu-20.04, ubuntu-18.04 +ARG VARIANT="jammy" +FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT} + +# Install additional OS packages. +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get -y install --no-install-recommends python3 python3-pip cloud-init + +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY .pre-commit-config.yaml . +RUN git init . && pre-commit install-hooks + +COPY .tfdocs-config.yml . +ADD https://github.com/terraform-docs/terraform-docs/releases/download/v0.16.0/terraform-docs-v0.16.0-linux-amd64.tar.gz ./terraform-docs.tar.gz +RUN tar -xzf terraform-docs.tar.gz && chmod +x terraform-docs && mv terraform-docs /usr/local/bin/terraform-docs diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..f7b5cf7 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 cloudandthings.io + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e69de29..4e2dc78 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,283 @@ +# terraform-aws-github-runners + +![terraform-aws-github-runners](docs/images/icon.gif "terraform-aws-github-runners" ) + +Simple, self-hosted GitHub runners. + +--- + +[![Maintenance](https://img.shields.io/badge/Maintained-yes-green.svg)](https://github.com/cloudandthings/terraform-aws-github-runners/graphs/commit-activity) +[![Test Status](https://github.com/cloudandthings/terraform-aws-github-runners/actions/workflows/main.yml/badge.svg)](https://github.com/cloudandthings/terraform-aws-github-runners/actions/workflows/main.yml) +![Terraform Version](https://img.shields.io/badge/tf-%3E%3D0.13.0-blue) +[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) + +## Features + +- Simple! See examples below. +- Cost-effective - using EC2 Spot pricing and AutoScaling. +- Customisable using [cloudinit](https://cloudinit.readthedocs.io/). +- Scalable. By default one runner process and 20GB storage is provided per vCPU per EC2 instance. + +## Why? + +Deploying a self-hosted github runner should be simple. +It shouldn't need a long setup process or a lot of infrastructure. + +This module additionally does not require public inbound traffic, and can be easily customised if needed. + +### Known limitations + +Parallel runners are ephemeral and their work environment is destroyed after each job is done. +However they still run on the same underlying EC2 instance. +This means they can make changes which impact each other, for example if the EBS storage gets full. + +A possible workaround could be to [run jobs in a container](https://docs.github.com/en/actions/using-jobs/running-jobs-in-a-container) on these runners. + +## How it works + +![infrastructure](docs/images/runner.svg "infrastructure" ) + +An AutoScaling group is created to spin up Spot EC2 instances on a schedule. The instances retrieve a pre-configured GitHub access token from AWS SSM Parameter Store, and start one (or more) ephemeral actions runner processes. These authenticate with GitHub and wait for work. + +Steps execute arbitrary commands, defined by your repo workflows. + +For example: + - Perform a linting check. + - Connect to another AWS Account using an IAM credential and operate on some EC2 or RDS infrastructure. + - Anything else... + + +A full list of created resources is shown below. + +## How to use it + +### 1. Store your GitHub token +Create a [GitHub personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token). +Add it to AWS Systems Manager Parameter Store with the `SecureString` type. + +![ssm](docs/images/ssm.png "ssm" ) + + +### 2. Configure module +Configure and deploy the module using Terraform. See examples below. + +## More info + +- Found an issue? Want to help? [Contribute](.github/contribute.md). +- Review a [cost estimate](docs/cost_estimate.md). + + +## Module Docs + +### Basic Example +```hcl +module "github_runner" { + source = "../../" + + # Required parameters + ############################ + region = "af-south-1" + github_url = "https://github.com/my-org" + + # Naming for all created resources + naming_prefix = "test-github-runner" + + ssm_parameter_name = "/github/runner/token" + + # 2 cores, so 2 ephemeral runners will start in parallel. + ec2_instance_type = "t3.micro" + + vpc_id = "vpc-0ffaabbcc1122" + subnet_ids = ["subnet-0123", "subnet-0456"] +} +``` +### Advanced Example +```hcl +locals { + naming_prefix = "test-github-runner" + vpc_id = "vpc-0ffaabbcc1122" +} + +# Create a custom security-group to allow SSH to all EC2 instances +resource "aws_security_group" "this" { + name = "${local.naming_prefix}-sg" + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + vpc_id = local.vpc_id +} + +data "http" "myip" { + url = "http://ipv4.icanhazip.com" +} + +resource "aws_security_group_rule" "ssh_ingress" { + description = "Allow SSH ingress to EC2 instance" + type = "ingress" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["${chomp(data.http.myip.body)}/32"] + security_group_id = aws_security_group.this.id +} + +module "github_runner" { + source = "../../" + + # Required parameters + ############################ + region = "af-south-1" + github_url = "https://github.com/my-org" + + naming_prefix = local.naming_prefix + + ssm_parameter_name = "/github/runner/token" + + ec2_instance_type = "t3.micro" + + vpc_id = local.vpc_id + subnet_ids = ["subnet-0123", "subnet-0456"] + + # Optional parameters + ################################ + + # If for some reason you dont want to install everything. + software_packs = [ + "docker-engine", + "node", + "python2" # Required for cloudwatch logging + ] + + ec2_associate_public_ip_address = true + ec2_key_pair_name = "my_key_pair" + security_groups = [aws_security_group.this.id] + + autoscaling_max_instance_lifetime = 86400 + autoscaling_min_size = 2 + autoscaling_desired_size = 2 + autoscaling_max_size = 5 + + autoscaling_schedule_time_zone = "Africa/Johannesburg" + # Scale up to desired capacity during work hours + autoscaling_schedule_on_recurrences = ["0 07 * * MON-FRI"] + # Scale down to zero after hours + autoscaling_schedule_off_recurrences = ["0 18 * * *"] + + cloud_init_extra_packages = ["neofetch"] + cloud_init_extra_runcmds = [ + "echo \"hello world\" > ~/test_file" + ] + + cloudwatch_log_group = "/some/log/group" +} +``` +### Software packs +```hcl +locals { + # All available software packs + all = [ + "docker-engine", + "node", + "pre-commit", + "python2", + "python3", + "terraform", + "terraform-docs", + "tflint", + ] +} +``` +---- +### Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [ami\_name](#input\_ami\_name) | AWS AMI name filter for launching instances.
GitHub supports specific operating systems and architectures, including Ubuntu 22.04 amd64 which is the default.
Note: The included software packs are not tested with other AMIs. | `string` | `"ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-20220609"` | no | +| [autoscaling\_desired\_size](#input\_autoscaling\_desired\_size) | The number of Amazon EC2 instances that should be running.
*When `scaling_mode="autoscaling-group"`* | `number` | `1` | no | +| [autoscaling\_max\_instance\_lifetime](#input\_autoscaling\_max\_instance\_lifetime) | The maximum amount of time, in seconds, that an instance can be in service. Values must be either equal to `0` or between `86400` and `31536000` seconds.
*When `scaling_mode="autoscaling-group"`* | `string` | `0` | no | +| [autoscaling\_max\_size](#input\_autoscaling\_max\_size) | The maximum size of the Auto Scaling Group.
*When `scaling_mode="autoscaling-group"`* | `number` | `3` | no | +| [autoscaling\_min\_size](#input\_autoscaling\_min\_size) | The minimum size of the Auto Scaling Group.
*When `scaling_mode="autoscaling-group"`* | `number` | `1` | no | +| [autoscaling\_schedule\_off\_recurrences](#input\_autoscaling\_schedule\_off\_recurrences) | A list of schedule cron expressions, specifying when the Auto Scaling Group will terminate all instances.
Example: `["0 18 * * *"]`
*When `scaling_mode="autoscaling-group"`* | `list(string)` | `[]` | no | +| [autoscaling\_schedule\_on\_recurrences](#input\_autoscaling\_schedule\_on\_recurrences) | A list of schedule cron expressions, specifying when the Auto Scaling Group will launch instances.
Example: `["0 07 * * MON-FRI"]`
*When `scaling_mode="autoscaling-group"`* | `list(string)` | `[]` | no | +| [autoscaling\_schedule\_time\_zone](#input\_autoscaling\_schedule\_time\_zone) | The timezone for schedule cron expressions.
https://www.joda.org/joda-time/timezones.html
*When `scaling_mode="autoscaling-group"`* | `string` | `""` | no | +| [cloud\_init\_extra\_other](#input\_cloud\_init\_extra\_other) | Arbitrary text to append to the `cloudinit` script. | `string` | `""` | no | +| [cloud\_init\_extra\_packages](#input\_cloud\_init\_extra\_packages) | A list of strings to append beneath the `packages:` section of the `cloudinit` script.
https://cloudinit.readthedocs.io/en/latest/topics/modules.html#package-update-upgrade-install | `list(string)` | `[]` | no | +| [cloud\_init\_extra\_runcmds](#input\_cloud\_init\_extra\_runcmds) | A list of strings to append beneath the `runcmd:` section of the `cloudinit` script.
https://cloudinit.readthedocs.io/en/latest/topics/modules.html#runcmd | `list(string)` | `[]` | no | +| [cloud\_init\_extra\_write\_files](#input\_cloud\_init\_extra\_write\_files) | A list of strings to append beneath the `write_files:` section of the `cloudinit` script.
https://cloudinit.readthedocs.io/en/latest/topics/modules.html#write-files | `list(string)` | `[]` | no | +| [cloudwatch\_log\_group](#input\_cloudwatch\_log\_group) | CloudWatch log group name prefix. Runner logs from /var/log/syslog are sent here.
Example: `github_runner`, with this value logs will be written to `github_runner/var/log/syslog/`.
If left unspecified then logging is disabled. | `string` | `""` | no | +| [ec2\_associate\_public\_ip\_address](#input\_ec2\_associate\_public\_ip\_address) | Whether to associate a public IP address with EC2 instances in a VPC. | `bool` | `false` | no | +| [ec2\_ebs\_volume\_size](#input\_ec2\_ebs\_volume\_size) | Size in GB of instance-attached EBS storage. By default this is set to number of vCPUs per instance * 20 GB. | `number` | `-1` | no | +| [ec2\_instance\_type](#input\_ec2\_instance\_type) | Instance type for EC2 instances. | `string` | n/a | yes | +| [ec2\_key\_pair\_name](#input\_ec2\_key\_pair\_name) | EC2 Key Pair name to allow SSH to EC2 instances. | `string` | `""` | no | +| [github\_organisation\_name](#input\_github\_organisation\_name) | GitHub orgnisation name. Derived from `github_url` by default. | `string` | `""` | no | +| [github\_runner\_group](#input\_github\_runner\_group) | Custom GitHub runner group. | `string` | `""` | no | +| [github\_runner\_labels](#input\_github\_runner\_labels) | Custom GitHub runner labels.
Example: `"gpu,x64,linux"`. | `list(string)` | `[]` | no | +| [github\_url](#input\_github\_url) | GitHub organisation URL.
Example: "https://github.com/cloudandthings/". | `string` | n/a | yes | +| [iam\_instance\_profile\_arn](#input\_iam\_instance\_profile\_arn) | IAM Instance Profile to launch EC2 instances with. Must allow permissions to read the SSM Parameter. Will be created by default. | `string` | `""` | no | +| [naming\_prefix](#input\_naming\_prefix) | Created resources will be prefixed with this. | `string` | `"github-runner"` | no | +| [per\_instance\_runner\_count](#input\_per\_instance\_runner\_count) | Number of runners per instance. By default this is set equal to the number of vCPUs per instance. May be set to 0 to never create runners. | `number` | `-1` | no | +| [region](#input\_region) | AWS region. | `string` | n/a | yes | +| [scaling\_mode](#input\_scaling\_mode) | How instances are managed.
Can be either `"autoscaling-group"` or `"single-instance"`. | `string` | `"autoscaling-group"` | no | +| [security\_groups](#input\_security\_groups) | A list of security groups to assign to EC2 instances.
Note: If none are provided, a new security group will be used which will deny inbound traffic **including SSH**. | `list(string)` | `[]` | no | +| [software\_packs](#input\_software\_packs) | A list of pre-defined software packs to install.
Valid options are: `"ALL"`, `"docker-engine"`, `"node"`, `"python2"`, `"python3"`, `"terraform"`, `"terraform-docs"`, `"tflint"`.
An empty list will mean none are installed. | `list(string)` |
[
"ALL"
]
| no | +| [ssm\_parameter\_name](#input\_ssm\_parameter\_name) | SSM parameter name for the GitHub Runner token.
Example: "/github/runner/token". | `string` | n/a | yes | +| [subnet\_ids](#input\_subnet\_ids) | The list of Subnet IDs to launch EC2 instances in.
If `scaling_mode="single-instance"` then the first Subnet ID from this list will be used. | `list(string)` | n/a | yes | +| [vpc\_id](#input\_vpc\_id) | The VPC ID to launch instances in. | `string` | n/a | yes | +---- +### Modules + +| Name | Source | Version | +|------|--------|---------| +| [software\_packs](#module\_software\_packs) | ./modules/software | n/a | +| [user\_data](#module\_user\_data) | ./modules/user_data | n/a | +---- +### Outputs + +| Name | Description | +|------|-------------| +| [aws\_instance\_id](#output\_aws\_instance\_id) | Instance ID (when `scaled_mode=single-instance`) | +| [aws\_instance\_public\_ip](#output\_aws\_instance\_public\_ip) | Instance public IP (when `scaled_mode=single-instance`) | +| [per\_instance\_runner\_count](#output\_per\_instance\_runner\_count) | Effective per instance runner count. | +| [software\_packs](#output\_software\_packs) | List of software packs that were installed. | +---- +### Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | 4.27.0 | +---- +### Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 0.13.1 | +| [aws](#requirement\_aws) | >= 4.9 | +| [http](#requirement\_http) | 3.0.1 | +---- +### Resources + +| Name | Type | +|------|------| +| [aws_autoscaling_group.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/autoscaling_group) | resource | +| [aws_autoscaling_policy.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/autoscaling_policy) | resource | +| [aws_autoscaling_schedule.off](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/autoscaling_schedule) | resource | +| [aws_autoscaling_schedule.on](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/autoscaling_schedule) | resource | +| [aws_cloudwatch_metric_alarm.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_metric_alarm) | resource | +| [aws_iam_instance_profile.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_instance_profile) | resource | +| [aws_iam_policy.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | +| [aws_iam_role.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy_attachment.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_instance.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/instance) | resource | +| [aws_launch_template.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template) | resource | +| [aws_security_group.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [aws_ami.ami](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ami) | data source | +| [aws_ec2_instance_type.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ec2_instance_type) | data source | +| [aws_ssm_parameter.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ssm_parameter) | data source | +---- +``` + diff --git a/data.tf b/data.tf new file mode 100644 index 0000000..fff2b6e --- /dev/null +++ b/data.tf @@ -0,0 +1,22 @@ +data "aws_ssm_parameter" "this" { + name = var.ssm_parameter_name +} + +data "aws_ec2_instance_type" "this" { + instance_type = var.ec2_instance_type +} + +data "aws_ami" "ami" { + most_recent = true + owners = ["099720109477"] + + filter { + name = "name" + values = [var.ami_name] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } +} diff --git a/docs/cost_estimate.md b/docs/cost_estimate.md new file mode 100644 index 0000000..b7360ce --- /dev/null +++ b/docs/cost_estimate.md @@ -0,0 +1,37 @@ +## Cost Estimate + +As per https://calculator.aws/#/ + +Assumptions: +- A single `t3.medium` instance type. +- Region is `af-south-1`. +- The instance will run for 9 hours from Mon-Fri (`195.54` instance hours per month). +- EBS Storage is configured as `40 GB`. + +As the `t3.medium` has 2 vCPU, this would provide 2 concurrent runners by default. + +**EC2 monthly cost** + +- On-Demand hourly cost for `t3.medium`: `0.0542 USD` +- Historical average discount for `t3.medium`: 70% +- `195.54` On-Demand instances hours x `0.0542 USD`: `10.6 USD` +- Less 70% Spot discount: `10.6 USD - (10.6 USD x 0.7)` : `3.18 USD` + +EC2 monthly subtotal = `3.18 USD` + +**EBS monthly cost** + +- No snapshots. +- Cost per GB: `0.1309 USD` +- `195.54` total EC2 hours / `730` hours in a month : `0.27` instance months +- `40 GB x 0.27` instance months at `0.1309 USD` : `0.35 USD` + +EBS monthly subotal = `1.41 USD` + +**Total monthly cost** + - EC2 monthly subtotal + EBS monthly subtotal + - `3.18 USD + 1.41 USD` : `4.59 USD` + +Total monthly cost: + +`4.59 USD` πŸ’ΈπŸš« diff --git a/docs/images/icon.gif b/docs/images/icon.gif new file mode 100644 index 0000000..d7f096a Binary files /dev/null and b/docs/images/icon.gif differ diff --git a/docs/images/infrastructure.svg b/docs/images/infrastructure.svg new file mode 100644 index 0000000..1b8867d --- /dev/null +++ b/docs/images/infrastructure.svg @@ -0,0 +1,4 @@ + + +GitHub Runner VPCOther VPCM5T3T3Scheduled Auto ScalingScheduled Auto ScalingParameter StoreParameter StoreGitHub serversGitHub serversIAM AuthenticationIAM AuthenticationSome RDSSome RDSSome EC2Some EC2GitHub runner infrastructureGitHub runner infrastructure diff --git a/docs/images/runner.svg b/docs/images/runner.svg new file mode 100644 index 0000000..ebb597f --- /dev/null +++ b/docs/images/runner.svg @@ -0,0 +1,4 @@ + + +GitHub Runner VPCOther VPCM5T3T3Scheduled Auto ScalingScheduled Auto ScalingParameter StoreParameter StoreGitHub serversGitHub serversIAM AuthenticationIAM AuthenticationSome RDSSome RDSSome EC2Some EC2GitHub runner infrastructureGitHub runner infrastructure diff --git a/docs/images/ssm.png b/docs/images/ssm.png new file mode 100644 index 0000000..03c1d2b Binary files /dev/null and b/docs/images/ssm.png differ diff --git a/docs/test_badges.md b/docs/test_badges.md new file mode 100644 index 0000000..d3d9e96 --- /dev/null +++ b/docs/test_badges.md @@ -0,0 +1,3 @@ +[![Latest Release](https://img.shields.io/github/release/cloudandthings/terraform-aws-github-runners)](https://github.com/cloudandthings/terraform-aws-github-runners/releases/latest) +[![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/cloudandthings/terraform-aws-github-runners?label=latest)](https://github.com/cloudandthings/terraform-aws-github-runners/releases/latest) +[![Github All Releases](https://img.shields.io/github/downloads/cloudandthings/terraform-aws-github-runners/total)](https://github.com/cloudandthings/terraform-aws-github-runners/releases) diff --git a/examples/advanced/main.tf b/examples/advanced/main.tf new file mode 100644 index 0000000..5fa2d69 --- /dev/null +++ b/examples/advanced/main.tf @@ -0,0 +1,82 @@ +locals { + naming_prefix = "test-github-runner" + vpc_id = "vpc-0ffaabbcc1122" +} + +# Create a custom security-group to allow SSH to all EC2 instances +resource "aws_security_group" "this" { + name = "${local.naming_prefix}-sg" + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + vpc_id = local.vpc_id +} + +data "http" "myip" { + url = "http://ipv4.icanhazip.com" +} + +resource "aws_security_group_rule" "ssh_ingress" { + description = "Allow SSH ingress to EC2 instance" + type = "ingress" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["${chomp(data.http.myip.body)}/32"] + security_group_id = aws_security_group.this.id +} + +module "github_runner" { + source = "../../" + + # Required parameters + ############################ + region = "af-south-1" + github_url = "https://github.com/my-org" + + naming_prefix = local.naming_prefix + + ssm_parameter_name = "/github/runner/token" + + ec2_instance_type = "t3.micro" + + vpc_id = local.vpc_id + subnet_ids = ["subnet-0123", "subnet-0456"] + + # Optional parameters + ################################ + + # If for some reason you dont want to install everything. + software_packs = [ + "docker-engine", + "node", + "python2" # Required for cloudwatch logging + ] + + ec2_associate_public_ip_address = true + ec2_key_pair_name = "my_key_pair" + security_groups = [aws_security_group.this.id] + + autoscaling_max_instance_lifetime = 86400 + autoscaling_min_size = 2 + autoscaling_desired_size = 2 + autoscaling_max_size = 5 + + autoscaling_schedule_time_zone = "Africa/Johannesburg" + # Scale up to desired capacity during work hours + autoscaling_schedule_on_recurrences = ["0 07 * * MON-FRI"] + # Scale down to zero after hours + autoscaling_schedule_off_recurrences = ["0 18 * * *"] + + cloud_init_extra_packages = ["neofetch"] + cloud_init_extra_runcmds = [ + "echo \"hello world\" > ~/test_file" + ] + + cloudwatch_log_group = "/some/log/group" +} diff --git a/examples/advanced/outputs.tf b/examples/advanced/outputs.tf new file mode 100644 index 0000000..e69de29 diff --git a/examples/advanced/variables.tf b/examples/advanced/variables.tf new file mode 100644 index 0000000..e69de29 diff --git a/examples/advanced/versions.tf b/examples/advanced/versions.tf new file mode 100644 index 0000000..7c70354 --- /dev/null +++ b/examples/advanced/versions.tf @@ -0,0 +1,13 @@ +terraform { + required_version = ">= 0.13.1" + required_providers { + http = { + source = "hashicorp/http" + version = "3.0.1" + } + aws = { + source = "hashicorp/aws" + version = ">= 4.9" + } + } +} diff --git a/examples/basic/main.tf b/examples/basic/main.tf index e69de29..364a181 100644 --- a/examples/basic/main.tf +++ b/examples/basic/main.tf @@ -0,0 +1,19 @@ +module "github_runner" { + source = "../../" + + # Required parameters + ############################ + region = "af-south-1" + github_url = "https://github.com/my-org" + + # Naming for all created resources + naming_prefix = "test-github-runner" + + ssm_parameter_name = "/github/runner/token" + + # 2 cores, so 2 ephemeral runners will start in parallel. + ec2_instance_type = "t3.micro" + + vpc_id = "vpc-0ffaabbcc1122" + subnet_ids = ["subnet-0123", "subnet-0456"] +} diff --git a/examples/basic/versions.tf b/examples/basic/versions.tf new file mode 100644 index 0000000..51cad10 --- /dev/null +++ b/examples/basic/versions.tf @@ -0,0 +1,3 @@ +terraform { + required_version = ">= 0.13.1" +} diff --git a/github-runner.tf b/github-runner.tf new file mode 100644 index 0000000..e69de29 diff --git a/main.tf b/main.tf index e69de29..673796a 100644 --- a/main.tf +++ b/main.tf @@ -0,0 +1,303 @@ +locals { + iam_policy_statements_cloudwatch = ( + length(var.cloudwatch_log_group) > 0 + ? [{ + Effect = "Allow" + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogStreams" + ] + Resource = "*" + }] + : []) +} + +resource "aws_iam_policy" "this" { + count = length(var.iam_instance_profile_arn) == 0 ? 1 : 0 + name = var.naming_prefix + + policy = jsonencode({ + Version = "2012-10-17" + Statement = concat([ + { + Action = ["ssm:GetParameter*"] + Effect = "Allow" + Resource = data.aws_ssm_parameter.this.arn + }, + { + Action = ["ec2:CreateTags"] + Effect = "Allow" + Resource = "*" + Condition = { + StringEquals = { + "aws:ResourceTag/Name" = var.naming_prefix + } + } + } + ], + local.iam_policy_statements_cloudwatch) + }) +} + +resource "aws_iam_role" "this" { + count = length(var.iam_instance_profile_arn) == 0 ? 1 : 0 + name = var.naming_prefix + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ec2.amazonaws.com" + } + } + ] + }) + + tags = { + Name = var.naming_prefix + } +} + +resource "aws_iam_role_policy_attachment" "this" { + count = length(var.iam_instance_profile_arn) == 0 ? 1 : 0 + role = aws_iam_role.this[count.index].name + policy_arn = aws_iam_policy.this[count.index].arn +} + +resource "aws_iam_instance_profile" "this" { + count = length(var.iam_instance_profile_arn) == 0 ? 1 : 0 + name = var.naming_prefix + role = aws_iam_role.this[count.index].name +} + +locals { + iam_instance_profile_arn = ( + length(var.iam_instance_profile_arn) == 0 + ? aws_iam_instance_profile.this[0].arn + : var.iam_instance_profile_arn + ) +} + +resource "aws_security_group" "this" { + count = length(var.security_groups) > 0 ? 0 : 1 + name = var.naming_prefix + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + vpc_id = var.vpc_id +} + +locals { + security_groups = ( + length(var.security_groups) > 0 + ? var.security_groups + : flatten(aws_security_group.this[*].id) + ) + + per_instance_runner_count = ( + var.per_instance_runner_count == -1 + ? data.aws_ec2_instance_type.this.default_vcpus + : var.per_instance_runner_count + ) +} + +module "software_packs" { + source = "./modules/software" + count = length(var.software_packs) + software_pack = var.software_packs[count.index] +} + +module "user_data" { + source = "./modules/user_data" + config = { + region = var.region + cloudwatch_log_group = var.cloudwatch_log_group + github_url = var.github_url + github_organisation_name = var.github_organisation_name + + cloud_init_packages = distinct( + concat( + flatten(module.software_packs[*].packages), + var.cloud_init_extra_packages + ) + ) + cloud_init_runcmds = concat( + flatten(module.software_packs[*].runcmds), + var.cloud_init_extra_runcmds + ) + cloud_init_write_files = var.cloud_init_extra_write_files + cloud_init_other = var.cloud_init_extra_other + + per_instance_runner_count = local.per_instance_runner_count + + runner_group = var.github_runner_group + runner_labels = var.github_runner_labels + + ssm_parameter_name = var.ssm_parameter_name + } +} + + +resource "aws_launch_template" "this" { + name = var.naming_prefix + + block_device_mappings { + device_name = "/dev/sda1" + ebs { + encrypted = true + delete_on_termination = true + volume_size = ( + var.ec2_ebs_volume_size == -1 + ? data.aws_ec2_instance_type.this.default_vcpus * 20 + : var.ec2_ebs_volume_size + ) + } + } + + iam_instance_profile { + arn = local.iam_instance_profile_arn + } + + image_id = data.aws_ami.ami.id + + instance_market_options { + market_type = "spot" + } + + instance_type = var.ec2_instance_type + + key_name = var.ec2_key_pair_name + + network_interfaces { + associate_public_ip_address = var.ec2_associate_public_ip_address + security_groups = local.security_groups + } + + user_data = base64gzip(module.user_data.user_data) + + lifecycle { + create_before_destroy = true + } +} + +locals { + autoscaling_group_name = var.naming_prefix +} + +resource "aws_autoscaling_group" "this" { + count = (var.scaling_mode == "autoscaling-group" ? 1 : 0) + name = local.autoscaling_group_name + min_size = var.autoscaling_min_size + max_size = var.autoscaling_max_size + desired_capacity = var.autoscaling_desired_size + + launch_template { + id = aws_launch_template.this.id + # trigger instance refresh + version = aws_launch_template.this.latest_version + } + + max_instance_lifetime = var.autoscaling_max_instance_lifetime + + instance_refresh { + strategy = "Rolling" + } + + vpc_zone_identifier = var.subnet_ids + + lifecycle { + ignore_changes = [desired_capacity, target_group_arns] + } + + tag { + key = "Name" + value = var.naming_prefix + propagate_at_launch = true + } +} + +resource "aws_autoscaling_schedule" "on" { + count = ( + var.scaling_mode == "autoscaling-group" + ? length(var.autoscaling_schedule_on_recurrences) + : 0) + scheduled_action_name = "${var.naming_prefix}-on-${count.index}" + min_size = var.autoscaling_min_size + max_size = var.autoscaling_max_size + desired_capacity = var.autoscaling_desired_size + recurrence = var.autoscaling_schedule_on_recurrences[count.index] + time_zone = var.autoscaling_schedule_time_zone + autoscaling_group_name = local.autoscaling_group_name + depends_on = [ + aws_autoscaling_group.this + ] +} + +resource "aws_autoscaling_schedule" "off" { + count = ( + var.scaling_mode == "autoscaling-group" + ? length(var.autoscaling_schedule_off_recurrences) + : 0) + scheduled_action_name = "${var.naming_prefix}-off-${count.index}" + min_size = 0 + max_size = 0 + desired_capacity = 0 + recurrence = var.autoscaling_schedule_off_recurrences[count.index] + time_zone = var.autoscaling_schedule_time_zone + autoscaling_group_name = local.autoscaling_group_name + depends_on = [ + aws_autoscaling_group.this + ] +} + +resource "aws_autoscaling_policy" "scale_down" { + count = (var.scaling_mode == "autoscaling-group" ? 1 : 0) + name = "${var.naming_prefix}-scale-down" + autoscaling_group_name = local.autoscaling_group_name + adjustment_type = "ChangeInCapacity" + scaling_adjustment = -1 + cooldown = 120 +} + +resource "aws_cloudwatch_metric_alarm" "scale_down" { + count = (var.scaling_mode == "autoscaling-group" ? 1 : 0) + alarm_description = "Monitor CPU for ASG ${local.autoscaling_group_name}" + alarm_actions = [aws_autoscaling_policy.scale_down[count.index].arn] + alarm_name = "${var.naming_prefix}-scale-down" + comparison_operator = "LessThanOrEqualToThreshold" + namespace = "AWS/EC2" + metric_name = "CPUUtilization" + threshold = "10" + evaluation_periods = "2" + period = "120" + statistic = "Average" + + dimensions = { + AutoScalingGroupName = local.autoscaling_group_name + } + depends_on = [ + aws_autoscaling_group.this + ] +} + +resource "aws_instance" "this" { + count = var.scaling_mode == "single-instance" ? 1 : 0 + launch_template { + id = aws_launch_template.this.id + version = aws_launch_template.this.latest_version + } + subnet_id = var.subnet_ids[0] + tags = { + Name = var.naming_prefix + } +} diff --git a/modules/software/main.tf b/modules/software/main.tf new file mode 100644 index 0000000..920d29e --- /dev/null +++ b/modules/software/main.tf @@ -0,0 +1,66 @@ +locals { + + packages = { + "docker-engine" = ["ca-certificates", "curl", "gnupg", "lsb-release"] + "node" = ["nodejs"] + "pre-commit" = ["pre-commit"] + "python2" = ["python2"] + "python3" = ["python3", "python3-pip", "python3-venv", "python-is-python3"] + "tflint" = ["unzip"] + } + + runcmds = { + "docker-engine" = [ + "echo ==== DOCKER-ENGINE ====", + "sudo mkdir -p /etc/apt/keyrings", + "curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg", + "echo \"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable\" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null", + "sudo apt-get update", + "sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin", + "sudo usermod -aG docker ubuntu", + # "sudo usermod -a -G root ubuntu" # issue #6 + ] + + "terraform" = [ + "echo ==== TERRAFORM ====", + "wget -O- https://apt.releases.hashicorp.com/gpg | gpg --dearmor | sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg", + "echo \"deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main\" | sudo tee /etc/apt/sources.list.d/hashicorp.list", + "sudo apt-get update", + "sudo apt-get install -y terraform" + ] + + "terraform-docs" = [ + "echo ==== TERRAFORM-DOCS ====", + "curl -sSLo ./terraform-docs.tar.gz https://terraform-docs.io/dl/v0.16.0/terraform-docs-v0.16.0-$(uname)-amd64.tar.gz", + "tar -xzf terraform-docs.tar.gz", + "chmod +x terraform-docs", + "mv terraform-docs /usr/local/bin/terraform-docs" + ] + + "tflint" = [ + "echo ==== TFLINT ====", + "sudo curl -s https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash" + ] + + "__TEST_ORDER__" = [ + "z1", "y2", "x3", "w4", "v5" + ] + } + + packages_out = ( + var.software_pack == "ALL" + ? flatten([for k, v in local.packages : v]) + : lookup(local.packages, var.software_pack, []) + ) + runcmds_out = ( + var.software_pack == "ALL" + ? flatten([for k, v in local.runcmds : v]) + : lookup(local.runcmds, var.software_pack, []) + ) + + software_packs = ( + var.software_pack == "ALL" + ? local.all + : [var.software_pack] + ) +} diff --git a/modules/software/outputs.tf b/modules/software/outputs.tf new file mode 100644 index 0000000..8e4c75a --- /dev/null +++ b/modules/software/outputs.tf @@ -0,0 +1,14 @@ +output "packages" { + value = local.packages_out + description = "Ordered list of cloudinit packages" +} + +output "runcmds" { + value = local.runcmds_out + description = "Ordered list of cloudinit runcmds" +} + +output "software_packs" { + description = "List of software packs that were installed." + value = local.software_packs +} diff --git a/modules/software/software_packs.tf b/modules/software/software_packs.tf new file mode 100644 index 0000000..0894739 --- /dev/null +++ b/modules/software/software_packs.tf @@ -0,0 +1,13 @@ +locals { + # All available software packs + all = [ + "docker-engine", + "node", + "pre-commit", + "python2", + "python3", + "terraform", + "terraform-docs", + "tflint", + ] +} diff --git a/modules/software/variables.tf b/modules/software/variables.tf new file mode 100644 index 0000000..a254697 --- /dev/null +++ b/modules/software/variables.tf @@ -0,0 +1,4 @@ +variable "software_pack" { + type = string + description = "Pre-defined software pack." +} diff --git a/modules/software/versions.tf b/modules/software/versions.tf new file mode 100644 index 0000000..51cad10 --- /dev/null +++ b/modules/software/versions.tf @@ -0,0 +1,3 @@ +terraform { + required_version = ">= 0.13.1" +} diff --git a/modules/user_data/cloud-init-ephemeral.yaml b/modules/user_data/cloud-init-ephemeral.yaml new file mode 100644 index 0000000..fa94506 --- /dev/null +++ b/modules/user_data/cloud-init-ephemeral.yaml @@ -0,0 +1,121 @@ +#cloud-config +# See https://cloudinit.readthedocs.io/en/latest/ + +# ## Helpful commands ## +# sudo cat /var/lib/cloud/instance/user-data.txt +# sudo cat /var/log/cloud-init-output.log +# cloud-init devel schema --config-file x.yaml + +# Install additional packages on first boot +package_update: true +package_reboot_if_required: true +packages: +- awscli +- jq +- parallel +- cloud-utils +# ########################################## +# Extra packages +%{~ for X in PACKAGES } +- ${X} +%{~ endfor } +# ########################################## +# ########################################## +write_files: +- path: /home/ubuntu/ephemeral_runner.sh + permissions: '700' + content: | + #!/bin/bash + set -e + die() { echo "$*" 1>&2 ; exit 1; } + [ "$#" -eq 1 ] || die "1 argument required, $# provided." + RUN=$1 + cp -R /home/ubuntu/actions-runner /home/ubuntu/actions-runner-$RUN + PERSONAL_ACCESS_TOKEN=`aws ssm get-parameter --with-decryption --name ${SSM_PARAMETER_NAME} --region ${REGION} | jq -r '.Parameter.Value'` + [ "$PERSONAL_ACCESS_TOKEN" != "" ] || die "Unable to retrieve access token." + TOKEN_RESPONSE=`curl -s -X POST -H "Accept: application/vnd.github+json" -H "Authorization: token $PERSONAL_ACCESS_TOKEN" "https://api.github.com/orgs/${GITHUB_ORGANISATION_NAME}/actions/runners/registration-token"` + TOKEN=`echo $TOKEN_RESPONSE | jq -r '.token'` + [ "$TOKEN" != "null" ] || die "Unable to retrieve token." + NAME=`hostname`-run-$RUN + echo NAME=$NAME + /home/ubuntu/actions-runner-$RUN/config.sh --url '${GITHUB_URL}' --ephemeral --disableupdate --token $TOKEN --name $NAME ${ARG_RUNNERGROUP} ${ARG_LABELS} + cd /home/ubuntu/actions-runner-$RUN/ + bash ./run.sh +- path: /home/ubuntu/ephemeral_runner_svc.sh + permissions: '700' + content: | + #!/bin/bash + # Cleanup leftovers if instance was rebooted + rm -rf /home/ubuntu/actions-runner-* + INSTANCE_ID=`ec2metadata --instance-id` + aws ec2 create-tags --region ${REGION} --resources $INSTANCE_ID --tags Key=terraform-aws-github-runner:setup,Value=done +%{~ if PARALLEL_RUNNER_COUNT == -1 } + JOBS=`nproc` +%{~ else} + JOBS=${PARALLEL_RUNNER_COUNT} +%{~ endif } + echo JOBS=$JOBS + if [ $JOBS -ge 1 ]; then + seq 99999 | parallel -j $JOBS -a - 'su ubuntu -c "/home/ubuntu/ephemeral_runner.sh {}" ; rm -rf /home/ubuntu/actions-runner-{} ; sleep 1' + fi +- path: /etc/systemd/system/this.service + content: | + [Unit] + Description=this service + After=network.target + [Service] + Type=simple + User=root + ExecStart=/home/ubuntu/ephemeral_runner_svc.sh + [Install] + WantedBy=multi-user.target +%{~ if length(CLOUDWATCH_LOG_GROUP) > 0 } +- path: awslogs.conf + content: | + [general] + state_file = /var/awslogs/state/agent-state + [/var/log/syslog] + datetime_format = %Y-%m-%d %H:%M:%S + file = /var/log/syslog + buffer_duration = 5000 + log_stream_name = {instance_id} + initial_position = start_of_file + log_group_name = ${CLOUDWATCH_LOG_GROUP}/var/log/syslog +%{~ endif } +%{~ for X in WRITE_FILES } +- ${X} +%{~ endfor } +runcmd: +# ########################################## +# Extra runcmds +- echo ==== EXTRA RUNCMDS ==== +%{~ for X in RUNCMDS } +- ${X} +%{~ endfor } +%{~ if length(CLOUDWATCH_LOG_GROUP) > 0 } +- curl -s https://s3.amazonaws.com/aws-cloudwatch/downloads/latest/awslogs-agent-setup.py -O +- python2 ./awslogs-agent-setup.py -r ${REGION} -n -c awslogs.conf +%{~ endif } +# ########################################## +# ########################################## +- echo ==== ACTIONS-RUNNER ==== +# download actions-runner +- mkdir actions-runner && cd actions-runner +- curl -s https://github.com/actions/runner/releases | grep -o -E "https://github.*actions-runner-linux-x64-[0-9\.]+.tar.gz" | sort | uniq > versions.txt +- RUNNER_FILE_LINK=`cat versions.txt | tail -n1` +- curl -s -o actions-runner-linux-x64.tar.gz -L $RUNNER_FILE_LINK +- tar xzf ./actions-runner-linux-x64.tar.gz +- cd .. +- mv actions-runner /home/ubuntu/actions-runner +- chown -R ubuntu:ubuntu /home/ubuntu +- chmod a+r -R /home/ubuntu/actions-runner +- systemctl enable this.service + +power_state: + mode: reboot + +# ########################################## +# Other +%{~ if length(OTHER) > 0 } +${OTHER} +%{~ endif } diff --git a/modules/user_data/cloud-init.yaml b/modules/user_data/cloud-init.yaml new file mode 100644 index 0000000..980919b --- /dev/null +++ b/modules/user_data/cloud-init.yaml @@ -0,0 +1,98 @@ +#cloud-config +# See https://cloudinit.readthedocs.io/en/latest/ + +# ## Helpful commands ## +# sudo cat /var/lib/cloud/instance/user-data.txt +# sudo cat /var/log/cloud-init-output.log +# cloud-init devel schema --config-file x.yaml + +users: +- default +%{~ if length(SSH_AUTHORIZED_KEYS) > 0 } +- name: ubuntu + ssh_authorized_keys: + %{~ for X in SSH_AUTHORIZED_KEYS } + - ${X} + %{~ endfor } +%{~ endif } +%{~ for X in USERS } +- ${X} +%{~ endfor } + + +# Install additional packages on first boot +package_update: true +package_reboot_if_required: true +packages: +- awscli +- jq +- cloud-utils +# ########################################## +# Extra packages +%{~ for X in PACKAGES } +- ${X} +%{~ endfor } +# ########################################## +# ########################################## +%{~ if length(WRITE_FILES) > 0 or length(CLOUDWATCH_LOG_GROUP) > 0 } +write_files: +%{~ endif } +%{~ if length(CLOUDWATCH_LOG_GROUP) > 0 } +- path: awslogs.conf + content: | + [general] + state_file = /var/awslogs/state/agent-state + [/var/log/syslog] + datetime_format = %Y-%m-%d %H:%M:%S + file = /var/log/syslog + buffer_duration = 5000 + log_stream_name = {instance_id} + initial_position = start_of_file + log_group_name = ${CLOUDWATCH_LOG_GROUP}/var/log/syslog +%{~ endif } +%{~ for X in WRITE_FILES } +- ${X} +%{~ endfor } +runcmd: +# ########################################## +# Extra runcmds +- echo ==== EXTRA RUNCMDS ==== +%{~ for X in RUNCMDS } +- ${X} +%{~ endfor } +%{~ if length(CLOUDWATCH_LOG_GROUP) > 0 } +- curl https://s3.amazonaws.com/aws-cloudwatch/downloads/latest/awslogs-agent-setup.py -O +- python2 ./awslogs-agent-setup.py -r ${REGION} -n -c awslogs.conf +%{~ endif } +# ########################################## +# ########################################## +- echo ==== ACTIONS-RUNNER ==== +# install actions-runner +- mkdir actions-runner && cd actions-runner +- curl -s https://github.com/actions/runner/releases | grep -o -E "https://github.*actions-runner-linux-x64-[0-9\.]+.tar.gz" | sort | uniq > versions.txt +- RUNNER_FILE_LINK=`cat versions.txt | tail -n1` +- curl -o actions-runner-linux-x64.tar.gz -L $RUNNER_FILE_LINK +- tar xzf ./actions-runner-linux-x64.tar.gz +- cd .. +- mv actions-runner /home/ubuntu/actions-runner +- chown -R ubuntu:ubuntu /home/ubuntu + +# configure actions-runner +- PERSONAL_ACCESS_TOKEN=`aws ssm get-parameter --with-decryption --name ${SSM_PARAMETER_NAME} --region ${REGION} | jq -r '.Parameter.Value'` +- > + TOKEN_RESPONSE=`curl -X POST -H "Accept: application/vnd.github+json" -H "Authorization: token $PERSONAL_ACCESS_TOKEN" https://api.github.com/orgs/${GITHUB_ORGANISATION_NAME}/actions/runners/registration-token` +- TOKEN=`echo $TOKEN_RESPONSE | jq -r '.token'` +- su ubuntu -c "/home/ubuntu/actions-runner/config.sh --url '${GITHUB_URL}' --unattended --token $TOKEN ${ARG_RUNNERGROUP} ${ARG_LABELS}" +# Install as service +- cd /home/ubuntu/actions-runner/ && bash ./svc.sh install ubuntu +- echo ==== ACTIONS-RUNNER DONE ==== +- INSTANCE_ID=`ec2metadata --instance-id` +- aws ec2 create-tags --region ${REGION} --resources $INSTANCE_ID --tags Key=terraform-aws-github-runner:setup,Value=done +power_state: + mode: reboot + +# ########################################## +# Other +%{~ if length(OTHER) > 0 } +${OTHER} +%{~ endif } diff --git a/modules/user_data/main.tf b/modules/user_data/main.tf new file mode 100644 index 0000000..9f077f1 --- /dev/null +++ b/modules/user_data/main.tf @@ -0,0 +1,75 @@ +locals { + runner_labels = join(",", var.config.runner_labels) + + user_data = templatefile( + "${path.module}/cloud-init-ephemeral.yaml", { + REGION = var.config.region + SSM_PARAMETER_NAME = var.config.ssm_parameter_name + CLOUDWATCH_LOG_GROUP = var.config.cloudwatch_log_group + + PACKAGES = var.config.cloud_init_packages + RUNCMDS = var.config.cloud_init_runcmds + WRITE_FILES = var.config.cloud_init_write_files + OTHER = var.config.cloud_init_other + + GITHUB_URL = var.config.github_url + GITHUB_ORGANISATION_NAME = ( + length(var.config.github_organisation_name) > 0 + ? var.config.github_organisation_name + : split("/", + regex( + #https://www.rfc-editor.org/rfc/rfc3986#appendix-B + "^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\\?([^#]*))?(#(.*))?", + var.config.github_url)[4] + )[1] + ) + PARALLEL_RUNNER_COUNT = var.config.per_instance_runner_count + + ARG_RUNNERGROUP = length(var.config.runner_group) > 0 ? "--runnergroup '${var.config.runner_group}'" : "" + ARG_LABELS = length(local.runner_labels) > 0 ? "--labels '${local.runner_labels}'" : "" + }) + user_data_no_comments = join("\n", + [ # Comments stripped if they start with "# " + for x in split("\n", local.user_data) : x + if length(regexall("^[[:blank:]]*#[[:blank:]]", x)) == 0 + ] + ) +} + +/* +./actions-runner/config.sh --help + +Commands: + ./config.sh Configures the runner + ./config.sh remove Unconfigures the runner + ./run.sh Runs the runner interactively. Does not require any options. + +Options: + --help Prints the help for each command + --version Prints the runner version + --commit Prints the runner commit + --check Check the runner's network connectivity with GitHub server + +Config Options: + --unattended Disable interactive prompts for missing arguments. Defaults will be used for missing options + --url string Repository to add the runner to. Required if unattended + --token string Registration token. Required if unattended + --name string Name of the runner to configure (default ip-10-1-226-36) + --runnergroup string Name of the runner group to add this runner to (defaults to the default runner group) + --labels string Extra labels in addition to the default: 'self-hosted,Linux,X64' + --work string Relative runner work directory (default _work) + --replace Replace any existing runner with the same name (default false) + --pat GitHub personal access token with repo scope. Used for checking network connectivity when executing `./run.sh --check` + --disableupdate Disable self-hosted runner automatic update to the latest released version` + --ephemeral Configure the runner to only take one job and then let the service un-configure the runner after the job finishes (default false) + +Examples: + Check GitHub server network connectivity: + ./run.sh --check --url --pat + Configure a runner non-interactively: + ./config.sh --unattended --url --token + Configure a runner non-interactively, replacing any existing runner with the same name: + ./config.sh --unattended --url --token --replace [--name ] + Configure a runner non-interactively with three extra labels: + ./config.sh --unattended --url --token --labels L1,L2,L3 +*/ diff --git a/modules/user_data/outputs.tf b/modules/user_data/outputs.tf new file mode 100644 index 0000000..41ff890 --- /dev/null +++ b/modules/user_data/outputs.tf @@ -0,0 +1,4 @@ +output "user_data" { + description = "User data" + value = local.user_data_no_comments +} diff --git a/modules/user_data/variables.tf b/modules/user_data/variables.tf new file mode 100644 index 0000000..8e1fdb1 --- /dev/null +++ b/modules/user_data/variables.tf @@ -0,0 +1,21 @@ +variable "config" { + type = object({ + region = string + ssm_parameter_name = string + cloudwatch_log_group = string + + cloud_init_packages = list(string) + cloud_init_runcmds = list(string) + cloud_init_write_files = list(string) + cloud_init_other = string + + per_instance_runner_count = number + + runner_group = string + runner_labels = list(string) + + github_url = string + github_organisation_name = string + }) + description = "Various configuration needed to generate a GitHub Runner cloudinit script." +} diff --git a/modules/user_data/versions.tf b/modules/user_data/versions.tf new file mode 100644 index 0000000..51cad10 --- /dev/null +++ b/modules/user_data/versions.tf @@ -0,0 +1,3 @@ +terraform { + required_version = ">= 0.13.1" +} diff --git a/outputs.tf b/outputs.tf index e69de29..c266108 100644 --- a/outputs.tf +++ b/outputs.tf @@ -0,0 +1,36 @@ +/* +output "aws_autoscaling_group_arn" { + description = "The Auto Scaling Group ARN." + value = flatten(aws_autoscaling_group.this[*].arn) +} + +output "security_groups" { + description = "The Security Groups associated with EC2 instances." + value = local.security_groups +} + +output "aws_launch_template_arn" { + description = "The EC2 launch template." + value = aws_launch_template.this.arn +} +*/ + +output "software_packs" { + description = "List of software packs that were installed." + value = flatten(module.software_packs[*].software_packs) +} + +output "aws_instance_id" { + description = "Instance ID (when `scaled_mode=single-instance`)" + value = concat([for x in aws_instance.this : x.id], [""])[0] +} + +output "aws_instance_public_ip" { + description = "Instance public IP (when `scaled_mode=single-instance`)" + value = concat([for x in aws_instance.this : x.public_ip], [""])[0] +} + +output "per_instance_runner_count" { + description = "Effective per instance runner count." + value = local.per_instance_runner_count +} diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..6a4db60 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +markers = + slow: marks tests as slow (deselect with '-m "not slow"') +log_level = INFO diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f7cb9e4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +black +boto3 +fabric +flake8 +mock +pre-commit +pytest +pytest-depends +pytest_terraform +PyYAML diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..8538d0f --- /dev/null +++ b/test/README.md @@ -0,0 +1,22 @@ +These tests can easily be run interactively as follows: + +- Configure `terraform.tfvars` in each test folder + +- Configure AWS credentials + +- Create SSM Parameter (optional) + +- Configure AWS region: `export AWS_DEFAULT_REGION=x` + +Then execute: + +```sh +pytest --run-id MY_RUN_ID +``` +or + +```sh +pytest --run-id MY_RUN_ID --ec2-key-pair-name MY_KEY_PAIR_NAME +``` + +While the tests are running, CTRL-C can be used to kill the tests and automatically destroy all created infrastructure. diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..9f99573 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,17 @@ +import pytest + + +def pytest_addoption(parser): + parser.addoption("--run-id", action="store") + parser.addoption("--ec2-key-pair-name", action="store", default="") + + +@pytest.fixture +def inputs(request): + inputs = { + "run_id": request.config.getoption("--run-id"), + "ec2_key_pair_name": request.config.getoption("--ec2-key-pair-name"), + } + if inputs["run_id"] is None: + raise Exception("run_id must be specified.") + return inputs diff --git a/test/terraform/main/defaults.tf b/test/terraform/main/defaults.tf new file mode 100644 index 0000000..e69de29 diff --git a/test/terraform/main/main.tf b/test/terraform/main/main.tf new file mode 100644 index 0000000..7c7114a --- /dev/null +++ b/test/terraform/main/main.tf @@ -0,0 +1,73 @@ +data "aws_caller_identity" "current" { + provider = aws +} + +locals { + naming_prefix = "${var.naming_prefix}-${var.run_id}" +} + +resource "null_resource" "tf_guard_provider_account_match" { + count = tonumber(data.aws_caller_identity.current.account_id == var.aws_account_id ? "1" : "fail") +} + +resource "aws_security_group" "this" { + name = local.naming_prefix + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + vpc_id = var.vpc_id +} + +data "http" "myip" { + url = "http://ipv4.icanhazip.com" +} + +resource "aws_security_group_rule" "ingress" { + description = "Allow SSH ingress to EC2 instance" + type = "ingress" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["${chomp(data.http.myip.body)}/32"] + security_group_id = aws_security_group.this.id +} + +module "this" { + source = "../../../" + + region = var.region + naming_prefix = local.naming_prefix + + ssm_parameter_name = "/github/runner/token" + + github_url = var.github_url + + iam_instance_profile_arn = var.iam_instance_profile_arn + + ec2_instance_type = "t3.micro" + ec2_associate_public_ip_address = true + ec2_key_pair_name = var.ec2_key_pair_name + + scaling_mode = "single-instance" + per_instance_runner_count = 0 + + software_packs = ["python2"] + + cloud_init_extra_other = <<-EOT + users: + - default + - name: ubuntu + ssh_authorized_keys: + - ${var.public_key} + EOT + + security_groups = [aws_security_group.this.id] + + subnet_ids = var.subnet_ids + vpc_id = var.vpc_id +} diff --git a/test/terraform/main/outputs.tf b/test/terraform/main/outputs.tf new file mode 100644 index 0000000..345fee1 --- /dev/null +++ b/test/terraform/main/outputs.tf @@ -0,0 +1,19 @@ +output "public_ip" { + description = "public_ip" + value = module.this.aws_instance_public_ip +} + +output "instance_id" { + description = "instance_id" + value = module.this.aws_instance_id +} + +output "public_key" { + description = "public_key" + value = var.public_key +} + +output "software_packs" { + description = "software_packs" + value = module.this.software_packs +} diff --git a/test/terraform/main/terraform.tf b/test/terraform/main/terraform.tf new file mode 100644 index 0000000..b31028a --- /dev/null +++ b/test/terraform/main/terraform.tf @@ -0,0 +1,21 @@ +terraform { + required_version = ">= 0.13.1" + required_providers { + aws = { + source = "hashicorp/aws" + version = "4.26.0" + } + http = { + source = "hashicorp/http" + version = "3.0.1" + } + null = { + source = "hashicorp/null" + version = "3.1.1" + } + } +} + +provider "aws" { + region = var.region +} diff --git a/test/terraform/main/terraform.tfvars b/test/terraform/main/terraform.tfvars new file mode 100644 index 0000000..d424c3b --- /dev/null +++ b/test/terraform/main/terraform.tfvars @@ -0,0 +1,13 @@ +naming_prefix = "test-terraform-aws-github-runners" + +aws_account_id = "353444730604" +region = "eu-west-1" +vpc_id = "vpc-0ffb43241fb2a6b77" +github_url = "https://github.com/cloudandthings" + +subnet_ids = [ + "subnet-0932051ee2f898528", + "subnet-0640faa0e0e882b4d" +] + +iam_instance_profile_arn = "arn:aws:iam::353444730604:instance-profile/terraform-aws-github-runners" diff --git a/test/terraform/main/variables.tf b/test/terraform/main/variables.tf new file mode 100644 index 0000000..57a69e3 --- /dev/null +++ b/test/terraform/main/variables.tf @@ -0,0 +1,50 @@ +variable "region" { + type = string + description = "region" +} + +variable "public_key" { + type = string + description = "public_key" +} + +variable "naming_prefix" { + type = string + description = "naming_prefix" +} + +variable "run_id" { + type = string + description = "run_id" +} + +variable "ec2_key_pair_name" { + type = string + description = "ec2_key_pair_name" + default = "" +} + +variable "aws_account_id" { + type = string + description = "aws_account_id" +} + +variable "subnet_ids" { + type = list(string) + description = "subnet_ids" +} + +variable "vpc_id" { + type = string + description = "vpc_id" +} + +variable "github_url" { + type = string + description = "github_url" +} + +variable "iam_instance_profile_arn" { + type = string + description = "iam_instance_profile_arn" +} diff --git a/test/terraform/software/main.tf b/test/terraform/software/main.tf new file mode 100644 index 0000000..1ce043b --- /dev/null +++ b/test/terraform/software/main.tf @@ -0,0 +1,15 @@ +module "test_software_all" { + source = "../../../modules/software" + software_pack = "ALL" +} + +module "test_software_order" { + source = "../../../modules/software" + software_pack = "__TEST_ORDER__" +} + +module "test_software" { + source = "../../../modules/software" + count = length(local.software) + software_pack = local.software[count.index] +} diff --git a/test/terraform/software/outputs.tf b/test/terraform/software/outputs.tf new file mode 100644 index 0000000..389a0d3 --- /dev/null +++ b/test/terraform/software/outputs.tf @@ -0,0 +1,25 @@ + +output "test_packages" { + value = module.test_software[*].packages + description = "packages" +} + +output "test_runcmds" { + value = module.test_software[*].runcmds + description = "runcmds" +} + +output "test_all_packages" { + value = module.test_software_all.packages + description = "packages" +} + +output "test_all_software_packs" { + value = module.test_software_all.software_packs + description = "software_packs" +} + +output "test_order_runcmds" { + value = module.test_software_order.runcmds + description = "runcmds" +} diff --git a/test/terraform/software/software_packs.tf b/test/terraform/software/software_packs.tf new file mode 100644 index 0000000..6cfde09 --- /dev/null +++ b/test/terraform/software/software_packs.tf @@ -0,0 +1,12 @@ +locals { + software = [ + "docker-engine", + "node", + "pre-commit", + "python2", + "python3", + "terraform", + "terraform-docs", + "tflint", + ] +} diff --git a/test/terraform/software/variables.tf b/test/terraform/software/variables.tf new file mode 100644 index 0000000..e69de29 diff --git a/test/terraform/software/versions.tf b/test/terraform/software/versions.tf new file mode 100644 index 0000000..51cad10 --- /dev/null +++ b/test/terraform/software/versions.tf @@ -0,0 +1,3 @@ +terraform { + required_version = ">= 0.13.1" +} diff --git a/test/terraform/user_data/main.tf b/test/terraform/user_data/main.tf new file mode 100644 index 0000000..861e86e --- /dev/null +++ b/test/terraform/user_data/main.tf @@ -0,0 +1,47 @@ +# Provide default values for all optional fields +module "user_data_1" { + source = "../../../modules/user_data" + config = { + region = "TEST_REGION" + ssm_parameter_name = "__TEST_PARAMETER__" + cloudwatch_log_group = "" + + cloud_init_packages = [] + cloud_init_runcmds = [] + cloud_init_write_files = [] + cloud_init_other = "" + + per_instance_runner_count = 0 + runner_name = "" + runner_group = "" + runner_labels = [] + + github_url = "__TEST_GITHUB_URL__" + github_organisation_name = "__TEST_GITHUB_ORG_NAME__" + } +} + +# Provide test values for all optional fields +module "user_data_2" { + source = "../../../modules/user_data" + config = { + region = "TEST_REGION" + ssm_parameter_name = "__TEST_PARAMETER__" + cloudwatch_log_group = "_TEST_CLOUDWATCH_LOG_GROUP_" + + cloud_init_packages = ["some_package1", "some_package2"] + cloud_init_runcmds = ["some_cmd_1", "some_cmd_2"] + cloud_init_write_files = ["some_text", "some_more_text"] + cloud_init_other = yamlencode({ + some_section = { some_key = "some_value" } + }) + + per_instance_runner_count = 0 + runner_name = "my_runner" + runner_group = "my_group" + runner_labels = ["label1", "label2"] + + github_url = "__TEST_GITHUB_URL__" + github_organisation_name = "__TEST_GITHUB_ORG_NAME__" + } +} diff --git a/test/terraform/user_data/outputs.tf b/test/terraform/user_data/outputs.tf new file mode 100644 index 0000000..d72f6b5 --- /dev/null +++ b/test/terraform/user_data/outputs.tf @@ -0,0 +1,10 @@ + +output "user_data_1" { + value = module.user_data_1.user_data + description = "user_data_1" +} + +output "user_data_2" { + value = module.user_data_2.user_data + description = "user_data_2" +} diff --git a/test/terraform/user_data/variables.tf b/test/terraform/user_data/variables.tf new file mode 100644 index 0000000..e69de29 diff --git a/test/terraform/user_data/versions.tf b/test/terraform/user_data/versions.tf new file mode 100644 index 0000000..51cad10 --- /dev/null +++ b/test/terraform/user_data/versions.tf @@ -0,0 +1,3 @@ +terraform { + required_version = ">= 0.13.1" +} diff --git a/test/test_environment.py b/test/test_environment.py new file mode 100644 index 0000000..97c3330 --- /dev/null +++ b/test/test_environment.py @@ -0,0 +1,14 @@ +import subprocess +from pytest import mark + + +def test_cloud_init(): + result = subprocess.run( + ["cloud-init", "--version"], capture_output=True, text=True, check=True + ) + assert result.returncode == 0 + + +@mark.slow +def test_inputs(inputs): + assert inputs["run_id"] is not None diff --git a/test/test_main.py b/test/test_main.py new file mode 100644 index 0000000..e5258f4 --- /dev/null +++ b/test/test_main.py @@ -0,0 +1,248 @@ +import os +import string +import random +import time +import pytest +from mock import patch +import logging +from botocore.config import Config +import boto3 +from pytest import mark + +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +from fabric.connection import Connection +from pytest_terraform import terraform + +REGION = "eu-west-1" +SOFTWARE_PACK_TEST_CMDS = { + "docker-engine": "docker version", + "node": "node --version", + "pre-commit": "pre-commit --version", + "python": "python --version", + "python2": "python2 --version", + "python3": "python3 --version", + "terraform": "terraform --version", + "terraform-docs": "terraform-docs --version", + "tflint": "tflint --version", +} + +letters = string.ascii_lowercase +private_key_pass = "".join(random.choice(letters) for i in range(10)) + +# Thanks https://dev.to/aaronktberry/generating-encrypted-key-pairs-in-python-69b +private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) +alg = serialization.BestAvailableEncryption(private_key_pass.encode()) + +encrypted_pem_private_key = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.OpenSSH, + encryption_algorithm=alg, +) + +pem_public_key = private_key.public_key().public_bytes( + encoding=serialization.Encoding.OpenSSH, + format=serialization.PublicFormat.OpenSSH, +) + +private_key_file = open("test-rsa.pem", "w") +private_key_file.write(encrypted_pem_private_key.decode()) +private_key_file.close() + +public_key_file = open("test-rsa.pub", "w") +public_key_file.write(pem_public_key.decode()) +public_key_file.close() + +# Hack: Resorting to global vars and test dependencies +# because pytest_terraform doesn't play nice with Classes. + + +@mark.slow +def test_0_variables(inputs): + os.environ["TF_VAR_run_id"] = inputs["run_id"] + os.environ["TF_VAR_ec2_key_pair_name"] = inputs["ec2_key_pair_name"] + os.environ["TF_VAR_public_key"] = pem_public_key.decode() + os.environ["TF_VAR_region"] = REGION + + +config = Config(region_name=REGION) +ec2 = boto3.client("ec2", config=config) + +public_ip = None +instance_id = None + + +@mark.slow +@pytest.mark.depends(on=["test_0_variables"]) +@terraform("main", scope="session", replay=False) +def test_1_terraform(main): + logging.info(main.outputs) + global instance_id + instance_id = main.outputs["instance_id"]["value"] + if len(instance_id) == 0: + raise Exception("instance_id is invalid") + + global public_ip + public_ip = main.outputs["public_ip"]["value"] + if len(public_ip) == 0: + raise Exception("public_ip is invalid") + + +@mark.slow +@pytest.mark.depends(on=["test_1_terraform"]) +@terraform("main", scope="session", replay=False) +def test_2_ec2_starts(main): + if instance_id is None: + raise Exception + # Wait for instance to be running... + still_starting = True + attempt_count = 0 + while still_starting: + response = ec2.describe_instance_status( + InstanceIds=[instance_id], IncludeAllInstances=True + ) + logging.info(f"{response=}") + x = response["InstanceStatuses"][0] + instance_state = x["InstanceState"]["Name"] + instance_status = x["InstanceStatus"]["Status"] + system_status = x["SystemStatus"]["Status"] + + if instance_state not in ("pending", "running"): + raise Exception(f"instance_state={instance_state}") + if instance_status not in ("ok", "initializing"): + raise Exception(f"instance_status={instance_status}") + if system_status not in ("ok", "initializing"): + raise Exception(f"system_status={system_status}") + if ( + instance_state == "running" + and instance_status == "ok" + and system_status == "ok" + ): + still_starting = False + attempt_count = attempt_count + 1 + logging.info(f"{attempt_count=}") + # Wait up to 5min + if not still_starting or attempt_count >= 6 * 5: + break + time.sleep(10) + assert not still_starting + + +@mark.slow +@pytest.mark.depends(on=["test_2_ec2_starts"]) +@terraform("main", scope="session", replay=False) +def test_3_ec2_tagged(main): + done = False + attempt_count = 0 + while not done: + response = ec2.describe_instances(InstanceIds=[instance_id]) + logging.info(f"{response=}") + reservations = response["Reservations"] + if len(reservations) != 1: + raise Exception("Reservation for instance not found") + instances = reservations[0]["Instances"] + if len(instances) != 1: + raise Exception("Instance not found") + tags = instances[0]["Tags"] + for tag in tags: + if tag["Key"] == "terraform-aws-github-runner:setup": + done = True + attempt_count = attempt_count + 1 + # Wait up to 15 min + if done or attempt_count > 6 * 15: + break + time.sleep(10) + assert done + + +def connection(): + if public_ip is None: + raise Exception + # Configure SSH connection + host = public_ip + user = "ubuntu" + connect_kwargs = { + "key_filename": "test-rsa.pem", + "passphrase": private_key_pass, + "timeout": 20, + } + return Connection(host=host, user=user, connect_kwargs=connect_kwargs) + + +@mark.slow +@patch("sys.stdin", new=open("/dev/null")) +@pytest.mark.depends(on=["test_3_ec2_starts"]) +@terraform("main", scope="session", replay=False) +def test_4_ec2_connection(main): + connected = False + attempt_count = 1 + result = None + while not connected: + with connection() as c: + result = c.run("uname -a") + if result.ok: + connected = True + attempt_count = attempt_count + 1 + # Wait up to 2 min + if connected or attempt_count > 4: + break + logging.info(f"{attempt_count=}") + time.sleep(1) + + logging.info(f"{result=}") + assert connected + + +@mark.slow +@pytest.mark.depends(on=["test_4_ec2_connection"]) +@patch("sys.stdin", new=open("/dev/null")) +@terraform("main", scope="session", replay=False) +def test_5_ec2_completed(main): + completed = False + attempt_count = 1 + result = None + while not completed: + with connection() as c: + result = c.run("cloud-init status") + if result.ok: + logging.info(f"stdout={result.stdout=} {result.stderr=}") + if "status:" in result.stdout and "done" in result.stdout: + completed = True + attempt_count = attempt_count + 1 + # Wait up to 2 min + if completed or attempt_count > 4: + break + logging.info(f"{attempt_count=}") + time.sleep(1) + + logging.info(f"{result=}") + assert completed + + +@mark.slow +@pytest.mark.depends(on=["test_5_ec2_completed"]) +@patch("sys.stdin", new=open("/dev/null")) +@terraform("main", scope="session", replay=False) +def test_6_installed_software(main): + software_packs = main.outputs["software_packs"]["value"] + with connection() as c: + for software_pack in software_packs: + logging.info(f"Testing {software_pack=}") + result = c.run(SOFTWARE_PACK_TEST_CMDS[software_pack]) + logging.info(f"{result.stdout=} {result.stderr=}") + assert result.ok + + +""" +# Download cloud-init logs. +# Useful for local debugging. +@mark.slow +@pytest.mark.depends(on=['test_6_installed_software']) +@patch("sys.stdin", new=open("/dev/null")) +@terraform("main", scope="session", replay=False) +def test_7_cloud_init_get_logs(main): + with connection() as c: + assert c.run("cloud-init collect-logs").ok + c.get("cloud-init.tar.gz") + # TODO Add sudo get for user data.... +""" diff --git a/test/test_software.py b/test/test_software.py new file mode 100644 index 0000000..6fd2622 --- /dev/null +++ b/test/test_software.py @@ -0,0 +1,30 @@ +from pytest_terraform import terraform + + +@terraform("software", scope="session", replay=False) +def test_null(software): + pass + + +def test_software(software): + packages = software.outputs["test_packages"] + runcmds = software.outputs["test_runcmds"] + assert len(packages) > 0 + assert len(runcmds) > 0 + + +def test_software_order(software): + runcmds = software.outputs["test_order_runcmds"] + assert runcmds["value"] == ["z1", "y2", "x3", "w4", "v5"] + + +def test_software_all(software): + software_packs = software.outputs["test_all_software_packs"] + assert "node" in software_packs["value"] + assert "python3" in software_packs["value"] + assert "python2" in software_packs["value"] + + packages = software.outputs["test_all_packages"] + assert "nodejs" in packages["value"] + assert "python3" in packages["value"] + assert "python3-venv" in packages["value"] diff --git a/test/test_user_data.py b/test/test_user_data.py new file mode 100644 index 0000000..695431d --- /dev/null +++ b/test/test_user_data.py @@ -0,0 +1,30 @@ +import pytest +from pytest_terraform import terraform + +import yaml +import subprocess + + +@terraform("user_data", scope="session", replay=False) +def test_null(user_data): + pass + + +@pytest.mark.parametrize("version", range(1, 2)) +def test_user_data_is_valid_yaml(user_data, version): + out = user_data.outputs[f"user_data_{version}"] + yaml.safe_load(out["value"]) + + +@pytest.mark.parametrize("version", range(1, 2)) +def test_user_data_is_valid_cloud_init(user_data, version, tmp_path): + out = user_data.outputs[f"user_data_{version}"] + tmp_file = tmp_path / "hello.txt" + tmp_file.write_text(out["value"]) + result = subprocess.run( + ["cloud-init", "schema", "--config-file", tmp_file], + capture_output=True, + text=True, + check=True, + ) + assert result.returncode == 0 diff --git a/variables.tf b/variables.tf index e69de29..546b13c 100644 --- a/variables.tf +++ b/variables.tf @@ -0,0 +1,201 @@ +# Required variables +variable "ssm_parameter_name" { + description = "SSM parameter name for the GitHub Runner token.
Example: \"/github/runner/token\"." + type = string +} + +variable "region" { + description = "AWS region." + type = string +} + +variable "github_url" { + description = "GitHub organisation URL.
Example: \"https://github.com/cloudandthings/\"." + type = string +} + +variable "naming_prefix" { + description = "Created resources will be prefixed with this." + type = string + default = "github-runner" + validation { + condition = length(var.naming_prefix) > 0 + error_message = "The naming_prefix value cannot be an empty string." + } +} + +variable "subnet_ids" { + type = list(string) + description = "The list of Subnet IDs to launch EC2 instances in.
If `scaling_mode=\"single-instance\"` then the first Subnet ID from this list will be used." +} + +variable "vpc_id" { + type = string + description = "The VPC ID to launch instances in." +} + +# Optional variables +variable "ami_name" { + description = "AWS AMI name filter for launching instances.
GitHub supports specific operating systems and architectures, including Ubuntu 22.04 amd64 which is the default.
Note: The included software packs are not tested with other AMIs." + type = string + default = "ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-20220609" +} + +variable "scaling_mode" { + description = "How instances are managed.
Can be either `\"autoscaling-group\"` or `\"single-instance\"`." + type = string + default = "autoscaling-group" + + validation { + condition = contains([ + "autoscaling-group", "single-instance" + ], var.scaling_mode) + error_message = "The scaling mode must be \"autoscaling-group\" or \"single-instance\"." + } +} + +variable "cloudwatch_log_group" { + description = "CloudWatch log group name prefix. Runner logs from /var/log/syslog are sent here.
Example: `github_runner`, with this value logs will be written to `github_runner/var/log/syslog/`.
If left unspecified then logging is disabled." + type = string + default = "" +} + +variable "autoscaling_min_size" { + description = "The minimum size of the Auto Scaling Group.
*When `scaling_mode=\"autoscaling-group\"`*" + type = number + default = 1 +} + +variable "autoscaling_desired_size" { + description = "The number of Amazon EC2 instances that should be running.
*When `scaling_mode=\"autoscaling-group\"`*" + type = number + default = 1 +} + +variable "autoscaling_max_size" { + description = "The maximum size of the Auto Scaling Group.
*When `scaling_mode=\"autoscaling-group\"`*" + type = number + default = 3 +} + +variable "autoscaling_max_instance_lifetime" { + description = "The maximum amount of time, in seconds, that an instance can be in service. Values must be either equal to `0` or between `86400` and `31536000` seconds.
*When `scaling_mode=\"autoscaling-group\"`*" + type = string + default = 0 +} + +variable "autoscaling_schedule_on_recurrences" { + description = "A list of schedule cron expressions, specifying when the Auto Scaling Group will launch instances.
Example: `[\"0 07 * * MON-FRI\"]`
*When `scaling_mode=\"autoscaling-group\"`*" + type = list(string) + default = [] +} + +variable "autoscaling_schedule_off_recurrences" { + description = "A list of schedule cron expressions, specifying when the Auto Scaling Group will terminate all instances.
Example: `[\"0 18 * * *\"]`
*When `scaling_mode=\"autoscaling-group\"`*" + type = list(string) + default = [] +} + +variable "autoscaling_schedule_time_zone" { + description = "The timezone for schedule cron expressions.
https://www.joda.org/joda-time/timezones.html
*When `scaling_mode=\"autoscaling-group\"`*" + type = string + default = "" +} + +variable "cloud_init_extra_packages" { + description = "A list of strings to append beneath the `packages:` section of the `cloudinit` script.
https://cloudinit.readthedocs.io/en/latest/topics/modules.html#package-update-upgrade-install" + type = list(string) + default = [] +} + +variable "cloud_init_extra_runcmds" { + description = "A list of strings to append beneath the `runcmd:` section of the `cloudinit` script.
https://cloudinit.readthedocs.io/en/latest/topics/modules.html#runcmd" + type = list(string) + default = [] +} + +variable "cloud_init_extra_write_files" { + description = "A list of strings to append beneath the `write_files:` section of the `cloudinit` script.
https://cloudinit.readthedocs.io/en/latest/topics/modules.html#write-files" + type = list(string) + default = [] +} + +variable "cloud_init_extra_other" { + description = "Arbitrary text to append to the `cloudinit` script." + type = string + default = "" +} + +variable "ec2_associate_public_ip_address" { + description = "Whether to associate a public IP address with EC2 instances in a VPC." + type = bool + default = false +} + +variable "ec2_instance_type" { + description = "Instance type for EC2 instances." + type = string +} + +variable "ec2_ebs_volume_size" { + description = "Size in GB of instance-attached EBS storage. By default this is set to number of vCPUs per instance * 20 GB." + type = number + default = -1 +} + +variable "ec2_key_pair_name" { + description = "EC2 Key Pair name to allow SSH to EC2 instances." + type = string + default = "" +} + +variable "per_instance_runner_count" { + description = "Number of runners per instance. By default this is set equal to the number of vCPUs per instance. May be set to 0 to never create runners." + type = number + default = -1 +} + +variable "github_organisation_name" { + description = "GitHub orgnisation name. Derived from `github_url` by default." + type = string + default = "" +} + +variable "github_runner_group" { + description = "Custom GitHub runner group." + type = string + default = "" +} + +variable "github_runner_labels" { + description = "Custom GitHub runner labels.
Example: `\"gpu,x64,linux\"`." + type = list(string) + default = [] +} + +variable "iam_instance_profile_arn" { + description = "IAM Instance Profile to launch EC2 instances with. Must allow permissions to read the SSM Parameter. Will be created by default." + type = string + default = "" +} + +variable "security_groups" { + description = "A list of security groups to assign to EC2 instances.
Note: If none are provided, a new security group will be used which will deny inbound traffic **including SSH**." + type = list(string) + default = [] +} + +variable "software_packs" { + type = list(string) + description = "A list of pre-defined software packs to install.
Valid options are: `\"ALL\"`, `\"docker-engine\"`, `\"node\"`, `\"python2\"`, `\"python3\"`, `\"terraform\"`, `\"terraform-docs\"`, `\"tflint\"`.
An empty list will mean none are installed." + default = ["ALL"] + + validation { + condition = alltrue( + [for x in var.software_packs : contains([ + "ALL", "docker-engine", "node", "python2", "python3", "terraform", "terraform-docs", "tflint" + ], x)] + ) + error_message = "Software packs must be a list of: [\"ALL\", \"docker-engine\", \"node\", \"python2\", \"python3\", \"terraform\", \"terraform-docs\", \"tflint\"]." + } +} diff --git a/versions.tf b/versions.tf index e69de29..4df33fe 100644 --- a/versions.tf +++ b/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 0.13.1" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 4.9" + } + http = { + source = "hashicorp/http" + version = "3.0.1" + } + } +}