From d23875cccb21a4baab01ea18d3ee0e716e875e9b Mon Sep 17 00:00:00 2001 From: abtreece Date: Fri, 24 Apr 2026 20:52:42 -0500 Subject: [PATCH 1/6] feat: add GCS buckets for APT/YUM archive repositories --- terraform/repo_buckets.tf | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/terraform/repo_buckets.tf b/terraform/repo_buckets.tf index 26fc536..e077791 100644 --- a/terraform/repo_buckets.tf +++ b/terraform/repo_buckets.tf @@ -38,3 +38,32 @@ resource "google_storage_bucket_iam_binding" "server-edition-yum-repo-writable-b role = "roles/storage.objectAdmin" members = ["principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github-ci-deploy.name}/attribute.repository/fullstaq-ruby/server-edition"] } + +resource "google_storage_bucket" "server-edition-apt-repo-archive" { + depends_on = [google_project_service.storage-api] + name = "${var.gcloud_bucket_prefix}-server-edition-apt-repo-archive" + force_destroy = true + uniform_bucket_level_access = true + location = var.gcloud_storage_location +} + +resource "google_storage_bucket_iam_binding" "server-edition-apt-repo-archive-public-viewable" { + bucket = google_storage_bucket.server-edition-apt-repo-archive.self_link + role = "roles/storage.objectViewer" + members = ["allUsers"] +} + + +resource "google_storage_bucket" "server-edition-yum-repo-archive" { + depends_on = [google_project_service.storage-api] + name = "${var.gcloud_bucket_prefix}-server-edition-yum-repo-archive" + force_destroy = true + uniform_bucket_level_access = true + location = var.gcloud_storage_location +} + +resource "google_storage_bucket_iam_binding" "server-edition-yum-repo-archive-public-viewable" { + bucket = google_storage_bucket.server-edition-yum-repo-archive.self_link + role = "roles/storage.objectViewer" + members = ["allUsers"] +} From cb9de39c66599b4a5d086b2dcb94d21bb10b73f4 Mon Sep 17 00:00:00 2001 From: abtreece Date: Fri, 24 Apr 2026 20:52:50 -0500 Subject: [PATCH 2/6] feat: add DNS zones for APT/YUM archive subdomains --- terraform/dns.tf | 72 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/terraform/dns.tf b/terraform/dns.tf index eb72fc0..18c973f 100644 --- a/terraform/dns.tf +++ b/terraform/dns.tf @@ -149,3 +149,75 @@ resource "azurerm_dns_aaaa_record" "yum" { records = [var.backend_server_ipv6] ttl = 86400 } + + +resource "azurerm_dns_zone" "apt-archive" { + name = "apt-archive.${var.dns_name}" + resource_group_name = "fullstaq-ruby-infra-maintainers" +} + +resource "azurerm_role_assignment" "caddy-update-dns-apt-archive" { + scope = azurerm_dns_zone.apt-archive.id + role_definition_name = "DNS Zone Contributor" + principal_id = azuread_service_principal.caddy.object_id +} + +resource "azurerm_dns_ns_record" "apt-archive" { + name = "apt-archive" + zone_name = azurerm_dns_zone.website.name + resource_group_name = azurerm_dns_zone.website.resource_group_name + ttl = 86400 + records = azurerm_dns_zone.apt-archive.name_servers +} + +resource "azurerm_dns_a_record" "apt-archive" { + name = "@" + zone_name = azurerm_dns_zone.apt-archive.name + resource_group_name = azurerm_dns_zone.apt-archive.resource_group_name + records = [var.backend_server_ipv4] + ttl = 86400 +} + +resource "azurerm_dns_aaaa_record" "apt-archive" { + name = "@" + zone_name = azurerm_dns_zone.apt-archive.name + resource_group_name = azurerm_dns_zone.apt-archive.resource_group_name + records = [var.backend_server_ipv6] + ttl = 86400 +} + + +resource "azurerm_dns_zone" "yum-archive" { + name = "yum-archive.${var.dns_name}" + resource_group_name = "fullstaq-ruby-infra-maintainers" +} + +resource "azurerm_role_assignment" "caddy-update-dns-yum-archive" { + scope = azurerm_dns_zone.yum-archive.id + role_definition_name = "DNS Zone Contributor" + principal_id = azuread_service_principal.caddy.object_id +} + +resource "azurerm_dns_ns_record" "yum-archive" { + name = "yum-archive" + zone_name = azurerm_dns_zone.website.name + resource_group_name = azurerm_dns_zone.website.resource_group_name + ttl = 86400 + records = azurerm_dns_zone.yum-archive.name_servers +} + +resource "azurerm_dns_a_record" "yum-archive" { + name = "@" + zone_name = azurerm_dns_zone.yum-archive.name + resource_group_name = azurerm_dns_zone.yum-archive.resource_group_name + records = [var.backend_server_ipv4] + ttl = 86400 +} + +resource "azurerm_dns_aaaa_record" "yum-archive" { + name = "@" + zone_name = azurerm_dns_zone.yum-archive.name + resource_group_name = azurerm_dns_zone.yum-archive.resource_group_name + records = [var.backend_server_ipv6] + ttl = 86400 +} From 7e7c90ee3977f0c7c77809fc1ec92bc6cca91b58 Mon Sep 17 00:00:00 2001 From: abtreece Date: Fri, 24 Apr 2026 20:52:59 -0500 Subject: [PATCH 3/6] feat: add Caddy config and version query for archive repos --- ansible/files/Caddyfile | 32 +++++++++++++++++++++ ansible/files/query-latest-repo-versions.rb | 15 ++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/ansible/files/Caddyfile b/ansible/files/Caddyfile index 1e35bbe..30fa547 100644 --- a/ansible/files/Caddyfile +++ b/ansible/files/Caddyfile @@ -48,3 +48,35 @@ yum.{$DOMAIN_NAME} { redir https://storage.googleapis.com/{$GCLOUD_BUCKET_PREFIX}-server-edition-yum-repo/versions/{$YUM_LATEST_VERSION}/public{uri} } } + +apt-archive.{$DOMAIN_NAME} { + tls { + dns azure { + tenant_id {$AZURE_TENANT_ID} + subscription_id {$AZURE_SUBSCRIPTION_ID} + resource_group_name fullstaq-ruby-infra-maintainers + client_id {$AZURE_DNS_UPDATER_CLIENT_ID} + client_secret {$AZURE_DNS_UPDATER_CLIENT_SECRET} + } + } + encode gzip + handle { + redir https://storage.googleapis.com/{$GCLOUD_BUCKET_PREFIX}-server-edition-apt-repo-archive/versions/{$APT_ARCHIVE_LATEST_VERSION}/public{uri} + } +} + +yum-archive.{$DOMAIN_NAME} { + tls { + dns azure { + tenant_id {$AZURE_TENANT_ID} + subscription_id {$AZURE_SUBSCRIPTION_ID} + resource_group_name fullstaq-ruby-infra-maintainers + client_id {$AZURE_DNS_UPDATER_CLIENT_ID} + client_secret {$AZURE_DNS_UPDATER_CLIENT_SECRET} + } + } + encode gzip + handle { + redir https://storage.googleapis.com/{$GCLOUD_BUCKET_PREFIX}-server-edition-yum-repo-archive/versions/{$YUM_ARCHIVE_LATEST_VERSION}/public{uri} + } +} diff --git a/ansible/files/query-latest-repo-versions.rb b/ansible/files/query-latest-repo-versions.rb index 3a87c42..f705140 100755 --- a/ansible/files/query-latest-repo-versions.rb +++ b/ansible/files/query-latest-repo-versions.rb @@ -6,12 +6,15 @@ def main open_io(ARGV[0]) do |io| query_repo_version('apt', io) query_repo_version('yum', io) + query_repo_version('apt-archive', io, suffix: '-archive') + query_repo_version('yum-archive', io, suffix: '-archive') io.puts "REPO_QUERY_TIME=#{Time.now.to_f}" end end -def query_repo_version(type, output) - uri = URI("https://storage.googleapis.com/#{require_env(:GCLOUD_BUCKET_PREFIX)}-server-edition-#{type}-repo/versions/latest_version.txt") +def query_repo_version(type, output, suffix: '') + bucket_type = type.gsub('-archive', '') + uri = URI("https://storage.googleapis.com/#{require_env(:GCLOUD_BUCKET_PREFIX)}-server-edition-#{bucket_type}-repo#{suffix}/versions/latest_version.txt") STDERR.puts "Querying #{uri}..." req = Net::HTTP::Get.new(uri) @@ -22,11 +25,17 @@ def query_repo_version(type, output) end if resp.code.to_i / 100 != 2 + if suffix != '' + STDERR.puts "Warning: #{type} repo not found (#{resp.code}), skipping" + output.puts "#{type.upcase.gsub('-', '_')}_LATEST_VERSION=0" + return + end abort("Failed to query #{uri}: #{resp.code} #{resp.body}") end + env_key = type.upcase.gsub('-', '_') STDERR.puts "#{type} latest version: #{resp.body}" - output.puts "#{type.upcase}_LATEST_VERSION=#{resp.body}" + output.puts "#{env_key}_LATEST_VERSION=#{resp.body}" end def open_io(path) From 4f829ce4621102371ac00315fede6d2cdf295a1d Mon Sep 17 00:00:00 2001 From: abtreece Date: Mon, 27 Apr 2026 21:50:07 -0500 Subject: [PATCH 4/6] fix: archive version lookup must fail hard on non-404 errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous code degraded any non-2xx response (auth errors, 5xx, redirects) to LATEST_VERSION=0 whenever the suffix indicated an archive bucket. That silently turns a transient outage or misconfiguration into Caddy redirecting clients to /versions/0/... 404s — broken behavior masquerading as valid config. Only treat HTTP 404 as the legitimate "archive not yet populated" case; surface every other failure. --- ansible/files/query-latest-repo-versions.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ansible/files/query-latest-repo-versions.rb b/ansible/files/query-latest-repo-versions.rb index f705140..49d3e1d 100755 --- a/ansible/files/query-latest-repo-versions.rb +++ b/ansible/files/query-latest-repo-versions.rb @@ -25,8 +25,12 @@ def query_repo_version(type, output, suffix: '') end if resp.code.to_i / 100 != 2 - if suffix != '' - STDERR.puts "Warning: #{type} repo not found (#{resp.code}), skipping" + # Archive bucket may legitimately not exist before the first migration runs. + # Only treat 404 as the "not yet populated" case; any other non-2xx + # (auth, 5xx, redirects) is a real failure and must surface — silently + # falling back to version 0 would point clients at /versions/0/... 404s. + if suffix != '' && resp.code.to_i == 404 + STDERR.puts "Warning: #{type} repo not found (404), skipping" output.puts "#{type.upcase.gsub('-', '_')}_LATEST_VERSION=0" return end From b58dae6c78f04b0d3bbb6e48b00231e2a756dfe9 Mon Sep 17 00:00:00 2001 From: abtreece Date: Fri, 22 May 2026 15:03:11 -0500 Subject: [PATCH 5/6] docs: add archive repo buckets section to infrastructure overview Documents the new APT/YUM archive infrastructure introduced in this PR: the two new public-read GCS buckets, the deliberate absence of CI write access (frozen-mirror invariant enforced in IAM), the Azure DNS zones and apex NS delegation for the archive subdomains, and the 404 fallback behavior in query-latest-repo-versions.rb that lets the web server start cleanly before the first migration runs. Addresses FooBarWidget's note on PR #57 that #47's changes should ship with their own documentation. --- docs/infrastructure-overview.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/infrastructure-overview.md b/docs/infrastructure-overview.md index f95bab9..131be1c 100644 --- a/docs/infrastructure-overview.md +++ b/docs/infrastructure-overview.md @@ -104,6 +104,21 @@ The Server Edition's APT and YUM repositories are stored inside these buckets. T Users don't access these buckets directly. Instead, they access `apt.fullstaqruby.org` and `yum.fullstaqruby.org` (served by the Nginx web servers), which redirect to these buckets. +## Server Edition APT & YUM archive repo buckets + +- Administered by role: Infra Maintainers + +The Server Edition's APT and YUM archive repositories — frozen mirrors for end-of-life distribution packages — are stored in these buckets: + +- `fsruby-server-edition-apt-repo-archive` +- `fsruby-server-edition-yum-repo-archive` + +Both buckets are publicly readable. Unlike the live APT/YUM repo buckets, the archive buckets deliberately have **no CI write access** — the frozen-mirror invariant is enforced in IAM rather than by convention. Migration into these buckets happens out-of-band via scripts in the [server-edition repository](https://github.com/fullstaq-ruby/server-edition). + +Users access these archives via `apt-archive.fullstaqruby.org` and `yum-archive.fullstaqruby.org`, which redirect to the bucket contents. Each archive subdomain has its own Azure DNS zone, delegated via NS records in the `fullstaqruby.org` apex zone, with A/AAAA records pointing at the backend server. + +Before the first migration completes, the `latest_version.txt` file in each archive bucket may not yet exist. The `query-latest-repo-versions.rb` script handles this case explicitly: archive buckets that return 404 fall back to version 0 (`APT_ARCHIVE_LATEST_VERSION=0` / `YUM_ARCHIVE_LATEST_VERSION=0`), allowing the web server to start cleanly. Any other non-2xx response is still treated as a hard failure. + ## Container registry - Administered by role: Infra Maintainers From c1814eb0af62c450c5f299f26ca9ff973d386c8b Mon Sep 17 00:00:00 2001 From: abtreece Date: Fri, 22 May 2026 20:16:25 -0500 Subject: [PATCH 6/6] refactor(archive): rename archive buckets to -archive-repo Address review feedback on PR #47 from @noahssarcastic and @FooBarWidget: the awkward `suffix:` parameter in query-latest-repo-versions.rb was a downstream symptom of inconsistent bucket naming (`-repo-archive` broke the `-repo` terminal-suffix convention used by the live buckets). Rename: -server-edition-apt-repo-archive -> -apt-archive-repo -server-edition-yum-repo-archive -> -yum-archive-repo Safe to do now -- the buckets don't exist yet (PR not deployed). With consistent naming, query_repo_version becomes a direct lookup: `type` is the bucket-name slot, and the only special-casing for archives is a semantic `allow_missing:` flag for the pre-migration bootstrap window. No string manipulation, no latent type/suffix mismatch footgun. Coordination: server-edition#191's CI workflow sets ARCHIVE_REPO_BUCKET_NAME -- that value will need to match the new bucket name before the first migration runs. --- ansible/files/Caddyfile | 4 ++-- ansible/files/query-latest-repo-versions.rb | 24 ++++++++++----------- docs/infrastructure-overview.md | 4 ++-- terraform/repo_buckets.tf | 16 +++++++------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/ansible/files/Caddyfile b/ansible/files/Caddyfile index 30fa547..cc87894 100644 --- a/ansible/files/Caddyfile +++ b/ansible/files/Caddyfile @@ -61,7 +61,7 @@ apt-archive.{$DOMAIN_NAME} { } encode gzip handle { - redir https://storage.googleapis.com/{$GCLOUD_BUCKET_PREFIX}-server-edition-apt-repo-archive/versions/{$APT_ARCHIVE_LATEST_VERSION}/public{uri} + redir https://storage.googleapis.com/{$GCLOUD_BUCKET_PREFIX}-server-edition-apt-archive-repo/versions/{$APT_ARCHIVE_LATEST_VERSION}/public{uri} } } @@ -77,6 +77,6 @@ yum-archive.{$DOMAIN_NAME} { } encode gzip handle { - redir https://storage.googleapis.com/{$GCLOUD_BUCKET_PREFIX}-server-edition-yum-repo-archive/versions/{$YUM_ARCHIVE_LATEST_VERSION}/public{uri} + redir https://storage.googleapis.com/{$GCLOUD_BUCKET_PREFIX}-server-edition-yum-archive-repo/versions/{$YUM_ARCHIVE_LATEST_VERSION}/public{uri} } } diff --git a/ansible/files/query-latest-repo-versions.rb b/ansible/files/query-latest-repo-versions.rb index 49d3e1d..8a61300 100755 --- a/ansible/files/query-latest-repo-versions.rb +++ b/ansible/files/query-latest-repo-versions.rb @@ -6,15 +6,15 @@ def main open_io(ARGV[0]) do |io| query_repo_version('apt', io) query_repo_version('yum', io) - query_repo_version('apt-archive', io, suffix: '-archive') - query_repo_version('yum-archive', io, suffix: '-archive') + query_repo_version('apt-archive', io, allow_missing: true) + query_repo_version('yum-archive', io, allow_missing: true) io.puts "REPO_QUERY_TIME=#{Time.now.to_f}" end end -def query_repo_version(type, output, suffix: '') - bucket_type = type.gsub('-archive', '') - uri = URI("https://storage.googleapis.com/#{require_env(:GCLOUD_BUCKET_PREFIX)}-server-edition-#{bucket_type}-repo#{suffix}/versions/latest_version.txt") +def query_repo_version(type, output, allow_missing: false) + env_key = type.upcase.tr('-', '_') + uri = URI("https://storage.googleapis.com/#{require_env(:GCLOUD_BUCKET_PREFIX)}-server-edition-#{type}-repo/versions/latest_version.txt") STDERR.puts "Querying #{uri}..." req = Net::HTTP::Get.new(uri) @@ -25,19 +25,19 @@ def query_repo_version(type, output, suffix: '') end if resp.code.to_i / 100 != 2 - # Archive bucket may legitimately not exist before the first migration runs. - # Only treat 404 as the "not yet populated" case; any other non-2xx - # (auth, 5xx, redirects) is a real failure and must surface — silently - # falling back to version 0 would point clients at /versions/0/... 404s. - if suffix != '' && resp.code.to_i == 404 + # Archive buckets may legitimately not be populated before the first + # migration runs. Only treat 404 as the "not yet populated" case; any + # other non-2xx (auth, 5xx, redirects) is a real failure and must surface + # — silently falling back to version 0 would point clients at + # /versions/0/... 404s. + if allow_missing && resp.code.to_i == 404 STDERR.puts "Warning: #{type} repo not found (404), skipping" - output.puts "#{type.upcase.gsub('-', '_')}_LATEST_VERSION=0" + output.puts "#{env_key}_LATEST_VERSION=0" return end abort("Failed to query #{uri}: #{resp.code} #{resp.body}") end - env_key = type.upcase.gsub('-', '_') STDERR.puts "#{type} latest version: #{resp.body}" output.puts "#{env_key}_LATEST_VERSION=#{resp.body}" end diff --git a/docs/infrastructure-overview.md b/docs/infrastructure-overview.md index 131be1c..79abc72 100644 --- a/docs/infrastructure-overview.md +++ b/docs/infrastructure-overview.md @@ -110,8 +110,8 @@ Users don't access these buckets directly. Instead, they access `apt.fullstaqrub The Server Edition's APT and YUM archive repositories — frozen mirrors for end-of-life distribution packages — are stored in these buckets: -- `fsruby-server-edition-apt-repo-archive` -- `fsruby-server-edition-yum-repo-archive` +- `fsruby-server-edition-apt-archive-repo` +- `fsruby-server-edition-yum-archive-repo` Both buckets are publicly readable. Unlike the live APT/YUM repo buckets, the archive buckets deliberately have **no CI write access** — the frozen-mirror invariant is enforced in IAM rather than by convention. Migration into these buckets happens out-of-band via scripts in the [server-edition repository](https://github.com/fullstaq-ruby/server-edition). diff --git a/terraform/repo_buckets.tf b/terraform/repo_buckets.tf index e077791..09d6050 100644 --- a/terraform/repo_buckets.tf +++ b/terraform/repo_buckets.tf @@ -39,31 +39,31 @@ resource "google_storage_bucket_iam_binding" "server-edition-yum-repo-writable-b members = ["principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github-ci-deploy.name}/attribute.repository/fullstaq-ruby/server-edition"] } -resource "google_storage_bucket" "server-edition-apt-repo-archive" { +resource "google_storage_bucket" "server-edition-apt-archive-repo" { depends_on = [google_project_service.storage-api] - name = "${var.gcloud_bucket_prefix}-server-edition-apt-repo-archive" + name = "${var.gcloud_bucket_prefix}-server-edition-apt-archive-repo" force_destroy = true uniform_bucket_level_access = true location = var.gcloud_storage_location } -resource "google_storage_bucket_iam_binding" "server-edition-apt-repo-archive-public-viewable" { - bucket = google_storage_bucket.server-edition-apt-repo-archive.self_link +resource "google_storage_bucket_iam_binding" "server-edition-apt-archive-repo-public-viewable" { + bucket = google_storage_bucket.server-edition-apt-archive-repo.self_link role = "roles/storage.objectViewer" members = ["allUsers"] } -resource "google_storage_bucket" "server-edition-yum-repo-archive" { +resource "google_storage_bucket" "server-edition-yum-archive-repo" { depends_on = [google_project_service.storage-api] - name = "${var.gcloud_bucket_prefix}-server-edition-yum-repo-archive" + name = "${var.gcloud_bucket_prefix}-server-edition-yum-archive-repo" force_destroy = true uniform_bucket_level_access = true location = var.gcloud_storage_location } -resource "google_storage_bucket_iam_binding" "server-edition-yum-repo-archive-public-viewable" { - bucket = google_storage_bucket.server-edition-yum-repo-archive.self_link +resource "google_storage_bucket_iam_binding" "server-edition-yum-archive-repo-public-viewable" { + bucket = google_storage_bucket.server-edition-yum-archive-repo.self_link role = "roles/storage.objectViewer" members = ["allUsers"] }