Files
78--xiaozhi-esp32/docker/firmware-builder/firmware_builder.py
T

462 lines
16 KiB
Python

#!/usr/bin/env python3
"""One-shot cloud firmware build entrypoint.
The container is intentionally job-oriented: one invocation builds exactly one
board configuration, writes immutable artifacts and metadata to the output
directory, and exits with the underlying build status.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Sequence
SAFE_BOARD_DIR = re.compile(r"^[a-z0-9][a-z0-9._/-]*$")
SAFE_REPORTED_IDENTIFIER = re.compile(r"^[a-z0-9][a-z0-9.-]*$")
SAFE_JOB_ID = re.compile(r"^[a-z0-9][a-z0-9.-]*$")
SAFE_WAKE_WORD = re.compile(r"^(?:disabled|nihaoxiaozhi|wn9[sl]?_[a-z0-9_]+)$")
ARTIFACTS = {
"ota": Path("build/xiaozhi.bin"),
"full": Path("build/merged-binary.bin"),
}
UPLOAD_MAX_ATTEMPTS = 4
UPLOAD_BASE_DELAY_SECONDS = 1
UPLOAD_TIMEOUT_SECONDS = 120
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def env(name: str) -> str | None:
value = os.environ.get(name)
return value.strip() if value and value.strip() else None
def upload_config() -> dict[str, str] | None:
upload_url = env("FIRMWARE_UPLOAD_URL")
upload_token = env("FIRMWARE_UPLOAD_TOKEN")
if not upload_url and not upload_token:
return None
if not upload_url or not upload_token:
raise ValueError(
"FIRMWARE_UPLOAD_URL and FIRMWARE_UPLOAD_TOKEN must be configured together"
)
parsed_url = urllib.parse.urlparse(upload_url)
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
raise ValueError("FIRMWARE_UPLOAD_URL must be an absolute HTTP(S) URL")
return {"url": upload_url.rstrip("/"), "token": upload_token}
def parser() -> argparse.ArgumentParser:
result = argparse.ArgumentParser(
description=(
"Build one XiaoZhi firmware board configuration. Arguments may also "
"be supplied through FIRMWARE_BOARD_DIR, FIRMWARE_BOARD_NAME, "
"FIRMWARE_LANGUAGE, FIRMWARE_WAKE_WORD, and "
"FIRMWARE_BUILD_OPTIONS."
)
)
result.add_argument("--board-dir", default=env("FIRMWARE_BOARD_DIR"))
result.add_argument("--board-name", default=env("FIRMWARE_BOARD_NAME"))
result.add_argument("--language", default=env("FIRMWARE_LANGUAGE"))
result.add_argument("--wake-word", default=env("FIRMWARE_WAKE_WORD"))
result.add_argument(
"--build-options-json",
default=env("FIRMWARE_BUILD_OPTIONS") or "{}",
help="Curated semantic build options as a JSON object",
)
result.add_argument(
"--source-dir",
type=Path,
default=Path(env("FIRMWARE_SOURCE_DIR") or "/opt/xiaozhi-esp32"),
)
result.add_argument(
"--output-dir",
type=Path,
default=Path(env("FIRMWARE_OUTPUT_DIR") or "/output"),
)
result.add_argument(
"--job-id",
default=env("FIRMWARE_JOB_ID"),
help="Optional caller job identifier written to manifest.json",
)
return result
def validate(args: argparse.Namespace) -> None:
missing = [
option
for option, value in (
("--board-dir", args.board_dir),
("--board-name", args.board_name),
("--language", args.language),
("--wake-word", args.wake_word),
)
if not value
]
if missing:
raise ValueError(f"Missing required build inputs: {', '.join(missing)}")
if (
not SAFE_BOARD_DIR.fullmatch(args.board_dir)
or args.board_dir.startswith("/")
or ".." in Path(args.board_dir).parts
):
raise ValueError(f"Invalid board directory: {args.board_dir!r}")
if not SAFE_REPORTED_IDENTIFIER.fullmatch(args.board_name):
raise ValueError(f"Invalid board name: {args.board_name!r}")
normalized_wake_word = args.wake_word.casefold().replace("-", "_")
if not SAFE_WAKE_WORD.fullmatch(normalized_wake_word):
raise ValueError(f"Invalid wake word: {args.wake_word!r}")
args.wake_word = normalized_wake_word
try:
build_options = json.loads(args.build_options_json)
except json.JSONDecodeError as error:
raise ValueError(f"Invalid build options JSON: {error}") from error
if not isinstance(build_options, dict):
raise ValueError("Build options JSON must contain an object")
invalid_values = [
key
for key, value in build_options.items()
if not isinstance(key, str) or not isinstance(value, (str, bool))
]
if invalid_values:
raise ValueError(
"Build option values must be strings or booleans: "
+ ", ".join(map(str, invalid_values))
)
args.build_options = build_options
args.build_options_json = json.dumps(
build_options,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
build_script = args.source_dir / "scripts/build.py"
if not build_script.is_file():
raise ValueError(f"Firmware build script not found: {build_script}")
config_path = (
args.source_dir / "main" / "boards" / args.board_dir / "config.json"
)
if not config_path.is_file():
raise ValueError(f"Board configuration not found: {config_path}")
try:
config = json.loads(config_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise ValueError(f"Invalid board configuration {config_path}: {error}") from error
configured_type = config.get("type")
if (
not isinstance(configured_type, str)
or not SAFE_REPORTED_IDENTIFIER.fullmatch(configured_type)
):
raise ValueError(
f"Invalid board type in {config_path}: {configured_type!r}"
)
args.board_type = configured_type
configured_names = {
build.get("name")
for build in config.get("builds", [])
if isinstance(build, dict)
}
if args.board_name not in configured_names:
raise ValueError(
f"Board name {args.board_name!r} is not defined for {args.board_dir}"
)
def run_and_log(command: Sequence[str], cwd: Path, log_path: Path) -> int:
with log_path.open("w", encoding="utf-8") as log:
log.write(f"command={json.dumps(list(command), ensure_ascii=False)}\n")
log.flush()
process = subprocess.Popen(
list(command),
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
)
assert process.stdout is not None
with process.stdout:
for line in process.stdout:
sys.stdout.write(line)
sys.stdout.flush()
log.write(line)
log.flush()
return process.wait()
def command_output(command: Sequence[str], cwd: Path) -> str:
try:
return subprocess.check_output(
list(command),
cwd=cwd,
text=True,
encoding="utf-8",
stderr=subprocess.STDOUT,
).strip()
except (OSError, subprocess.CalledProcessError):
return "unknown"
def project_version(source_dir: Path) -> str:
content = (source_dir / "CMakeLists.txt").read_text(encoding="utf-8")
match = re.search(r'^set\(PROJECT_VER\s+"([^"]+)"\)', content, re.MULTILINE)
return match.group(1) if match else "unknown"
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def collect_artifacts(source_dir: Path, output_dir: Path) -> list[dict[str, object]]:
collected: list[dict[str, object]] = []
for kind, relative_path in ARTIFACTS.items():
source = source_dir / relative_path
if not source.is_file():
raise FileNotFoundError(f"Expected build artifact not found: {source}")
destination = output_dir / source.name
temporary = destination.with_suffix(destination.suffix + ".tmp")
shutil.copyfile(source, temporary)
temporary.replace(destination)
collected.append(
{
"kind": kind,
"file": destination.name,
"size": destination.stat().st_size,
"sha256": sha256(destination),
}
)
return collected
def write_manifest(output_dir: Path, manifest: dict[str, object]) -> None:
path = output_dir / "manifest.json"
temporary = path.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
temporary.replace(path)
def failure_summary(log_path: Path) -> str:
"""Extract a concise actionable failure from a compiler log."""
if not log_path.is_file():
return "Firmware build failed"
ansi_escape = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
lines = [
ansi_escape.sub("", line).strip()
for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines()
]
meaningful = [
line for line in lines
if line
and not line.startswith(("XIAOZHI_STAGE ", "XIAOZHI_SOURCE_REVISION "))
]
patterns = (
re.compile(r"(?:fatal error|error:|ValueError:|RuntimeError:|FileNotFoundError:)", re.I),
re.compile(r"(?:Kconfig rejected|Unsupported build option|build stopped|failed with exit code)", re.I),
)
for pattern in patterns:
for line in reversed(meaningful):
if pattern.search(line):
return line[:500]
return meaningful[-1][:500] if meaningful else "Firmware build failed"
def is_retryable_upload_error(error: Exception) -> bool:
if isinstance(error, urllib.error.HTTPError):
return error.code in {408, 429} or error.code >= 500
return isinstance(
error,
(urllib.error.URLError, ConnectionError, TimeoutError, OSError),
)
def upload_file_with_retry(
upload_url: str,
upload_token: str,
local_path: Path,
) -> None:
payload = local_path.read_bytes()
request = urllib.request.Request(
upload_url,
data=payload,
method="PUT",
headers={
"Authorization": f"Bearer {upload_token}",
"Content-Type": "application/octet-stream",
"Content-Length": str(len(payload)),
"X-Artifact-SHA256": hashlib.sha256(payload).hexdigest(),
},
)
for attempt in range(1, UPLOAD_MAX_ATTEMPTS + 1):
try:
with urllib.request.urlopen(
request,
timeout=UPLOAD_TIMEOUT_SECONDS,
) as response:
response.read()
return
except Exception as error:
retryable = is_retryable_upload_error(error)
if isinstance(error, urllib.error.HTTPError):
error.close()
if (
attempt >= UPLOAD_MAX_ATTEMPTS
or not retryable
):
raise
delay = UPLOAD_BASE_DELAY_SECONDS * (2 ** (attempt - 1))
print(
"firmware-builder: transient artifact upload failure for "
f"{local_path.name}; retry {attempt + 1}/"
f"{UPLOAD_MAX_ATTEMPTS} in {delay}s: {error}",
file=sys.stderr,
)
time.sleep(delay)
def upload_outputs(
output_dir: Path,
manifest: dict[str, Any],
config: dict[str, str],
) -> None:
if not manifest.get("job_id"):
raise ValueError("FIRMWARE_JOB_ID is required when artifact upload is enabled")
job_id = str(manifest["job_id"])
if not SAFE_JOB_ID.fullmatch(job_id):
raise ValueError(f"Invalid job ID for artifact upload: {job_id!r}")
file_names = ["build.log"]
file_names.extend(str(item["file"]) for item in manifest["artifacts"])
file_names.append("manifest.json")
manifest["delivery_status"] = "uploading"
write_manifest(output_dir, manifest)
for name in file_names[:-1]:
upload_file_with_retry(
f"{config['url']}/{urllib.parse.quote(job_id)}/artifacts/"
f"{urllib.parse.quote(name)}",
config["token"],
output_dir / name,
)
manifest["delivery_status"] = "succeeded"
write_manifest(output_dir, manifest)
upload_file_with_retry(
f"{config['url']}/{urllib.parse.quote(job_id)}/artifacts/manifest.json",
config["token"],
output_dir / "manifest.json",
)
def main(argv: Sequence[str] | None = None) -> int:
args = parser().parse_args(argv)
started_at = utc_now()
try:
validate(args)
artifact_upload_config = upload_config()
except ValueError as error:
print(f"firmware-builder: {error}", file=sys.stderr)
return 2
args.output_dir.mkdir(parents=True, exist_ok=True)
log_path = args.output_dir / "build.log"
manifest: dict[str, object] = {
"schema_version": 1,
"status": "running",
"job_id": args.job_id,
"board_dir": args.board_dir,
"board_type": args.board_type,
"board_name": args.board_name,
"language": args.language,
"wake_word": args.wake_word,
"build_options": args.build_options,
"firmware_version": project_version(args.source_dir),
"firmware_source_revision": env("FIRMWARE_SOURCE_REVISION") or "unknown",
"idf_version": command_output(["idf.py", "--version"], args.source_dir),
"runtime_architecture": command_output(["uname", "-m"], args.source_dir),
"runtime_cpu_count": os.cpu_count(),
"started_at": started_at,
"artifacts": [],
}
write_manifest(args.output_dir, manifest)
command = [
sys.executable,
"scripts/build.py",
args.board_dir,
"--name",
args.board_name,
"--language",
args.language,
"--wake-word",
args.wake_word,
"--build-options-json",
args.build_options_json,
]
return_code = run_and_log(command, args.source_dir, log_path)
manifest["finished_at"] = utc_now()
manifest["exit_code"] = return_code
if return_code == 0:
try:
manifest["artifacts"] = collect_artifacts(args.source_dir, args.output_dir)
manifest["status"] = "succeeded"
except (OSError, FileNotFoundError) as error:
print(f"firmware-builder: {error}", file=sys.stderr)
manifest["status"] = "failed"
manifest["error"] = str(error)
return_code = 1
manifest["exit_code"] = return_code
else:
manifest["status"] = "failed"
manifest["error"] = failure_summary(log_path)
write_manifest(args.output_dir, manifest)
if artifact_upload_config is not None:
try:
print("XIAOZHI_STAGE uploading", flush=True)
upload_outputs(args.output_dir, manifest, artifact_upload_config)
except Exception as error:
print(f"firmware-builder: artifact upload failed: {error}", file=sys.stderr)
manifest["status"] = "failed"
manifest["delivery_status"] = "failed"
manifest["error"] = f"Artifact upload failed: {error}"
return_code = 1
manifest["exit_code"] = return_code
write_manifest(args.output_dir, manifest)
return return_code
if __name__ == "__main__":
raise SystemExit(main())