Skip to content

Commit 526bdc1

Browse files
authored
feat(asahi): add AsahiBootChain rollback model and bootChain schema field (#26)
schemas/boot-release-set.schema.json: adds optional bootChain field to spec with type enum (asahi-m1n1-uboot-systemd-boot, uefi-systemd-boot, uefi-grub, coreboot, uboot-generic) and asahi sub-object (m1n1Version, ubootVersion, efiVarsMutable, rollbackTarget[string|null]) asahi_boot_chain.py: non-mutating model of the m1n1→U-Boot→systemd-boot chain; AsahiBootChainInfo.validate() enforces efiVarsMutable=false (mandatory on Apple Silicon); AsahiBootChain.detect_generations() reads NixOS profile symlinks; plan_rollback() returns AsahiRollbackPlan targeting most-recent previous generation; no subprocess calls, no disk writes examples/builder-aarch64-dev-release.example.json: Phase 0 fixture pointing at local Katello content server (127.0.0.1:8101) with Asahi boot chain, dev lifecycle environment, katello:SocioProphet/sourceos-builder-aarch64/dev release set ref 13 tests, all passing
1 parent bdc2239 commit 526bdc1

4 files changed

Lines changed: 534 additions & 0 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
{
2+
"apiVersion": "sourceos.dev/v1",
3+
"kind": "BootReleaseSet",
4+
"metadata": {
5+
"name": "sourceos-builder-aarch64-dev",
6+
"version": "0.1.0",
7+
"createdAt": "2026-06-16T00:00:00Z",
8+
"labels": {
9+
"surface": "local-katello",
10+
"platform": "apple-silicon",
11+
"stage": "dev",
12+
"entry-type": "live",
13+
"katello-content-view": "sourceos-builder-aarch64",
14+
"lifecycle-env": "dev"
15+
}
16+
},
17+
"spec": {
18+
"releaseSetRef": "katello:SocioProphet/sourceos-builder-aarch64/dev",
19+
"bootChain": {
20+
"type": "asahi-m1n1-uboot-systemd-boot",
21+
"asahi": {
22+
"m1n1Version": "1.4.x",
23+
"ubootVersion": "2024.01",
24+
"efiVarsMutable": false,
25+
"rollbackTarget": null
26+
}
27+
},
28+
"platforms": ["apple-silicon"],
29+
"channels": ["live"],
30+
"artifacts": [
31+
{
32+
"name": "sourceos-builder-aarch64-closure",
33+
"role": "rootfs",
34+
"uri": "http://127.0.0.1:8101/socioprophet/content/sourceos/sourceos-closures-aarch64/builder-aarch64-0.1.0.nar.zst",
35+
"sha256": "0000000000000000000000000000000000000000000000000000000000000001",
36+
"sizeBytes": 0,
37+
"contentType": "application/x-nix-nar"
38+
},
39+
{
40+
"name": "sourceos-nix-cache-index",
41+
"role": "manifest",
42+
"uri": "http://127.0.0.1:8101/socioprophet/content/sourceos/nix-cache-aarch64-linux/nix-cache-info",
43+
"sha256": "0000000000000000000000000000000000000000000000000000000000000002",
44+
"sizeBytes": 0,
45+
"contentType": "text/plain"
46+
}
47+
],
48+
"policy": {
49+
"network": "restricted",
50+
"diskWrite": "allowed",
51+
"tokenRequired": false,
52+
"allowedActions": ["fetch", "verify", "install", "rollback"]
53+
},
54+
"evidence": {
55+
"correlationId": "builder-aarch64-dev-phase0",
56+
"requiredReports": ["manifest-hash", "verification-result", "selected-channel", "install-result"]
57+
},
58+
"provenance": {
59+
"builderId": "local-katello-sourceos-builder-aarch64",
60+
"sourceRefs": [
61+
"git:SociOS-Linux/source-os#main",
62+
"katello:SocioProphet/sourceos-builder-aarch64/dev"
63+
],
64+
"attestations": ["slsa"]
65+
},
66+
"trust": {
67+
"model": "static-root",
68+
"rootRef": "local-dev-trust-root",
69+
"metadataRef": "local-dev-metadata"
70+
},
71+
"signature": {
72+
"type": "minisign",
73+
"digest": "sha256:0000000000000000000000000000000000000000000000000000000000000010"
74+
},
75+
"antiRollback": {
76+
"minimumVersion": "0.1.0",
77+
"allowOfflineFallback": true,
78+
"allowedRollbackRefs": []
79+
},
80+
"telemetry": {
81+
"traceRequired": false,
82+
"metricSet": ["boot-duration", "verify-duration", "download-bytes", "action-result"]
83+
}
84+
}
85+
}

schemas/boot-release-set.schema.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,32 @@
2525
"required": ["platforms", "channels", "artifacts", "policy", "evidence", "provenance", "trust", "signature", "antiRollback", "telemetry"],
2626
"properties": {
2727
"releaseSetRef": {"type": "string"},
28+
"bootChain": {
29+
"type": "object",
30+
"additionalProperties": false,
31+
"required": ["type"],
32+
"properties": {
33+
"type": {
34+
"enum": [
35+
"asahi-m1n1-uboot-systemd-boot",
36+
"uefi-systemd-boot",
37+
"uefi-grub",
38+
"coreboot",
39+
"uboot-generic"
40+
]
41+
},
42+
"asahi": {
43+
"type": "object",
44+
"additionalProperties": false,
45+
"properties": {
46+
"m1n1Version": {"type": "string"},
47+
"ubootVersion": {"type": "string"},
48+
"efiVarsMutable": {"type": "boolean"},
49+
"rollbackTarget": {"type": ["string", "null"], "description": "NixOS store path of rollback generation; null when no prior generation exists"}
50+
}
51+
}
52+
}
53+
},
2854
"platforms": {"type": "array", "items": {"enum": ["apple-silicon", "uefi-x86_64", "uefi-aarch64", "generic-arm64"]}, "minItems": 1, "uniqueItems": true},
2955
"channels": {"type": "array", "items": {"enum": ["live", "installer", "recovery", "rollback", "rescue"]}, "minItems": 1, "uniqueItems": true},
3056
"artifacts": {"type": "array", "minItems": 1, "items": {"type": "object", "additionalProperties": false, "required": ["name", "role", "uri", "sha256"], "properties": {"name": {"type": "string"}, "role": {"enum": ["kernel", "initrd", "rootfs", "manifest", "bootloader", "recovery-image", "installer-data", "signature", "attestation", "tuf-metadata", "other"]}, "uri": {"type": "string", "format": "uri"}, "sha256": {"type": "string", "pattern": "^[a-fA-F0-9]{64}$"}, "sizeBytes": {"type": "integer", "minimum": 0}, "contentType": {"type": "string"}}}},
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
"""Asahi Linux boot chain model for sourceos-boot.
2+
3+
Models the m1n1 → U-Boot → systemd-boot chain specific to Apple Silicon
4+
devices running Asahi Linux. Provides a non-mutating rollback plan that
5+
describes what a future executor would need to do to return to the previous
6+
NixOS generation.
7+
8+
Boundary invariant: no disk writes, no EFI var mutations, no kexec, no
9+
subprocess calls that modify state. plan_rollback() is pure.
10+
11+
Key Asahi constraint: efiVarsMutable MUST be false. NixOS systemd-boot
12+
integration must be configured with canTouchEfiVariables = false or boot
13+
entries may conflict with macOS's EFI namespace.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import os
19+
from dataclasses import dataclass
20+
from typing import Any
21+
22+
BOOT_CHAIN_TYPE = "asahi-m1n1-uboot-systemd-boot"
23+
ASAHI_BOOT_SCHEMA = "sourceos.asahi-boot-chain/v0.1"
24+
25+
# The NixOS generations directory on a standard NixOS install
26+
NIX_PROFILES_SYSTEM = "/nix/var/nix/profiles"
27+
CURRENT_SYSTEM_LINK = "/run/current-system"
28+
SYSTEM_PROFILE = "system"
29+
30+
31+
@dataclass(frozen=True)
32+
class AsahiBootChainInfo:
33+
"""Static description of the Asahi boot chain for provenance records."""
34+
35+
chain_type: str
36+
m1n1_version: str | None
37+
uboot_version: str | None
38+
efi_vars_mutable: bool = False
39+
40+
def to_dict(self) -> dict[str, Any]:
41+
return {
42+
"type": self.chain_type,
43+
"m1n1Version": self.m1n1_version,
44+
"ubootVersion": self.uboot_version,
45+
"efiVarsMutable": self.efi_vars_mutable,
46+
}
47+
48+
def validate(self) -> list[str]:
49+
"""Return a list of invariant violations. Empty list = valid."""
50+
issues = []
51+
if self.efi_vars_mutable:
52+
issues.append(
53+
"efiVarsMutable must be false on Apple Silicon — "
54+
"set boot.loader.efi.canTouchEfiVariables = false in NixOS config"
55+
)
56+
if self.chain_type != BOOT_CHAIN_TYPE:
57+
issues.append(f"unexpected chain_type {self.chain_type!r}; expected {BOOT_CHAIN_TYPE!r}")
58+
return issues
59+
60+
61+
@dataclass(frozen=True)
62+
class NixOSGeneration:
63+
number: int
64+
store_path: str
65+
is_current: bool = False
66+
67+
def to_dict(self) -> dict[str, Any]:
68+
return {
69+
"number": self.number,
70+
"store_path": self.store_path,
71+
"is_current": self.is_current,
72+
}
73+
74+
75+
@dataclass(frozen=True)
76+
class AsahiRollbackPlan:
77+
"""Non-mutating rollback plan for an Asahi-booted NixOS device."""
78+
79+
schema: str
80+
chain: AsahiBootChainInfo
81+
current_generation: NixOSGeneration | None
82+
rollback_target: NixOSGeneration | None
83+
policy_gate: str
84+
policy_reason: str
85+
steps: list[str]
86+
87+
def to_dict(self) -> dict[str, Any]:
88+
return {
89+
"schema": self.schema,
90+
"chain": self.chain.to_dict(),
91+
"current_generation": self.current_generation.to_dict() if self.current_generation else None,
92+
"rollback_target": self.rollback_target.to_dict() if self.rollback_target else None,
93+
"policy_gate": self.policy_gate,
94+
"policy_reason": self.policy_reason,
95+
"steps": self.steps,
96+
}
97+
98+
@property
99+
def allowed(self) -> bool:
100+
return self.policy_gate == "allowed"
101+
102+
103+
class AsahiBootChain:
104+
"""Models the Asahi Linux boot chain and provides rollback planning.
105+
106+
Reads the NixOS profile symlink tree to detect current and previous
107+
generations. All reads are from /nix/var/nix/profiles and /run —
108+
no writes, no subprocess calls.
109+
"""
110+
111+
def __init__(
112+
self,
113+
chain_info: AsahiBootChainInfo | None = None,
114+
profiles_root: str = NIX_PROFILES_SYSTEM,
115+
current_link: str = CURRENT_SYSTEM_LINK,
116+
) -> None:
117+
self._chain = chain_info or AsahiBootChainInfo(
118+
chain_type=BOOT_CHAIN_TYPE,
119+
m1n1_version=None,
120+
uboot_version=None,
121+
efi_vars_mutable=False,
122+
)
123+
self._profiles_root = profiles_root
124+
self._current_link = current_link
125+
126+
def detect_generations(self) -> list[NixOSGeneration]:
127+
"""Read NixOS system profile symlinks and return all known generations.
128+
129+
Returns an empty list if the profiles directory doesn't exist (e.g.
130+
running on macOS for testing).
131+
"""
132+
generations = []
133+
system_profile_dir = os.path.join(self._profiles_root, SYSTEM_PROFILE)
134+
135+
if not os.path.isdir(system_profile_dir):
136+
return generations
137+
138+
current_path = None
139+
if os.path.islink(self._current_link):
140+
try:
141+
current_path = os.path.realpath(self._current_link)
142+
except OSError:
143+
pass
144+
145+
for entry in sorted(os.listdir(system_profile_dir)):
146+
# NixOS generation symlinks are named system-<N>-link
147+
if not entry.startswith(SYSTEM_PROFILE + "-") or not entry.endswith("-link"):
148+
continue
149+
try:
150+
num_str = entry[len(SYSTEM_PROFILE) + 1 : -len("-link")]
151+
gen_num = int(num_str)
152+
except ValueError:
153+
continue
154+
link_path = os.path.join(system_profile_dir, entry)
155+
try:
156+
store_path = os.path.realpath(link_path)
157+
except OSError:
158+
continue
159+
is_current = current_path is not None and store_path == current_path
160+
generations.append(NixOSGeneration(
161+
number=gen_num,
162+
store_path=store_path,
163+
is_current=is_current,
164+
))
165+
166+
return sorted(generations, key=lambda g: g.number)
167+
168+
def plan_rollback(self) -> AsahiRollbackPlan:
169+
"""Return a non-mutating rollback plan. No writes performed."""
170+
171+
violations = self._chain.validate()
172+
if violations:
173+
return AsahiRollbackPlan(
174+
schema=ASAHI_BOOT_SCHEMA,
175+
chain=self._chain,
176+
current_generation=None,
177+
rollback_target=None,
178+
policy_gate="denied",
179+
policy_reason="; ".join(violations),
180+
steps=[],
181+
)
182+
183+
generations = self.detect_generations()
184+
current = next((g for g in generations if g.is_current), None)
185+
186+
if not generations:
187+
# Running outside of a NixOS device (e.g. CI on macOS) —
188+
# emit a plan that describes the intent without real paths
189+
return AsahiRollbackPlan(
190+
schema=ASAHI_BOOT_SCHEMA,
191+
chain=self._chain,
192+
current_generation=None,
193+
rollback_target=None,
194+
policy_gate="allowed",
195+
policy_reason="no NixOS generations detected — rollback command shown for reference",
196+
steps=["nixos-rebuild switch --rollback"],
197+
)
198+
199+
prev_gens = [g for g in generations if not g.is_current]
200+
rollback_target = prev_gens[-1] if prev_gens else None
201+
202+
if rollback_target is None:
203+
return AsahiRollbackPlan(
204+
schema=ASAHI_BOOT_SCHEMA,
205+
chain=self._chain,
206+
current_generation=current,
207+
rollback_target=None,
208+
policy_gate="denied",
209+
policy_reason="no previous generation available to roll back to",
210+
steps=[],
211+
)
212+
213+
steps = [
214+
f"nixos-rebuild switch --rollback",
215+
f"# rolls back to generation {rollback_target.number}: {rollback_target.store_path}",
216+
f"# efiVarsMutable=false enforced — boot entry managed by systemd-boot, not EFI vars",
217+
]
218+
219+
return AsahiRollbackPlan(
220+
schema=ASAHI_BOOT_SCHEMA,
221+
chain=self._chain,
222+
current_generation=current,
223+
rollback_target=rollback_target,
224+
policy_gate="allowed",
225+
policy_reason=f"rollback from generation {current.number if current else '?'} "
226+
f"to generation {rollback_target.number} via nixos-rebuild --rollback",
227+
steps=steps,
228+
)

0 commit comments

Comments
 (0)