project-nomad/admin/app/utils/platform_metadata.ts
chriscrosstalk 0891d176e5
feat(benchmark): official multi-arch sysbench, resolved digest, platform metadata (#1158)
Three changes that together let ARM hardware appear on the leaderboard honestly.
Shipping them separately would leave ARM half-supported either way: without the
image a Pi cannot submit at all, and without the architecture field it submits
but is indistinguishable from x86.

1. PIN THE OFFICIAL MULTI-ARCH SYSBENCH IMAGE

severalnines/sysbench publishes amd64 only, so ARM hosts could not run the
System Benchmark at all — not a graceful failure, the container simply cannot
execute. Apple Silicon could only run it under Rosetta emulation, which distorts
the measurement it is taking, and that is what drove a community macOS fork to
substitute a different benchmark and submit incomparable numbers.

Swaps to ghcr.io/crosstalk-solutions/nomad-sysbench (Debian 12 + sysbench
1.0.20+ds-5, built for linux/amd64 + linux/arm64). One digest covers both
architectures; verified that pulling the pinned manifest-list digest resolves to
arm64 on a Raspberry Pi 5 and amd64 on x86, and that RepoDigests reports the
same manifest-list digest on both — so a single allowlist entry serves both.

No rescoring: 1.0.17 -> 1.0.20 measured 1.25% apart on identical hardware with
identical flags (7170.18 vs 7259.56 events/sec), inside run-to-run noise and
~0.3% on a composite. Both digests are allowlisted server-side, so the fleet can
cross over gradually.

2. REPORT THE DIGEST ACTUALLY RESOLVED

The submission previously sent SYSBENCH_DIGEST, the constant the client was
compiled with. The leaderboard validates that field, but a constant attests to
how a client was BUILT rather than what it RAN, so any build inherits a valid
value simply by carrying the same source.

Now reads it back from the image. Uses RepoDigests (the manifest digest we
pulled by), never Id — Id is the config digest, differs per architecture, and
would never match the allowlist. Falls back to the constant if inspection yields
nothing usable, so a benchmark never fails over provenance metadata.

Still forgeable, and always will be with an open-source client. It moves the bar
from "no effort" to "deliberate", which is the distinction that matters when
judging whether a submission is a mistake or a choice.

3. RECORD CPU ARCHITECTURE AND OS

The leaderboard is a single board across instruction sets by design, with
disclosure as the fairness mechanism. Without an architecture field an ARM result
sits unlabelled beside x86 — exactly what the disclosure exists to prevent.

All three fields come from the Docker daemon, reusing the docker.info() call
_detectRunEnvironment already makes. That is deliberate: inside the admin
container os.arch() and si.osInfo() describe the CONTAINER, not the host being
benchmarked.

  cpu_architecture  Architecture       x86_64 -> amd64, aarch64 -> arm64
  os_version        OSVersion          '24.04' (already structured, no parsing)
  os_name           OperatingSystem    'Ubuntu 24.04.4 LTS' minus the version

run_environment is kept rather than replaced: "which distro" and "is this
virtualised" are different questions, and WSL2 is a real performance factor.

String handling lives in app/utils/platform_metadata.ts with unit tests, matching
the amd_hsa_override convention, so it is testable without a Docker daemon.
Unknown architectures pass through verbatim rather than being guessed at, and
os_name falls back to the full description whenever the version is missing or
absent from it — an over-long name is harmless, a wrong one is not.

Columns are nullable and the submission fields optional, so results recorded
before this shipped remain submittable.

Closes #1156
Refs #1151
2026-07-27 10:26:38 -07:00

69 lines
2.7 KiB
TypeScript

/**
* Pure helpers for turning the Docker daemon's platform strings into the fields
* the benchmark submission carries.
*
* These read the HOST's platform, which is the entire point: inside the admin
* container `os.arch()` and `si.osInfo()` describe the container, not the
* machine being benchmarked. `BenchmarkService` delegates to these so the string
* handling is unit-testable without a Docker daemon.
*
* Observed daemon output across the test fleet:
*
* Architecture 'x86_64' | 'aarch64'
* OSVersion '24.04' | '26.04'
* OperatingSystem 'Ubuntu 24.04.4 LTS' | 'Ubuntu 26.04 LTS'
*/
/**
* Canonicalise the daemon's architecture string to the OCI platform names used
* everywhere else in the project (image manifests, install docs, the
* leaderboard).
*
* Docker reports `x86_64` / `aarch64`; images and the board talk in `amd64` /
* `arm64`. A fixed two-way map rather than a general normalisation table: these
* are the only architectures NOMAD targets, and anything unrecognised passes
* through verbatim rather than being guessed at, so an unexpected platform shows
* up honestly instead of mislabelled.
*/
export function normalizeArchitecture(raw: string): string {
const map: Record<string, string> = {
x86_64: 'amd64',
amd64: 'amd64',
aarch64: 'arm64',
arm64: 'arm64',
}
const key = raw.trim().toLowerCase()
return map[key] ?? raw.trim()
}
/**
* Split the distro name out of the daemon's free-form OperatingSystem string.
*
* `OperatingSystem` is a description ('Ubuntu 24.04.4 LTS') while `OSVersion` is
* structured ('24.04'). Taking the text before the version yields the name
* without hand-maintaining a list of distributions:
*
* 'Ubuntu 24.04.4 LTS' + '24.04' -> 'Ubuntu'
* 'Ubuntu 26.04 LTS' + '26.04' -> 'Ubuntu'
* 'Debian GNU/Linux 12 (bookworm)' + '12' -> 'Debian GNU/Linux'
*
* Falls back to the full description whenever the version is missing, empty, or
* doesn't appear in the string. An over-long name is harmless; a wrong one is
* not, and silently truncating an unfamiliar distro would be worse than leaving
* it verbose.
*/
export function deriveOsName(operatingSystem: string, osVersion: string | null): string {
const description = operatingSystem.trim()
if (!osVersion) return description
const version = osVersion.trim()
if (version === '') return description
const idx = description.indexOf(version)
// idx === 0 means the string starts with the version and has no name to take.
if (idx <= 0) return description
const name = description.slice(0, idx).trim()
return name.length > 0 ? name : description
}