Tachyon A/B system image v1: dual-slot LUN0 + particle_image_v1 OTA format#59
Tachyon A/B system image v1: dual-slot LUN0 + particle_image_v1 OTA format#59mrlambchop wants to merge 10 commits into
Conversation
…rdata Rework the LUN0 (HLOS) layout in partition_ext.xml from a single-slot efi+system to an A/B layout, and write both slots at factory: efi_a / efi_b 512 MB each (ESP), A/B system_a / system_b 12 GB each (fixed), A/B — both flashed with rootfs.ext4 persist 30 MB, not slotted (survives A/B) userdata grow-to-fill last partition, 4 GB floor, not slotted system_a/system_b must be fixed (only the last partition can grow), so they are sized to fit the 64 GB SKU: 2x12 GB system + 2x512 MB EFI + persist = 25 GB fixed, leaving ~30 GB for userdata on 64 GB and ~90 GB on 128 GB. LUN0 already grows to fill the whole device (provision_ufs22.xml LUNtoGrow=0), so userdata absorbs the SKU difference dynamically. compose_24_04.sh now passes --system_a/--system_b (both -> rootfs.ext4) and --efi_a/--efi_b (both -> efi.img): one source image written to both slots, stored once in the zip, exactly as the 20.04 dual-slot flash did. A/B partitions carry distinct type GUIDs (same convention as the LUN4 firmware slots). This lands the partition STRUCTURE; making slot B actually bootable still needs firmware-B blobs, a UEFI slot-switch policy, per-slot GRUB, and userdata first-boot mkfs+mount (tracked separately). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D7qj7xrmw9rmu1m23n9qeB
Add nodejs/npm to the builder (Dockerfile v1.5) and a section 6c that runs the shared @particle/tachyon-image CLI over the assembled factory tree to emit the Particle OTA image format and validate it (signature, slot symmetry, sizes, emittable formats). Makefile gains EMIT_FORMAT/EMIT_SLOT, a vendor_particle_image target (build+pack the lib into .tmp/vendor as a tgz the container installs), and passes the two args to compose. Step is additive: if the CLI/tgz is absent the legacy factory zip is still produced. ed25519 manifest-signing test keypair added under keys/ (public + throwaway TEST private; prod key supplied at build time). OTA.md documents the full design.
The section 6c emit previously resolved particle-image only from a local sibling checkout, which CI does not have, so the OTA format was silently skipped on PR builds. Add a public-tag fallback (github:#PARTICLE_IMAGE_REF, default v0.0.1) resolved by the container's npm, so CI emits + validates the particle_image_v1 format for real. Upload the resulting .pimg.zip as a downloadable GitHub artifact per region/variant so it can be tested against. Best-effort throughout: compose has no set -e, so a skip/failure never blocks the legacy system-image zip.
The first PR build skipped the OTA emit: npm's `github:` shorthand resolves to git+ssh first, and the build container has no SSH key, so the install failed in ~5s and the silent redirect hid it. Use the explicit git+https://github.com/...#<ref> URL (public repo → no auth) and show npm's output instead of discarding it. Verified locally: https ls-remote needs no credentials and npm git+https install builds the CLI bin.
Replace the in-container git+https install (which failed: the debianised container npm could not run the git-dependency prepare build — 'tsc: not found') with the published, prebuilt npm package. The npm tarball ships dist/, so there's no clone and no build step in the container. PARTICLE_IMAGE_REF (git tag) becomes PARTICLE_IMAGE_VERSION (npm semver, default 0.1.0).
There was a problem hiding this comment.
Pull request overview
This PR combines the A/B LUN0 partition layout work with generation/validation of a new particle_image_v1 OTA artifact (*.pimg.zip) emitted from the assembled factory tree via the @particle/tachyon-image CLI.
Changes:
- Redesign LUN0 to dual-slot
efi_a/b+system_a/bplus non-slottedpersistand grow-to-filluserdata. - Add optional emission + validation of
particle_image_v1fromcompose_24_04.sh, with Node/npm added to the builder image. - Extend CI to upload the resulting
*.pimg.zipas a workflow artifact per region/variant.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/assemble/config/partition_ext.xml | Updates LUN0 partition table for A/B + adds userdata partition. |
| OTA.md | Adds design/format documentation for Particle’s OTA image system and related components. |
| Makefile | Adds vendoring support + parameters for particle_image_v1 emission and passes them into the compose script. |
| keys/particle-ota-test-ed25519.key | Adds an ed25519 private key intended for test signing. |
| keys/particle-ota-pub.pem | Adds the corresponding public key for validation. |
| keys/.gitignore | Re-includes the new OTA signing key material under keys/. |
| Dockerfile | Installs nodejs/npm in the builder container to run the particle-image CLI. |
| compose_24_04.sh | Writes rootfs/efi into both slots at factory; adds section 6c to emit/validate *.pimg.zip. |
| .github/workflows/build.yml | Uploads *.pimg.zip as a GitHub Actions artifact (warn if missing). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "$PI" generate --factory-dir "$OUT/factory" --out "$PIMG" \ | ||
| --emit "$EMIT_FORMAT" "${SLOT_ARGS[@]}" \ | ||
| --sign-profile "$SIGNING_PROFILE" "${KEY_ARGS[@]}" | ||
| "$PI" validate "$PIMG" --public-key "$PROJ/keys/particle-ota-pub.pem" | ||
| echo "OK particle_image_v1: $PIMG" |
| if [ "$SIGNING_PROFILE" != "none" ] && [ -f "$PROJ/keys/particle-ota-test-ed25519.key" ]; then | ||
| KEY_ARGS=(--key "$PROJ/keys/particle-ota-test-ed25519.key" --key-id particle-ota-test) | ||
| fi |
| -----BEGIN PRIVATE KEY----- | ||
| MC4CAQAwBQYDK2VwBCIEIB4HoQ+FixBE6sakVXVOaQSetc4ODVevHO/N6bzdJkXP | ||
| -----END PRIVATE KEY----- |
| # re-include the particle_image_v1 manifest-signing keys. The public key is needed | ||
| # everywhere to verify; the .key is a THROWAWAY TEST signing key only (sign-profile=test). | ||
| # Production manifest keys are supplied at build time via secret/mount and never tracked. | ||
| !particle-ota-pub.pem | ||
| !particle-ota-test-ed25519.key |
| <partition label="system" size_in_kb="10485760" type="B921B045-1DF0-41C3-AF44-4C6F280D3FAE" bootable="false" readonly="false" system="true" dontautomount="true" filename="system.img"/> | ||
| </physical_partition> | ||
| <partition label="efi_a" size_in_kb="524288" type="C12A7328-F81F-11D2-BA4B-00A0C93EC93B" bootable="false" readonly="true" system="true" dontautomount="true" filename="efi.bin"/> | ||
| <partition label="efi_b" size_in_kb="524288" type="FFFE4A7E-CB5C-4290-87D9-4B7C5B994CA5" bootable="false" readonly="true" system="true" dontautomount="true" filename=""/> |
| ## Workflow constraints & branch setup | ||
|
|
||
| - **Never push.** All work stays local — feature branches and local commits only; no `git push`, no | ||
| PRs, until the user explicitly asks. | ||
| - **Branch per repo (off the latest default branch):** | ||
| - `tachyon-composer`: discard the uncommitted `partition_ext.xml` experiment | ||
| (`git checkout -- scripts/assemble/config/partition_ext.xml`), then branch | ||
| **`feature/ota-image-format`** off `main` (carry `OTA.md` over). | ||
| - `particle-cli` (`../../cli/particle-cli`): default branch is **`master`** — pull latest, then | ||
| branch **`feature/tachyon-ota-ab`**. Leave the existing untracked backup artifacts alone. | ||
| - `particle-tachyon-image`, `particle-tachyon-ota`: new repos (`git init`, initial local commit). | ||
| - **Everything must run.** Build + unit-test the shared lib and CLI as they're written; the CLI gains | ||
| a **`--dry-run`** mode (below) so the full flash/slot/update path is exercisable with no hardware. | ||
|
|
| New repo at **`../particle-tachyon-image`** (i.e. `/Users/nicklambourne/Documents/particle/tachyon/particle-tachyon-image`, | ||
| sibling of tachyon-composer; to be created) — npm package, recommend **TypeScript**, emit CommonJS+ESM | ||
| (it is a contract shared by four consumers; types prevent drift). Every module is independently unit-tested (the user's explicit | ||
| requirement: *the common files themselves need tests*). Modules: |
| @if [ -d "$(PARTICLE_IMAGE_LIB)" ]; then \ | ||
| echo "Vendoring particle-image from $(PARTICLE_IMAGE_LIB) ..."; \ | ||
| mkdir -p "$(VENDOR_DIR)"; \ | ||
| rm -f "$(VENDOR_DIR)"/particle-tachyon-image-*.tgz; \ | ||
| ( cd "$(PARTICLE_IMAGE_LIB)" && npm ci --silent && npm run build --silent \ | ||
| && npm pack --silent --pack-destination "$(abspath $(VENDOR_DIR))" ) \ | ||
| && echo "Vendored: $$(ls "$(VENDOR_DIR)"/particle-tachyon-image-*.tgz)"; \ | ||
| else \ | ||
| echo "NOTE: $(PARTICLE_IMAGE_LIB) not found; particle_image_v1 emission will be skipped"; \ | ||
| fi |
|
PR build uploaded for RoW headless |
|
PR build uploaded for NA headless |
|
PR build uploaded for RoW desktop |
|
PR build uploaded for NA desktop |
…ack for prod Address Copilot review on PR #59: - compose_24_04.sh: guard particle-image generate/validate so a non-zero exit no longer aborts the legacy factory build under set -euo pipefail (warn only). - compose_24_04.sh: select the OTA signing key by profile via a case statement. The committed ed25519 key is test-only and is used solely for SIGNING_PROFILE=test; any real/prod profile must supply PARTICLE_OTA_KEY[/PARTICLE_OTA_KEY_ID] or the OTA step is skipped with a loud error instead of signing with the test key. Validation pubkey is overridable via PARTICLE_OTA_PUBKEY. - Makefile: gate vendor_particle_image on npm availability; emit a NOTE instead of hard-failing make build_24.04 when npm is absent on the host. - OTA.md: drop the local working-plan directives and the personal filesystem path in favour of neutral, portable repo guidance.
|
Thanks @copilot — addressed in 0b9589d. Summary of what changed vs. what's intentional: Fixed
Intentional / deferred (with rationale)
|
|
PR build uploaded for NA headless |
|
PR build uploaded for RoW desktop |
|
PR build uploaded for NA desktop |
|
PR build uploaded for RoW headless |
|
PR build uploaded for RoW headless |
|
PR build uploaded for NA headless |
|
PR build uploaded for NA desktop |
|
PR build uploaded for RoW desktop |
Emit edl.provision_xml when provision_ufs22.xml is present in the factory tree, so the OTA image format carries it and a factory flash can re-provision the LUN geometry before programming.
|
PR build uploaded for RoW headless |
|
PR build uploaded for NA headless |
|
PR build uploaded for NA desktop |
|
PR build uploaded for RoW desktop |
Combines the two halves of the A/B system-image work into a single PR (supersedes #57 and #58, which are closed in favour of this).
1. A/B LUN0 layout (was #57)
Redesigns LUN0 for A/B updates: dual
system_a/system_b(12 GB each), dualefi_a/efi_b(512 MB each, distinct GUIDs), persistentpersist, and a grow-to-filluserdata. Compose writes rootfs/efi into both slots. (Boot/firmware LUNs already carried A/B.)2. particle_image_v1 OTA format (was #58)
compose_24_04.sh§6c emits + validates the Particle OTA image (<image>.pimg.zip) from the assembled factory tree using the shared@particle/tachyon-imageCLI, signed with the test ed25519 key and validated against the bundled public key. Additive and never fatal to the legacy zip.build.yml): uploads the.pimg.zipper region/variant as a downloadable artifact.@particle/tachyon-image@0.1.0from npm (published, prebuilt — no in-container git/build, which is what failed previously).Why combine
The image check showed they're inseparable: the OTA format needs a second
systemslot to be useful, and that slot only exists with the LUN0 redesign. Shipping them together means a built image is actually A/B-OTA-capable end to end.Test against
Each matrix build (RoW/NA × headless/desktop) attaches the legacy system-image
.zip(S3 + PR comment) and the new*.pimg.zip(GitHub artifact).Update — ship UFS provisioning in the image manifest
compose_24_04.shnow emitsedl.provision_xmlinmanifest.jsonwhenprovision_ufs22.xmlis present in the factory tree (it is —make_factory_img.shcopies it). The OTA image format then carries the provisioning XML, so aparticle tachyon update --mode factoryre-provisions the UFS LUN geometry (the A/B layout changed it) before programming — required so the NON-HLOS modem write doesn't run off the device's old LUN boundary.Note: the in-container
particle_image_v1emit installs@particle/tachyon-imagefrom npm (PARTICLE_IMAGE_VERSION, currently 0.1.0); a published version carryingmanifest.provision(lib PR) is needed for the regenerated pimg to include provisioning.