mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-23 06:22:48 +00:00
Bind resumed firmware packages to their effective build recipe
This commit is contained in:
1 parent
eab6676316
commit
e368b52f7d
5 files changed
+1092
-15
No files matched your search
@@ -296,6 +296,7 @@ jobs:
|
||||
python3 -B test/test_full_build_flag_dedup.py
|
||||
python3 -B test/test_full_logging_compile.py
|
||||
python3 -B test/test_full_logging_resume.py
|
||||
python3 -B test/test_firmware_build_recipe.py -v
|
||||
python3 -B test/test_firmware_capabilities.py
|
||||
MESHCORE_TEST_REAL_PIO_PATHS=1 python3 -B test/test_esp32_partition_migration_build_dir.py -v
|
||||
bash test/test_build_profiles.sh
|
||||
|
||||
+108
-14
@@ -107,7 +107,7 @@ FALLBACK_VERSION_PREFIX="dev"
|
||||
FALLBACK_VERSION_DATE_FORMAT='+%Y-%m-%d-%H-%M'
|
||||
|
||||
# External programs invoked by this script:
|
||||
# bash, cat, cp, date, env, find, flock, git, grep, head, mkdir, mv, pgrep,
|
||||
# bash, cat, cp, date, env, find, flock, git, grep, head, mkdir, mktemp, mv, pgrep,
|
||||
# pio, python3, rm, sed, sleep, sort, systemctl, systemd-run, tee, wc
|
||||
# Keep this list in sync when adding or removing non-builtin command usage.
|
||||
|
||||
@@ -4332,7 +4332,10 @@ write_build_capability_manifest() {
|
||||
checker_args+=(--expect-application "$item")
|
||||
done
|
||||
|
||||
python3 scripts/check_firmware_capabilities.py "${checker_args[@]}"
|
||||
python3 scripts/check_firmware_capabilities.py "${checker_args[@]}" || return $?
|
||||
python3 scripts/firmware_build_recipe.py attach \
|
||||
"${OUTPUT_DIR}/${firmware_filename}.capabilities.json" \
|
||||
"$BUILD_RECIPE_SHA256"
|
||||
}
|
||||
|
||||
collect_esp32_artifacts() {
|
||||
@@ -4414,6 +4417,26 @@ output_artifact_exists() {
|
||||
[ -s "${OUTPUT_DIR}/$1" ]
|
||||
}
|
||||
|
||||
compute_build_recipe_digest() {
|
||||
local env_name=$1 env_platform=$2 pio_env_name=$3 embedded_version=$4
|
||||
local original_flags=$5 full_source_commit=$6 artifact_target checkout_dir
|
||||
artifact_target=${FIRMWARE_OUTPUT_ENV_NAME:-$env_name}
|
||||
if [ -n "${NRF52_OTA_SENSOR_PROFILE:-}" ] \
|
||||
&& is_nrf52_sensor_ota_pair_target "$env_name"; then
|
||||
artifact_target+="-${NRF52_OTA_SENSOR_PROFILE}-ota"
|
||||
fi
|
||||
checkout_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) || return 1
|
||||
MESHCORE_RECIPE_ORIGINAL_FLAGS="$original_flags" \
|
||||
python3 "${checkout_dir}/scripts/firmware_build_recipe.py" digest \
|
||||
--root "$checkout_dir" --output "$OUTPUT_DIR" \
|
||||
--source-commit "$full_source_commit" \
|
||||
--target "$env_name" --artifact-target "$artifact_target" \
|
||||
--pio-env "$pio_env_name" --platform "$env_platform" \
|
||||
--embedded-version "$embedded_version" --profile "$BUILD_PROFILE_FOR_TARGET" \
|
||||
--sensor-profile "${NRF52_OTA_SENSOR_PROFILE:-}" \
|
||||
--ota-policy "${REQUIRE_OTA_UPDATES:-}" <<<"$PIO_CONFIG_JSON"
|
||||
}
|
||||
|
||||
nrf52_sensor_profile_manifest_matches() {
|
||||
python3 - "$1" "$2" "$3" "$4" <<'PY'
|
||||
import json, sys
|
||||
@@ -4676,6 +4699,7 @@ build_firmware_one_profile() {
|
||||
local pio_env_name
|
||||
local env_platform
|
||||
local commit_hash
|
||||
local full_source_commit
|
||||
local firmware_build_date
|
||||
local firmware_build_epoch
|
||||
local firmware_version
|
||||
@@ -4694,6 +4718,9 @@ build_firmware_one_profile() {
|
||||
local had_platformio_build_src_filter=0
|
||||
local had_platformio_extra_scripts=0
|
||||
local build_status
|
||||
local BUILD_RECIPE_SHA256
|
||||
local recipe_build_flags
|
||||
local completed_recipe
|
||||
local platformio_package_lock_fd=""
|
||||
local -a pio_run_args=()
|
||||
local -a BUILD_CAPABILITIES=()
|
||||
@@ -4798,8 +4825,8 @@ build_firmware_one_profile() {
|
||||
# Publication tools bind artifacts to an explicit eight-character prefix
|
||||
# of the full source commit. Git's adaptive --short length can grow (or be
|
||||
# configured differently), which otherwise makes valid releases look stale.
|
||||
commit_hash=$(git rev-parse HEAD)
|
||||
commit_hash=${commit_hash:0:8}
|
||||
full_source_commit=$(git rev-parse HEAD) || return 1
|
||||
commit_hash=${full_source_commit:0:8}
|
||||
firmware_build_date=$(date -u '+%d-%b-%Y')
|
||||
firmware_build_epoch=$(date -u '+%s')
|
||||
firmware_version=${FIRMWARE_VERSION:-}
|
||||
@@ -4845,11 +4872,6 @@ build_firmware_one_profile() {
|
||||
local embedded_version_string="${firmware_version}${embedded_build_suffix}${embedded_variant_tag}-${commit_hash}"
|
||||
|
||||
declare_full_logging_application_contract "$env_name"
|
||||
if [ "$RESUME_BUILD_OUTPUT" == "1" ] && build_artifacts_exist "$env_name" "$env_platform" "$firmware_filename"; then
|
||||
echo "Skipping ${env_name}; existing artifacts found for ${firmware_filename}."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "${PLATFORMIO_BUILD_FLAGS+x}" ]; then
|
||||
had_platformio_build_flags=1
|
||||
original_platformio_build_flags=$PLATFORMIO_BUILD_FLAGS
|
||||
@@ -4875,7 +4897,23 @@ build_firmware_one_profile() {
|
||||
original_platformio_extra_scripts=""
|
||||
fi
|
||||
|
||||
export PLATFORMIO_BUILD_FLAGS="${original_platformio_build_flags} -DFIRMWARE_BUILD_DATE='\"${firmware_build_date}\"' -DFIRMWARE_BUILD_EPOCH=${firmware_build_epoch} -DFIRMWARE_VERSION='\"${embedded_version_string}\"' -DOTA_VARIANT='\"${env_name}\"'${mota_target_flag}${mota_migration_flag}"
|
||||
# Scope the ambient values and their export attributes to this target. In
|
||||
# particular, a resume skip/conflict must not leak its computed profile into
|
||||
# the next target or convert an unset/unexported caller variable to exported.
|
||||
local PLATFORMIO_BUILD_FLAGS="$original_platformio_build_flags"
|
||||
local PLATFORMIO_BUILD_UNFLAGS="$original_platformio_build_unflags"
|
||||
local PLATFORMIO_BUILD_SRC_FILTER="$original_platformio_build_src_filter"
|
||||
local PLATFORMIO_EXTRA_SCRIPTS="$original_platformio_extra_scripts"
|
||||
local MESHCORE_ESP32_FULL_BUILD="${MESHCORE_ESP32_FULL_BUILD-}"
|
||||
local MESHCORE_REQUIRE_PACKET_LOGGING="${MESHCORE_REQUIRE_PACKET_LOGGING-}"
|
||||
local MESHCORE_COMPANION_RADIO_FULL="${MESHCORE_COMPANION_RADIO_FULL-}"
|
||||
local MESHCORE_ESP32_FULL_PARTITION_TABLE="${MESHCORE_ESP32_FULL_PARTITION_TABLE-}"
|
||||
local MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE="${MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE-}"
|
||||
local MESHCORE_FORCE_LORA_OTA="${MESHCORE_FORCE_LORA_OTA-}"
|
||||
|
||||
# Only builder-generated wall-clock stamps are excluded from the recipe.
|
||||
# User-supplied flags remain verbatim, including any timestamp definitions.
|
||||
export PLATFORMIO_BUILD_FLAGS="${original_platformio_build_flags} -DFIRMWARE_VERSION='\"${embedded_version_string}\"' -DOTA_VARIANT='\"${env_name}\"'${mota_target_flag}${mota_migration_flag}"
|
||||
disable_debug_flags "$env_name"
|
||||
apply_debug_overrides "$env_name"
|
||||
apply_mqtt_bridge_override "$env_name"
|
||||
@@ -4943,6 +4981,29 @@ build_firmware_one_profile() {
|
||||
append_platformio_extra_script "post:scripts/check_firmware_ram.py"
|
||||
fi
|
||||
|
||||
recipe_build_flags=$PLATFORMIO_BUILD_FLAGS
|
||||
BUILD_RECIPE_SHA256=$(compute_build_recipe_digest "$env_name" "$env_platform" \
|
||||
"$pio_env_name" "$embedded_version_string" "$original_platformio_build_flags" \
|
||||
"$full_source_commit") || return 1
|
||||
if [ "$RESUME_BUILD_OUTPUT" == "1" ]; then
|
||||
if python3 scripts/firmware_build_recipe.py occupied "${OUTPUT_DIR}/${firmware_filename}"; then
|
||||
if python3 scripts/firmware_build_recipe.py matches \
|
||||
"${OUTPUT_DIR}/${firmware_filename}.capabilities.json" "$BUILD_RECIPE_SHA256" \
|
||||
>/dev/null 2>&1 \
|
||||
&& build_artifacts_exist "$env_name" "$env_platform" "$firmware_filename"; then
|
||||
echo "Skipping ${env_name}; matching recipe and qualified artifacts found for ${firmware_filename}."
|
||||
return 0
|
||||
fi
|
||||
echo "Cannot resume ${firmware_filename}: existing package has a missing/different recipe or failed qualification." >&2
|
||||
echo "Existing files were preserved. Use a fresh OUTPUT_DIR or a different --firmware-version." >&2
|
||||
return 1
|
||||
elif [ "$?" -ne 1 ]; then
|
||||
echo "Cannot inspect the existing package safely; refusing to build over OUTPUT_DIR." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
export PLATFORMIO_BUILD_FLAGS="${recipe_build_flags} -DFIRMWARE_BUILD_DATE='\"${firmware_build_date}\"' -DFIRMWARE_BUILD_EPOCH=${firmware_build_epoch}"
|
||||
print_build_flags "$pio_env_name" "$env_name"
|
||||
build_status=0
|
||||
if [ "$env_platform" = "ESP32_PLATFORM" ]; then
|
||||
@@ -4978,6 +5039,16 @@ build_firmware_one_profile() {
|
||||
run_pio_with_size_detection "${pio_run_args[@]}"
|
||||
build_status=$?
|
||||
fi
|
||||
if [ "$build_status" -eq 0 ]; then
|
||||
completed_recipe=$(PLATFORMIO_BUILD_FLAGS="$recipe_build_flags" \
|
||||
compute_build_recipe_digest "$env_name" "$env_platform" "$pio_env_name" \
|
||||
"$embedded_version_string" "$original_platformio_build_flags" \
|
||||
"$full_source_commit") || build_status=1
|
||||
if [ "$build_status" -eq 0 ] && [ "$completed_recipe" != "$BUILD_RECIPE_SHA256" ]; then
|
||||
echo "Source or build inputs changed during compilation; refusing to publish this package." >&2
|
||||
build_status=1
|
||||
fi
|
||||
fi
|
||||
if [ "$build_status" -eq 0 ]; then
|
||||
collect_build_artifacts "$env_name" "$env_platform" "$pio_env_name" "$firmware_filename"
|
||||
build_status=$?
|
||||
@@ -5926,15 +5997,26 @@ run_logged_build_targets() {
|
||||
for env in "${targets[@]}"; do
|
||||
log_path="${log_dir}/${env}-${profile}.log"
|
||||
log_tmp="${log_path}.tmp"
|
||||
if [ "$RESUME_BUILD_OUTPUT" = "1" ]; then
|
||||
log_tmp=$(mktemp "${log_path%.log}-resume-attempt.XXXXXX.log") || {
|
||||
BATCH_BUILD_MODE=$previous_batch_build_mode
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
preserved_log=0
|
||||
echo "Building ${env} (${profile}); log: ${log_path}"
|
||||
build_firmware "$env" > "$log_tmp" 2>&1
|
||||
build_status=$?
|
||||
if [ "$build_status" -eq 0 ] \
|
||||
&& grep -q "^Skipping ${env}; existing artifacts found" "$log_tmp" \
|
||||
&& grep -Eq "^Skipping ${env}; (existing artifacts found|matching recipe and qualified artifacts found)" "$log_tmp" \
|
||||
&& [ -s "$log_path" ]; then
|
||||
rm -f -- "$log_tmp"
|
||||
preserved_log=1
|
||||
elif [ "$build_status" -ne 0 ] \
|
||||
&& [ "$RESUME_BUILD_OUTPUT" = "1" ] && [ -s "$log_path" ]; then
|
||||
# Keep the original compile log. This already-unique attempt file is
|
||||
# the failure's real diagnostic log, including in summary/report paths.
|
||||
log_path=$log_tmp
|
||||
else
|
||||
mv -f -- "$log_tmp" "$log_path"
|
||||
fi
|
||||
@@ -5948,7 +6030,7 @@ run_logged_build_targets() {
|
||||
elif [ "$build_status" -ne 0 ]; then
|
||||
overall_status=1
|
||||
LOGGING_MATRIX_FAILURES+=("${env} (${profile}) -> ${log_path}")
|
||||
echo "FAILED: ${env} (${profile}), status ${build_status}"
|
||||
echo "FAILED: ${env} (${profile}), status ${build_status}; log: ${log_path}"
|
||||
echo "FAILED: ${env} (${profile}), status ${build_status}" >> "$log_path"
|
||||
else
|
||||
echo "SUCCEEDED: ${env} (${profile})"
|
||||
@@ -5993,6 +6075,15 @@ run_logged_build_targets() {
|
||||
pio_env_key=$candidate_key
|
||||
log_path="${log_dir}/${env}-${profile}.log"
|
||||
log_tmp="${log_path}.tmp"
|
||||
if [ "$RESUME_BUILD_OUTPUT" = "1" ]; then
|
||||
log_tmp=$(mktemp "${log_path%.log}-resume-attempt.XXXXXX.log") || {
|
||||
for interrupt_pid in "${running_pids[@]}"; do terminate_process_tree "$interrupt_pid"; done
|
||||
for interrupt_pid in "${running_pids[@]}"; do wait "$interrupt_pid" 2>/dev/null || true; done
|
||||
BATCH_BUILD_MODE=$previous_batch_build_mode
|
||||
trap - INT TERM
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
echo "Building ${env} (${profile}); log: ${log_path}"
|
||||
(
|
||||
BATCH_BUILD_MODE=1
|
||||
@@ -6050,10 +6141,13 @@ run_logged_build_targets() {
|
||||
log_tmp=${job_tmp_by_pid[$pid]}
|
||||
preserved_log=0
|
||||
if [ "$build_status" -eq 0 ] \
|
||||
&& grep -q "^Skipping ${env}; existing artifacts found" "$log_tmp" \
|
||||
&& grep -Eq "^Skipping ${env}; (existing artifacts found|matching recipe and qualified artifacts found)" "$log_tmp" \
|
||||
&& [ -s "$log_path" ]; then
|
||||
rm -f -- "$log_tmp"
|
||||
preserved_log=1
|
||||
elif [ "$build_status" -ne 0 ] \
|
||||
&& [ "$RESUME_BUILD_OUTPUT" = "1" ] && [ -s "$log_path" ]; then
|
||||
log_path=$log_tmp
|
||||
else
|
||||
mv -f -- "$log_tmp" "$log_path"
|
||||
fi
|
||||
@@ -6067,7 +6161,7 @@ run_logged_build_targets() {
|
||||
elif [ "$build_status" -ne 0 ]; then
|
||||
overall_status=1
|
||||
LOGGING_MATRIX_FAILURES+=("${env} (${profile}) -> ${log_path}")
|
||||
echo "FAILED: ${env} (${profile}), status ${build_status}"
|
||||
echo "FAILED: ${env} (${profile}), status ${build_status}; log: ${log_path}"
|
||||
echo "FAILED: ${env} (${profile}), status ${build_status}" >> "$log_path"
|
||||
else
|
||||
echo "SUCCEEDED: ${env} (${profile})"
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bind resumable firmware to its effective inputs without publishing secrets."""
|
||||
|
||||
import argparse
|
||||
import configparser
|
||||
import glob
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
SCHEMA = 1
|
||||
RECIPE_ENVIRONMENT = (
|
||||
"PLATFORMIO_BUILD_FLAGS", "PLATFORMIO_BUILD_UNFLAGS",
|
||||
"PLATFORMIO_BUILD_SRC_FILTER", "PLATFORMIO_EXTRA_SCRIPTS",
|
||||
"MESHCORE_ESP32_FULL_BUILD", "MESHCORE_REQUIRE_PACKET_LOGGING",
|
||||
"MESHCORE_COMPANION_RADIO_FULL", "MESHCORE_ESP32_FULL_PARTITION_TABLE",
|
||||
"MESHCORE_NRF52_INTERNAL_BOOTLOADER_UPDATE", "MESHCORE_FORCE_LORA_OTA",
|
||||
"MESHCORE_NRF52_LEGACY_FLASH_OTA", "MESHCORE_REDUCED_TLS",
|
||||
)
|
||||
PACKAGE_SUFFIXES = (".bin", "-merged.bin", ".uf2", ".zip", ".hex",
|
||||
".capabilities.json", ".memory.json", ".font-license.txt")
|
||||
|
||||
|
||||
def canonical_digest(value):
|
||||
payload = json.dumps(value, sort_keys=True, separators=(",", ":"),
|
||||
ensure_ascii=True).encode("ascii")
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def git(root, *arguments):
|
||||
return subprocess.check_output(["git", "-C", str(root), *arguments],
|
||||
stderr=subprocess.PIPE)
|
||||
|
||||
|
||||
def file_digest(path):
|
||||
result = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for block in iter(lambda: source.read(1024 * 1024), b""):
|
||||
result.update(block)
|
||||
return result.hexdigest()
|
||||
|
||||
|
||||
def source_digest(root, output, ancestors=(), expected_commit=None):
|
||||
"""Read actual tracked bytes, including staged/unstaged and missing files."""
|
||||
root = root.resolve()
|
||||
output = Path(os.path.abspath(output))
|
||||
output_paths = (output, output.resolve())
|
||||
if any(selected == root or selected in root.parents for selected in output_paths):
|
||||
raise ValueError("output overlaps the source checkout or its ancestors")
|
||||
if root in ancestors:
|
||||
raise ValueError("recursive source checkout")
|
||||
commit = git(root, "rev-parse", "HEAD").decode("ascii").strip()
|
||||
if expected_commit is not None and commit != expected_commit:
|
||||
raise ValueError("source commit no longer matches artifact identity")
|
||||
entries = {}
|
||||
for record in git(root, "ls-files", "--stage", "-z").split(b"\0"):
|
||||
if not record:
|
||||
continue
|
||||
metadata, name = record.split(b"\t", 1)
|
||||
mode, object_id, stage = metadata.split()
|
||||
if stage != b"0":
|
||||
raise ValueError("unresolved source merge")
|
||||
entries[os.fsdecode(name)] = (mode.decode("ascii"), object_id.decode("ascii"))
|
||||
for name in git(root, "ls-files", "--others", "--exclude-standard", "-z").split(b"\0"):
|
||||
if name:
|
||||
entries.setdefault(os.fsdecode(name), ("untracked", ""))
|
||||
# The project imports this Git-ignored file. Its absence is an input too;
|
||||
# cached resolved options alone cannot see edits during a long compile.
|
||||
entries.setdefault("platformio.local.ini", ("local-config", ""))
|
||||
content = []
|
||||
for name, (mode, object_id) in sorted(entries.items()):
|
||||
path = root / name
|
||||
# Only generated, untracked output may be excluded. Tracked sources
|
||||
# and imported configuration must always bind their actual bytes,
|
||||
# even if a caller incorrectly places output within their subtree.
|
||||
if mode == "untracked" and any(path == selected or selected in path.parents
|
||||
for selected in output_paths):
|
||||
continue
|
||||
item = [name, mode]
|
||||
if mode == "160000":
|
||||
item.append(object_id)
|
||||
if path.is_dir() and (path / ".git").exists():
|
||||
item.append(source_digest(path, output, (*ancestors, root)))
|
||||
else:
|
||||
if path.is_dir() and any(path.iterdir()):
|
||||
raise ValueError("nonempty source checkout lacks Git metadata")
|
||||
item.append("uninitialized")
|
||||
elif path.is_symlink():
|
||||
item.extend(("symlink", os.readlink(path)))
|
||||
if path.is_file():
|
||||
item.append(file_digest(path))
|
||||
elif path.exists():
|
||||
raise ValueError("source symlink is not a regular file")
|
||||
else:
|
||||
item.append("missing-target")
|
||||
elif path.is_file():
|
||||
item.extend(("file", bool(path.stat().st_mode & stat.S_IXUSR), file_digest(path)))
|
||||
elif not path.exists():
|
||||
item.append("missing")
|
||||
else:
|
||||
raise ValueError("source is not a regular file")
|
||||
content.append(item)
|
||||
if git(root, "rev-parse", "HEAD").decode("ascii").strip() != commit:
|
||||
raise ValueError("source commit changed during snapshot")
|
||||
return canonical_digest({"commit": commit, "files": content})
|
||||
|
||||
|
||||
def configuration_inputs(root, options):
|
||||
"""Bind config imports, not package caches; unresolved imports fail closed."""
|
||||
patterns = options.get("extra_configs", [])
|
||||
if isinstance(patterns, str):
|
||||
patterns = [line.strip() for line in patterns.splitlines() if line.strip()]
|
||||
if not isinstance(patterns, list) or not all(isinstance(value, str) for value in patterns):
|
||||
raise ValueError("invalid imported configuration paths")
|
||||
# Resolved options may contain only the final imported setting. Include
|
||||
# literal imports from the project and each loaded file too, so an
|
||||
# intermediate external config cannot disappear from the receipt.
|
||||
pending = ["platformio.ini", "platformio.local.ini", *patterns]
|
||||
seen_patterns = set()
|
||||
parsed_files = set()
|
||||
inputs = []
|
||||
while pending:
|
||||
pattern = pending.pop(0)
|
||||
expanded = os.path.expanduser(os.path.expandvars(pattern))
|
||||
if "$" in expanded or "%(" in expanded:
|
||||
raise ValueError("unresolved imported configuration path")
|
||||
absolute = str(root.resolve() / expanded)
|
||||
if absolute in seen_patterns:
|
||||
continue
|
||||
seen_patterns.add(absolute)
|
||||
files = []
|
||||
for name in sorted(glob.glob(absolute, recursive=True)):
|
||||
path = Path(name)
|
||||
if not path.is_file():
|
||||
raise ValueError("imported configuration is not a regular file")
|
||||
files.append({"path": name, "resolved": str(path.resolve()),
|
||||
"link": os.readlink(path) if path.is_symlink() else None,
|
||||
"sha256": file_digest(path)})
|
||||
resolved = path.resolve()
|
||||
if resolved not in parsed_files:
|
||||
parsed_files.add(resolved)
|
||||
parser = configparser.RawConfigParser(inline_comment_prefixes=("#", ";"))
|
||||
with path.open(encoding="utf-8") as stream:
|
||||
parser.read_file(stream)
|
||||
imports = parser.get("platformio", "extra_configs", fallback="")
|
||||
if imports:
|
||||
pending.extend(value.strip() for value in imports.split(
|
||||
"\n" if "\n" in imports else ", ") if value.strip())
|
||||
# An unmatched optional file/glob is retained, so later creation or
|
||||
# removal is visible even when the resolved PIO options are cached.
|
||||
inputs.append({"pattern": pattern, "expanded": absolute, "files": files})
|
||||
return inputs
|
||||
|
||||
|
||||
def recipe_digest(args, options):
|
||||
if not isinstance(options, list):
|
||||
raise ValueError("invalid resolved PlatformIO configuration")
|
||||
selected = {}
|
||||
for value in options:
|
||||
if not isinstance(value, list) or len(value) != 2:
|
||||
raise ValueError("invalid PlatformIO section")
|
||||
section, pairs = value
|
||||
if section not in ("platformio", "env:" + args.pio_env):
|
||||
continue
|
||||
if section in selected or not isinstance(pairs, list):
|
||||
raise ValueError("invalid PlatformIO options")
|
||||
selected[section] = {}
|
||||
for pair in pairs:
|
||||
if not isinstance(pair, list) or len(pair) != 2 or not isinstance(pair[0], str):
|
||||
raise ValueError("invalid PlatformIO option")
|
||||
key, setting = pair
|
||||
if key in selected[section]:
|
||||
raise ValueError("duplicate PlatformIO option")
|
||||
selected[section][key] = setting
|
||||
if "env:" + args.pio_env not in selected:
|
||||
raise ValueError("missing resolved PlatformIO environment")
|
||||
names = set(RECIPE_ENVIRONMENT) | {name for name in os.environ
|
||||
if name.startswith(("PLATFORMIO_", "MESHCORE_"))}
|
||||
names.discard("MESHCORE_RECIPE_ORIGINAL_FLAGS")
|
||||
environment = {name: os.environ.get(name) for name in names}
|
||||
return canonical_digest({
|
||||
"schema_version": SCHEMA,
|
||||
"source": source_digest(args.root, args.output, expected_commit=args.source_commit),
|
||||
"target": args.target, "artifact_target": args.artifact_target,
|
||||
"platformio_env": args.pio_env, "platform": args.platform,
|
||||
"embedded_version": args.embedded_version, "profile": args.profile,
|
||||
"sensor_profile": args.sensor_profile, "ota_policy": args.ota_policy,
|
||||
"pio_options": selected, "environment": environment,
|
||||
"config_inputs": configuration_inputs(args.root, selected.get("platformio", {})),
|
||||
"original_flags": os.environ.get("MESHCORE_RECIPE_ORIGINAL_FLAGS", ""),
|
||||
})
|
||||
|
||||
|
||||
def recipe_record(digest):
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", digest):
|
||||
raise ValueError("invalid recipe digest")
|
||||
return {"schema_version": SCHEMA, "sha256": digest}
|
||||
|
||||
|
||||
def attach_recipe(manifest_path, digest):
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
if not isinstance(manifest, dict):
|
||||
raise ValueError("invalid capability manifest")
|
||||
manifest["build_recipe"] = recipe_record(digest)
|
||||
temporary = manifest_path.with_name(f".{manifest_path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_text(json.dumps(manifest, indent=2, sort_keys=True, ensure_ascii=True) + "\n")
|
||||
temporary.replace(manifest_path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def matches_recipe(manifest_path, digest):
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
record = manifest.get("build_recipe")
|
||||
return (isinstance(record, dict) and type(record.get("schema_version")) is int
|
||||
and record == recipe_record(digest))
|
||||
|
||||
|
||||
def package_occupied(stem):
|
||||
return any(os.path.lexists(str(stem) + suffix) for suffix in PACKAGE_SUFFIXES)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
digest = commands.add_parser("digest")
|
||||
digest.add_argument("--root", required=True, type=Path)
|
||||
digest.add_argument("--output", required=True, type=Path)
|
||||
for name in ("target", "artifact-target", "pio-env", "platform", "source-commit", "embedded-version",
|
||||
"profile", "sensor-profile", "ota-policy"):
|
||||
digest.add_argument("--" + name, required=True)
|
||||
for name in ("attach", "matches"):
|
||||
action = commands.add_parser(name)
|
||||
action.add_argument("manifest", type=Path)
|
||||
action.add_argument("digest")
|
||||
occupied = commands.add_parser("occupied")
|
||||
occupied.add_argument("stem", type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.command == "digest":
|
||||
print(recipe_digest(args, json.load(sys.stdin)))
|
||||
elif args.command == "attach":
|
||||
attach_recipe(args.manifest, args.digest)
|
||||
elif args.command == "matches":
|
||||
return 0 if matches_recipe(args.manifest, args.digest) else 1
|
||||
else:
|
||||
return 0 if package_occupied(args.stem) else 1
|
||||
except (OSError, RuntimeError, ValueError, TypeError, AttributeError,
|
||||
configparser.Error, subprocess.CalledProcessError):
|
||||
# Raw options, flags, paths and nested Git diagnostics can contain
|
||||
# credentials. Report a fixed diagnostic, never their exception text.
|
||||
print("firmware build recipe: cannot qualify recipe inputs or manifest", file=sys.stderr)
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,684 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise real package qualification and safe recipe-bound resume, without PIO."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location("firmware_build_recipe", ROOT / "scripts/firmware_build_recipe.py")
|
||||
RECIPE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(RECIPE)
|
||||
SECRET = "private-credential-not-for-manifests"
|
||||
TARGET = "test_companion_radio_usb"
|
||||
OPTIONS = [["env:" + TARGET, [["build_flags", ["-DESP32_PLATFORM"]],
|
||||
["board", "test-board"], ["extra_scripts", []]]]]
|
||||
|
||||
|
||||
def sha256(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
class RecipeTests(unittest.TestCase):
|
||||
def source_fixture(self, root, *, untracked=(), stage=b"0", tracked="source.cpp"):
|
||||
def git(_root, *arguments):
|
||||
if arguments == ("rev-parse", "HEAD"):
|
||||
return b"a" * 40 + b"\n"
|
||||
if arguments == ("ls-files", "--stage", "-z"):
|
||||
return b"100644 " + b"b" * 40 + b" " + stage + b"\t" + os.fsencode(tracked) + b"\0"
|
||||
if arguments == ("ls-files", "--others", "--exclude-standard", "-z"):
|
||||
return b"\0".join(os.fsencode(name) for name in untracked)
|
||||
raise AssertionError(arguments)
|
||||
return mock.patch.object(RECIPE, "git", side_effect=git)
|
||||
|
||||
def test_actual_tracked_bytes_deletions_and_untracked_source_change_digest(self):
|
||||
with tempfile.TemporaryDirectory(prefix="mesh-recipe-source-") as temporary:
|
||||
root = Path(temporary)
|
||||
path = root / "source.cpp"; path.write_bytes(b"original tracked source")
|
||||
with self.source_fixture(root):
|
||||
original = RECIPE.source_digest(root, root / "output")
|
||||
path.write_bytes(b"changed without changing HEAD or index")
|
||||
self.assertNotEqual(original, RECIPE.source_digest(root, root / "output"))
|
||||
path.unlink()
|
||||
self.assertNotEqual(original, RECIPE.source_digest(root, root / "output"))
|
||||
path.write_bytes(b"original tracked source")
|
||||
extra = root / "added.cpp"; extra.write_bytes(b"uncommitted source")
|
||||
with self.source_fixture(root, untracked=(extra.name,)):
|
||||
self.assertNotEqual(original, RECIPE.source_digest(root, root / "output"))
|
||||
with self.source_fixture(root, stage=b"2"):
|
||||
with self.assertRaises(ValueError):
|
||||
RECIPE.source_digest(root, root / "output")
|
||||
|
||||
def test_source_commit_must_match_filename_and_remain_stable_during_snapshot(self):
|
||||
with tempfile.TemporaryDirectory(prefix="mesh-recipe-source-") as temporary:
|
||||
root = Path(temporary)
|
||||
(root / "source.cpp").write_bytes(b"source")
|
||||
with self.source_fixture(root):
|
||||
with self.assertRaises(ValueError):
|
||||
RECIPE.source_digest(root, root / "output", expected_commit="d" * 40)
|
||||
commits = iter((b"a" * 40, b"b" * 40))
|
||||
def git(_root, *arguments):
|
||||
return next(commits) if arguments == ("rev-parse", "HEAD") else b""
|
||||
with mock.patch.object(RECIPE, "git", side_effect=git):
|
||||
with self.assertRaises(ValueError):
|
||||
RECIPE.source_digest(root, root / "output", expected_commit="a" * 40)
|
||||
|
||||
def test_output_root_aliases_and_ancestors_are_rejected_instead_of_hiding_source(self):
|
||||
with tempfile.TemporaryDirectory(prefix="mesh-recipe-source-") as temporary:
|
||||
root = Path(temporary)
|
||||
(root / "source.cpp").write_bytes(b"source")
|
||||
link = root / "root-link"; link.symlink_to(root, target_is_directory=True)
|
||||
with self.source_fixture(root):
|
||||
for output in (root, root / "child/..", root.parent, Path("/"), link):
|
||||
with self.subTest(output=output), self.assertRaises(ValueError):
|
||||
RECIPE.source_digest(root, output)
|
||||
|
||||
def test_tracked_sources_inside_output_are_hashed_but_untracked_artifacts_are_not(self):
|
||||
with tempfile.TemporaryDirectory(prefix="mesh-recipe-source-") as temporary:
|
||||
root = Path(temporary)
|
||||
output = root / "src"; output.mkdir()
|
||||
source = output / "source.cpp"; source.write_bytes(b"tracked source")
|
||||
artifact = output / "generated.bin"; artifact.write_bytes(b"old artifact")
|
||||
with self.source_fixture(root, tracked="src/source.cpp", untracked=("src/generated.bin",)):
|
||||
first = RECIPE.source_digest(root, output)
|
||||
artifact.write_bytes(b"new artifact")
|
||||
self.assertEqual(first, RECIPE.source_digest(root, output))
|
||||
source.write_bytes(b"changed tracked source")
|
||||
self.assertNotEqual(first, RECIPE.source_digest(root, output))
|
||||
|
||||
def test_ignored_local_config_absence_presence_and_bytes_are_source_inputs(self):
|
||||
with tempfile.TemporaryDirectory(prefix="mesh-recipe-source-") as temporary:
|
||||
root = Path(temporary)
|
||||
(root / "source.cpp").write_bytes(b"source")
|
||||
local = root / "platformio.local.ini"
|
||||
with self.source_fixture(root):
|
||||
absent = RECIPE.source_digest(root, root / "output")
|
||||
local.write_text("[env:test]\nbuild_flags=-DONE=1\n")
|
||||
present = RECIPE.source_digest(root, root / "output")
|
||||
self.assertNotEqual(absent, present)
|
||||
local.write_text("[env:test]\nbuild_flags=-DONE=2\n")
|
||||
self.assertNotEqual(present, RECIPE.source_digest(root, root / "output"))
|
||||
|
||||
def test_external_config_imports_globs_nested_files_and_missing_matches_are_bound(self):
|
||||
with tempfile.TemporaryDirectory(prefix="mesh-recipe-config-") as temporary:
|
||||
directory = Path(temporary)
|
||||
root = directory / "project"; root.mkdir()
|
||||
external = directory / "external.ini"
|
||||
nested = directory / "nested.ini"
|
||||
overrides = directory / "overrides"; overrides.mkdir()
|
||||
external.write_text("[platformio]\nextra_configs = ../nested.ini\n")
|
||||
nested.write_text("[env:test]\nbuild_flags=-DPRIVATE=" + SECRET + "\n")
|
||||
# An intermediate external file is not necessarily listed in the
|
||||
# final resolved extra_configs value, but remains an input.
|
||||
(root / "platformio.ini").write_text("[platformio]\nextra_configs=../external.ini\n")
|
||||
options = {"extra_configs": [str(directory / "overrides/*.ini"), "../nested.ini"]}
|
||||
before = RECIPE.canonical_digest(RECIPE.configuration_inputs(root, options))
|
||||
external.write_text("[platformio]\nextra_configs=../nested.ini\n[env:test]\nboard=changed\n")
|
||||
after = RECIPE.canonical_digest(RECIPE.configuration_inputs(root, options))
|
||||
self.assertNotEqual(before, after)
|
||||
(overrides / "added.ini").write_text("[env:test]\nboard=added\n")
|
||||
self.assertNotEqual(after, RECIPE.canonical_digest(RECIPE.configuration_inputs(root, options)))
|
||||
for pattern in ("${sysenv.UNRESOLVED_RECIPE_CONFIG}", "%(unresolved_config)s"):
|
||||
with self.subTest(pattern=pattern), self.assertRaises(ValueError):
|
||||
RECIPE.configuration_inputs(root, {"extra_configs": [pattern]})
|
||||
|
||||
def test_custom_output_and_output_link_do_not_hash_generated_files(self):
|
||||
with tempfile.TemporaryDirectory(prefix="mesh-recipe-source-") as temporary:
|
||||
root = Path(temporary)
|
||||
(root / "source.cpp").write_bytes(b"source")
|
||||
output = root / "unignored-artifacts"; output.mkdir()
|
||||
generated = output / "firmware.bin"; generated.write_bytes(b"old artifact")
|
||||
with self.source_fixture(root, untracked=("unignored-artifacts/firmware.bin",)):
|
||||
initial = RECIPE.source_digest(root, output)
|
||||
generated.write_bytes(b"new artifact")
|
||||
self.assertEqual(initial, RECIPE.source_digest(root, output))
|
||||
link = root / "output-link"; link.symlink_to(output, target_is_directory=True)
|
||||
with self.source_fixture(root, untracked=("output-link", "unignored-artifacts/firmware.bin")):
|
||||
self.assertEqual(initial, RECIPE.source_digest(root, link))
|
||||
|
||||
def test_gitlinks_and_source_symlink_targets_do_not_hide_local_changes(self):
|
||||
with tempfile.TemporaryDirectory(prefix="mesh-recipe-source-") as temporary:
|
||||
root = Path(temporary)
|
||||
nested = root / "nested"; nested.mkdir()
|
||||
gitlink = b"160000 " + b"b" * 40 + b" 0\tnested\0"
|
||||
def git(where, *arguments):
|
||||
if arguments == ("rev-parse", "HEAD"):
|
||||
return b"a" * 40 + b"\n"
|
||||
if arguments == ("ls-files", "--stage", "-z"):
|
||||
return gitlink if where == root else b"100644 " + b"c" * 40 + b" 0\tfile.cpp\0"
|
||||
return b""
|
||||
with mock.patch.object(RECIPE, "git", side_effect=git):
|
||||
RECIPE.source_digest(root, root / "output")
|
||||
source = nested / "file.cpp"; source.write_bytes(b"local source")
|
||||
with self.assertRaises(ValueError):
|
||||
RECIPE.source_digest(root, root / "output")
|
||||
(nested / ".git").write_text("fixture metadata marker")
|
||||
first = RECIPE.source_digest(root, root / "output")
|
||||
source.write_bytes(b"changed local source")
|
||||
self.assertNotEqual(first, RECIPE.source_digest(root, root / "output"))
|
||||
external = root / "external.txt"; external.write_bytes(b"initial target")
|
||||
(root / "source.cpp").symlink_to(external)
|
||||
with self.source_fixture(root):
|
||||
first = RECIPE.source_digest(root, root / "output")
|
||||
external.write_bytes(b"changed target")
|
||||
self.assertNotEqual(first, RECIPE.source_digest(root, root / "output"))
|
||||
|
||||
def test_every_package_suffix_and_broken_symlink_counts_as_occupied(self):
|
||||
with tempfile.TemporaryDirectory(prefix="mesh-recipe-occupied-") as temporary:
|
||||
stem = Path(temporary) / "firmware"
|
||||
self.assertFalse(RECIPE.package_occupied(stem))
|
||||
for suffix in RECIPE.PACKAGE_SUFFIXES:
|
||||
path = Path(str(stem) + suffix)
|
||||
path.write_bytes(b"")
|
||||
self.assertTrue(RECIPE.package_occupied(stem))
|
||||
path.unlink()
|
||||
path.symlink_to(stem.parent / "missing")
|
||||
self.assertTrue(RECIPE.package_occupied(stem))
|
||||
|
||||
def test_selected_options_are_canonical_but_ordered_flags_and_source_are_bound(self):
|
||||
args = argparse.Namespace(root=ROOT, output=ROOT / "out", target=TARGET,
|
||||
artifact_target=TARGET, pio_env=TARGET, platform="ESP32_PLATFORM",
|
||||
source_commit="a" * 40,
|
||||
embedded_version="vtest.12-abcdef00", profile="auto",
|
||||
sensor_profile="", ota_policy="0")
|
||||
environment = {"PLATFORMIO_BUILD_FLAGS": "-DONE=1 -UTWO -DTWO=2",
|
||||
"MESHCORE_RECIPE_ORIGINAL_FLAGS": "-DWIFI_PWD=" + SECRET}
|
||||
with mock.patch.object(RECIPE, "source_digest", return_value="c" * 64), \
|
||||
mock.patch.dict(os.environ, environment, clear=True):
|
||||
first = RECIPE.recipe_digest(args, OPTIONS)
|
||||
reordered = [[OPTIONS[0][0], list(reversed(OPTIONS[0][1]))],
|
||||
["env:unrelated", [["build_flags", ["not compiled"]]]]]
|
||||
self.assertEqual(first, RECIPE.recipe_digest(args, reordered))
|
||||
os.environ["PLATFORMIO_BUILD_FLAGS"] = "-DONE=1 -DTWO=2 -UTWO"
|
||||
self.assertNotEqual(first, RECIPE.recipe_digest(args, OPTIONS))
|
||||
os.environ.update(environment)
|
||||
args.embedded_version = "vtest.13-abcdef00"
|
||||
self.assertNotEqual(first, RECIPE.recipe_digest(args, OPTIONS))
|
||||
args.embedded_version = "vtest.12-abcdef00"
|
||||
os.environ["MESHCORE_NEW_RECIPE_OPTION"] = "1"
|
||||
self.assertNotEqual(first, RECIPE.recipe_digest(args, OPTIONS))
|
||||
|
||||
def test_stored_recipe_is_digest_only_and_has_strict_schema(self):
|
||||
with tempfile.TemporaryDirectory(prefix="mesh-recipe-manifest-") as temporary:
|
||||
manifest = Path(temporary) / "capabilities.json"
|
||||
manifest.write_text(json.dumps({"verified": True}))
|
||||
digest = RECIPE.canonical_digest({"credentials": SECRET})
|
||||
RECIPE.attach_recipe(manifest, digest)
|
||||
text = manifest.read_text()
|
||||
self.assertNotIn(SECRET, text)
|
||||
self.assertTrue(RECIPE.matches_recipe(manifest, digest))
|
||||
self.assertEqual(json.loads(text)["build_recipe"],
|
||||
{"schema_version": 1, "sha256": digest})
|
||||
for record in (None, {}, {"schema_version": True, "sha256": digest},
|
||||
{"schema_version": 1, "sha256": "broken"},
|
||||
{"schema_version": 1, "sha256": digest, "flags": SECRET}):
|
||||
manifest.write_text(json.dumps({"build_recipe": record}))
|
||||
self.assertFalse(RECIPE.matches_recipe(manifest, digest))
|
||||
|
||||
def test_malformed_inputs_cannot_echo_credentials_in_diagnostics(self):
|
||||
command = ["python3", "-B", str(ROOT / "scripts/firmware_build_recipe.py"), "digest",
|
||||
"--root", str(ROOT), "--output", str(ROOT / "out"), "--target", TARGET,
|
||||
"--artifact-target", TARGET, "--pio-env", TARGET, "--platform", "ESP32_PLATFORM",
|
||||
"--source-commit", "a" * 40,
|
||||
"--embedded-version", "vtest", "--profile", "auto", "--sensor-profile", "",
|
||||
"--ota-policy", "0"]
|
||||
result = subprocess.run(command, input=SECRET, capture_output=True, text=True, timeout=10)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertNotIn(SECRET, result.stdout + result.stderr)
|
||||
self.assertEqual(result.stdout, "")
|
||||
|
||||
|
||||
class ResumeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="mesh-recipe-resume-")
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.work = Path(self.temporary.name)
|
||||
(self.work / "scripts").symlink_to(ROOT / "scripts", target_is_directory=True)
|
||||
self.output = self.work / "artifacts"; self.output.mkdir()
|
||||
self.build_dir = self.work / "current-builds"
|
||||
build = self.build_dir / TARGET; build.mkdir(parents=True)
|
||||
image = (b"qualified fixture\0ERR usage: tempradio\0OTA: status\0"
|
||||
b"image_hash MISMATCH after decode\0")
|
||||
(build / "firmware.elf").write_bytes(image)
|
||||
(build / "firmware.bin").write_bytes(image)
|
||||
(build / "firmware-merged.bin").write_bytes(b"merged fixture\0" + image)
|
||||
table = struct.pack("<HBBII16sI", 0x50AA, 0, 0x10, 0x10000, 0x140000, b"ota_0", 0)
|
||||
(build / "partitions.bin").write_bytes(table)
|
||||
report = dict(schema_version=1, passed=True, target=TARGET,
|
||||
available_internal_bytes=80000, required_heap_bytes=50000,
|
||||
largest_internal_region_bytes=80000, required_contiguous_bytes=5120,
|
||||
elf_sha256=sha256(image))
|
||||
(build / "firmware.memory.json").write_text(json.dumps(report))
|
||||
|
||||
def build(self, *, version="v1.17.1.9", settings="", succeed=False, ambient="exported",
|
||||
clock="", after_build="", logged=False, source_root=None):
|
||||
script = r'''
|
||||
source "$1/build_legacy.sh"
|
||||
recipe_root=$1
|
||||
git() { command git -C "$recipe_root" "$@"; }
|
||||
pio() { printf 'FORBIDDEN_PLATFORMIO\n' >&2; return 99; }
|
||||
prepare_esp32_arduino3_framework() { :; }
|
||||
print_build_flags() { :; }
|
||||
PIO_ENV_PLATFORM_BY_NAME[test_companion_radio_usb]=ESP32_PLATFORM
|
||||
PIO_CONFIG_JSON=$4
|
||||
OUTPUT_DIR=$2
|
||||
PIO_BUILD_DIR_OVERRIDE=$3
|
||||
FIRMWARE_VERSION=$5
|
||||
RESUME_BUILD_OUTPUT=1
|
||||
RADIO_FREQ_OVERRIDE=868.1
|
||||
RADIO_BW_OVERRIDE=125
|
||||
RADIO_SF_OVERRIDE=8
|
||||
RADIO_CR_OVERRIDE=6
|
||||
FIRMWARE_PROFILE_OVERRIDE=default
|
||||
REQUIRE_OTA_UPDATES=0
|
||||
recipe_source=$6
|
||||
if [ -n "$recipe_source" ]; then
|
||||
fixture_git_dir=$(command git -C "$recipe_root" rev-parse --absolute-git-dir)
|
||||
compute_build_recipe_digest() {
|
||||
GIT_DIR="$fixture_git_dir" GIT_WORK_TREE="$recipe_source" \
|
||||
MESHCORE_RECIPE_ORIGINAL_FLAGS="$5" \
|
||||
command python3 "$recipe_root/scripts/firmware_build_recipe.py" digest \
|
||||
--root "$recipe_source" --output "$OUTPUT_DIR" --source-commit "$6" \
|
||||
--target "$1" --artifact-target "$1" --pio-env "$3" --platform "$2" \
|
||||
--embedded-version "$4" --profile "$BUILD_PROFILE_FOR_TARGET" \
|
||||
--sensor-profile "${NRF52_OTA_SENSOR_PROFILE:-}" --ota-policy "$REQUIRE_OTA_UPDATES" \
|
||||
<<<"$PIO_CONFIG_JSON"
|
||||
}
|
||||
fi
|
||||
'''
|
||||
values = {"PLATFORMIO_BUILD_FLAGS": "-DWIFI_PWD='\\\"" + SECRET + "\\\"'",
|
||||
"PLATFORMIO_BUILD_UNFLAGS": "-DOLD_FLAG",
|
||||
"PLATFORMIO_BUILD_SRC_FILTER": "+<original/*.cpp>",
|
||||
"PLATFORMIO_EXTRA_SCRIPTS": "pre:scripts/original.py"}
|
||||
if ambient == "unset":
|
||||
script += "unset " + " ".join(values) + "\n"
|
||||
else:
|
||||
prefix = "export " if ambient == "exported" else ""
|
||||
for name, value in values.items():
|
||||
script += prefix + name + "=" + self.shell_quote(value) + "\n"
|
||||
script += settings + "\n" + clock + "\n"
|
||||
script += r'''
|
||||
ambient_before=$(for name in PLATFORMIO_BUILD_FLAGS PLATFORMIO_BUILD_UNFLAGS PLATFORMIO_BUILD_SRC_FILTER PLATFORMIO_EXTRA_SCRIPTS BUILD_RECIPE_SHA256 recipe_build_flags completed_recipe; do declare -p "$name" 2>/dev/null || :; done)
|
||||
run_pio_with_size_detection() {
|
||||
printf 'STUB_BUILD_REQUIRED\n'
|
||||
'''
|
||||
script += after_build + "\nreturn " + ("0" if succeed else "89") + "\n}\n"
|
||||
script += r'''
|
||||
build_firmware_one_profile test_companion_radio_usb
|
||||
build_result=$?
|
||||
ambient_after=$(for name in PLATFORMIO_BUILD_FLAGS PLATFORMIO_BUILD_UNFLAGS PLATFORMIO_BUILD_SRC_FILTER PLATFORMIO_EXTRA_SCRIPTS BUILD_RECIPE_SHA256 recipe_build_flags completed_recipe; do declare -p "$name" 2>/dev/null || :; done)
|
||||
if [ "$ambient_before" != "$ambient_after" ]; then printf 'AMBIENT_CHANGED\n' >&2; exit 77; fi
|
||||
exit "$build_result"
|
||||
'''
|
||||
if logged:
|
||||
script = script.replace("build_firmware_one_profile test_companion_radio_usb\n",
|
||||
"run_logged_build_targets test_companion_radio_usb\n")
|
||||
script = script.replace('exit "$build_result"\n',
|
||||
'for failure in "${LOGGING_MATRIX_FAILURES[@]}"; do '
|
||||
'printf "RECORDED_FAILURE:%s\\n" "$failure"; done\n'
|
||||
'exit "$build_result"\n')
|
||||
result = subprocess.run(["bash", "-c", script, "recipe-test", str(ROOT), str(self.output),
|
||||
str(self.build_dir), json.dumps(OPTIONS), version,
|
||||
str(source_root) if source_root is not None else ""],
|
||||
cwd=self.work, text=True, capture_output=True, timeout=45)
|
||||
self.assertNotIn("FORBIDDEN_PLATFORMIO", result.stdout + result.stderr)
|
||||
self.assertNotIn(SECRET, result.stdout + result.stderr)
|
||||
self.assertNotIn("AMBIENT_CHANGED", result.stdout + result.stderr)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def shell_quote(value):
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
def qualified_fixture(self, **kwargs):
|
||||
result = self.build(succeed=True, **kwargs)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("STUB_BUILD_REQUIRED", result.stdout)
|
||||
capabilities = next(self.output.glob("*.capabilities.json"))
|
||||
self.stem = Path(str(capabilities).removesuffix(".capabilities.json"))
|
||||
self.capabilities = capabilities
|
||||
self.memory = Path(str(self.stem) + ".memory.json")
|
||||
self.assertNotIn(SECRET, capabilities.read_text() + self.memory.read_text())
|
||||
self.validate_fixture()
|
||||
|
||||
def validate_fixture(self):
|
||||
result = subprocess.run(["python3", "-B", str(ROOT / "scripts/firmware_memory_manifest.py"),
|
||||
"validate-package", str(self.stem)], cwd=ROOT,
|
||||
text=True, capture_output=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def snapshot(self):
|
||||
return {str(path.relative_to(self.output)):
|
||||
(os.readlink(path) if path.is_symlink() else path.read_bytes() if path.is_file() else None,
|
||||
path.lstat().st_ino, path.lstat().st_mtime_ns)
|
||||
for path in self.output.rglob("*")}
|
||||
|
||||
def reseal_capabilities(self):
|
||||
report = json.loads(self.memory.read_text())
|
||||
report["files"][self.capabilities.name] = sha256(self.capabilities.read_bytes())
|
||||
self.memory.write_text(json.dumps(report))
|
||||
self.validate_fixture()
|
||||
|
||||
def assert_conflict(self, **kwargs):
|
||||
before = self.snapshot()
|
||||
result = self.build(**kwargs)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertNotIn("STUB_BUILD_REQUIRED", result.stdout)
|
||||
self.assertNotIn("Skipping", result.stdout)
|
||||
self.assertIn("fresh OUTPUT_DIR", result.stderr)
|
||||
self.assertEqual(before, self.snapshot())
|
||||
|
||||
def test_fresh_build_attaches_recipe_and_exact_qualified_match_skips(self):
|
||||
self.qualified_fixture()
|
||||
before = self.snapshot()
|
||||
result = self.build()
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("matching recipe and qualified artifacts", result.stdout)
|
||||
self.assertNotIn("STUB_BUILD_REQUIRED", result.stdout)
|
||||
self.assertEqual(before, self.snapshot())
|
||||
|
||||
def test_generated_clock_changes_do_not_invalidate_recipe(self):
|
||||
self.qualified_fixture()
|
||||
result = self.build(clock=r'''date() { case "$*" in *+%s*) printf '9999999999\n';; *) printf '01-Jan-2099\n';; esac; }''')
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("Skipping", result.stdout)
|
||||
|
||||
def test_real_logged_recipe_resume_preserves_original_compile_log(self):
|
||||
self.qualified_fixture()
|
||||
logs = self.output / "build-logs"; logs.mkdir()
|
||||
log = logs / (TARGET + "-standard.log")
|
||||
log.write_bytes(b"original compiler output and memory qualification\n")
|
||||
before = (log.read_bytes(), log.stat().st_ino, log.stat().st_mtime_ns)
|
||||
artifacts = {name: value for name, value in self.snapshot().items() if not name.startswith("build-logs")}
|
||||
result = self.build(logged=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertNotIn("STUB_BUILD_REQUIRED", result.stdout + log.read_text())
|
||||
self.assertEqual(before, (log.read_bytes(), log.stat().st_ino, log.stat().st_mtime_ns))
|
||||
self.assertEqual(artifacts, {name: value for name, value in self.snapshot().items()
|
||||
if not name.startswith("build-logs")})
|
||||
self.assertFalse(Path(str(log) + ".tmp").exists())
|
||||
|
||||
def test_real_logged_resume_conflict_preserves_prior_log_and_points_to_new_attempt(self):
|
||||
self.qualified_fixture()
|
||||
logs = self.output / "build-logs"; logs.mkdir()
|
||||
log = logs / (TARGET + "-standard.log")
|
||||
log.write_bytes(b"original compiler output and memory qualification\n")
|
||||
before = (log.read_bytes(), log.stat().st_ino, log.stat().st_mtime_ns)
|
||||
artifacts = {name: value for name, value in self.snapshot().items() if not name.startswith("build-logs")}
|
||||
result = self.build(logged=True, settings="RADIO_FREQ_OVERRIDE=910.525")
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
self.assertEqual(before, (log.read_bytes(), log.stat().st_ino, log.stat().st_mtime_ns))
|
||||
self.assertEqual(artifacts, {name: value for name, value in self.snapshot().items()
|
||||
if not name.startswith("build-logs")})
|
||||
attempts = list(logs.glob(TARGET + "-standard-resume-attempt.*.log"))
|
||||
self.assertEqual(len(attempts), 1)
|
||||
attempt = attempts[0]
|
||||
self.assertIn("Cannot resume", attempt.read_text())
|
||||
self.assertNotIn("STUB_BUILD_REQUIRED", result.stdout + attempt.read_text())
|
||||
self.assertNotIn(SECRET, attempt.read_text())
|
||||
self.assertIn("status 1; log: " + str(attempt), result.stdout)
|
||||
self.assertIn("RECORDED_FAILURE:" + TARGET + " (standard) -> " + str(attempt), result.stdout)
|
||||
|
||||
def test_changed_radio_profile_build_number_and_ambient_options_stop_without_overwrite(self):
|
||||
self.qualified_fixture()
|
||||
for settings in ("RADIO_FREQ_OVERRIDE=910.525", "RADIO_BW_OVERRIDE=62.5",
|
||||
"RADIO_SF_OVERRIDE=7", "RADIO_CR_OVERRIDE=5",
|
||||
"FIRMWARE_PROFILE_OVERRIDE=cascade", "FIRMWARE_BUILD_NUMBER=2",
|
||||
"export PLATFORMIO_BUILD_FLAGS+=' -DCHANGED_OPTION=1'",
|
||||
"export PLATFORMIO_BUILD_UNFLAGS+=' -DANOTHER_OPTION'",
|
||||
"export PLATFORMIO_BUILD_SRC_FILTER+=' +<changed/*.cpp>'",
|
||||
"export PLATFORMIO_EXTRA_SCRIPTS+=' post:scripts/changed.py'",
|
||||
"export MESHCORE_REDUCED_TLS=1",
|
||||
"PIO_CONFIG_JSON=" + self.shell_quote(json.dumps(
|
||||
[[OPTIONS[0][0], OPTIONS[0][1] + [["board_build.partitions", "other.csv"]]]]))):
|
||||
with self.subTest(settings=settings):
|
||||
self.assert_conflict(settings=settings)
|
||||
|
||||
def test_missing_and_malformed_recipes_stop_even_when_package_hashes_are_valid(self):
|
||||
self.qualified_fixture()
|
||||
original = json.loads(self.capabilities.read_text())
|
||||
for recipe in (None, {}, {"schema_version": True, "sha256": "a" * 64},
|
||||
{"schema_version": 1, "sha256": "broken"},
|
||||
{"schema_version": 1, "sha256": "a" * 64}):
|
||||
with self.subTest(recipe=recipe):
|
||||
manifest = dict(original)
|
||||
if recipe is None:
|
||||
manifest.pop("build_recipe")
|
||||
else:
|
||||
manifest["build_recipe"] = recipe
|
||||
self.capabilities.write_text(json.dumps(manifest))
|
||||
self.reseal_capabilities()
|
||||
self.assert_conflict()
|
||||
|
||||
def test_corrupt_and_partial_occupied_packages_stop_without_pio_or_writes(self):
|
||||
self.qualified_fixture()
|
||||
application = Path(str(self.stem) + ".bin")
|
||||
application.write_bytes(b"tampered application")
|
||||
self.assert_conflict()
|
||||
for path in list(self.output.iterdir()):
|
||||
if path != application:
|
||||
path.unlink()
|
||||
self.assert_conflict()
|
||||
application.write_bytes(b"")
|
||||
self.assert_conflict()
|
||||
application.unlink()
|
||||
application.symlink_to(self.work / "missing-image")
|
||||
self.assert_conflict()
|
||||
|
||||
def test_memory_seal_prevents_recipe_marker_tampering(self):
|
||||
self.qualified_fixture()
|
||||
old_output = self.output
|
||||
self.output = self.work / "other-artifacts"; self.output.mkdir()
|
||||
result = self.build(succeed=True, settings="RADIO_FREQ_OVERRIDE=910.525")
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
new_manifest = json.loads(next(self.output.glob("*.capabilities.json")).read_text())
|
||||
self.output = old_output
|
||||
manifest = json.loads(self.capabilities.read_text())
|
||||
# Marker now matches the changed settings, but cannot be substituted
|
||||
# into the old image's sealed capability manifest to bypass the gate.
|
||||
manifest["build_recipe"] = new_manifest["build_recipe"]
|
||||
self.capabilities.write_text(json.dumps(manifest))
|
||||
self.assert_conflict(settings="RADIO_FREQ_OVERRIDE=910.525")
|
||||
|
||||
def test_ambient_unset_unexported_and_exported_state_is_restored_on_skip_and_conflict(self):
|
||||
for ambient in ("unset", "unexported", "exported"):
|
||||
with self.subTest(ambient=ambient):
|
||||
self.qualified_fixture(ambient=ambient, version="vtest-" + ambient)
|
||||
result = self.build(ambient=ambient, version="vtest-" + ambient)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("Skipping", result.stdout)
|
||||
self.assert_conflict(ambient=ambient, version="vtest-" + ambient,
|
||||
settings="RADIO_FREQ_OVERRIDE=910.525")
|
||||
|
||||
def test_empty_different_stem_may_build_without_overwriting_previous_version(self):
|
||||
self.qualified_fixture()
|
||||
before = self.snapshot()
|
||||
result = self.build(version="v1.17.1.10")
|
||||
self.assertEqual(result.returncode, 89, result.stdout + result.stderr)
|
||||
self.assertIn("STUB_BUILD_REQUIRED", result.stdout)
|
||||
self.assertEqual(before, self.snapshot())
|
||||
|
||||
def test_input_change_during_stub_compile_prevents_artifact_publication(self):
|
||||
result = self.build(succeed=True, after_build="export PLATFORMIO_BUILD_UNFLAGS+=' -DCHANGED_DURING_BUILD'")
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("changed during compilation", result.stderr)
|
||||
self.assertEqual(self.snapshot(), {})
|
||||
|
||||
def test_ignored_local_config_change_during_stub_compile_prevents_publication(self):
|
||||
source = self.work / "source-fixture"; source.mkdir()
|
||||
# Read-only Git metadata from the real checkout plus a scratch working
|
||||
# tree avoids initializing/changing Git or touching the real private
|
||||
# local config. The helper reads only Git inventory/HEAD commands.
|
||||
(source / ".gitignore").write_bytes((ROOT / ".gitignore").read_bytes())
|
||||
result = self.build(succeed=True, source_root=source, after_build=r'''
|
||||
python3 - "$recipe_source/platformio.local.ini" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
Path(sys.argv[1]).write_text("[env:test]\nbuild_flags=-DLOCAL_RECIPE_REPRO=1\n")
|
||||
PY
|
||||
''')
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("changed during compilation", result.stderr)
|
||||
self.assertEqual(self.snapshot(), {})
|
||||
|
||||
def test_external_config_change_during_stub_compile_prevents_publication(self):
|
||||
source = self.work / "source-fixture"; source.mkdir()
|
||||
(source / ".gitignore").write_bytes((ROOT / ".gitignore").read_bytes())
|
||||
(source / "platformio.ini").write_text("[platformio]\nextra_configs=../external.ini\n")
|
||||
external = self.work / "external.ini"
|
||||
external.write_text("[env:test]\nbuild_flags=-DEXTERNAL_RECIPE_REPRO=1\n")
|
||||
result = self.build(succeed=True, source_root=source, after_build=r'''
|
||||
python3 - "$recipe_source/../external.ini" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
Path(sys.argv[1]).write_text("[env:test]\nbuild_flags=-DEXTERNAL_RECIPE_REPRO=2\n")
|
||||
PY
|
||||
''')
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("changed during compilation", result.stderr)
|
||||
self.assertEqual(self.snapshot(), {})
|
||||
|
||||
def test_occupied_inspection_error_fails_closed_before_stub_compile(self):
|
||||
result = self.build(settings=r'''
|
||||
python3() {
|
||||
if [ "$1" = scripts/firmware_build_recipe.py ] && [ "$2" = occupied ]; then return 2; fi
|
||||
command python3 "$@"
|
||||
}
|
||||
''')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertNotIn("STUB_BUILD_REQUIRED", result.stdout)
|
||||
self.assertIn("Cannot inspect", result.stderr)
|
||||
self.assertEqual(self.snapshot(), {})
|
||||
|
||||
|
||||
class LogPreservationTests(unittest.TestCase):
|
||||
def test_serial_and_dormant_worker_consumers_preserve_new_and_legacy_skip_logs(self):
|
||||
for parallel in (False, True):
|
||||
for message in ("existing artifacts found", "matching recipe and qualified artifacts found"):
|
||||
with self.subTest(parallel=parallel, message=message), \
|
||||
tempfile.TemporaryDirectory(prefix="mesh-recipe-logs-") as temporary:
|
||||
work = Path(temporary)
|
||||
output = work / "artifacts"
|
||||
logs = output / "build-logs"; logs.mkdir(parents=True)
|
||||
paths = [logs / (name + "-standard.log") for name in ("first", "second")]
|
||||
for path in paths:
|
||||
path.write_bytes(b"original compile log\n")
|
||||
before = [(path.read_bytes(), path.stat().st_ino, path.stat().st_mtime_ns)
|
||||
for path in paths]
|
||||
script = r'''
|
||||
source "$1/build_legacy.sh"
|
||||
OUTPUT_DIR=$2
|
||||
skip_message=$3
|
||||
pio() { printf 'FORBIDDEN_PLATFORMIO\n' >&2; return 99; }
|
||||
build_firmware() { printf 'Skipping %s; %s for qualified-fixture.\n' "$1" "$skip_message"; }
|
||||
if [ "$4" = parallel ]; then
|
||||
# Exercise the retained consumer code using harmless shell-only workers.
|
||||
# The production policy stays at one worker; no PIO process is launched.
|
||||
consumer=$(declare -f run_logged_build_targets)
|
||||
consumer=${consumer/local worker_limit=1/local worker_limit=2}
|
||||
eval "$consumer"
|
||||
fi
|
||||
run_logged_build_targets first second
|
||||
'''
|
||||
result = subprocess.run(["bash", "-c", script, "log-test", str(ROOT), str(output),
|
||||
message, "parallel" if parallel else "serial"],
|
||||
cwd=work, text=True, capture_output=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertNotIn("FORBIDDEN_PLATFORMIO", result.stdout + result.stderr)
|
||||
self.assertEqual(before, [(path.read_bytes(), path.stat().st_ino, path.stat().st_mtime_ns)
|
||||
for path in paths])
|
||||
self.assertEqual(list(logs.glob("*.tmp")), [])
|
||||
|
||||
def test_failed_resume_attempts_get_unique_logs_but_other_build_results_keep_old_policy(self):
|
||||
for parallel in (False, True):
|
||||
for resume, status in ((True, 1), (False, 1), (True, 0), (False, 0)):
|
||||
with self.subTest(parallel=parallel, resume=resume, status=status), \
|
||||
tempfile.TemporaryDirectory(prefix="mesh-recipe-log-failures-") as temporary:
|
||||
work = Path(temporary)
|
||||
output = work / "artifacts"
|
||||
logs = output / "build-logs"; logs.mkdir(parents=True)
|
||||
paths = [logs / (name + "-standard.log") for name in ("first", "second")]
|
||||
for path in paths:
|
||||
path.write_bytes(b"original compile log\n")
|
||||
before = [(path.read_bytes(), path.stat().st_ino, path.stat().st_mtime_ns)
|
||||
for path in paths]
|
||||
script = r'''
|
||||
source "$1/build_legacy.sh"
|
||||
OUTPUT_DIR=$2
|
||||
RESUME_BUILD_OUTPUT=$3
|
||||
stub_status=$4
|
||||
pio() { printf 'FORBIDDEN_PLATFORMIO\n' >&2; return 99; }
|
||||
build_firmware() { printf 'current attempt diagnostics\n'; return "$stub_status"; }
|
||||
if [ "$5" = parallel ]; then
|
||||
consumer=$(declare -f run_logged_build_targets)
|
||||
consumer=${consumer/local worker_limit=1/local worker_limit=2}
|
||||
eval "$consumer"
|
||||
fi
|
||||
run_logged_build_targets first second
|
||||
first_result=$?
|
||||
run_logged_build_targets first second
|
||||
second_result=$?
|
||||
for failure in "${LOGGING_MATRIX_FAILURES[@]}"; do printf 'RECORDED_FAILURE:%s\n' "$failure"; done
|
||||
printf 'RESULTS:%s/%s\n' "$first_result" "$second_result"
|
||||
'''
|
||||
result = subprocess.run(["bash", "-c", script, "failed-log-test", str(ROOT), str(output),
|
||||
"1" if resume else "0", str(status),
|
||||
"parallel" if parallel else "serial"],
|
||||
cwd=work, text=True, capture_output=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertNotIn("FORBIDDEN_PLATFORMIO", result.stdout + result.stderr)
|
||||
self.assertIn(f"RESULTS:{status}/{status}", result.stdout)
|
||||
attempts = list(logs.glob("*-resume-attempt.*.log"))
|
||||
failed_paths = {line.split(" -> ", 1)[1] for line in result.stdout.splitlines()
|
||||
if line.startswith("RECORDED_FAILURE:")}
|
||||
if resume and status:
|
||||
self.assertEqual(before, [(path.read_bytes(), path.stat().st_ino, path.stat().st_mtime_ns)
|
||||
for path in paths])
|
||||
self.assertEqual(len(attempts), 4)
|
||||
self.assertEqual(failed_paths, set(map(str, attempts)))
|
||||
for attempt in attempts:
|
||||
self.assertIn("current attempt diagnostics", attempt.read_text())
|
||||
self.assertIn("FAILED:", attempt.read_text())
|
||||
self.assertIn("status 1; log: " + str(attempt), result.stdout)
|
||||
else:
|
||||
self.assertEqual(attempts, [])
|
||||
for path in paths:
|
||||
self.assertIn("current attempt diagnostics", path.read_text())
|
||||
self.assertNotIn("original compile log", path.read_text())
|
||||
self.assertEqual(failed_paths, set(map(str, paths)) if status else set())
|
||||
self.assertEqual(list(logs.glob("*.tmp")), [])
|
||||
|
||||
def test_failed_attempt_log_allocation_preserves_prior_log_and_never_starts_build(self):
|
||||
with tempfile.TemporaryDirectory(prefix="mesh-recipe-log-allocation-") as temporary:
|
||||
work = Path(temporary)
|
||||
output = work / "artifacts"
|
||||
logs = output / "build-logs"; logs.mkdir(parents=True)
|
||||
original = logs / "first-standard.log"; original.write_bytes(b"original compile log\n")
|
||||
before = (original.read_bytes(), original.stat().st_ino, original.stat().st_mtime_ns)
|
||||
script = r'''
|
||||
source "$1/build_legacy.sh"
|
||||
OUTPUT_DIR=$2
|
||||
RESUME_BUILD_OUTPUT=1
|
||||
mktemp() { return 70; }
|
||||
build_firmware() { printf 'SHOULD_NOT_BUILD\n'; }
|
||||
run_logged_build_targets first second
|
||||
status=$?
|
||||
printf 'RESULT:%s BATCH:%s\n' "$status" "$BATCH_BUILD_MODE"
|
||||
'''
|
||||
result = subprocess.run(["bash", "-c", script, "allocation-log-test", str(ROOT), str(output)],
|
||||
cwd=work, text=True, capture_output=True, timeout=10)
|
||||
self.assertNotIn("SHOULD_NOT_BUILD", result.stdout)
|
||||
self.assertIn("RESULT:1 BATCH:0", result.stdout)
|
||||
self.assertEqual(before, (original.read_bytes(), original.stat().st_ino, original.stat().st_mtime_ns))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,6 +8,36 @@ fail() { echo "test_full_infrastructure_selection: $*" >&2; exit 1; }
|
||||
# Resolve real board inheritance, but never compile or flash firmware.
|
||||
init_project_context >/dev/null
|
||||
|
||||
# No fixture assertion is allowed to fall through into a firmware build. The
|
||||
# sole PlatformIO operation above resolves board metadata; all later calls are
|
||||
# errors, including dependency preflight before the compiler entry point.
|
||||
pio() { fail "selection fixture attempted PlatformIO after metadata resolution"; }
|
||||
prepare_esp32_arduino3_framework() { fail "selection fixture reached build preflight"; }
|
||||
run_pio_with_size_detection() { fail "selection fixture attempted compilation"; }
|
||||
collect_build_artifacts() { fail "selection fixture attempted artifact publication"; }
|
||||
|
||||
use_qualified_resume_fixture() {
|
||||
fixture_output_dir=$(mktemp -d "${TMPDIR:-/tmp}/mesh-full-selection.XXXXXX")
|
||||
OUTPUT_DIR=$fixture_output_dir
|
||||
trap 'rm -rf -- "$fixture_output_dir"' EXIT
|
||||
# These tests mock package qualification to inspect target selection. Supply
|
||||
# a real, schema-valid receipt for the new recipe gate too; its actual
|
||||
# occupied/matches helpers run normally. Recipe hashing and package sealing
|
||||
# are exercised independently by test_firmware_build_recipe.py.
|
||||
compute_build_recipe_digest() {
|
||||
python3 - "${OUTPUT_DIR}/${firmware_filename}.capabilities.json" "$1" "$3" <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
digest = "a" * 64
|
||||
manifest = {"target": sys.argv[2], "platformio_env": sys.argv[3],
|
||||
"build_recipe": {"schema_version": 1, "sha256": digest}}
|
||||
Path(sys.argv[1]).write_text(json.dumps(manifest) + "\n")
|
||||
print(digest)
|
||||
PY
|
||||
}
|
||||
}
|
||||
|
||||
assert_auto_unified() (
|
||||
local plain=$1 expected=$2
|
||||
BUILD_PROFILE_OVERRIDE=auto
|
||||
@@ -180,8 +210,9 @@ done
|
||||
|
||||
# Direct observer promotion must use the matrix's exact output recipe even if
|
||||
# interactive flags previously requested MQTT without USB logging. Stop at the
|
||||
# existing-artifact check to exercise the real build entry point without pio run.
|
||||
# matching-recipe/qualified-artifact check without entering compilation.
|
||||
(
|
||||
use_qualified_resume_fixture
|
||||
ESP32_FULL_BUILD=0
|
||||
BUILD_PROFILE_EFFECTIVE=standard
|
||||
BUILD_PROFILE_OVERRIDE=auto
|
||||
@@ -211,6 +242,7 @@ done
|
||||
# A canonical bulk build promotes an audited target in-place, retaining its
|
||||
# environment/mOTA identity. Explicit standard builds remain untouched above.
|
||||
(
|
||||
use_qualified_resume_fixture
|
||||
ESP32_FULL_BUILD=0
|
||||
BUILD_PROFILE_EFFECTIVE=standard
|
||||
BUILD_PROFILE_OVERRIDE=auto
|
||||
@@ -232,6 +264,7 @@ done
|
||||
|| fail "FULL-only bulk target lost its exact identity"
|
||||
)
|
||||
(
|
||||
use_qualified_resume_fixture
|
||||
ESP32_FULL_BUILD=0
|
||||
BUILD_PROFILE_EFFECTIVE=standard
|
||||
BUILD_PROFILE_OVERRIDE=auto
|
||||
@@ -250,6 +283,7 @@ done
|
||||
|| fail "V4 Full did not keep its identity and unified feature recipe"
|
||||
)
|
||||
(
|
||||
use_qualified_resume_fixture
|
||||
ESP32_FULL_BUILD=0
|
||||
BUILD_PROFILE_EFFECTIVE=standard
|
||||
BUILD_PROFILE_OVERRIDE=standard
|
||||
|
||||
Reference in new issue
Block a user