diff --git a/TODO.md b/TODO.md index 18c2904..00ed5ee 100644 --- a/TODO.md +++ b/TODO.md @@ -38,6 +38,11 @@ compute of its own yet. changes, so most reads are edge hits - but a cold URL 502s, and that 502 is no longer cached. Whatever raises its hand for the api host should raise it for this too. +- [ ] **Nothing says which index a deployed site expects.** The site pins a + MegaMek version in its own source and fetches `/assets/helm//…`; + nothing here fails when that version has no index published. The check + belongs at deploy time, where the release id and the bucket are both in + reach. - [ ] **Nothing prunes an old MegaMek's art.** Each release writes a prefix in the assets-megamek bucket and nothing removes one, deliberately — a match started under the old image still draws from it. 39MB a version is cheap enough diff --git a/docs/runbook.md b/docs/runbook.md index b732810..d52439a 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -244,6 +244,26 @@ through `mekset.txt`, and `helm art --megamek ` resolves that to the paths this sync uploads. `cargo test -p helm-unitfile -- --ignored` in helm is the check that every file the mapping names is one the release ships. +## helm's index, when it changes + +The unit index a force-building screen filters on is generated by helm and +published beside the art it describes: + + helm index --megamek --bridge-dir --out units.json # in helm + scripts/assets-helm.sh --dry-run 0.51.0 units.json + scripts/assets-helm.sh 0.51.0 units.json + + https://lance.blue/assets/helm/0.51.0/units.json + +The version argument is checked against what the file says it describes, and a +mismatch stops the upload: an index published under the wrong release is the +one failure here that looks fine and is not, because a screen would fetch it +and then ask for sprites under a release that never had them. + +Unlike the art, this is ours and gets regenerated — a new helm, a corrected +figure — so the script invalidates the path it overwrote. That is why it needs +the distribution as well as the bucket. + ## Starting a match by hand Headquarters will do this eventually. Until it exists, this is how to run one diff --git a/envs/lance.blue/main.tf b/envs/lance.blue/main.tf index 3223822..e408890 100644 --- a/envs/lance.blue/main.tf +++ b/envs/lance.blue/main.tf @@ -182,13 +182,24 @@ module "api_host" { # 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" + source = "../../modules/asset-bucket" bucket_name = "${local.project}-assets-megamek-${local.env}-${data.aws_caller_identity.current.account_id}" tags = { Component = "assets-megamek", Environment = local.env } } +# helm's generated index of MegaMek's unit library: what a force-building +# screen filters on, keyed by the MegaMek release it describes so it cannot +# drift from the art beside it. scripts/assets-helm.sh publishes one. +module "assets_helm" { + source = "../../modules/asset-bucket" + + bucket_name = "${local.project}-assets-helm-${local.env}-${data.aws_caller_identity.current.account_id}" + + tags = { Component = "assets-helm", Environment = local.env } +} + module "static_site" { source = "../../modules/static-site" @@ -203,16 +214,23 @@ module "static_site" { # site's own distribution, so a shared match is a lance.blue link. reports_origin = "api.${var.domain_name}" - # MegaMek's files under /assets/megamek/, from their own bucket through the - # same distribution. scripts/assets-megamek.sh is what fills it. - # - # assets_enabled is separate from the three values because a bucket being - # created in this same apply has no attributes to read yet, and a count that - # depends on one cannot be planned. - assets_enabled = true - 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 + # Third-party and generated files, each family in its own bucket, all of + # them under /assets// on this distribution. The keys are literal + # because every origin, behaviour and bucket policy is counted from them at + # plan time; the values are bucket attributes and may be unknown until the + # apply that creates one. + asset_origins = { + megamek = { + bucket_domain = module.assets_megamek.bucket_regional_domain_name + bucket_id = module.assets_megamek.bucket_name + bucket_arn = module.assets_megamek.bucket_arn + } + helm = { + bucket_domain = module.assets_helm.bucket_regional_domain_name + bucket_id = module.assets_helm.bucket_name + bucket_arn = module.assets_helm.bucket_arn + } + } providers = { aws = aws diff --git a/envs/lance.blue/outputs.tf b/envs/lance.blue/outputs.tf index 81685fa..cb84fd7 100644 --- a/envs/lance.blue/outputs.tf +++ b/envs/lance.blue/outputs.tf @@ -124,3 +124,8 @@ 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 } + +output "assets_helm_bucket" { + description = "Bucket holding helm's generated index, one prefix per MegaMek release. Filled by scripts/assets-helm.sh." + value = module.assets_helm.bucket_name +} diff --git a/modules/asset-bucket/main.tf b/modules/asset-bucket/main.tf new file mode 100644 index 0000000..2820f1a --- /dev/null +++ b/modules/asset-bucket/main.tf @@ -0,0 +1,72 @@ +# Somebody else's files, at the version we serve them from. +# +# One bucket per family — MegaMek's release tree, helm's generated index — and +# the same shape for each: a private bucket whose top level is versions and +# nothing else, read by the site's CloudFront through an origin access +# control, served under `/assets//`. +# +# 0.51.0/data/images/units/meks/Atlas.png +# https://lance.blue/assets/megamek/0.51.0/data/images/units/meks/Atlas.png +# +# The `/assets/` in front is this site's namespace rather than the +# family's, and the distribution takes it off at the edge, so what is stored +# is a mirror of the thing rather than a mirror of our URLs. A family whose +# paths mean something to somebody else — MegaMek's do; several things here +# are written against its data and already know which file they want — keeps +# them exactly. +# +# Version-prefixed and, for third-party bytes, 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. +# +# The licence travels with the bytes. MegaMek's tree is CC BY-NC-SA 4.0 and +# scripts/assets-megamek.sh copies LICENSE.assets in beside it, rather than +# leaving the terms in a README of ours. +# +# Private, like every other bucket here. The bucket policy that lets the +# distribution read it 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/asset-bucket/outputs.tf similarity index 100% rename from modules/assets-megamek/outputs.tf rename to modules/asset-bucket/outputs.tf diff --git a/modules/assets-megamek/variables.tf b/modules/asset-bucket/variables.tf similarity index 100% rename from modules/assets-megamek/variables.tf rename to modules/asset-bucket/variables.tf diff --git a/modules/assets-megamek/main.tf b/modules/assets-megamek/main.tf deleted file mode 100644 index c529658..0000000 --- a/modules/assets-megamek/main.tf +++ /dev/null @@ -1,83 +0,0 @@ -# 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/static-site/assets-rewrite.js b/modules/static-site/assets-rewrite.js new file mode 100644 index 0000000..4d4485e --- /dev/null +++ b/modules/static-site/assets-rewrite.js @@ -0,0 +1,35 @@ +// Strip this site's namespace off an 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 from the version rightwards belongs to +// whoever produced it — MegaMek's own layout, copied out of a release +// unchanged, so that anything holding a path from an install can build the +// URL — and `/assets/` is ours. A bucket that mirrors somebody else's +// files should not carry our URL scheme inside it. +// +// One function for every family rather than one each: what it removes is the +// first two segments, whatever the second one is, so adding a family is an +// origin and a behaviour and nothing here. +// +// 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 parts = request.uri.split('/'); + + // ['', 'assets', '', ...the rest] + if (parts.length > 3 && parts[1] === 'assets') { + request.uri = '/' + parts.slice(3).join('/'); + } + + return request; +} diff --git a/modules/static-site/main.tf b/modules/static-site/main.tf index cc45932..666b477 100644 --- a/modules/static-site/main.tf +++ b/modules/static-site/main.tf @@ -79,19 +79,19 @@ 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 +# The same grant as the site's own bucket, on each bucket the assets live in. +# Written here rather than in the asset-bucket 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_enabled ? 1 : 0 + for_each = var.asset_origins - bucket = var.assets_bucket_id - policy = data.aws_iam_policy_document.assets[0].json + bucket = each.value.bucket_id + policy = data.aws_iam_policy_document.assets[each.key].json } data "aws_iam_policy_document" "assets" { - count = var.assets_enabled ? 1 : 0 + for_each = var.asset_origins statement { sid = "CloudFrontRead" @@ -102,7 +102,7 @@ data "aws_iam_policy_document" "assets" { } actions = ["s3:GetObject"] - resources = ["${var.assets_bucket_arn}/*"] + resources = ["${each.value.bucket_arn}/*"] condition { test = "StringEquals" @@ -281,16 +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_enabled ? 1 : 0 +# Takes `/assets/` off the front of an asset request; see the file for +# why the address and the key differ by exactly that much. One function for +# every family: what it removes is the first two segments, whatever the second +# one is. +resource "aws_cloudfront_function" "assets_rewrite" { + count = length(var.asset_origins) > 0 ? 1 : 0 - name = "${var.name}-megamek-rewrite" + name = "${var.name}-assets-rewrite" runtime = "cloudfront-js-2.0" - comment = "Map /assets/megamek//... onto the bucket's /..." + comment = "Map /assets///... onto the bucket's /..." publish = true - code = file("${path.module}/megamek-rewrite.js") + code = file("${path.module}/assets-rewrite.js") } resource "aws_cloudfront_distribution" "site" { @@ -314,17 +316,16 @@ 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. + # Somebody else's files, each family in its own bucket: MegaMek's release + # tree, helm's generated index. A second origin rather than a subdomain, + # because the art is drawn onto a canvas beside the site's own images and + # same-origin costs no certificate, no DNS record and no CORS preflight. dynamic "origin" { - for_each = var.assets_enabled ? [1] : [] + for_each = var.asset_origins content { - domain_name = var.assets_bucket_domain - origin_id = "megamek" + domain_name = origin.value.bucket_domain + origin_id = "assets-${origin.key}" origin_access_control_id = aws_cloudfront_origin_access_control.site.id } } @@ -374,30 +375,26 @@ 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///`. Everything + # from the version rightwards belongs to whoever produced it, so anything + # that already knows which file it wants builds the address from what it + # holds rather than through a mapping table of ours; `/assets/` is + # this site's namespace and is taken off at the edge. # - # `/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. + # A behaviour per family rather than one `/assets/*`, because each family is + # its own bucket and its own origin. `/assets/` is also where Astro emits + # the site's own hashed build output; those names are content hashes, and a + # named family cannot collide with one. # - # `/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. + # Everything under here is immutable — a path carries the version that + # produced it — so the cache is told to keep it, and a new version is a new + # path rather than an invalidation. dynamic "ordered_cache_behavior" { - for_each = var.assets_enabled ? [1] : [] + for_each = var.asset_origins content { - path_pattern = "/assets/megamek/*" - target_origin_id = "megamek" + path_pattern = "/assets/${ordered_cache_behavior.key}/*" + target_origin_id = "assets-${ordered_cache_behavior.key}" viewer_protocol_policy = "redirect-to-https" allowed_methods = ["GET", "HEAD"] cached_methods = ["GET", "HEAD"] @@ -407,7 +404,7 @@ resource "aws_cloudfront_distribution" "site" { function_association { event_type = "viewer-request" - function_arn = aws_cloudfront_function.megamek_rewrite[0].arn + function_arn = aws_cloudfront_function.assets_rewrite[0].arn } } } diff --git a/modules/static-site/megamek-rewrite.js b/modules/static-site/megamek-rewrite.js deleted file mode 100644 index 902aafc..0000000 --- a/modules/static-site/megamek-rewrite.js +++ /dev/null @@ -1,29 +0,0 @@ -// 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 4cf6d98..25bb80f 100644 --- a/modules/static-site/variables.tf +++ b/modules/static-site/variables.tf @@ -52,35 +52,22 @@ variable "log_retention_days" { default = 90 } -# Whether the assets origin exists at all, as its own flag rather than as -# "is the domain empty". +# The asset families this distribution serves, keyed by the name that appears +# in the URL: `megamek` is served at `/assets/megamek/*`. # -# The three values below are the bucket's own attributes, and a bucket that -# does not exist yet has none of them: OpenTofu cannot know a regional domain -# name or an id at plan time, so a count or a for_each reading one is -# unresolvable on the apply that creates it. `Invalid count argument` is what -# that looks like, and the fix is a value that comes from configuration -# instead - the caller knows whether it wired a bucket up. -variable "assets_enabled" { - description = "Serve MegaMek's files under /assets/megamek/*, from the bucket named below." - type = bool - default = false -} - -variable "assets_bucket_domain" { - description = "Regional domain of the MegaMek assets bucket. Read only when assets_enabled." - 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. Read only when assets_enabled." - type = string - default = "" -} - -variable "assets_bucket_arn" { - description = "ARN of that bucket, for the same policy. Read only when assets_enabled." - type = string - default = "" +# A map rather than three flat values, and its **keys have to be literals**. +# Everything that counts here — an origin, a behaviour, a bucket policy — is +# built with `for_each` over this map, and OpenTofu has to know how many of +# each there are at plan time. A key that came from a bucket attribute would +# not be knowable on the apply that creates the bucket, which is the +# `Invalid count argument` this shape exists to avoid; the values inside may +# be unknown freely, because nothing counts on them. +variable "asset_origins" { + description = "Asset families to serve under /assets//, each from its own bucket. Keys must be literal strings." + type = map(object({ + bucket_domain = string + bucket_id = string + bucket_arn = string + })) + default = {} } diff --git a/scripts/assets-helm.sh b/scripts/assets-helm.sh new file mode 100755 index 0000000..e8018d3 --- /dev/null +++ b/scripts/assets-helm.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Publish helm's unit index for one MegaMek release. +# +# scripts/assets-helm.sh 0.51.0 units.json +# scripts/assets-helm.sh --dry-run 0.51.0 units.json +# +# The file comes from helm, which reads a MegaMek install and writes the +# reduced view a force-building screen filters on: +# +# helm index --megamek --bridge-dir --out units.json +# +# It is generated there rather than here because the version, the battle-value +# producer and the sprite resolution are all helm's; this only moves the file +# and says where it goes. +# +# Lands beside the art it describes, under the same release: +# +# s3://…-assets-helm-…/0.51.0/units.json +# https://lance.blue/assets/helm/0.51.0/units.json +# +# The version argument is checked against what the file says it describes. An +# index published under the wrong release is the one failure that would look +# fine and be wrong: a screen would fetch it, then ask the art bucket for +# sprites under a release that never had them. +# +# Unlike the art, this is ours and gets regenerated - a new helm, a corrected +# figure - so republishing is expected and the script invalidates the path it +# overwrote. The art bucket needs no such thing: a MegaMek release's bytes are +# written once. +# +# Credentials are the caller's, as everywhere else here. +set -euo pipefail + +source "$(dirname "$0")/lib/aws.sh" + +usage() { + echo "usage: scripts/assets-helm.sh [--dry-run] [env]" +} + +dry_run="" +while [ $# -gt 0 ]; do + case "$1" in + -n | --dry-run) + dry_run=1 + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) break ;; + esac +done + +version="${1:-}" +index="${2:-}" +env_name="${3:-lance.blue}" +if [ -z "$version" ] || [ -z "$index" ]; then + usage >&2 + exit 2 +fi +[ -f "$index" ] || { + echo "$index: no such file" >&2 + exit 2 +} + +# What the file says it is, from the file. jq is not a dependency here - the +# keys are written by serde_json and their form is stable - but the read is +# deliberately narrow so a malformed index fails here rather than in a browser. +says="$(grep -o '"megamek":"[^"]*"' "$index" | head -1 | cut -d'"' -f4)" +[ -n "$says" ] || { + echo "$index names no MegaMek version; is it a helm index?" >&2 + exit 1 +} +[ "$says" = "$version" ] || { + echo "$index describes MegaMek $says, not $version" >&2 + echo " publishing it under $version would point a screen at art that release never had" >&2 + exit 1 +} + +producer="$(grep -o '"stats_producer":"[^"]*"' "$index" | head -1 | cut -d'"' -f4)" +# The count inside "counts", not the array that follows it - grep -o rather +# than a greedy sed, which would match the last "units": on a one-line file +# and come back empty. +units="$(grep -o '"units":[0-9]\+' "$index" | head -1 | cut -d: -f2)" +echo "index: megamek $says, battle values from ${producer:-unknown}, ${units:-?} units" + +require_aws_credentials + +bucket="$(tofu -chdir="envs/$env_name" output -raw assets_helm_bucket)" +key="$version/units.json" +echo "publishing to s3://$bucket/$key" + +# Uploaded uncompressed and compressed at the edge: the behaviour serving +# /assets/helm/* has compression on, so a viewer gets gzip or brotli without +# this having to pick one, and without a Content-Encoding on the object that +# a range request or a non-negotiating client would trip over. +if [ -n "$dry_run" ]; then + echo "dry run: would upload $(wc -c <"$index") bytes" + exit 0 +fi + +aws s3 cp "$index" "s3://$bucket/$key" \ + --content-type application/json \ + --cache-control "public, max-age=31536000, immutable" \ + --no-progress + +# Immutable, and republished anyway - so the path is invalidated rather than +# left to a year of edge caching. Cheap and rare: one path, once per index. +distribution="$(tofu -chdir="envs/$env_name" output -raw site_distribution_id)" +invalidation="$(aws cloudfront create-invalidation \ + --distribution-id "$distribution" \ + --paths "/assets/helm/$key" \ + --query 'Invalidation.Id' --output text)" +echo "invalidated /assets/helm/$key ($invalidation)" + +echo "live: https://lance.blue/assets/helm/$key"