phase: 32_gateway_resolved_target_liveness
Fixture and live stream.conf are byte-identical (modulo header comment). Let me test the parser directly:
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Re-record the gateway's proxy configs and kernel socket tables into tests/fixtures/.
|
||||
# Re-record the gateway's proxy configs, kernel socket tables and libc resolutions into
|
||||
# tests/fixtures/.
|
||||
#
|
||||
# ./scripts/capture-gateway-config.sh # dials the `gateway-ops` ssh alias
|
||||
# GATEWAY_SSH=lab-gw ./scripts/capture-gateway-config.sh
|
||||
@@ -12,13 +13,24 @@
|
||||
# gateway_nginx_stream_d_<n>.conf cat /etc/nginx/stream.d/*.conf
|
||||
# gateway_proc_net_tcp.txt cat /proc/net/tcp
|
||||
# gateway_proc_net_tcp6.txt cat /proc/net/tcp6
|
||||
# gateway_resolver.json getent ahostsv4/ahostsv6, once per declared target
|
||||
#
|
||||
# These documents are the ground truth behind `agent/host_agent.py`'s `haproxy`, `nginx` and
|
||||
# `sockets` sections (phase 28) the way `tests/fixtures/nut_<ups>.vars` pins the UPS wire and
|
||||
# `smartctl_driveripper_<dev>.txt` pins the drives. The include globs are read **out of the
|
||||
# captured main config**, so a gateway that moves its vhosts to a third directory grows a third
|
||||
# fixture on the next run instead of keeping a fixture set that quietly stopped being the tree
|
||||
# the agent merges.
|
||||
# These documents are the ground truth behind `agent/host_agent.py`'s `haproxy`, `nginx`,
|
||||
# `sockets` and `resolver` sections (phases 28 and 32) the way `tests/fixtures/nut_<ups>.vars`
|
||||
# pins the UPS wire and `smartctl_driveripper_<dev>.txt` pins the drives. The include globs are
|
||||
# read **out of the captured main config**, so a gateway that moves its vhosts to a third
|
||||
# directory grows a third fixture on the next run instead of keeping a fixture set that quietly
|
||||
# stopped being the tree the agent merges.
|
||||
#
|
||||
# The resolver document is the one fixture whose *name list* comes from the repo rather than from
|
||||
# the remote: the targets are `agent.host_agent.target_names()` over the configs captured above,
|
||||
# so the capture can never ask for more names than the agent will resolve, and a fixture naming a
|
||||
# target the parser no longer derives is a fixture that fails a test. Each name is then read on
|
||||
# the gateway itself — `getent ahostsv4 <name>` and `getent ahostsv6 <name>`, the same libc
|
||||
# resolution `via: "libc"` claims and the one haproxy's `init-addr last,libc,none` performs — so
|
||||
# the addresses in the fixture are the answers the *routing* box gives, not this laptop's. A
|
||||
# `config_directive` read out of the already captured nginx text costs no round trip, so the
|
||||
# fixture's provenance line and the agent's own answer come from the same bytes.
|
||||
#
|
||||
# Four rules, three of them `scripts/capture-fixtures.sh`'s:
|
||||
#
|
||||
@@ -27,7 +39,12 @@
|
||||
# The header is comment-shaped because `#` is a comment in haproxy's config and in nginx's,
|
||||
# and a `/proc` line whose 4th field is not a kernel state is skipped by the socket parser —
|
||||
# provenance that costs no parser anything, which `tests/test_agent.py` pins by parsing the
|
||||
# fixtures exactly as they sit on disk.
|
||||
# fixtures exactly as they sit on disk. `gateway_resolver.json` is the one fixture that cannot
|
||||
# carry it: it is JSON the tests `json.load`, and a `#` line is not JSON, so its provenance is
|
||||
# this block plus the `via`/`config_directive`/`ts` keys inside it (the same reason
|
||||
# `slots_gufo.json` and `agent_<host>.json` carry none). Its `ts` is a fixed constant, not the
|
||||
# capture's clock: the value is a *shape* (an entry carries the time it was answered) and a
|
||||
# live timestamp would make every re-run diff for a difference no test can read.
|
||||
# * **The configs are not rewritten at all.** A domain is not an identifier to hide here: a
|
||||
# `server_name` and the `proxy_pass` target beside it *are* the ingress map the gateway page
|
||||
# exists to draw, and a scrubbed fixture is a fixture that tests a config this gateway does
|
||||
@@ -55,7 +72,20 @@
|
||||
# * **A bad read never replaces a good fixture.** Empty output, an ssh failure or a permission
|
||||
# denial prints one line naming the file and the reason, leaves whatever is on disk alone,
|
||||
# and exits 1 — the `slots_gufo.json` rule, so a laptop with no route to the gateway cannot
|
||||
# turn six verbatim captures into six empty files.
|
||||
# turn seven verbatim captures into seven empty files. The resolver document keeps the rule with
|
||||
# one refinement: **one** name answering nothing is a fact and is recorded as
|
||||
# `status: "nxdomain"`, while a *broken vantage* is not — an ssh transport failure, a missing
|
||||
# `getent`, or not one declared name answering an address leaves the fixture untouched and
|
||||
# exits 1, because a document of nothing-but-failures would turn every live route on the page
|
||||
# into a permanent dash and every test of the join into a test of nothing.
|
||||
#
|
||||
# Two documents, one scrub. The socket tables and the resolver document each rewrite their
|
||||
# globally routable addresses through the **same** `Replacer` (one rule, two shapes, below), in
|
||||
# first-seen order per document. That order is per-document, so if a name ever resolves to a
|
||||
# globally routable address its replacement in `gateway_resolver.json` need not be the number the
|
||||
# socket tables gave that same address — which would silently unjoin `tests/test_gateway_source.py`'s
|
||||
# captured routes. `test_the_captured_resolver_document_needed_no_scrub_at_all` is the pin on that
|
||||
# hazard: it holds today because every declared target here answers a private address.
|
||||
#
|
||||
# No sudo, deliberately: haproxy's config is 0644, nginx's tree is 0644 and `/proc/net/tcp{,6}`
|
||||
# is world-readable, which is exactly what `dashboards-agent` reads as itself. A capture that
|
||||
@@ -80,12 +110,15 @@ usage() {
|
||||
cat <<'TXT'
|
||||
Usage: ./scripts/capture-gateway-config.sh [-h]
|
||||
|
||||
Re-records the gateway's proxy configs and kernel socket tables into tests/fixtures/:
|
||||
/etc/haproxy/haproxy.cfg, /etc/nginx/nginx.conf plus the documents its own glob includes
|
||||
name, and /proc/net/tcp{,6}. Every read goes over the `$GATEWAY_SSH` ssh alias (default
|
||||
`gateway-ops`) of a world-readable path, and a read that fails leaves the existing fixture
|
||||
alone and exits 1. The configs land unedited; in the socket tables a globally routable
|
||||
address becomes a documentation one and nothing else moves. See the block comment.
|
||||
Re-records the gateway's proxy configs, kernel socket tables and libc resolutions into
|
||||
tests/fixtures/: /etc/haproxy/haproxy.cfg, /etc/nginx/nginx.conf plus the documents its own
|
||||
glob includes name, /proc/net/tcp{,6}, and one `getent ahostsv4`/`getent ahostsv6` read on
|
||||
the gateway per target name `agent.host_agent.target_names()` derives from those configs
|
||||
(written as `gateway_resolver.json`, the agent's own `resolver` section shape). Every read
|
||||
goes over the `$GATEWAY_SSH` ssh alias (default `gateway-ops`) of a world-readable path or
|
||||
command, and a read that fails leaves the existing fixture alone and exits 1. The configs
|
||||
land unedited; in the socket tables and the resolver document a globally routable address
|
||||
becomes a documentation one and nothing else moves. See the block comment.
|
||||
TXT
|
||||
}
|
||||
|
||||
@@ -93,26 +126,60 @@ case "${1:-}" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
esac
|
||||
|
||||
# The socket tables' one edit, and the only place in this script that touches a captured byte.
|
||||
# Kept as a here-doc in the house style (capture-fixtures.sh embeds two); it is pinned by
|
||||
# running this script against a fake remote in tests/test_deploy_scripts.py.
|
||||
SOCKET_IDENTIFIERS_OUT=$(cat <<'PY'
|
||||
"""Move the globally-routable addresses in a /proc/net/tcp{,6} table to documentation space.
|
||||
# The interpreter for the two steps that import the repo's own parser.
|
||||
# `uv run python` is the same interpreter the tests use, which is the point: a capture whose name
|
||||
# list came from a different parser than the one the suite pins is a fixture that agrees with
|
||||
# nothing. A copy of this script with no project beside it (a test sandbox) has nothing for uv to
|
||||
# resolve, so it falls back to `python3` — `agent/host_agent.py` is stdlib-only, and PYTHONPATH
|
||||
# below is what makes it importable either way.
|
||||
export PYTHONPATH="$ROOT${PYTHONPATH:+:$PYTHONPATH}"
|
||||
run_py() {
|
||||
if command -v uv >/dev/null 2>&1 && [ -f "$ROOT/pyproject.toml" ]; then
|
||||
(cd "$ROOT" && uv run python "$@")
|
||||
else
|
||||
python3 "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
Every column, every port and every byte of spacing is the kernel's. What changes is an address
|
||||
`ipaddress` calls global, which becomes a documentation address in first-seen order (RFC 5737
|
||||
for v4, RFC 3849 for v6) re-encoded through the kernel's own per-32-bit-word little-endian
|
||||
spelling — so the replacement is exactly as wide as the original and an IPv4-mapped v6 row stays
|
||||
an IPv4-mapped v6 row. AGENTS.md invariant 6 is the reason: this repo's capture rule is that
|
||||
identifiers never go in tracked files, and a global IPv6 on Linux is EUI-64 (it encodes the
|
||||
NIC's MAC) while a visitor's public address belongs to the visitor. Loopback, private,
|
||||
link-local, multicast, wildcard and already-documentation addresses are returned untouched,
|
||||
which is what makes re-running this over its own output change nothing.
|
||||
# The identifier rule (AGENTS.md invariant 6) and the resolver document, in one module with three
|
||||
# subcommands, and the only place in this script that touches a captured byte. One `Replacer` for
|
||||
# both documents is the point: two copies of "globally routable becomes documentation space, in
|
||||
# first-seen order" is two rules. Kept as a here-doc in the house style (capture-fixtures.sh
|
||||
# embeds two); it is pinned by running this script against a fake remote in
|
||||
# tests/test_deploy_scripts.py.
|
||||
SOCKET_IDENTIFIERS_OUT=$(cat <<'PY'
|
||||
"""The gateway capture's identifier rule and resolver document.
|
||||
|
||||
sockets <table> a /proc/net/tcp{,6} table on stdout, scrubbed
|
||||
plan <fixtures dir> <plan file> every target the captured configs declare
|
||||
resolver <plan> <raw dir> <fixture> <ts> the resolver document, or a refusal
|
||||
|
||||
**The scrub** (sockets, resolver). Every column, every port and every byte of spacing is the
|
||||
kernel's or `getent`'s. What changes is an address `ipaddress` calls global, which becomes a
|
||||
documentation address in first-seen order (RFC 5737 for v4, RFC 3849 for v6); in a socket table
|
||||
it is re-encoded through the kernel's own per-32-bit-word little-endian spelling, so the
|
||||
replacement is exactly as wide as the original and an IPv4-mapped v6 row stays an IPv4-mapped v6
|
||||
row. AGENTS.md invariant 6 is the reason: this repo's capture rule is that identifiers never go in
|
||||
tracked files, and a global IPv6 on Linux is EUI-64 (it encodes the NIC's MAC) while a visitor's
|
||||
public address belongs to the visitor — and a name that resolves to one is the same exposure by
|
||||
another route. Loopback, private, link-local, multicast, wildcard and already-documentation
|
||||
addresses are returned untouched, which is what makes re-running this over its own output change
|
||||
nothing.
|
||||
|
||||
**The plan** is derived from the repo's own parser and never by re-implementing one —
|
||||
`from agent.host_agent import target_names` — so the capture can never out-ask the agent. The
|
||||
name list, the order, the literal/name split and the `resolver` directive all come out of the
|
||||
agent's own reading of the config fixtures this run captured.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
DOC_V4 = int(ipaddress.IPv4Address("203.0.113.0"))
|
||||
DOC_V6 = int(ipaddress.IPv6Address("2001:db8::"))
|
||||
@@ -184,10 +251,37 @@ class Replacer:
|
||||
out.append(text[last:])
|
||||
return "".join(out)
|
||||
|
||||
def address(self, value: str) -> str:
|
||||
"""One dotted/colon address in its own spelling: scrub it, keep the spelling.
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
The resolver document's shape, where the socket table's is hex words. Same rule and the
|
||||
same mapping: a globally routable address becomes a documentation one and everything else
|
||||
— private, loopback, link-local, wildcard, already-documentation — is returned as it came.
|
||||
A `::ffff:`-mapped address keeps its mapped spelling, because the spelling is a fact about
|
||||
the answer, not an identifier.
|
||||
"""
|
||||
try:
|
||||
candidate = ipaddress.ip_address(value)
|
||||
except ValueError:
|
||||
return value
|
||||
mapped = getattr(candidate, "ipv4_mapped", None)
|
||||
subject = mapped if mapped is not None else candidate
|
||||
if not subject.is_global:
|
||||
return value
|
||||
key = str(subject)
|
||||
if key not in self.mapping:
|
||||
self.mapping[key] = self.documentation(subject.version)
|
||||
replacement = self.mapping[key]
|
||||
self.replacements += 1
|
||||
if mapped is not None:
|
||||
return f"::ffff:{replacement}"
|
||||
return replacement
|
||||
|
||||
|
||||
def sockets(argv: list[str]) -> int:
|
||||
"""`sockets <table>` — the table on stdout with its two address columns scrubbed."""
|
||||
replacer = Replacer()
|
||||
with open(argv[1], encoding="utf-8", errors="replace") as handle:
|
||||
with open(argv[2], encoding="utf-8", errors="replace") as handle:
|
||||
for line in handle:
|
||||
sys.stdout.write(replacer.line(line))
|
||||
if replacer.replacements:
|
||||
@@ -197,6 +291,179 @@ def main(argv: list[str]) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def plan(argv: list[str]) -> int:
|
||||
"""`plan <fixtures dir> <plan file>` — every declared target, in the agent's own order.
|
||||
|
||||
Reads the config fixtures this run just captured, from a tree shaped the way the agent's
|
||||
`--root` shapes it, so the plan is the answer to "what would the agent on this box ask?" and
|
||||
not a second opinion about what a config declares. The first line is the nginx `resolver`
|
||||
directive read out of the same bytes the agent reads them from; the rest is one tab-separated
|
||||
line per target: `target`, `name` or `literal`, the name, the port (empty when the config
|
||||
wrote none).
|
||||
"""
|
||||
from agent.host_agent import Collector, address_family, target_names
|
||||
|
||||
fixtures, out = pathlib.Path(argv[2]), pathlib.Path(argv[3])
|
||||
tree = pathlib.Path(tempfile.mkdtemp(prefix="gateway-plan-"))
|
||||
for rel, fixture in (("etc/haproxy/haproxy.cfg", "gateway_haproxy.cfg"),
|
||||
("etc/nginx/nginx.conf", "gateway_nginx_main.conf")):
|
||||
if (fixtures / fixture).is_file():
|
||||
body = "".join(line for line in (fixtures / fixture).read_text().splitlines(keepends=True)
|
||||
if not line.startswith("#"))
|
||||
target = tree / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(body)
|
||||
else:
|
||||
print(f"plan: {fixture} is not on disk — capture the configs before the resolver",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
for prefix, rel in (("gateway_nginx_conf_d_", "etc/nginx/conf.d"),
|
||||
("gateway_nginx_stream_d_", "etc/nginx/stream.d")):
|
||||
for found in sorted(fixtures.glob(f"{prefix}*.conf")):
|
||||
target = tree / rel / f"{found.name[len(prefix):]}"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text("".join(line for line in found.read_text().splitlines(keepends=True)
|
||||
if not line.startswith("#")))
|
||||
|
||||
collector = Collector(str(tree), smartctl="/nonexistent/smartctl")
|
||||
errors: list[str] = []
|
||||
haproxy = collector.haproxy(errors)
|
||||
nginx, directive = collector.nginx_document(errors)
|
||||
targets = target_names(haproxy, nginx)
|
||||
if not targets:
|
||||
print("plan: the captured configs declare no target, so there is nothing to resolve — "
|
||||
"is the config capture intact?", file=sys.stderr)
|
||||
return 1
|
||||
# The name is the one part of the plan that goes back out over ssh, inside a remote command
|
||||
# string. A config target is written by the operator, so this is not a hostile input today —
|
||||
# but a fixture step that interpolated arbitrary config text into a shell command would be a
|
||||
# remote-command hole waiting for somebody to write a semicolon in a `server_name`, and the
|
||||
# honest answer to a name the script cannot safely ask about is to refuse the run, not to ask
|
||||
# it anyway. Names, dots, dashes, underscores and scoped literals only; no whitespace, quote,
|
||||
# semicolon, dollar or backtick.
|
||||
unsafe = [name for name, _port in targets
|
||||
if not re.fullmatch(r"[A-Za-z0-9._:.-]{1,253}", name)]
|
||||
if unsafe:
|
||||
print(f"plan: {len(unsafe)} target name(s) the script cannot ask about safely, starting "
|
||||
f"with {unsafe[0]!r} — nothing was read and nothing was written", file=sys.stderr)
|
||||
return 1
|
||||
with out.open("w", encoding="utf-8") as handle:
|
||||
handle.write(f"directive\t{directive if directive is not None else ''}\n")
|
||||
for name, port in targets:
|
||||
# The agent's own test, imported rather than restated: a target that is already an
|
||||
# address is `status: "literal"` and costs no read on the remote, exactly as the
|
||||
# agent's pass skips its own lookup.
|
||||
kind = "literal" if address_family(name) else "name"
|
||||
handle.write(f"target\t{kind}\t{name}\t{'' if port is None else port}\n")
|
||||
return 0
|
||||
|
||||
|
||||
def resolver(argv: list[str]) -> int:
|
||||
"""`resolver <plan file> <raw dir> <fixture> <ts>` — the document, or a refusal.
|
||||
|
||||
Written to the fixture path only when at least one declared name answered an address; every
|
||||
name failing means the vantage is broken (a resolver down, a firewall between, a `getent` that
|
||||
answers nothing), and a fixture of failures would make every live route on the page a
|
||||
permanent dash and every test of the join a test of nothing. One name failing among successes
|
||||
is a fact, and is kept as `status: "nxdomain"`.
|
||||
"""
|
||||
# The shape's single source of truth: the same `via` and the same address order the agent
|
||||
# publishes, imported rather than remembered, so the fixture is the agent's document.
|
||||
from agent.host_agent import RESOLVE_VIA, sort_resolver_addresses
|
||||
|
||||
plan_path = pathlib.Path(argv[2])
|
||||
raw = argv[3]
|
||||
out = pathlib.Path(argv[4])
|
||||
ts = float(argv[5])
|
||||
replacer = Replacer()
|
||||
directive: str | None = None
|
||||
entries: list[dict[str, Any]] = []
|
||||
names = 0
|
||||
answered = 0
|
||||
index = 0
|
||||
with open(plan_path, encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
fields = line.rstrip("\n").split("\t")
|
||||
if fields[0] == "directive":
|
||||
directive = fields[1] if len(fields) > 1 and fields[1] else None
|
||||
continue
|
||||
if fields[0] != "target":
|
||||
continue
|
||||
kind, name = fields[1], fields[2]
|
||||
port: int | None = int(fields[3]) if len(fields) > 3 and fields[3] else None
|
||||
if kind == "literal":
|
||||
found, is_literal = [name], True
|
||||
else:
|
||||
names += 1
|
||||
is_literal = False
|
||||
# `getent ahostsv6` answers `::ffff:a.b.c.d` for a name with no AAAA record — the
|
||||
# spelling glibc's AF_INET6 view synthesises, and the one `getaddrinfo` the agent
|
||||
# performs never publishes it. Keeping it would claim an address the agent does
|
||||
# not, so a mapped answer whose v4 form is in the same set is dropped. A mapped
|
||||
# answer with no plain one beside it survives, because then it is the answer.
|
||||
found = [field[0] for path in (f"{index}.v4", f"{index}.v6")
|
||||
for field in (line.split() for line in read_raw(raw, path).splitlines())
|
||||
if field]
|
||||
plain = {f"::ffff:{address}" for address in found
|
||||
if not address.startswith("::ffff:")}
|
||||
found = [address for address in found if address not in plain]
|
||||
index += 1
|
||||
# Scrub first, then sort: `addresses` is the published list, so the agent's ordering
|
||||
# rule belongs to what the fixture prints and not to what the resolver answered.
|
||||
addresses = sort_resolver_addresses(replacer.address(a) for a in found)
|
||||
if is_literal:
|
||||
status = "literal"
|
||||
else:
|
||||
status = "ok" if addresses else "nxdomain"
|
||||
if addresses:
|
||||
answered += 1
|
||||
entries.append({"name": name, "port": port, "status": status,
|
||||
"addresses": addresses, "ts": ts})
|
||||
|
||||
if names and not answered:
|
||||
print(f"resolver: none of the {names} declared name(s) answered an address — the vantage "
|
||||
"is broken, not the fixture; nothing was written", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
document = {"available": True, "via": RESOLVE_VIA, "config_directive": directive,
|
||||
"ts": ts, "entries": entries, "errors": []}
|
||||
tmp = out.with_name(f".{out.name}.tmp")
|
||||
tmp.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
|
||||
tmp.replace(out)
|
||||
tallies: dict[str, int] = {}
|
||||
for row in entries:
|
||||
tallies[row["status"]] = tallies.get(row["status"], 0) + 1
|
||||
summary = ", ".join(f"{count} {status}" for status, count in sorted(tallies.items()))
|
||||
if replacer.replacements:
|
||||
summary += (f"; {replacer.replacements} address(es) moved to documentation space "
|
||||
f"({len(replacer.mapping)} distinct)")
|
||||
print(f"{len(entries)} target(s): {summary}")
|
||||
return 0
|
||||
|
||||
|
||||
def read_raw(directory: str, name: str) -> str:
|
||||
"""One `getent` reply, or empty when the name never got that far.
|
||||
|
||||
A missing file is not an error here: the transport failure that would have left a hole is
|
||||
fatal in the shell and stops the run before this step, so a hole means a `literal` target,
|
||||
which is read out of the plan instead.
|
||||
"""
|
||||
path = os.path.join(directory, name)
|
||||
if not os.path.isfile(path):
|
||||
return ""
|
||||
with open(path, encoding="utf-8", errors="replace") as handle:
|
||||
return handle.read()
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
commands = {"sockets": sockets, "plan": plan, "resolver": resolver}
|
||||
mode = argv[1] if len(argv) > 1 else "sockets"
|
||||
if mode not in commands:
|
||||
print(f"this module does sockets, plan or resolver, not '{mode}'", file=sys.stderr)
|
||||
return 2
|
||||
return commands[mode](argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
PY
|
||||
@@ -250,7 +517,7 @@ capture() {
|
||||
{
|
||||
header "$kind" "$path"
|
||||
case "$kind" in
|
||||
sockets) python3 -c "$SOCKET_IDENTIFIERS_OUT" "$tmp" ;;
|
||||
sockets) run_py -c "$SOCKET_IDENTIFIERS_OUT" sockets "$tmp" ;;
|
||||
*) cat "$tmp" ;;
|
||||
esac
|
||||
} >"$FIX/$fixture"
|
||||
@@ -343,6 +610,96 @@ fi
|
||||
capture gateway_proc_net_tcp.txt sockets /proc/net/tcp
|
||||
capture gateway_proc_net_tcp6.txt sockets /proc/net/tcp6
|
||||
|
||||
# --- the resolver document ---------------------------------------------------
|
||||
# The names the two configs declare, read on the box that dials them. This is the only fixture
|
||||
# whose *question* comes from the repo: the plan is `target_names()` over the configs captured
|
||||
# above, so the capture asks exactly what the agent will ask and never one name more.
|
||||
#
|
||||
# One world-readable, unprivileged read per name — `getent ahostsv4 <name>` then
|
||||
# `getent ahostsv6 <name>` — because that is libc resolution on the routing box, which is what
|
||||
# `via: "libc"` claims and what haproxy's `init-addr last,libc,none` performs. No sudo, no `dig`,
|
||||
# no `host`, no copy tool: a capture that needed a tool the gateway does not ship would record
|
||||
# something the agent cannot see.
|
||||
#
|
||||
# The exit statuses are read as three different sentences. `0` answered; `2` is getent's "no such
|
||||
# record", which is a fact about the name and becomes `status: "nxdomain"`; `255` is ssh itself
|
||||
# failing and `127` is a missing `getent`, either of which means the vantage is broken rather than
|
||||
# the name, so the run stops and the fixture on disk stays exactly as it was.
|
||||
capture_resolver() {
|
||||
local scratch rc_plan=0 rc_doc=0
|
||||
scratch="$(mktemp -d "${TMPDIR:-/tmp}/gateway-resolver.XXXXXX")" || {
|
||||
echo " ✗ gateway_resolver.json — no scratch directory; the fixture on disk is left alone"
|
||||
failed=1
|
||||
return 1
|
||||
}
|
||||
|
||||
if ! run_py -c "$SOCKET_IDENTIFIERS_OUT" plan "$FIX" "$scratch/plan" 2>"$scratch/plan.err"; then
|
||||
# Last line, not first: an exception's own sentence is the last one, and this is the line the
|
||||
# operator reads to decide what to fix.
|
||||
echo " ✗ gateway_resolver.json — $(tail -n 1 "$scratch/plan.err" 2>/dev/null)"
|
||||
echo " (the name list comes from agent.host_agent.target_names(); the fixture on disk is "
|
||||
echo " left alone)"
|
||||
rm -rf "$scratch"
|
||||
failed=1
|
||||
return 1
|
||||
fi
|
||||
|
||||
local kind name port index=0 rc4=0 rc6=0 broken=""
|
||||
while IFS=$'\t' read -r line f1 f2 f3; do
|
||||
[ "$line" = target ] || continue # the directive line needs no read: it is in the text
|
||||
kind="$f1"; name="$f2"; port="$f3"
|
||||
[ "$kind" = name ] || continue # a literal is already an address; the agent asks nothing
|
||||
rc4=0; rc6=0
|
||||
ssh "${SSH_OPTS[@]}" "$GATEWAY_SSH" "getent ahostsv4 $name" >"$scratch/$index.v4" \
|
||||
2>"$scratch/$index.err" || rc4=$?
|
||||
ssh "${SSH_OPTS[@]}" "$GATEWAY_SSH" "getent ahostsv6 $name" >"$scratch/$index.v6" \
|
||||
2>"$scratch/$index.err6" || rc6=$?
|
||||
# `2` on its own is getent's "no such record", which the document records as nxdomain. These
|
||||
# two are not: one means the box never answered, the other means it has no getent to ask.
|
||||
if [ "$rc4" -eq 255 ] || [ "$rc6" -eq 255 ]; then
|
||||
broken="$GATEWAY_SSH did not answer (ssh exited 255)"
|
||||
break
|
||||
fi
|
||||
if [ "$rc4" -eq 127 ] || [ "$rc6" -eq 127 ]; then
|
||||
broken="getent is not available on $GATEWAY_SSH"
|
||||
break
|
||||
fi
|
||||
index=$((index + 1))
|
||||
done <"$scratch/plan"
|
||||
|
||||
if [ -n "$broken" ]; then
|
||||
echo " ✗ gateway_resolver.json — $broken; a broken vantage is not a fixture, and the one on "
|
||||
echo " disk is left alone"
|
||||
rm -rf "$scratch"
|
||||
failed=1
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Fixed, not `date`: the fixture's `ts` is a shape (an entry carries the time it was answered)
|
||||
# and a live clock would make every re-run diff for a difference no test can read. 2026-10-25
|
||||
# 00:00:00Z, the date this document was first recorded.
|
||||
local summary
|
||||
if ! summary="$(run_py -c "$SOCKET_IDENTIFIERS_OUT" resolver "$scratch/plan" "$scratch" \
|
||||
"$FIX/gateway_resolver.json" 1792886400 2>"$scratch/doc.err")"; then
|
||||
echo " ✗ gateway_resolver.json — $(tail -n 1 "$scratch/doc.err" 2>/dev/null)"
|
||||
rm -rf "$scratch"
|
||||
failed=1
|
||||
return 1
|
||||
fi
|
||||
echo " ✓ gateway_resolver.json ($summary, on $GATEWAY_SSH)"
|
||||
rm -rf "$scratch"
|
||||
}
|
||||
|
||||
# The resolver rides on the configs: no config capture means no name list, and a document derived
|
||||
# from a half-captured tree would be a fixture of the wrong question. Say so rather than being
|
||||
# silent about the one fixture this run did not refresh.
|
||||
if [ "$failed" -eq 0 ]; then
|
||||
capture_resolver
|
||||
else
|
||||
echo " - gateway_resolver.json — not attempted: the configs it derives its name list from did "
|
||||
echo " not all capture"
|
||||
fi
|
||||
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo
|
||||
echo "capture FAILED — every fixture named above is unchanged on disk. Is $GATEWAY_SSH an ssh"
|
||||
|
||||
Reference in New Issue
Block a user