tan-cli 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tan/__init__.py +0 -0
- tan/__main__.py +120 -0
- tan/cli.py +939 -0
- tan/command_table.py +265 -0
- tan/commands/__init__.py +8 -0
- tan/commands/bootstrap_cmd.py +4856 -0
- tan/commands/bootstrap_patches.py +164 -0
- tan/commands/bootstrap_toolchain_fetch.py +155 -0
- tan/commands/bootstrap_toolchain_pin.py +36 -0
- tan/commands/build/__init__.py +1 -0
- tan/commands/build/configure_inputs.py +277 -0
- tan/commands/build/execute.py +1852 -0
- tan/commands/build/host_python.py +59 -0
- tan/commands/build/link_stale.py +78 -0
- tan/commands/build/manifest.py +428 -0
- tan/commands/build/materialise.py +81 -0
- tan/commands/build/token_substitution.py +381 -0
- tan/commands/build/toolchain.py +482 -0
- tan/commands/build/user_defines_wipe.py +115 -0
- tan/commands/build/workspace_patches.py +65 -0
- tan/commands/build_cmd.py +2236 -0
- tan/commands/build_output.py +331 -0
- tan/commands/clean_cmd.py +1183 -0
- tan/commands/completion_cmd.py +786 -0
- tan/commands/debug_config_cmd.py +2425 -0
- tan/commands/diff_cmd.py +940 -0
- tan/commands/doctor_cmd.py +4767 -0
- tan/commands/examples_cmd.py +644 -0
- tan/commands/explain_cmd.py +1319 -0
- tan/commands/faultdecode_cmd.py +931 -0
- tan/commands/flash_atoc_guard.py +240 -0
- tan/commands/flash_cmd.py +5731 -0
- tan/commands/flash_mram_guard.py +150 -0
- tan/commands/flash_ram.py +578 -0
- tan/commands/flash_raw.py +583 -0
- tan/commands/generate_cmd.py +1456 -0
- tan/commands/image_cmd.py +753 -0
- tan/commands/init_cmd.py +1740 -0
- tan/commands/inspect_cmd.py +377 -0
- tan/commands/kconfig_cmd.py +734 -0
- tan/commands/model_cmd.py +1514 -0
- tan/commands/model_device_cmd.py +351 -0
- tan/commands/model_device_live.py +234 -0
- tan/commands/model_host_cmd.py +430 -0
- tan/commands/model_zoo_cmd.py +363 -0
- tan/commands/monitor_actions.py +125 -0
- tan/commands/monitor_cmd.py +626 -0
- tan/commands/monitor_session.py +505 -0
- tan/commands/new_som_cmd.py +1522 -0
- tan/commands/pinmux_cmd.py +568 -0
- tan/commands/presets_cmd.py +1123 -0
- tan/commands/probe_cmd.py +421 -0
- tan/commands/reset_cmd.py +354 -0
- tan/commands/reset_confirm.py +168 -0
- tan/commands/run_cmd.py +637 -0
- tan/commands/scaffold_cmd.py +490 -0
- tan/commands/sdk_cmd.py +1723 -0
- tan/commands/size_cmd.py +1020 -0
- tan/commands/support_bundle_cmd.py +1073 -0
- tan/commands/trace_cmd.py +341 -0
- tan/commands/validate_cmd.py +2009 -0
- tan/commands/west_forward_cmd.py +736 -0
- tan/commands/workspace_patch_check.py +296 -0
- tan/core/__init__.py +1 -0
- tan/core/alp_conf_pregen.py +100 -0
- tan/core/artifact_provenance.py +240 -0
- tan/core/atoc_guard.py +276 -0
- tan/core/atoc_guard_messages.py +232 -0
- tan/core/atoc_replacement.py +118 -0
- tan/core/atomic_write.py +178 -0
- tan/core/board_context.py +351 -0
- tan/core/board_diagnostic.py +111 -0
- tan/core/board_files.py +221 -0
- tan/core/board_validator.py +542 -0
- tan/core/board_validator_compat.py +196 -0
- tan/core/board_validator_run.py +129 -0
- tan/core/board_validator_schema.py +186 -0
- tan/core/board_validator_skew.py +46 -0
- tan/core/board_yaml_edit.py +295 -0
- tan/core/board_yaml_pos.py +118 -0
- tan/core/bootstrap.py +2572 -0
- tan/core/build_plan.py +625 -0
- tan/core/consent.py +104 -0
- tan/core/console_filter.py +245 -0
- tan/core/debug_launch.py +1803 -0
- tan/core/devicetree_lint.py +168 -0
- tan/core/dir_removal.py +229 -0
- tan/core/doctor_git.py +172 -0
- tan/core/doctor_libraries.py +253 -0
- tan/core/doctor_render.py +185 -0
- tan/core/doctor_scope.py +66 -0
- tan/core/doctor_setools.py +237 -0
- tan/core/doctor_stale.py +180 -0
- tan/core/document_guards.py +723 -0
- tan/core/dp_id.py +97 -0
- tan/core/elf_load.py +63 -0
- tan/core/error_catalog.py +171 -0
- tan/core/example_catalog.py +499 -0
- tan/core/example_facets.py +151 -0
- tan/core/faultdecode.py +521 -0
- tan/core/flash_plan.py +2501 -0
- tan/core/flow_d_report.py +381 -0
- tan/core/flow_d_uptime.py +164 -0
- tan/core/fs_confine.py +130 -0
- tan/core/global_flags.py +230 -0
- tan/core/host_python.py +210 -0
- tan/core/image_bundle.py +247 -0
- tan/core/inert.py +147 -0
- tan/core/jlink_binary.py +128 -0
- tan/core/jlink_probe.py +481 -0
- tan/core/jsonc_splice.py +404 -0
- tan/core/launch_provenance.py +257 -0
- tan/core/link_refusal.py +76 -0
- tan/core/metadata_schema.py +376 -0
- tan/core/model_check.py +227 -0
- tan/core/model_device.py +449 -0
- tan/core/model_doctor.py +198 -0
- tan/core/model_host.py +48 -0
- tan/core/model_list.py +112 -0
- tan/core/model_zoo.py +353 -0
- tan/core/module_template.py +340 -0
- tan/core/mram_link.py +87 -0
- tan/core/new_som_ospi.py +94 -0
- tan/core/os_class.py +159 -0
- tan/core/patch_paths.py +87 -0
- tan/core/pending.py +40 -0
- tan/core/plain_zephyr_plan.py +138 -0
- tan/core/plan_exec.py +373 -0
- tan/core/plan_tokens.py +600 -0
- tan/core/probe.py +155 -0
- tan/core/probe_plan.py +237 -0
- tan/core/proxy.py +168 -0
- tan/core/publish.py +31 -0
- tan/core/python_floor.py +178 -0
- tan/core/ram_run.py +694 -0
- tan/core/ram_watch.py +231 -0
- tan/core/raw_write.py +160 -0
- tan/core/reset_plan.py +56 -0
- tan/core/run.py +161 -0
- tan/core/scaffold.py +2203 -0
- tan/core/scaffold_selftest_identity.py +225 -0
- tan/core/sdk_default_registry.py +565 -0
- tan/core/sdk_discovery.py +996 -0
- tan/core/sdk_removal.py +465 -0
- tan/core/serial_actions.py +175 -0
- tan/core/serial_capture.py +185 -0
- tan/core/serial_url.py +45 -0
- tan/core/setools.py +608 -0
- tan/core/setools_scratch.py +243 -0
- tan/core/shapes.py +194 -0
- tan/core/size.py +610 -0
- tan/core/som_buildability.py +204 -0
- tan/core/som_schema_version.py +62 -0
- tan/core/subprocess_env.py +201 -0
- tan/core/sysbuild_image.py +94 -0
- tan/core/system_manifest.py +827 -0
- tan/core/text_layout.py +73 -0
- tan/core/timestamp.py +182 -0
- tan/core/tool_lookup.py +383 -0
- tan/core/toolchain_pin.py +117 -0
- tan/core/toolchain_provision.py +1224 -0
- tan/core/trusted_path.py +134 -0
- tan/core/uboot_breakin.py +154 -0
- tan/core/uri_reference.py +415 -0
- tan/core/user_defines.py +152 -0
- tan/core/venv.py +332 -0
- tan/core/west_patches.py +143 -0
- tan/core/west_workspace_refusal.py +159 -0
- tan/core/zephyr_env.py +124 -0
- tan/env.py +185 -0
- tan/envelope.py +647 -0
- tan/exit_codes.py +12 -0
- tan/lazy_typer.py +185 -0
- tan/model/__init__.py +2 -0
- tan/model/_gen_fixture.py +132 -0
- tan/model/adapters/__init__.py +116 -0
- tan/model/adapters/cpu.py +26 -0
- tan/model/adapters/deepx.py +94 -0
- tan/model/adapters/drpai.py +242 -0
- tan/model/adapters/ethos_u.py +951 -0
- tan/model/adapters/executorch.py +55 -0
- tan/model/analyze.py +694 -0
- tan/model/build.py +304 -0
- tan/model/check.py +682 -0
- tan/model/manifest.py +625 -0
- tan/model/measure.py +216 -0
- tan/model/npu_ops_validate.py +130 -0
- tan/model/package.py +125 -0
- tan/model/perf.py +635 -0
- tan/model/perf_apply.py +721 -0
- tan/model/prep.py +193 -0
- tan/model/sram_fit.py +502 -0
- tan/model/targets.py +374 -0
- tan/model/tensorio.py +287 -0
- tan/net.py +73 -0
- tan/output_format.py +213 -0
- tan/planner/__init__.py +219 -0
- tan/planner/aperture.py +202 -0
- tan/planner/buildplan.py +1092 -0
- tan/planner/camera_owner.py +192 -0
- tan/planner/cameras.py +132 -0
- tan/planner/carveout.py +755 -0
- tan/planner/cli.py +274 -0
- tan/planner/headers.py +315 -0
- tan/planner/kconfig.py +2435 -0
- tan/planner/kconfig_symbols.py +473 -0
- tan/planner/libraries.py +677 -0
- tan/planner/link_target.py +237 -0
- tan/planner/linux_ownership.py +216 -0
- tan/planner/loader.py +1525 -0
- tan/planner/manifest.py +116 -0
- tan/planner/memory.py +161 -0
- tan/planner/memregion.py +56 -0
- tan/planner/models.py +533 -0
- tan/planner/orchestrator.py +874 -0
- tan/planner/ownership.py +203 -0
- tan/planner/partition.py +1160 -0
- tan/planner/paths.py +32 -0
- tan/planner/plain_slice.py +139 -0
- tan/planner/project_emit/__init__.py +60 -0
- tan/planner/project_emit/bom_netlist.py +375 -0
- tan/planner/project_emit/dts.py +580 -0
- tan/planner/project_emit/hw_info.py +221 -0
- tan/planner/project_emit/native_sim.py +136 -0
- tan/planner/project_emit/west_libs.py +213 -0
- tan/planner/project_loader.py +264 -0
- tan/planner/sdk_capability.py +203 -0
- tan/planner/sdk_compat.py +395 -0
- tan/planner/secure.py +350 -0
- tan/planner/slugs.py +394 -0
- tan/planner/som_metadata.py +325 -0
- tan/planner/strict_loaders.py +131 -0
- tan/planner/template.py +1037 -0
- tan/planner/template_pins.py +535 -0
- tan/planner/template_rewrite.py +714 -0
- tan/planner/topology.py +208 -0
- tan/planner/validate.py +365 -0
- tan/planner/whole_device_alias.py +67 -0
- tan/planner/zephyr_board.py +3510 -0
- tan/planner_cli.py +97 -0
- tan/planner_emit.py +617 -0
- tan/planner_root.py +119 -0
- tan/soc_ref.py +58 -0
- tan/templates/__init__.py +34 -0
- tan/templates/vendored/MANIFEST.md +1862 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
- tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
- tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
- tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
- tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
- tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
- tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
- tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
- tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
- tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
- tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
- tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
- tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
- tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
- tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
- tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
- tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
- tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
- tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
- tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
- tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
- tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
- tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
- tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
- tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
- tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
- tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
- tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
- tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
- tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
- tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
- tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
- tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
- tan/version.py +102 -0
- tan_cli-0.7.0.dist-info/METADATA +125 -0
- tan_cli-0.7.0.dist-info/RECORD +330 -0
- tan_cli-0.7.0.dist-info/WHEEL +5 -0
- tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
- tan_cli-0.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1224 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Pure decision logic for ADR 0021 Lane 1 P1: `tan bootstrap` acquiring the
|
|
3
|
+
`arm-zephyr-eabi` cross toolchain via `west sdk install --install-dir`, into
|
|
4
|
+
the artifact-keyed store `~/.alp/toolchains/<artifact>-<version>/` (or
|
|
5
|
+
`$ALP_TOOLCHAIN_ROOT`, ADR 0021's own escape hatch).
|
|
6
|
+
|
|
7
|
+
**No IO here.** Every function below is a pure transform over already-read
|
|
8
|
+
bytes (a manifest's text, a stamp's text, a `shutil.disk_usage` reading, a
|
|
9
|
+
platform pair) -- the SAME split `tan.core.bootstrap` already draws for the
|
|
10
|
+
venv/west phases. `tan.commands.bootstrap_cmd.toolchain_phase` is the IO
|
|
11
|
+
layer: it reads `<sdkRoot>/metadata/toolchains.json`, probes the filesystem
|
|
12
|
+
and spawns `west sdk install`, then hands the results through the functions
|
|
13
|
+
here to decide what happened and what to do next.
|
|
14
|
+
|
|
15
|
+
**Why a manifest DIGEST, not just a version compare.** ADR 0021's own words:
|
|
16
|
+
"a stamped 1.0.1 store against a moved pin is a Fail with a fix, not 'a
|
|
17
|
+
toolchain exists'". A version bump already changes [`store_dir_name`]'s own
|
|
18
|
+
directory, so two DIFFERENT versions can never collide on disk -- the digest
|
|
19
|
+
exists for the narrower case a version number alone cannot catch: an
|
|
20
|
+
artifact's `sha256`/`baseUrl` rotating under an UNCHANGED version string (a
|
|
21
|
+
corrected pin, a re-signed release). [`ToolchainManifest.digest`] hashes the
|
|
22
|
+
manifest's own raw bytes, so that case invalidates a stamp too, at the one
|
|
23
|
+
cost of also invalidating it on a comment-only edit -- the safe direction to
|
|
24
|
+
err in, matching `zephyr_python_floor`'s own "prefer the fact over the cached
|
|
25
|
+
memory of it" reasoning doctor already applies one layer up.
|
|
26
|
+
|
|
27
|
+
**Directory-exists is never the success predicate anywhere in this module.**
|
|
28
|
+
[`stamp_matches_pin`] is the ONLY verdict function, and it takes a *parsed*
|
|
29
|
+
stamp plus a *parsed* manifest -- never a boolean "does the path exist".
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
import re
|
|
36
|
+
from collections.abc import Mapping
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
|
|
39
|
+
#: The one cross-toolchain ADR 0021 Lane 1 acquires. `west sdk install`'s own
|
|
40
|
+
#: `--gnu-toolchains` vocabulary spells it exactly this way.
|
|
41
|
+
TOOLCHAIN_COMPONENT = "arm-zephyr-eabi"
|
|
42
|
+
|
|
43
|
+
#: The stamp file's name, inside the store directory it describes. Written
|
|
44
|
+
#: LAST (`tan.commands.bootstrap_cmd.toolchain_phase`), only once the compiler
|
|
45
|
+
#: probe below has actually run and the installed `sdk_version` file has been
|
|
46
|
+
#: read back and compared -- never on `west sdk install` merely exiting 0.
|
|
47
|
+
STAMP_FILENAME = ".alp-toolchain-stamp.json"
|
|
48
|
+
|
|
49
|
+
#: Sibling-directory naming for an in-progress/interrupted acquisition.
|
|
50
|
+
#: Nothing else under a toolchain root ever produces this suffix, so a
|
|
51
|
+
#: directory matching [`wreckage_glob_pattern`] found at the START of a new
|
|
52
|
+
#: `tan bootstrap` run is unambiguously wreckage tan itself left behind on a
|
|
53
|
+
#: prior interrupted attempt -- reclaimable the same way `ensure_venv`
|
|
54
|
+
#: reclaims a corpse venv `_probe_venv_pip` proved absent, never merely
|
|
55
|
+
#: unstamped. Never applied to `$ALP_TOOLCHAIN_ROOT` (see [`ToolchainRoot`]):
|
|
56
|
+
#: an adopted root may contain directories tan never created, and this
|
|
57
|
+
#: pattern existing is not evidence tan created THIS ONE on THIS root.
|
|
58
|
+
TMP_SUFFIX_PREFIX = ".tmp-"
|
|
59
|
+
|
|
60
|
+
#: Safety margin above the manifest's own MEASURED extracted footprint
|
|
61
|
+
#: (`measuredFootprint.extractedBytes.wholeSdk`) -- covers filesystem block
|
|
62
|
+
#: overhead and the transient extra copy `west sdk install` itself needs
|
|
63
|
+
#: mid-extraction (the downloaded archive and the extracted tree briefly
|
|
64
|
+
#: coexist inside its own `tempfile.TemporaryDirectory` before the archive is
|
|
65
|
+
#: discarded). Not a guess: 15% of ~1.89 GiB is comfortably inside "a rounding
|
|
66
|
+
#: error", while still catching a host that is only a few hundred MiB short.
|
|
67
|
+
DISK_MARGIN_RATIO = 0.15
|
|
68
|
+
|
|
69
|
+
#: `<store>/sdk_version` -- the file `west sdk install` itself writes at the
|
|
70
|
+
#: SDK's own top level (verified against a real extracted 1.0.1 tree: exact
|
|
71
|
+
#: byte content `"1.0.1\n"`), read back and string-compared against the pin
|
|
72
|
+
#: AFTER install, never assumed from the version tan asked `west` to install.
|
|
73
|
+
#: A `west` that silently resolved a different version (a stale local cache,
|
|
74
|
+
#: a future `west` defaulting when `--version` is dropped by a caller error)
|
|
75
|
+
#: is caught here rather than trusted.
|
|
76
|
+
SDK_VERSION_FILE_RELPATH = "sdk_version"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def gcc_binary_relpath(*, is_windows: bool) -> tuple[str, ...]:
|
|
80
|
+
"""`<store>` -> the `arm-zephyr-eabi-gcc` binary's path components.
|
|
81
|
+
Verified against a real extracted `zephyr-sdk-1.0.1` tree:
|
|
82
|
+
`gnu/arm-zephyr-eabi/bin/arm-zephyr-eabi-gcc` (SDK 1.0.0+ places GNU
|
|
83
|
+
toolchains under `gnu/`, per `west`'s own `scripts/west_commands/sdk.py`
|
|
84
|
+
comment beside its `gnu_toolchains` walk)."""
|
|
85
|
+
name = "arm-zephyr-eabi-gcc.exe" if is_windows else "arm-zephyr-eabi-gcc"
|
|
86
|
+
return ("gnu", TOOLCHAIN_COMPONENT, "bin", name)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def store_compiler_present(store_dir, *, is_windows: bool) -> bool:
|
|
90
|
+
"""Whether the stamped store still holds its `arm-zephyr-eabi-gcc` file.
|
|
91
|
+
A stamp is written only after a compiler probe, but nothing stops the
|
|
92
|
+
compiler subtree being deleted afterwards (disk cleanup, a partial `rm`);
|
|
93
|
+
a stamp alone must not keep saying "verified" over that."""
|
|
94
|
+
try:
|
|
95
|
+
return store_dir.joinpath(*gcc_binary_relpath(is_windows=is_windows)).is_file()
|
|
96
|
+
except OSError:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
#: What a stamp does and does NOT establish (tan-cli#1496; the full account is in
|
|
101
|
+
#: `tan.core.toolchain_pin`'s docstring). `west sdk install` verifies only the
|
|
102
|
+
#: minimal SDK bundle (against the release `sha256.sum`) and its `setup.sh` fetches
|
|
103
|
+
#: the toolchain archive with no check, so a stamped install with `pinChecked` means:
|
|
104
|
+
#: the toolchain archive's sha256 was hashed by tan against alp-sdk's pin while
|
|
105
|
+
#: downloading it, and the minimal bundle matched the release sum which tan compared
|
|
106
|
+
#: with the pin before and after west ran. Nothing is re-hashed from disk later, and
|
|
107
|
+
#: tan never saw the minimal bundle's bytes. A stamp without `pinChecked` predates
|
|
108
|
+
#: this check: nothing about its archives was compared with the pin. See
|
|
109
|
+
#: docs/toolchain-archive-verification.md.
|
|
110
|
+
ARCHIVE_SHA256_NOTE = (
|
|
111
|
+
"files on disk are not re-hashed; a stamp marked pinChecked means the toolchain "
|
|
112
|
+
"archive was hashed against alp-sdk's pin at download (the minimal SDK bundle only "
|
|
113
|
+
"via the release sha256.sum, which west checks), and an unmarked stamp predates "
|
|
114
|
+
"that check, so its archives were never compared with the pin"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Host identity
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class UnsupportedHost:
|
|
125
|
+
"""This host cannot get the pinned toolchain -- named honestly rather than
|
|
126
|
+
guessed at or silently downgraded to a build that will not run.
|
|
127
|
+
`host_key` is the best key this function could compute (even an invented
|
|
128
|
+
one, for the detail message); `reason` is customer-facing prose."""
|
|
129
|
+
|
|
130
|
+
host_key: str
|
|
131
|
+
reason: str
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def toolchain_host_key(sys_platform: str, machine: str) -> str | UnsupportedHost:
|
|
135
|
+
"""Map `(sys.platform, platform.machine())` onto the
|
|
136
|
+
`metadata/toolchains.json` / `west sdk install` host vocabulary
|
|
137
|
+
(`linux-x86_64`, `linux-aarch64`, `windows-x86_64`, `macos-aarch64`,
|
|
138
|
+
`macos-x86_64`).
|
|
139
|
+
|
|
140
|
+
`windows-arm64` is refused BY NAME with the WSL2 redirect ADR 0021 itself
|
|
141
|
+
specifies ("no official Zephyr SDK host build ... must be routed to
|
|
142
|
+
WSL2-aarch64"), rather than silently resolving to `windows-x86_64`
|
|
143
|
+
(nothing on that host can execute those binaries) or falling through to
|
|
144
|
+
the generic "no artifact for this host" message a manifest-driven check
|
|
145
|
+
would otherwise give it, which does not name the WSL2 remedy.
|
|
146
|
+
"""
|
|
147
|
+
m = machine.strip().lower()
|
|
148
|
+
if sys_platform.startswith("linux"):
|
|
149
|
+
if m in ("x86_64", "amd64"):
|
|
150
|
+
return "linux-x86_64"
|
|
151
|
+
if m in ("aarch64", "arm64"):
|
|
152
|
+
return "linux-aarch64"
|
|
153
|
+
return UnsupportedHost(f"linux-{m or 'unknown'}", f"no Zephyr SDK host build for linux-{m}")
|
|
154
|
+
if sys_platform == "darwin":
|
|
155
|
+
if m in ("arm64", "aarch64"):
|
|
156
|
+
return "macos-aarch64"
|
|
157
|
+
if m in ("x86_64", "amd64"):
|
|
158
|
+
return "macos-x86_64"
|
|
159
|
+
return UnsupportedHost(f"macos-{m or 'unknown'}", f"no Zephyr SDK host build for macos-{m}")
|
|
160
|
+
if sys_platform in ("win32", "cygwin"):
|
|
161
|
+
if m in ("amd64", "x86_64"):
|
|
162
|
+
return "windows-x86_64"
|
|
163
|
+
if m in ("arm64", "aarch64"):
|
|
164
|
+
return UnsupportedHost(
|
|
165
|
+
"windows-arm64",
|
|
166
|
+
"the pinned Zephyr SDK publishes no windows-arm64 host build (ADR 0021) -- "
|
|
167
|
+
# tan-cli#990 review MINOR: NOT "linux-x86_64 or linux-aarch64" --
|
|
168
|
+
# measured against both the vendored fixture and a live alp-sdk
|
|
169
|
+
# checkout, the pinned manifest publishes linux-x86_64 only (no
|
|
170
|
+
# linux-aarch64 row at all), so a WSL2-aarch64 reader following
|
|
171
|
+
# the old wording landed on this SAME host's own unsupported-host
|
|
172
|
+
# skip, one level down. Name the ONE host this pin can actually
|
|
173
|
+
# serve; widen this sentence again only once the manifest does.
|
|
174
|
+
"run `tan bootstrap` from a WSL2 distro (linux-x86_64) instead; see "
|
|
175
|
+
"docs/cross-platform-setup.md.",
|
|
176
|
+
)
|
|
177
|
+
return UnsupportedHost(f"windows-{m or 'unknown'}", f"no Zephyr SDK host build for windows-{m}")
|
|
178
|
+
return UnsupportedHost(sys_platform or "unknown", f"unrecognised host platform {sys_platform!r}")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# The manifest -- `<sdkRoot>/metadata/toolchains.json`, read fresh every run
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class ToolchainManifestError(Exception):
|
|
187
|
+
"""`<sdkRoot>/metadata/toolchains.json` does not have the shape this
|
|
188
|
+
reader needs. Caught at the one call site (`toolchain_phase`) and reported
|
|
189
|
+
as a non-fatal, `WORKSPACE_BLOCKING`-eligible warning naming the file --
|
|
190
|
+
mirrors `BootstrapManifestError`'s own role for `bootstrap.json`."""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True)
|
|
194
|
+
class ToolchainArtifact:
|
|
195
|
+
host: str
|
|
196
|
+
component: str
|
|
197
|
+
filename: str
|
|
198
|
+
size_bytes: int
|
|
199
|
+
sha256: str
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True)
|
|
203
|
+
class ToolchainManifest:
|
|
204
|
+
version: str
|
|
205
|
+
base_url: str
|
|
206
|
+
artifacts: tuple[ToolchainArtifact, ...]
|
|
207
|
+
#: `measuredFootprint.extractedBytes.wholeSdk`, when present. `None` on an
|
|
208
|
+
#: older manifest shape that predates it -- [`required_bytes`] treats that
|
|
209
|
+
#: as "cannot preflight", not as "zero bytes needed".
|
|
210
|
+
extracted_bytes_whole_sdk: int | None
|
|
211
|
+
#: The exact bytes read from disk -- what [`digest`] hashes. Kept
|
|
212
|
+
#: verbatim (not re-serialised) so the digest is of what alp-sdk actually
|
|
213
|
+
#: shipped, not of this reader's own re-encoding of it.
|
|
214
|
+
raw_text: str
|
|
215
|
+
|
|
216
|
+
def digest(self) -> str:
|
|
217
|
+
"""A stable identity for "the pin this manifest declares" -- sha256 of
|
|
218
|
+
the file's own raw bytes. See the module docstring for why the WHOLE
|
|
219
|
+
file, not just the artifact rows."""
|
|
220
|
+
return hashlib.sha256(self.raw_text.encode("utf-8")).hexdigest()
|
|
221
|
+
|
|
222
|
+
def artifacts_for_host(self, host_key: str) -> tuple[ToolchainArtifact, ...]:
|
|
223
|
+
return tuple(a for a in self.artifacts if a.host == host_key)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def parse_toolchain_manifest(text: str) -> ToolchainManifest:
|
|
227
|
+
"""`<sdkRoot>/metadata/toolchains.json`'s text -> a [`ToolchainManifest`],
|
|
228
|
+
or [`ToolchainManifestError`] naming exactly which required field is
|
|
229
|
+
missing or malformed -- never a bare `KeyError`/`TypeError` escaping to
|
|
230
|
+
the caller, matching `parse_bootstrap_manifest`'s own contract.
|
|
231
|
+
"""
|
|
232
|
+
try:
|
|
233
|
+
doc = json.loads(text)
|
|
234
|
+
except ValueError as err:
|
|
235
|
+
raise ToolchainManifestError(f"not valid JSON: {err}") from err
|
|
236
|
+
if not isinstance(doc, dict):
|
|
237
|
+
raise ToolchainManifestError("top level is not a JSON object")
|
|
238
|
+
zephyr_sdk = doc.get("zephyrSdk")
|
|
239
|
+
if not isinstance(zephyr_sdk, dict):
|
|
240
|
+
raise ToolchainManifestError("missing object `zephyrSdk`")
|
|
241
|
+
version = zephyr_sdk.get("version")
|
|
242
|
+
base_url = zephyr_sdk.get("baseUrl")
|
|
243
|
+
if not isinstance(version, str) or not version:
|
|
244
|
+
raise ToolchainManifestError("missing non-empty string `zephyrSdk.version`")
|
|
245
|
+
if not isinstance(base_url, str) or not base_url:
|
|
246
|
+
raise ToolchainManifestError("missing non-empty string `zephyrSdk.baseUrl`")
|
|
247
|
+
raw_artifacts = zephyr_sdk.get("artifacts")
|
|
248
|
+
if not isinstance(raw_artifacts, list):
|
|
249
|
+
raise ToolchainManifestError("missing array `zephyrSdk.artifacts`")
|
|
250
|
+
artifacts = tuple(_parse_artifact(i, item) for i, item in enumerate(raw_artifacts))
|
|
251
|
+
extracted = _extracted_whole_sdk(doc)
|
|
252
|
+
return ToolchainManifest(version, base_url, artifacts, extracted, text)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _parse_artifact(index: int, item: object) -> ToolchainArtifact:
|
|
256
|
+
if not isinstance(item, dict):
|
|
257
|
+
raise ToolchainManifestError(f"artifacts[{index}] is not an object")
|
|
258
|
+
try:
|
|
259
|
+
return ToolchainArtifact(
|
|
260
|
+
host=str(item["host"]),
|
|
261
|
+
component=str(item["component"]),
|
|
262
|
+
filename=str(item["filename"]),
|
|
263
|
+
size_bytes=int(item["sizeBytes"]),
|
|
264
|
+
sha256=str(item["sha256"]),
|
|
265
|
+
)
|
|
266
|
+
except (KeyError, TypeError, ValueError) as err:
|
|
267
|
+
raise ToolchainManifestError(f"artifacts[{index}] malformed: {err}") from err
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _extracted_whole_sdk(doc: dict) -> int | None:
|
|
271
|
+
footprint = doc.get("measuredFootprint")
|
|
272
|
+
if not isinstance(footprint, dict):
|
|
273
|
+
return None
|
|
274
|
+
extracted = footprint.get("extractedBytes")
|
|
275
|
+
if not isinstance(extracted, dict):
|
|
276
|
+
return None
|
|
277
|
+
whole = extracted.get("wholeSdk")
|
|
278
|
+
return whole if isinstance(whole, int) and not isinstance(whole, bool) else None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def artifacts_missing_for_host(manifest: ToolchainManifest, host_key: str) -> bool:
|
|
282
|
+
"""True when the pinned manifest publishes NO artifact row for
|
|
283
|
+
`host_key` -- the general, manifest-driven form of the ADR's named Intel
|
|
284
|
+
Mac case (`macos-x86_64` simply has no row at the pinned version): a
|
|
285
|
+
coded, honest skip that needs no per-platform special-casing, and that
|
|
286
|
+
stays correct if alp-sdk adds/drops a host row later.
|
|
287
|
+
"""
|
|
288
|
+
return len(manifest.artifacts_for_host(host_key)) == 0
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
# The artifact-keyed store
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def store_dir_name(version: str) -> str:
|
|
297
|
+
"""`~/.alp/toolchains/<artifact>-<version>/`'s leaf name -- ADR 0021's own
|
|
298
|
+
worked example (`zephyr-sdk-0.17.0-arm-zephyr-eabi`). Keyed by ARTIFACT
|
|
299
|
+
(Zephyr SDK version + component), never by an alp-sdk checkout or SKU, so
|
|
300
|
+
two projects pinning the same Zephyr SDK version share one on-disk copy
|
|
301
|
+
("Key the toolchain store by SDK version" -- rejected in the ADR's own
|
|
302
|
+
Alternatives for duplicating ~1-17 GB per release)."""
|
|
303
|
+
return f"zephyr-sdk-{version}-{TOOLCHAIN_COMPONENT}"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def wreckage_glob_pattern(leaf_name: str) -> str:
|
|
307
|
+
"""The glob a caller runs INSIDE the toolchain root to find a prior
|
|
308
|
+
interrupted attempt at `leaf_name` worth reclaiming before starting a new
|
|
309
|
+
one. See [`TMP_SUFFIX_PREFIX`]."""
|
|
310
|
+
return f"{leaf_name}{TMP_SUFFIX_PREFIX}*"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@dataclass(frozen=True)
|
|
314
|
+
class ToolchainRoot:
|
|
315
|
+
#: Where `~/.alp/toolchains` (or the override) actually is.
|
|
316
|
+
path_str: str
|
|
317
|
+
#: True when `$ALP_TOOLCHAIN_ROOT` was set -- an ADOPTED root tan did not
|
|
318
|
+
#: necessarily create. Mirrors the venv-recreate rule's own exception:
|
|
319
|
+
#: wreckage reclamation ([`TMP_SUFFIX_PREFIX`]) still applies (that naming
|
|
320
|
+
#: pattern is tan's own and nothing else produces it), but a bare,
|
|
321
|
+
#: UNSTAMPED store directory under an adopted root is never assumed to be
|
|
322
|
+
#: tan's to delete and start over -- only a genuinely wreckage-suffixed
|
|
323
|
+
#: sibling is.
|
|
324
|
+
adopted: bool
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def resolve_toolchain_root(env_override: str | None, home_alp_dir_str: str) -> ToolchainRoot:
|
|
328
|
+
"""`$ALP_TOOLCHAIN_ROOT` when set and non-blank (ADR 0021's escape hatch
|
|
329
|
+
"for bench machines and CI"), else `<home_alp_dir>/toolchains`."""
|
|
330
|
+
if env_override and env_override.strip():
|
|
331
|
+
return ToolchainRoot(env_override.strip(), True)
|
|
332
|
+
return ToolchainRoot(f"{home_alp_dir_str.rstrip('/').rstrip(chr(92))}/toolchains", False)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ---------------------------------------------------------------------------
|
|
336
|
+
# The stamp -- written LAST, only after a real compiler probe succeeds
|
|
337
|
+
# ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@dataclass(frozen=True)
|
|
341
|
+
class ToolchainStamp:
|
|
342
|
+
version: str
|
|
343
|
+
manifest_digest: str
|
|
344
|
+
#: The triple `arm-zephyr-eabi-gcc -dumpmachine` (or the fixed
|
|
345
|
+
#: `TOOLCHAIN_COMPONENT` string, when the probe output was not parsed)
|
|
346
|
+
#: reported -- carried through to the doctor detail line so a customer
|
|
347
|
+
#: reading `tan doctor`'s output sees which compiler was actually proven
|
|
348
|
+
#: to run, not just a version number.
|
|
349
|
+
target_triple: str
|
|
350
|
+
#: True only when this tan hashed the toolchain archive against the alp-sdk pin
|
|
351
|
+
#: (tan-cli#1496). Absent in older stamps -> False.
|
|
352
|
+
pin_checked: bool = False
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def render_stamp(stamp: ToolchainStamp) -> str:
|
|
356
|
+
"""The stamp file's exact bytes. `schemaVersion` for the same forward-
|
|
357
|
+
compatibility reason every other tan/alp-sdk fact file carries one."""
|
|
358
|
+
return (
|
|
359
|
+
json.dumps(
|
|
360
|
+
{
|
|
361
|
+
"schemaVersion": 1,
|
|
362
|
+
"version": stamp.version,
|
|
363
|
+
"manifestDigest": stamp.manifest_digest,
|
|
364
|
+
"targetTriple": stamp.target_triple,
|
|
365
|
+
"pinChecked": stamp.pin_checked,
|
|
366
|
+
},
|
|
367
|
+
indent=2,
|
|
368
|
+
sort_keys=True,
|
|
369
|
+
)
|
|
370
|
+
+ "\n"
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def parse_stamp(text: str) -> ToolchainStamp | None:
|
|
375
|
+
"""`None` on ANY parse problem (missing file, invalid JSON, wrong shape)
|
|
376
|
+
-- an unreadable stamp is treated exactly like "no stamp", never a crash
|
|
377
|
+
and never a stale-but-trusted value."""
|
|
378
|
+
try:
|
|
379
|
+
doc = json.loads(text)
|
|
380
|
+
except ValueError:
|
|
381
|
+
return None
|
|
382
|
+
if not isinstance(doc, dict):
|
|
383
|
+
return None
|
|
384
|
+
version = doc.get("version")
|
|
385
|
+
digest = doc.get("manifestDigest")
|
|
386
|
+
triple = doc.get("targetTriple")
|
|
387
|
+
if not (isinstance(version, str) and isinstance(digest, str) and isinstance(triple, str)):
|
|
388
|
+
return None
|
|
389
|
+
return ToolchainStamp(version, digest, triple, doc.get("pinChecked") is True)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def stamp_matches_pin(stamp: ToolchainStamp | None, manifest: ToolchainManifest) -> bool:
|
|
393
|
+
"""The ONE verdict function -- `False` for "no stamp" (`None`) and `False`
|
|
394
|
+
for a stamp naming a different version OR a manifest whose bytes rotated
|
|
395
|
+
under an unchanged version. Never asked "does the directory exist";
|
|
396
|
+
callers that only have a path must read+parse the stamp file first.
|
|
397
|
+
|
|
398
|
+
This is ADR 0021's "a stamped 1.0.1 store against a moved pin is a Fail
|
|
399
|
+
with a fix, not 'a toolchain exists'" made literal: `doctor`'s `toolchain`
|
|
400
|
+
check and `bootstrap`'s skip-if-already-installed step both call this,
|
|
401
|
+
and only this, so they cannot independently drift on what "still valid"
|
|
402
|
+
means.
|
|
403
|
+
"""
|
|
404
|
+
if stamp is None:
|
|
405
|
+
return False
|
|
406
|
+
return stamp.version == manifest.version and stamp.manifest_digest == manifest.digest()
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# ---------------------------------------------------------------------------
|
|
410
|
+
# Disk preflight
|
|
411
|
+
# ---------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def required_bytes(manifest: ToolchainManifest) -> int | None:
|
|
415
|
+
"""Bytes tan should refuse to proceed without -- the manifest's own
|
|
416
|
+
measured extracted footprint plus [`DISK_MARGIN_RATIO`]. `None` when the
|
|
417
|
+
manifest predates `measuredFootprint` (an older alp-sdk pin): there is
|
|
418
|
+
nothing to preflight against, so the caller skips the check rather than
|
|
419
|
+
inventing a number -- an unmeasured guess would be exactly the ~17 GB
|
|
420
|
+
`pr-twister.yml` estimate ADR 0021 itself found wrong in both directions.
|
|
421
|
+
"""
|
|
422
|
+
if manifest.extracted_bytes_whole_sdk is None:
|
|
423
|
+
return None
|
|
424
|
+
return int(manifest.extracted_bytes_whole_sdk * (1 + DISK_MARGIN_RATIO))
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _gib(num_bytes: int) -> str:
|
|
428
|
+
return f"{num_bytes / (1 << 30):.2f} GiB"
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def disk_preflight_refusal(free_bytes: int, needed_bytes: int) -> str | None:
|
|
432
|
+
"""`None` when there is enough room; otherwise a message naming BOTH
|
|
433
|
+
numbers -- "Refuse with a number" (ADR 0021's own phrase), never a bare
|
|
434
|
+
"not enough disk space"."""
|
|
435
|
+
if free_bytes >= needed_bytes:
|
|
436
|
+
return None
|
|
437
|
+
return (
|
|
438
|
+
f"only {_gib(free_bytes)} free, but the Zephyr SDK + {TOOLCHAIN_COMPONENT} "
|
|
439
|
+
f"toolchain need about {_gib(needed_bytes)} on disk (the pinned manifest's "
|
|
440
|
+
f"own measured footprint plus a {int(DISK_MARGIN_RATIO * 100)}% margin) -- "
|
|
441
|
+
"free up space and re-run `tan bootstrap`, or pass `--no-toolchain` to skip "
|
|
442
|
+
"cross-toolchain acquisition for now (native_sim and workspace setup are "
|
|
443
|
+
"unaffected)."
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
#: Below this, a `west sdk install` failure gets a disk-exhaustion note even
|
|
448
|
+
#: though the PREFLIGHT passed -- the preflight checks free space once,
|
|
449
|
+
#: before anything downloads; the download+extract itself is what actually
|
|
450
|
+
#: consumes it, and a host that barely cleared the preflight bar can still
|
|
451
|
+
#: run out mid-extraction (the archive and the extracted tree briefly
|
|
452
|
+
#: coexist, exactly the margin [`DISK_MARGIN_RATIO`] exists for, but a margin
|
|
453
|
+
#: is a cushion, not a guarantee on an already-tight host).
|
|
454
|
+
LOW_DISK_AFTER_FAILURE_FLOOR_BYTES = 512 * (1 << 20)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def low_disk_note(free_bytes: int) -> str | None:
|
|
458
|
+
"""`None` when `free_bytes` is comfortably above
|
|
459
|
+
[`LOW_DISK_AFTER_FAILURE_FLOOR_BYTES`]; otherwise a note worth appending
|
|
460
|
+
to a `west sdk install` failure message -- disk exhaustion DURING
|
|
461
|
+
extraction reads identically to a dozen other causes, and is worth
|
|
462
|
+
naming as a candidate even now that `west sdk install`'s own capture
|
|
463
|
+
uses a wider tail (`bootstrap_cmd.TOOLCHAIN_INSTALL_TAIL_LINES`, tan-cli
|
|
464
|
+
#990 review) than `tan.core.bootstrap.capture_tail`'s 4-line default:
|
|
465
|
+
the free-space reading here is taken AFTER `west`'s own
|
|
466
|
+
`tempfile.TemporaryDirectory` cleanup already ran, so it can still miss
|
|
467
|
+
a failure that was genuinely disk-caused DURING extraction and only
|
|
468
|
+
looks fine again once the failed attempt's own partial state was
|
|
469
|
+
deleted.
|
|
470
|
+
"""
|
|
471
|
+
if free_bytes >= LOW_DISK_AFTER_FAILURE_FLOOR_BYTES:
|
|
472
|
+
return None
|
|
473
|
+
return (
|
|
474
|
+
f"only {_gib(free_bytes)} free on this volume right now -- if the failure "
|
|
475
|
+
"above does not obviously name a cause, low disk space during extraction "
|
|
476
|
+
"is a likely one even though the preflight passed (the preflight checks "
|
|
477
|
+
"space ONCE, before anything is written; a host that barely cleared it can "
|
|
478
|
+
"still run out mid-extraction)."
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# ---------------------------------------------------------------------------
|
|
483
|
+
# `west sdk install` argv + failure classification
|
|
484
|
+
# ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
#: Passed on EVERY `west sdk install` tan spawns (tan-cli#1176), never
|
|
488
|
+
#: probed for. Without it `west` runs the pinned SDK's own `setup.sh -t
|
|
489
|
+
#: arm-zephyr-eabi -h`, whose bundled host-tools install needs `file(1)` on
|
|
490
|
+
#: `PATH`. `file` is NOT among the prerequisites the quickstart documents
|
|
491
|
+
#: (`cmake ninja-build python3 python3-pip python3-venv git curl xz-utils
|
|
492
|
+
#: wget`), so on a host carrying only those -- a bare `ubuntu:24.04` is the
|
|
493
|
+
#: measured case -- the step dies with:
|
|
494
|
+
#:
|
|
495
|
+
#: Installing host tools ...
|
|
496
|
+
#: ERROR: Host tools installation failed
|
|
497
|
+
#: FATAL ERROR: command "<sdk>/setup.sh -t arm-zephyr-eabi -h" failed
|
|
498
|
+
#:
|
|
499
|
+
#: `bootstrap_cmd._acquire_toolchain` then retries three times and `tan
|
|
500
|
+
#: bootstrap` exits 1 with "bootstrap: INCOMPLETE -- toolchain-install did not
|
|
501
|
+
#: install, so this workspace cannot build yet." The flag is what makes the
|
|
502
|
+
#: automatic acquisition path (tan-cli#990 / ADR 0021 Lane 1 P1) work on the
|
|
503
|
+
#: exact host class the quickstart documents, and it is what makes
|
|
504
|
+
#: `doctor_cmd`'s own note -- that `file` stays out of the prerequisites
|
|
505
|
+
#: manifest because "`tan bootstrap` genuinely does not need it" -- TRUE;
|
|
506
|
+
#: between #990 and #1176 that sentence was false. It is also the same flag
|
|
507
|
+
#: alp-sdk's `metadata/bootstrap.json` (`manualInstallHints.posix.note[0]`,
|
|
508
|
+
#: vendored at `contract/fixtures/bootstrap/manifest.json` and mirrored in
|
|
509
|
+
#: `tan.core.bootstrap`'s own hint text) and
|
|
510
|
+
#: `.github/workflows/onramp-clean-container.yml` have always used. NOT the
|
|
511
|
+
#: README's manual command, which is `west sdk install --version 1.0.1 -t
|
|
512
|
+
#: arm-zephyr-eabi` and carries no `--no-hosttools` on purpose: it mirrors
|
|
513
|
+
#: `doctor_cmd.zephyr_sdk_install_command()` / the frozen Rust oracle
|
|
514
|
+
#: verbatim, so it DOES run the host-tools step and DOES need `file` -- which
|
|
515
|
+
#: is exactly what the README says beside it.
|
|
516
|
+
#:
|
|
517
|
+
#: UNCONDITIONAL, and that is deliberate. Passing it only when `file(1)` is
|
|
518
|
+
#: absent would produce two differently-shaped installs -- one with
|
|
519
|
+
#: `hosttools/`, one without -- that are INDISTINGUISHABLE in the store: the
|
|
520
|
+
#: store is keyed by artifact and version alone
|
|
521
|
+
#: (`~/.alp/toolchains/zephyr-sdk-<version>-arm-zephyr-eabi/`) and the
|
|
522
|
+
#: verification stamp records the pinned version plus the manifest digest,
|
|
523
|
+
#: neither of which encodes the flag. A host that installed one shape and
|
|
524
|
+
#: later wanted the other would read a matching stamp and skip. One shape,
|
|
525
|
+
#: always.
|
|
526
|
+
#:
|
|
527
|
+
#: WHAT IT GIVES UP, stated rather than glossed: the whole `hosttools/`
|
|
528
|
+
#: bundle. Measured on a real `zephyr-sdk-1.0.1` install,
|
|
529
|
+
#: `hosttools/sysroots/x86_64-pokysdk-linux/usr/bin/` holds 39 binaries, among
|
|
530
|
+
#: them `dtc` (plus `fdtdump`/`fdtget`/`fdtoverlay`/`fdtput`), `openocd`,
|
|
531
|
+
#: `bossac` and the `qemu-system-*` family;
|
|
532
|
+
#: `contract/fixtures/toolchains/toolchains.json` measures the same bundle at
|
|
533
|
+
#: 1242632545 bytes, 61% of the 2026739200-byte SDK. Zephyr treats both `dtc`
|
|
534
|
+
#: and `openocd` as optional -- `cmake/modules/FindHostTools.cmake` says so in
|
|
535
|
+
#: as many words for openocd ("openocd is an optional dependency", a bare
|
|
536
|
+
#: `find_program(OPENOCD openocd)`) -- and the container job above builds a
|
|
537
|
+
#: real `zephyr.elf` with this flag and no `dtc`, `gperf` or `file` anywhere
|
|
538
|
+
#: on the host.
|
|
539
|
+
#:
|
|
540
|
+
#: The consequence worth naming beyond `dtc` is OPENOCD. `tan debug-config
|
|
541
|
+
#: --server openocd` fills `serverpath`/`searchDir` from `runners.yaml`'s
|
|
542
|
+
#: `config.openocd` / `config.openocd_search`, and Zephyr writes those keys
|
|
543
|
+
#: only inside `if(OPENOCD)` (`cmake/flash/CMakeLists.txt`), resolved from the
|
|
544
|
+
#: SDK's own `list(APPEND CMAKE_PREFIX_PATH ${HOST_TOOLS_HOME}/usr)`
|
|
545
|
+
#: (`<sdk>/cmake/zephyr/host-tools.cmake`). With no `hosttools/` and no system
|
|
546
|
+
#: OpenOCD both keys are simply ABSENT -- never `OPENOCD-NOTFOUND` -- so
|
|
547
|
+
#: `debug_launch.apply_launch_resolution` inserts neither (both are additive,
|
|
548
|
+
#: neither is a `<resolved-...>` placeholder) and `_preview_notes_for`'s
|
|
549
|
+
#: "Placeholder fields" warning does not fire. The emitted configuration stays
|
|
550
|
+
#: valid and `configFiles` still resolves (it comes from `board.cmake`, not
|
|
551
|
+
#: from `hosttools/`); cortex-debug then falls back to its own
|
|
552
|
+
#: `openocd`-on-`PATH` lookup. NO LONGER SILENT (tan-cli#1179): a host with no
|
|
553
|
+
#: `openocd` on `PATH` either used to learn this from the debug adapter at
|
|
554
|
+
#: session start, with `tan` nowhere in the loop -- `debug_config_cmd`'s
|
|
555
|
+
#: `_openocd_hosttools_note` now says it in `data.notes` instead, naming this
|
|
556
|
+
#: flag and that OpenOCD has to come from the system. It stays a NOTE, not a
|
|
557
|
+
#: failure: one advisory field is missing, nothing else is, and a host that
|
|
558
|
+
#: does have `openocd` on `PATH` (or a `serverpath` that resolved) hears
|
|
559
|
+
#: nothing. `tan support-bundle` probes `openocd` on `PATH` too -- through the
|
|
560
|
+
#: very table and lookup that note borrows.
|
|
561
|
+
#:
|
|
562
|
+
#: The `dtc` half of the same shape is STILL silent and is tracked as
|
|
563
|
+
#: tan-cli#1192: `cmake/modules/dts.cmake` runs the devicetree lint only
|
|
564
|
+
#: `if(DTC)`, so with no `hosttools/` and no system `dtc` the
|
|
565
|
+
#: `-Wunique_unit_address_if_enabled` / `-E unit_address_vs_reg` pass stops
|
|
566
|
+
#: running and nothing says so. It is a `tan doctor` CHECK rather than a note,
|
|
567
|
+
#: which is why tan-cli#1179 did not carry it: it moves that command's verdict,
|
|
568
|
+
#: and after this flag a CORRECT install has no `dtc` at all, so the naive
|
|
569
|
+
#: on-PATH predicate would warn on every correctly-provisioned host.
|
|
570
|
+
NO_HOSTTOOLS_FLAG = "--no-hosttools"
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
#: tan-cli#1496: west installs ONLY the (west-verified) minimal bundle; the
|
|
574
|
+
#: toolchain archive is fetched and hashed by tan itself, because the bundle's
|
|
575
|
+
#: `setup.sh` downloads it with `wget` and no integrity check.
|
|
576
|
+
NO_GNU_TOOLCHAINS_FLAG = "--no-gnu-toolchains"
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def west_sdk_install_argv(west: str, *, version: str, install_dir: str) -> list[str]:
|
|
580
|
+
"""The one place this argv is assembled -- `--no-gnu-toolchains`, not the
|
|
581
|
+
deprecated `--no-toolchains` alias (`scripts/west_commands/sdk.py`: the
|
|
582
|
+
deprecated spelling only warns-and-aliases today, but a customer reading
|
|
583
|
+
`data.plannedCommands` under `--dry-run` should see the command tan will
|
|
584
|
+
actually run). The toolchain itself is then acquired by tan
|
|
585
|
+
([`NO_GNU_TOOLCHAINS_FLAG`]).
|
|
586
|
+
|
|
587
|
+
[`NO_HOSTTOOLS_FLAG`] rides on every one of these, unconditionally --
|
|
588
|
+
see its own comment for the `file(1)` failure it prevents (tan-cli#1176),
|
|
589
|
+
why it is not probed for, and what it gives up (`dtc`, OpenOCD).
|
|
590
|
+
"""
|
|
591
|
+
return [
|
|
592
|
+
west,
|
|
593
|
+
"sdk",
|
|
594
|
+
"install",
|
|
595
|
+
"--version",
|
|
596
|
+
version,
|
|
597
|
+
NO_GNU_TOOLCHAINS_FLAG,
|
|
598
|
+
NO_HOSTTOOLS_FLAG,
|
|
599
|
+
"--install-dir",
|
|
600
|
+
install_dir,
|
|
601
|
+
]
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def augment_acquisition_failure(detail: str) -> str:
|
|
605
|
+
"""Append the proxy/CA-interference hint (#304's `_TLS_HINT` lesson,
|
|
606
|
+
applied to THIS download) when `detail` -- the captured `west sdk
|
|
607
|
+
install` failure text -- names a checksum mismatch. `west` itself
|
|
608
|
+
compares downloads against the release's own published `sha256.sum`
|
|
609
|
+
(tan checks alp-sdk's pin against that sum BEFORE the install) and raises `sha256 mismatched: <want>:<got>` on a
|
|
610
|
+
disagreement -- which reads exactly like "the upstream archive is
|
|
611
|
+
corrupt" unless a reader is told a TLS-terminating middlebox rewriting
|
|
612
|
+
the byte stream produces the identical symptom.
|
|
613
|
+
"""
|
|
614
|
+
lower = detail.lower()
|
|
615
|
+
if "sha256" in lower or "checksum" in lower:
|
|
616
|
+
return (
|
|
617
|
+
f"{detail} This can mean a TLS-intercepting proxy or corporate CA "
|
|
618
|
+
"rewrote the download in flight rather than the upstream archive "
|
|
619
|
+
"being corrupt -- check ALL_PROXY/HTTPS_PROXY/NO_PROXY, or retry "
|
|
620
|
+
"from a network path that does not intercept TLS."
|
|
621
|
+
)
|
|
622
|
+
return detail
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
# ---------------------------------------------------------------------------
|
|
626
|
+
# Authenticating the SDK download (tan-cli#1143)
|
|
627
|
+
# ---------------------------------------------------------------------------
|
|
628
|
+
|
|
629
|
+
#: The environment variables `tan bootstrap` reads a GitHub credential from,
|
|
630
|
+
#: in precedence order, for the `west sdk install` download ONLY.
|
|
631
|
+
#:
|
|
632
|
+
#: **An environment variable, not a CLI flag, and not a manifest field.** A
|
|
633
|
+
#: token is a secret, and the three candidate surfaces are not equally safe
|
|
634
|
+
#: for one:
|
|
635
|
+
#:
|
|
636
|
+
#: - a CLI flag lands the value in shell history, in the host process table
|
|
637
|
+
#: for the whole (multi-minute) run, and in any CI log that echoes the
|
|
638
|
+
#: command -- and `tan`'s own argv is what a customer pastes into a bug
|
|
639
|
+
#: report;
|
|
640
|
+
#: - a manifest field puts it in a file people commit;
|
|
641
|
+
#: - an environment variable is the one surface that survives none of those,
|
|
642
|
+
#: and is already how every workflow in this repo carries `GH_TOKEN`.
|
|
643
|
+
#:
|
|
644
|
+
#: The precedence deliberately DEFERS to the existing credential sources
|
|
645
|
+
#: rather than inventing a tan-only concept: `GH_TOKEN` then `GITHUB_TOKEN`
|
|
646
|
+
#: is `gh`'s own order, so a developer who has run `gh auth login`, and a
|
|
647
|
+
#: GitHub Actions job that already exports the workflow token, both get an
|
|
648
|
+
#: authenticated download with nothing new to set. `TAN_GITHUB_TOKEN` leads
|
|
649
|
+
#: so a host with an ambient `GH_TOKEN` bound to some other account can
|
|
650
|
+
#: override it for tan alone.
|
|
651
|
+
SDK_TOKEN_ENV_VARS: tuple[str, ...] = ("TAN_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
|
|
652
|
+
|
|
653
|
+
#: The env var `requests` reads a netrc path from -- `requests.utils.
|
|
654
|
+
#: get_netrc_auth` consults `os.environ["NETRC"]` FIRST and falls back to
|
|
655
|
+
#: `~/.netrc` only when it is unset. This is the whole reason the token can
|
|
656
|
+
#: travel to `west` out of band: `west sdk install` reads a token from
|
|
657
|
+
#: `--personal-access-token` and from NOTHING else (measured against Zephyr
|
|
658
|
+
#: v4.4.1 `scripts/west_commands/sdk.py:473` -- it consults no environment
|
|
659
|
+
#: variable of its own; `grep environ` across that file finds only
|
|
660
|
+
#: `ZEPHYR_BASE` and `ZEPHYR_SDK_INSTALL_DIR`), but its GitHub call goes
|
|
661
|
+
#: through `requests`, and `requests` applies netrc credentials whenever the
|
|
662
|
+
#: request carries no `Authorization` header -- which is exactly west's
|
|
663
|
+
#: `req_headers = {}` branch. West's own rate-limit message names the
|
|
664
|
+
#: mechanism: "Try executing install script with --personal-access-token
|
|
665
|
+
#: argument **or use a .netrc file**".
|
|
666
|
+
#:
|
|
667
|
+
#: **This is STRICTLY NARROWER than the flag it replaces, and that is a
|
|
668
|
+
#: feature, not a consolation.** `--personal-access-token` puts an
|
|
669
|
+
#: `Authorization: Bearer` header on west's `requests` session, and
|
|
670
|
+
#: `requests` carries a session header across a redirect to a different host
|
|
671
|
+
#: unless the caller strips it; a netrc credential is matched PER MACHINE, and
|
|
672
|
+
#: [`netrc_text`] names only [`GITHUB_API_HOST`]. So the token is offered to
|
|
673
|
+
#: the rate-limited `fetch_releases` call and to nothing else -- not to the
|
|
674
|
+
#: release CDN the multi-hundred-megabyte archive is actually fetched from,
|
|
675
|
+
#: not to anything else the child talks to.
|
|
676
|
+
#:
|
|
677
|
+
#: **It REPLACES rather than augments the caller's own netrc**, for the
|
|
678
|
+
#: duration of that one child. `get_netrc_auth` sets
|
|
679
|
+
#: `netrc_locations = (netrc_file,)` when `$NETRC` is set -- there is no
|
|
680
|
+
#: `~/.netrc` fallback behind it -- so a caller whose own `~/.netrc`
|
|
681
|
+
#: authenticates some other host loses it inside `west sdk install`.
|
|
682
|
+
#: Accepted rather than worked around: merging their file into ours would
|
|
683
|
+
#: mean copying THEIR secrets into a file tan wrote, which is a worse trade
|
|
684
|
+
#: than one child process not seeing an unrelated credential, and the child
|
|
685
|
+
#: in question talks to GitHub and its CDN and nothing else. Only ever set
|
|
686
|
+
#: when tan actually has a token; an unauthenticated run passes no env at all.
|
|
687
|
+
#:
|
|
688
|
+
#: **Staging touches disk.** The token is written to a real file (see
|
|
689
|
+
#: `bootstrap_cmd._stage_sdk_credential`), not held in memory -- mode 0600
|
|
690
|
+
#: inside a 0700 directory under the toolchain root, deleted in a `finally`
|
|
691
|
+
#: and swept on the next run if a crash skipped that.
|
|
692
|
+
#:
|
|
693
|
+
#: **RESIDUAL RISK, recorded because tan pins neither west nor requests --
|
|
694
|
+
#: and CHECKED AT RUNTIME since tan-cli#1154.** Two external behaviours make
|
|
695
|
+
#: this work and neither is under a version constraint tan controls: (1)
|
|
696
|
+
#: `requests.utils.get_netrc_auth` reading `$NETRC` (measured on 2.34.2, which
|
|
697
|
+
#: is also the current release on PyPI; present since 2.15), and (2) west's
|
|
698
|
+
#: no-token branch leaving `req_headers` EMPTY (measured on Zephyr v4.4.1
|
|
699
|
+
#: `sdk.py:478`, unchanged at v4.4.2 `:478` and on `zephyrproject/main`
|
|
700
|
+
#: @8a4e180b93e `:479`).
|
|
701
|
+
#:
|
|
702
|
+
#: **(2)'s MECHANISM was stated wrongly here and the correction is recorded
|
|
703
|
+
#: rather than quietly swapped in.** An earlier revision said `requests`
|
|
704
|
+
#: "skips netrc entirely for a request that already carries an
|
|
705
|
+
#: `Authorization` header". Measured against 2.34.2 -- a real `http.server`, a
|
|
706
|
+
#: real netrc, the header read back off the wire -- it does not.
|
|
707
|
+
#: `Session.prepare_request` gates netrc on `self.trust_env and not auth and
|
|
708
|
+
#: not self.auth` -- the `auth=` ARGUMENT and the session's `trust_env`, never
|
|
709
|
+
#: the headers; `PreparedRequest.prepare` runs `prepare_auth` AFTER
|
|
710
|
+
#: `prepare_headers`; so a netrc match OVERWRITES an `Authorization` header
|
|
711
|
+
#: passed through `headers=`, which is exactly how west passes `req_headers`.
|
|
712
|
+
#: A future west that sets a header on that branch therefore does not make
|
|
713
|
+
#: TAN's credential inert -- it makes WEST'S inert, under tan's, silently.
|
|
714
|
+
#: Either way the user is authenticated as something other than what they were
|
|
715
|
+
#: told, and either way the answer is the same: stop claiming authentication
|
|
716
|
+
#: and say so.
|
|
717
|
+
#:
|
|
718
|
+
#: The corollary matters more than the correction, because it says what a
|
|
719
|
+
#: SUPPRESSED netrc actually looks like: the only two shapes that stop tan's
|
|
720
|
+
#: credential reaching the wire are the `auth=` argument (netrc skipped,
|
|
721
|
+
#: west's own credential goes out instead) and `trust_env=False` (measured:
|
|
722
|
+
#: `Authorization: None`, nothing goes out at all). Headers are not one of
|
|
723
|
+
#: them. Anything downstream that tells a reader otherwise is wrong -- see
|
|
724
|
+
#: [`_AUTHENTICATED_QUOTA_NOTE`], which said exactly that until tan-cli#1170,
|
|
725
|
+
#: two rounds after this note was corrected: a docstring fix that leaves the
|
|
726
|
+
#: customer-facing copy of the same claim standing has fixed nothing.
|
|
727
|
+
#:
|
|
728
|
+
#: There is no CI gate for this: `requests` is not a tan dependency
|
|
729
|
+
#: (`pyproject.toml` -- `import requests` is a `ModuleNotFoundError` in a
|
|
730
|
+
#: CI-shaped venv) and `ci.yml` checks out alp-sdk but never zephyr, so a
|
|
731
|
+
#: CI-time gate would SKIP everywhere, which is a dead gate rather than a real
|
|
732
|
+
#: one. The RUNTIME check is [`west_sdk_netrc_assumptions_hold`], driven by
|
|
733
|
+
#: `bootstrap_cmd._west_sdk_netrc_drift` against the `zephyr_base` the
|
|
734
|
+
#: bootstrap has already resolved: it runs where the artifact exists and
|
|
735
|
+
#: nowhere else, and downgrades the "Authenticating ..." line to a
|
|
736
|
+
#: `bootstrap.sdk-credential-unverified` warning when it trips. It watches for
|
|
737
|
+
#: BOTH suppressing shapes named above and for the flag the token arrives on
|
|
738
|
+
#: -- read its own docstring for the bound on that, which is real: a west that
|
|
739
|
+
#: stops using `requests` at all satisfies every fact it checks.
|
|
740
|
+
#: [`rate_limit_note`]'s authenticated branch remains the second half of this
|
|
741
|
+
#: -- it is the moment a suppressed transport becomes observable, when a
|
|
742
|
+
#: credentialled download is rate-limited anyway. Re-verify with
|
|
743
|
+
#: `grep -n "req_headers" <zephyr>/scripts/west_commands/sdk.py` and
|
|
744
|
+
#: `python -c "import inspect,requests as r; print(inspect.getsource(
|
|
745
|
+
#: r.sessions.Session.prepare_request))"` -- the gate itself, not just
|
|
746
|
+
#: `get_netrc_auth`, since the gate is where `auth=` and `trust_env` bite.
|
|
747
|
+
NETRC_ENV_VAR = "NETRC"
|
|
748
|
+
|
|
749
|
+
#: The source line `west sdk install`'s NO-TOKEN branch leaves behind -- the
|
|
750
|
+
#: `else:` arm of `if args.personal_access_token:`. See
|
|
751
|
+
#: [`west_sdk_netrc_assumptions_hold`].
|
|
752
|
+
WEST_SDK_NO_TOKEN_HEADERS = "req_headers = {}"
|
|
753
|
+
|
|
754
|
+
#: The attribute that `else:` is the arm of. Measured present in the real file
|
|
755
|
+
#: at Zephyr v4.4.1 `sdk.py:473`. Two jobs: it is what makes
|
|
756
|
+
#: [`WEST_SDK_NO_TOKEN_HEADERS`] mean anything (a `req_headers = {}` that is no
|
|
757
|
+
#: longer that branch proves nothing about the no-token path), and its
|
|
758
|
+
#: DISAPPEARANCE is the "west grew a credential of its own" drift -- a west
|
|
759
|
+
#: that reads a token from a config file rather than this flag can leave every
|
|
760
|
+
#: other fact below satisfied while tan's netrc is no longer what authenticates
|
|
761
|
+
#: the download.
|
|
762
|
+
WEST_SDK_TOKEN_FLAG_ATTR = "args.personal_access_token"
|
|
763
|
+
|
|
764
|
+
#: Every environment variable `scripts/west_commands/sdk.py` is known to read
|
|
765
|
+
#: (`sdk.py:231` and `:573` at Zephyr v4.4.1). No credential among them, which
|
|
766
|
+
#: is the entire reason tan's token has to travel by netrc rather than by an
|
|
767
|
+
#: environment variable west would read directly.
|
|
768
|
+
WEST_SDK_KNOWN_ENV_READS = frozenset({"ZEPHYR_BASE", "ZEPHYR_SDK_INSTALL_DIR"})
|
|
769
|
+
|
|
770
|
+
#: What [`west_sdk_netrc_assumptions_hold`]'s environment-allowlist fact
|
|
771
|
+
#: actually sees, and what it does not. It matches TEXT, not reads: measured
|
|
772
|
+
#: against a copy of the real v4.4.1 file it TRIPS on a bare COMMENT
|
|
773
|
+
#: mentioning `os.environ.get("ZEPHYR_TOOLCHAIN_VARIANT")`, and it stays
|
|
774
|
+
#: SILENT on `from os import environ; environ.get("WEST_GITHUB_TOKEN")` and on
|
|
775
|
+
#: `os.environ.get(_TOKEN_VAR)` -- an indirect name it cannot resolve. The
|
|
776
|
+
#: substring facts have the same two-sided character: `req_headers = dict()`
|
|
777
|
+
#: and `req_headers: dict = {}` both TRIP [`WEST_SDK_NO_TOKEN_HEADERS`]
|
|
778
|
+
#: despite changing nothing that matters. Widening the regex to close the
|
|
779
|
+
#: silences would cost more false trips, which is the trade this file has
|
|
780
|
+
#: already made in the other direction -- but a reader must not take silence
|
|
781
|
+
#: for proof.
|
|
782
|
+
_WEST_SDK_ENV_READ = re.compile(
|
|
783
|
+
r"""os\.(?:environ(?:\.get)?|getenv)\s*[(\[]\s*["']([A-Za-z_][A-Za-z0-9_]*)"""
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
#: An `auth=` keyword argument anywhere in the file. THE shape that makes
|
|
787
|
+
#: `requests` skip netrc outright -- `Session.prepare_request` gates the netrc
|
|
788
|
+
#: lookup on `self.trust_env and not auth and not self.auth`, re-measured on
|
|
789
|
+
#: 2.34.2 -- so a west that grows one takes tan's credential out of the request
|
|
790
|
+
#: entirely and puts its own in. Word-bounded, so `oauth=` is not a match;
|
|
791
|
+
#: measured ABSENT from the real v4.4.1 file, whose three `requests.get` calls
|
|
792
|
+
#: pass only `url`, `headers=`, `params=` and `stream=` (`sdk.py:266`, `:332`,
|
|
793
|
+
#: `:340`).
|
|
794
|
+
#:
|
|
795
|
+
#: Two-sided in the same way [`_WEST_SDK_ENV_READ`] is, and recorded here for
|
|
796
|
+
#: the same reason: it matches TEXT, so a comment, a help string or an
|
|
797
|
+
#: unrelated helper's `auth=None` default TRIPS it, while a call that reaches
|
|
798
|
+
#: the same place indirectly (`**kwargs`, a wrapper that adds the argument
|
|
799
|
+
#: itself) is SILENT. Fail-toward-telling is the chosen direction, but the
|
|
800
|
+
#: silence is real.
|
|
801
|
+
_WEST_SDK_REQUESTS_AUTH_ARG = re.compile(r"\bauth\s*=")
|
|
802
|
+
|
|
803
|
+
#: The other half of that gate. A `Session(trust_env=False)` was measured to
|
|
804
|
+
#: send `Authorization: None` -- no netrc read at all, and unlike the `auth=`
|
|
805
|
+
#: case nothing of west's replacing it, so the download simply goes out on the
|
|
806
|
+
#: anonymous quota. Matched as a bare substring rather than a call shape, so
|
|
807
|
+
#: `s.trust_env = False` on a session built elsewhere trips it too. Measured
|
|
808
|
+
#: absent from the real v4.4.1 file, which constructs no `Session` and calls
|
|
809
|
+
#: the module-level `requests.get`.
|
|
810
|
+
WEST_SDK_TRUST_ENV = "trust_env"
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def west_sdk_netrc_assumptions_hold(source: str) -> bool:
|
|
814
|
+
"""Whether `source` -- the text of `<zephyr_base>/scripts/west_commands/
|
|
815
|
+
sdk.py` -- still matches what [`NETRC_ENV_VAR`]'s residual-risk note was
|
|
816
|
+
measured against.
|
|
817
|
+
|
|
818
|
+
**A TRIPWIRE, not a parser** (tan-cli#1154). It answers one question --
|
|
819
|
+
"is this the shape the netrc route was reasoned about?" -- and deliberately
|
|
820
|
+
does not model west's control flow. Four substring-level facts:
|
|
821
|
+
|
|
822
|
+
1. [`WEST_SDK_TOKEN_FLAG_ATTR`] is still present -- west's own token still
|
|
823
|
+
arrives on `--personal-access-token`, not from a config file.
|
|
824
|
+
2. [`WEST_SDK_NO_TOKEN_HEADERS`] is still present -- that flag's `else:`
|
|
825
|
+
arm still sends an empty header dict.
|
|
826
|
+
3. Nothing passes `requests` an [`_WEST_SDK_REQUESTS_AUTH_ARG`] and nothing
|
|
827
|
+
mentions [`WEST_SDK_TRUST_ENV`] -- the only two shapes MEASURED to stop
|
|
828
|
+
tan's netrc reaching the wire.
|
|
829
|
+
4. Every environment variable the file reads is in
|
|
830
|
+
[`WEST_SDK_KNOWN_ENV_READS`]. (Nothing here checks that the flag still
|
|
831
|
+
feeds a header; fact 1 is the whole of what stands behind it.)
|
|
832
|
+
|
|
833
|
+
**Fact 3 is why 1, 2 and 4 are not enough.** An `Authorization` header on
|
|
834
|
+
the no-token branch does NOT make tan's credential inert -- the netrc match
|
|
835
|
+
OVERWRITES it (see [`NETRC_ENV_VAR`]) -- so a check watching only for that
|
|
836
|
+
would guard none of the vectors this predicate exists for.
|
|
837
|
+
|
|
838
|
+
**It fails toward telling the user, but only for shapes its substrings and
|
|
839
|
+
its regex see, and both directions are real** -- the measured trips and the
|
|
840
|
+
measured silences are recorded on [`_WEST_SDK_ENV_READ`]. The false trip is
|
|
841
|
+
the direction to prefer: it costs one warning and an issue report, while a
|
|
842
|
+
missed one costs a user who is told they are authenticated and is not.
|
|
843
|
+
|
|
844
|
+
**STILL UNCOVERED, said plainly rather than left to be inferred.** Two
|
|
845
|
+
drifts satisfy all four facts: a west that stops using `requests`
|
|
846
|
+
altogether (`urllib`, `httpx`), or moves the GitHub call into a module this
|
|
847
|
+
file merely imports -- `$NETRC` then means nothing and no single-file
|
|
848
|
+
substring check can see it -- and a PARTIAL move off
|
|
849
|
+
`--personal-access-token`, since fact 1 is a substring and a rewrite
|
|
850
|
+
leaving `args.personal_access_token` in a deprecation shim or a help string
|
|
851
|
+
still passes it (measured: replacing BOTH of `sdk.py:473`/`:475` trips it,
|
|
852
|
+
replacing only `:473` does not). The absence of a
|
|
853
|
+
`bootstrap.sdk-credential-unverified` warning is not evidence that neither
|
|
854
|
+
happened.
|
|
855
|
+
"""
|
|
856
|
+
if WEST_SDK_TOKEN_FLAG_ATTR not in source:
|
|
857
|
+
return False
|
|
858
|
+
if WEST_SDK_NO_TOKEN_HEADERS not in source:
|
|
859
|
+
return False
|
|
860
|
+
if WEST_SDK_TRUST_ENV in source or _WEST_SDK_REQUESTS_AUTH_ARG.search(source):
|
|
861
|
+
return False
|
|
862
|
+
return not (set(_WEST_SDK_ENV_READ.findall(source)) - WEST_SDK_KNOWN_ENV_READS)
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def west_sdk_netrc_drift_message(var: str, version: str) -> str:
|
|
866
|
+
"""The `bootstrap.sdk-credential-unverified` message.
|
|
867
|
+
|
|
868
|
+
**Names what the reader should DO, never what west does internally**
|
|
869
|
+
(tan-cli#1154 acceptance). `req_headers`, `os.environ` and
|
|
870
|
+
`prepare_request` are facts about somebody else's file; a customer whose
|
|
871
|
+
SDK download is about to go out on the anonymous quota needs the two
|
|
872
|
+
commands that get them unstuck and the URL that gets tan fixed.
|
|
873
|
+
"""
|
|
874
|
+
return (
|
|
875
|
+
"this workspace's Zephyr handles `west sdk install` credentials "
|
|
876
|
+
"differently from the version tan was measured against, so tan cannot "
|
|
877
|
+
f"confirm the token in ${var} reaches the download -- treat this run as "
|
|
878
|
+
"unauthenticated. If it fails on a GitHub rate limit, retry from a "
|
|
879
|
+
"network with its own egress address, or run `west sdk install "
|
|
880
|
+
f"--version {version} -t {TOOLCHAIN_COMPONENT}` by hand from your west "
|
|
881
|
+
"workspace's top-level directory with a `~/.netrc` entry for "
|
|
882
|
+
f"{GITHUB_API_HOST}. Please report this at "
|
|
883
|
+
"https://github.com/alplabai/tan-cli/issues so tan can be updated."
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
#: The host `west sdk install`'s rate-limited call goes to (`fetch_releases`
|
|
888
|
+
#: against the GitHub REST API). The staged netrc names ONLY this machine, so
|
|
889
|
+
#: the credential is never offered to the release CDN the archive itself is
|
|
890
|
+
#: fetched from, nor to anything else the child happens to talk to.
|
|
891
|
+
GITHUB_API_HOST = "api.github.com"
|
|
892
|
+
|
|
893
|
+
#: The prefix of the private directory the staged netrc lives in, created
|
|
894
|
+
#: under the TOOLCHAIN ROOT rather than under `$TMPDIR` -- see
|
|
895
|
+
#: [`netrc_scratch_glob_pattern`] for why that placement is what makes the
|
|
896
|
+
#: crash-residue sweep safe. Leading dot, so it can never collide with a
|
|
897
|
+
#: [`store_dir_name`] result or with a [`TMP_SUFFIX_PREFIX`] sibling.
|
|
898
|
+
NETRC_SCRATCH_PREFIX = ".alp-netrc-"
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def netrc_scratch_glob_pattern() -> str:
|
|
902
|
+
"""The glob that reclaims a PRIOR run's staged credential -- the
|
|
903
|
+
crash-residue half of the cleanup, since a `finally` does not run on
|
|
904
|
+
SIGKILL, an OOM kill or power loss, and a secret accumulating one copy
|
|
905
|
+
per crash with nothing sweeping it is the wrong steady state.
|
|
906
|
+
|
|
907
|
+
The reason this pattern is rooted at the toolchain root and not at
|
|
908
|
+
`$TMPDIR` is that the sweep's only proof of provenance is the NAME, and a
|
|
909
|
+
name is only proof inside a namespace tan owns. `$TMPDIR` is shared by
|
|
910
|
+
every process on the box, so a `.alp-netrc-*` found there is not
|
|
911
|
+
necessarily one tan wrote; the toolchain root (or the directory
|
|
912
|
+
`$ALP_TOOLCHAIN_ROOT` deliberately named) is the same ownership argument
|
|
913
|
+
[`wreckage_glob_pattern`] already relies on, so this sweep is exactly as
|
|
914
|
+
well-founded as the one running beside it.
|
|
915
|
+
|
|
916
|
+
**Not, as an earlier revision claimed, because sweeping `/tmp` would be
|
|
917
|
+
exploitable** -- corrected on measurement (tan-cli#1148 round 2): `/tmp`
|
|
918
|
+
is `0o1777`, but `shutil.rmtree.avoids_symlink_attacks` is `True` here,
|
|
919
|
+
so no escalation was ever demonstrated and this should not have asserted
|
|
920
|
+
one. What the move measurably DID buy is a swept namespace that is
|
|
921
|
+
NARROWER than `$TMPDIR`, not one that is unconditionally private, and the
|
|
922
|
+
difference is the caller's `umask`. Measured under `umask 022`: root
|
|
923
|
+
`0o755`, scratch `0o700`, netrc `0o600` -- no other user can create a
|
|
924
|
+
`.alp-netrc-*` here. Measured under `umask 002` (this repo's own CI and
|
|
925
|
+
developer default): root `0o775`, so the root is GROUP-WRITABLE and a
|
|
926
|
+
member of that group can. The credential itself is unreadable either way
|
|
927
|
+
(`mkdtemp` forces `0o700` and the netrc `0o600` regardless of umask); the
|
|
928
|
+
part that varies is who can put a directory into the namespace the sweep
|
|
929
|
+
globs.
|
|
930
|
+
|
|
931
|
+
Two residuals follow from that and are recorded rather than claimed away:
|
|
932
|
+
a group-writable root under `umask 002`, and an `$ALP_TOOLCHAIN_ROOT`
|
|
933
|
+
that the operator deliberately pointed at a SHARED directory. In both,
|
|
934
|
+
the sweep can be handed a `.alp-netrc-*` tan did not write. What that
|
|
935
|
+
buys an attacker is bounded by what the sweep does -- `rmtree` of a
|
|
936
|
+
directory, with `avoids_symlink_attacks` true -- so it is a nuisance
|
|
937
|
+
(deleting their own planted directory) rather than an escalation; but
|
|
938
|
+
"narrower than `$TMPDIR`, and private at `umask 022`" is the honest
|
|
939
|
+
statement, not "not creatable at all".
|
|
940
|
+
"""
|
|
941
|
+
return f"{NETRC_SCRATCH_PREFIX}*"
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
#: netrc's `login` for a GitHub token. GitHub ignores the username on a
|
|
945
|
+
#: token-as-password Basic credential; `x-access-token` is the spelling
|
|
946
|
+
#: GitHub's own docs use for it.
|
|
947
|
+
NETRC_LOGIN = "x-access-token"
|
|
948
|
+
|
|
949
|
+
#: A token this module is willing to write into a netrc file. Deliberately a
|
|
950
|
+
#: WHITELIST, not a blacklist of separators: `netrc`'s parser is
|
|
951
|
+
#: whitespace-and-quote-sensitive, so a value carrying a space, a newline or
|
|
952
|
+
#: a `#` does not merely fail to authenticate -- it can make the parser
|
|
953
|
+
#: mis-split the file, and `netrc.NetrcParseError` quotes the offending token
|
|
954
|
+
#: back in its message, which is precisely a secret arriving in a child's
|
|
955
|
+
#: stderr and from there in `capture_tail`. Every GitHub credential format
|
|
956
|
+
#: (`ghp_`/`gho_`/`ghs_`/`github_pat_`, and a bare 40-hex classic token) is
|
|
957
|
+
#: inside this class, so the guard costs nothing real and closes the
|
|
958
|
+
#: injection path completely.
|
|
959
|
+
_SDK_TOKEN_ALLOWED = set(
|
|
960
|
+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-."
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
@dataclass(frozen=True)
|
|
965
|
+
class SdkToken:
|
|
966
|
+
"""A usable GitHub credential and the NAME of the variable it came from.
|
|
967
|
+
|
|
968
|
+
`source` is the only half of this that is ever printed, logged or put in
|
|
969
|
+
an envelope: it is a variable name the customer chose, never the secret.
|
|
970
|
+
"""
|
|
971
|
+
|
|
972
|
+
#: The secret. Never logged, never in an argv, never in an envelope.
|
|
973
|
+
value: str
|
|
974
|
+
#: The environment variable it was read from -- safe to print.
|
|
975
|
+
source: str
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def usable_sdk_token(raw: str | None) -> str | None:
|
|
979
|
+
"""`raw` stripped, or `None` when it is empty or carries a character
|
|
980
|
+
[`_SDK_TOKEN_ALLOWED`] refuses. Surrounding whitespace is forgiven (a
|
|
981
|
+
`GH_TOKEN` set from a file read is routinely `"...\\n"`); interior
|
|
982
|
+
whitespace is not, because a value that cannot be written into a netrc
|
|
983
|
+
line unambiguously must not be written into one at all."""
|
|
984
|
+
if raw is None:
|
|
985
|
+
return None
|
|
986
|
+
token = raw.strip()
|
|
987
|
+
if not token:
|
|
988
|
+
return None
|
|
989
|
+
if any(ch not in _SDK_TOKEN_ALLOWED for ch in token):
|
|
990
|
+
return None
|
|
991
|
+
return token
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def resolve_sdk_token(environ: Mapping[str, str]) -> SdkToken | None:
|
|
995
|
+
"""The first usable credential among [`SDK_TOKEN_ENV_VARS`], in order, or
|
|
996
|
+
`None` when the environment names none -- in which case the download
|
|
997
|
+
stays exactly as unauthenticated as it has always been. A variable that
|
|
998
|
+
is set but empty, or set to something [`usable_sdk_token`] refuses, is
|
|
999
|
+
SKIPPED rather than ending the search: `GITHUB_TOKEN=` is what an unset
|
|
1000
|
+
workflow secret expands to, and letting that shadow a perfectly good
|
|
1001
|
+
`GH_TOKEN` behind it would be the surprise."""
|
|
1002
|
+
for name in SDK_TOKEN_ENV_VARS:
|
|
1003
|
+
token = usable_sdk_token(environ.get(name))
|
|
1004
|
+
if token is not None:
|
|
1005
|
+
return SdkToken(token, name)
|
|
1006
|
+
return None
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def rejected_sdk_token_vars(environ: Mapping[str, str]) -> tuple[str, ...]:
|
|
1010
|
+
"""The [`SDK_TOKEN_ENV_VARS`] that are SET to a non-blank value this
|
|
1011
|
+
module will not use -- [`usable_sdk_token`] refused the characters.
|
|
1012
|
+
|
|
1013
|
+
Exists so the refusal is not silent. The realistic way to land here is a
|
|
1014
|
+
`.env` line or a shell export that kept its literal quotes
|
|
1015
|
+
(`GH_TOKEN="ghp_..."` read by something that does not strip them), and
|
|
1016
|
+
the symptom without this is a download that is anonymous for a reason
|
|
1017
|
+
nothing on screen names, followed by a remedy telling the reader to set
|
|
1018
|
+
a variable they can see is already set.
|
|
1019
|
+
|
|
1020
|
+
A variable that is unset or blank is NOT reported: `GITHUB_TOKEN=` is
|
|
1021
|
+
what an unset workflow secret expands to, and warning about it on every
|
|
1022
|
+
CI run would be noise, not signal.
|
|
1023
|
+
"""
|
|
1024
|
+
return tuple(
|
|
1025
|
+
name
|
|
1026
|
+
for name in SDK_TOKEN_ENV_VARS
|
|
1027
|
+
if (environ.get(name) or "").strip() and usable_sdk_token(environ.get(name)) is None
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def shadowed_sdk_token_vars(
|
|
1032
|
+
environ: Mapping[str, str], winner: SdkToken | None
|
|
1033
|
+
) -> tuple[str, ...]:
|
|
1034
|
+
"""The [`rejected_sdk_token_vars`] that would have WON precedence over
|
|
1035
|
+
`winner` -- all of them when `winner` is `None`.
|
|
1036
|
+
|
|
1037
|
+
`winner` is a REQUIRED argument rather than something this re-derives:
|
|
1038
|
+
the one caller has already called [`resolve_sdk_token`], and resolving
|
|
1039
|
+
the same three variables twice invites the two answers to disagree the
|
|
1040
|
+
day anything about the environment is not stable between them
|
|
1041
|
+
(tan-cli#1148 round 3).
|
|
1042
|
+
|
|
1043
|
+
This, not the raw rejected set, is what is worth telling a customer
|
|
1044
|
+
about. A variable BEHIND the one that won was never going to be consulted
|
|
1045
|
+
(`resolve_sdk_token` takes the first usable and stops), so reporting it
|
|
1046
|
+
is noise about a value that changed nothing; a variable AHEAD of it lost
|
|
1047
|
+
a race it would otherwise have won, which is a real surprise worth
|
|
1048
|
+
naming.
|
|
1049
|
+
"""
|
|
1050
|
+
order = SDK_TOKEN_ENV_VARS
|
|
1051
|
+
limit = order.index(winner.source) if winner is not None else len(order)
|
|
1052
|
+
return tuple(name for name in rejected_sdk_token_vars(environ) if order.index(name) < limit)
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
#: What a customer is told about a variable [`usable_sdk_token`] refused.
|
|
1056
|
+
#: `{name}` is the variable, never the value -- a rejected token is as much a
|
|
1057
|
+
#: secret as an accepted one.
|
|
1058
|
+
_UNUSABLE_TOKEN_PREFIX = (
|
|
1059
|
+
"${name} is set but is not a value tan can use as a GitHub token (letters, "
|
|
1060
|
+
"digits, `_`, `-` and `.` only -- a quoted `.env` value keeps its quotes)"
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
#: The two possible ENDINGS, chosen from what actually happened rather than
|
|
1064
|
+
#: from what was about to be attempted. tan-cli#1148 review round 2: this
|
|
1065
|
+
#: warning used to be raised before the resolve, so with
|
|
1066
|
+
#: `TAN_GITHUB_TOKEN='"quoted"'` AND a good `GH_TOKEN` the envelope carried a
|
|
1067
|
+
#: registered issue code asserting the download "will go out unauthenticated"
|
|
1068
|
+
#: while it went out authenticated. A wire surface stating the opposite of
|
|
1069
|
+
#: what happened is worse than the silence this warning replaced.
|
|
1070
|
+
_UNUSABLE_TOKEN_FELL_BACK = "; tan authenticated the download with ${fallback} instead."
|
|
1071
|
+
_UNUSABLE_TOKEN_UNAUTHENTICATED = "; the Zephyr SDK download will go out unauthenticated."
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def unusable_token_message(name: str, *, authenticated_as: str | None) -> str:
|
|
1075
|
+
"""The `bootstrap.sdk-credential-unstaged` message for one refused
|
|
1076
|
+
variable. `authenticated_as` is the variable whose token really reached
|
|
1077
|
+
the download by the time this is rendered -- `None` if none did."""
|
|
1078
|
+
text = _UNUSABLE_TOKEN_PREFIX.format(name=name)
|
|
1079
|
+
if authenticated_as is not None:
|
|
1080
|
+
return text + _UNUSABLE_TOKEN_FELL_BACK.format(fallback=authenticated_as)
|
|
1081
|
+
return text + _UNUSABLE_TOKEN_UNAUTHENTICATED
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def netrc_text(token: str) -> str:
|
|
1085
|
+
"""The netrc document staged for the `west sdk install` child -- one
|
|
1086
|
+
machine, [`GITHUB_API_HOST`], and nothing else. Trailing newline: Python's
|
|
1087
|
+
`netrc` parser is line-oriented and a file whose last line is unterminated
|
|
1088
|
+
is a needless edge case."""
|
|
1089
|
+
return f"machine {GITHUB_API_HOST}\n login {NETRC_LOGIN}\n password {token}\n"
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
#: The one narrowly-matched marker that says a `west sdk install` failure was
|
|
1093
|
+
#: GitHub's API quota rather than anything about the pin, the network or the
|
|
1094
|
+
#: host. `clean-host.yml`'s own rate-limit exclusion already settled the
|
|
1095
|
+
#: shape of this test in this repo -- match the literal `rate limit`, not a
|
|
1096
|
+
#: bare `403`, so a CA/permission 403 (the #304 shape) cannot be mistaken for
|
|
1097
|
+
#: a quota one and handed a remedy that would not fix it.
|
|
1098
|
+
_RATE_LIMIT_MARKER = "rate limit"
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
#: The AUTHENTICATED-quota branch of [`rate_limit_note`]. `{source}` is the
|
|
1102
|
+
#: variable name, never the value. The closing sentence is deliberate: an
|
|
1103
|
+
#: authenticated download that is rate-limited anyway is the only live symptom
|
|
1104
|
+
#: a SUPPRESSED netrc would ever produce (see [`NETRC_ENV_VAR`]), and this is
|
|
1105
|
+
#: exactly the moment it would show.
|
|
1106
|
+
#:
|
|
1107
|
+
#: **It named the wrong shape until tan-cli#1170, and this is the only
|
|
1108
|
+
#: USER-FACING string that carried the claim.** It used to close with "a
|
|
1109
|
+
#: `west` that sets its own Authorization header would silently bypass it".
|
|
1110
|
+
#: Measured on `requests` 2.34.2, a header does not bypass anything -- the
|
|
1111
|
+
#: netrc match OVERWRITES it, so tan's credential is the one that goes out. A
|
|
1112
|
+
#: customer following that sentence would have filed a west bug about a header
|
|
1113
|
+
#: that demonstrably suppresses nothing, and gone on believing the netrc route
|
|
1114
|
+
#: was the suspect. The two shapes that really do leave it unread are the
|
|
1115
|
+
#: `auth=` argument and `trust_env=False`, so those are what it names now, and
|
|
1116
|
+
#: what [`west_sdk_netrc_assumptions_hold`] watches the file for.
|
|
1117
|
+
_AUTHENTICATED_QUOTA_NOTE = (
|
|
1118
|
+
"That is GitHub's AUTHENTICATED quota, not the anonymous per-IP one: tan "
|
|
1119
|
+
"already handed this download the credential in ${source}. Check that the token "
|
|
1120
|
+
"is valid, unexpired and not already exhausted by other traffic, or wait for the "
|
|
1121
|
+
"quota window to reset and re-run `tan bootstrap`. If you are confident the token "
|
|
1122
|
+
"is good and this persists, the credential may not be reaching `west sdk install` "
|
|
1123
|
+
"at all -- tan hands it over through a netrc, which a `west` that passes its HTTP "
|
|
1124
|
+
"client its own credential argument, or that builds a session with the "
|
|
1125
|
+
"environment switched off, would leave unread; please report it with your "
|
|
1126
|
+
"`west --version` (tan-cli#1143)."
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
#: The branch for "tan staged and passed a credential, but this run had
|
|
1130
|
+
#: already withdrawn the claim that it reaches the download" -- the state
|
|
1131
|
+
#: `bootstrap.sdk-credential-unverified` puts the run into. `{source}` is the
|
|
1132
|
+
#: variable name, never the value.
|
|
1133
|
+
#:
|
|
1134
|
+
#: It exists so the two surfaces cannot contradict each other inside one run
|
|
1135
|
+
#: (tan-cli#1170). The thing it must NOT do is what
|
|
1136
|
+
#: [`_AUTHENTICATED_QUOTA_NOTE`] does -- open with "that is GitHub's
|
|
1137
|
+
#: AUTHENTICATED quota". That sentence is an INFERENCE from "tan handed over a
|
|
1138
|
+
#: credential", and the `sdk-credential-unverified` warning is precisely tan
|
|
1139
|
+
#: saying it can no longer draw it. So this branch names both possibilities and
|
|
1140
|
+
#: hands the reader back to the remedies already printed above, rather than
|
|
1141
|
+
#: repeating them or picking a quota it cannot see.
|
|
1142
|
+
_UNVERIFIED_QUOTA_NOTE = (
|
|
1143
|
+
"tan cannot tell you which of GitHub's two quotas that is. It staged the "
|
|
1144
|
+
"credential in ${source} and passed it to `west sdk install`, but it also warned "
|
|
1145
|
+
"above (`sdk-credential-unverified`) that this workspace's `west` no longer "
|
|
1146
|
+
"matches the shape that route was measured against. So this is either the "
|
|
1147
|
+
"AUTHENTICATED quota -- check the token is valid, unexpired and not already "
|
|
1148
|
+
"exhausted by other traffic, then wait for the window to reset and re-run "
|
|
1149
|
+
"`tan bootstrap` -- or the anonymous per-IP one, shared with everyone behind the "
|
|
1150
|
+
"same egress address. The two remedies in that warning cover both cases."
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
#: The branch for "the environment HELD a credential and this download went
|
|
1154
|
+
#: out anonymous anyway". Points at the `bootstrap.sdk-credential-unstaged`
|
|
1155
|
+
#: warning that says WHY, instead of repeating advice the reader has already
|
|
1156
|
+
#: followed.
|
|
1157
|
+
_UNUSED_CREDENTIAL_NOTE = (
|
|
1158
|
+
"That is GitHub's anonymous per-IP API quota, and this download went out "
|
|
1159
|
+
"anonymous even though ${source} is set -- tan could not use it, and said so in "
|
|
1160
|
+
"the warning above rather than here. Fix that first: re-running with a usable "
|
|
1161
|
+
"credential is what lifts this limit."
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
#: The plain anonymous branch -- the one a customer behind a shared egress
|
|
1165
|
+
#: address actually hits, and the only one that names a variable to set.
|
|
1166
|
+
_ANONYMOUS_QUOTA_NOTE = (
|
|
1167
|
+
"That is GitHub's anonymous per-IP API quota -- shared with everyone behind the "
|
|
1168
|
+
"same egress address, so it can be exhausted by traffic that is not yours. "
|
|
1169
|
+
"Authenticate the download by setting ${primary} (or {alternatives}, read in that "
|
|
1170
|
+
"order) to a GitHub token with no scopes -- listing public releases needs none -- "
|
|
1171
|
+
"and re-run `tan bootstrap`. Ignore the `--personal-access-token` flag named "
|
|
1172
|
+
"above: that is `west`'s own flag, and `tan bootstrap` does not accept it. tan "
|
|
1173
|
+
"passes the token to `west sdk install` out of band, so it never appears in an "
|
|
1174
|
+
"argv, a log or a `--dry-run` plan."
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def rate_limit_note(
|
|
1179
|
+
detail: str,
|
|
1180
|
+
*,
|
|
1181
|
+
authenticated_as: str | None,
|
|
1182
|
+
credential_seen: str | None = None,
|
|
1183
|
+
verified: bool = True,
|
|
1184
|
+
) -> str | None:
|
|
1185
|
+
"""The tan-side remedy to append to a rate-limited `west sdk install`
|
|
1186
|
+
failure, or `None` when `detail` says nothing about a quota.
|
|
1187
|
+
|
|
1188
|
+
The same shape as [`low_disk_note`] and [`augment_acquisition_failure`]:
|
|
1189
|
+
the child's own message is kept verbatim and a note is appended, because
|
|
1190
|
+
that message is actively MISLEADING here -- west tells the reader to
|
|
1191
|
+
"try executing install script with --personal-access-token argument",
|
|
1192
|
+
which is `west`'s flag on a command the customer never typed and which
|
|
1193
|
+
`tan bootstrap` does not accept. Naming tan's own surface is the whole
|
|
1194
|
+
point of the note.
|
|
1195
|
+
|
|
1196
|
+
FOUR states, because there are four genuinely different situations and
|
|
1197
|
+
collapsing any two of them hands somebody advice that cannot help:
|
|
1198
|
+
a credential that reached the download
|
|
1199
|
+
([`_AUTHENTICATED_QUOTA_NOTE`]), one that was staged and passed but whose
|
|
1200
|
+
transport tan could not vouch for ([`_UNVERIFIED_QUOTA_NOTE`]), one that
|
|
1201
|
+
was present but unusable ([`_UNUSED_CREDENTIAL_NOTE`]), and none at all
|
|
1202
|
+
([`_ANONYMOUS_QUOTA_NOTE`]).
|
|
1203
|
+
|
|
1204
|
+
`verified` is the fourth, added in tan-cli#1170 because without it this
|
|
1205
|
+
function contradicted a warning printed minutes earlier in the same run:
|
|
1206
|
+
`bootstrap.sdk-credential-unverified` says "treat this run as
|
|
1207
|
+
unauthenticated", and the authenticated branch then said "tan already
|
|
1208
|
+
handed this download the credential in ${source}". Two wire surfaces
|
|
1209
|
+
asserting opposite facts about one download is the shape
|
|
1210
|
+
`bootstrap_cmd._sdk_credential`'s own docstring calls worse than the
|
|
1211
|
+
silence the warning replaced.
|
|
1212
|
+
"""
|
|
1213
|
+
if _RATE_LIMIT_MARKER not in detail.lower():
|
|
1214
|
+
return None
|
|
1215
|
+
if authenticated_as is not None:
|
|
1216
|
+
if not verified:
|
|
1217
|
+
return _UNVERIFIED_QUOTA_NOTE.format(source=authenticated_as)
|
|
1218
|
+
return _AUTHENTICATED_QUOTA_NOTE.format(source=authenticated_as)
|
|
1219
|
+
if credential_seen is not None:
|
|
1220
|
+
return _UNUSED_CREDENTIAL_NOTE.format(source=credential_seen)
|
|
1221
|
+
primary, *fallbacks = SDK_TOKEN_ENV_VARS
|
|
1222
|
+
return _ANONYMOUS_QUOTA_NOTE.format(
|
|
1223
|
+
primary=primary, alternatives="/".join(f"${name}" for name in fallbacks)
|
|
1224
|
+
)
|