diff --git a/.github/workflows/easy-ecr.yaml b/.github/workflows/easy-ecr.yaml new file mode 100644 index 0000000..6da2e5e --- /dev/null +++ b/.github/workflows/easy-ecr.yaml @@ -0,0 +1,47 @@ +--- +name: easy-ecr - verify and build + +on: + push: + branches: + - main + paths: + - 'easy-ecr/**' + pull_request: + branches: + - main + paths: + - 'easy-ecr/**' + +jobs: + test-and-verify: + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: '1.23' + - name: Setup Terraform + uses: hashicorp/setup-terraform@v2 + with: + terraform_version: 1.14.0 + - name: Setup tflint + uses: terraform-linters/setup-tflint@v6 + with: + tflint_version: 'v0.60.0' + - name: Install Checkov + run: | + pip install checkov + - name: Run verification + run: | + make codeartifact-repo-verify + release: + needs: test-and-verify + uses: ./.github/workflows/common-release.yaml + with: + tag-prefix: 'easy-ecr-' diff --git a/Makefile b/Makefile index e7ecefd..5e95414 100644 --- a/Makefile +++ b/Makefile @@ -9,4 +9,10 @@ codeartifact-repo-format: init cd ./codeartifact-repo && ../scripts/format.sh codeartifact-repo-verify: init - cd ./codeartifact-repo && ../scripts/verify.sh \ No newline at end of file + cd ./codeartifact-repo && ../scripts/verify.sh + +easy-ecr-format: init + cd ./easy-ecr && ../scripts/format.sh + +easy-ecr-verify: init + cd ./easy-ecr && ../scripts/verify.sh \ No newline at end of file diff --git a/README.md b/README.md index 5bf3872..aac9b5d 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,5 @@ Included modules: * [tf-bootstrap-aws](./tf-bootstrap-aws/README.md) - Cloudformation template to easily bootstrap Terraform S3 backend * [codeartifact-repo](./codeartifact-repo/README.md) - Terraform module to setup and configure AWS CodeArtifact domain and repository +* [easy-ecr](./easy-ecr/README.md) - Terraform module to setup and configure AWS ECR repository + diff --git a/easy-ecr/README.md b/easy-ecr/README.md new file mode 100644 index 0000000..7ed30b0 --- /dev/null +++ b/easy-ecr/README.md @@ -0,0 +1,95 @@ + +# Easy ECR + +This Terraform module provides production-ready ECR repository for storing container images. + +## Contents +* [Requirements](#requirements) +* [Providers](#providers) +* [Resources](#resources) +* [Inputs](#inputs) +* [Outputs](#outputs) +* [Examples](#examples) + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.14.0 | +| [aws](#requirement\_aws) | >= 6.21.0 | + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | 6.25.0 | + +## Resources +| Name | Type | +|------|------| +| [aws_ecr_account_setting.account_scan_config](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecr_account_setting) | resource | +|**Description:** || +| [aws_ecr_lifecycle_policy.repo_lifecycle_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecr_lifecycle_policy) | resource | +|**Description:** || +| [aws_ecr_pull_through_cache_rule.custom_pullthrough_cache_rule](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecr_pull_through_cache_rule) | resource | +|**Description:** || +| [aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecr_pull_through_cache_rule) | resource | +|**Description:** || +| [aws_ecr_registry_policy.registry_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecr_registry_policy) | resource | +|**Description:** || +| [aws_ecr_repository.ecr_private_repo](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecr_repository) | resource | +|**Description:** || +| [aws_ecr_repository_policy.repo_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecr_repository_policy) | resource | +|**Description:** || +| [aws_ecrpublic_repository.ecr_public_repo](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecrpublic_repository) | resource | +|**Description:** || +| [aws_ecrpublic_repository_policy.public_repo_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecrpublic_repository_policy) | resource | +|**Description:** || +| [aws_kms_key.domain_encryption_key](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/kms_key) | resource | +|**Description:** || + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [aws\_public\_pullthrough\_cache\_rule](#input\_aws\_public\_pullthrough\_cache\_rule) | Pullthrough cache rule for AWS public registry. Override default values to customize |
object({
enabled = optional(bool, false)
ecr_repository_prefix = optional(string, "ROOT")
upstream_repository_prefix = optional(string, "ROOT")
})
| `{}` | no | +| [default\_account\_scan\_config](#input\_default\_account\_scan\_config) | Default ECR basic scan type configuration. |
object({
name = string
value = string
})
|
{
"name": "BASIC_SCAN_TYPE_VERSION",
"value": "AWS_NATIVE"
}
| no | +| [docker\_hub\_pullthrough\_cache\_rule](#input\_docker\_hub\_pullthrough\_cache\_rule) | Pullthrough cache rule for Docker Hub registry. Override default values to customize |
object({
enabled = optional(bool, false)
ecr_repository_prefix = optional(string, "ROOT")
upstream_repository_prefix = optional(string, "ROOT")
credential_arn = optional(string, null)
})
| `{}` | no | +| [domain\_encryption\_key\_policy\_path](#input\_domain\_encryption\_key\_policy\_path) | Local path to policy file to be applied to created KMS key. If not specified, no custom policy is applied. | `string` | `null` | no | +| [ecr\_region](#input\_ecr\_region) | Region in which repositories will be managed. If not specified, defaults to region configured for provider | `string` | `null` | no | +| [encryption\_key\_arn](#input\_encryption\_key\_arn) | ARN of KMS key used for repository encryption. If not specified, and use\_default\_ecnryption\_key is false, creates new KMS key | `string` | `null` | no | +| [force\_delete](#input\_force\_delete) | If 'true', deletes repository even if it has contents | `bool` | `false` | no | +| [github\_cr\_pullthrough\_cache\_rule](#input\_github\_cr\_pullthrough\_cache\_rule) | Pullthrough cache rule for Github Container registry. Override default values to customize |
object({
enabled = optional(bool, false)
ecr_repository_prefix = optional(string, "ROOT")
upstream_repository_prefix = optional(string, "ROOT")
credential_arn = optional(string, null)
})
| `{}` | no | +| [gitlab\_cr\_pullthrough\_cache\_rule](#input\_gitlab\_cr\_pullthrough\_cache\_rule) | Pullthrough cache rule for Gitlab Container registry. Override default values to customize |
object({
enabled = optional(bool, false)
ecr_repository_prefix = optional(string, "ROOT")
upstream_repository_prefix = optional(string, "ROOT")
credential_arn = optional(string, null)
})
| `{}` | no | +| [image\_lifecycle\_policy\_path](#input\_image\_lifecycle\_policy\_path) | Path to JSON file providing lifecycle policy for the repository | `string` | `null` | no | +| [image\_tag\_mutable](#input\_image\_tag\_mutable) | Whether image tags are mutable. Only applicable for private repositories. | `bool` | `true` | no | +| [k8s\_pullthrough\_cache\_rule](#input\_k8s\_pullthrough\_cache\_rule) | Pullthrough cache rule for Kubernetes public registry. Override default values to customize |
object({
enabled = optional(bool, false)
ecr_repository_prefix = optional(string, "ROOT")
upstream_repository_prefix = optional(string, "ROOT")
})
| `{}` | no | +| [mutability\_exclusion\_filters](#input\_mutability\_exclusion\_filters) | List of tag prefixes to exclude from image tag mutability. Setting this will result in IMMUTABLE\_WITH\_EXLUSIONSo MUTABLE\_WITH\_EXCLUSION behavior. Only applicable for private repositories. | `list(string)` | `[]` | no | +| [public\_catalog\_data](#input\_public\_catalog\_data) | Catalog data for public repositories (optional) |
object({
about = optional(string, ""),
description = optional(string, ""),
architectures = optional(list(string), []),
operating_systems = optional(list(string), []),
usage = optional(string, ""),
logo_image_path = optional(string, null)
})
| `{}` | no | +| [public\_repo\_policy\_path](#input\_public\_repo\_policy\_path) | Path to JSON policy file (optional). If specified, policy will be applied to public repository. | `string` | `null` | no | +| [pullthrough\_cache\_rules](#input\_pullthrough\_cache\_rules) | List of custom pullthrough cache rules to apply to repository |
list(object({
ecr_repository_prefix = optional(string, "ROOT")
upstream_repository_prefix = optional(string, "ROOT")
credential_arn = optional(string, null)
custom_role_arn = optional(string, null)
upstream_registry_url = string
}))
| `[]` | no | +| [quay\_pullthrough\_cache\_rule](#input\_quay\_pullthrough\_cache\_rule) | Pullthrough cache rule for Quay public registry. Override default values to customize |
object({
enabled = optional(bool, false)
ecr_repository_prefix = optional(string, "ROOT")
upstream_repository_prefix = optional(string, "ROOT")
})
| `{}` | no | +| [registry\_policy\_path](#input\_registry\_policy\_path) | Path to JSON policy file (optional). If specified, policy will be applied to registry | `string` | `null` | no | +| [repo\_policy\_path](#input\_repo\_policy\_path) | Path to JSON policy file (optional). If specified, policy will be applied to repository | `string` | `null` | no | +| [repository\_name](#input\_repository\_name) | Name of the repository | `string` | n/a | yes | +| [scan\_images\_on\_push](#input\_scan\_images\_on\_push) | Whether images are scanned after being pushed to the repository (true) or not scanned (false). Default is true. | `bool` | `true` | no | +| [tags](#input\_tags) | Tags to be applied to resources | `map(string)` | `{}` | no | +| [use\_default\_ecnryption\_key](#input\_use\_default\_ecnryption\_key) | Whether to use default ECR encryption key (defaults to true) | `bool` | `true` | no | +| [use\_default\_image\_lifecycle\_policy](#input\_use\_default\_image\_lifecycle\_policy) | Whether to use default image lifecycle or not. Defaults to true. | `bool` | `true` | no | +| [visibility](#input\_visibility) | Visibility of the repository. Allowed values are 'PRIVATE' (default) and 'PUBLIC'. | `string` | `"PRIVATE"` | no | + +## Outputs + +No outputs. + +## Examples + +Examples configuration for using the module: + +```hcl + +module "easy_ecr" { + source = ""https://github.com/bitshifted/cloud-tools//easy-ecr?ref=easy-ecr-" +} +``` + \ No newline at end of file diff --git a/easy-ecr/cache.tf b/easy-ecr/cache.tf new file mode 100644 index 0000000..9dea563 --- /dev/null +++ b/easy-ecr/cache.tf @@ -0,0 +1,48 @@ +# Copyright 2025 Bitshift +# SPDX-License-Identifier: MPL-2.0 + + + +locals { + default_cache_rules = { + aws_public = merge(var.aws_public_pullthrough_cache_rule, { + upstream_registry_url = "public.ecr.aws" + }) + k8s_public = merge(var.k8s_pullthrough_cache_rule, { + upstream_registry_url = "registry.k8s.io" + }) + quay = merge(var.quay_pullthrough_cache_rule, { + upstream_registry_url = "quay.io" + }) + docker_hub = merge(var.docker_hub_pullthrough_cache_rule, { + upstream_registry_url = "registry-1.docker.io" + }) + github = merge(var.github_cr_pullthrough_cache_rule, { + upstream_registry_url = "ghcr.io" + }) + gitlab = merge(var.gitlab_cr_pullthrough_cache_rule, { + upstream_registry_url = "registry.gitlab.com" + }) + } + +} + +resource "aws_ecr_pull_through_cache_rule" "default_pullthrough_cache_rule" { + for_each = { for k, v in local.default_cache_rules : k => v if v.enabled == true } + region = var.ecr_region != null ? var.ecr_region : data.aws_region.current_region.region + ecr_repository_prefix = each.value.ecr_repository_prefix + credential_arn = can(each.value.credential_arn) ? each.value.credential_arn : null + custom_role_arn = can(each.value.custom_role_arn) ? each.value.custom_role_arn : null + upstream_registry_url = each.value.upstream_registry_url + upstream_repository_prefix = each.value.upstream_repository_prefix +} + +resource "aws_ecr_pull_through_cache_rule" "custom_pullthrough_cache_rule" { + for_each = { for k, v in var.pullthrough_cache_rules : k => v } + region = var.ecr_region != null ? var.ecr_region : data.aws_region.current_region.region + ecr_repository_prefix = each.value.ecr_repository_prefix + credential_arn = can(each.value.credential_arn) ? each.value.credential_arn : null + custom_role_arn = can(each.value.custom_role_arn) ? each.value.custom_role_arn : null + upstream_registry_url = each.value.upstream_registry_url + upstream_repository_prefix = each.value.upstream_repository_prefix +} \ No newline at end of file diff --git a/easy-ecr/default-lifecycle-policy.json b/easy-ecr/default-lifecycle-policy.json new file mode 100644 index 0000000..78e2b28 --- /dev/null +++ b/easy-ecr/default-lifecycle-policy.json @@ -0,0 +1,30 @@ +{ + "rules": [ + { + "rulePriority": 10, + "description": "Untagged images policy", + "selection": { + "tagStatus": "untagged", + "countType": "sinceImagePushed", + "countUnit": "days", + "countNumber": 30 + }, + "action": { + "type": "expire" + } + }, + { + "rulePriority": 20, + "description": "Any image policy", + "selection": { + "tagStatus": "tagged", + "tagPatternList": ["*"], + "countType": "imageCountMoreThan", + "countNumber": 10 + }, + "action": { + "type": "expire" + } + } + ] +} diff --git a/easy-ecr/docs/examples.md b/easy-ecr/docs/examples.md new file mode 100644 index 0000000..ee045e0 --- /dev/null +++ b/easy-ecr/docs/examples.md @@ -0,0 +1,10 @@ +## Examples + +Examples configuration for using the module: + +```hcl + +module "easy_ecr" { + source = ""https://github.com/bitshifted/cloud-tools//easy-ecr?ref=easy-ecr-" +} +``` \ No newline at end of file diff --git a/easy-ecr/docs/intro.md b/easy-ecr/docs/intro.md new file mode 100644 index 0000000..436afdf --- /dev/null +++ b/easy-ecr/docs/intro.md @@ -0,0 +1,3 @@ +# Easy ECR + +This Terraform module provides production-ready ECR repository for storing container images. \ No newline at end of file diff --git a/easy-ecr/main.tf b/easy-ecr/main.tf new file mode 100644 index 0000000..f7c1494 --- /dev/null +++ b/easy-ecr/main.tf @@ -0,0 +1,65 @@ +# Copyright 2025 Bitshift +# SPDX-License-Identifier: MPL-2.0 + + + +locals { + resolved_region = var.ecr_region != null ? var.ecr_region : data.aws_region.current_region.region + should_create_kms_key = (!var.use_default_ecnryption_key && var.encryption_key_arn == null) ? true : false + image_tag_mutability = var.image_tag_mutable ? (length(var.mutability_exclusion_filters) > 0 ? "MUTABLE_WITH_EXCLUSION" : "MUTABLE") : (length(var.mutability_exclusion_filters) > 0 ? "IMMUTABLE_WITH_EXCLUSION" : "IMUTABLE") +} + +data "aws_region" "current_region" {} + +resource "aws_ecr_repository" "ecr_private_repo" { + count = var.visibility == "PRIVATE" ? 1 : 0 + name = var.repository_name + region = local.resolved_region + force_delete = var.force_delete + image_tag_mutability = local.image_tag_mutability + + #checkov:skip=CKV_AWS_136: Using AWS-managed AES256 is acceptable if user enables it + encryption_configuration { + encryption_type = var.use_default_ecnryption_key ? "AES256" : "KMS" + kms_key = !var.use_default_ecnryption_key ? var.encryption_key_arn != null ? var.encryption_key_arn : aws_kms_key.domain_encryption_key[0].arn : null + } + + dynamic "image_tag_mutability_exclusion_filter" { + for_each = var.mutability_exclusion_filters + content { + filter = image_tag_mutability_exclusion_filter.value + filter_type = "WILDCARD" + } + } + + image_scanning_configuration { + scan_on_push = var.scan_images_on_push + } + + tags = var.tags +} + +resource "aws_ecrpublic_repository" "ecr_public_repo" { + count = var.visibility == "PUBLIC" ? 1 : 0 + repository_name = var.repository_name + region = local.resolved_region + + catalog_data { + about_text = var.public_catalog_data.about + architectures = var.public_catalog_data.architectures + description = var.public_catalog_data.description + logo_image_blob = var.public_catalog_data.logo_image_path != null ? filebase64(var.public_catalog_data.logo_image_path) : null + operating_systems = var.public_catalog_data.operating_systems + usage_text = var.public_catalog_data.usage + } + + tags = var.tags +} + +resource "aws_kms_key" "domain_encryption_key" { + count = local.should_create_kms_key ? 1 : 0 + description = "KMS key for ECR repository domain ${var.repository_name}" + enable_key_rotation = true + policy = var.domain_encryption_key_policy_path != null ? file(var.domain_encryption_key_policy_path) : null + tags = var.tags +} \ No newline at end of file diff --git a/easy-ecr/policies.tf b/easy-ecr/policies.tf new file mode 100644 index 0000000..f14aa4b --- /dev/null +++ b/easy-ecr/policies.tf @@ -0,0 +1,34 @@ +# Copyright 2025 Bitshift +# SPDX-License-Identifier: MPL-2.0 + +locals { + use_any_lifecycle_policy = var.visibility == "PRIVATE" && (var.use_default_image_lifecycle_policy || var.image_lifecycle_policy_path != null) + apply_default_lifecycle_policy = var.use_default_image_lifecycle_policy && var.image_lifecycle_policy_path == null +} + +resource "aws_ecr_registry_policy" "registry_policy" { + count = var.registry_policy_path != null ? 1 : 0 + region = var.ecr_region != null ? var.ecr_region : data.aws_region.current_region.region + policy = file(var.registry_policy_path) +} + +resource "aws_ecr_repository_policy" "repo_policy" { + count = var.repo_policy_path != null ? 1 : 0 + region = var.ecr_region != null ? var.ecr_region : data.aws_region.current_region.region + repository = aws_ecr_repository.ecr_private_repo[0].name + policy = file(var.repo_policy_path) +} + +resource "aws_ecr_lifecycle_policy" "repo_lifecycle_policy" { + count = local.use_any_lifecycle_policy ? 1 : 0 + region = var.ecr_region != null ? var.ecr_region : data.aws_region.current_region.region + repository = aws_ecr_repository.ecr_private_repo[0].name + policy = local.apply_default_lifecycle_policy ? file("${path.module}/default-lifecycle-policy.json") : file(var.image_lifecycle_policy_path) +} + +resource "aws_ecrpublic_repository_policy" "public_repo_policy" { + count = var.public_repo_policy_path != null ? 1 : 0 + region = var.ecr_region != null ? var.ecr_region : data.aws_region.current_region.region + repository_name = aws_ecrpublic_repository.ecr_public_repo[0].id + policy = file(var.public_repo_policy_path) +} \ No newline at end of file diff --git a/easy-ecr/providers.tf b/easy-ecr/providers.tf new file mode 100644 index 0000000..cd6a56b --- /dev/null +++ b/easy-ecr/providers.tf @@ -0,0 +1,12 @@ +# Copyright 2025 Bitshift +# SPDX-License-Identifier: MPL-2.0 + +terraform { + required_version = ">= 1.14.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.21.0" + } + } +} \ No newline at end of file diff --git a/easy-ecr/scan_config.tf b/easy-ecr/scan_config.tf new file mode 100644 index 0000000..63c3270 --- /dev/null +++ b/easy-ecr/scan_config.tf @@ -0,0 +1,8 @@ +# Copyright 2025 Bitshift +# SPDX-License-Identifier: MPL-2.0 + + +resource "aws_ecr_account_setting" "account_scan_config" { + name = var.default_account_scan_config.name + value = var.default_account_scan_config.value +} \ No newline at end of file diff --git a/easy-ecr/tests/cache_rules.tftest.hcl b/easy-ecr/tests/cache_rules.tftest.hcl new file mode 100644 index 0000000..feb2383 --- /dev/null +++ b/easy-ecr/tests/cache_rules.tftest.hcl @@ -0,0 +1,257 @@ +# Copyright 2025 Bitshift +# SPDX-License-Identifier: MPL-2.0 + +mock_provider "aws" { + +} + +run "no_rules_should_be_created_by_default" { + command = plan + + variables { + repository_name = "test-repo" + } + + assert { + condition = length(aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule) == 0 + error_message = "Pullthrough cache rule was created, but should not be" + } +} + +run "rule_override_creates_aws_public_rule" { + command = plan + + variables { + repository_name = "test-repo" + aws_public_pullthrough_cache_rule = { + enabled = true + upstream_repository_prefix = "my-prefix" + } + } + + assert { + condition = length(aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule) == 1 + error_message = "Pullthrough cache rule was not created, but should be" + } + + assert { + condition = aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule["aws_public"].ecr_repository_prefix == "ROOT" + error_message = "Invalid ECR repository prefix" + } + + assert { + condition = aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule["aws_public"].upstream_repository_prefix == "my-prefix" + error_message = "Invalid upstream repository prefix" + } +} + +run "rule_override_creates_k8s_public_rule" { + command = plan + + variables { + repository_name = "test-repo" + k8s_pullthrough_cache_rule = { + enabled = true + upstream_repository_prefix = "my-prefix" + } + } + + assert { + condition = length(aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule) == 1 + error_message = "Pullthrough cache rule was not created, but should be" + } + + assert { + condition = aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule["k8s_public"].ecr_repository_prefix == "ROOT" + error_message = "Invalid ECR repository prefix" + } + + assert { + condition = aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule["k8s_public"].upstream_repository_prefix == "my-prefix" + error_message = "Invalid upstream repository prefix" + } +} + +run "rule_override_creates_quay_rule" { + command = plan + + variables { + repository_name = "test-repo" + quay_pullthrough_cache_rule = { + enabled = true + upstream_repository_prefix = "my-prefix" + } + } + + assert { + condition = length(aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule) == 1 + error_message = "Pullthrough cache rule was not created, but should be" + } + + assert { + condition = aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule["quay"].ecr_repository_prefix == "ROOT" + error_message = "Invalid ECR repository prefix" + } + + assert { + condition = aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule["quay"].upstream_repository_prefix == "my-prefix" + error_message = "Invalid upstream repository prefix" + } +} + +run "rule_override_creates_docker_hub_rule" { + command = plan + + variables { + repository_name = "test-repo" + docker_hub_pullthrough_cache_rule = { + enabled = true + upstream_repository_prefix = "my-prefix" + credential_arn = "arn:aws:iam::123456789012:role/my-role" + } + } + + assert { + condition = length(aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule) == 1 + error_message = "Pullthrough cache rule was not created, but should be" + } + + assert { + condition = aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule["docker_hub"].ecr_repository_prefix == "ROOT" + error_message = "Invalid ECR repository prefix" + } + + assert { + condition = aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule["docker_hub"].upstream_repository_prefix == "my-prefix" + error_message = "Invalid upstream repository prefix" + } + + assert { + condition = aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule["docker_hub"].credential_arn == "arn:aws:iam::123456789012:role/my-role" + error_message = "Invalid credential ARN" + } +} + +run "rule_override_creates_github_cr_rule" { + command = plan + + variables { + repository_name = "test-repo" + github_cr_pullthrough_cache_rule = { + enabled = true + upstream_repository_prefix = "my-prefix" + credential_arn = "arn:aws:iam::123456789012:role/my-role" + } + } + + assert { + condition = length(aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule) == 1 + error_message = "Pullthrough cache rule was not created, but should be" + } + + assert { + condition = aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule["github"].ecr_repository_prefix == "ROOT" + error_message = "Invalid ECR repository prefix" + } + + assert { + condition = aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule["github"].upstream_repository_prefix == "my-prefix" + error_message = "Invalid upstream repository prefix" + } + + assert { + condition = aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule["github"].credential_arn == "arn:aws:iam::123456789012:role/my-role" + error_message = "Invalid credential ARN" + } +} + +run "rule_override_creates_gitlab_cr_rule" { + command = plan + + variables { + repository_name = "test-repo" + gitlab_cr_pullthrough_cache_rule = { + enabled = true + upstream_repository_prefix = "my-prefix" + credential_arn = "arn:aws:iam::123456789012:role/my-role" + } + } + + assert { + condition = length(aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule) == 1 + error_message = "Pullthrough cache rule was not created, but should be" + } + + assert { + condition = aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule["gitlab"].ecr_repository_prefix == "ROOT" + error_message = "Invalid ECR repository prefix" + } + + assert { + condition = aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule["gitlab"].upstream_repository_prefix == "my-prefix" + error_message = "Invalid upstream repository prefix" + } + + assert { + condition = aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule["gitlab"].credential_arn == "arn:aws:iam::123456789012:role/my-role" + error_message = "Invalid credential ARN" + } +} + +run "custom_rule_should_be_created_when_specified" { + command = plan + + variables { + repository_name = "test-repo" + pullthrough_cache_rules = [ + { + upstream_repository_prefix = "my-prefix" + upstream_registry_url = "my-registry.example.com" + } + ] + } + + assert { + condition = length(aws_ecr_pull_through_cache_rule.custom_pullthrough_cache_rule) == 1 + error_message = "Custom rule not created, but should be" + } +} + +run "should_create_both_default_and_custom_rules" { + command = plan + + variables { + repository_name = "test-repo" + docker_hub_pullthrough_cache_rule = { + enabled = true + upstream_repository_prefix = "my-prefix" + credential_arn = "arn:aws:iam::123456789012:role/my-role" + } + github_cr_pullthrough_cache_rule = { + enabled = true + upstream_repository_prefix = "my-prefix" + credential_arn = "arn:aws:iam::123456789012:role/my-role" + } + pullthrough_cache_rules = [ + { + upstream_repository_prefix = "my-prefix" + upstream_registry_url = "my-registry.example.com" + } + ] + } + + assert { + condition = contains(keys(aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule),"docker_hub") + error_message = "Docker Hub rule not created, but should be" + } + + assert { + condition = contains(keys(aws_ecr_pull_through_cache_rule.default_pullthrough_cache_rule),"github") + error_message = "Github rule not created, but should be" + } + + assert { + condition = length(aws_ecr_pull_through_cache_rule.custom_pullthrough_cache_rule) == 1 + error_message = "Custom rule not created, but should be" + } +} \ No newline at end of file diff --git a/easy-ecr/tests/kms.tftest.hcl b/easy-ecr/tests/kms.tftest.hcl new file mode 100644 index 0000000..150d868 --- /dev/null +++ b/easy-ecr/tests/kms.tftest.hcl @@ -0,0 +1,49 @@ +# Copyright 2025 Bitshift +# SPDX-License-Identifier: MPL-2.0 + +mock_provider "aws" { + +} + +run "kms_key_should_be_created_when_specified" { + command = plan + + variables { + use_default_ecnryption_key = false + encryption_key_arn = null + repository_name = "test-repo" + } + + assert { + condition = length(aws_kms_key.domain_encryption_key) == 1 + error_message = "KMS key was not created when use_default_ecnryption_key is false and encryption_key_arn is null." + } +} + +run "specific_kms_key_should_be_used_when_specified" { + command = plan + + variables { + use_default_ecnryption_key = false + encryption_key_arn = "arn:aws:kms:us-west-2:123456789012:key/abcd-efgh-ijkl-mnop" + repository_name = "test-repo" + } + + assert { + condition = length(aws_kms_key.domain_encryption_key) == 0 + error_message = "KMS key was created despite encryption_key_arn being specified." + } +} + +run "default_kms_key_should_be_used_when_specified" { + command = plan + + variables { + repository_name = "test-repo" + } + + assert { + condition = length(aws_kms_key.domain_encryption_key) == 0 + error_message = "KMS key was created despite use_default_ecnryption_key being true." + } +} \ No newline at end of file diff --git a/easy-ecr/tests/policies.tftest.hcl b/easy-ecr/tests/policies.tftest.hcl new file mode 100644 index 0000000..27cb75f --- /dev/null +++ b/easy-ecr/tests/policies.tftest.hcl @@ -0,0 +1,117 @@ +# Copyright 2025 Bitshift +# SPDX-License-Identifier: MPL-2.0 + +mock_provider "aws" { + +} + +run "registry_policy_is_created_when_policy_file_path_provided" { + command = apply + + variables { + repository_name = "test-repo" + registry_policy_path = "tests/test-policy.json" + } + + assert { + condition = length(aws_ecr_registry_policy.registry_policy) == 1 + error_message = "The registry policy was not created." + } + + assert { + + condition = aws_ecr_registry_policy.registry_policy[0].registry_id != "" + error_message = "reigstry_id was not set" + } + +} + +run "registry_policy_not_created_when_policy_file_path_not_provided" { + command = plan + + variables { + repository_name = "test-repo" + } + + assert { + condition = length(aws_ecr_registry_policy.registry_policy) == 0 + error_message = "The registry policy was created." + } + +} + +run "custom_repostitory_policy_should_be_created_when_policy_file_path_provided" { + command = apply + + variables { + repository_name = "test-repo" + repo_policy_path = "tests/test-policy.json" + } + + assert { + condition = length(aws_ecr_repository_policy.repo_policy) == 1 + error_message = "The repository policy was not created." + } + + assert { + condition = aws_ecr_repository_policy.repo_policy[0].registry_id != "" + error_message = "reigstry_id was not set" + } +} + +run "custom_image_lifecycle_policy_should_be_created_when_policy_file_path_provided" { + command = apply + + variables { + repository_name = "test-repo" + image_lifecycle_policy_path = "tests/test-policy.json" + } + + assert { + condition = length(aws_ecr_lifecycle_policy.repo_lifecycle_policy) == 1 + error_message = "The lifecycle policy was not created." + } +} + +run "default_image_lifecycle_policy_should_be_created_when_policy_file_path_not_provided" { + command = plan + + variables { + repository_name = "test-repo" + } + + assert { + condition = length(aws_ecr_lifecycle_policy.repo_lifecycle_policy) == 1 + error_message = "The lifecycle policy was not created." + } +} + +run "no_image_lifecycle_policy_when_policy_file_path_not_provided_and_default_policy_is_false" { + command = plan + + variables { + repository_name = "test-repo" + use_default_image_lifecycle_policy = false + } + + assert { + condition = length(aws_ecr_lifecycle_policy.repo_lifecycle_policy) == 0 + error_message = "The lifecycle policy was created." + } +} + +run "public_repo_policy_should_be_created_when_policy_file_path_provided" { + command = apply + + variables { + repository_name = "test-repo" + visibility = "PUBLIC" + public_repo_policy_path = "tests/test-policy.json" + } + + assert { + condition = length(aws_ecrpublic_repository_policy.public_repo_policy) == 1 + error_message = "The repository policy was not created." + } + +} \ No newline at end of file diff --git a/easy-ecr/tests/private_repo.tftest.hcl b/easy-ecr/tests/private_repo.tftest.hcl new file mode 100644 index 0000000..4ffa604 --- /dev/null +++ b/easy-ecr/tests/private_repo.tftest.hcl @@ -0,0 +1,92 @@ +# Copyright 2025 Bitshift +# SPDX-License-Identifier: MPL-2.0 + +mock_provider "aws" { + +} + +run "invalid_encryption_key_arn_should_produce_error" { + command = plan + + variables { + use_default_ecnryption_key = false + encryption_key_arn = "invalid-arn" + repository_name = "test-repo" + } + expect_failures = [ + var.encryption_key_arn + ] +} + +run "invalid_visibility_should_produce_error" { + command = plan + + variables { + visibility = "INVALID" + repository_name = "test-repo" + } + expect_failures = [ + var.visibility + ] +} + +run "private_repository_is_created_correctly" { + command = plan + + variables { + visibility = "PRIVATE" + repository_name = "private-repo" + scan_images_on_push = false + } + + assert { + condition = aws_ecr_repository.ecr_private_repo[0].name == "private-repo" + error_message = "The private ECR repository was not created with the correct name." + } + + assert { + condition = length(aws_ecrpublic_repository.ecr_public_repo) == 0 + error_message = "A public ECR repository was created despite visibility being set to PRIVATE." + } + + assert { + condition = !aws_ecr_repository.ecr_private_repo[0].image_scanning_configuration[0].scan_on_push + error_message = "Image scan on push is enabled" + } +} + +run "image_tag_mutable_setting" { + command = plan + + variables { + visibility = "PRIVATE" + repository_name = "mutable-repo" + image_tag_mutable = false + } + + assert { + condition = aws_ecr_repository.ecr_private_repo[0].image_tag_mutability == "IMMUTABLE" + error_message = "The image tag mutability setting is incorrect." + } +} + +run "image_tag_mutable_with_exclusion_filters" { + command = plan + + variables { + visibility = "PRIVATE" + repository_name = "mutable-exclusion-repo" + image_tag_mutable = true + mutability_exclusion_filters = ["latest*", "stable*"] + } + + assert { + condition = aws_ecr_repository.ecr_private_repo[0].image_tag_mutability == "MUTABLE_WITH_EXCLUSION" + error_message = "The image tag mutability with exclusion filters setting is incorrect." + } + + assert { + condition = length(aws_ecr_repository.ecr_private_repo[0].image_tag_mutability_exclusion_filter) == 2 + error_message = "Expected two image_tag_mutability_exclusion_filter blocks to be set." + } +} \ No newline at end of file diff --git a/easy-ecr/tests/public_repo.tftest.hcl b/easy-ecr/tests/public_repo.tftest.hcl new file mode 100644 index 0000000..99b1927 --- /dev/null +++ b/easy-ecr/tests/public_repo.tftest.hcl @@ -0,0 +1,72 @@ +# Copyright 2025 Bitshift +# SPDX-License-Identifier: MPL-2.0 + +mock_provider "aws" { + +} + +run "should_create_public_repository" { + command = plan + + variables { + visibility = "PUBLIC" + repository_name = "public-repo" + } + assert { + condition = length(aws_ecrpublic_repository.ecr_public_repo) == 1 + error_message = "The public ECR repository was not created with the correct name." + } +} + +run "should_create_public_repo_with_catalog_data" { + command = plan + + variables { + visibility = "PUBLIC" + repository_name = "public-repo" + public_catalog_data = { + about = "About text" + architectures = ["ARM 64", "x86-64"] + description = "descritpion text" + operating_systems = ["Linux"] + } + } + assert { + condition = aws_ecrpublic_repository.ecr_public_repo[0].catalog_data[0].about_text == "About text" + error_message = "Wrong about text" + } + + assert { + condition = aws_ecrpublic_repository.ecr_public_repo[0].catalog_data[0].description == "descritpion text" + error_message = "Wrong description text" + } +} + +run "should_fail_on_invalid_architectures_data" { + command = plan + + variables { + visibility = "PUBLIC" + repository_name = "public-repo" + public_catalog_data = { + architectures = ["INVALID"] + } + } + expect_failures = [ + var.public_catalog_data.architectures + ] +} + +run "should_fail_on_invalie_operating_system" { + command = plan + + variables { + visibility = "PUBLIC" + repository_name = "public-repo" + public_catalog_data = { + architectures = ["x86-64", "ARM 64"] + operating_systems = ["Mac Os"] + } + } + expect_failures = [ var.public_catalog_data.operating_systems ] +} \ No newline at end of file diff --git a/easy-ecr/tests/scan_config.tftest.hcl b/easy-ecr/tests/scan_config.tftest.hcl new file mode 100644 index 0000000..c2a942a --- /dev/null +++ b/easy-ecr/tests/scan_config.tftest.hcl @@ -0,0 +1,46 @@ +# Copyright 2025 Bitshift +# SPDX-License-Identifier: MPL-2.0 + +mock_provider "aws" { + +} + +run "default_scan_configuration_should_be_created" { + command = plan + + variables { + repository_name = "test-repo" + } + + assert { + condition = aws_ecr_account_setting.account_scan_config.name == "BASIC_SCAN_TYPE_VERSION" + error_message = "Scan configuration resource was not created with correct name" + } + + assert { + condition = aws_ecr_account_setting.account_scan_config.value == "AWS_NATIVE" + error_message = "Scan configuration resource was not created with correct value" + } +} + +run "default_scan_config_is_overriden_when_specified" { + command = apply + + variables { + repository_name = "test-repo" + default_account_scan_config = { + name = "BASIC_SCAN_TYPE_VERSION" + value = "CLAIR" + } + } + + assert { + condition = aws_ecr_account_setting.account_scan_config.name == "BASIC_SCAN_TYPE_VERSION" + error_message = "Scan configuration resource was not created with correct name" + } + + assert { + condition = aws_ecr_account_setting.account_scan_config.value == "CLAIR" + error_message = "Scan configuration resource was not created with correct value" + } +} diff --git a/easy-ecr/tests/test-policy.json b/easy-ecr/tests/test-policy.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/easy-ecr/tests/test-policy.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/easy-ecr/variables.tf b/easy-ecr/variables.tf new file mode 100644 index 0000000..18ab90e --- /dev/null +++ b/easy-ecr/variables.tf @@ -0,0 +1,220 @@ +# Copyright 2025 Bitshift +# SPDX-License-Identifier: MPL-2.0 + + +variable "repository_name" { + type = string + description = "Name of the repository" +} + +variable "ecr_region" { + type = string + default = null + description = "Region in which repositories will be managed. If not specified, defaults to region configured for provider" +} + +variable "visibility" { + type = string + description = "Visibility of the repository. Allowed values are 'PRIVATE' (default) and 'PUBLIC'." + default = "PRIVATE" + + validation { + condition = contains(["PRIVATE", "PUBLIC"], var.visibility) + error_message = "visibility must be either 'PRIVATE' or 'PUBLIC'." + } +} + +variable "force_delete" { + type = bool + description = "If 'true', deletes repository even if it has contents" + default = false +} + + +variable "use_default_ecnryption_key" { + type = bool + default = true + description = "Whether to use default ECR encryption key (defaults to true)" +} + +variable "encryption_key_arn" { + type = string + default = null + description = "ARN of KMS key used for repository encryption. If not specified, and use_default_ecnryption_key is false, creates new KMS key" + + validation { + # If a value is provided for encryption_key_arn, it must be a valid KMS key ARN. + condition = (var.use_default_ecnryption_key == false && var.encryption_key_arn != null) ? can(regex("^arn:aws:kms:[a-z0-9-]*:[0-9]*:key\\/[0-9a-z-]+$", var.encryption_key_arn)) : true + error_message = "If provided, encryption_key_arn must be a valid KMS key ARN." + } +} + +variable "domain_encryption_key_policy_path" { + type = string + default = null + description = " Local path to policy file to be applied to created KMS key. If not specified, no custom policy is applied." +} + +variable "image_tag_mutable" { + type = bool + default = true + description = "Whether image tags are mutable. Only applicable for private repositories." +} + +variable "mutability_exclusion_filters" { + type = list(string) + default = [] + description = "List of tag prefixes to exclude from image tag mutability. Setting this will result in IMMUTABLE_WITH_EXLUSIONSo MUTABLE_WITH_EXCLUSION behavior. Only applicable for private repositories." +} + +variable "scan_images_on_push" { + type = bool + default = true + description = "Whether images are scanned after being pushed to the repository (true) or not scanned (false). Default is true." +} + +variable "public_catalog_data" { + type = object({ + about = optional(string, ""), + description = optional(string, ""), + architectures = optional(list(string), []), + operating_systems = optional(list(string), []), + usage = optional(string, ""), + logo_image_path = optional(string, null) + }) + default = {} + description = "Catalog data for public repositories (optional)" + + validation { + condition = length(var.public_catalog_data.architectures) == 0 || length([for a in var.public_catalog_data.architectures : a if contains(["ARM", "x86", "ARM 64", "x86-64"], a)]) == length(var.public_catalog_data.architectures) + error_message = "Allowed values for architecture: 'ARM', 'ARM 64', 'x86', 'x86-64'" + } + + validation { + condition = length(var.public_catalog_data.operating_systems) == 0 || length([for a in var.public_catalog_data.operating_systems : a if contains(["Windows", "Linux"], a)]) == length(var.public_catalog_data.operating_systems) + error_message = "Allowed values for operating system: 'Windows', 'Linux'" + } +} + +variable "registry_policy_path" { + type = string + default = null + description = "Path to JSON policy file (optional). If specified, policy will be applied to registry" +} + +variable "repo_policy_path" { + type = string + default = null + description = "Path to JSON policy file (optional). If specified, policy will be applied to repository" +} + +variable "public_repo_policy_path" { + type = string + default = null + description = "Path to JSON policy file (optional). If specified, policy will be applied to public repository." +} + +variable "image_lifecycle_policy_path" { + type = string + default = null + description = "Path to JSON file providing lifecycle policy for the repository" +} + +variable "use_default_image_lifecycle_policy" { + type = bool + default = true + description = "Whether to use default image lifecycle or not. Defaults to true." +} + +variable "aws_public_pullthrough_cache_rule" { + type = object({ + enabled = optional(bool, false) + ecr_repository_prefix = optional(string, "ROOT") + upstream_repository_prefix = optional(string, "ROOT") + }) + default = {} + description = "Pullthrough cache rule for AWS public registry. Override default values to customize" +} + +variable "k8s_pullthrough_cache_rule" { + type = object({ + enabled = optional(bool, false) + ecr_repository_prefix = optional(string, "ROOT") + upstream_repository_prefix = optional(string, "ROOT") + }) + default = {} + description = "Pullthrough cache rule for Kubernetes public registry. Override default values to customize" +} + +variable "quay_pullthrough_cache_rule" { + type = object({ + enabled = optional(bool, false) + ecr_repository_prefix = optional(string, "ROOT") + upstream_repository_prefix = optional(string, "ROOT") + }) + default = {} + description = "Pullthrough cache rule for Quay public registry. Override default values to customize" +} + +variable "docker_hub_pullthrough_cache_rule" { + type = object({ + enabled = optional(bool, false) + ecr_repository_prefix = optional(string, "ROOT") + upstream_repository_prefix = optional(string, "ROOT") + credential_arn = optional(string, null) + }) + default = {} + description = "Pullthrough cache rule for Docker Hub registry. Override default values to customize" +} + +variable "github_cr_pullthrough_cache_rule" { + type = object({ + enabled = optional(bool, false) + ecr_repository_prefix = optional(string, "ROOT") + upstream_repository_prefix = optional(string, "ROOT") + credential_arn = optional(string, null) + }) + default = {} + description = "Pullthrough cache rule for Github Container registry. Override default values to customize" +} + +variable "gitlab_cr_pullthrough_cache_rule" { + type = object({ + enabled = optional(bool, false) + ecr_repository_prefix = optional(string, "ROOT") + upstream_repository_prefix = optional(string, "ROOT") + credential_arn = optional(string, null) + }) + default = {} + description = "Pullthrough cache rule for Gitlab Container registry. Override default values to customize" +} + +variable "pullthrough_cache_rules" { + type = list(object({ + ecr_repository_prefix = optional(string, "ROOT") + upstream_repository_prefix = optional(string, "ROOT") + credential_arn = optional(string, null) + custom_role_arn = optional(string, null) + upstream_registry_url = string + })) + default = [] + description = "List of custom pullthrough cache rules to apply to repository" +} + +variable "default_account_scan_config" { + type = object({ + name = string + value = string + }) + default = { + name = "BASIC_SCAN_TYPE_VERSION" + value = "AWS_NATIVE" + } + description = "Default ECR basic scan type configuration." +} + +variable "tags" { + type = map(string) + default = {} + description = "Tags to be applied to resources" +}