From 3aeab8e60e65a4da252e72a3308ecbaaba96f824 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 20 Aug 2026 15:45:15 -0400 Subject: [PATCH] feat(forces): a bucket for MegaMek's files, at their own paths The bucket's top level is release versions and everything under one is that release's own layout, so anything holding a path from an install can build the address. A viewer-request function takes this site's /assets/megamek namespace off at the edge, which keeps the bucket a mirror of MegaMek rather than of our URLs. --- envs/lance.blue/main.tf | 14 ++++ envs/lance.blue/outputs.tf | 5 ++ modules/assets-megamek/main.tf | 83 ++++++++++++++++++++++ modules/assets-megamek/outputs.tf | 14 ++++ modules/assets-megamek/variables.tf | 10 +++ modules/static-site/main.tf | 98 ++++++++++++++++++++++++++ modules/static-site/megamek-rewrite.js | 29 ++++++++ modules/static-site/variables.tf | 18 +++++ 8 files changed, 271 insertions(+) create mode 100644 modules/assets-megamek/main.tf create mode 100644 modules/assets-megamek/outputs.tf create mode 100644 modules/assets-megamek/variables.tf create mode 100644 modules/static-site/megamek-rewrite.js diff --git a/envs/lance.blue/main.tf b/envs/lance.blue/main.tf index 725ff7e..b202068 100644 --- a/envs/lance.blue/main.tf +++ b/envs/lance.blue/main.tf @@ -181,6 +181,14 @@ module "api_host" { # this repo provides the bucket and the CloudFront distribution. The # certificate cannot validate until the domain is delegated at the registrar # - see the runbook for the first-apply order. +module "assets_megamek" { + source = "../../modules/assets-megamek" + + bucket_name = "${local.project}-assets-megamek-${local.env}-${data.aws_caller_identity.current.account_id}" + + tags = { Component = "assets-megamek", Environment = local.env } +} + module "static_site" { source = "../../modules/static-site" @@ -195,6 +203,12 @@ module "static_site" { # site's own distribution, so a shared match is a lance.blue link. reports_origin = "api.${var.domain_name}" + # MegaMek's unit art under /mm/, from its own bucket through the same + # distribution. scripts/assets-megamek.sh is what fills it. + assets_bucket_domain = module.assets_megamek.bucket_regional_domain_name + assets_bucket_id = module.assets_megamek.bucket_name + assets_bucket_arn = module.assets_megamek.bucket_arn + providers = { aws = aws aws.us_east_1 = aws.us_east_1 diff --git a/envs/lance.blue/outputs.tf b/envs/lance.blue/outputs.tf index c428b65..81685fa 100644 --- a/envs/lance.blue/outputs.tf +++ b/envs/lance.blue/outputs.tf @@ -119,3 +119,8 @@ output "control_plane_policy_arns" { run_match = module.match_cluster.control_plane_policy_arn } } + +output "assets_megamek_bucket" { + description = "Bucket holding MegaMek's unit art, one prefix per release. Filled by scripts/assets-megamek.sh." + value = module.assets_megamek.bucket_name +} diff --git a/modules/assets-megamek/main.tf b/modules/assets-megamek/main.tf new file mode 100644 index 0000000..c529658 --- /dev/null +++ b/modules/assets-megamek/main.tf @@ -0,0 +1,83 @@ +# MegaMek's own files, at the version a match plays under. +# +# The bucket's top level is release versions and nothing else, and everything +# under one is that release's own layout, unchanged: +# +# 0.51.0/data/images/units/meks/Atlas.png +# +# served as +# +# https://lance.blue/assets/megamek/0.51.0/data/images/units/meks/Atlas.png +# +# The `/assets/megamek` in front is this site's namespace rather than +# MegaMek's, and the distribution takes it off at the edge, so what is stored +# here is a mirror of MegaMek releases rather than a mirror of our URLs. +# Things on this side are written against MegaMek's data and often already +# know which file they want, so the rest of the address is something they can +# build from what they hold. We host a subset today, the unit art; a subset of +# the same layout costs nothing extra and grows without a second scheme. +# +# Verbatim matters beyond that convenience. MegaMek maps a design to a picture +# through mekset.txt, and `helm art` resolves that mapping to the paths the +# release stores - so what is here has to be the tree those paths name, with +# its own spelling and its own capitalisation. 6,420 of 6,995 files have a +# space or a capital in them, S3 is case-sensitive, and normalising any of it +# silently breaks a fifth of the lookups. +# +# Version-prefixed and never overwritten. arena pins one MegaMek, and a bump +# runs the two side by side for a while: art shown for a match has to be the +# art that match will draw, so a release's prefix is written once and left. +# +# Nothing here is ours. It is MegaMek's data under CC BY-NC-SA 4.0 - the same +# terms the camo art in headquarters already ships under, credited the same +# way. LICENSE.assets is copied in beside it by scripts/assets-megamek.sh so +# the terms travel with the bytes rather than living only in a README. +# +# Private, like every other bucket here. The site's CloudFront reads it through +# an origin access control, and the bucket policy that allows that is written +# by the static-site module, which is the thing that knows the distribution's +# ARN. + +terraform { + required_version = ">= 1.11" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} + +resource "aws_s3_bucket" "this" { + bucket = var.bucket_name + + # Regenerable in the strict sense - it is a copy of a public release - but + # re-uploading 39MB per version by hand is not something to do by accident. + force_destroy = false + + tags = merge(var.tags, { Name = var.bucket_name }) +} + +# Nothing here is reachable except through the distribution. A policy naming +# the CloudFront service principal with a SourceArn condition is not a public +# policy, so all four blocks stay on, exactly as on the site's own bucket. +resource "aws_s3_bucket_public_access_block" "this" { + bucket = aws_s3_bucket.this.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "this" { + bucket = aws_s3_bucket.this.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + bucket_key_enabled = true + } +} diff --git a/modules/assets-megamek/outputs.tf b/modules/assets-megamek/outputs.tf new file mode 100644 index 0000000..8462f2c --- /dev/null +++ b/modules/assets-megamek/outputs.tf @@ -0,0 +1,14 @@ +output "bucket_name" { + description = "The bucket the art is synced into." + value = aws_s3_bucket.this.id +} + +output "bucket_arn" { + description = "For the bucket policy the static-site module writes." + value = aws_s3_bucket.this.arn +} + +output "bucket_regional_domain_name" { + description = "The origin domain CloudFront reads." + value = aws_s3_bucket.this.bucket_regional_domain_name +} diff --git a/modules/assets-megamek/variables.tf b/modules/assets-megamek/variables.tf new file mode 100644 index 0000000..ef3e19e --- /dev/null +++ b/modules/assets-megamek/variables.tf @@ -0,0 +1,10 @@ +variable "bucket_name" { + description = "Bucket name. Globally unique, so the caller includes the account id." + type = string +} + +variable "tags" { + description = "Tags applied to everything here." + type = map(string) + default = {} +} diff --git a/modules/static-site/main.tf b/modules/static-site/main.tf index 2065254..babb94e 100644 --- a/modules/static-site/main.tf +++ b/modules/static-site/main.tf @@ -79,6 +79,39 @@ data "aws_iam_policy_document" "site" { } } +# The same grant as the site's own bucket, on the bucket the art lives in. +# It is written here rather than in the assets-megamek module because this is +# what knows the distribution's ARN, and pointing the two modules at each other +# would be a cycle. +resource "aws_s3_bucket_policy" "assets" { + count = var.assets_bucket_id == "" ? 0 : 1 + + bucket = var.assets_bucket_id + policy = data.aws_iam_policy_document.assets[0].json +} + +data "aws_iam_policy_document" "assets" { + count = var.assets_bucket_id == "" ? 0 : 1 + + statement { + sid = "CloudFrontRead" + + principals { + type = "Service" + identifiers = ["cloudfront.amazonaws.com"] + } + + actions = ["s3:GetObject"] + resources = ["${var.assets_bucket_arn}/*"] + + condition { + test = "StringEquals" + variable = "AWS:SourceArn" + values = [aws_cloudfront_distribution.site.arn] + } + } +} + # CloudFront only accepts certificates from us-east-1, so the certificate uses # the aliased provider. resource "aws_acm_certificate" "site" { @@ -248,6 +281,18 @@ resource "aws_cloudfront_function" "index_rewrite" { code = file("${path.module}/index-rewrite.js") } +# Takes `/assets/megamek` off the front of an asset request; see the file for +# why the address and the key differ by exactly that much. +resource "aws_cloudfront_function" "megamek_rewrite" { + count = var.assets_bucket_domain == "" ? 0 : 1 + + name = "${var.name}-megamek-rewrite" + runtime = "cloudfront-js-2.0" + comment = "Map /assets/megamek//... onto the bucket's /..." + publish = true + code = file("${path.module}/megamek-rewrite.js") +} + resource "aws_cloudfront_distribution" "site" { enabled = true is_ipv6_enabled = true @@ -269,6 +314,21 @@ resource "aws_cloudfront_distribution" "site" { origin_access_control_id = aws_cloudfront_origin_access_control.site.id } + # MegaMek's own files, at the version a match plays under. A second origin + # rather than a subdomain of its own: the art is drawn onto a canvas beside + # the site's own images, so same-origin costs no certificate, no DNS record + # and no CORS preflight. The bucket is its own, because the bytes are + # MegaMek's rather than ours and a release push must never overwrite them. + dynamic "origin" { + for_each = var.assets_bucket_domain == "" ? [] : [var.assets_bucket_domain] + + content { + domain_name = origin.value + origin_id = "megamek" + origin_access_control_id = aws_cloudfront_origin_access_control.site.id + } + } + # The API, for the public match reports alone. It is a whole origin rather # than a redirect because the point is the address: a match shared on # Bluesky should be a lance.blue link, and an unfurler follows what it is @@ -314,6 +374,44 @@ resource "aws_cloudfront_distribution" "site" { # /reports//index.html, which is not a thing it has. # # Reports are read, so GET and HEAD only. + # `/assets/megamek//`. Everything from + # the version rightwards is MegaMek's own layout, so anything that already + # knows which file it wants out of an install - and several things here do, + # since MegaMek's data is what they are written against - builds the address + # from what it holds rather than through a mapping table of ours. + # + # `/assets/megamek` is this site's namespace and not MegaMek's, so it is + # taken off at the edge rather than stored: the bucket's top level is + # release versions and nothing else, which is what makes it a mirror of + # MegaMek rather than a mirror of our URLs. + # + # `/assets/megamek/*` rather than `/assets/*`: this bucket holds MegaMek's + # files, and a second family of third-party assets should arrive as its own + # behaviour rather than by widening this one. + # + # Everything under here is immutable - a path carries the version that + # produced it and a version's prefix is written once - so the cache is told + # to keep it, and a bump is a new path rather than an invalidation. + dynamic "ordered_cache_behavior" { + for_each = var.assets_bucket_domain == "" ? [] : [var.assets_bucket_domain] + + content { + path_pattern = "/assets/megamek/*" + target_origin_id = "megamek" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + compress = true + cache_policy_id = data.aws_cloudfront_cache_policy.caching_optimized.id + response_headers_policy_id = aws_cloudfront_response_headers_policy.site.id + + function_association { + event_type = "viewer-request" + function_arn = aws_cloudfront_function.megamek_rewrite[0].arn + } + } + } + dynamic "ordered_cache_behavior" { for_each = var.reports_origin == "" ? [] : [var.reports_origin] diff --git a/modules/static-site/megamek-rewrite.js b/modules/static-site/megamek-rewrite.js new file mode 100644 index 0000000..902aafc --- /dev/null +++ b/modules/static-site/megamek-rewrite.js @@ -0,0 +1,29 @@ +// Strip this site's namespace off a MegaMek asset request. +// +// /assets/megamek/0.51.0/data/images/units/meks/Atlas.png what a viewer asks for +// /0.51.0/data/images/units/meks/Atlas.png what the bucket holds +// +// The two halves of that path have different owners, which is why they are +// not the same string. Everything after the version is MegaMek's own layout, +// copied out of a release unchanged so that anything holding a path from an +// install can build the URL. `/assets/megamek` is ours, and a bucket that +// mirrors MegaMek releases should not carry our URL scheme inside it. +// +// Viewer request rather than origin request, so the rewritten URI is the cache +// key as well: two viewers asking for the same sprite are one object at the +// edge. +// +// Only a literal ASCII prefix is removed. Whatever follows keeps its +// encoding, which matters here more than it looks - 6,420 of MegaMek's 6,995 +// unit images have a space or a capital in the name. + +function handler(event) { + var request = event.request; + var prefix = '/assets/megamek'; + + if (request.uri.substring(0, prefix.length) === prefix) { + request.uri = request.uri.substring(prefix.length); + } + + return request; +} diff --git a/modules/static-site/variables.tf b/modules/static-site/variables.tf index 2a6516c..1f334b1 100644 --- a/modules/static-site/variables.tf +++ b/modules/static-site/variables.tf @@ -51,3 +51,21 @@ variable "log_retention_days" { type = number default = 90 } + +variable "assets_bucket_domain" { + description = "Regional domain of the MegaMek art bucket, served under /mm/*. Empty leaves the origin and the behaviour out." + type = string + default = "" +} + +variable "assets_bucket_id" { + description = "Name of that bucket, so this module can write the policy granting its own distribution read access." + type = string + default = "" +} + +variable "assets_bucket_arn" { + description = "ARN of that bucket, for the same policy." + type = string + default = "" +} -- 2.51.2