Cloudfront blue green (#263)

This commit is contained in:
Robert Fairburn
2026-07-10 02:41:57 -05:00
committed by GitHub
parent f4a67ec750
commit a4813b4242
4 changed files with 156 additions and 19 deletions
@@ -30,6 +30,49 @@ These are the minimum versions of modules required if used:
Previous versions do not allow for proper interaction with both the software installers and logging s3 buckets.
## Blue-Green Key Rotation
This module supports rotating CloudFront signing keys without invalidating existing signed URLs. Instead of a single `public_key` / `private_key` pair, you can provide a map of named keypairs and select which one is active:
```hcl
module "cloudfront-software-installers" {
source = "..."
customer = "fleet"
s3_bucket = module.main.byo-vpc.byo-db.byo-ecs.fleet_s3_software_installers_config.bucket_name
keypairs = {
current = {
public_key = data.aws_kms_secrets.cloudfront.plaintext["public_key"]
private_key = data.aws_kms_secrets.cloudfront.plaintext["private_key"]
}
next = {
public_key = data.aws_kms_secrets.cloudfront_next.plaintext["public_key"]
private_key = data.aws_kms_secrets.cloudfront_next.plaintext["private_key"]
}
}
active_keypair_name = "current"
}
```
All keypairs in the map are added to the CloudFront key group, so signed URLs created by any retained key remain valid. Only the active keypair populates the Secrets Manager secret that Fleet reads.
### Rotation procedure
1. **Upgrade the module** with the existing key only (or via the legacy `public_key` / `private_key` inputs). Confirm Terraform plans only a resource move (`aws_cloudfront_public_key.software_installers[0]``["current"]`) with no create/update/delete.
2. **Add the new keypair** under any new map name (e.g. `next`), leaving `active_keypair_name = "current"`. Apply and wait for the CloudFront key group update to deploy.
3. **Flip the active key** by changing `active_keypair_name` to the new key name and applying. This creates a new Secrets Manager secret version.
4. **Redeploy Fleet ECS tasks** so the container receives the updated secret values.
5. **Retire the old keypair** after the maximum signed URL lifetime has passed. Remove it from the map and apply. If CloudFront reports the old public key is still in use, wait for propagation and apply again.
### Legacy inputs
The `public_key` and `private_key` variables are deprecated but still supported. When `keypairs` is not set, they are normalized into a single `current` keypair internally. Migrate to the `keypairs` map at your convenience.
### External key groups
Blue-green key rotation is not supported when `key_group_id` is set. When using an external key group, the module cannot manage the public key resources, so `active_keypair_name` cannot select the correct public key ID. Use `keypairs` only when the module manages the key group (i.e., `key_group_id` is not set).
## Configuration considerations for other modules
### tf-mod-root/tf-mod-byo-vpc/tf-mod-byo-db/tf-mod-byo-ecs
@@ -29,6 +29,49 @@ These are the minimum versions of modules required if used:
Previous versions do not allow for proper interaction with both the software installers and logging s3 buckets.
## Blue-Green Key Rotation
This module supports rotating CloudFront signing keys without invalidating existing signed URLs. Instead of a single `public_key` / `private_key` pair, you can provide a map of named keypairs and select which one is active:
```hcl
module "cloudfront-software-installers" {
source = "..."
customer = "fleet"
s3_bucket = module.main.byo-vpc.byo-db.byo-ecs.fleet_s3_software_installers_config.bucket_name
keypairs = {
current = {
public_key = data.aws_kms_secrets.cloudfront.plaintext["public_key"]
private_key = data.aws_kms_secrets.cloudfront.plaintext["private_key"]
}
next = {
public_key = data.aws_kms_secrets.cloudfront_next.plaintext["public_key"]
private_key = data.aws_kms_secrets.cloudfront_next.plaintext["private_key"]
}
}
active_keypair_name = "current"
}
```
All keypairs in the map are added to the CloudFront key group, so signed URLs created by any retained key remain valid. Only the active keypair populates the Secrets Manager secret that Fleet reads.
### Rotation procedure
1. **Upgrade the module** with the existing key only (or via the legacy `public_key` / `private_key` inputs). Confirm Terraform plans only a resource move (`aws_cloudfront_public_key.software_installers[0]``["current"]`) with no create/update/delete.
2. **Add the new keypair** under any new map name (e.g. `next`), leaving `active_keypair_name = "current"`. Apply and wait for the CloudFront key group update to deploy.
3. **Flip the active key** by changing `active_keypair_name` to the new key name and applying. This creates a new Secrets Manager secret version.
4. **Redeploy Fleet ECS tasks** so the container receives the updated secret values.
5. **Retire the old keypair** after the maximum signed URL lifetime has passed. Remove it from the map and apply. If CloudFront reports the old public key is still in use, wait for propagation and apply again.
### Legacy inputs
The `public_key` and `private_key` variables are deprecated but still supported. When `keypairs` is not set, they are normalized into a single `current` keypair internally. Migrate to the `keypairs` map at your convenience.
### External key groups
Blue-green key rotation is not supported when `key_group_id` is set. When using an external key group, the module cannot manage the public key resources, so `active_keypair_name` cannot select the correct public key ID. Use `keypairs` only when the module manages the key group (i.e., `key_group_id` is not set).
## Configuration considerations for other modules
### tf-mod-root/tf-mod-byo-vpc/tf-mod-byo-db/tf-mod-byo-ecs
@@ -180,19 +223,19 @@ No requirements.
## Providers
| Name | Version |
|------|---------|
| <a name="provider_aws"></a> [aws](#provider\_aws) | 6.49.0 |
| ---- | ------- |
| <a name="provider_aws"></a> [aws](#provider\_aws) | 6.54.0 |
## Modules
| Name | Source | Version |
|------|--------|---------|
| ---- | ------ | ------- |
| <a name="module_cloudfront_software_installers"></a> [cloudfront\_software\_installers](#module\_cloudfront\_software\_installers) | terraform-aws-modules/cloudfront/aws | 5.2.0 |
## Resources
| Name | Type |
|------|------|
| ---- | ---- |
| [aws_cloudfront_key_group.software_installers](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_key_group) | resource |
| [aws_cloudfront_public_key.software_installers](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_public_key) | resource |
| [aws_iam_policy.software_installers_secret](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource |
@@ -205,21 +248,23 @@ No requirements.
## Inputs
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| ---- | ----------- | ---- | ------- | :------: |
| <a name="input_active_keypair_name"></a> [active\_keypair\_name](#input\_active\_keypair\_name) | Name of the keypair in `keypairs` (or `"current"` when using legacy inputs) whose keys populate the Secrets Manager secret. All keypairs in the map are added to the CloudFront key group so signed URLs from any retained key remain valid. | `string` | `"current"` | no |
| <a name="input_customer"></a> [customer](#input\_customer) | Customer name for the cloudfront instance | `string` | `"fleet"` | no |
| <a name="input_enable_logging"></a> [enable\_logging](#input\_enable\_logging) | Enable optional logging to s3 | `bool` | `false` | no |
| <a name="input_key_group_id"></a> [key\_group\_id](#input\_key\_group\_id) | Cloudfront key group id | `string` | `null` | no |
| <a name="input_keypairs"></a> [keypairs](#input\_keypairs) | Map of named keypairs for blue-green key rotation. Each value must contain `public_key` and `private_key`. When set, `public_key` and `private_key` variables are ignored. | <pre>map(object({<br/> public_key = string<br/> private_key = string<br/> }))</pre> | `null` | no |
| <a name="input_logging_s3_bucket"></a> [logging\_s3\_bucket](#input\_logging\_s3\_bucket) | s3 bucket to log to | `string` | `null` | no |
| <a name="input_logging_s3_prefix"></a> [logging\_s3\_prefix](#input\_logging\_s3\_prefix) | logging s3 bucket prefix | `string` | `"cloudfront"` | no |
| <a name="input_private_key"></a> [private\_key](#input\_private\_key) | Private key used for signed URLs | `string` | n/a | yes |
| <a name="input_public_key"></a> [public\_key](#input\_public\_key) | Public key used for signed URLs | `string` | n/a | yes |
| <a name="input_private_key"></a> [private\_key](#input\_private\_key) | Private key used for signed URLs. Deprecated: use `keypairs` instead. | `string` | `null` | no |
| <a name="input_public_key"></a> [public\_key](#input\_public\_key) | Public key used for signed URLs. Deprecated: use `keypairs` instead. | `string` | `null` | no |
| <a name="input_public_key_id"></a> [public\_key\_id](#input\_public\_key\_id) | Cloudfront public key id. Required when passing in a key\_group\_id | `string` | `null` | no |
| <a name="input_s3_bucket"></a> [s3\_bucket](#input\_s3\_bucket) | Name of the S3 bucket that Cloudfront will point to | `string` | n/a | yes |
## Outputs
| Name | Description |
|------|-------------|
| ---- | ----------- |
| <a name="output_cloudfront_arn"></a> [cloudfront\_arn](#output\_cloudfront\_arn) | n/a |
| <a name="output_cloudfront_distribution_arn"></a> [cloudfront\_distribution\_arn](#output\_cloudfront\_distribution\_arn) | n/a |
| <a name="output_extra_execution_iam_policies"></a> [extra\_execution\_iam\_policies](#output\_extra\_execution\_iam\_policies) | n/a |
+25 -8
View File
@@ -1,3 +1,14 @@
locals {
keypairs = var.keypairs != null ? var.keypairs : {
current = {
public_key = var.public_key
private_key = var.private_key
}
}
keypair_names = var.keypairs != null ? keys(var.keypairs) : ["current"]
}
data "aws_s3_bucket" "software_installers" {
bucket = var.s3_bucket
}
@@ -15,16 +26,22 @@ resource "aws_iam_policy" "software_installers_secret" {
}
resource "aws_cloudfront_public_key" "software_installers" {
count = var.key_group_id == null ? 1 : 0
comment = "${var.customer} software installers public key"
encoded_key = var.public_key
name = "${var.customer}-software-installers"
for_each = var.key_group_id == null ? toset(nonsensitive(local.keypair_names)) : toset([])
comment = each.key == "current" ? "${var.customer} software installers public key" : "${var.customer} software installers public key ${each.key}"
encoded_key = local.keypairs[each.key].public_key
name = each.key == "current" ? "${var.customer}-software-installers" : "${var.customer}-software-installers-${each.key}"
}
moved {
from = aws_cloudfront_public_key.software_installers[0]
to = aws_cloudfront_public_key.software_installers["current"]
}
resource "aws_cloudfront_key_group" "software_installers" {
count = var.key_group_id == null ? 1 : 0
comment = "${var.customer} software installers key group"
items = [aws_cloudfront_public_key.software_installers[0].id]
items = values(aws_cloudfront_public_key.software_installers)[*].id
name = "${var.customer}-software-installers-group"
}
@@ -35,10 +52,10 @@ resource "aws_secretsmanager_secret" "software_installers" {
resource "aws_secretsmanager_secret_version" "software_installers" {
secret_id = aws_secretsmanager_secret.software_installers.id
secret_string = jsonencode({
FLEET_S3_SOFTWARE_INSTALLERS_CLOUDFRONT_URL_SIGNING_PRIVATE_KEY = var.private_key
FLEET_S3_SOFTWARE_INSTALLERS_CLOUDFRONT_URL_SIGNING_PUBLIC_KEY = var.public_key
FLEET_S3_SOFTWARE_INSTALLERS_CLOUDFRONT_URL_SIGNING_PRIVATE_KEY = local.keypairs[var.active_keypair_name].private_key
FLEET_S3_SOFTWARE_INSTALLERS_CLOUDFRONT_URL_SIGNING_PUBLIC_KEY = local.keypairs[var.active_keypair_name].public_key
FLEET_S3_SOFTWARE_INSTALLERS_CLOUDFRONT_URL = "https://${module.cloudfront_software_installers.cloudfront_distribution_domain_name}"
FLEET_S3_SOFTWARE_INSTALLERS_CLOUDFRONT_URL_SIGNING_PUBLIC_KEY_ID = var.public_key_id == null ? aws_cloudfront_public_key.software_installers[0].id : var.public_key_id
FLEET_S3_SOFTWARE_INSTALLERS_CLOUDFRONT_URL_SIGNING_PUBLIC_KEY_ID = var.public_key_id == null ? aws_cloudfront_public_key.software_installers[var.active_keypair_name].id : var.public_key_id
})
}
@@ -26,14 +26,46 @@ variable "public_key_id" {
}
}
variable "private_key" {
description = "Private key used for signed URLs"
variable "keypairs" {
description = "Map of named keypairs for blue-green key rotation. Each value must contain `public_key` and `private_key`. When set, `public_key` and `private_key` variables are ignored."
type = map(object({
public_key = string
private_key = string
}))
default = null
validation {
condition = var.keypairs != null || (var.public_key != null && var.private_key != null)
error_message = "Either keypairs must be set, or both public_key and private_key must be provided."
}
validation {
condition = var.keypairs == null || var.key_group_id == null
error_message = "keypairs cannot be used with key_group_id. Blue-green key rotation requires the module to manage the CloudFront key group."
}
}
variable "active_keypair_name" {
description = "Name of the keypair in `keypairs` (or `\"current\"` when using legacy inputs) whose keys populate the Secrets Manager secret. All keypairs in the map are added to the CloudFront key group so signed URLs from any retained key remain valid."
type = string
default = "current"
validation {
condition = var.keypairs == null ? true : contains(keys(var.keypairs), var.active_keypair_name)
error_message = "active_keypair_name must be a key present in the keypairs map."
}
}
variable "private_key" {
description = "Private key used for signed URLs. Deprecated: use `keypairs` instead."
type = string
default = null
}
variable "public_key" {
description = "Public key used for signed URLs"
description = "Public key used for signed URLs. Deprecated: use `keypairs` instead."
type = string
default = null
}
variable "s3_bucket" {