feat(firmware): ELF symbol store for server-side crash decoding
Crash reports carry elf_sha256 (first 16 hex chars of the crashing build's
ELF SHA-256). ELFs are now stored under that key in
{firmware_storage_path}/elf/, uploaded either with a firmware release
(optional elf_file on POST /upload and PUT /{id}) or standalone via
POST /api/firmware/elf. The ELF is validated before the release is created.
GET /api/firmware/elf/{sha} reports whether that build's ELF exists and
whether the decoder is available; POST /api/firmware/elf/{sha}/decode runs
xtensa-esp32-elf-addr2line -pfiaC over the addresses. The decoder is looked
up via settings.addr2line_path or PATH. It is NOT in the Docker image yet,
so decoding reports "not installed" until the toolchain is added; the
console falls back to a copy-paste addr2line command.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_permission
|
||||
from firmware.models import FirmwareVersion, FirmwareListResponse, FirmwareMetadataResponse, UpdateType
|
||||
from firmware import service
|
||||
from firmware import service, elf_store
|
||||
from database.postgres import get_pg_session
|
||||
from shared.audit import log_action
|
||||
|
||||
@@ -29,10 +29,15 @@ async def upload_firmware(
|
||||
release_note: Optional[str] = Form(None),
|
||||
bespoke_uid: Optional[str] = Form(None),
|
||||
file: UploadFile = File(...),
|
||||
elf_file: Optional[UploadFile] = File(None),
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
file_bytes = await file.read()
|
||||
elf_bytes = await elf_file.read() if elf_file and elf_file.filename else None
|
||||
if elf_bytes is not None:
|
||||
# Validate before the release is created, so a wrong file doesn't leave a half-done upload.
|
||||
elf_store.validate_elf(elf_bytes)
|
||||
fw = service.upload_firmware(
|
||||
hw_type=hw_type,
|
||||
channel=channel,
|
||||
@@ -44,11 +49,48 @@ async def upload_firmware(
|
||||
release_note=release_note,
|
||||
bespoke_uid=bespoke_uid,
|
||||
)
|
||||
if elf_bytes is not None:
|
||||
elf_store.save_elf(elf_bytes, elf_file.filename, _user.name or _user.email,
|
||||
firmware={"id": fw.id, "hw_type": hw_type, "channel": channel, "version": version})
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "firmware",
|
||||
fw.id, f"{hw_type} v{version} ({channel})")
|
||||
return fw
|
||||
|
||||
|
||||
# ── Firmware ELF symbols (crash decoding, F-070) ─────────────────────────────
|
||||
# Registered before the /{hw_type}/... routes. Viewing/decoding is gated on
|
||||
# mqtt:view like the crash data itself; uploading is a firmware-release action.
|
||||
|
||||
class ElfDecodeRequest(BaseModel):
|
||||
addresses: list[int]
|
||||
|
||||
|
||||
@router.post("/elf", status_code=201)
|
||||
async def upload_elf(
|
||||
file: UploadFile = File(...),
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
||||
):
|
||||
data = await file.read()
|
||||
return elf_store.save_elf(data, file.filename, _user.name or _user.email)
|
||||
|
||||
|
||||
@router.get("/elf/{elf_sha256}")
|
||||
def get_elf_info(
|
||||
elf_sha256: str,
|
||||
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||
):
|
||||
return elf_store.get_elf_info(elf_sha256)
|
||||
|
||||
|
||||
@router.post("/elf/{elf_sha256}/decode")
|
||||
def decode_addresses(
|
||||
elf_sha256: str,
|
||||
body: ElfDecodeRequest,
|
||||
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||
):
|
||||
return {"elf_sha256": elf_sha256.lower(), "results": elf_store.decode(elf_sha256, body.addresses)}
|
||||
|
||||
|
||||
@router.get("", response_model=FirmwareListResponse)
|
||||
def list_firmware(
|
||||
hw_type: Optional[str] = Query(None),
|
||||
@@ -114,10 +156,14 @@ async def edit_firmware(
|
||||
release_note: Optional[str] = Form(None),
|
||||
bespoke_uid: Optional[str] = Form(None),
|
||||
file: Optional[UploadFile] = File(None),
|
||||
elf_file: Optional[UploadFile] = File(None),
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
file_bytes = await file.read() if file and file.filename else None
|
||||
elf_bytes = await elf_file.read() if elf_file and elf_file.filename else None
|
||||
if elf_bytes is not None:
|
||||
elf_store.validate_elf(elf_bytes)
|
||||
fw = service.edit_firmware(
|
||||
doc_id=firmware_id,
|
||||
channel=channel,
|
||||
@@ -129,6 +175,10 @@ async def edit_firmware(
|
||||
bespoke_uid=bespoke_uid,
|
||||
file_bytes=file_bytes,
|
||||
)
|
||||
if elf_bytes is not None:
|
||||
elf_store.save_elf(elf_bytes, elf_file.filename, _user.name or _user.email,
|
||||
firmware={"id": firmware_id, "hw_type": fw.hw_type if fw else None,
|
||||
"channel": fw.channel if fw else None, "version": fw.version if fw else None})
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "firmware",
|
||||
firmware_id, f"{fw.hw_type} v{fw.version} ({fw.channel})" if fw else firmware_id)
|
||||
return fw
|
||||
|
||||
Reference in New Issue
Block a user