├── .github
├── CODEOWNERS
└── workflows
│ ├── terraform-docs.yml
│ ├── terraform-master.yml
│ └── terraform-tests.yml
├── .gitignore
├── LICENSE
├── Makefile
├── README.md
├── alb.tf
├── ecs.tf
├── examples
├── basic-local-with-requirements
│ ├── main.tf
│ └── requirements.txt
├── basic-sequential-rbac
│ ├── main.tf
│ └── requirements.txt
└── basic-sequential
│ └── main.tf
├── go.mod
├── go.sum
├── iam.tf
├── locals.tf
├── main.tf
├── outputs.tf
├── rds.tf
├── route53.tf
├── s3.tf
├── templates
├── dags
│ ├── airflow_seed_dag.py
│ └── example_dag.py
└── startup
│ ├── entrypoint_init.sh
│ ├── entrypoint_scheduler.sh
│ └── entrypoint_webserver.sh
├── test
├── preexisting
│ └── main.tf
├── provider.tf
└── terraform_apply_destroy_test.go
└── variables.tf
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | .github/* @datarootsio/terraform-modules
--------------------------------------------------------------------------------
/.github/workflows/terraform-docs.yml:
--------------------------------------------------------------------------------
1 | name: "docs"
2 | on:
3 | push:
4 | branches:
5 | - main
6 | pull_request:
7 | branches:
8 | - main
9 | - open-pr-here
10 | jobs:
11 | docs:
12 | name: "Docs"
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@v2
16 | with:
17 | ref: ${{ github.event.pull_request.head.ref }}
18 |
19 | - name: Render terraform docs inside the README.md and push changes back to PR branch
20 | uses: Dirrk/terraform-docs@v1.0.8
21 | with:
22 | tf_docs_working_dir: .
23 | tf_docs_output_file: README.md
24 | tf_docs_output_method: inject
25 | tf_docs_git_push: 'true'
--------------------------------------------------------------------------------
/.github/workflows/terraform-master.yml:
--------------------------------------------------------------------------------
1 | name: 'publish'
2 | on:
3 | push:
4 | branches:
5 | - main
6 | jobs:
7 | publish:
8 | name: 'Publish'
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: 'Checkout'
12 | uses: actions/checkout@main
13 | - name: Find Tag
14 | id: get_latest_tag
15 | uses: jimschubert/query-tag-action@v1
16 | with:
17 | include: 'v*'
18 | exclude: '*-rc*'
19 | commit-ish: 'HEAD~'
20 | - name: 'Get next version'
21 | id: next_tag
22 | uses: "WyriHaximus/github-action-next-semvers@master"
23 | with:
24 | version: ${{ steps.get_latest_tag.outputs.tag }}
25 | - name: Create Release
26 | id: create_release
27 | uses: actions/create-release@v1
28 | env:
29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
30 | with:
31 | tag_name: "${{ steps.next_tag.outputs.v_patch }}"
32 | release_name: "${{ steps.next_tag.outputs.v_patch }}"
33 | body: |
34 | Automatic release for ${{ steps.next_tag.outputs.v_patch }}
35 | draft: false
36 | prerelease: false
37 | - name: Push changes
38 | uses: ad-m/github-push-action@master
39 | with:
40 | github_token: ${{ secrets.GITHUB_TOKEN }}
41 | branch: open-pr-here
42 | force: true
--------------------------------------------------------------------------------
/.github/workflows/terraform-tests.yml:
--------------------------------------------------------------------------------
1 | name: "tests"
2 | on:
3 | push:
4 | branches:
5 | - main
6 | pull_request:
7 | branches:
8 | - main
9 | - open-pr-here
10 | jobs:
11 | validate:
12 | name: "Validate"
13 | runs-on: ubuntu-latest
14 | if: ${{ github.event_name == 'pull_request' }}
15 | steps:
16 | - name: "Checkout"
17 | uses: actions/checkout@master
18 |
19 | - uses: hashicorp/setup-terraform@v1
20 | with:
21 | terraform_version: 0.15.4
22 |
23 | - name: Terraform Init
24 | run: terraform init
25 |
26 | - name: Terraform Format
27 | run: terraform fmt -check
28 |
29 | - name: Terraform Validate
30 | run: terraform validate
31 |
32 | - name: tflint
33 | run: docker run --rm -v $(pwd):/data -t wata727/tflint
34 | test:
35 | env:
36 | tests_timeout: "2h"
37 | golangci_lint_version: "v1.32"
38 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
39 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
40 | AWS_DEFAULT_REGION: eu-west-1
41 | name: "Tests"
42 | runs-on: ubuntu-latest
43 | if: ${{ github.event_name == 'pull_request' && github.base_ref == 'main' && github.head_ref == 'open-pr-here' }}
44 | steps:
45 | - name: "Checkout"
46 | uses: actions/checkout@master
47 |
48 | - name: "go vet"
49 | run: go vet ./...
50 |
51 | - name: golangci-lint
52 | uses: golangci/golangci-lint-action@v2
53 | with:
54 | # Optional: golangci-lint command line arguments.
55 | args: --timeout=3m0s
56 | version: ${{ env.golangci_lint_version }}
57 |
58 | - uses: hashicorp/setup-terraform@v1
59 | with:
60 | terraform_version: 0.15.4
61 |
62 | - name: "go test"
63 | run: |
64 | go test -v -timeout ${{ env.tests_timeout }} ./...
65 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # Local .terraform directories
3 | **/.terraform/*
4 |
5 | # .tfstate files
6 | *.tfstate
7 | *.tfstate.*
8 |
9 | # Crash log files
10 | crash.log
11 |
12 | # Ignore any .tfvars files that are generated automatically for each Terraform run. Most
13 | # .tfvars files are managed as part of configuration and so should be included in
14 | # version control.
15 | #
16 | # example.tfvars
17 |
18 | # Ignore override files as they are usually used to override resources locally and so
19 | # are not checked in
20 | override.tf
21 | override.tf.json
22 | *_override.tf
23 | *_override.tf.json
24 |
25 | # Include override files you do wish to add to version control using negated pattern
26 | #
27 | # !example_override.tf
28 |
29 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
30 | # example: *tfplan*
31 | .idea
32 | .vscode
33 |
34 | *.tfvars
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 dataroots
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 |
2 | default: lint
3 |
4 | tools:
5 | go install gotest.tools/gotestsum
6 | terraform init
7 |
8 | fmt:
9 | terraform fmt
10 | go mod tidy
11 | gofmt -w -s test
12 |
13 | lint-tf: tools
14 | terraform fmt -check
15 | terraform validate
16 | tflint
17 |
18 | lint-go:
19 | test -z $(gofmt -l -s test)
20 | go vet ./...
21 |
22 | lint: lint-tf lint-go
23 |
24 | test: tools lint
25 | go test -timeout 2h ./...
26 |
27 | testverbose: tools lint
28 | go test -v -timeout 2h ./...
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://dataroots.io)
2 | [](https://airflow.apache.org/)
3 | [](https://www.terraform.io)
4 | [](https://registry.terraform.io/modules/datarootsio/ecs-airflow/aws)
5 | [](https://github.com/datarootsio/terraform-aws-ecs-airflow/actions)
6 | [](https://goreportcard.com/report/github.com/datarootsio/terraform-aws-ecs-airflow)
7 |
8 | 
9 |
10 | ### DEPRECATED: We are no longer actively maintaining this module, instead we recommend that you look into [AWS MWAA](https://aws.amazon.com/managed-workflows-for-apache-airflow/) as a replacement.
11 |
12 | # Terraform module Airflow on AWS ECS
13 |
14 | This is a module for Terraform that deploys Airflow in AWS.
15 |
16 | ## Setup
17 |
18 | - An ECS Cluster with:
19 | - Sidecar injection container
20 | - Airflow init container
21 | - Airflow webserver container
22 | - Airflow scheduler container
23 | - An ALB
24 | - A RDS instance (optional but recommended)
25 | - A DNS Record (optional but recommended)
26 | - A S3 Bucket (optional)
27 |
28 | Average cost of the minimal setup (with RDS): ~50$/Month
29 |
30 | Why do I need a RDS instance?
31 | 1. This makes Airflow statefull, you will be able to rerun failed dags, keep history of failed/succeeded dags, ...
32 | 2. It allows for dags to run concurrently, otherwise two dags will not be able to run at the same time
33 | 3. The state of your dags persists, even if the Airflow container fails or if you update the container definition (this will trigger an update of the ECS task)
34 |
35 | ## Intend
36 |
37 | The Airflow setup provided with this module, is a setup where the only task of Airflow is to manage your jobs/workflows. So not to do actually heavy lifting like SQL queries, Spark jobs, ... . Offload as many task to AWS Lambda, AWS EMR, AWS Glue, ... . If you want Airflow to have access to these services, use the output role and give it permissions to these services through IAM.
38 |
39 | ## Usage
40 |
41 | ```hcl
42 | module "airflow" {
43 | source = "datarootsio/ecs-airflow/aws"
44 |
45 | resource_prefix = "my-awesome-company"
46 | resource_suffix = "env"
47 |
48 | vpc_id = "vpc-123456"
49 | public_subnet_ids = ["subnet-456789", "subnet-098765"]
50 |
51 | rds_password = "super-secret-pass"
52 | }
53 | ```
54 | (This will create Airflow, backed up by an RDS (both in a public subnet) and without https)
55 |
56 | [Press here to see more examples](https://github.com/datarootsio/terraform-aws-ecs-airflow/tree/main/examples)
57 |
58 | Note: After that Terraform is done deploying everything, it can take up to a minute for Airflow to be available through HTTP(S)
59 |
60 | ## Adding DAGs
61 |
62 | To add dags, upload them to the created S3 bucket in the subdir "dags/". After you uploaded them run the seed dag. This will sync the s3 bucket with the local dags folder of the ECS container.
63 |
64 | ## Authentication
65 |
66 | For now the only authentication option is 'RBAC'. When enabling this, this module will create a default admin role (only if there are no users in the database). This default role is just a one time entrypoint in to the airflow web interface. When you log in for the first time immediately change the password! Also with this default admin role you can create any user you want.
67 |
68 | ## Todo
69 |
70 | - [ ] RDS Backup options
71 | - [ ] Option to use SQL instead of Postgres
72 | - [ ] Add a Lambda function that triggers the sync dag (so that you can auto sync through ci/cd)
73 | - [x] RBAC
74 | - [ ] Support for [Google OAUTH](https://airflow.readthedocs.io/en/latest/security.html#google-authentication)
75 |
76 |
77 | ## Requirements
78 |
79 | | Name | Version |
80 | |------|---------|
81 | | terraform | ~> 0.15 |
82 | | aws | ~> 3.12.0 |
83 |
84 | ## Providers
85 |
86 | | Name | Version |
87 | |------|---------|
88 | | aws | ~> 3.12.0 |
89 |
90 | ## Inputs
91 |
92 | | Name | Description | Type | Default | Required |
93 | |------|-------------|------|---------|:--------:|
94 | | airflow\_authentication | Authentication backend to be used, supported backends ["", "rbac"]. When "rbac" is selected an admin role is create if there are no other users in the db, from here you can create all the other users. Make sure to change the admin password directly upon first login! (if you don't change the rbac\_admin options the default login is => username: admin, password: admin) | `string` | `""` | no |
95 | | airflow\_container\_home | Working dir for airflow (only change if you are using a different image) | `string` | `"/opt/airflow"` | no |
96 | | airflow\_example\_dag | Add an example dag on startup (mostly for sanity check) | `bool` | `true` | no |
97 | | airflow\_executor | The executor mode that airflow will use. Only allowed values are ["Local", "Sequential"]. "Local": Run DAGs in parallel (will created a RDS); "Sequential": You can not run DAGs in parallel (will NOT created a RDS); | `string` | `"Local"` | no |
98 | | airflow\_image\_name | The name of the airflow image | `string` | `"apache/airflow"` | no |
99 | | airflow\_image\_tag | The tag of the airflow image | `string` | `"2.0.1"` | no |
100 | | airflow\_log\_region | The region you want your airflow logs in, defaults to the region variable | `string` | `""` | no |
101 | | airflow\_log\_retention | The number of days you want to keep the log of airflow container | `string` | `"7"` | no |
102 | | airflow\_py\_requirements\_path | The relative path to a python requirements.txt file to install extra packages in the container that you can use in your DAGs. | `string` | `""` | no |
103 | | airflow\_variables | The variables passed to airflow as an environment variable (see airflow docs for more info https://airflow.apache.org/docs/). You can not specify "AIRFLOW\_\_CORE\_\_SQL\_ALCHEMY\_CONN" and "AIRFLOW\_\_CORE\_\_EXECUTOR" (managed by this module) | `map(string)` | `{}` | no |
104 | | certificate\_arn | The ARN of the certificate that will be used | `string` | `""` | no |
105 | | dns\_name | The DNS name that will be used to expose Airflow. Optional if not serving over HTTPS. Will be autogenerated if not provided | `string` | `""` | no |
106 | | ecs\_cpu | The allocated cpu for your airflow instance | `number` | `1024` | no |
107 | | ecs\_memory | The allocated memory for your airflow instance | `number` | `2048` | no |
108 | | extra\_tags | Extra tags that you would like to add to all created resources | `map(string)` | `{}` | no |
109 | | ip\_allow\_list | A list of ip ranges that are allowed to access the airflow webserver, default: full access | `list(string)` |
[
"0.0.0.0/0"
]
| no |
110 | | postgres\_uri | The postgres uri of your postgres db, if none provided a postgres db in rds is made. Format ":@:/" | `string` | `""` | no |
111 | | private\_subnet\_ids | A list of subnet ids of where the ECS and RDS reside, this will only work if you have a NAT Gateway in your VPC | `list(string)` | `[]` | no |
112 | | public\_subnet\_ids | A list of subnet ids of where the ALB will reside, if the "private\_subnet\_ids" variable is not provided ECS and RDS will also reside in these subnets | `list(string)` | n/a | yes |
113 | | rbac\_admin\_email | RBAC Email (only when airflow\_authentication = 'rbac') | `string` | `"admin@admin.com"` | no |
114 | | rbac\_admin\_firstname | RBAC Firstname (only when airflow\_authentication = 'rbac') | `string` | `"admin"` | no |
115 | | rbac\_admin\_lastname | RBAC Lastname (only when airflow\_authentication = 'rbac') | `string` | `"airflow"` | no |
116 | | rbac\_admin\_password | RBAC Password (only when airflow\_authentication = 'rbac') | `string` | `"admin"` | no |
117 | | rbac\_admin\_username | RBAC Username (only when airflow\_authentication = 'rbac') | `string` | `"admin"` | no |
118 | | rds\_allocated\_storage | The allocated storage for the rds db in gibibytes | `number` | `20` | no |
119 | | rds\_availability\_zone | Availability zone for the rds instance | `string` | `"eu-west-1a"` | no |
120 | | rds\_deletion\_protection | Deletion protection for the rds instance | `bool` | `false` | no |
121 | | rds\_engine | The database engine to use. For supported values, see the Engine parameter in [API action CreateDBInstance](https://docs.aws.amazon.com/AmazonRDS/latest/APIReference/API_CreateDBInstance.html) | `string` | `"postgres"` | no |
122 | | rds\_instance\_class | The class of instance you want to give to your rds db | `string` | `"db.t2.micro"` | no |
123 | | rds\_password | Password of rds | `string` | `""` | no |
124 | | rds\_skip\_final\_snapshot | Whether or not to skip the final snapshot before deleting (mainly for tests) | `bool` | `false` | no |
125 | | rds\_storage\_type | One of `"standard"` (magnetic), `"gp2"` (general purpose SSD), or `"io1"` (provisioned IOPS SSD) | `string` | `"standard"` | no |
126 | | rds\_username | Username of rds | `string` | `"airflow"` | no |
127 | | rds\_version | The DB version to use for the RDS instance | `string` | `"12.7"` | no |
128 | | region | The region to deploy your solution to | `string` | `"eu-west-1"` | no |
129 | | resource\_prefix | A prefix for the create resources, example your company name (be aware of the resource name length) | `string` | n/a | yes |
130 | | resource\_suffix | A suffix for the created resources, example the environment for airflow to run in (be aware of the resource name length) | `string` | n/a | yes |
131 | | route53\_zone\_name | The name of a Route53 zone that will be used for the certificate validation. | `string` | `""` | no |
132 | | s3\_bucket\_name | The S3 bucket name where the DAGs and startup scripts will be stored, leave this blank to let this module create a s3 bucket for you. WARNING: this module will put files into the path "dags/" and "startup/" of the bucket | `string` | `""` | no |
133 | | use\_https | Expose traffic using HTTPS or not | `bool` | `false` | no |
134 | | vpc\_id | The id of the vpc where you will run ECS/RDS | `string` | n/a | yes |
135 |
136 | ## Outputs
137 |
138 | | Name | Description |
139 | |------|-------------|
140 | | airflow\_alb\_dns | The DNS name of the ALB, with this you can access the Airflow webserver |
141 | | airflow\_connection\_sg | The security group with which you can connect other instance to Airflow, for example EMR Livy |
142 | | airflow\_dns\_record | The created DNS record (only if "use\_https" = true) |
143 | | airflow\_task\_iam\_role | The IAM role of the airflow task, use this to give Airflow more permissions |
144 |
145 |
146 |
147 | ## Makefile Targets
148 |
149 | ```text
150 | Available targets:
151 |
152 | tools Pull Go and Terraform dependencies
153 | fmt Format Go and Terraform code
154 | lint/lint-tf/lint-go Lint Go and Terraform code
155 | test/testverbose Run tests
156 |
157 | ```
158 |
159 | ## Contributing
160 |
161 | Contributions to this repository are very welcome! Found a bug or do you have a suggestion? Please open an issue. Do you know how to fix it? Pull requests are welcome as well! To get you started faster, a Makefile is provided.
162 |
163 | Make sure to install [Terraform](https://learn.hashicorp.com/terraform/getting-started/install.html), [Go](https://golang.org/doc/install) (for automated testing) and Make (optional, if you want to use the Makefile) on your computer. Install [tflint](https://github.com/terraform-linters/tflint) to be able to run the linting.
164 |
165 | * Setup tools & dependencies: `make tools`
166 | * Format your code: `make fmt`
167 | * Linting: `make lint`
168 | * Run tests: `make test` (or `go test -timeout 2h ./...` without Make)
169 |
170 | Make sure you branch from the 'open-pr-here' branch, and submit a PR back to the 'open-pr-here' branch.
171 |
172 | ## License
173 |
174 | MIT license. Please see [LICENSE](LICENSE.md) for details.
175 |
--------------------------------------------------------------------------------
/alb.tf:
--------------------------------------------------------------------------------
1 | // SG only meant for the alb to connect to the outside world
2 | resource "aws_security_group" "alb" {
3 | vpc_id = var.vpc_id
4 | name = "${var.resource_prefix}-alb-${var.resource_suffix}"
5 | description = "Security group for the alb attached to the airflow ecs task"
6 |
7 | egress {
8 | description = "Allow all traffic out"
9 | from_port = 0
10 | to_port = 0
11 | protocol = "-1"
12 | cidr_blocks = ["0.0.0.0/0"]
13 | }
14 |
15 | tags = local.common_tags
16 | }
17 |
18 | resource "aws_security_group_rule" "alb_outside_http" {
19 | for_each = local.inbound_ports
20 | security_group_id = aws_security_group.alb.id
21 | type = "ingress"
22 | protocol = "TCP"
23 | from_port = each.value
24 | to_port = each.value
25 | cidr_blocks = var.ip_allow_list
26 | }
27 |
28 |
29 | // Give this SG to all the instances that want to connect to
30 | // the airflow ecs task. For example rds and the alb
31 | resource "aws_security_group" "airflow" {
32 | vpc_id = var.vpc_id
33 | name = "${var.resource_prefix}-airflow-${var.resource_suffix}"
34 | description = "Security group to connect to the airflow instance"
35 |
36 | egress {
37 | description = "Allow all traffic out"
38 | from_port = 0
39 | to_port = 0
40 | protocol = "-1"
41 | cidr_blocks = ["0.0.0.0/0"]
42 | }
43 |
44 | tags = local.common_tags
45 |
46 | }
47 |
48 | resource "aws_security_group_rule" "airflow_connection" {
49 | security_group_id = aws_security_group.airflow.id
50 | type = "ingress"
51 | protocol = "-1"
52 | from_port = 0
53 | to_port = 0
54 | source_security_group_id = aws_security_group.airflow.id
55 | }
56 |
57 | // ALB
58 | resource "aws_lb" "airflow" {
59 | name = "${var.resource_prefix}-airflow-${var.resource_suffix}"
60 | internal = false
61 | load_balancer_type = "application"
62 | security_groups = [aws_security_group.alb.id, aws_security_group.airflow.id]
63 | subnets = var.public_subnet_ids
64 |
65 | enable_deletion_protection = false
66 |
67 | tags = local.common_tags
68 | }
69 |
70 | resource "aws_lb_listener" "airflow" {
71 | load_balancer_arn = aws_lb.airflow.arn
72 | port = var.use_https ? "443" : "80"
73 | protocol = var.use_https ? "HTTPS" : "HTTP"
74 | certificate_arn = var.use_https ? local.certificate_arn : ""
75 |
76 | default_action {
77 | type = "forward"
78 | target_group_arn = aws_lb_target_group.airflow.arn
79 | }
80 | }
81 |
82 | resource "aws_lb_listener" "airflow_http_redirect" {
83 | count = var.use_https ? 1 : 0
84 | load_balancer_arn = aws_lb.airflow.arn
85 | port = "80"
86 | protocol = "HTTP"
87 |
88 | default_action {
89 | type = "redirect"
90 |
91 | redirect {
92 | port = "443"
93 | protocol = "HTTPS"
94 | status_code = "HTTP_301"
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/ecs.tf:
--------------------------------------------------------------------------------
1 | resource "aws_cloudwatch_log_group" "airflow" {
2 | name = "${var.resource_prefix}-airflow-${var.resource_suffix}"
3 | retention_in_days = var.airflow_log_retention
4 |
5 | tags = local.common_tags
6 | }
7 |
8 | resource "aws_ecs_cluster" "airflow" {
9 | name = "${var.resource_prefix}-airflow-${var.resource_suffix}"
10 | capacity_providers = ["FARGATE_SPOT", "FARGATE"]
11 |
12 | default_capacity_provider_strategy {
13 | capacity_provider = "FARGATE_SPOT"
14 | }
15 |
16 | tags = local.common_tags
17 | }
18 |
19 | resource "aws_ecs_task_definition" "airflow" {
20 | family = "${var.resource_prefix}-airflow-${var.resource_suffix}"
21 | requires_compatibilities = ["FARGATE"]
22 | cpu = var.ecs_cpu
23 | memory = var.ecs_memory
24 | network_mode = "awsvpc"
25 | task_role_arn = aws_iam_role.task.arn
26 | execution_role_arn = aws_iam_role.execution.arn
27 |
28 | volume {
29 | name = local.airflow_volume_name
30 | }
31 |
32 | container_definitions = < {
24 | name = dvo.resource_record_name
25 | record = dvo.resource_record_value
26 | type = dvo.resource_record_type
27 | }
28 | } : {}
29 |
30 | allow_overwrite = true
31 | name = each.value.name
32 | records = [each.value.record]
33 | ttl = 60
34 | type = each.value.type
35 | zone_id = data.aws_route53_zone.zone[0].zone_id
36 | }
37 |
38 | resource "aws_acm_certificate" "cert" {
39 | count = var.use_https && var.certificate_arn == "" ? 1 : 0
40 | domain_name = local.dns_record
41 | validation_method = "DNS"
42 |
43 | lifecycle {
44 | create_before_destroy = true
45 | }
46 |
47 | tags = local.common_tags
48 | }
49 |
50 | resource "aws_acm_certificate_validation" "cert" {
51 | count = var.use_https && var.certificate_arn == "" ? 1 : 0
52 | certificate_arn = aws_acm_certificate.cert[0].arn
53 | validation_record_fqdns = [for record in aws_route53_record.validation : record.fqdn]
54 | }
55 |
--------------------------------------------------------------------------------
/s3.tf:
--------------------------------------------------------------------------------
1 | resource "aws_s3_bucket" "airflow" {
2 | count = var.s3_bucket_name == "" ? 1 : 0
3 | bucket = "${var.resource_prefix}-airflow-${var.resource_suffix}"
4 | acl = "private"
5 |
6 | versioning {
7 | enabled = true
8 | }
9 |
10 | server_side_encryption_configuration {
11 | rule {
12 | apply_server_side_encryption_by_default {
13 | sse_algorithm = "aws:kms"
14 | }
15 | }
16 | }
17 |
18 | tags = local.common_tags
19 | }
20 |
21 | resource "aws_s3_bucket_public_access_block" "airflow" {
22 | count = var.s3_bucket_name == "" ? 1 : 0
23 | bucket = aws_s3_bucket.airflow[0].id
24 |
25 | block_public_acls = true
26 | block_public_policy = true
27 | ignore_public_acls = true
28 | restrict_public_buckets = true
29 | }
30 |
31 | resource "aws_s3_bucket_object" "airflow_seed_dag" {
32 | bucket = local.s3_bucket_name
33 | key = "dags/airflow_seed_dag.py"
34 | content = templatefile("${path.module}/templates/dags/airflow_seed_dag.py", {
35 | BUCKET_NAME = local.s3_bucket_name,
36 | KEY = local.s3_key,
37 | AIRFLOW_HOME = var.airflow_container_home
38 | YEAR = local.year
39 | MONTH = local.month
40 | DAY = local.day
41 | })
42 | }
43 |
44 | resource "aws_s3_bucket_object" "airflow_example_dag" {
45 | count = var.airflow_example_dag ? 1 : 0
46 | bucket = local.s3_bucket_name
47 | key = "dags/example_dag.py"
48 | content = templatefile("${path.module}/templates/dags/example_dag.py", {})
49 | }
50 |
51 | resource "aws_s3_bucket_object" "airflow_scheduler_entrypoint" {
52 | bucket = local.s3_bucket_name
53 | key = "startup/entrypoint_scheduler.sh"
54 | content = templatefile("${path.module}/templates/startup/entrypoint_scheduler.sh", { AIRFLOW_HOME = var.airflow_container_home })
55 | }
56 |
57 | resource "aws_s3_bucket_object" "airflow_webserver_entrypoint" {
58 | bucket = local.s3_bucket_name
59 | key = "startup/entrypoint_webserver.sh"
60 | content = templatefile("${path.module}/templates/startup/entrypoint_webserver.sh", { AIRFLOW_HOME = var.airflow_container_home })
61 | }
62 |
63 | resource "aws_s3_bucket_object" "airflow_init_entrypoint" {
64 | bucket = local.s3_bucket_name
65 | key = "startup/entrypoint_init.sh"
66 | content = templatefile("${path.module}/templates/startup/entrypoint_init.sh", {
67 | RBAC_AUTH = var.airflow_authentication == "rbac" ? "true" : "false",
68 | RBAC_USERNAME = var.rbac_admin_username,
69 | RBAC_EMAIL = var.rbac_admin_email,
70 | RBAC_FIRSTNAME = var.rbac_admin_firstname,
71 | RBAC_LASTNAME = var.rbac_admin_lastname,
72 | RBAC_PASSWORD = var.rbac_admin_password,
73 | AIRFLOW_VERSION = var.airflow_image_tag
74 | })
75 | }
76 |
77 | resource "aws_s3_bucket_object" "airflow_requirements" {
78 | count = var.airflow_py_requirements_path == "" ? 0 : 1
79 | bucket = local.s3_bucket_name
80 | key = "startup/requirements.txt"
81 | content = templatefile(local.airflow_py_requirements_path, {})
82 | }
--------------------------------------------------------------------------------
/templates/dags/airflow_seed_dag.py:
--------------------------------------------------------------------------------
1 | import os
2 | from os import listdir
3 | from os.path import isfile, join
4 |
5 | import datetime
6 | from typing import Dict
7 |
8 | from airflow import DAG
9 | from airflow.operators.python_operator import PythonOperator
10 | from airflow.operators.bash_operator import BashOperator
11 |
12 | import boto3
13 |
14 | # The bucket name and key the of where dags are stored in S3
15 | S3_BUCKET_NAME = "${BUCKET_NAME}"
16 | # airflow home directory where dags & plugins reside
17 | AIRFLOW_HOME = "${AIRFLOW_HOME}"
18 |
19 | args = {
20 | "start_date": datetime.datetime(${YEAR}, ${MONTH}, ${DAY}),
21 | }
22 |
23 | # we prefix the dag with '0' to make it the first dag
24 | with DAG(
25 | dag_id="0_sync_dags_in_s3_to_local_airflow_dags_folder",
26 | default_args=args,
27 | schedule_interval=None
28 | ) as dag:
29 | list_dags_before = BashOperator(
30 | task_id="list_dags_before",
31 | bash_command="find ${AIRFLOW_HOME}/dags -not -path '*__pycache__*'",
32 | )
33 |
34 | sync_dags = BashOperator(
35 | task_id="sync_dag_s3_to_airflow",
36 | bash_command=f"python -m awscli s3 sync --exclude='*' --include='*.py' --size-only --delete s3://{S3_BUCKET_NAME}/dags/ {AIRFLOW_HOME}/dags/"
37 | )
38 |
39 | sync_plugins = BashOperator(
40 | task_id="sync_plugins_s3_to_airflow",
41 | bash_command=f"python -m awscli s3 sync --exclude='*' --include='*.py' --size-only --delete s3://{S3_BUCKET_NAME}/plugins/ {AIRFLOW_HOME}/plugins/"
42 | )
43 |
44 | refresh_dag_bag = BashOperator(
45 | task_id="refresh_dag_bag",
46 | bash_command="python -c 'from airflow.models import DagBag; d = DagBag();'",
47 | )
48 |
49 | list_dags_after = BashOperator(
50 | task_id="list_dags_after",
51 | bash_command="find ${AIRFLOW_HOME}/dags -not -path '*__pycache__*'",
52 | )
53 |
54 | (
55 | list_dags_before >>
56 | [sync_dags, sync_plugins] >>
57 | refresh_dag_bag >>
58 | list_dags_after
59 | )
60 |
--------------------------------------------------------------------------------
/templates/dags/example_dag.py:
--------------------------------------------------------------------------------
1 | from datetime import timedelta, datetime
2 |
3 | from airflow import DAG
4 | from airflow.operators.dummy_operator import DummyOperator
5 | from airflow import AirflowException
6 |
7 | args = {
8 | "owner": "dataroots",
9 | "start_date": datetime(2020, 10, 12),
10 | }
11 |
12 | with DAG(
13 | dag_id="example_dag",
14 | catchup=False,
15 | max_active_runs=1,
16 | default_args=args,
17 | schedule_interval="*/5 * * * *"
18 | ) as dag:
19 | task_a = DummyOperator(
20 | task_id="task_a"
21 | )
22 |
23 | task_b = DummyOperator(
24 | task_id="task_b"
25 | )
26 | task_c = DummyOperator(
27 | task_id="task_c"
28 | )
29 |
30 | task_d = DummyOperator(
31 | task_id="task_d"
32 | )
33 |
34 | task_a >> [task_b, task_c] >> task_d
35 |
--------------------------------------------------------------------------------
/templates/startup/entrypoint_init.sh:
--------------------------------------------------------------------------------
1 | echo "Starting up airflow init"
2 |
3 | # commands change between version so get the major version here
4 | airflow_major_version=$(echo ${AIRFLOW_VERSION} | awk -F. '{ print $1 }')
5 |
6 | # airflow
7 | if [[ "$airflow_major_version" == "1" ]]; then
8 | airflow initdb
9 | else
10 | airflow db init
11 | fi
12 |
13 | # add admin user if rbac enabled and not exists
14 | if [[ "${RBAC_AUTH}" == "true" ]]; then
15 | # get the amount of users to see if we need to add a default user
16 | amount_of_users="-9999"
17 | if [[ "$airflow_major_version" == "1" ]]; then
18 | amount_of_users=$(python -c 'import sys;print((sys.argv.count("│") // 7) - 1)' $(airflow list_users))
19 | else
20 | amount_of_users=$(python -c 'import sys;cmd_in = " ".join(sys.argv);print((cmd_in.count("|") // 5) - 1 if "No data found" not in cmd_in else 0)' $(airflow users list))
21 | fi
22 |
23 | if [[ "$amount_of_users" == "0" ]]; then
24 | echo "Adding admin users, users list is empty!"
25 | if [[ "$airflow_major_version" == "1" ]]; then
26 | airflow create_user -r Admin -u ${RBAC_USERNAME} -e ${RBAC_EMAIL} -f ${RBAC_FIRSTNAME} -l ${RBAC_LASTNAME} -p ${RBAC_PASSWORD}
27 | else
28 | airflow users create -r Admin -u ${RBAC_USERNAME} -e ${RBAC_EMAIL} -f ${RBAC_FIRSTNAME} -l ${RBAC_LASTNAME} -p ${RBAC_PASSWORD}
29 | fi
30 | else
31 | echo "No admin user added, users already exists!"
32 | fi
33 | fi
--------------------------------------------------------------------------------
/templates/startup/entrypoint_scheduler.sh:
--------------------------------------------------------------------------------
1 | echo "Starting up airflow scheduler"
2 | # Sanity check the dags
3 | ls /opt/airflow/dags
4 |
5 | # Install boto and awscli for the seed dag
6 | python -m pip install awscli --user
7 |
8 | # Intall python packages through req.txt and pip (if exists)
9 | if [[ -f "${AIRFLOW_HOME}/startup/requirements.txt" ]]; then
10 | echo "requirements.txt provided, installing it with pip"
11 | python -m pip install -r ${AIRFLOW_HOME}/startup/requirements.txt --user
12 | fi
13 | # Run the airflow webserver
14 | airflow scheduler
--------------------------------------------------------------------------------
/templates/startup/entrypoint_webserver.sh:
--------------------------------------------------------------------------------
1 | echo "Starting up airflow webserver"
2 | # sanity check the dags
3 | ls /opt/airflow/dags
4 |
5 | # Install boto and awscli for the seed dag
6 | python -m pip install awscli --user
7 |
8 | # Intall python packages through req.txt and pip (if exists)
9 | if [[ -f "${AIRFLOW_HOME}/startup/requirements.txt" ]]; then
10 | echo "requirements.txt provided, installing it with pip"
11 | python -m pip install -r ${AIRFLOW_HOME}/startup/requirements.txt --user
12 | fi
13 |
14 | export AIRFLOW__WEBSERVER__SECRET_KEY=$(openssl rand -hex 30)
15 |
16 | # Run the airflow webserver
17 | airflow webserver
--------------------------------------------------------------------------------
/test/preexisting/main.tf:
--------------------------------------------------------------------------------
1 | variable "rds_name" {
2 | type = string
3 | }
4 |
5 | variable "region" {
6 | type = string
7 | description = "The region to deploy your solution to"
8 | default = "eu-west-1"
9 | }
10 |
11 | variable "resource_prefix" {
12 | type = string
13 | description = "A prefix for the create resources, example your company name (be aware of the resource name length)"
14 | }
15 |
16 | variable "resource_suffix" {
17 | type = string
18 | description = "A suffix for the created resources, example the environment for airflow to run in (be aware of the resource name length)"
19 | }
20 |
21 | variable "extra_tags" {
22 | description = "Extra tags that you would like to add to all created resources"
23 | type = map(string)
24 | default = {}
25 | }
26 |
27 | variable "vpc_id" {
28 | type = string
29 | description = "The id of the vpc where you will run ecs/rds"
30 |
31 | validation {
32 | condition = can(regex("^vpc-", var.vpc_id))
33 | error_message = "The vpc_id value must be a valid VPC id, starting with \"vpc-\"."
34 | }
35 | }
36 |
37 | variable "public_subnet_ids" {
38 | type = list(string)
39 | description = "A list of subnet ids of where the ALB will reside, if the \"private_subnet_ids\" variable is not provided ECS and RDS will also reside in these subnets"
40 |
41 | validation {
42 | condition = length(var.public_subnet_ids) >= 2
43 | error_message = "The size of the list \"public_subnet_ids\" must be at least 2."
44 | }
45 | }
46 |
47 | variable "private_subnet_ids" {
48 | type = list(string)
49 | description = "A list of subnet ids of where the ECS and RDS reside, this will only work if you have a NAT Gateway in your VPC"
50 | default = []
51 |
52 | validation {
53 | condition = length(var.private_subnet_ids) >= 2 || length(var.private_subnet_ids) == 0
54 | error_message = "The size of the list \"private_subnet_ids\" must be at least 2 or empty."
55 | }
56 | }
57 |
58 | variable "route53_zone_name" {
59 | type = string
60 | description = "The name of a Route53 zone that will be used for the certificate validation."
61 | default = ""
62 | }
63 |
64 | locals {
65 | own_tags = {
66 | Name = "${var.resource_prefix}-airflow-${var.resource_suffix}"
67 | CreatedBy = "Terraform"
68 | Module = "terraform-aws-ecs-airflow"
69 | }
70 | common_tags = merge(local.own_tags, var.extra_tags)
71 |
72 | timestamp_sanitized = replace(timestamp(), "/[- TZ:]/", "")
73 |
74 | rds_ecs_subnet_ids = length(var.private_subnet_ids) == 0 ? var.public_subnet_ids : var.private_subnet_ids
75 | dns_record = "${var.resource_prefix}-airflow-${var.resource_suffix}.${data.aws_route53_zone.zone.name}"
76 | }
77 |
78 | terraform {
79 | required_version = "~> 0.15"
80 | required_providers {
81 | aws = {
82 | source = "hashicorp/aws"
83 | version = "~> 3.12.0"
84 | }
85 | }
86 | }
87 |
88 | provider "aws" {
89 | version = "~> 3.12.0"
90 | region = var.region
91 | }
92 |
93 | resource "aws_security_group" "rds" {
94 | vpc_id = var.vpc_id
95 | name = "${var.resource_prefix}-rds-${var.resource_suffix}"
96 | description = "Security group for the RDS"
97 |
98 | egress {
99 | description = "Allow all traffic out"
100 | from_port = 0
101 | to_port = 0
102 | protocol = "-1"
103 | cidr_blocks = ["0.0.0.0/0"]
104 | }
105 |
106 | ingress {
107 | description = "Allow PostgreSQL traffic in"
108 | from_port = 5432
109 | to_port = 5432
110 | protocol = "TCP"
111 | cidr_blocks = ["10.150.0.0/16"]
112 | }
113 |
114 | tags = local.common_tags
115 |
116 | }
117 |
118 | resource "aws_db_instance" "airflow" {
119 | name = replace(title(var.rds_name), "-", "")
120 | allocated_storage = 20
121 | storage_type = "standard"
122 | engine = "postgres"
123 | engine_version = "11.8"
124 | instance_class = "db.t2.micro"
125 | username = "dataroots"
126 | password = "dataroots"
127 | multi_az = false
128 | availability_zone = "eu-west-1a"
129 | publicly_accessible = false
130 | skip_final_snapshot = true
131 | deletion_protection = false
132 | final_snapshot_identifier = "${var.resource_prefix}-airflow-${var.resource_suffix}-${local.timestamp_sanitized}"
133 | identifier = var.rds_name
134 | vpc_security_group_ids = [aws_security_group.rds.id]
135 | db_subnet_group_name = aws_db_subnet_group.airflow.name
136 |
137 | tags = local.common_tags
138 | }
139 |
140 | resource "aws_db_subnet_group" "airflow" {
141 | name = "${var.resource_prefix}-airflow-${var.resource_suffix}"
142 | subnet_ids = local.rds_ecs_subnet_ids
143 |
144 | tags = local.common_tags
145 | }
146 |
147 | data "aws_route53_zone" "zone" {
148 | name = var.route53_zone_name
149 | }
150 |
151 | resource "aws_route53_record" "validation" {
152 | for_each = {
153 | for dvo in aws_acm_certificate.cert.domain_validation_options : dvo.domain_name => {
154 | name = dvo.resource_record_name
155 | record = dvo.resource_record_value
156 | type = dvo.resource_record_type
157 | }
158 | }
159 |
160 | allow_overwrite = true
161 | name = each.value.name
162 | records = [each.value.record]
163 | ttl = 60
164 | type = each.value.type
165 | zone_id = data.aws_route53_zone.zone.zone_id
166 | }
167 |
168 | resource "aws_acm_certificate" "cert" {
169 | domain_name = local.dns_record
170 | validation_method = "DNS"
171 |
172 | lifecycle {
173 | create_before_destroy = true
174 | }
175 |
176 | tags = local.common_tags
177 | }
178 |
179 | resource "aws_acm_certificate_validation" "cert" {
180 | certificate_arn = aws_acm_certificate.cert.arn
181 | validation_record_fqdns = [for record in aws_route53_record.validation : record.fqdn]
182 | }
183 |
184 | output "certificate_arn" {
185 | value = aws_acm_certificate.cert.arn
186 | }
187 |
188 | output "postgres_uri" {
189 | value = "${aws_db_instance.airflow.address}:${aws_db_instance.airflow.port}/${aws_db_instance.airflow.name}"
190 | }
191 |
--------------------------------------------------------------------------------
/test/provider.tf:
--------------------------------------------------------------------------------
1 | // Provider to initialize tests
2 |
3 | provider "aws" {
4 | region = var.region
5 | }
6 |
--------------------------------------------------------------------------------
/test/terraform_apply_destroy_test.go:
--------------------------------------------------------------------------------
1 | package test
2 |
3 | import (
4 | "fmt"
5 | "io/ioutil"
6 | "net/http"
7 | "net/http/cookiejar"
8 | "net/url"
9 | "path/filepath"
10 | "strings"
11 | "testing"
12 | "time"
13 |
14 | "github.com/PuerkitoBio/goquery"
15 |
16 | "github.com/aws/aws-sdk-go/service/ecs"
17 | "github.com/aws/aws-sdk-go/service/iam"
18 |
19 | "github.com/gruntwork-io/terratest/modules/aws"
20 | "github.com/gruntwork-io/terratest/modules/files"
21 | "github.com/gruntwork-io/terratest/modules/logger"
22 | "github.com/gruntwork-io/terratest/modules/random"
23 | "github.com/gruntwork-io/terratest/modules/terraform"
24 | testStructure "github.com/gruntwork-io/terratest/modules/test-structure"
25 | "github.com/stretchr/testify/assert"
26 | )
27 |
28 | func AddPreAndSuffix(resourceName string, resourcePrefix string, resourceSuffix string) string {
29 | if resourcePrefix == "" {
30 | resourcePrefix = "dataroots"
31 | }
32 | if resourceSuffix == "" {
33 | resourceSuffix = "dev"
34 | }
35 | return fmt.Sprintf("%s-%s-%s", resourcePrefix, resourceName, resourceSuffix)
36 | }
37 |
38 | func GetContainerWithName(containerName string, containers []*ecs.Container) *ecs.Container {
39 | for _, container := range containers {
40 | if *container.Name == containerName {
41 | return container
42 | }
43 | }
44 | return nil
45 | }
46 |
47 | func validateCluster(t *testing.T, options *terraform.Options, region string, resourcePrefix string, resourceSuffix string) {
48 | retrySleepTime := time.Duration(10) * time.Second
49 | ecsGetTaskArnMaxRetries := 20
50 | ecsGetTaskStatusMaxRetries := 50
51 | httpStatusCodeMaxRetries := 15
52 | amountOfConsecutiveGetsToBeHealthy := 3
53 | desiredStatusRunning := "RUNNING"
54 | clusterName := AddPreAndSuffix("airflow", resourcePrefix, resourceSuffix)
55 | serviceName := AddPreAndSuffix("airflow", resourcePrefix, resourceSuffix)
56 | webserverContainerName := AddPreAndSuffix("airflow-webserver", resourcePrefix, resourceSuffix)
57 | schedulerContainerName := AddPreAndSuffix("airflow-webserver", resourcePrefix, resourceSuffix)
58 | sidecarContainerName := AddPreAndSuffix("airflow-sidecar", resourcePrefix, resourceSuffix)
59 |
60 | expectedNavbarColor := "#e27d60"
61 |
62 | iamClient := aws.NewIamClient(t, region)
63 |
64 | fmt.Println("Checking if roles exists")
65 | rolesToCheck := []string{
66 | AddPreAndSuffix("airflow-task-execution-role", resourcePrefix, resourceSuffix),
67 | AddPreAndSuffix("airflow-task-role", resourcePrefix, resourceSuffix),
68 | }
69 | for _, roleName := range rolesToCheck {
70 | roleInput := &iam.GetRoleInput{RoleName: &roleName}
71 | _, err := iamClient.GetRole(roleInput)
72 | assert.NoError(t, err)
73 | }
74 |
75 | fmt.Println("Checking if ecs cluster exists")
76 | _, err := aws.GetEcsClusterE(t, region, clusterName)
77 | assert.NoError(t, err)
78 |
79 | fmt.Println("Checking if the service is ACTIVE")
80 | airflowEcsService, err := aws.GetEcsServiceE(t, region, clusterName, serviceName)
81 | assert.NoError(t, err)
82 | assert.Equal(t, "ACTIVE", *airflowEcsService.Status)
83 |
84 | fmt.Println("Checking if there is 1 deployment namely the airflow one")
85 | assert.Equal(t, 1, len(airflowEcsService.Deployments))
86 |
87 | ecsClient := aws.NewEcsClient(t, region)
88 |
89 | // Get all the arns of the task that are running.
90 | // There should only be one task running, the airflow task
91 | fmt.Println("Getting task arns")
92 | listRunningTasksInput := &ecs.ListTasksInput{
93 | Cluster: &clusterName,
94 | ServiceName: &serviceName,
95 | DesiredStatus: &desiredStatusRunning,
96 | }
97 |
98 | var taskArns []*string
99 | for i := 0; i < ecsGetTaskArnMaxRetries; i++ {
100 | fmt.Printf("Getting task arns, try... %d\n", i)
101 |
102 | runningTasks, _ := ecsClient.ListTasks(listRunningTasksInput)
103 | if len(runningTasks.TaskArns) == 1 {
104 | taskArns = runningTasks.TaskArns
105 | break
106 | }
107 | time.Sleep(retrySleepTime)
108 | }
109 | fmt.Println("Getting that there is only one task running")
110 | assert.Equal(t, 1, len(taskArns))
111 |
112 | // If there is no task running you can't do the following tests so skip them
113 | if len(taskArns) == 1 {
114 | fmt.Println("Task is running, continuing")
115 | describeTasksInput := &ecs.DescribeTasksInput{
116 | Cluster: &clusterName,
117 | Tasks: taskArns,
118 | }
119 |
120 | // Wait until the 3 containers are in there desired state
121 | // - Sidecar container must be STOPPED to be healthy
122 | // (only runs once and then stops it's an "init container")
123 | // - Webserver container must be RUNNING to be healthy
124 | // - Scheduler container must be RUNNING to be healthy
125 | fmt.Println("Getting container statuses")
126 | var webserverContainer ecs.Container
127 | var schedulerContainer ecs.Container
128 | var sidecarContainer ecs.Container
129 | for i := 0; i < ecsGetTaskStatusMaxRetries; i++ {
130 | fmt.Printf("Getting container statuses, try... %d\n", i)
131 |
132 | describeTasks, _ := ecsClient.DescribeTasks(describeTasksInput)
133 | airflowTask := describeTasks.Tasks[0]
134 | containers := airflowTask.Containers
135 |
136 | webserverContainer = *GetContainerWithName(webserverContainerName, containers)
137 | schedulerContainer = *GetContainerWithName(schedulerContainerName, containers)
138 | sidecarContainer = *GetContainerWithName(sidecarContainerName, containers)
139 |
140 | if *webserverContainer.LastStatus == "RUNNING" &&
141 | *schedulerContainer.LastStatus == "RUNNING" &&
142 | *sidecarContainer.LastStatus == "STOPPED" {
143 | break
144 | }
145 | time.Sleep(retrySleepTime)
146 | }
147 | assert.Equal(t, "RUNNING", *webserverContainer.LastStatus)
148 | assert.Equal(t, "RUNNING", *schedulerContainer.LastStatus)
149 | assert.Equal(t, "STOPPED", *sidecarContainer.LastStatus)
150 |
151 | // We do consecutive checks because sometime it could be that
152 | // the webserver is available for a short amount of time and crashes
153 | // a couple of seconds later
154 | fmt.Println("Doing HTTP request/checking health")
155 |
156 | protocol := "https"
157 | airflowAlbDNS := terraform.Output(t, options, "airflow_dns_record")
158 |
159 | if options.Vars["use_https"] == false {
160 | protocol = "http"
161 | }
162 |
163 | if options.Vars["route53_zone_name"] == "" {
164 | airflowAlbDNS = terraform.Output(t, options, "airflow_alb_dns")
165 | }
166 | airflowURL := fmt.Sprintf("%s://%s", protocol, airflowAlbDNS)
167 |
168 | var amountOfConsecutiveHealthyChecks int
169 | var res *http.Response
170 | for i := 0; i < httpStatusCodeMaxRetries; i++ {
171 | fmt.Printf("Doing HTTP request to airflow webservice, try... %d\n", i)
172 | res, err = http.Get(airflowURL)
173 | if res != nil && err == nil {
174 | fmt.Println(res.StatusCode)
175 | if res.StatusCode >= 200 && res.StatusCode < 400 {
176 | amountOfConsecutiveHealthyChecks++
177 | fmt.Println("Webservice is healthy")
178 | } else {
179 | amountOfConsecutiveHealthyChecks = 0
180 | fmt.Println("Webservice is NOT healthy")
181 | }
182 |
183 | if amountOfConsecutiveHealthyChecks == amountOfConsecutiveGetsToBeHealthy {
184 | break
185 | }
186 | }
187 | time.Sleep(retrySleepTime)
188 | }
189 |
190 | if res != nil {
191 | assert.Equal(t, true, res.StatusCode >= 200 && res.StatusCode < 400)
192 | assert.Equal(t, amountOfConsecutiveGetsToBeHealthy, amountOfConsecutiveHealthyChecks)
193 |
194 | if res.StatusCode >= 200 && res.StatusCode < 400 {
195 | fmt.Println("Getting the actual HTML code")
196 | defer res.Body.Close()
197 | doc, err := goquery.NewDocumentFromReader(res.Body)
198 | assert.NoError(t, err)
199 |
200 | fmt.Println("Checking if the navbar has the correct color")
201 | navbarStyle, exists := doc.Find(".navbar.navbar-inverse.navbar-fixed-top").First().Attr("style")
202 | assert.Equal(t, true, exists)
203 | assert.Contains(t, navbarStyle, fmt.Sprintf("background-color: %s", expectedNavbarColor))
204 |
205 | // if rbac is enabled check if you can log in
206 | // this is to prevent 'issue #9' of happening again
207 | // ref: https://github.com/datarootsio/terraform-aws-ecs-airflow/issues/9
208 | if options.Vars["airflow_authentication"] == "rbac" {
209 | loginToAirflow(t, airflowURL)
210 | }
211 | }
212 | }
213 | }
214 | }
215 |
216 | func loginToAirflow(t *testing.T, airflowURL string) {
217 | username := "admin"
218 | password := "admin"
219 | airflowLoginURL := fmt.Sprintf("%s/login/", airflowURL)
220 |
221 | // we create a client/session to persist some headers
222 | // throughout the calls that we do
223 | jar, _ := cookiejar.New(nil)
224 | client := &http.Client{
225 | Jar: jar,
226 | }
227 |
228 | // get the page to fill in some headers in the http session
229 | // and to parse out the csrfToken
230 | res, err := client.Get(airflowLoginURL)
231 | assert.NoError(t, err)
232 | assert.Equal(t, true, res.StatusCode >= 200 && res.StatusCode < 400)
233 | defer res.Body.Close()
234 |
235 | // read the plain body html and get the csrfToken
236 | bodyBytes, _ := ioutil.ReadAll(res.Body)
237 | bodyLines := strings.Split(string(bodyBytes), "\n")
238 |
239 | // parse out the csrfToken
240 | // TODO: replace this with regex
241 | var csrfToken string
242 | for _, bodyLine := range bodyLines {
243 | if strings.Contains(bodyLine, "csrfToken") {
244 | stringNoSpaces := strings.ReplaceAll(bodyLine, " ", "")
245 | stringRemovedLeft := strings.ReplaceAll(stringNoSpaces, "varcsrfToken='", "")
246 | csrfToken = strings.ReplaceAll(stringRemovedLeft, "';", "")
247 | break
248 | }
249 | }
250 |
251 | // create the url vals to be posted, so that we can login
252 | values := make(url.Values)
253 | values.Set("username", username)
254 | values.Set("password", password)
255 | values.Set("csrf_token", csrfToken)
256 |
257 | // try to login into airflow with given creds
258 | // if we can't login w'll come back to the login page
259 | // if we can w'll see the dags table
260 | res, err = client.PostForm(airflowLoginURL, values)
261 | assert.NoError(t, err)
262 | assert.Equal(t, true, res.StatusCode >= 200 && res.StatusCode < 400)
263 | defer res.Body.Close()
264 |
265 | // check on which page we are login or dags
266 | doc, _ := goquery.NewDocumentFromReader(res.Body)
267 | loginBoxExists := doc.Find("div#loginbox").Length() == 1
268 | dagsTableExists := doc.Find("table#dags").Length() == 1
269 |
270 | assert.Equal(t, false, loginBoxExists)
271 | assert.Equal(t, true, dagsTableExists)
272 | }
273 |
274 | // func getPreexistingTerraformOptions(t *testing.T, region string, resourcePrefix string, resourceSuffix string) (*terraform.Options, error) {
275 | // tempTestFolder := testStructure.CopyTerraformFolderToTemp(t, "preexisting", ".")
276 |
277 | // terraformOptions := &terraform.Options{
278 | // TerraformDir: tempTestFolder,
279 | // Vars: map[string]interface{}{},
280 | // MaxRetries: 5,
281 | // TimeBetweenRetries: 5 * time.Minute,
282 | // NoColor: true,
283 | // Logger: logger.TestingT,
284 | // }
285 |
286 | // terraformOptions.Vars["region"] = region
287 | // terraformOptions.Vars["resource_prefix"] = resourcePrefix
288 | // terraformOptions.Vars["resource_suffix"] = resourceSuffix
289 | // terraformOptions.Vars["extra_tags"] = map[string]interface{}{
290 | // "ATestTag": "a_test_tag",
291 | // "ResourcePrefix": resourcePrefix,
292 | // "ResourceSuffix": resourceSuffix,
293 | // }
294 |
295 | // terraformOptions.Vars["vpc_id"] = "vpc-0eafa6867cb3bdaa3"
296 | // terraformOptions.Vars["public_subnet_ids"] = []string{
297 | // "subnet-08da686d46e99872d",
298 | // "subnet-0e5bb83f963f8df0f",
299 | // }
300 | // terraformOptions.Vars["private_subnet_ids"] = []string{
301 | // "subnet-03c2a3885cfc8a740",
302 | // "subnet-09c0ce0aff676904a",
303 | // }
304 |
305 | // terraformOptions.Vars["rds_name"] = AddPreAndSuffix("preexisting", resourcePrefix, resourceSuffix)
306 | // terraformOptions.Vars["route53_zone_name"] = ""
307 |
308 | // return terraformOptions, nil
309 | // }
310 |
311 | func getDefaultTerraformOptions(t *testing.T, region string, resourcePrefix string, resourceSuffix string) (*terraform.Options, error) {
312 | tempTestFolder := testStructure.CopyTerraformFolderToTemp(t, "..", ".")
313 | err := files.CopyFile("./provider.tf", filepath.Join(tempTestFolder, "provider.tf"))
314 | if err != nil {
315 | t.Fatal(err)
316 | }
317 |
318 | terraformOptions := &terraform.Options{
319 | TerraformDir: tempTestFolder,
320 | Vars: map[string]interface{}{},
321 | MaxRetries: 5,
322 | TimeBetweenRetries: 5 * time.Minute,
323 | NoColor: true,
324 | Logger: logger.TestingT,
325 | }
326 |
327 | terraformOptions.Vars["region"] = region
328 | terraformOptions.Vars["resource_prefix"] = resourcePrefix
329 | terraformOptions.Vars["resource_suffix"] = resourceSuffix
330 | terraformOptions.Vars["extra_tags"] = map[string]interface{}{
331 | "ATestTag": "a_test_tag",
332 | "ResourcePrefix": resourcePrefix,
333 | "ResourceSuffix": resourceSuffix,
334 | }
335 |
336 | terraformOptions.Vars["airflow_image_name"] = "apache/airflow"
337 | terraformOptions.Vars["airflow_image_tag"] = "1.10.12"
338 | terraformOptions.Vars["airflow_log_region"] = region
339 | terraformOptions.Vars["airflow_log_retention"] = "7"
340 | terraformOptions.Vars["airflow_example_dag"] = true
341 | terraformOptions.Vars["airflow_variables"] = map[string]interface{}{
342 | "AIRFLOW__WEBSERVER__NAVBAR_COLOR": "#e27d60",
343 | }
344 | terraformOptions.Vars["airflow_executor"] = "Local"
345 |
346 | terraformOptions.Vars["ecs_cpu"] = 2048
347 | terraformOptions.Vars["ecs_memory"] = 4096
348 |
349 | terraformOptions.Vars["ip_allow_list"] = []string{
350 | "0.0.0.0/0",
351 | }
352 | terraformOptions.Vars["vpc_id"] = "vpc-0eafa6867cb3bdaa3"
353 | terraformOptions.Vars["public_subnet_ids"] = []string{
354 | "subnet-08da686d46e99872d",
355 | "subnet-0e5bb83f963f8df0f",
356 | }
357 | terraformOptions.Vars["private_subnet_ids"] = []string{
358 | "subnet-03c2a3885cfc8a740",
359 | "subnet-09c0ce0aff676904a",
360 | }
361 |
362 | // Get password and username from env vars
363 | terraformOptions.Vars["postgres_uri"] = ""
364 | terraformOptions.Vars["rds_username"] = "dataroots"
365 | terraformOptions.Vars["rds_password"] = "dataroots"
366 | terraformOptions.Vars["rds_instance_class"] = "db.t2.micro"
367 | terraformOptions.Vars["rds_availability_zone"] = fmt.Sprintf("%sa", region)
368 | terraformOptions.Vars["rds_skip_final_snapshot"] = true
369 | terraformOptions.Vars["rds_deletion_protection"] = false
370 |
371 | terraformOptions.Vars["use_https"] = true
372 | terraformOptions.Vars["route53_zone_name"] = "aws-sandbox.dataroots.io"
373 |
374 | return terraformOptions, nil
375 | }
376 |
377 | func TestApplyAndDestroyWithDefaultValues(t *testing.T) {
378 | fmt.Println("Starting test: TestApplyAndDestroyWithDefaultValues")
379 | // 'GLOBAL' test vars
380 | region := "eu-west-1"
381 | resourcePrefix := "dtr"
382 | resourceSuffix := strings.ToLower(random.UniqueId())
383 |
384 | // TODO: Check the task def rev number before and after apply and see if the rev num has increased by 1
385 |
386 | // t.Parallel()
387 |
388 | options, err := getDefaultTerraformOptions(t, region, resourcePrefix, resourceSuffix)
389 | assert.NoError(t, err)
390 |
391 | // terraform destroy => when test completes
392 | defer terraform.Destroy(t, options)
393 | fmt.Println("Running: terraform init && terraform apply")
394 | _, err = terraform.InitE(t, options)
395 | assert.NoError(t, err)
396 | _, err = terraform.PlanE(t, options)
397 | assert.NoError(t, err)
398 | _, err = terraform.ApplyE(t, options)
399 | assert.NoError(t, err)
400 |
401 | // if there are terraform errors, do nothing
402 | if err == nil {
403 | fmt.Println("Terraform apply returned no error, continuing")
404 | validateCluster(t, options, region, resourcePrefix, resourceSuffix)
405 | }
406 | }
407 |
408 | func TestApplyAndDestroyWithPlainHTTP(t *testing.T) {
409 | fmt.Println("Starting test: TestApplyAndDestroyWithPlainHTTP")
410 | // 'GLOBAL' test vars
411 | region := "eu-west-1"
412 | resourcePrefix := "dtr"
413 | resourceSuffix := strings.ToLower(random.UniqueId())
414 |
415 | // TODO: Check the task def rev number before and after apply and see if the rev num has increased by 1
416 |
417 | t.Parallel()
418 |
419 | options, err := getDefaultTerraformOptions(t, region, resourcePrefix, resourceSuffix)
420 | assert.NoError(t, err)
421 | options.Vars["use_https"] = false
422 | options.Vars["route53_zone_name"] = ""
423 |
424 | // terraform destroy => when test completes
425 | defer terraform.Destroy(t, options)
426 | fmt.Println("Running: terraform init && terraform apply")
427 | _, err = terraform.InitE(t, options)
428 | assert.NoError(t, err)
429 | _, err = terraform.PlanE(t, options)
430 | assert.NoError(t, err)
431 | _, err = terraform.ApplyE(t, options)
432 | assert.NoError(t, err)
433 |
434 | // if there are terraform errors, do nothing
435 | if err == nil {
436 | fmt.Println("Terraform apply returned no error, continuing")
437 | validateCluster(t, options, region, resourcePrefix, resourceSuffix)
438 | }
439 | }
440 |
441 | func TestApplyAndDestroyWithPlainHTTPAndSequentialExecutor(t *testing.T) {
442 | fmt.Println("Starting test: TestApplyAndDestroyWithPlainHTTPAndSequentialExecutor")
443 | // 'GLOBAL' test vars
444 | region := "eu-west-1"
445 | resourcePrefix := "dtr"
446 | resourceSuffix := strings.ToLower(random.UniqueId())
447 |
448 | // TODO: Check the task def rev number before and after apply and see if the rev num has increased by 1
449 |
450 | t.Parallel()
451 |
452 | options, err := getDefaultTerraformOptions(t, region, resourcePrefix, resourceSuffix)
453 | assert.NoError(t, err)
454 | options.Vars["airflow_executor"] = "Sequential"
455 |
456 | options.Vars["use_https"] = false
457 | options.Vars["route53_zone_name"] = ""
458 |
459 | // terraform destroy => when test completes
460 | defer terraform.Destroy(t, options)
461 | fmt.Println("Running: terraform init && terraform apply")
462 | _, err = terraform.InitE(t, options)
463 | assert.NoError(t, err)
464 | _, err = terraform.PlanE(t, options)
465 | assert.NoError(t, err)
466 | _, err = terraform.ApplyE(t, options)
467 | assert.NoError(t, err)
468 |
469 | // if there are terraform errors, do nothing
470 | if err == nil {
471 | fmt.Println("Terraform apply returned no error, continuing")
472 | validateCluster(t, options, region, resourcePrefix, resourceSuffix)
473 | }
474 | }
475 |
476 | func TestApplyAndDestroyWithPlainHTTPAndSequentialExecutorOnlyPublicSubnet(t *testing.T) {
477 | fmt.Println("Starting test: TestApplyAndDestroyWithPlainHTTPAndSequentialExecutorOnlyPublicSubnet")
478 | // 'GLOBAL' test vars
479 | region := "eu-west-1"
480 | resourcePrefix := "dtr"
481 | resourceSuffix := strings.ToLower(random.UniqueId())
482 |
483 | // TODO: Check the task def rev number before and after apply and see if the rev num has increased by 1
484 |
485 | t.Parallel()
486 |
487 | options, err := getDefaultTerraformOptions(t, region, resourcePrefix, resourceSuffix)
488 | assert.NoError(t, err)
489 | options.Vars["airflow_executor"] = "Sequential"
490 |
491 | options.Vars["private_subnet_ids"] = []string{}
492 |
493 | options.Vars["use_https"] = false
494 | options.Vars["route53_zone_name"] = ""
495 |
496 | // terraform destroy => when test completes
497 | defer terraform.Destroy(t, options)
498 | fmt.Println("Running: terraform init && terraform apply")
499 | _, err = terraform.InitE(t, options)
500 | assert.NoError(t, err)
501 | _, err = terraform.PlanE(t, options)
502 | assert.NoError(t, err)
503 | _, err = terraform.ApplyE(t, options)
504 | assert.NoError(t, err)
505 |
506 | // if there are terraform errors, do nothing
507 | if err == nil {
508 | fmt.Println("Terraform apply returned no error, continuing")
509 | validateCluster(t, options, region, resourcePrefix, resourceSuffix)
510 | }
511 | }
512 |
513 | func TestApplyAndDestroyWithPlainHTTPAndSequentialExecutorUsingRBAC(t *testing.T) {
514 | fmt.Println("Starting test: TestApplyAndDestroyWithPlainHTTPAndSequentialExecutorUsingRBAC")
515 | // 'GLOBAL' test vars
516 | region := "eu-west-1"
517 | resourcePrefix := "dtr"
518 | resourceSuffix := strings.ToLower(random.UniqueId())
519 |
520 | // TODO: Check the task def rev number before and after apply and see if the rev num has increased by 1
521 |
522 | t.Parallel()
523 |
524 | options, err := getDefaultTerraformOptions(t, region, resourcePrefix, resourceSuffix)
525 | assert.NoError(t, err)
526 | options.Vars["airflow_executor"] = "Sequential"
527 | options.Vars["airflow_authentication"] = "rbac"
528 |
529 | options.Vars["use_https"] = false
530 | options.Vars["route53_zone_name"] = ""
531 |
532 | // terraform destroy => when test completes
533 | defer terraform.Destroy(t, options)
534 | fmt.Println("Running: terraform init && terraform apply")
535 | _, err = terraform.InitE(t, options)
536 | assert.NoError(t, err)
537 | _, err = terraform.PlanE(t, options)
538 | assert.NoError(t, err)
539 | _, err = terraform.ApplyE(t, options)
540 | assert.NoError(t, err)
541 |
542 | // if there are terraform errors, do nothing
543 | if err == nil {
544 | fmt.Println("Terraform apply returned no error, continuing")
545 | validateCluster(t, options, region, resourcePrefix, resourceSuffix)
546 | }
547 | }
548 |
549 | // func TestApplyAndDestroyWithPlainHTTPAndPreexistingRDS(t *testing.T) {
550 | // fmt.Println("Starting test: TestApplyAndDestroyWithPlainHTTPAndPreexistingRDS")
551 | // // 'GLOBAL' test vars
552 | // region := "eu-west-1"
553 | // resourcePrefix := "dtr"
554 | // resourceSuffix := strings.ToLower(random.UniqueId())
555 |
556 | // // TODO: Check the task def rev number before and after apply and see if the rev num has increased by 1
557 |
558 | // // t.Parallel()
559 |
560 | // preExistingOptions, err := getPreexistingTerraformOptions(t, region, resourcePrefix, resourceSuffix)
561 | // assert.NoError(t, err)
562 |
563 | // // terraform destroy => when test completes
564 | // defer terraform.Destroy(t, preExistingOptions)
565 | // fmt.Println("Running: terraform init && terraform apply")
566 | // _, err = terraform.InitE(t, preExistingOptions)
567 | // assert.NoError(t, err)
568 | // _, err = terraform.PlanE(t, preExistingOptions)
569 | // assert.NoError(t, err)
570 | // _, err = terraform.ApplyE(t, preExistingOptions)
571 | // assert.NoError(t, err)
572 |
573 | // options, err := getDefaultTerraformOptions(t, region, resourcePrefix, resourceSuffix)
574 | // assert.NoError(t, err)
575 | // options.Vars["postgres_uri"] = terraform.Output(t, preExistingOptions, "postgres_uri")
576 | // options.Vars["certificate_arn"] = ""
577 | // options.Vars["use_https"] = false
578 |
579 | // // terraform destroy => when test completes
580 | // defer terraform.Destroy(t, options)
581 | // fmt.Println("Running: terraform init && terraform apply")
582 | // _, err = terraform.InitE(t, options)
583 | // assert.NoError(t, err)
584 | // _, err = terraform.PlanE(t, options)
585 | // assert.NoError(t, err)
586 | // _, err = terraform.ApplyE(t, options)
587 | // assert.NoError(t, err)
588 | // // if there are terraform errors, do nothing
589 | // if err == nil {
590 | // fmt.Println("Terraform apply returned no error, continuing")
591 | // validateCluster(t, options, region, resourcePrefix, resourceSuffix)
592 | // }
593 | // }
594 |
--------------------------------------------------------------------------------
/variables.tf:
--------------------------------------------------------------------------------
1 | variable "region" {
2 | type = string
3 | description = "The region to deploy your solution to"
4 | default = "eu-west-1"
5 | }
6 |
7 | variable "resource_prefix" {
8 | type = string
9 | description = "A prefix for the create resources, example your company name (be aware of the resource name length)"
10 | }
11 |
12 | variable "resource_suffix" {
13 | type = string
14 | description = "A suffix for the created resources, example the environment for airflow to run in (be aware of the resource name length)"
15 | }
16 |
17 | variable "extra_tags" {
18 | description = "Extra tags that you would like to add to all created resources"
19 | type = map(string)
20 | default = {}
21 | }
22 |
23 | // Airflow variables
24 | variable "airflow_image_name" {
25 | type = string
26 | description = "The name of the airflow image"
27 | default = "apache/airflow"
28 | }
29 |
30 | variable "airflow_image_tag" {
31 | type = string
32 | description = "The tag of the airflow image"
33 | default = "2.0.1"
34 | }
35 |
36 | variable "airflow_executor" {
37 | type = string
38 | description = "The executor mode that airflow will use. Only allowed values are [\"Local\", \"Sequential\"]. \"Local\": Run DAGs in parallel (will created a RDS); \"Sequential\": You can not run DAGs in parallel (will NOT created a RDS);"
39 | default = "Local"
40 |
41 | validation {
42 | condition = contains(["Local", "Sequential"], var.airflow_executor)
43 | error_message = "The only values that are allowed for \"airflow_executor\" are [\"Local\", \"Sequential\"]."
44 | }
45 | }
46 |
47 | variable "airflow_authentication" {
48 | type = string
49 | description = "Authentication backend to be used, supported backends [\"\", \"rbac\"]. When \"rbac\" is selected an admin role is create if there are no other users in the db, from here you can create all the other users. Make sure to change the admin password directly upon first login! (if you don't change the rbac_admin options the default login is => username: admin, password: admin)"
50 | default = ""
51 |
52 | validation {
53 | condition = contains(["", "rbac"], var.airflow_authentication)
54 | error_message = "The only values that are allowed for \"airflow_executor\" are [\"\", \"rbac\"]."
55 | }
56 | }
57 |
58 | variable "airflow_py_requirements_path" {
59 | type = string
60 | description = "The relative path to a python requirements.txt file to install extra packages in the container that you can use in your DAGs."
61 | default = ""
62 | }
63 |
64 | variable "airflow_variables" {
65 | type = map(string)
66 | description = "The variables passed to airflow as an environment variable (see airflow docs for more info https://airflow.apache.org/docs/). You can not specify \"AIRFLOW__CORE__SQL_ALCHEMY_CONN\" and \"AIRFLOW__CORE__EXECUTOR\" (managed by this module)"
67 | default = {}
68 | }
69 |
70 | variable "airflow_container_home" {
71 | type = string
72 | description = "Working dir for airflow (only change if you are using a different image)"
73 | default = "/opt/airflow"
74 | }
75 |
76 | variable "airflow_log_region" {
77 | type = string
78 | description = "The region you want your airflow logs in, defaults to the region variable"
79 | default = ""
80 | }
81 |
82 | variable "airflow_log_retention" {
83 | type = string
84 | description = "The number of days you want to keep the log of airflow container"
85 | default = "7"
86 | }
87 |
88 | variable "airflow_example_dag" {
89 | type = bool
90 | description = "Add an example dag on startup (mostly for sanity check)"
91 | default = true
92 | }
93 |
94 | // RBAC
95 | variable "rbac_admin_username" {
96 | type = string
97 | description = "RBAC Username (only when airflow_authentication = 'rbac')"
98 | default = "admin"
99 | }
100 |
101 | variable "rbac_admin_password" {
102 | type = string
103 | description = "RBAC Password (only when airflow_authentication = 'rbac')"
104 | default = "admin"
105 | }
106 |
107 | variable "rbac_admin_email" {
108 | type = string
109 | description = "RBAC Email (only when airflow_authentication = 'rbac')"
110 | default = "admin@admin.com"
111 | }
112 |
113 | variable "rbac_admin_firstname" {
114 | type = string
115 | description = "RBAC Firstname (only when airflow_authentication = 'rbac')"
116 | default = "admin"
117 | }
118 |
119 | variable "rbac_admin_lastname" {
120 | type = string
121 | description = "RBAC Lastname (only when airflow_authentication = 'rbac')"
122 | default = "airflow"
123 | }
124 |
125 | // ECS variables
126 | variable "ecs_cpu" {
127 | type = number
128 | description = "The allocated cpu for your airflow instance"
129 | default = 1024
130 | }
131 |
132 | variable "ecs_memory" {
133 | type = number
134 | description = "The allocated memory for your airflow instance"
135 | default = 2048
136 | }
137 |
138 | // Networking variables
139 | variable "ip_allow_list" {
140 | type = list(string)
141 | description = "A list of ip ranges that are allowed to access the airflow webserver, default: full access"
142 | default = ["0.0.0.0/0"]
143 | }
144 |
145 | variable "vpc_id" {
146 | type = string
147 | description = "The id of the vpc where you will run ECS/RDS"
148 |
149 | validation {
150 | condition = can(regex("^vpc-", var.vpc_id))
151 | error_message = "The vpc_id value must be a valid VPC id, starting with \"vpc-\"."
152 | }
153 | }
154 |
155 | variable "public_subnet_ids" {
156 | type = list(string)
157 | description = "A list of subnet ids of where the ALB will reside, if the \"private_subnet_ids\" variable is not provided ECS and RDS will also reside in these subnets"
158 |
159 | validation {
160 | condition = length(var.public_subnet_ids) >= 2
161 | error_message = "The size of the list \"public_subnet_ids\" must be at least 2."
162 | }
163 | }
164 |
165 | variable "private_subnet_ids" {
166 | type = list(string)
167 | description = "A list of subnet ids of where the ECS and RDS reside, this will only work if you have a NAT Gateway in your VPC"
168 | default = []
169 |
170 | validation {
171 | condition = length(var.private_subnet_ids) >= 2 || length(var.private_subnet_ids) == 0
172 | error_message = "The size of the list \"private_subnet_ids\" must be at least 2 or empty."
173 | }
174 | }
175 |
176 | // ACM + Route53
177 | variable "use_https" {
178 | type = bool
179 | description = "Expose traffic using HTTPS or not"
180 | default = false
181 | }
182 |
183 | variable "dns_name" {
184 | type = string
185 | description = "The DNS name that will be used to expose Airflow. Optional if not serving over HTTPS. Will be autogenerated if not provided"
186 | default = ""
187 | }
188 |
189 | variable "certificate_arn" {
190 | type = string
191 | description = "The ARN of the certificate that will be used"
192 | default = ""
193 | }
194 |
195 | variable "route53_zone_name" {
196 | type = string
197 | description = "The name of a Route53 zone that will be used for the certificate validation."
198 | default = ""
199 | }
200 |
201 |
202 | // Database variables
203 | variable "postgres_uri" {
204 | type = string
205 | description = "The postgres uri of your postgres db, if none provided a postgres db in rds is made. Format \":@:/\""
206 | default = ""
207 | }
208 |
209 | variable "rds_allocated_storage" {
210 | type = number
211 | description = "The allocated storage for the rds db in gibibytes"
212 | default = 20
213 | }
214 |
215 | variable "rds_storage_type" {
216 | type = string
217 | description = <