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 = {}