diff --git a/README.md b/README.md index ea40aa3..902e311 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Edit `.env`: | `MENDER_PAT` | Yes | Personal Access Token from Mender UI | | `MENDER_SERVER` | No | Default: `https://hosted.mender.io` | | `NODE_ID_PREFIX` | No | Only accept nodes with this prefix (e.g., `ret`) | +| `RETINA_SYNC_TARGETS` | No | Comma-separated `url=token` pairs, one per retina-server environment. Unset means no snapshot is pushed. See below | ### How it works @@ -61,6 +62,48 @@ Edit `.env`: 3. Filters by `node_id` prefix (if configured) 4. Accepts matching devices +### Fleet snapshot to retina-server + +After every sweep, `auto_accept.py` sends retina-server a snapshot of the whole +Mender fleet. It runs even if the deploy phase fails. The script reads every +device from Mender (devauth v2 plus inventory v1, paged, no status filter) and +POSTs one JSON snapshot to each target. `NODE_ID_PREFIX` only limits which +devices get accepted. The snapshot is not filtered by it. + +`RETINA_SYNC_TARGETS` is a comma-separated list of `url=token` pairs, one per +retina-server environment: + +``` +RETINA_SYNC_TARGETS=https://retina.example/api/internal/mender/snapshot=,https://staging.retina.example/api/internal/mender/snapshot=,https://test.retina.example/api/internal/mender/snapshot= +``` + +* The url is the full endpoint, `https:///api/internal/mender/snapshot`. + Plain `http` is only accepted for localhost. +* The token is that environment's `MENDER_SYNC_TOKEN`. Each pair is split at the + first `=`, so a token may contain `=`. +* Prod and staging are the usual targets. Test is optional. The test server + stays on `MENDER_SOURCE=api`, so it doesn't need one. +* If the variable is unset or empty, nothing is fetched or pushed. + +Each POST has a 10 second timeout. A failing target doesn't stop the others. A +409 means that server already has a newer snapshot, and counts as success. The +result for each target is printed with the accepted, rejected and absent +counts the server returned. A non-zero rejected count means a device was dropped +or stored with an issue, and the retina-server log names it. + +The POST carries a browser User-Agent, because Cloudflare answers a script's +default one with 403 error 1010. + +There is no retry queue. The next sweep, 30 seconds later, is the retry. A +target that stays unreachable shows up on the retina-server side as a stale +mirror, and that is where the alerting lives. + +If any Mender fetch fails, nothing is pushed that sweep. A partial fleet would +mark devices absent. + +The Mender token stays on this host and is never sent to retina-server. The sync +tokens are separate, one per environment. + ## Failed-deployment retry diff --git a/mender-auto-accept/.env.example b/mender-auto-accept/.env.example index f930b77..a0eb65a 100644 --- a/mender-auto-accept/.env.example +++ b/mender-auto-accept/.env.example @@ -63,3 +63,12 @@ NODE_ID_PREFIX=ret # StateDirectory= is picked up automatically. Set it only for a hand run that # needs the counts somewhere specific. # DEPLOY_RETRY_STATE_FILE= + + +# --- Fleet snapshot to retina-server (mender_snapshot.py) --- + +# Comma-separated url=token pairs, one per retina-server environment. The url is +# the full endpoint, the token is that environment's MENDER_SYNC_TOKEN. https is +# required (http only for localhost). Test is optional. Unset or empty means no +# snapshot is fetched or pushed. +# RETINA_SYNC_TARGETS=https://retina.example/api/internal/mender/snapshot=,https://staging.retina.example/api/internal/mender/snapshot=,https://test.retina.example/api/internal/mender/snapshot= diff --git a/mender-auto-accept/auto_accept.py b/mender-auto-accept/auto_accept.py index 1fd1003..21b1a1e 100644 --- a/mender-auto-accept/auto_accept.py +++ b/mender-auto-accept/auto_accept.py @@ -25,6 +25,7 @@ import sys import time +import mender_snapshot import requests MENDER_SERVER = os.environ.get("MENDER_SERVER", "https://hosted.mender.io") @@ -420,4 +421,11 @@ def main() -> int: if __name__ == "__main__": - sys.exit(main()) + # The snapshot goes out even when the pass fails or raises: it reads Mender + # itself, and the mirror goes stale without it. It never raises, so the + # exit status and any traceback are still main's. + try: + exit_code = main() + finally: + mender_snapshot.run() + sys.exit(exit_code) diff --git a/mender-auto-accept/mender_snapshot.py b/mender-auto-accept/mender_snapshot.py new file mode 100644 index 0000000..6effad9 --- /dev/null +++ b/mender-auto-accept/mender_snapshot.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +"""Push a whole-fleet Mender snapshot to retina-server after each auto-accept pass. + +Each retina-server in RETINA_SYNC_TARGETS mirrors the snapshot into its +mender_devices table and answers registration from it, so the Mender token +stays on this host instead of on every droplet. + +Every push carries the whole fleet, and the server marks a device missing from +it as absent. So if any page of either fetch fails, nothing is pushed that pass: +a partial fleet would mark real devices absent. There is no retry queue. The +next pass is the retry, and a target that stays unreachable shows up on its own +side as a stale mirror. + +The body follows the snapshot contract in retina-server's docs/mender-snapshot.md. +The receiver rejects unknown keys, so the keys here must match it exactly. The +snapshot is not filtered by NODE_ID_PREFIX: the server shows non-ret devices too. + +auto_accept.py calls run() after its own pass. `python3 mender_snapshot.py` +pushes one snapshot by hand. + +Environment variables: + MENDER_PAT: Personal Access Token for Mender API (required) + MENDER_SERVER: Mender server URL (default: https://hosted.mender.io) + RETINA_SYNC_TARGETS: Comma-separated url=token pairs, one per environment. + The url is the full endpoint and the token is that server's + MENDER_SYNC_TOKEN. Unset or empty, no snapshot is built. +""" +import os +import re +import socket +import sys +from collections import Counter +from datetime import datetime, timezone +from urllib.parse import urlsplit + +import requests + +MENDER_SERVER = os.environ.get("MENDER_SERVER", "https://hosted.mender.io") +MENDER_PAT = os.environ.get("MENDER_PAT") +RETINA_SYNC_TARGETS = os.environ.get("RETINA_SYNC_TARGETS", "") + +DEVAUTH_PATH = "/api/management/v2/devauth/devices" +INVENTORY_PATH = "/api/management/v1/inventory/devices" +PER_PAGE = 500 +# 25,000 devices. A list still going past that is a server misbehaving, not a fleet. +MAX_PAGES = 50 +PUSH_TIMEOUT = 10 +# Cloudflare's browser integrity check answers a script's default User-Agent with 403 error 1010. +USER_AGENT = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36" +) +# Plain http is allowed only to these, for a server running on this host. +LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"}) + +RFC3339 = re.compile(r"([0-9]{4}-[0-9]{2}-[0-9]{2})[Tt]([0-9]{2}:[0-9]{2}:[0-9]{2})" + r"(?:\.([0-9]{1,9}))?([Zz]|[+-][0-9]{2}:[0-9]{2})") +# Printable ASCII, no space. requests refuses some other header values with an +# error that quotes "Bearer ", and run() and push() print that error. +BEARER_TOKEN = re.compile(r"[\x21-\x7e]+") + + +def _bearer_safe(token: str) -> bool: + return BEARER_TOKEN.fullmatch(token) is not None + + +# --- Targets --- + + +def _allowed_url(url: str) -> bool: + try: + parts = urlsplit(url) + host = parts.hostname + except ValueError: + return False + scheme = parts.scheme.lower() + return bool(host) and (scheme == "https" or (scheme == "http" and host in LOCAL_HOSTS)) + + +def parse_targets(raw: str | None) -> list[tuple[str, str]]: + """RETINA_SYNC_TARGETS as (url, token) pairs, in order, bad entries skipped. + + Each entry splits at its first "=", because a token may contain "=". + Errors name an entry by its position only: a malformed entry may have the + token where the url should be, and it must not end up in the journal. + """ + targets = [] + entries = [entry.strip() for entry in (raw or "").split(",")] + for position, entry in enumerate((entry for entry in entries if entry), start=1): + url, sep, token = entry.partition("=") + url, token = url.strip(), token.strip() + if not sep: + problem = 'has no "="' + elif not url or not token: + problem = "has an empty url or token" + elif not _allowed_url(url): + problem = "is not an https url (http only for localhost)" + elif not _bearer_safe(token): + problem = "has a token with a space, control or non-ASCII character" + else: + targets.append((url, token)) + continue + print(f"Error: RETINA_SYNC_TARGETS entry {position} {problem}, skipping it", file=sys.stderr) + return targets + + +# --- Fetch --- + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _stamp(moment: datetime) -> str: + """UTC whole seconds with a "Z", the contract's timestamp format. + + isoformat rather than strftime, because isoformat always pads the year to + four digits, so even Go's zero time comes out in a shape the receiver accepts. + """ + return moment.astimezone(timezone.utc).replace(tzinfo=None, microsecond=0).isoformat() + "Z" + + +def fetch_all(path: str) -> list: + """Every entry of a paged Mender list, deduplicated by id. + + Raises on any failed or malformed page rather than returning what it has, + because a partial fleet pushed as the whole one would mark devices absent. + Stops at the first empty page, or at a page that adds no new id after a + short one, which is what a server ignoring `page` returns. After a full + page that same repeat raises: the server may be ignoring `page` with more + of the list still unread. + """ + entries: list = [] + seen: set[str] = set() + # Only a short page shows the list has ended. Page 1 has none before it, + # so a first page with entries but no ids raises too. + previous_full = True + for page in range(1, MAX_PAGES + 1): + resp = requests.get( + f"{MENDER_SERVER}{path}", + params={"page": page, "per_page": PER_PAGE}, + headers={"Authorization": f"Bearer {MENDER_PAT}"}, + timeout=30, + ) + resp.raise_for_status() + batch = resp.json() + if not isinstance(batch, list): + raise ValueError(f"{path} page {page} is not a list") + if not batch: + return entries + known = len(seen) + fresh = [] + for entry in batch: + entry_id = entry.get("id") if isinstance(entry, dict) else None + if isinstance(entry_id, str) and entry_id: + if entry_id in seen: + continue + seen.add(entry_id) + # Entries without an id are kept so build_snapshot can report them. + fresh.append(entry) + if len(seen) == known: + if previous_full: + raise RuntimeError(f"{path} page {page} has entries but no new id, so the list may be partial") + return entries + entries.extend(fresh) + previous_full = len(batch) >= PER_PAGE + raise RuntimeError(f"{path} still had entries after {MAX_PAGES} pages") + + +# --- Snapshot --- + + +def _str_or_none(value: object) -> str | None: + return value if isinstance(value, str) else None + + +def _nonempty_str(value: object) -> str | None: + return value if isinstance(value, str) and value else None + + +def normalise_ts(value: object) -> str | None: + """An RFC 3339 timestamp as UTC whole seconds with a "Z", or None if unreadable. + + Mender writes up to nine fractional digits; the fraction is cut to the six + datetime holds, and then dropped, so seconds are truncated, not rounded. + """ + if not isinstance(value, str): + return None + match = RFC3339.fullmatch(value) + if not match: + return None + date, time_of_day, fraction, offset = match.groups() + fraction = f".{fraction[:6].ljust(6, '0')}" if fraction else "" + offset = "+00:00" if offset in ("Z", "z") else offset + try: + return _stamp(datetime.fromisoformat(f"{date}T{time_of_day}{fraction}{offset}")) + except (ValueError, OverflowError): + # Out-of-range fields, or an offset that pushes the date past year 1 or 9999. + return None + + +def auth_state(device: dict) -> tuple[str, str | None, str | None]: + """(auth_status, auth_set_id, auth_set_pubkey) for one devauth device. + + The same rule as _record in retina-server's services/mender.py, so the + mirror and the live lookup agree. One accepted auth set is the device's + key. More than one, or a device saying accepted with none, is "ambiguous": + picking one would bless whichever key happened to come first. auth_sets + that is not a list of dicts counts as having no accepted set. + With one accepted set, a junk status, or "accepted" missing its id or pubkey, is "ambiguous" too. + """ + auth_sets = device.get("auth_sets") + accepted = [] + if isinstance(auth_sets, list) and all(isinstance(auth_set, dict) for auth_set in auth_sets): + accepted = [auth_set for auth_set in auth_sets if auth_set.get("status") == "accepted"] + if len(accepted) > 1: + return "ambiguous", None, None + if not accepted: + status = _nonempty_str(device.get("status")) or "pending" + return ("ambiguous" if status == "accepted" else status), None, None + # The default applies only when the key is absent, as in _record, so a + # junk status is never read as "accepted". + status = device.get("status", "accepted") + set_id, pubkey = accepted[0].get("id"), accepted[0].get("pubkey") + if not _nonempty_str(status): + return "ambiguous", None, None + # The contract wants the accepted set's id and pubkey. The receiver would + # store "accepted" without them with an issue, so it is sent as "ambiguous". + if status == "accepted" and not (_nonempty_str(set_id) and _nonempty_str(pubkey)): + return "ambiguous", None, None + # A device status that disagrees with its accepted set is passed through, + # as _record does. Registration refuses anything but "accepted" anyway. + return status, _str_or_none(set_id), _str_or_none(pubkey) + + +def _attribute(attributes: object, name: str) -> str | None: + """An inventory attribute's value, from the "inventory" scope if it has one.""" + if not isinstance(attributes, list): + return None + named = [attr for attr in attributes if isinstance(attr, dict) and attr.get("name") == name] + chosen = next((attr for attr in named if attr.get("scope") == "inventory"), named[0] if named else None) + return _str_or_none(chosen.get("value")) if chosen else None + + +def device_entry(device: dict, inventory: dict | None) -> dict: + """One device in the contract's shape. inventory is None when Mender has none for it.""" + identity = device.get("identity_data") + # An empty node_id goes out as null: the mirror keeps node_id unique. + node_id = _nonempty_str(identity.get("node_id")) if isinstance(identity, dict) else None + auth_status, auth_set_id, auth_set_pubkey = auth_state(device) + attributes = inventory.get("attributes") if inventory else None + return { + "mender_device_id": device["id"], + "node_id": node_id, + "auth_status": auth_status, + "auth_set_id": auth_set_id, + "auth_set_pubkey": auth_set_pubkey, + "device_type": _attribute(attributes, "device_type"), + "artifact_name": _attribute(attributes, "artifact_name"), + "inventory_updated_at": normalise_ts(inventory.get("updated_ts")) if inventory else None, + } + + +def build_snapshot(generated_at: str, devices: list, inventory: list) -> dict: + """The request body from the devauth and inventory lists, joined on device id.""" + by_id: dict[str, dict] = {} + for entry in inventory: + if isinstance(entry, dict) and _nonempty_str(entry.get("id")): + by_id.setdefault(entry["id"], entry) + + entries = [] + for device in devices: + device_id = device.get("id") if isinstance(device, dict) else None + if not _nonempty_str(device_id): + print(f"Warning: Mender device with no id left out of the snapshot: {str(device)[:200]}", + file=sys.stderr) + continue + entries.append(device_entry(device, by_id.get(device_id))) + + # Two devices claiming one node_id are both sent; the server decides. + for node_id, count in Counter(entry["node_id"] for entry in entries if entry["node_id"]).items(): + if count > 1: + ids = ", ".join(entry["mender_device_id"] for entry in entries if entry["node_id"] == node_id) + print(f"Warning: node_id {node_id} is on {count} Mender devices ({ids})", file=sys.stderr) + + host = socket.gethostname().split(".")[0] + return {"generated_at": generated_at, "source": f"mender-auto-accept@{host}", "devices": entries} + + +def collect() -> dict: + """Fetch the whole fleet and build the body. Raises if any page fails. + + generated_at is stamped before the first request, so the server's + staleness check measures from the oldest data in the snapshot. + """ + generated_at = _stamp(_now()) + devices = fetch_all(DEVAUTH_PATH) + inventory = fetch_all(INVENTORY_PATH) + return build_snapshot(generated_at, devices, inventory) + + +# --- Push --- + + +def _label(url: str) -> str: + """Host and path, for log lines.""" + parts = urlsplit(url) + return parts.netloc.rpartition("@")[2] + parts.path + + +def push(body: dict, targets: list[tuple[str, str]]) -> int: + """POST the body to each target in turn. Returns how many took it. + + One target failing never stops the next. A 409 counts as delivered: it + means a newer snapshot already landed there. Redirects are not followed, + so the token goes to the configured url and nowhere else. + """ + pushed = 0 + for url, token in targets: + label = _label(url) + try: + resp = requests.post( + url, + json=body, + headers={"Authorization": f"Bearer {token}", "User-Agent": USER_AGENT}, + timeout=PUSH_TIMEOUT, + allow_redirects=False, + ) + except Exception as e: + print(f"Error pushing snapshot to {label}: {e}", file=sys.stderr) + continue + if 200 <= resp.status_code < 300: + try: + counts = resp.json() + except ValueError: + counts = None + if not isinstance(counts, dict): + counts = {} + print(f"Pushed snapshot to {label}: {len(body['devices'])} devices, " + f"accepted {counts.get('accepted', '?')}, rejected {counts.get('rejected', '?')}, " + f"absent {counts.get('absent', '?')}") + pushed += 1 + elif resp.status_code == 409: + print(f"Snapshot not applied at {label}: a newer one already landed (409)") + pushed += 1 + else: + detail = f"HTTP {resp.status_code}" + if 300 <= resp.status_code < 400: + detail += f" (redirect to {resp.headers.get('Location')}, not followed)" + if resp.text: + detail += f": {resp.text[:200]}" + print(f"Error pushing snapshot to {label}: {detail}", file=sys.stderr) + print(f"Mender snapshot: pushed to {pushed} of {len(targets)} targets") + return pushed + + +def run() -> None: + """Build and push one snapshot. Never raises, so it cannot change + auto_accept's exit status or bury its traceback.""" + try: + if not MENDER_PAT: + return + targets = parse_targets(RETINA_SYNC_TARGETS) + if not targets: + return + if not _bearer_safe(MENDER_PAT): + print("Error: Mender snapshot not pushed: MENDER_PAT has a space, control or non-ASCII character", + file=sys.stderr) + return + push(collect(), targets) + except Exception as e: + print(f"Error: Mender snapshot not pushed: {e}", file=sys.stderr) + + +if __name__ == "__main__": + run() diff --git a/mender-auto-accept/test_mender_snapshot.py b/mender-auto-accept/test_mender_snapshot.py new file mode 100644 index 0000000..751278b --- /dev/null +++ b/mender-auto-accept/test_mender_snapshot.py @@ -0,0 +1,739 @@ +"""Tests for the Mender snapshot pushed to retina-server. + +The receiver rejects unknown keys and marks every device missing from a push +as absent. So what matters most is the exact shape of each entry, the same +auth reduction retina-server applies to a live lookup, and never pushing a +partial fleet. Every HTTP call here is a fake. Tests that could reach the +network call isolate() first, after which an unexpected request fails the test. +""" + +import os +import re +import runpy +from datetime import datetime, timedelta, timezone + +import mender_snapshot +import pytest + +CONTRACT_KEYS = { + "mender_device_id", "node_id", "auth_status", "auth_set_id", "auth_set_pubkey", + "device_type", "artifact_name", "inventory_updated_at", +} +PAT = "mender-pat-stays-on-this-host" +MENDER = "https://mender.test" +DEVAUTH = "https://mender.test/api/management/v2/devauth/devices" +INVENTORY_URL = "https://mender.test/api/management/v1/inventory/devices" +GENERATED_AT = "2026-10-16T14:02:11Z" + + +def isolate(monkeypatch, targets="", pat=PAT): + """Known settings, and no request reaches a real server. A call not faked + after this raises, and is also recorded in the returned list, because + run() swallows every exception and would hide the raise on its own.""" + unexpected = [] + + def no_network(url, **kwargs): + unexpected.append(url) + raise AssertionError(f"unexpected request to {url}") + + monkeypatch.setattr(mender_snapshot, "MENDER_SERVER", MENDER) + monkeypatch.setattr(mender_snapshot, "MENDER_PAT", pat) + monkeypatch.setattr(mender_snapshot, "RETINA_SYNC_TARGETS", targets) + monkeypatch.setattr(mender_snapshot.requests, "get", no_network) + monkeypatch.setattr(mender_snapshot.requests, "post", no_network) + monkeypatch.setattr(mender_snapshot.socket, "gethostname", lambda: "central.ops.example") + return unexpected + + +def build_then_refuse(monkeypatch): + """Fake get and post that build each request the way requests does, so a + header value requests refuses raises its own error, which quotes the value. + A request that builds is recorded and refused, so none reaches a server.""" + attempted = [] + + def fake(method): + def send(url, **kwargs): + attempted.append(url) + mender_snapshot.requests.Request(method, url, headers=kwargs.get("headers"), + params=kwargs.get("params"), json=kwargs.get("json")).prepare() + raise AssertionError(f"unexpected request to {url}") + return send + + monkeypatch.setattr(mender_snapshot.requests, "get", fake("GET")) + monkeypatch.setattr(mender_snapshot.requests, "post", fake("POST")) + return attempted + + +class _Resp: + def __init__(self, status_code, payload=None, text="", headers=None): + self.status_code = status_code + self._payload = payload + self.text = text + self.headers = headers or {} + + def json(self): + if isinstance(self._payload, Exception): + raise self._payload + return self._payload + + def raise_for_status(self): + if self.status_code >= 400: + raise mender_snapshot.requests.HTTPError(f"{self.status_code}") + + +def key(name): + return f"-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA{name}\n-----END PUBLIC KEY-----\n" + + +def auth_set(set_id, status): + """One auth set as devauth v2 returns it.""" + return {"id": set_id, "pubkey": key(set_id), "status": status, "ts": "2026-10-01T09:00:00.000Z"} + + +def inventory(device_id, artifact, updated_ts): + """One device as inventory v1 returns it, identity attributes included.""" + return {"id": device_id, "updated_ts": updated_ts, "attributes": [ + {"name": "status", "value": "accepted", "scope": "identity"}, + {"name": "device_type", "value": "pi5-v3-arm64", "scope": "inventory"}, + {"name": "artifact_name", "value": artifact, "scope": "inventory"}, + {"name": "kernel", "value": "Linux version 6.6.31", "scope": "inventory"}, + ]} + + +# ── the reduction ──────────────────────────────────────────────── + +DEVICES = [ + # A new board, enrolled and waiting. + {"id": "dev-pending", "identity_data": {"node_id": "ret00000001"}, "status": "pending", + "auth_sets": [auth_set("set-p", "pending")]}, + # An ordinary node. + {"id": "dev-accepted", "identity_data": {"node_id": "ret00000002"}, "status": "accepted", + "auth_sets": [auth_set("set-a", "accepted")]}, + # Reflashed, and both keys ended up accepted: two live identities. + {"id": "dev-two-keys", "identity_data": {"node_id": "ret00000003"}, "status": "accepted", + "auth_sets": [auth_set("set-old", "accepted"), auth_set("set-new", "accepted")]}, + # Reflashed, new key still pending: the accepted key is the one in use. + {"id": "dev-reflashing", "identity_data": {"node_id": "ret00000004"}, "status": "accepted", + "auth_sets": [auth_set("set-r-old", "accepted"), auth_set("set-r-new", "pending")]}, + # Says accepted, but no auth set agrees. + {"id": "dev-accepted-no-set", "identity_data": {"node_id": "ret00000005"}, "status": "accepted", + "auth_sets": [auth_set("set-x", "rejected")]}, + # No identity data at all. + {"id": "dev-no-identity", "status": "rejected", "auth_sets": [auth_set("set-n", "rejected")]}, + # An empty node_id, which must not collide with other empties in a unique column. + {"id": "dev-empty-node-id", "identity_data": {"node_id": "", "mac": "dc:a6:32:00:00:07"}, + "status": "preauthorized", "auth_sets": [auth_set("set-e", "preauthorized")]}, + # A non-ret board, accepted, that inventory has never heard of. + {"id": "dev-no-inventory", "identity_data": {"node_id": "lab-bench-7"}, "status": "accepted", + "auth_sets": [auth_set("set-i", "accepted")]}, +] + +INVENTORY = [ + inventory("dev-pending", "owl-os-pi5-v0.16.1", "2026-10-16T14:01:40.123Z"), + inventory("dev-accepted", "owl-os-pi5-v0.16.2", "2026-10-16T14:01:40.123456789Z"), + inventory("dev-two-keys", "owl-os-pi5-v0.16.2", "2026-10-16T14:01:41Z"), + inventory("dev-reflashing", "owl-os-pi5-v0.16.0", "2026-10-16T16:01:42+02:00"), + inventory("dev-accepted-no-set", "owl-os-pi5-v0.16.2", "2026-10-16T14:01:43.5Z"), + inventory("dev-no-identity", "owl-os-pi5-v0.15.9", "2026-10-15T23:59:59.999999999Z"), + {"id": "dev-empty-node-id", "attributes": [], "updated_ts": "not a time"}, + # Gone from devauth: inventory alone does not put a device in the snapshot. + inventory("dev-decommissioned", "owl-os-pi5-v0.14.0", "2026-09-01T00:00:00Z"), +] + + +def entry(device_id, node_id, auth_status, set_id=None, device_type=None, artifact=None, updated=None): + return { + "mender_device_id": device_id, + "node_id": node_id, + "auth_status": auth_status, + "auth_set_id": set_id, + "auth_set_pubkey": key(set_id) if set_id else None, + "device_type": device_type, + "artifact_name": artifact, + "inventory_updated_at": updated, + } + + +EXPECTED = [ + entry("dev-pending", "ret00000001", "pending", None, "pi5-v3-arm64", "owl-os-pi5-v0.16.1", "2026-10-16T14:01:40Z"), + entry("dev-accepted", "ret00000002", "accepted", "set-a", "pi5-v3-arm64", "owl-os-pi5-v0.16.2", + "2026-10-16T14:01:40Z"), + entry("dev-two-keys", "ret00000003", "ambiguous", None, "pi5-v3-arm64", "owl-os-pi5-v0.16.2", + "2026-10-16T14:01:41Z"), + entry("dev-reflashing", "ret00000004", "accepted", "set-r-old", "pi5-v3-arm64", "owl-os-pi5-v0.16.0", + "2026-10-16T14:01:42Z"), + entry("dev-accepted-no-set", "ret00000005", "ambiguous", None, "pi5-v3-arm64", "owl-os-pi5-v0.16.2", + "2026-10-16T14:01:43Z"), + entry("dev-no-identity", None, "rejected", None, "pi5-v3-arm64", "owl-os-pi5-v0.15.9", "2026-10-15T23:59:59Z"), + entry("dev-empty-node-id", None, "preauthorized"), + entry("dev-no-inventory", "lab-bench-7", "accepted", "set-i"), +] + + +@pytest.mark.parametrize("expected", EXPECTED, ids=[e["mender_device_id"] for e in EXPECTED]) +def test_each_device_reduces_to_its_contract_entry(expected): + devices = mender_snapshot.build_snapshot(GENERATED_AT, DEVICES, INVENTORY)["devices"] + got = {d["mender_device_id"]: d for d in devices} + assert got[expected["mender_device_id"]] == expected + + +def test_the_body_is_exactly_the_contract(monkeypatch): + """extra="forbid" on the receiver: one stray key and every push is a 422.""" + isolate(monkeypatch) + body = mender_snapshot.build_snapshot(GENERATED_AT, DEVICES, INVENTORY) + assert set(body) == {"generated_at", "source", "devices"} + assert body["generated_at"] == GENERATED_AT + assert body["source"] == "mender-auto-accept@central" + assert all(set(d) == CONTRACT_KEYS for d in body["devices"]) + assert body["devices"] == EXPECTED + + +ABSENT = object() # the device has no "status" key at all + + +@pytest.mark.parametrize("status,auth_sets,expected", [ + # Mender left the device status out: the auth sets decide. + (ABSENT, [auth_set("s1", "accepted")], ("accepted", "s1", key("s1"))), + (ABSENT, [auth_set("s1", "pending")], ("pending", None, None)), + # A status that is there but unreadable is never taken as "accepted". + (None, [auth_set("s1", "accepted")], ("ambiguous", None, None)), + ("", [auth_set("s1", "accepted")], ("ambiguous", None, None)), + (7, [auth_set("s1", "accepted")], ("ambiguous", None, None)), + (7, [], ("pending", None, None)), + # An accepted set with a device status that disagrees is passed through, as _record does. + ("pending", [auth_set("s1", "accepted")], ("pending", "s1", key("s1"))), + # Unreadable auth_sets count as none, so an "accepted" device is ambiguous. + ("accepted", {"s1": "accepted"}, ("ambiguous", None, None)), + ("accepted", [auth_set("s1", "accepted"), "s2"], ("ambiguous", None, None)), + ("accepted", "accepted", ("ambiguous", None, None)), + ("accepted", None, ("ambiguous", None, None)), + ("pending", [auth_set("s1", "accepted"), None], ("pending", None, None)), + # Statuses the contract names go through as they are. + ("rejected", [], ("rejected", None, None)), + ("noauth", [], ("noauth", None, None)), +]) +def test_auth_state_edges(status, auth_sets, expected): + device = {"id": "d1", "auth_sets": auth_sets} + if status is not ABSENT: + device["status"] = status + assert mender_snapshot.auth_state(device) == expected + + +UNUSABLE_SETS = { + "no-id": {"pubkey": key("s1"), "status": "accepted"}, + "empty-id": {"id": "", "pubkey": key("s1"), "status": "accepted"}, + "int-id": {"id": 42, "pubkey": key("s1"), "status": "accepted"}, + "no-pubkey": {"id": "s1", "status": "accepted"}, + "empty-pubkey": {"id": "s1", "pubkey": "", "status": "accepted"}, + "list-pubkey": {"id": "s1", "pubkey": ["x"], "status": "accepted"}, +} + + +@pytest.mark.parametrize("status", [ABSENT, "accepted"], ids=["status-absent", "status-accepted"]) +@pytest.mark.parametrize("accepted_set", UNUSABLE_SETS.values(), ids=UNUSABLE_SETS.keys()) +def test_an_accepted_set_without_its_id_or_pubkey_is_ambiguous(status, accepted_set): + """The contract wants both for an accepted device, and the receiver would + store it with an issue if either is null.""" + device = {"id": "d1", "auth_sets": [accepted_set, auth_set("s2", "pending")]} + if status is not ABSENT: + device["status"] = status + assert mender_snapshot.auth_state(device) == ("ambiguous", None, None) + + +def test_a_disagreeing_status_still_sends_a_non_string_set_id_or_pubkey_as_null(): + device = {"id": "d1", "status": "pending", "auth_sets": [{"id": 42, "pubkey": ["x"], "status": "accepted"}]} + assert mender_snapshot.auth_state(device) == ("pending", None, None) + + +def test_every_accepted_entry_carries_its_set_id_and_pubkey(): + """Null is allowed only when not accepted, whatever Mender sends.""" + junk = [{"id": f"junk-{name}", "auth_sets": [accepted_set]} for name, accepted_set in UNUSABLE_SETS.items()] + junk += [{"id": f"junk-status-{n}", "status": status, "auth_sets": [auth_set("s1", "accepted")]} + for n, status in enumerate([None, "", 7, ["accepted"]])] + devices = mender_snapshot.build_snapshot(GENERATED_AT, DEVICES + junk, INVENTORY)["devices"] + accepted = [d for d in devices if d["auth_status"] == "accepted"] + assert [d["mender_device_id"] for d in accepted] == ["dev-accepted", "dev-reflashing", "dev-no-inventory"] + assert all(isinstance(d[k], str) and d[k] for d in accepted for k in ("auth_set_id", "auth_set_pubkey")) + + +@pytest.mark.parametrize("identity", [None, "node_id=ret1", ["ret1"], {}, {"node_id": ""}, {"node_id": 5}]) +def test_an_unusable_node_id_goes_out_as_null(identity): + device = {"id": "d1", "status": "pending", "auth_sets": []} + if identity is not None: + device["identity_data"] = identity + assert mender_snapshot.device_entry(device, None)["node_id"] is None + + +def test_devices_without_an_id_are_left_out_with_a_warning(capsys): + devices = [{"status": "pending"}, "garbage", {"id": ""}, {"id": 5}, None, DEVICES[0]] + body = mender_snapshot.build_snapshot(GENERATED_AT, devices, []) + assert [d["mender_device_id"] for d in body["devices"]] == ["dev-pending"] + assert capsys.readouterr().err.count("Warning: Mender device with no id") == 5 + + +def test_a_node_id_on_two_devices_is_warned_about_but_both_are_sent(capsys): + twin = {**DEVICES[1], "id": "dev-accepted-twin"} + body = mender_snapshot.build_snapshot(GENERATED_AT, [DEVICES[1], twin, DEVICES[0]], []) + assert [d["mender_device_id"] for d in body["devices"]] == ["dev-accepted", "dev-accepted-twin", "dev-pending"] + err = capsys.readouterr().err + assert "node_id ret00000002 is on 2 Mender devices (dev-accepted, dev-accepted-twin)" in err + assert "ret00000001" not in err + + +@pytest.mark.parametrize("attributes,expected", [ + ([{"name": "device_type", "value": "from-identity", "scope": "identity"}, + {"name": "device_type", "value": "pi5-v3-arm64", "scope": "inventory"}], "pi5-v3-arm64"), + ([{"name": "device_type", "value": "first"}, {"name": "device_type", "value": "second"}], "first"), + ([{"name": "device_type", "value": ["pi5"], "scope": "inventory"}], None), + ([{"name": "device_type", "value": 5, "scope": "inventory"}], None), + ([{"name": "artifact_name", "value": "owl-os", "scope": "inventory"}], None), + (["device_type", None], None), + ({"device_type": "pi5-v3-arm64"}, None), + (None, None), +]) +def test_inventory_attributes_prefer_the_inventory_scope(attributes, expected): + inv = {"id": "d1", "attributes": attributes} + assert mender_snapshot.device_entry({"id": "d1"}, inv)["device_type"] == expected + + +@pytest.mark.parametrize("value,expected", [ + ("2026-10-16T14:01:40.123456789Z", "2026-10-16T14:01:40Z"), + ("2026-10-16T14:01:40.999999999Z", "2026-10-16T14:01:40Z"), + ("2026-10-16T14:01:40.1Z", "2026-10-16T14:01:40Z"), + ("2026-10-16T14:01:40Z", "2026-10-16T14:01:40Z"), + ("2026-10-16T16:01:40.5+02:00", "2026-10-16T14:01:40Z"), + ("2026-10-16T16:01:40.123+02:00", "2026-10-16T14:01:40Z"), + ("2026-10-16T19:31:40.123456789+05:30", "2026-10-16T14:01:40Z"), + ("2026-10-15T23:30:00-01:00", "2026-10-16T00:30:00Z"), + ("2026-10-16t14:01:40z", "2026-10-16T14:01:40Z"), + ("0001-01-01T00:00:00Z", "0001-01-01T00:00:00Z"), + (None, None), + (1760623300, None), + ("", None), + ("not a time", None), + ("2026-10-16T14:01:40", None), + ("2026-10-16 14:01:40Z", None), + ("2026-10-16T14:01:40.1234567890Z", None), + ("2026-13-01T00:00:00Z", None), + ("2026-10-16T14:01:40+24:00", None), + ("0001-01-01T00:00:00+01:00", None), + ("2026-10-16T14:01:40Z\n", None), + ("2026-10-16T14:01:40Z", None), +]) +def test_timestamps_normalise_to_utc_seconds(value, expected): + assert mender_snapshot.normalise_ts(value) == expected + + +# ── fetching ───────────────────────────────────────────────────── + +def fake_mender(monkeypatch, devauth_pages, inventory_pages, fail=None, on_call=None): + """Serve each list one page at a time; any page past the end is empty. + + fail is (url, page, response or exception) for the one request that goes wrong. + """ + calls = [] + + def fake_get(url, **kwargs): + page = kwargs["params"]["page"] + calls.append((url, kwargs)) + if on_call: + on_call() + if fail and (url, page) == fail[:2]: + if isinstance(fail[2], Exception): + raise fail[2] + return fail[2] + pages = {DEVAUTH: devauth_pages, INVENTORY_URL: inventory_pages}[url] + return _Resp(200, pages[page - 1] if page <= len(pages) else []) + + monkeypatch.setattr(mender_snapshot.requests, "get", fake_get) + return calls + + +def test_pages_are_read_until_an_empty_one_and_deduplicated(monkeypatch): + isolate(monkeypatch) + a, b, c = {"id": "a"}, {"id": "b"}, {"id": "c"} + calls = fake_mender(monkeypatch, [[a, b], [b, c]], []) + assert mender_snapshot.fetch_all(mender_snapshot.DEVAUTH_PATH) == [a, b, c] + assert calls == [ + (DEVAUTH, {"params": {"page": n, "per_page": 500}, "headers": {"Authorization": f"Bearer {PAT}"}, + "timeout": 30}) + for n in (1, 2, 3) + ] + + +def test_a_server_ignoring_page_does_not_loop(monkeypatch): + """The same first page every time adds no new id, so the second ends it.""" + isolate(monkeypatch) + calls = [] + monkeypatch.setattr(mender_snapshot.requests, "get", + lambda url, **kwargs: calls.append(kwargs) or _Resp(200, [{"id": "a"}, {"id": "b"}])) + assert mender_snapshot.fetch_all(mender_snapshot.DEVAUTH_PATH) == [{"id": "a"}, {"id": "b"}] + assert len(calls) == 2 + + +def full_page(size=None): + return [{"id": f"dev-{n}"} for n in range(mender_snapshot.PER_PAGE if size is None else size)] + + +@pytest.mark.parametrize("size", [mender_snapshot.PER_PAGE, mender_snapshot.PER_PAGE + 1], ids=["full", "over-full"]) +def test_a_server_ignoring_page_after_a_full_page_is_refused(monkeypatch, size): + """The same full page again means the rest of the list may never have been read.""" + isolate(monkeypatch) + calls = [] + page = full_page(size) + monkeypatch.setattr(mender_snapshot.requests, "get", lambda url, **kwargs: calls.append(kwargs) or _Resp(200, page)) + with pytest.raises(RuntimeError, match=re.escape(f"{mender_snapshot.DEVAUTH_PATH} page 2")): + mender_snapshot.fetch_all(mender_snapshot.DEVAUTH_PATH) + assert len(calls) == 2 + + +def test_a_first_page_with_entries_but_no_ids_is_refused(monkeypatch): + """Returning nothing for it would push an empty fleet and mark every device absent.""" + isolate(monkeypatch) + fake_mender(monkeypatch, [[{"status": "pending"}, "junk"]], []) + with pytest.raises(RuntimeError, match=re.escape(f"{mender_snapshot.DEVAUTH_PATH} page 1")): + mender_snapshot.fetch_all(mender_snapshot.DEVAUTH_PATH) + + +def test_a_list_that_never_ends_is_refused(monkeypatch): + isolate(monkeypatch) + calls = [] + + def endless(url, **kwargs): + calls.append(kwargs["params"]["page"]) + return _Resp(200, [{"id": f"dev-{kwargs['params']['page']}"}]) + + monkeypatch.setattr(mender_snapshot.requests, "get", endless) + with pytest.raises(RuntimeError, match="50 pages"): + mender_snapshot.fetch_all(mender_snapshot.DEVAUTH_PATH) + assert calls == list(range(1, 51)) + + +def test_entries_without_an_id_survive_the_fetch_for_the_warning(monkeypatch): + isolate(monkeypatch) + fake_mender(monkeypatch, [[{"id": "a"}, {"status": "pending"}, "junk"]], []) + assert mender_snapshot.fetch_all(mender_snapshot.DEVAUTH_PATH) == [{"id": "a"}, {"status": "pending"}, "junk"] + + +@pytest.mark.parametrize("payload", [{"error": "nope"}, "devices", None]) +def test_a_page_that_is_not_a_list_is_a_failure(monkeypatch, payload): + isolate(monkeypatch) + monkeypatch.setattr(mender_snapshot.requests, "get", lambda url, **kwargs: _Resp(200, payload)) + with pytest.raises(ValueError, match="not a list"): + mender_snapshot.fetch_all(mender_snapshot.DEVAUTH_PATH) + + +def test_generated_at_is_stamped_before_the_devauth_fetch_starts(monkeypatch): + """The server measures staleness from generated_at, so it must not claim + data fresher than the first request. Each request here takes 7 s.""" + isolate(monkeypatch) + clock = [datetime(2026, 10, 16, 14, 2, 11, 750000, tzinfo=timezone.utc)] + + def tick(): + clock[0] += timedelta(seconds=7) + + monkeypatch.setattr(mender_snapshot, "_now", lambda: clock[0]) + calls = fake_mender(monkeypatch, [DEVICES], [INVENTORY], on_call=tick) + body = mender_snapshot.collect() + assert body["generated_at"] == "2026-10-16T14:02:11Z" + assert [url for url, _ in calls] == [DEVAUTH, DEVAUTH, INVENTORY_URL, INVENTORY_URL] + assert body["devices"] == EXPECTED + + +# ── pushing ────────────────────────────────────────────────────── + +BODY = {"generated_at": GENERATED_AT, "source": "mender-auto-accept@central", "devices": EXPECTED} +TARGETS = [ + ("https://prod.example/api/internal/mender/snapshot", "prod-token"), + ("https://staging.example/api/internal/mender/snapshot", "staging-token"), + ("http://localhost:8000/api/internal/mender/snapshot", "test-token"), +] + + +def fake_targets(monkeypatch, responses): + """responses maps a url to the _Resp it answers or the exception it raises.""" + calls = [] + + def fake_post(url, **kwargs): + calls.append((url, kwargs)) + if isinstance(responses[url], Exception): + raise responses[url] + return responses[url] + + monkeypatch.setattr(mender_snapshot.requests, "post", fake_post) + return calls + + +def test_one_failing_target_does_not_stop_the_others(monkeypatch, capsys): + isolate(monkeypatch) + calls = fake_targets(monkeypatch, { + TARGETS[0][0]: _Resp(200, {"accepted": 7, "rejected": 1, "absent": 1, "generated_at": GENERATED_AT}), + TARGETS[1][0]: mender_snapshot.requests.ConnectionError("connection refused"), + TARGETS[2][0]: _Resp(200, {"accepted": 8, "rejected": 0, "absent": 0, "generated_at": GENERATED_AT}), + }) + assert mender_snapshot.push(BODY, TARGETS) == 2 + assert calls == [ + (url, {"json": BODY, "headers": {"Authorization": f"Bearer {token}", "User-Agent": mender_snapshot.USER_AGENT}, + "timeout": 10, + "allow_redirects": False}) + for url, token in TARGETS + ] + assert PAT not in repr(calls) + out, err = capsys.readouterr() + assert "Pushed snapshot to prod.example/api/internal/mender/snapshot: 8 devices, accepted 7, rejected 1, absent 1" in out + assert "Pushed snapshot to localhost:8000/api/internal/mender/snapshot: 8 devices, accepted 8, rejected 0, absent 0" in out + assert "Error pushing snapshot to staging.example/api/internal/mender/snapshot: connection refused" in err + assert out.rstrip().endswith("Mender snapshot: pushed to 2 of 3 targets") + + +def test_the_push_sends_a_browser_user_agent_for_cloudflare(monkeypatch): + """A script's default User-Agent gets 403 error 1010 from Cloudflare's browser integrity check.""" + isolate(monkeypatch) + calls = fake_targets(monkeypatch, {TARGETS[0][0]: _Resp(200, {})}) + mender_snapshot.push(BODY, TARGETS[:1]) + headers = calls[0][1]["headers"] + assert headers["User-Agent"] == mender_snapshot.USER_AGENT + assert headers["User-Agent"].startswith("Mozilla/5.0 ") + assert "python-requests" not in headers["User-Agent"] + + +def test_a_200_without_a_rejected_count_still_logs_the_others(monkeypatch, capsys): + """Older receivers answer accepted and absent only.""" + isolate(monkeypatch) + fake_targets(monkeypatch, {TARGETS[0][0]: _Resp(200, {"accepted": 8, "absent": 2})}) + assert mender_snapshot.push(BODY, TARGETS[:1]) == 1 + out, err = capsys.readouterr() + assert "8 devices, accepted 8, rejected ?, absent 2" in out + assert err == "" + + +def test_a_409_counts_as_delivered_and_is_not_an_error(monkeypatch, capsys): + """A newer snapshot already landed there, from a retry or a second sender.""" + isolate(monkeypatch) + fake_targets(monkeypatch, {TARGETS[0][0]: _Resp(409, {"detail": "stale generated_at"})}) + assert mender_snapshot.push(BODY, TARGETS[:1]) == 1 + out, err = capsys.readouterr() + assert "a newer one already landed (409)" in out + assert "pushed to 1 of 1 targets" in out + assert err == "" + + +@pytest.mark.parametrize("resp,shown", [ + (_Resp(401, text="bad token"), "HTTP 401: bad token"), + (_Resp(422, text="x" * 500), "HTTP 422: " + "x" * 200 + "\n"), + (_Resp(500, text="oops"), "HTTP 500: oops"), + (_Resp(301, headers={"Location": "https://www.prod.example/"}), + "HTTP 301 (redirect to https://www.prod.example/, not followed)\n"), + (_Resp(204, text=""), None), +]) +def test_other_statuses_are_errors_and_bodies_are_cut(monkeypatch, capsys, resp, shown): + isolate(monkeypatch) + fake_targets(monkeypatch, {TARGETS[0][0]: resp}) + pushed = mender_snapshot.push(BODY, TARGETS[:1]) + out, err = capsys.readouterr() + if shown is None: + assert pushed == 1 and err == "" + else: + assert pushed == 0 + assert shown in err + assert "x" * 201 not in err + assert "pushed to 0 of 1 targets" in out + + +@pytest.mark.parametrize("payload", [mender_snapshot.requests.JSONDecodeError("Expecting value", "", 0), + ["not", "a", "dict"]]) +def test_an_unreadable_2xx_body_still_counts(monkeypatch, capsys, payload): + isolate(monkeypatch) + fake_targets(monkeypatch, {TARGETS[0][0]: _Resp(200, payload)}) + assert mender_snapshot.push(BODY, TARGETS[:1]) == 1 + out, err = capsys.readouterr() + assert "8 devices, accepted ?, rejected ?, absent ?" in out + assert err == "" + + +# ── run(): when it pushes, and when it must not ────────────────── + +def test_run_pushes_the_whole_fleet_to_every_target(monkeypatch): + isolate(monkeypatch, targets=",".join(f"{u}={t}" for u, t in TARGETS)) + monkeypatch.setattr(mender_snapshot, "_now", lambda: datetime(2026, 10, 16, 14, 2, 11, tzinfo=timezone.utc)) + gets = fake_mender(monkeypatch, [DEVICES[:5], DEVICES[3:]], [INVENTORY[:4], INVENTORY[4:]]) + posts = fake_targets(monkeypatch, {url: _Resp(200, {"accepted": 8, "absent": 0}) for url, _ in TARGETS}) + mender_snapshot.run() + assert len(gets) == 6 + assert all(kwargs["headers"] == {"Authorization": f"Bearer {PAT}"} for _, kwargs in gets) + assert [url for url, _ in posts] == [url for url, _ in TARGETS] + assert all(kwargs["json"] == BODY for _, kwargs in posts) + assert PAT not in repr(posts) + + +@pytest.mark.parametrize("fail", [ + (DEVAUTH, 1, _Resp(500)), + (DEVAUTH, 2, mender_snapshot.requests.ConnectionError("reset")), + (DEVAUTH, 1, _Resp(200, {"devices": []})), + (INVENTORY_URL, 1, _Resp(401)), + (INVENTORY_URL, 2, mender_snapshot.requests.Timeout("read timed out")), + (INVENTORY_URL, 2, _Resp(200, mender_snapshot.requests.JSONDecodeError("Expecting value", "", 0))), +], ids=["devauth-500", "devauth-page2-reset", "devauth-not-a-list", "inventory-401", "inventory-page2-timeout", + "inventory-page2-not-json"]) +def test_a_failed_fetch_pushes_nothing(monkeypatch, capsys, fail): + """A partial fleet would mark the missing devices absent on every server.""" + unexpected = isolate(monkeypatch, targets=f"{TARGETS[0][0]}={TARGETS[0][1]}") + fake_mender(monkeypatch, [DEVICES[:4], DEVICES[4:]], [INVENTORY[:4], INVENTORY[4:]], fail=fail) + posts = fake_targets(monkeypatch, {}) + mender_snapshot.run() + assert posts == [] + assert unexpected == [] + assert "Error: Mender snapshot not pushed" in capsys.readouterr().err + + +def test_a_repeated_full_page_pushes_nothing(monkeypatch, capsys): + """A server ignoring `page` with more than one page of devices: the rest would be marked absent.""" + unexpected = isolate(monkeypatch, targets=f"{TARGETS[0][0]}={TARGETS[0][1]}") + page = full_page() + fake_mender(monkeypatch, [page, page], [INVENTORY]) + posts = fake_targets(monkeypatch, {}) + mender_snapshot.run() + assert posts == [] + assert unexpected == [] + assert f"Error: Mender snapshot not pushed: {mender_snapshot.DEVAUTH_PATH} page 2" in capsys.readouterr().err + + +@pytest.mark.parametrize("bad", ["\n", "\r", "\t", " ", "é", "\x00", "\x7f"], + ids=["lf", "cr", "tab", "space", "non-ascii", "nul", "del"]) +@pytest.mark.parametrize("where", ["inner", "trailing"]) +def test_a_pat_that_cannot_be_a_header_means_no_mender_call_and_is_never_printed(monkeypatch, capsys, bad, where): + pat = f"PATHEAD{bad}PATTAIL" if where == "inner" else f"PATHEAD-PATTAIL{bad}" + isolate(monkeypatch, targets=f"{TARGETS[0][0]}={TARGETS[0][1]}", pat=pat) + attempted = build_then_refuse(monkeypatch) + mender_snapshot.run() + out, err = capsys.readouterr() + assert attempted == [] + assert "Error: Mender snapshot not pushed: MENDER_PAT has a space, control or non-ASCII character" in err + assert "PATHEAD" not in out + err + assert "PATTAIL" not in out + err + + +@pytest.mark.parametrize("targets,pat", [ + ("", PAT), + (" ", PAT), + (" , ,", PAT), + ("http://remote.example/api/internal/mender/snapshot=t", PAT), + ("no-equals-sign", PAT), + ("https://prod.example/api/internal/mender/snapshot=t", None), + ("https://prod.example/api/internal/mender/snapshot=t", ""), +], ids=["unset", "blank", "only-commas", "http-to-remote", "no-equals", "pat-unset", "pat-empty"]) +def test_no_targets_or_no_pat_means_no_mender_call(monkeypatch, capsys, targets, pat): + unexpected = isolate(monkeypatch, targets=targets, pat=pat) + mender_snapshot.run() + assert unexpected == [] + assert "not pushed" not in capsys.readouterr().err + + +def test_unset_targets_print_nothing(monkeypatch, capsys): + unexpected = isolate(monkeypatch) + mender_snapshot.run() + assert unexpected == [] + assert capsys.readouterr() == ("", "") + + +def test_run_never_raises(monkeypatch, capsys): + def boom(): + raise KeyError("devices") + + isolate(monkeypatch, targets=f"{TARGETS[0][0]}={TARGETS[0][1]}") + monkeypatch.setattr(mender_snapshot, "collect", boom) + assert mender_snapshot.run() is None + assert "Error: Mender snapshot not pushed: 'devices'" in capsys.readouterr().err + + +# ── RETINA_SYNC_TARGETS ────────────────────────────────────────── + +def test_targets_parse_and_bad_entries_never_print_their_token(capsys): + raw = ", ".join([ + " https://prod.example/api/internal/mender/snapshot = tok==en= ", + "", + "secret-no-equals", + "http://remote.example/x=secret-http", + "http://localhost:8000/api/internal/mender/snapshot=local-tok", + "https://test.example/x=", + "=secret-no-url", + "http://[::1]:8000/x=v6-tok", + "https:///x=secret-no-host", + "ftp://files.example/x=secret-ftp", + "secret-swapped=https://prod.example/x", + "HTTPS://Staging.Example/api/internal/mender/snapshot=st=g", + ]) + assert mender_snapshot.parse_targets(raw) == [ + ("https://prod.example/api/internal/mender/snapshot", "tok==en="), + ("http://localhost:8000/api/internal/mender/snapshot", "local-tok"), + ("http://[::1]:8000/x", "v6-tok"), + ("HTTPS://Staging.Example/api/internal/mender/snapshot", "st=g"), + ] + out, err = capsys.readouterr() + assert out == "" + assert err.count("Error: RETINA_SYNC_TARGETS entry") == 7 + assert "entry 2 has no" in err + for leaked in ("secret", "tok==en=", "local-tok", "v6-tok", "st=g"): + assert leaked not in err + + +@pytest.mark.parametrize("bad", ["\n", "\r", "\t", " ", "é", "’", "\x00", "\x7f"], + ids=["lf", "cr", "tab", "space", "latin-1", "non-latin-1", "nul", "del"]) +def test_a_token_that_cannot_be_a_header_is_skipped_unprinted(capsys, bad): + """requests would refuse some of these with an error quoting the token, which push() prints.""" + raw = f"https://prod.example/x=good-tok, http://localhost:9/api/internal/mender/snapshot=SECRETHEAD{bad}SECRETTAIL" + assert mender_snapshot.parse_targets(raw) == [("https://prod.example/x", "good-tok")] + out, err = capsys.readouterr() + assert out == "" + assert err == ("Error: RETINA_SYNC_TARGETS entry 2 has a token with a space, control or non-ASCII character, " + "skipping it\n") + + +def test_a_token_with_a_newline_never_reaches_push(monkeypatch, capsys): + """The reported case: requests refused the header and push() printed "Bearer ".""" + isolate(monkeypatch) + attempted = build_then_refuse(monkeypatch) + targets = mender_snapshot.parse_targets("http://localhost:9/api/internal/mender/snapshot=SECRETTOKEN123\nSECONDLINE") + mender_snapshot.push(BODY, targets) + out, err = capsys.readouterr() + assert attempted == [] + assert "SECRETTOKEN123" not in out + err + assert "SECONDLINE" not in out + err + + +@pytest.mark.parametrize("raw", [None, "", " ", ",", " , ,"]) +def test_empty_targets_parse_to_nothing_silently(capsys, raw): + assert mender_snapshot.parse_targets(raw) == [] + assert capsys.readouterr() == ("", "") + + +# ── the hook in auto_accept.py ─────────────────────────────────── + +AUTO_ACCEPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "auto_accept.py") + + +def test_auto_accept_pushes_after_its_pass_and_keeps_its_exit_status(monkeypatch): + isolate(monkeypatch) + calls = [] + monkeypatch.delenv("MENDER_PAT", raising=False) + monkeypatch.setattr(mender_snapshot, "run", lambda: calls.append("snapshot")) + with pytest.raises(SystemExit) as exit_info: + runpy.run_path(AUTO_ACCEPT, run_name="__main__") + assert exit_info.value.code == 1 + assert calls == ["snapshot"] + + +def test_auto_accept_pushes_even_when_its_pass_raises(monkeypatch, tmp_path): + """The traceback is main's, not swallowed, and the snapshot still goes.""" + def broken(url, **kwargs): + raise RuntimeError("mender exploded") + + isolate(monkeypatch) + calls = [] + monkeypatch.setenv("MENDER_PAT", "fake-pat") + monkeypatch.setenv("PENDING_DEPLOY_FILE", str(tmp_path / "pending.json")) + monkeypatch.setattr(mender_snapshot.requests, "get", broken) + monkeypatch.setattr(mender_snapshot, "run", lambda: calls.append("snapshot")) + with pytest.raises(RuntimeError, match="mender exploded"): + runpy.run_path(AUTO_ACCEPT, run_name="__main__") + assert calls == ["snapshot"]