Files
fips/testing/check-ci-labels.py
Johnathan Corgan 26abd1be67 Give every CI container the run label and make the label check require it
The startup-stop scenarios in the gateway suite, the OpenWrt maintainer
script scenarios and the Debian package build and depends check started
containers with no CI label at all. ci-cleanup.sh selects containers by
label or compose project, never by name, so a container from one of these
sites that outlived a killed run was reaped by nothing: not the run's own
teardown, and not the builder's broad sweep.

Each site now carries the generic and run labels, through CI_LABELS where
the script sources testing/lib/image-build.sh and as the literal pair in
startup-stop-test.sh, which sources nothing. Outside local CI the run label
reads "manual", which no teardown reaps by run.

ci-cleanup.sh's two ip(8) helpers stay unlabelled on purpose: the builder
skips its orphan sweep while any generic-labelled container is up, so a
labelled helper would let one run's teardown hold off that sweep. Each is
marked with a "# ci-labels:" comment on the command's last line, ready for
the label check to read as an exemption; on the first line it would follow
the continuation backslash and end the command there.

check-ci-labels.py failed a docker command that gave the generic CI label
without the run label, but passed one that carried no label at all, so a
new container site written without labels passed every repository check.
Such a container is worse off than a generic-labelled one: ci-cleanup.sh
selects by label or compose project, never by name, so nothing reaps it.

Rule 3 now fails a docker run, create, container run, container create,
network create or volume create under testing/ or packaging/ that carries
no run label, unless rule 1 already reported the line. It reads shell
words, so a quoted error message naming "docker network create" is not a
command, while a command after a separator, behind timeout or "if !", or
inside a $(...) or backtick substitution, quoted or not, is still found.
Builds stay out: shared images keep fixed tags and no labels by design.

A container that must stay unlabelled takes a trailing "# ci-labels:
<reason>" comment. Rule 4 fails one with no reason, and one placed after a
continuation backslash, where the comment would end the command on that
line and run the rest as a separate command.

File arguments are now scoped by their path relative to the work tree, so
testing an uncommitted copy reads it as the sweep would. Docker's short -l
flag is read as a label, and the self-test carries a red or a green for
each new shape.
2026-10-25 09:15:02 +00:00

709 lines
29 KiB
Python
Executable File

#!/usr/bin/env python3
"""Fail when a CI container, network or volume lacks the run label.
Every container, network, volume and image a local CI run creates carries
`com.corganlabs.fips-ci=1`, which marks it as CI's, and
`com.corganlabs.fips-ci.run=<run id>`, which says whose. The generic label is
shared by every run on the host, so ci-local.sh's teardown reaps by the run
label alone (ci-cleanup.sh --run-id): a resource carrying only the generic
label is never reaped by its own run. If the suite that made it is killed
before its own cleanup runs, the resource outlives the run, and the internal
builder, whose orphan sweep is skipped while any generic-labelled container is
up, stops sweeping for every run after it. A dns-resolver container left that
way stopped the sweep for three days in October 2026. A container with no
label at all is worse off still: ci-cleanup.sh selects by label or compose
project, never by name, so nothing ever reaps it.
The labels a suite passes are spelled three ways in the tree, and each is read:
a literal `--label com.corganlabs.fips-ci=1`; a variable holding it
(`LABEL="com.corganlabs.fips-ci=1"` ... `--label "$LABEL"`); and an array
expanded into the command (`"${CI_LABELS[@]}"`, defined in
testing/lib/image-build.sh). Variables and arrays defined in testing/lib/*.sh
are visible to every file, since the suites source that library.
What is read: in sweep mode (no arguments), the files committed at HEAD that
are `*.sh`, `.github/workflows/*.yml` or `*.yaml`, or start with an sh or bash
shebang. File arguments replace the sweep and are read from disk, with the
libraries read from beside this file, which is how the check is tested against
uncommitted copies. A file argument inside the current git work tree is
named, and scoped, by its path relative to the work-tree root, so
`testing/x.sh` and `/abs/path/to/checkout/testing/x.sh` read the same.
Lines whose first non-blank character is `#` are skipped, a trailing comment
is ignored, and backslash continuations are joined. The rules:
1. a `docker run`, `docker create`, `docker build`, `docker buildx build`,
`docker network create` or `docker volume create` whose `--label`
values, after variables and arrays are expanded, include the generic
label but no `com.corganlabs.fips-ci.run=` label;
2. an array whose words include `--label` with the generic label but no
`--label` with the run label, reported where the array is defined, since
a helper that passes it on to docker is not a line rule 1 reads;
3. in a file under testing/ or packaging/, a `docker run`, `docker create`,
`docker container run`, `docker container create`, `docker network
create` or `docker volume create` with no run label, labelled or not,
unless rule 1 already reported that logical line;
4. a `# ci-labels:` exemption with no reason, or one following a
continuation backslash, where the comment ends the command on that line.
Rules 1 and 2 name the physical line holding the generic label: the literal,
the variable reference or the array expansion. Rule 3 names the line holding
the docker command, and rule 4 the line holding the comment.
Rule 3 reads shell words, so a quoted message such as
`fail "docker network create: $err"` is not a command. A command is found at
the top level of the line, after `;`, `&&`, `|` and the like, and inside a
`$(...)` or backtick substitution, quoted or not; `timeout`, `if !` and other
prefixes before the word `docker` do not hide it. Builds are not covered:
shared images carry fixed tags and no labels by design.
A container that must not carry the labels, such as ci-cleanup.sh's own
ip(8) helpers, is exempted from rule 3 by a trailing comment
`# ci-labels: <reason>` on any physical line of the command other than one
ending in a continuation backslash; put it on the last line.
Before reading anything, the check runs a self-test: planted cases for every
rule and for the shapes that must not be flagged. A wrong verdict there exits
2, so a run that passes has also shown that it can fail.
Known gaps, recorded rather than discovered: a label in a variable passed to a
helper function that runs docker is not followed through the call (rule 2
covers arrays, not scalars), and so a helper whose docker command takes its
labels from "$@" reds rule 3; a label whose value is built from a variable
assigned in a file that is neither the same file nor testing/lib/*.sh is not
resolved, and so is not seen as either label; a docker command held in a
string and run later (testing/boringtun/scripts/generate-keys.sh), or written
with docker's global options before the subcommand, is not read as a command;
Python callers (chaos/sim/runner.py, chaos/sim/veth.py) and compose files are
not read.
Compose services carry only the generic label by design: ci-local.sh names
every compose project per run and reaps by that prefix.
Exit codes:
0 - nothing flagged
1 - at least one finding; every one is printed
2 - the check could not run (not a git work tree, git failed, no files,
or a self-test verdict was wrong); never a pass
"""
from __future__ import annotations
import re
import shlex
import subprocess
import sys
from pathlib import Path
NAME = "check-ci-labels"
GENERIC = "com.corganlabs.fips-ci=1"
RUN = "com.corganlabs.fips-ci.run="
LIBRARY_DIR = "testing/lib/"
RUN_SCOPE = ("testing/", "packaging/")
DOCKER_RE = re.compile(
r"(?<![\w-])docker\s+(?:run|create|build|buildx\s+build|network\s+create"
r"|volume\s+create)(?![\w-])")
SCALAR_RE = re.compile(
r"(?:^|(?<=[\s;&|(]))(?:(?:local|declare|readonly|export|typeset)\s+(?:-\w+\s+)*)?"
r"([A-Za-z_]\w*)=(?!\()(\"[^\"]*\"|'[^']*'|[^\s;&|]*)")
ARRAY_RE = re.compile(
r"^\s*(?:(?:local|declare|readonly|typeset)\s+(?:-\w+\s+)*)?([A-Za-z_]\w*)\+?=\(")
VAR_RE = re.compile(r"\$\{([A-Za-z_]\w*)(?:[:]?[-=?+][^}]*)?\}|\$([A-Za-z_]\w*)")
ARRAY_USE_RE = re.compile(r"^\$\{([A-Za-z_]\w*)\[[@*]\]\}$")
CREATE_RE = re.compile(
r"(?<![\w-])docker\s+(?:(?:container\s+)?(?:run|create)|network\s+create"
r"|volume\s+create)(?![\w-])")
CREATES = {("run",), ("create",), ("container", "run"), ("container", "create"),
("network", "create"), ("volume", "create")}
ANNOTATION_RE = re.compile(r"^#\s*ci-labels:(.*)$")
OPERATORS = ";&|()"
SHEBANG_RE = re.compile(r"#!\s*(?:\S*/)?(?:env\s+(?:-\S+\s+)*)?(?:\S*/)?(?:ba|da|a)?sh(?:\s|$)")
DEPTH = 6
class Line:
"""One logical shell line: continuations joined, trailing comment dropped.
`raw` holds the physical lines it came from and `first` the number of the
first, so a finding can be reported at the physical line that holds it.
"""
def __init__(self, first: int, raw: list[str], code: str):
"""Record the first physical line number, the physical lines and the code."""
self.first = first
self.raw = raw
self.code = code
def find(self, pattern: re.Pattern[str]) -> int:
"""Return the number of the first physical line matching PATTERN."""
for i, text in enumerate(self.raw):
if pattern.search(strip_comment(text)):
return self.first + i
return self.first
def strip_comment(line: str) -> str:
"""Return LINE without a trailing comment.
A `#` starts a comment only outside quotes, at the start of the line or
after whitespace, so `${#x}` and `$#` are kept.
"""
quote = None
prev = " "
for i, c in enumerate(line):
if quote:
if c == quote:
quote = None
elif c in ("'", '"'):
quote = c
elif c == "#" and prev.isspace():
return line[:i]
prev = c
return line
def logical_lines(text: str) -> list[Line]:
"""Return each logical line of TEXT that is not a whole-line comment."""
phys = text.split("\n")
out = []
i = 0
while i < len(phys):
start = i
line = phys[i]
i += 1
if line.lstrip().startswith("#"):
continue
raw = [line]
code = strip_comment(line)
while re.search(r"(?<!\\)(?:\\\\)*\\$", code) and i < len(phys):
code = code[:-1] + " "
nxt = phys[i]
raw.append(nxt)
i += 1
if not nxt.lstrip().startswith("#"):
code += strip_comment(nxt)
out.append(Line(start + 1, raw, code))
return out
def words(code: str) -> list[str]:
"""Split CODE into shell words, quotes removed.
A line that does not split, such as one opening a quoted string that
closes on a later line, falls back to whitespace splitting with quote
characters dropped.
"""
try:
return shlex.split(code, comments=False, posix=True)
except ValueError:
return [w.replace('"', "").replace("'", "") for w in code.split()]
class Defs:
"""Scalar and array assignments, from one file and the libraries."""
def __init__(self):
"""Start with no definitions."""
self.scalars: dict[str, set[str]] = {}
self.arrays: dict[str, list[tuple[int, list[str]]]] = {}
def add_text(self, text: str) -> None:
"""Record every assignment in TEXT."""
for ln in logical_lines(text):
for m in SCALAR_RE.finditer(ln.code):
value = m.group(2)
if value[:1] in ("'", '"'):
value = value[1:-1]
self.scalars.setdefault(m.group(1), set()).add(value)
for line_no, name, body in arrays_in(text):
self.arrays.setdefault(name, []).append((line_no, body))
def expand(self, word: str, depth: int = 0) -> set[str]:
"""Return the texts WORD can expand to, through the known scalars.
An unknown variable is left as written, so `${FIPS_CI_RUN_ID:-manual}`
stays a run label.
"""
if depth >= DEPTH:
return {word}
m = next((m for m in VAR_RE.finditer(word) if (m.group(1) or m.group(2)) in self.scalars),
None)
if m is None:
return {word}
out: set[str] = set()
for value in self.scalars[m.group(1) or m.group(2)]:
out |= self.expand(word[:m.start()] + value + word[m.end():], depth + 1)
return out
def flatten(self, ws: list[str], depth: int = 0) -> list[tuple[str, str]]:
"""Expand array uses in WS; return (word, the word it came from) pairs."""
out: list[tuple[str, str]] = []
for w in ws:
m = ARRAY_USE_RE.match(w)
if m and m.group(1) in self.arrays and depth < DEPTH:
for _, body in self.arrays[m.group(1)]:
out += [(x, w) for x, _ in self.flatten(body, depth + 1)]
else:
out.append((w, w))
return out
def arrays_in(text: str) -> list[tuple[int, str, list[str]]]:
"""Return (line, name, words) for each array assignment in TEXT.
The body runs from `NAME=(` to the `)` that closes it outside quotes,
across physical lines, with comments dropped.
"""
phys = text.split("\n")
found = []
for i, line in enumerate(phys):
if line.lstrip().startswith("#"):
continue
m = ARRAY_RE.match(line)
if not m:
continue
body, depth, quote, j, rest = [], 1, None, i, line[m.end():]
while True:
chunk = strip_comment(rest)
for k, c in enumerate(chunk):
if quote:
if c == quote:
quote = None
elif c in ("'", '"'):
quote = c
elif c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth == 0:
body.append(chunk[:k])
break
if depth == 0:
break
body.append(chunk.rstrip("\\"))
j += 1
if j >= len(phys):
break
rest = phys[j] if not phys[j].lstrip().startswith("#") else ""
found.append((i + 1, m.group(1), words(" ".join(body))))
return found
def label_values(pairs: list[tuple[str, str]]) -> list[tuple[str, str]]:
"""Return (value, source word) for each `--label` or `-l` in PAIRS."""
out = []
for k, (w, src) in enumerate(pairs):
if w in ("--label", "-l") and k + 1 < len(pairs):
out.append(pairs[k + 1])
elif w.startswith(("--label=", "-l=")):
out.append((w.partition("=")[2], src))
return out
def classify(defs: Defs, values: list[tuple[str, str]]) -> tuple[list[str], bool]:
"""Return the source words that give the generic label, and whether any gives the run label."""
generic, run = [], False
for value, src in values:
texts = defs.expand(value)
if any(GENERIC in t for t in texts):
generic.append(src)
if any(RUN in t for t in texts):
run = True
return generic, run
def source_pattern(src: str) -> re.Pattern[str]:
"""Return a pattern finding the physical line that wrote SRC."""
m = ARRAY_USE_RE.match(src)
if m:
return re.compile(r"\$\{" + re.escape(m.group(1)) + r"\[[@*]\]\}")
m = VAR_RE.search(src)
if m and GENERIC not in src:
name = m.group(1) or m.group(2)
return re.compile(r"\$\{?" + re.escape(name) + r"(?!\w)")
return re.compile(re.escape(GENERIC))
def segments(code: str) -> list[str]:
"""Return CODE's top level and each command substitution's body, apart.
A `$(...)` or backtick substitution, inside double quotes or not, is
lifted out as a text of its own and left in its place as the word `_`,
so the command inside it is read as a command and not as part of one
quoted word. Single-quoted text is literal. A quote still open at the end
is closed, so text that continues on a later line stays a string.
"""
out: list[str] = []
def walk(i: int, close: str) -> tuple[str, int]:
buf: list[str] = []
quote = None
depth = 0
while i < len(code):
c = code[i]
if quote == "'":
buf.append(c)
quote = None if c == "'" else quote
i += 1
elif c == "\\":
buf.append(code[i:i + 2])
i += 2
elif code.startswith("$(", i) or (c == "`" and close != "`"):
body, i = walk(i + (2 if c == "$" else 1), ")" if c == "$" else "`")
out.append(body)
buf.append("_")
elif quote == '"':
buf.append(c)
quote = None if c == '"' else quote
i += 1
elif c == close and (close == "`" or depth == 0):
return "".join(buf), i + 1
else:
if c in ("'", '"'):
quote = c
elif c == "(" and close == ")":
depth += 1
elif c == ")" and close == ")":
depth -= 1
buf.append(c)
i += 1
if quote:
buf.append(quote)
return "".join(buf), i
out.insert(0, walk(0, "")[0])
return out
def commands(code: str) -> list[list[str]]:
"""Return the words of each simple command in CODE, substitutions included.
Commands are split at the shell's control operators (`;`, `&&`, `||`,
`|`, `&`, parentheses); redirections such as `2>&1` stay inside theirs.
"""
out = []
for seg in segments(code):
lex = shlex.shlex(seg, posix=True, punctuation_chars=OPERATORS + "<>")
lex.whitespace_split = True
try:
toks = list(lex)
except ValueError:
toks = seg.split()
cur: list[str] = []
for tok in toks:
if tok and all(ch in OPERATORS for ch in tok):
out.append(cur)
cur = []
else:
cur.append(tok)
out.append(cur)
return [c for c in out if c]
def creates(cmd: list[str]) -> list[str] | None:
"""Return the words after `docker <subcommand>` when CMD creates a container, network or volume."""
for k, w in enumerate(cmd):
if w != "docker":
continue
for n in (1, 2):
if tuple(cmd[k + 1:k + 1 + n]) in CREATES:
return cmd[k + 1 + n:]
return None
def annotation(ln: Line) -> tuple[bool, list[int]]:
"""Return whether LN carries a valid `# ci-labels:` exemption, and the lines of malformed ones."""
valid, bad = False, []
for i, text in enumerate(ln.raw):
if text.lstrip().startswith("#"):
continue
code = strip_comment(text)
m = ANNOTATION_RE.match(text[len(code):].strip())
if not m:
continue
if not m.group(1).strip() or code.rstrip().endswith("\\"):
bad.append(ln.first + i)
else:
valid = True
return valid, bad
def scan(text: str, libraries: list[str], path: str) -> list[tuple[int, int]]:
"""Return the sorted (line, rule) findings for one file's text, read as PATH."""
defs = Defs()
for lib in libraries:
defs.add_text(lib)
defs.add_text(text)
found: set[tuple[int, int]] = set()
scoped = path.startswith(RUN_SCOPE)
for ln in logical_lines(text):
exempt, bad = annotation(ln)
found |= {(n, 4) for n in bad}
rule1 = False
if DOCKER_RE.search(ln.code):
generic, run = classify(defs, label_values(defs.flatten(words(ln.code))))
if generic and not run:
found.add((ln.find(source_pattern(generic[0])), 1))
rule1 = True
if not scoped or rule1 or exempt:
continue
for cmd in commands(ln.code):
rest = creates(cmd)
if rest is None:
continue
_, run = classify(defs, label_values(defs.flatten(rest)))
if not run:
found.add((ln.find(CREATE_RE), 3))
for line_no, _, body in arrays_in(text):
generic, run = classify(defs, label_values(defs.flatten(body)))
if generic and not run:
found.add((line_no, 2))
return sorted(found)
RULES = {
1: "a docker command gives the generic CI label without the run label",
2: "an array of docker arguments holds the generic CI label without the run label",
3: "a CI container, network or volume is created without the run label",
4: "a ci-labels exemption has no reason, or follows a continuation backslash",
}
LIB = ('CI_LABELS=(\n --label com.corganlabs.fips-ci=1\n'
' --label "com.corganlabs.fips-ci.run=${FIPS_CI_RUN_ID:-manual}"\n)\n')
# (name, path, text, expected (line, rule) set). Every rule has a red here, and
# every shape the matchers must leave alone has a green, so a matcher that
# drifts in either direction fails the run before it reads the tree.
T = "testing/t.sh"
CASES: list[tuple[str, str, str, set[tuple[int, int]]]] = [
("literal generic only", T,
'docker run -d --label com.corganlabs.fips-ci=1 img\n', {(1, 1)}),
("literal generic, quoted, --label= form", T,
'docker run -d --label="com.corganlabs.fips-ci=1" img\n', {(1, 1)}),
("continued command, finding on the label's line, rule 1 only", T,
'docker run -d --name x \\\n --label com.corganlabs.fips-ci=1 \\\n img\n', {(2, 1)}),
("LABEL variable only", T,
'LABEL="com.corganlabs.fips-ci=1"\ndocker create --label "$LABEL" img\n', {(2, 1)}),
("braced variable on a network create", T,
'L=com.corganlabs.fips-ci=1\ndocker network create --label "${L}" n\n', {(2, 1)}),
("generic-only array, defined and used", T,
'A=(\n --label com.corganlabs.fips-ci=1\n)\ndocker run "${A[@]}" img\n', {(1, 2), (4, 1)}),
("docker build with the generic label", T,
'docker build -t t --label com.corganlabs.fips-ci=1 .\n', {(1, 1)}),
("volume create through a variable", T,
'C=com.corganlabs.fips-ci=1\ndocker volume create --label "$C" v\n', {(2, 1)}),
("run label removed from a two-line pair", T,
'docker run --rm \\\n --label com.corganlabs.fips-ci=1 \\\n --name x img\n', {(2, 1)}),
("short -l flag, generic only", T,
'docker run -l com.corganlabs.fips-ci=1 img\n', {(1, 1)}),
("unlabelled container under testing/", T, 'docker run --rm img\n', {(1, 3)}),
("unlabelled container under packaging/", "packaging/p.sh",
'docker run --rm img\n', {(1, 3)}),
("unlabelled docker create", T, 'docker create --name x img\n', {(1, 3)}),
("unlabelled docker container run", T, 'docker container run --rm img\n', {(1, 3)}),
("unlabelled network create", T, 'docker network create n\n', {(1, 3)}),
("unlabelled volume create", T, 'docker volume create v\n', {(1, 3)}),
("unlabelled create in a command substitution", T,
'cid=$(docker create --name x img)\n', {(1, 3)}),
("unlabelled run in a quoted substitution", T,
'cid="$(docker run -d img)"\n', {(1, 3)}),
("generic only in a quoted substitution, which rule 1 cannot read", T,
'cid="$(docker run -d --label com.corganlabs.fips-ci=1 img)"\n', {(1, 3)}),
("unlabelled run in a backtick substitution", T,
'cid=`docker run -d img`\n', {(1, 3)}),
("unlabelled run after a separator with no space", T,
'true;docker run --rm img\n', {(1, 3)}),
("unlabelled run under timeout and if !", T,
'if ! timeout "$T" docker run --rm img; then :; fi\n', {(1, 3)}),
("second command on the line unlabelled", T,
'docker network create --label "com.corganlabs.fips-ci.run=$R" n'
' && docker run --rm img\n', {(1, 3)}),
("continued unlabelled command, finding on the docker line", T,
'X=1 \\\n docker run --rm \\\n img\n', {(2, 3)}),
("exemption with no reason", T,
'docker run --rm img # ci-labels:\n', {(1, 3), (1, 4)}),
("exemption after a continuation backslash ends the command", T,
'docker run --rm \\ # ci-labels: helper\n img\n', {(1, 3), (1, 4)}),
("literal pair", T, 'docker run --label com.corganlabs.fips-ci=1 '
'--label "com.corganlabs.fips-ci.run=${FIPS_CI_RUN_ID:-manual}" img\n', set()),
("library array", T, 'docker run -d "${CI_LABELS[@]}" img\n', set()),
("library array on docker create", T,
'docker create --name x "${CI_LABELS[@]}" img\n', set()),
("labelled network create in a command substitution", T,
'if err=$(docker network create "${CI_LABELS[@]}" n 2>&1); then :; fi\n', set()),
("labels after a redirection are the same command's", T,
'docker run -d >/dev/null 2>&1 "${CI_LABELS[@]}" img\n', set()),
("short -l flag pair", T,
'docker run -l com.corganlabs.fips-ci=1 -l "com.corganlabs.fips-ci.run=$R" img\n', set()),
("variable pair through a key variable", T,
'G=com.corganlabs.fips-ci=1\nK=com.corganlabs.fips-ci.run\n'
'docker volume create --label "$G" --label "$K=$P" v\n', set()),
("filter is not a label", T,
'L=com.corganlabs.fips-ci=1\ndocker ps --filter "label=$L" -q\n', set()),
("reaper option on a non-docker line", T,
'L=com.corganlabs.fips-ci=1\nbash ci-cleanup.sh --label "$L" --run-id x\n', set()),
("commented-out command", T,
'# docker run --label com.corganlabs.fips-ci=1 img\n', set()),
("trailing comment", T,
'echo hi # docker run --label com.corganlabs.fips-ci=1 img\n', set()),
("quoted message naming a network create", T,
'fail "[gateway] docker network create: $err"\n', set()),
("message whose quote closes on a later line", T,
'echo "the docker run --rm img\n step failed"\n', set()),
("unlabelled container outside testing/ and packaging/", "examples/e.sh",
'docker run --rm img\n', set()),
("unlabelled docker build is not rule 3's", T, 'docker build -t t .\n', set()),
("docker as an argument is not a command", T, 'command -v docker >/dev/null\n', set()),
("exempted helper with a reason", T,
'docker run --rm img # ci-labels: the reaper\'s helper\n', set()),
("exemption on the last line of a continued command", T,
'timeout 5 docker run --rm \\\n --entrypoint ip img "$@" # ci-labels: helper\n', set()),
("library array passed to a helper", T,
'build_inline t "$df" . "${CI_LABELS[@]}"\n', set()),
("array holding both labels", T,
'B=(\n --label com.corganlabs.fips-ci=1 # generic\n'
' --label "com.corganlabs.fips-ci.run=$R"\n)\n', set()),
]
def selftest() -> list[str]:
"""Run the planted cases; return a message for each wrong verdict."""
errors = []
for name, path, text, want in CASES:
got = set(scan(text, [LIB], path))
if got != want:
errors.append(f"case '{name}': want {sorted(want)}, got {sorted(got)}")
return errors
def git(root: str | None, *args: str) -> str:
"""Run a git command and return its stdout, exiting 2 if it fails."""
cmd = ["git"] + (["-C", root] if root else []) + list(args)
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
print(f"{NAME}: {' '.join(cmd)} failed: {proc.stderr.strip()}", file=sys.stderr)
sys.exit(2)
return proc.stdout
def wanted(path: str, first_line: str) -> bool:
"""True when PATH is a file the sweep reads."""
workflow = path.startswith(".github/workflows/") and path.endswith((".yml", ".yaml"))
return path.endswith(".sh") or workflow or bool(SHEBANG_RE.match(first_line))
def work_tree() -> Path | None:
"""Return the current git work tree's root, or None outside one."""
proc = subprocess.run(["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True)
top = proc.stdout.strip()
return Path(top).resolve() if proc.returncode == 0 and top else None
def relative(arg: str, top: Path | None) -> str:
"""Return ARG relative to the work-tree root TOP when it is inside it, else as given."""
if top is not None:
try:
return Path(arg).resolve().relative_to(top).as_posix()
except ValueError:
pass
return arg
def committed_blobs(root: str) -> list[tuple[str, str]]:
"""Return (path, text) for each file at HEAD the sweep reads."""
paths = []
for entry in git(root, "ls-tree", "-r", "-z", "HEAD").split("\0"):
meta, _, path = entry.partition("\t")
if path and meta.split()[1] == "blob":
paths.append(path)
proc = subprocess.run(["git", "-C", root, "cat-file", "--batch"],
input="".join(f"HEAD:{p}\n" for p in paths).encode(),
capture_output=True)
if proc.returncode != 0:
print(f"{NAME}: git cat-file failed: {proc.stderr.decode().strip()}", file=sys.stderr)
sys.exit(2)
data, pos, out = proc.stdout, 0, []
for p in paths:
header_end = data.index(b"\n", pos)
header = data[pos:header_end].split()
if len(header) < 3 or header[1] != b"blob":
print(f"{NAME}: unexpected object for {p}: {header!r}", file=sys.stderr)
sys.exit(2)
size = int(header[2])
body = data[header_end + 1:header_end + 1 + size]
pos = header_end + 1 + size + 1
text = body.decode("utf-8", "replace")
if wanted(p, text.split("\n", 1)[0]):
out.append((p, text))
return out
def main(argv: list[str]) -> int:
"""Self-test, then scan the committed tree (or the named files)."""
if argv and argv[0] in ("-h", "--help"):
print(f"usage: {NAME}.py [FILE...]\n"
" no FILE: read the files committed at HEAD\n"
" FILE...: read these files from disk instead (for testing the check)")
return 0
errors = selftest()
if errors:
for e in errors:
print(f"{NAME}: self-test: {e}", file=sys.stderr)
return 2
if argv:
files = []
top = work_tree()
for a in argv:
try:
files.append((relative(a, top), Path(a).read_text(errors="replace")))
except OSError as e:
print(f"{NAME}: cannot read {a}: {e}", file=sys.stderr)
return 2
lib_dir = Path(__file__).resolve().parent / "lib"
libraries = [p.read_text(errors="replace") for p in sorted(lib_dir.glob("*.sh"))]
else:
root = git(None, "rev-parse", "--show-toplevel").strip()
if not root:
print(f"{NAME}: empty work-tree root", file=sys.stderr)
return 2
files = committed_blobs(root)
libraries = [t for p, t in files
if p.startswith(LIBRARY_DIR) and p.endswith(".sh")
and "/" not in p[len(LIBRARY_DIR):]]
if not files:
print(f"{NAME}: no shell or workflow files to read", file=sys.stderr)
return 2
if not libraries:
print(f"{NAME}: no {LIBRARY_DIR}*.sh libraries found; cannot resolve CI_LABELS",
file=sys.stderr)
return 2
findings = []
for path, text in files:
for line, rule in scan(text, libraries, path):
findings.append(f"{path}:{line}: rule {rule}: {RULES[rule]}")
if findings:
for f in findings:
print(f)
sys.stdout.flush()
print("", file=sys.stderr)
print(f"{NAME}: a resource without the run label is not reaped by its run's",
file=sys.stderr)
print("teardown. Pass \"${CI_LABELS[@]}\" from testing/lib/image-build.sh, or add",
file=sys.stderr)
print("--label com.corganlabs.fips-ci=1", file=sys.stderr)
print("--label \"com.corganlabs.fips-ci.run=${FIPS_CI_RUN_ID:-manual}\".", file=sys.stderr)
print("A container that must stay unlabelled takes a trailing", file=sys.stderr)
print("'# ci-labels: <reason>' on the command's last line.", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))