diff --git a/easy-ecr/README.md b/easy-ecr/README.md index 7ed30b0..f9f2d79 100644 --- a/easy-ecr/README.md +++ b/easy-ecr/README.md @@ -37,6 +37,10 @@ This Terraform module provides production-ready ECR repository for storing conta |**Description:** || | [aws_ecr_registry_policy.registry_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecr_registry_policy) | resource | |**Description:** || +| [aws_ecr_registry_scanning_configuration.registry_scan_config](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecr_registry_scanning_configuration) | resource | +|**Description:** || +| [aws_ecr_replication_configuration.replication_config](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecr_replication_configuration) | resource | +|**Description:** Defines registry replication configuration. Current implementation allows only replication withing the same AWS account. It is possible to define rule filters for replication. || | [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 | @@ -45,6 +49,14 @@ This Terraform module provides production-ready ECR repository for storing conta |**Description:** || | [aws_ecrpublic_repository_policy.public_repo_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecrpublic_repository_policy) | resource | |**Description:** || +| [aws_iam_role.repo_push_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +|**Description:** Role which allows read/write access to repository || +| [aws_iam_role.repo_read_only_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +|**Description:** Role which allows read-only access to repository || +| [aws_iam_role_policy.push_role_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +|**Description:** IAM policy for role allowing read/write (push) access to repository || +| [aws_iam_role_policy.read_only_role_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +|**Description:** IAM policy for role allowing read-only (pull) access to repository || | [aws_kms_key.domain_encryption_key](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/kms_key) | resource | |**Description:** || @@ -67,9 +79,13 @@ This Terraform module provides production-ready ECR repository for storing conta | [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 | +| [pull\_only\_principals](#input\_pull\_only\_principals) | List of principal ARNs who are allowed to assume role allowing pull access to repository | `list(string)` | `[]` | 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 | +| [push\_principals](#input\_push\_principals) | List of principal ARNs who are allowed to assume role allowing read/write (push) access to repository | `list(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 | +| [registry\_scan\_configuration](#input\_registry\_scan\_configuration) | Registry scanning configuration |
object({
type = optional(string, "BASIC")
rules = optional(list(object({
frequency = optional(string, "SCAN_ON_PUSH")
filter = optional(string, "*")
})), [])
})
| `{}` | no | +| [replication\_config](#input\_replication\_config) | Registry replication configuration |
list(object({
region = string
filter = optional(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 | @@ -80,7 +96,10 @@ This Terraform module provides production-ready ECR repository for storing conta ## Outputs -No outputs. +| Name | Description | +|------|-------------| +| [private\_repository\_arn](#output\_private\_repository\_arn) | ARN of the private repository | +| [private\_repository\_url](#output\_private\_repository\_url) | URL of the private repository | ## Examples diff --git a/easy-ecr/main.tf b/easy-ecr/main.tf index f7c1494..b34a071 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") : (length(var.mutability_exclusion_filters) > 0 ? "IMMUTABLE_WITH_EXCLUSION" : "IMUTABLE") + 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" : "IMMUTABLE") } data "aws_region" "current_region" {} diff --git a/easy-ecr/outputs.tf b/easy-ecr/outputs.tf new file mode 100644 index 0000000..83ea141 --- /dev/null +++ b/easy-ecr/outputs.tf @@ -0,0 +1,13 @@ +# Copyright 2025 Bitshift +# SPDX-License-Identifier: MPL-2.0 + +output "private_repository_url" { + value = length(aws_ecr_repository.ecr_private_repo) > 0 ? aws_ecr_repository.ecr_private_repo[0].repository_url : "" + description = "URL of the private repository" +} + +output "private_repository_arn" { + value = length(aws_ecr_repository.ecr_private_repo) > 0 ? aws_ecr_repository.ecr_private_repo[0].arn : "" + description = "ARN of the private repository" +} + diff --git a/easy-ecr/replication.tf b/easy-ecr/replication.tf new file mode 100644 index 0000000..903b6fc --- /dev/null +++ b/easy-ecr/replication.tf @@ -0,0 +1,31 @@ +# Copyright 2025 Bitshift +# SPDX-License-Identifier: MPL-2.0 + + + +data "aws_caller_identity" "current" {} + +# Defines registry replication configuration. Current implementation allows only replication +# withing the same AWS account. It is possible to define rule filters for replication. +resource "aws_ecr_replication_configuration" "replication_config" { + count = length(var.replication_config) > 0 ? 1 : 0 + region = var.ecr_region != null ? var.ecr_region : data.aws_region.current_region.region + replication_configuration { + rule { + dynamic "destination" { + for_each = var.replication_config + content { + region = destination.value.region + registry_id = data.aws_caller_identity.current.account_id + } + } + dynamic "repository_filter" { + for_each = [for r in var.replication_config : r if r.filter != null] + content { + filter = repository_filter.value.filter + filter_type = "PREFIX_MATCH" + } + } + } + } +} \ No newline at end of file diff --git a/easy-ecr/roles.tf b/easy-ecr/roles.tf new file mode 100644 index 0000000..a8568dd --- /dev/null +++ b/easy-ecr/roles.tf @@ -0,0 +1,122 @@ +# Copyright 2025 Bitshift +# SPDX-License-Identifier: MPL-2.0 + + + +################################################################################### +# +# Roles allowing read-only (pull) access to repository. +# +################################################################################### + +# IAM policy defining read-only access to repository +data "aws_iam_policy_document" "pull_only_policy_document" { + count = length(var.pull_only_principals) > 0 ? 1 : 0 + statement { + effect = "Allow" + actions = ["ecr:GetAuthorizationToken"] + resources = ["*"] + } + statement { + effect = "Allow" + actions = [ + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchImportUpstreamImage" + ] + resources = ["arn:aws:ecr:${data.aws_region.current_region.region}:${data.aws_caller_identity.current.account_id}:repository/*"] + } +} + +# policy to allow assuming read-only role +data "aws_iam_policy_document" "assume_pull_role_document" { + count = length(var.pull_only_principals) > 0 ? 1 : 0 + statement { + effect = "Allow" + principals { + type = "AWS" + identifiers = var.pull_only_principals + } + actions = ["sts:AssumeRole"] + } +} + +# Role which allows read-only access to repository +resource "aws_iam_role" "repo_read_only_role" { + count = length(var.pull_only_principals) > 0 ? 1 : 0 + name = "ECRPullAccess-${var.repository_name}" + assume_role_policy = data.aws_iam_policy_document.assume_pull_role_document[0].json + tags = var.tags +} + +# IAM policy for role allowing read-only (pull) access to repository +resource "aws_iam_role_policy" "read_only_role_policy" { + count = length(var.pull_only_principals) > 0 ? 1 : 0 + policy = data.aws_iam_policy_document.pull_only_policy_document[0].json + role = aws_iam_role.repo_read_only_role[0].name +} + +################################################################################### +# +# Roles allowing read/write (push/pull) access to repository. +# +################################################################################### + +data "aws_iam_policy_document" "push_policy_document" { + count = length(var.push_principals) > 0 ? 1 : 0 + statement { + effect = "Allow" + actions = ["ecr:GetAuthorizationToken"] + resources = ["*"] + } + statement { + effect = "Allow" + actions = [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:GetRepositoryPolicy", + "ecr:DescribeRepositories", + "ecr:ListImages", + "ecr:DescribeImages", + "ecr:BatchGetImage", + "ecr:GetLifecyclePolicy", + "ecr:GetLifecyclePolicyPreview", + "ecr:ListTagsForResource", + "ecr:DescribeImageScanFindings", + "ecr:InitiateLayerUpload", + "ecr:UploadLayerPart", + "ecr:CompleteLayerUpload", + "ecr:PutImage", + "ecr:BatchImportUpstreamImage" + ] + resources = ["arn:aws:ecr:${data.aws_region.current_region.region}:${data.aws_caller_identity.current.account_id}:repository/*"] + } +} + +data "aws_iam_policy_document" "assume_push_role_document" { + count = length(var.push_principals) > 0 ? 1 : 0 + statement { + effect = "Allow" + principals { + type = "AWS" + identifiers = var.push_principals + } + actions = ["sts:AssumeRole"] + } +} + +# Role which allows read/write access to repository +resource "aws_iam_role" "repo_push_role" { + count = length(var.push_principals) > 0 ? 1 : 0 + name = "ECRPushAccess-${var.repository_name}" + assume_role_policy = data.aws_iam_policy_document.assume_push_role_document[0].json + tags = var.tags +} + +# IAM policy for role allowing read/write (push) access to repository +resource "aws_iam_role_policy" "push_role_policy" { + count = length(var.push_principals) > 0 ? 1 : 0 + policy = data.aws_iam_policy_document.push_policy_document[0].json + role = aws_iam_role.repo_push_role[0].name +} + diff --git a/easy-ecr/scan_config.tf b/easy-ecr/scan_config.tf index 63c3270..699c7ac 100644 --- a/easy-ecr/scan_config.tf +++ b/easy-ecr/scan_config.tf @@ -5,4 +5,19 @@ 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 +} + +resource "aws_ecr_registry_scanning_configuration" "registry_scan_config" { + region = var.ecr_region != null ? var.ecr_region : data.aws_region.current_region.region + scan_type = var.registry_scan_configuration.type + dynamic "rule" { + for_each = var.registry_scan_configuration.rules + content { + scan_frequency = rule.value.frequency + repository_filter { + filter = rule.value.filter + filter_type = "WILDCARD" + } + } + } +} diff --git a/easy-ecr/tests/replication_config.tftest.hcl b/easy-ecr/tests/replication_config.tftest.hcl new file mode 100644 index 0000000..bcbaecf --- /dev/null +++ b/easy-ecr/tests/replication_config.tftest.hcl @@ -0,0 +1,58 @@ +# Copyright 2025 Bitshift +# SPDX-License-Identifier: MPL-2.0 + +mock_provider "aws" { + override_data { + target = data.aws_caller_identity.current + values = { + account_id = "123456789012" + } + } +} + +run "no_replication_config_by_default" { + command = apply + + variables { + repository_name = "test-repo" + } + + assert { + condition = length(aws_ecr_replication_configuration.replication_config) == 0 + error_message = "Replication configuration is not empty" + } +} + +run "per_region_config_should_be_created" { + command = plan + + variables { + repository_name = "test-repo" + replication_config = [ + { + region = "us-east-1" + }, + { + region = "us-east-2" + filter = "my-filter" + } + ] + } + + assert { + condition = length(aws_ecr_replication_configuration.replication_config) == 1 + error_message = "Replication configuration is empty" + } + assert { + condition = aws_ecr_replication_configuration.replication_config[0].replication_configuration[0].rule[0].destination[0].region == "us-east-1" + error_message = "Invalid replication configuration" + } + assert { + condition = aws_ecr_replication_configuration.replication_config[0].replication_configuration[0].rule[0].destination[1].region == "us-east-2" + error_message = "Invalid replication configuration" + } + assert { + condition = aws_ecr_replication_configuration.replication_config[0].replication_configuration[0].rule[0].repository_filter[0].filter == "my-filter" + error_message = "Invalid replication configuration (filter)" + } +} \ No newline at end of file diff --git a/easy-ecr/tests/roles.tftest.hcl b/easy-ecr/tests/roles.tftest.hcl new file mode 100644 index 0000000..5303e58 --- /dev/null +++ b/easy-ecr/tests/roles.tftest.hcl @@ -0,0 +1,49 @@ +# Copyright 2025 Bitshift +# SPDX-License-Identifier: MPL-2.0 + +mock_provider "aws" { + override_data { + target = data.aws_iam_policy_document.assume_pull_role_document + values = { + json = "{}" + } + } + override_data { + target = data.aws_iam_policy_document.pull_only_policy_document + values = { + json = "{}" + } + } +} + +run "roles_should_not_be_created_when_no_principals" { + command = apply + + variables { + repository_name = "test-repo" + } + + assert { + condition = length(aws_iam_role.repo_read_only_role) == 0 + error_message = "Read only role is created when no principals specified" + } + + assert { + condition = length(aws_iam_role.repo_push_role) == 0 + error_message = "Push is created when no principals specified" + } +} + +run "read_only_role_is_created_when_principals_are_specified" { + command = apply + + variables { + repository_name = "test-repo" + pull_only_principals = ["arn:aws:iam::/user/user1"] + } + + assert { + condition = length(aws_iam_role.repo_read_only_role) == 1 + error_message = "Read only role is notcreated when principals are specified" + } +} \ No newline at end of file diff --git a/easy-ecr/tests/scan_config.tftest.hcl b/easy-ecr/tests/scan_config.tftest.hcl index c2a942a..fb47657 100644 --- a/easy-ecr/tests/scan_config.tftest.hcl +++ b/easy-ecr/tests/scan_config.tftest.hcl @@ -44,3 +44,51 @@ run "default_scan_config_is_overriden_when_specified" { error_message = "Scan configuration resource was not created with correct value" } } + +run "scan_configuration_is_basic_by_default" { + command = apply + + variables { + repository_name = "test-repo" + } + + assert { + condition = aws_ecr_registry_scanning_configuration.registry_scan_config.scan_type == "BASIC" + error_message = "Scanning configuration is not BASIC" + } + assert { + condition = length(aws_ecr_registry_scanning_configuration.registry_scan_config.rule) == 0 + error_message = "Rules list is not empty" + } +} + +run "scan_configuration_overrides_defaults" { + command = apply + + variables { + repository_name = "test-repo" + registry_scan_configuration = { + type = "ENHANCED" + rules = [ + { + frequency = "CONTINUOUS_SCAN" + filter = "my-filter" + } + ] + } + } + + assert { + condition = aws_ecr_registry_scanning_configuration.registry_scan_config.scan_type == "ENHANCED" + error_message = "Scanning configuration is not ENHANCED" + } + assert { + condition = length(aws_ecr_registry_scanning_configuration.registry_scan_config.rule) == 1 + error_message = "Rules list is empty" + } + + assert { + condition = length([for r in aws_ecr_registry_scanning_configuration.registry_scan_config.rule : r if r.scan_frequency == "CONTINUOUS_SCAN"]) == 1 + error_message = "Invalid scan configuration" + } +} diff --git a/easy-ecr/variables.tf b/easy-ecr/variables.tf index 18ab90e..4c55929 100644 --- a/easy-ecr/variables.tf +++ b/easy-ecr/variables.tf @@ -213,6 +213,39 @@ variable "default_account_scan_config" { description = "Default ECR basic scan type configuration." } +variable "registry_scan_configuration" { + type = object({ + type = optional(string, "BASIC") + rules = optional(list(object({ + frequency = optional(string, "SCAN_ON_PUSH") + filter = optional(string, "*") + })), []) + }) + default = {} + description = "Registry scanning configuration" +} + +variable "replication_config" { + type = list(object({ + region = string + filter = optional(string, null) + })) + default = [] + description = "Registry replication configuration" +} + +variable "pull_only_principals" { + type = list(string) + default = [] + description = "List of principal ARNs who are allowed to assume role allowing pull access to repository" +} + +variable "push_principals" { + type = list(string) + default = [] + description = "List of principal ARNs who are allowed to assume role allowing read/write (push) access to repository" +} + variable "tags" { type = map(string) default = {}