feat(P2.2): thread project owner_id into compile scope materialization

So a scoped compile can resolve the project OWNER's per-user custom libraries
(not the requester's) — a shared/embed/anon compile of someone else's project
still finds that owner's uploaded libs.

- core/hooks.py: new get_project_owner hook; materialize_library_scope gains an
  opaque owner_id param (no-op default unchanged).
- espidf_compiler.compile/_attempt: thread owner_id to the materializer.
- compile.py: resolve owner via get_project_owner(project_id), pass to compile.

Additive: the OSS image (no overlay) ignores owner_id; index libs still resolve
from the cache. Foundation for per-user custom-lib storage (P2.2a write side).
This commit is contained in:
David Montero 2026-06-07 17:12:08 +02:00
parent 810d8e4b51
commit 6d5f6b01a4
3 changed files with 49 additions and 5 deletions

View File

@ -8,7 +8,12 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from app.core.hooks import get_current_user_id, get_project_libraries, record_compile
from app.core.hooks import (
get_current_user_id,
get_project_libraries,
get_project_owner,
record_compile,
)
from app.services.arduino_cli import ArduinoCLIService
from app.services.espidf_compiler import espidf_compiler
@ -229,12 +234,17 @@ async def _run_compile(
project_libs = await get_project_libraries(request.project_id)
if project_libs:
allowed_libraries = set(project_libs)
# P2.2 — the OWNER of the project (not the requester) owns any per-user
# custom libraries the manifest references, so resolve the owner so the
# scope materializer can find them. None for unsaved/anon compiles.
owner_id = await get_project_owner(request.project_id)
result = await espidf_compiler.compile(
files, request.board_fqbn,
progress_callback=progress_callback,
board_options=request.board_options,
spiffs_files=spiffs_dicts,
allowed_libraries=allowed_libraries,
owner_id=owner_id,
)
return CompileResponse(
success=result["success"],

View File

@ -145,7 +145,11 @@ async def get_project_libraries(project_id: Optional[str]) -> Optional[list[str]
# empty -> the compiler uses its single default libraries dir (OSS self-host
# parity / scan-all). SYNC — pure filesystem (symlink creation).
MaterializeLibraryScopeHook = Callable[[set], Optional[tuple]]
# `owner_id` is the project OWNER's id (NOT the requester's): a shared / embed /
# anonymous compile of someone else's project must resolve THAT user's custom
# (per-user-store) libraries. The overlay treats it as an opaque key; the OSS
# compiler only threads it through. None for unsaved/anon-no-project compiles.
MaterializeLibraryScopeHook = Callable[[set, Optional[str]], Optional[tuple]]
_materialize_library_scope_hook: Optional[MaterializeLibraryScopeHook] = None
@ -156,19 +160,48 @@ def register_materialize_library_scope(hook: MaterializeLibraryScopeHook) -> Non
_materialize_library_scope_hook = hook
def materialize_library_scope(allowed_libraries: Optional[set]) -> Optional[tuple]:
def materialize_library_scope(
allowed_libraries: Optional[set], owner_id: Optional[str] = None
) -> Optional[tuple]:
"""Return (libraries_dir, content_token) for the manifest, or None to use the
compiler's default single libraries dir. Never raises (a failing materializer
degrades to the default dir)."""
if _materialize_library_scope_hook is None or not allowed_libraries:
return None
try:
return _materialize_library_scope_hook(allowed_libraries)
return _materialize_library_scope_hook(allowed_libraries, owner_id)
except Exception:
logger.exception("materialize_library_scope hook failed (using default libraries dir)")
return None
# ── get_project_owner ─────────────────────────────────────────────────────────
# Resolve a project's OWNER user-id from its id (the value stored as user_id on
# the project record). Used so a compile resolves the OWNER's per-user custom
# libraries, not the requester's. Returns None for an unknown project or when no
# overlay is loaded.
GetProjectOwnerHook = Callable[[str], Awaitable[Optional[str]]]
_get_project_owner_hook: Optional[GetProjectOwnerHook] = None
def register_get_project_owner(hook: GetProjectOwnerHook) -> None:
"""Install the project-owner resolver. Called by overlays in register_pro."""
global _get_project_owner_hook
_get_project_owner_hook = hook
async def get_project_owner(project_id: Optional[str]) -> Optional[str]:
if _get_project_owner_hook is None or not project_id:
return None
try:
return await _get_project_owner_hook(project_id)
except Exception:
logger.exception("get_project_owner hook failed (treating as no owner)")
return None
# ── lifespan startup ──────────────────────────────────────────────────────────
# Overlays that need to run async setup during FastAPI lifespan (DB init,
# table creation, legacy column migrations, etc.) register a coroutine here.

View File

@ -1718,6 +1718,7 @@ class ESPIDFCompiler:
board_options: dict | None = None,
spiffs_files: list[dict] | None = None,
allowed_libraries: set[str] | None = None,
owner_id: str | None = None,
) -> dict:
"""
Compile Arduino sketch using ESP-IDF.
@ -1804,7 +1805,7 @@ class ESPIDFCompiler:
# cache update) gets its own clean build dir — and the throwaway
# scope dir is removed after the attempt (its files were already
# copied into the build's user_libs_all by _compile_in_dir).
scope = materialize_library_scope(allowed)
scope = materialize_library_scope(allowed, owner_id)
scope_dir = scope[0] if scope else None
scope_token = scope[1] if scope else ''
# Fold the effective library set + resolved content into the build-dir