mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-05 11:08:25 +00:00
The released .deb installs cleanly on Debian 12 and Ubuntu 22.04 and the daemon then cannot start, with the loader reporting GLIBC_2.39 not found. Three binaries are affected, fips, fipstop and fips-gateway; fipsctl runs, which is why it stayed quiet, since an install checked by running fipsctl gets a clean answer while the daemon is dead. It is not a v0.5.0 regression: every release artifact from v0.3.0 onward carries the same floor and the same unversioned dependency. The cause is the build machine. Rust's standard library references pidfd_spawnp and pidfd_getpid as weak undefined symbols behind a runtime check, so a binary should fall back where the C library lacks them. Linking against a C library that has them records a hard version dependency instead, and the loader refuses the image on that entry alone. No Rust changed here. One script now produces the Linux artifacts. It builds in a container pinned to the oldest distribution still supported for free by its distributor, named with the floor in packaging/build-floor.env, and runs the floor check on the package it produced, so every producer is gated rather than one workflow. The floor guard reads readelf's Version needs section: the obvious objdump formulation returns 2.2.5 for the shipped fips and would have passed every affected release. The script prints the package path as the only thing on its stdout, which is what lets a caller take it without parsing, and it takes --features. Both required care. The container's own stdout reaches the caller, so the build runs with its output on stderr; without that, a caller using a plain command substitution captures four lines of build chatter along with the path. And a feature build must keep the +<features> marker that distinguishes it from the default build of the same commit, or dpkg sees two packages at one version and a revert silently no-ops. The version is derived on the host, because the image has no git and the source is mounted read-only, so build-deb.sh now applies that marker to an explicit version as well as to one it derives. Both runners now build once through that script and install the artifact. The five deb-install legs previously built their own package each, so one CI run performed five complete release builds and four were waste; they now live in a job of their own that downloads one built package, which also stops the rest of the integration matrix waiting on it. The parity guard read one hardcoded job and now sweeps every job's matrix. The release workflow builds both architectures through the same script, and the systemd tarball takes its binaries out of that package instead of from a second, unchecked set on the runner, then is floor-checked after the strip. Cargo.toml derives the dependency with $auto rather than stating a bare libc6 that nothing can fail. Note the ordering this implies for any pipeline that builds on a current distribution: until it builds through this script, its packages will declare libc6 (>= 2.39). Measured: the container build produces four binaries at 2.34, and one artifact passes all five distributions, 95 checks, in about two minutes. The floor check fails the released 0.5.0 package on three binaries and passes this one. A profiling build produces fips_0.5.1~dev+git<date>.<sha>+profiling-1_amd64.deb.
293 lines
13 KiB
Bash
Executable File
293 lines
13 KiB
Bash
Executable File
#!/bin/bash
|
|
# ── CI parity invariant guard ───────────────────────────────────────────────
|
|
# The GitHub integration matrices (.github/workflows/ci.yml, swept across every
|
|
# job) and the local default suite set (ci-local.sh) MUST run the same
|
|
# integration suites, EXCEPT for the deliberate local-only entries listed below.
|
|
# Adding a suite to one runner without the other means "local green" and
|
|
# "GitHub green" stop being equivalent claims.
|
|
#
|
|
# Deliberate local-only (NOT on the GitHub gate), with reason:
|
|
# tor-socks5 — requires live Tor network; opt-in via --with-tor,
|
|
# unreliable on GitHub-hosted runners.
|
|
# tor-directory — same; live Tor dependency.
|
|
#
|
|
# What is compared, and at what granularity:
|
|
# chaos — per scenario, plus its flags. GitHub fans each scenario
|
|
# into its own matrix leg carrying `scenario:` (and
|
|
# optionally `chaos_flags:`); local lists the same scenarios
|
|
# in CHAOS_SUITES as "display scenario flags". The `suite:`
|
|
# names differ cosmetically between runners and are ignored
|
|
# — `scenario:` is the identity.
|
|
# deb-install — per distro. GitHub splits into per-distro legs carrying
|
|
# `scenario:`, in a job of their own; local runs the same
|
|
# distro set in one suite, enumerated by ALL_SCENARIOS in
|
|
# deb-install/test.sh.
|
|
# everything else — per suite name.
|
|
#
|
|
# dns-resolver is the one leg still compared at leg granularity rather than
|
|
# per scenario: it is a single leg and a single suite on both sides, and it
|
|
# runs all of its scenarios internally. Its scenario list is NOT cross-checked.
|
|
#
|
|
# The local suite set is discovered by sweeping ci-local.sh for *_SUITES arrays
|
|
# rather than from a hardcoded list of variable names, and every run_suite
|
|
# dispatch arm is then checked to have a backing array — a suite dispatched
|
|
# without one is invisible to a name-list sweep, which is how a real divergence
|
|
# went unnoticed.
|
|
#
|
|
# Exit 0 = parity clean. Exit 1 = unexpected divergence. Exit 2 = the guard
|
|
# could not run (missing file or missing dependency); never treated as a pass.
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
set -uo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
|
|
CI_LOCAL="$SCRIPT_DIR/ci-local.sh"
|
|
CI_YML="$PROJECT_ROOT/.github/workflows/ci.yml"
|
|
DEB_TEST="$SCRIPT_DIR/deb-install/test.sh"
|
|
|
|
# Deliberate local-only allowlist (suites intentionally absent from GitHub).
|
|
ALLOWLIST="tor-socks5 tor-directory"
|
|
|
|
for f in "$CI_LOCAL" "$CI_YML" "$DEB_TEST"; do
|
|
if [[ ! -f "$f" ]]; then
|
|
echo "check-ci-parity: missing file: $f" >&2
|
|
exit 2
|
|
fi
|
|
done
|
|
|
|
if ! command -v python3 >/dev/null 2>&1; then
|
|
echo "check-ci-parity: python3 not found; cannot verify CI parity" >&2
|
|
exit 2
|
|
fi
|
|
if ! python3 -c "import yaml" >/dev/null 2>&1; then
|
|
echo "check-ci-parity: python3 module 'yaml' not found; cannot verify CI parity" >&2
|
|
echo "check-ci-parity: install it with 'pip3 install pyyaml'" >&2
|
|
exit 2
|
|
fi
|
|
|
|
python3 - "$CI_LOCAL" "$CI_YML" "$DEB_TEST" "$ALLOWLIST" <<'PY'
|
|
import re
|
|
import sys
|
|
|
|
import yaml
|
|
|
|
ci_local_path, ci_yml_path, deb_test_path, allowlist_raw = sys.argv[1:5]
|
|
allowlist = set(allowlist_raw.split())
|
|
|
|
with open(ci_local_path, encoding="utf-8") as fh:
|
|
local_src = fh.read()
|
|
with open(deb_test_path, encoding="utf-8") as fh:
|
|
deb_src = fh.read()
|
|
|
|
|
|
def bash_array_entries(var):
|
|
"""Full entries of a bash array, in order, quotes stripped."""
|
|
m = re.search(rf"^{var}=\((.*?)\)", local_src, re.MULTILINE | re.DOTALL)
|
|
if not m:
|
|
return []
|
|
body = m.group(1)
|
|
quoted = re.findall(r'"([^"]*)"', body)
|
|
if quoted:
|
|
return [e.strip() for e in quoted if e.strip()]
|
|
return [tok for tok in body.split() if tok.strip()]
|
|
|
|
|
|
def discovered_arrays():
|
|
"""Every *_SUITES array in ci-local.sh, by name.
|
|
|
|
Swept rather than hardcoded: a suite whose array is not on a fixed list
|
|
would otherwise be invisible to this guard.
|
|
"""
|
|
return {
|
|
name + "_SUITES": bash_array_entries(name + "_SUITES")
|
|
for name in re.findall(r"^([A-Z_]+)_SUITES=\(", local_src, re.MULTILINE)
|
|
}
|
|
|
|
|
|
arrays = discovered_arrays()
|
|
|
|
# ── Local side ───────────────────────────────────────────────────────────────
|
|
# Chaos: "display scenario flags" — compare the scenario and its flags.
|
|
local_chaos = {}
|
|
for entry in arrays.get("CHAOS_SUITES", []):
|
|
parts = entry.split()
|
|
if len(parts) < 2:
|
|
continue
|
|
local_chaos[parts[1]] = " ".join(parts[2:])
|
|
|
|
# deb-install: one local suite that runs the distro set enumerated in its script.
|
|
m = re.search(r'^ALL_SCENARIOS="([^"]*)"', deb_src, re.MULTILINE)
|
|
local_deb = set(m.group(1).split()) if m else set()
|
|
|
|
# Everything else: suite names, with NAT stored bare and prefixed at use.
|
|
local = set()
|
|
for name, entries in arrays.items():
|
|
if name in ("CHAOS_SUITES", "DEB_INSTALL_SUITES"):
|
|
continue
|
|
names = [e.split()[0] for e in entries]
|
|
if name == "NAT_SUITES":
|
|
local |= {f"nat-{n}" for n in names}
|
|
else:
|
|
local |= set(names)
|
|
|
|
# ── GitHub side ──────────────────────────────────────────────────────────────
|
|
with open(ci_yml_path, encoding="utf-8") as fh:
|
|
doc = yaml.safe_load(fh)
|
|
|
|
# Sweep every job's matrix rather than one named job: the install legs live in
|
|
# a job of their own so the rest of the integration matrix does not wait on the
|
|
# package build, and a guard keyed to a job name reports them as local-only the
|
|
# moment they move. Identity comes from the leg's own fields, so where a leg
|
|
# lives does not matter; a leg deleted from every job still shows up as
|
|
# local-only, because the local side is the reference.
|
|
include = []
|
|
for job in (doc.get("jobs") or {}).values():
|
|
legs = (((job.get("strategy") or {}).get("matrix") or {}).get("include") or [])
|
|
if isinstance(legs, list):
|
|
include += [leg for leg in legs if isinstance(leg, dict)]
|
|
if not include:
|
|
print("check-ci-parity: no matrix include: found in any job of "
|
|
f"{ci_yml_path}; cannot verify CI parity", file=sys.stderr)
|
|
sys.exit(2)
|
|
github_chaos, github_deb, github = {}, set(), set()
|
|
malformed = []
|
|
for leg in include:
|
|
if "suite" not in leg and "scenario" not in leg:
|
|
continue
|
|
kind = str(leg.get("type", ""))
|
|
if kind in ("chaos", "deb-install"):
|
|
# scenario: is the identity for these; suite: is cosmetic.
|
|
if "scenario" not in leg:
|
|
malformed.append(f"{leg.get('suite', '(unnamed leg)')} has type "
|
|
f"{kind} but no scenario:")
|
|
continue
|
|
if kind == "chaos":
|
|
github_chaos[str(leg["scenario"])] = str(leg.get("chaos_flags", ""))
|
|
else:
|
|
github_deb.add(str(leg["scenario"]))
|
|
elif "suite" in leg:
|
|
github.add(str(leg["suite"]))
|
|
else:
|
|
malformed.append(f"leg with scenario {leg['scenario']} has no suite: "
|
|
f"and no chaos/deb-install type")
|
|
|
|
# ── Dispatch cross-check: every run_suite arm needs a backing array ──────────
|
|
# A suite dispatched without an array is invisible to the sweep above, so the
|
|
# guard would report it as GitHub-only forever without ever naming the cause.
|
|
body = re.search(r"^run_suite\(\).*?^\}", local_src, re.MULTILINE | re.DOTALL)
|
|
dispatch_uncovered = []
|
|
if body is None:
|
|
print("check-ci-parity: could not locate run_suite() in ci-local.sh", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
# Arms sit at one fixed indentation inside the case block. Pin to it, taken from
|
|
# the first arm rather than assumed, so a body line that happens to end in ')'
|
|
# cannot be read as an arm.
|
|
arm_re = re.compile(r"^([ \t]+)['\"]?([a-z0-9|*_.-]+)['\"]?\)", re.MULTILINE)
|
|
first = arm_re.search(body.group(0))
|
|
if first is None:
|
|
print("check-ci-parity: no dispatch arms found in run_suite()", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
indent = first.group(1)
|
|
# An arm-shaped line at a different indent is not skipped silently: it would
|
|
# make this check quietly stop covering a suite, which is the failure mode the
|
|
# check exists to prevent.
|
|
odd_arms = [
|
|
m.group(2) for m in arm_re.finditer(body.group(0)) if m.group(1) != indent
|
|
]
|
|
if odd_arms:
|
|
print("check-ci-parity: run_suite has arm-shaped lines at an unexpected "
|
|
f"indent, so the dispatch check cannot be trusted: {', '.join(odd_arms)}",
|
|
file=sys.stderr)
|
|
sys.exit(2)
|
|
known = (set(local) | set(local_chaos) | local_deb
|
|
| {e.split()[0] for e in arrays.get("DEB_INSTALL_SUITES", [])})
|
|
for m in arm_re.finditer(body.group(0)):
|
|
if m.group(1) != indent:
|
|
continue
|
|
for arm in m.group(2).split("|"):
|
|
if arm == "*":
|
|
continue # the unknown-suite error arm, not a suite
|
|
if arm == "chaos-*":
|
|
# Dispatches any chaos-<name> through a fallback, so its vocabulary
|
|
# is unbounded; the chaos scenario comparison covers it instead.
|
|
continue
|
|
if arm not in known:
|
|
dispatch_uncovered.append(arm)
|
|
|
|
# ── Diff ─────────────────────────────────────────────────────────────────────
|
|
local_cmp = {n for n in local if n not in allowlist}
|
|
|
|
local_only = sorted(local_cmp - github)
|
|
github_only = sorted(github - local_cmp)
|
|
chaos_local_only = sorted(set(local_chaos) - set(github_chaos))
|
|
chaos_github_only = sorted(set(github_chaos) - set(local_chaos))
|
|
chaos_flag_drift = sorted(
|
|
(s, local_chaos[s], github_chaos[s])
|
|
for s in set(local_chaos) & set(github_chaos)
|
|
if local_chaos[s] != github_chaos[s]
|
|
)
|
|
deb_local_only = sorted(local_deb - github_deb)
|
|
deb_github_only = sorted(github_deb - local_deb)
|
|
|
|
problems = (local_only or github_only or chaos_local_only or chaos_github_only
|
|
or chaos_flag_drift or deb_local_only or deb_github_only
|
|
or dispatch_uncovered or malformed)
|
|
|
|
if problems:
|
|
print("CI parity FAILED: the two runners do not cover the same work.\n")
|
|
if local_only:
|
|
print(" Suites local-only (in ci-local.sh, missing from ci.yml, "
|
|
"not in the deliberate allowlist):")
|
|
for n in local_only:
|
|
print(f" - {n}")
|
|
if github_only:
|
|
print(" Suites GitHub-only (in ci.yml, missing from the local default path):")
|
|
for n in github_only:
|
|
print(f" - {n}")
|
|
if chaos_local_only:
|
|
print(" Chaos scenarios local-only:")
|
|
for n in chaos_local_only:
|
|
print(f" - {n}")
|
|
if chaos_github_only:
|
|
print(" Chaos scenarios GitHub-only:")
|
|
for n in chaos_github_only:
|
|
print(f" - {n}")
|
|
if chaos_flag_drift:
|
|
print(" Chaos scenarios whose flags differ between runners:")
|
|
for name, lflags, gflags in chaos_flag_drift:
|
|
print(f" - {name}: local '{lflags}' vs GitHub '{gflags}'")
|
|
if deb_local_only:
|
|
print(" deb-install distros local-only:")
|
|
for n in deb_local_only:
|
|
print(f" - {n}")
|
|
if deb_github_only:
|
|
print(" deb-install distros GitHub-only:")
|
|
for n in deb_github_only:
|
|
print(f" - {n}")
|
|
if malformed:
|
|
print(" Matrix legs this guard cannot identify:")
|
|
for n in malformed:
|
|
print(f" - {n}")
|
|
if dispatch_uncovered:
|
|
print(" run_suite dispatches these with no backing *_SUITES array, so "
|
|
"this guard\n cannot see them in the local set:")
|
|
for n in dispatch_uncovered:
|
|
print(f" - {n}")
|
|
print("\n Resolve by adding the suite to the other runner, by giving a "
|
|
"dispatchable\n suite a *_SUITES array, or by adding it to the "
|
|
"deliberate local-only\n allowlist in check-ci-parity.sh with a "
|
|
"stated reason.")
|
|
sys.exit(1)
|
|
|
|
total = len(github) + len(github_chaos) + len(github_deb)
|
|
print("CI parity OK: both runners cover the same work "
|
|
"(allowlist: " + ", ".join(sorted(allowlist)) + ").")
|
|
print(f" {len(github)} suites, {len(github_chaos)} chaos scenarios "
|
|
f"(flags compared), {len(github_deb)} deb-install distros "
|
|
f"— {total} legs on each side.")
|
|
sys.exit(0)
|
|
PY
|