diff --git a/adi_lg_plugins/drivers/_remote.py b/adi_lg_plugins/drivers/_remote.py index 1c71cf2..b5d7364 100644 --- a/adi_lg_plugins/drivers/_remote.py +++ b/adi_lg_plugins/drivers/_remote.py @@ -29,6 +29,7 @@ import hashlib import os +import socket import subprocess from labgrid.resource.common import NetworkResource @@ -37,6 +38,33 @@ _STAGE_ROOT = "/tmp/adi-lg-stage" +#: host -> resolvable form, memoized (DNS probes are slow and per-run stable). +_RESOLVED_HOSTS: dict[str, str] = {} + + +def _resolvable_host(host: str) -> str: + """Return ``host`` in a form the local resolver can actually resolve. + + The coordinator names exporter hosts by bare name (e.g. ``tron``); on many + lab networks only the mDNS ``.local`` form resolves. Mirror + ``hw_ci.all_places.host_reachable``: keep the bare name when it resolves, + fall back to ``.local`` when only that resolves, and otherwise return + the input unchanged so the eventual ssh error names the real host. + """ + cached = _RESOLVED_HOSTS.get(host) + if cached is not None: + return cached + resolved = host + for candidate in (host, f"{host}.local"): + try: + socket.getaddrinfo(candidate, None) + resolved = candidate + break + except OSError: + continue + _RESOLVED_HOSTS[host] = resolved + return resolved + def _sha256_file(path: str) -> str: h = hashlib.sha256() @@ -78,10 +106,11 @@ def _exporter_host(res): """ host = getattr(res, "host", None) if host: - return host + return _resolvable_host(host) extra = getattr(res, "extra", None) or {} if isinstance(extra, dict): - return extra.get("proxy") + proxy = extra.get("proxy") + return _resolvable_host(proxy) if proxy else None return None def _remote_prefix(self): diff --git a/adi_lg_plugins/hw_ci/all_places.py b/adi_lg_plugins/hw_ci/all_places.py index 4843abc..3feb5d5 100644 --- a/adi_lg_plugins/hw_ci/all_places.py +++ b/adi_lg_plugins/hw_ci/all_places.py @@ -31,6 +31,9 @@ "BootFPGASoCSSH", "BootTickFPGASSH", "BootRPI", + # JTAG-strapped ZynqMP production boot (ADRV9009-ZU11EG on tron): + # confirmed to boot Kuiper and serve iiod (validated 2026-08-04). + "BootZynqMPJTAG", } ) diff --git a/adi_lg_plugins/hw_ci/render_env.py b/adi_lg_plugins/hw_ci/render_env.py index 49ad1a5..1dc935f 100644 --- a/adi_lg_plugins/hw_ci/render_env.py +++ b/adi_lg_plugins/hw_ci/render_env.py @@ -175,6 +175,19 @@ def render_env( "firmware_elf": place.extra_tags.get("firmware-elf", ""), "boot_marker": place.extra_tags.get("boot-marker", "Successfully initialized"), "a9_target_name": place.extra_tags.get("a9-target-name", "*Cortex-A9 MPCore #0"), + # BootZynqMPJTAG (JTAG-strapped ZynqMP production boot): boot payloads + # live at canonical names under `recovery-root` on the exporter host + # (see the template header); serial overrides handle ser2net/pyserial + # rfc2217 quirks and coordinator-advertised hosts that don't resolve + # from the runner. Lab admin opts a place in, e.g.: + # labgrid-client -p tron set-tags boot-strategy=BootZynqMPJTAG \ + # recovery-root=/home/tcollins/zu11eg-recovery \ + # bitstream-path=/home/tcollins/zu11eg-recovery/bitstream.bin \ + # serial-protocol-override=raw + "recovery_root": place.extra_tags.get("recovery-root", "/tmp/recovery"), + "jtag_url": place.extra_tags.get("jtag-url", "TCP:127.0.0.1:3121"), + "serial_host_override": place.extra_tags.get("serial-host-override", ""), + "serial_protocol_override": place.extra_tags.get("serial-protocol-override", ""), } if extra_subs: subs.update({str(k): str(v) for k, v in extra_subs.items()}) diff --git a/adi_lg_plugins/hw_ci/templates/BootZynqMPJTAG.yaml b/adi_lg_plugins/hw_ci/templates/BootZynqMPJTAG.yaml index 260045c..3addbc1 100644 --- a/adi_lg_plugins/hw_ci/templates/BootZynqMPJTAG.yaml +++ b/adi_lg_plugins/hw_ci/templates/BootZynqMPJTAG.yaml @@ -1,14 +1,22 @@ # Auto-generated by adi_lg_plugins.hw_ci.render_env for place ${place_name}. # carrier=${carrier} daughter-board=${daughter_board} hdl-config=${hdl_config} # -# BootZynqMPJTAG: JTAG boot for UltraScale+ (ZynqMP) boards via the Xilinx -# "mini" U-Boot SPL (EL3, no ATF / no PMU-FW). The DUT is brought up over -# JTAG far enough to own the SD host controller; useful for re-flashing -# broken SD cards. DCC/JTAG-UART console is captured via xsdb. +# BootZynqMPJTAG: production boot for JTAG-strapped UltraScale+ (ZynqMP) +# boards — e.g. the ADRV9009-ZU11EG on ADRV2CRR-FMC (place `tron`), whose +# mode straps are fixed to JTAG. xsdb wakes the PMU ROM with the production +# PMUFW payload, optionally programs the PL, and enters production U-Boot +# through BL31 + the XLNX handoff; the strategy then drives U-Boot's SD +# path into a Kuiper shell. transition("shell") (the fleet-wide +# booted-Linux alias for this strategy's kuiper_shell) therefore works with +# the generic hardware-request flow. # -# Caller must set psu_init_tcl + spl_elf (and optional bitstream_path) to -# paths readable by the host that runs xsdb. See -# examples/ultrascale_jtag_boot/ for how to build the mini SPL. +# Boot payloads live under the `recovery-root` place tag (default +# /tmp/recovery) at CANONICAL names on the exporter host that runs xsdb: +# psu_init.tcl spl.elf pmufw.bin u-boot.bin handoff.bin bl31.bin +# atf-handoff.bin pm-config.bin ddr-scrub.elf +# Extract/build them with examples/ultrascale_jtag_boot/ +# (prepare-production-boot.py + build-mini-uboot.sh + build-recovery-uboot.sh). +# The PL bitstream is per-place via the `bitstream-path` tag (empty = skip). imports: - adi_lg_plugins @@ -30,43 +38,67 @@ targets: XilinxJTAGDriver: {} SerialDriver: {} ADIShellDriver: - prompt: 'root@[^:]+:[^ ]+[#] ' - login_prompt: 'login:' + prompt: 'root@.*[#$$]' + login_prompt: '(analog|zu11eg-recovery) login: ?' username: 'root' + password: 'analog' BootZynqMPJTAG: - # Paths on the exporter host that runs xsdb (fill in per bench). - psu_init_tcl: '/tmp/recovery/psu_init.tcl' - spl_elf: '/tmp/recovery/u-boot-spl' - # Optional production handoff after repairing the SD. Uncomment all - # required payloads before transition("production_boot"). - # pmufw_bin: '/tmp/recovery/production/pmufw.bin' - # uboot_bin: '/tmp/recovery/production/u-boot.bin' - # bl31_bin: '/tmp/recovery/production/bl31.bin' - # atf_handoff_bin: '/tmp/recovery/production/atf-handoff.bin' - # pm_config_bin: '/tmp/recovery/production/pm-config-object.bin' - # ddr_scrub_elf: '/tmp/recovery/ddr-ecc-scrub.elf' - # Direct-JTAG RAM recovery Linux (transition "recovery_linux"): - # recovery_trampoline_elf: '/tmp/recovery/el3-to-el2.elf' - # recovery_kernel_image: '/tmp/recovery/Image-recovery' - # recovery_initramfs: '/tmp/recovery/initramfs.cpio.gz' - # recovery_dtb: '/tmp/recovery/system-recovery.dtb' - # Recovery uses its low-bank scrubber and board-specific SD reset. - # recovery_ddr_scrub_elf: '/tmp/recovery/ddr-ecc-scrub-low-2g.elf' + # JTAG bootstrap inputs (paths on the host that runs xsdb). + psu_init_tcl: '${recovery_root}/psu_init.tcl' + spl_elf: '${recovery_root}/spl.elf' + # Production handoff payloads (see prepare-production-boot.py). + pmufw_bin: '${recovery_root}/pmufw.bin' + uboot_bin: '${recovery_root}/u-boot.bin' + handoff_bin: '${recovery_root}/handoff.bin' + bl31_bin: '${recovery_root}/bl31.bin' + atf_handoff_bin: '${recovery_root}/atf-handoff.bin' + pm_config_bin: '${recovery_root}/pm-config.bin' + # BL31 in the ADI production image logs on UART0 even when the + # external console is UART1 — initialize it (generic ZynqMP UART0 / + # CRL_APB register addresses). + bl31_console_uart_base: '0xFF000000' + bl31_console_ref_ctrl_address: '0xFF5E0074' + bl31_console_reset_mask: '0x2' + # Optional PL bitstream, programmed at the xsdb `fpga` step before + # psu_init (`bitstream-path` place tag; raw .bit works). + bitstream_path: '${bitstream_path}' + # Full-bank DDR ECC scrubber (psu_init enables ECC uninitialized). + ddr_scrub_elf: '${recovery_root}/ddr-scrub.elf' + ddr_scrub_done_address: '0xFFFC0054' + ddr_scrub_settle_ms: 120000 + # 0x380E releases A53 #0 only (generated ZU11EG value); 0x0 = all A53s. + apu_release_rst_value: '0x380E' + a53_target_name: '*Cortex-A53*#0*' + # xsdb runs on the exporter via RemoteExecMixin, so the hw_server + # endpoint is exporter-local; override with the `jtag-url` tag. + jtag_url: '${jtag_url}' + # Serial quirks (per-place tags; empty = labgrid defaults). ser2net + # benches whose rfc2217 hangs pyserial's 'purge' negotiation set + # `serial-protocol-override=raw`; benches whose coordinator-advertised + # serial host doesn't resolve from the runner set + # `serial-host-override=.local`. + serial_host_override: '${serial_host_override}' + serial_protocol_override: '${serial_protocol_override}' + # Capture the mini-SPL DCC console (headless). + dcc_log_path: '/tmp/zynqmp-mini-dcc.log' + spl_settle_ms: 12000 + production_settle_ms: 15000 + pmufw_timeout_ms: 10000 + power_off_settle_s: 5 + power_on_settle_s: 8 + # Direct-JTAG RAM recovery Linux (transition "recovery_linux") and the + # destructive SD reflash are opt-in — uncomment and stage the extra + # payloads before using those states. + # recovery_trampoline_elf: '${recovery_root}/el3-to-el2.elf' + # recovery_kernel_image: '${recovery_root}/Image-recovery' + # recovery_initramfs: '${recovery_root}/initramfs.cpio.gz' + # recovery_dtb: '${recovery_root}/system-recovery.dtb' + # recovery_ddr_scrub_elf: '${recovery_root}/ddr-ecc-scrub-low-2g.elf' # recovery_ddr_scrub_done_address: '0xFFFC002C' - # recovery_bitstream_path: '/tmp/recovery/system_top.bit' + # recovery_bitstream_path: '${recovery_root}/system_top.bit' # recovery_post_init_mask_writes: # - ['0xFF5E0238', '0x00000002', '0x00000000'] - # Destructive flash state additionally requires sd_image_url, - # sd_image_size, sd_head_sha256, and sd_tail_sha256. # sd_image_url: 'http://IMAGE_SERVER/kuiper-full.img' # sd_image_size: 10305404928 # sd_head_sha256: '...' # sd_tail_sha256: '...' - # Optional PL bitstream, programmed before psu_init. - # bitstream_path: '/tmp/recovery/system_top.bit' - # 0x380E releases A53 #0 only (generated ZU11EG value); 0x0 = all A53s. - apu_release_rst_value: '0x380E' - a53_target_name: '*Cortex-A53*#0*' - # Capture the mini-SPL DCC console (headless). - dcc_log_path: '/tmp/zynqmp-mini-dcc.log' - spl_settle_ms: 12000 diff --git a/adi_lg_plugins/request/core.py b/adi_lg_plugins/request/core.py index d8ec1be..f11f1cc 100644 --- a/adi_lg_plugins/request/core.py +++ b/adi_lg_plugins/request/core.py @@ -226,7 +226,9 @@ def request( os.environ["LG_COORDINATOR"] = coord api = _resolve_api(coord) try: - match = match_client.get_match(api, part=part, carrier=carrier, bootfile=bootfile, mode=mode) + match = match_client.get_match( + api, part=part, carrier=carrier, bootfile=bootfile, mode=mode + ) except Exception as e: raise BoardUnavailable(f"Coordinator REST API at {api} is unreachable: {e}") from e if not match.satisfiable: diff --git a/adi_lg_plugins/strategies/_compat.py b/adi_lg_plugins/strategies/_compat.py index 6737e0a..318c0f5 100644 --- a/adi_lg_plugins/strategies/_compat.py +++ b/adi_lg_plugins/strategies/_compat.py @@ -29,7 +29,9 @@ def never_retry(func): def wrapper(self, *args, **kwargs): broken = getattr(self, "broken", None) if broken is not None: - raise StrategyError(f"{self.__class__.__name__} is in broken state: {broken}") from broken + raise StrategyError( + f"{self.__class__.__name__} is in broken state: {broken}" + ) from broken try: return func(self, *args, **kwargs) except Exception as e: diff --git a/adi_lg_plugins/strategies/bootzynqmpjtag.py b/adi_lg_plugins/strategies/bootzynqmpjtag.py index 7aa2bd6..25d7223 100644 --- a/adi_lg_plugins/strategies/bootzynqmpjtag.py +++ b/adi_lg_plugins/strategies/bootzynqmpjtag.py @@ -229,6 +229,12 @@ def transition(self, status, *, step): >>> strategy.transition("jtag_bootstrap") """ if not isinstance(status, Status): + # Fleet-wide convention: "shell" means the booted-Linux shell. + # This strategy's Linux state is kuiper_shell; alias it so the + # generic hardware-request flow (strategy.transition("shell")) + # can boot JTAG-strapped ZynqMP boards like any other place. + if status == "shell": + status = "kuiper_shell" status = Status[status] # Validate required inputs up-front, before @never_retry mutates any diff --git a/tests/hw_ci/test_all_places.py b/tests/hw_ci/test_all_places.py index 2577231..36c528e 100644 --- a/tests/hw_ci/test_all_places.py +++ b/tests/hw_ci/test_all_places.py @@ -163,3 +163,13 @@ def fake_conn(addr, timeout=None): monkeypatch.setattr(ap.socket, "create_connection", fake_conn) assert host_reachable("nuc") is False + + +def test_bootzynqmpjtag_place_becomes_a_uri_leg(): + # JTAG-strapped ZynqMP production boot (ADRV9009-ZU11EG on tron) boots + # Kuiper and serves iiod, so it must get a real boot-verify leg, not a + # reserve-only reachability check. + legs, _, _ = build_all_places_matrix( + [_place("tron", "adrv9009zu11eg", "adrv2crr-fmc", strategy="BootZynqMPJTAG")] + ) + assert legs[0].mode == "uri" diff --git a/tests/hw_ci/test_render_env.py b/tests/hw_ci/test_render_env.py index dca519b..3bb1b53 100644 --- a/tests/hw_ci/test_render_env.py +++ b/tests/hw_ci/test_render_env.py @@ -394,3 +394,40 @@ def test_every_template_declares_place_features(): assert feats == ["ad9081", "zcu102"], ( f"{strat}: rendered env missing place features (got {feats!r})" ) + + +# ── BootZynqMPJTAG: daily-bootable production config from place tags ── + + +def test_bootzynqmpjtag_renders_recovery_root_and_serial_overrides(): + p = _place( + "BootZynqMPJTAG", + place="tron", + extra_tags={ + "recovery-root": "/home/tcollins/zu11eg-recovery", + "bitstream-path": "/home/tcollins/zu11eg-recovery/bitstream.bin", + "serial-protocol-override": "raw", + "serial-host-override": "tron.local", + "power-driver": "HomeAssistantPowerDriver", + }, + ) + doc = yaml.safe_load(render_env(p)) + drivers = doc["targets"]["main"]["drivers"] + assert "HomeAssistantPowerDriver" in drivers + boot = drivers["BootZynqMPJTAG"] + assert boot["psu_init_tcl"] == "/home/tcollins/zu11eg-recovery/psu_init.tcl" + assert boot["pmufw_bin"] == "/home/tcollins/zu11eg-recovery/pmufw.bin" + assert boot["bitstream_path"] == "/home/tcollins/zu11eg-recovery/bitstream.bin" + assert boot["serial_protocol_override"] == "raw" + assert boot["serial_host_override"] == "tron.local" + assert boot["jtag_url"] == "TCP:127.0.0.1:3121" + # The shell prompt survives Template substitution ($$ -> $). + assert drivers["ADIShellDriver"]["prompt"] == "root@.*[#$]" + + +def test_bootzynqmpjtag_defaults_to_tmp_recovery(): + doc = yaml.safe_load(render_env(_place("BootZynqMPJTAG"))) + boot = doc["targets"]["main"]["drivers"]["BootZynqMPJTAG"] + assert boot["spl_elf"] == "/tmp/recovery/spl.elf" + assert boot["serial_host_override"] == "" + assert boot["serial_protocol_override"] == "" diff --git a/tests/test_bootzynqmpjtag_strat.py b/tests/test_bootzynqmpjtag_strat.py index d4a2e4c..89176cf 100644 --- a/tests/test_bootzynqmpjtag_strat.py +++ b/tests/test_bootzynqmpjtag_strat.py @@ -253,3 +253,13 @@ def test_kuiper_shell_runs_explicit_sd_boot_and_runtime_checks(): s.shell._inject_run.assert_called_once() s.shell.run_check.assert_any_call("ip link show eth0", timeout=s.kuiper_verify_timeout) assert s.status == Status.kuiper_shell + + +def test_transition_shell_aliases_kuiper_shell(): + """The generic hardware-request flow transitions to "shell"; for this + strategy that must mean kuiper_shell (not a KeyError on Status["shell"]). + Both spellings hit the same missing-production-inputs validation.""" + with pytest.raises(StrategyError): + _make_strategy().transition("kuiper_shell") + with pytest.raises(StrategyError): + _make_strategy().transition("shell") diff --git a/tests/test_remote_exec.py b/tests/test_remote_exec.py index 3cc91a3..5993a75 100644 --- a/tests/test_remote_exec.py +++ b/tests/test_remote_exec.py @@ -177,3 +177,45 @@ def test_stage_file_network_resource_uses_managedfile(tmp_path): MF.assert_called_once() mf_instance.sync_to_resource.assert_called_once() assert remote == "/var/cache/abc/boot.scr" + + +# --- mDNS .local fallback -------------------------------------------------- + + +def test_exporter_host_falls_back_to_dot_local(monkeypatch): + """The coordinator advertises bare exporter names; on mDNS-only lab + networks just ``.local`` resolves (live-found: the nemo runner + cannot resolve bare ``tron``). Mirror hw_ci.all_places.host_reachable.""" + _remote._RESOLVED_HOSTS.clear() + + def fake_gai(host, *args, **kwargs): + if host == "tron.local": + return [("resolved",)] + raise OSError("no dns") + + monkeypatch.setattr(_remote.socket, "getaddrinfo", fake_gai) + d = _FakeDriver(_proxied_resource("tron")) + assert d._exporter_host(d._remote_resource()) == "tron.local" + _remote._RESOLVED_HOSTS.clear() + + +def test_exporter_host_keeps_bare_name_when_it_resolves(monkeypatch): + _remote._RESOLVED_HOSTS.clear() + monkeypatch.setattr(_remote.socket, "getaddrinfo", lambda *a, **kw: [("resolved",)]) + d = _FakeDriver(_proxied_resource("nemo")) + assert d._exporter_host(d._remote_resource()) == "nemo" + _remote._RESOLVED_HOSTS.clear() + + +def test_exporter_host_unresolvable_returns_input(monkeypatch): + """A host that resolves in no form passes through unchanged so the + eventual ssh error names the real host, not a mangled one.""" + _remote._RESOLVED_HOSTS.clear() + + def fail_gai(*args, **kwargs): + raise OSError("no dns") + + monkeypatch.setattr(_remote.socket, "getaddrinfo", fail_gai) + d = _FakeDriver(_proxied_resource("ghost")) + assert d._exporter_host(d._remote_resource()) == "ghost" + _remote._RESOLVED_HOSTS.clear() diff --git a/tests/test_request_cli.py b/tests/test_request_cli.py index 9560ca1..a4460a1 100644 --- a/tests/test_request_cli.py +++ b/tests/test_request_cli.py @@ -324,7 +324,9 @@ def fake_run_child(cmd, env): return 0 monkeypatch.setattr(rc_mod, "_run_child", fake_run_child) - result = CliRunner().invoke(cli, ["request", "--part", "adrv9009", "--no-boot", "--run", "true"]) + result = CliRunner().invoke( + cli, ["request", "--part", "adrv9009", "--no-boot", "--run", "true"] + ) assert result.exit_code == 0 assert fake.kwargs["mode"] == "reserve" assert captured["LG_ENV"] == "/tmp/env.yaml" diff --git a/tests/test_request_core.py b/tests/test_request_core.py index 85dfe44..fd86895 100644 --- a/tests/test_request_core.py +++ b/tests/test_request_core.py @@ -559,6 +559,7 @@ def test_request_exports_lg_coordinator(patched, monkeypatch): monkeypatch.delenv("LG_COORDINATOR", raising=False) with core.request(part="adrv9002"): import os + assert os.environ.get("LG_COORDINATOR") == "10.0.0.41:20408" @@ -570,12 +571,21 @@ def fake_get_match(*a, **k): monkeypatch.setattr(core.match_client, "get_match", fake_get_match) - with pytest.raises(BoardUnavailable, match="Coordinator REST API at localhost:8000 is unreachable"): + with pytest.raises( + BoardUnavailable, match="Coordinator REST API at localhost:8000 is unreachable" + ): with core.request(part="adrv9002"): pass def test_request_skipped_place_shows_validation_reason(monkeypatch): - monkeypatch.setattr(core, "list_live_places", lambda c: ([], [("talise", "missing required tag(s) boot-strategy")])) - with pytest.raises(ProvisionError, match=r"(?s)acquired place 'talise' is invalid and was skipped: missing required tag\(s\) boot-strategy.*To fix this"): + monkeypatch.setattr( + core, + "list_live_places", + lambda c: ([], [("talise", "missing required tag(s) boot-strategy")]), + ) + with pytest.raises( + ProvisionError, + match=r"(?s)acquired place 'talise' is invalid and was skipped: missing required tag\(s\) boot-strategy.*To fix this", + ): core._concrete_place("c:20408", "talise")