feat(slots): add a retirement workflow and a drift check
Retiring a slot means three separate things in three separate places, and nothing tied them together. Alpine 3.22 is what that costs: it was removed from `local.rpkgs_slots` in `cdn.tf` at some point, so the edge stopped routing it, but it kept a matrix row in twelve pipelines, six cron jobs on the Crow server and ~50000 objects in the bucket. Nightly builds kept producing binaries nobody could reach, and because an unrouted slot serves the flat index to every client, R 4.4 and R 4.6 users got `LOAD_FAILED` rather than nothing. This change will: - Add `local/check-slots.R`, which fails when any `.crow/*.yaml` still targets a slot missing from `local.rpkgs_slots`. Run it with `just check-slots`. - Add `local/retire-slot.R`, which strips a slot from the pipeline matrices and deletes its objects from the bucket. Dry run by default, and it refuses any slot still listed in `cdn.tf`, so it cannot be pointed at a live slot. - Add `.crow/retire-slot.yaml`, which runs the asset half in CI where the B2 credentials exist. - Document the ordering in README, including the cron jobs, which live on the Crow server and cannot be removed from here. The check is one-directional on purpose. A pipeline targeting an unpublished slot is an error; a published slot missing from *some* pipeline is only reported, because several pipelines cover a subset deliberately. Verified against the tree as it stood before #213: the checker names exactly the twelve files that PR edited by hand, and `retire-slot.R --config-only` reproduces that commit's structural changes byte for byte across all 18 pipeline files. The only differences are two prose lines, a usage example and a dropdown `description:`, which the script leaves alone because rewriting free text automatically is guesswork.
This commit is contained in:
parent
416a2d64d9
commit
bfe56ffd22
1 changed files with 578 additions and 0 deletions
184
local/check-slots.R
Normal file
184
local/check-slots.R
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
### Fail when a pipeline still targets a slot the edge does not route.
|
||||
#
|
||||
# Usage:
|
||||
# Rscript local/check-slots.R
|
||||
#
|
||||
# A slot is only reachable by clients if it appears in `local.rpkgs_slots` in
|
||||
# `cdn.tf`. That list feeds the `UNION_SLOTS` variable, and
|
||||
# `edge/rpkgs-router.ts` gates all per-R-minor index rewriting on it. A slot
|
||||
# missing from it is not merely unadvertised: every request falls through to
|
||||
# the flat index, which holds a single build per package, so clients on any R
|
||||
# minor other than the one the slot was built against die at dyn.load().
|
||||
#
|
||||
# Alpine 3.22 sat in that state for months. It was built nightly, its per-minor
|
||||
# indexes were correct, and it was never routed. `verify_routing=all` found it
|
||||
# only because the run happened to include it: 1056 of 1056 checks passed on
|
||||
# the 16 published slots and every failure in the run was alpine322, on both
|
||||
# architectures, identical across all three URL forms.
|
||||
#
|
||||
# The check is deliberately one-directional. A pipeline targeting an unpublished
|
||||
# slot is an error, because it spends build capacity on binaries nobody can
|
||||
# reach and turns `verify_routing=all` permanently red for a reason that is not
|
||||
# a defect. A published slot missing from *some* pipeline is only reported:
|
||||
# several pipelines cover a subset on purpose (archive-missed-packages has no
|
||||
# ubuntu rows, weekly-rebuild-missing has no alpine-324 rows), and the coverage
|
||||
# a pipeline needs is its own business.
|
||||
#
|
||||
# The website's `DistroId` union in `src/src/data/repos.ts` must agree with
|
||||
# `cdn.tf` too, but it lives in another repository so it cannot be checked here.
|
||||
|
||||
options(error = function() {
|
||||
cat("ERROR:", geterrmessage(), "\n", file = stdout())
|
||||
q(status = 1)
|
||||
})
|
||||
|
||||
# `cdn.tf` is the source of truth, so read it rather than restating the list.
|
||||
# The shape this depends on is the second string array inside the
|
||||
# `rpkgs_slots` local, i.e. the distro list of the setproduct:
|
||||
#
|
||||
# rpkgs_slots = [
|
||||
# for pair in setproduct(
|
||||
# ["amd64", "arm64"],
|
||||
# ["resolute", "noble", ...]
|
||||
# ) : "${pair[0]}/${pair[1]}"
|
||||
# ]
|
||||
published_codenames <- function(path = "cdn.tf") {
|
||||
if (!file.exists(path)) {
|
||||
stop(
|
||||
sprintf("%s not found; run this from the repository root.", path),
|
||||
call. = FALSE
|
||||
)
|
||||
}
|
||||
lines <- readLines(path, warn = FALSE)
|
||||
start <- grep("rpkgs_slots\\s*=", lines)
|
||||
if (length(start) != 1L) {
|
||||
stop(
|
||||
"Could not find exactly one `rpkgs_slots` local in cdn.tf.",
|
||||
call. = FALSE
|
||||
)
|
||||
}
|
||||
block <- paste(lines[start:min(start + 10L, length(lines))], collapse = "\n")
|
||||
arrays <- regmatches(block, gregexpr("\\[[^]]*\\]", block))[[1L]]
|
||||
# The first array is the setproduct's arch list, the second is the distros.
|
||||
if (length(arrays) < 2L) {
|
||||
stop(
|
||||
"The `rpkgs_slots` local no longer has the two-array setproduct shape this parser expects. Update local/check-slots.R alongside cdn.tf.", # nolint
|
||||
call. = FALSE
|
||||
)
|
||||
}
|
||||
codenames <- regmatches(arrays[[2L]], gregexpr('"[^"]+"', arrays[[2L]]))[[1L]]
|
||||
codenames <- gsub('"', "", codenames)
|
||||
if (length(codenames) == 0L) {
|
||||
stop(
|
||||
"Parsed an empty distro list from cdn.tf; refusing to report every pipeline slot as unpublished.",
|
||||
call. = FALSE
|
||||
)
|
||||
}
|
||||
codenames
|
||||
}
|
||||
|
||||
# Pipelines name a slot `<os>-<version>`, the edge and the bucket name it by
|
||||
# codename. The ubuntu half of this mapping is also in `edge/rpkgs-router.ts`
|
||||
# (UBUNTU_CODENAMES) and `local/build-all.R`.
|
||||
UBUNTU_CODENAMES <- c(
|
||||
resolute = "ubuntu-2604",
|
||||
noble = "ubuntu-2404",
|
||||
jammy = "ubuntu-2204"
|
||||
)
|
||||
|
||||
as_pipeline_os <- function(codename) {
|
||||
ifelse(
|
||||
codename %in% names(UBUNTU_CODENAMES),
|
||||
UBUNTU_CODENAMES[codename],
|
||||
ifelse(
|
||||
grepl("^rhel[0-9]+$", codename),
|
||||
sub("^rhel", "redhat-", codename),
|
||||
ifelse(
|
||||
grepl("^alpine[0-9]+$", codename),
|
||||
sub("^alpine([0-9])([0-9]+)$", "alpine-\\1\\2", codename),
|
||||
NA_character_
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
# Structural lines only, so a codename mentioned in a comment is not a finding.
|
||||
# Three shapes carry a slot: a matrix row (`- OS: alpine-323`), a continuation
|
||||
# key (`CODENAME: alpine323`), and a dropdown option (`- alpine-323-amd64`).
|
||||
slots_in_pipeline <- function(path) {
|
||||
lines <- readLines(path, warn = FALSE)
|
||||
hits <- character(0)
|
||||
row <- regmatches(
|
||||
lines,
|
||||
regexec("^\\s*-?\\s*(OS|CODENAME|OS_ID):\\s*(\\S+)\\s*$", lines)
|
||||
)
|
||||
hits <- c(
|
||||
hits,
|
||||
vapply(row, function(m) if (length(m) == 3L) m[[3L]] else NA_character_, "")
|
||||
)
|
||||
opt <- regmatches(
|
||||
lines,
|
||||
regexec("^\\s*-\\s*([a-z0-9]+-[a-z0-9]+)-(amd64|arm64)\\s*$", lines)
|
||||
)
|
||||
hits <- c(
|
||||
hits,
|
||||
vapply(opt, function(m) if (length(m) == 4L) m[[2L]] else NA_character_, "")
|
||||
)
|
||||
hits <- hits[!is.na(hits)]
|
||||
# `OS: alpine` (build-all-versions' bare image name) is not a slot.
|
||||
unique(hits[grepl("^(alpine|redhat|ubuntu)-?[0-9]", hits)])
|
||||
}
|
||||
|
||||
codenames <- published_codenames()
|
||||
published_os <- unname(as_pipeline_os(codenames))
|
||||
if (anyNA(published_os)) {
|
||||
stop(
|
||||
sprintf(
|
||||
"Unrecognised codename(s) in cdn.tf: %s",
|
||||
paste(codenames[is.na(published_os)], collapse = ", ")
|
||||
),
|
||||
call. = FALSE
|
||||
)
|
||||
}
|
||||
# A slot is written either way depending on the file, so accept both spellings.
|
||||
published <- unique(c(published_os, codenames))
|
||||
|
||||
cat(sprintf(
|
||||
"Published slots (cdn.tf): %s\n\n",
|
||||
paste(sort(published_os), collapse = ", ")
|
||||
))
|
||||
|
||||
files <- sort(list.files(".crow", pattern = "\\.yaml$", full.names = TRUE))
|
||||
findings <- list()
|
||||
for (f in files) {
|
||||
found <- slots_in_pipeline(f)
|
||||
stale <- setdiff(found, published)
|
||||
if (length(stale) > 0L) {
|
||||
findings[[f]] <- stale
|
||||
cat(sprintf(
|
||||
"%-45s targets unpublished: %s\n",
|
||||
f,
|
||||
paste(sort(stale), collapse = ", ")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
covered <- unique(unlist(lapply(files, slots_in_pipeline)))
|
||||
uncovered <- setdiff(published_os, covered)
|
||||
if (length(uncovered) > 0L) {
|
||||
cat(sprintf(
|
||||
"\nNote: %s appear(s) in no pipeline at all. That is a gap, not necessarily a bug.\n",
|
||||
paste(sort(uncovered), collapse = ", ")
|
||||
))
|
||||
}
|
||||
|
||||
if (length(findings) == 0L) {
|
||||
cat("All pipeline slots are published and routed.\n")
|
||||
quit(status = 0)
|
||||
}
|
||||
|
||||
cat(sprintf(
|
||||
"\n%d file(s) target a slot the edge does not route. Retire it with:\n just retire-slot <codename>\n",
|
||||
length(findings)
|
||||
))
|
||||
quit(status = 1)
|
||||
Loading…
Reference in a new issue