From 403ed7fd1dcc609210d8e28958f71c5d397d1760 Mon Sep 17 00:00:00 2001 From: Vladimir Djurovic Date: Mon, 8 Dec 2025 18:35:34 +0100 Subject: [PATCH 1/5] feat: initial implementation --- .github/workflows/easy-ecr.yaml | 47 +++++++++++ Makefile | 8 +- easy-ecr/README.md | 68 ++++++++++++++++ easy-ecr/docs/examples.md | 10 +++ easy-ecr/docs/intro.md | 3 + easy-ecr/main.tf | 65 ++++++++++++++++ easy-ecr/providers.tf | 12 +++ easy-ecr/tests/kms.tftest.hcl | 49 ++++++++++++ easy-ecr/tests/private_repo.tftest.hcl | 92 ++++++++++++++++++++++ easy-ecr/tests/public_repo.tftest.hcl | 72 +++++++++++++++++ easy-ecr/variables.tf | 103 +++++++++++++++++++++++++ 11 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/easy-ecr.yaml create mode 100644 easy-ecr/README.md create mode 100644 easy-ecr/docs/examples.md create mode 100644 easy-ecr/docs/intro.md create mode 100644 easy-ecr/main.tf create mode 100644 easy-ecr/providers.tf create mode 100644 easy-ecr/tests/kms.tftest.hcl create mode 100644 easy-ecr/tests/private_repo.tftest.hcl create mode 100644 easy-ecr/tests/public_repo.tftest.hcl create mode 100644 easy-ecr/variables.tf 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/easy-ecr/README.md b/easy-ecr/README.md new file mode 100644 index 0000000..214653a --- /dev/null +++ b/easy-ecr/README.md @@ -0,0 +1,68 @@ + +# 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_repository.ecr_private_repo](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecr_repository) | resource | +|**Description:** || +| [aws_ecrpublic_repository.ecr_public_repo](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecrpublic_repository) | 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 | +|------|-------------|------|---------|:--------:| +| [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 | +| [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 | +| [image\_tag\_mutable](#input\_image\_tag\_mutable) | Whether image tags are mutable. Only applicable for private repositories. | `bool` | `true` | 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 | +| [repo\_region](#input\_repo\_region) | Region in which repositories will be managed. If not specified, defaults to region configured for provider | `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 | +| [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/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..9e37ef1 --- /dev/null +++ b/easy-ecr/main.tf @@ -0,0 +1,65 @@ +# Copyright 2025 Bitshift +# SPDX-License-Identifier: MPL-2.0 + + + +locals { + resolved_region = var.repo_region != null ? var.repo_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") : "IMMUTABLE" +} + +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/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/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/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/variables.tf b/easy-ecr/variables.tf new file mode 100644 index 0000000..cd598fa --- /dev/null +++ b/easy-ecr/variables.tf @@ -0,0 +1,103 @@ +# Copyright 2025 Bitshift +# SPDX-License-Identifier: MPL-2.0 + + +variable "repository_name" { + type = string + description = "Name of the repository" +} + +variable "repo_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 "tags" { + type = map(string) + default = {} + description = "Tags to be applied to resources" +} From 79f919209f0035a67b71930ca5f7b22f6c94b54d Mon Sep 17 00:00:00 2001 From: Vladimir Djurovic Date: Tue, 9 Dec 2025 13:52:06 +0100 Subject: [PATCH 2/5] add policy confoiguration --- easy-ecr/README.md | 11 ++++- easy-ecr/main.tf | 2 +- easy-ecr/policies.tf | 24 ++++++++++ easy-ecr/tests/policies.tftest.hcl | 75 ++++++++++++++++++++++++++++++ easy-ecr/tests/test-policy.json | 1 + easy-ecr/variables.tf | 22 ++++++++- 6 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 easy-ecr/policies.tf create mode 100644 easy-ecr/tests/policies.tftest.hcl create mode 100644 easy-ecr/tests/test-policy.json diff --git a/easy-ecr/README.md b/easy-ecr/README.md index 214653a..4746a96 100644 --- a/easy-ecr/README.md +++ b/easy-ecr/README.md @@ -27,8 +27,14 @@ This Terraform module provides production-ready ECR repository for storing conta ## Resources | Name | Type | |------|------| +| [aws_ecr_lifecycle_policy.repo_lifecycle_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecr_lifecycle_policy) | 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_kms_key.domain_encryption_key](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/kms_key) | resource | @@ -39,12 +45,15 @@ This Terraform module provides production-ready ECR repository for storing conta | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| | [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 | | [image\_tag\_mutable](#input\_image\_tag\_mutable) | Whether image tags are mutable. Only applicable for private repositories. | `bool` | `true` | 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 | -| [repo\_region](#input\_repo\_region) | Region in which repositories will be managed. If not specified, defaults to region configured for provider | `string` | `null` | 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\_lifecycle\_policy\_path](#input\_repo\_lifecycle\_policy\_path) | Path to JSON file providing lifecycle policy for the repository | `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 | diff --git a/easy-ecr/main.tf b/easy-ecr/main.tf index 9e37ef1..d018417 100644 --- a/easy-ecr/main.tf +++ b/easy-ecr/main.tf @@ -4,7 +4,7 @@ locals { - resolved_region = var.repo_region != null ? var.repo_region : data.aws_region.current_region.region + 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") : "IMMUTABLE" } diff --git a/easy-ecr/policies.tf b/easy-ecr/policies.tf new file mode 100644 index 0000000..142fcdf --- /dev/null +++ b/easy-ecr/policies.tf @@ -0,0 +1,24 @@ +# Copyright 2025 Bitshift +# SPDX-License-Identifier: MPL-2.0 + + + +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 = var.repo_lifecycle_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_lifecycle_policy_path) +} \ 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..e5e071b --- /dev/null +++ b/easy-ecr/tests/policies.tftest.hcl @@ -0,0 +1,75 @@ +# 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 "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 "repo_lifecycle_policy_should_be_created_when_policy_file_path_provided" { + command = apply + + variables { + repository_name = "test-repo" + repo_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." + } + +} 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 index cd598fa..d51a7ed 100644 --- a/easy-ecr/variables.tf +++ b/easy-ecr/variables.tf @@ -7,7 +7,7 @@ variable "repository_name" { description = "Name of the repository" } -variable "repo_region" { +variable "ecr_region" { type = string default = null description = "Region in which repositories will be managed. If not specified, defaults to region configured for provider" @@ -96,6 +96,26 @@ variable "public_catalog_data" { } } +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 "repo_lifecycle_policy_path" { + type = string + default = null + description = "Path to JSON file providing lifecycle policy for the repository" +} + variable "tags" { type = map(string) default = {} From 2a762d8be4fb2fe38e0bd90c431cabd51b4acfda Mon Sep 17 00:00:00 2001 From: Vladimir Djurovic Date: Wed, 10 Dec 2025 14:32:20 +0100 Subject: [PATCH 3/5] improve policy handling --- easy-ecr/README.md | 6 +++- easy-ecr/default-lifecycle-policy.json | 30 ++++++++++++++++ easy-ecr/policies.tf | 16 +++++++-- easy-ecr/tests/policies.tftest.hcl | 48 ++++++++++++++++++++++++-- easy-ecr/variables.tf | 14 +++++++- 5 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 easy-ecr/default-lifecycle-policy.json diff --git a/easy-ecr/README.md b/easy-ecr/README.md index 4746a96..5e49f08 100644 --- a/easy-ecr/README.md +++ b/easy-ecr/README.md @@ -37,6 +37,8 @@ This Terraform module provides production-ready ECR repository for storing conta |**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:** || @@ -48,16 +50,18 @@ This Terraform module provides production-ready ECR repository for storing conta | [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 | +| [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 | | [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 | | [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\_lifecycle\_policy\_path](#input\_repo\_lifecycle\_policy\_path) | Path to JSON file providing lifecycle policy for the repository | `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 diff --git a/easy-ecr/default-lifecycle-policy.json b/easy-ecr/default-lifecycle-policy.json new file mode 100644 index 0000000..5930cfb --- /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", + "tagPrefixList": ["*"], + "countType": "imageCountMoreThan", + "countNumber": 10 + }, + "action": { + "type": "expire" + } + } + ] +} diff --git a/easy-ecr/policies.tf b/easy-ecr/policies.tf index 142fcdf..f14aa4b 100644 --- a/easy-ecr/policies.tf +++ b/easy-ecr/policies.tf @@ -1,7 +1,10 @@ # 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 @@ -17,8 +20,15 @@ resource "aws_ecr_repository_policy" "repo_policy" { } resource "aws_ecr_lifecycle_policy" "repo_lifecycle_policy" { - count = var.repo_lifecycle_policy_path != null ? 1 : 0 + 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 = file(var.repo_lifecycle_policy_path) + 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/tests/policies.tftest.hcl b/easy-ecr/tests/policies.tftest.hcl index e5e071b..27cb75f 100644 --- a/easy-ecr/tests/policies.tftest.hcl +++ b/easy-ecr/tests/policies.tftest.hcl @@ -40,7 +40,7 @@ run "registry_policy_not_created_when_policy_file_path_not_provided" { } -run "repostitory_policy_should_be_created_when_policy_file_path_provided" { +run "custom_repostitory_policy_should_be_created_when_policy_file_path_provided" { command = apply variables { @@ -59,17 +59,59 @@ run "repostitory_policy_should_be_created_when_policy_file_path_provided" { } } -run "repo_lifecycle_policy_should_be_created_when_policy_file_path_provided" { +run "custom_image_lifecycle_policy_should_be_created_when_policy_file_path_provided" { command = apply variables { repository_name = "test-repo" - repo_lifecycle_policy_path = "tests/test-policy.json" + 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/variables.tf b/easy-ecr/variables.tf index d51a7ed..f263040 100644 --- a/easy-ecr/variables.tf +++ b/easy-ecr/variables.tf @@ -110,12 +110,24 @@ variable "repo_policy_path" { description = "Path to JSON policy file (optional). If specified, policy will be applied to repository" } -variable "repo_lifecycle_policy_path" { +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 "tags" { type = map(string) default = {} From cd17e894f84a0add067aaa99dbc00d31141d67a7 Mon Sep 17 00:00:00 2001 From: Vladimir Djurovic Date: Thu, 11 Dec 2025 16:13:29 +0100 Subject: [PATCH 4/5] added pullthorugh cache config --- easy-ecr/README.md | 11 ++ easy-ecr/cache.tf | 48 +++++ easy-ecr/tests/cache_rules.tftest.hcl | 257 ++++++++++++++++++++++++++ easy-ecr/variables.tf | 78 ++++++++ 4 files changed, 394 insertions(+) create mode 100644 easy-ecr/cache.tf create mode 100644 easy-ecr/tests/cache_rules.tftest.hcl diff --git a/easy-ecr/README.md b/easy-ecr/README.md index 5e49f08..a80ab5d 100644 --- a/easy-ecr/README.md +++ b/easy-ecr/README.md @@ -29,6 +29,10 @@ This Terraform module provides production-ready ECR repository for storing conta |------|------| | [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 | @@ -46,15 +50,22 @@ This Terraform module provides production-ready ECR repository for storing conta | 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")
# credential_arn = optional(string, null)
# custom_role_arn = optional(string, null)
# upstream_registry_url = optional(string, null)
})
| `{}` | 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 | 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/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/variables.tf b/easy-ecr/variables.tf index f263040..97f03ec 100644 --- a/easy-ecr/variables.tf +++ b/easy-ecr/variables.tf @@ -128,6 +128,84 @@ variable "use_default_image_lifecycle_policy" { 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") + # credential_arn = optional(string, null) + # custom_role_arn = optional(string, null) + # upstream_registry_url = optional(string, null) + }) + 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 "tags" { type = map(string) default = {} From 70ea34db167ad120d850d151715596e6f0ccec51 Mon Sep 17 00:00:00 2001 From: Vladimir Djurovic Date: Fri, 12 Dec 2025 12:57:57 +0100 Subject: [PATCH 5/5] update configuration --- README.md | 2 ++ easy-ecr/README.md | 5 ++- easy-ecr/default-lifecycle-policy.json | 2 +- easy-ecr/main.tf | 2 +- easy-ecr/scan_config.tf | 8 +++++ easy-ecr/tests/scan_config.tftest.hcl | 46 ++++++++++++++++++++++++++ easy-ecr/variables.tf | 17 +++++++--- 7 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 easy-ecr/scan_config.tf create mode 100644 easy-ecr/tests/scan_config.tftest.hcl 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 index a80ab5d..7ed30b0 100644 --- a/easy-ecr/README.md +++ b/easy-ecr/README.md @@ -27,6 +27,8 @@ This Terraform module provides production-ready ECR repository for storing conta ## 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 | @@ -50,7 +52,8 @@ This Terraform module provides production-ready ECR repository for storing conta | 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")
# credential_arn = optional(string, null)
# custom_role_arn = optional(string, null)
# upstream_registry_url = optional(string, null)
})
| `{}` | no | +| [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 | diff --git a/easy-ecr/default-lifecycle-policy.json b/easy-ecr/default-lifecycle-policy.json index 5930cfb..78e2b28 100644 --- a/easy-ecr/default-lifecycle-policy.json +++ b/easy-ecr/default-lifecycle-policy.json @@ -18,7 +18,7 @@ "description": "Any image policy", "selection": { "tagStatus": "tagged", - "tagPrefixList": ["*"], + "tagPatternList": ["*"], "countType": "imageCountMoreThan", "countNumber": 10 }, diff --git a/easy-ecr/main.tf b/easy-ecr/main.tf index d018417..f7c1494 100644 --- a/easy-ecr/main.tf +++ b/easy-ecr/main.tf @@ -6,7 +6,7 @@ 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") : "IMMUTABLE" + 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" {} 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/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/variables.tf b/easy-ecr/variables.tf index 97f03ec..18ab90e 100644 --- a/easy-ecr/variables.tf +++ b/easy-ecr/variables.tf @@ -102,8 +102,6 @@ variable "registry_policy_path" { description = "Path to JSON policy file (optional). If specified, policy will be applied to registry" } - - variable "repo_policy_path" { type = string default = null @@ -133,9 +131,6 @@ variable "aws_public_pullthrough_cache_rule" { enabled = optional(bool, false) 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 = optional(string, null) }) default = {} description = "Pullthrough cache rule for AWS public registry. Override default values to customize" @@ -206,6 +201,18 @@ variable "pullthrough_cache_rules" { 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 = {}