routes¶
FastAPI route modules registered on the main app. Public reads (/apps, /health) are open; admin writes (/admin/*) are gated by a shared secret and rate-limited per IP.
apps¶
Public catalog reads. List + filter, per-slug fetch, per-source payloads, drift detection, and label generation.
- async list_apps(vendor: str | None = None, source: str | None = None, exclude_source: str | None = None, limit: int = Query(100), offset: int = Query(0), session: AsyncSession = Depends(dependency=<function get_session>, use_cache=True, scope=None)) list[App][source]¶
List apps in the catalog with optional filters and pagination.
All filters (
vendor,source,exclude_source) and thelimit/offsetpagination push down into a single SQL statement so the database does the filtering before paginating. Earlier versions of this endpoint filteredsource/exclude_sourcein Python after materializing every row that matchedvendor, which madelimitdescribe the post-fetch slice rather than the actual page size.Results are ordered by
slugso pagination is deterministic across requests.- Parameters:
vendor (str | None) – Case-insensitive exact vendor match. None disables.
source (str | None) – Include only rows whose
sourcescontains this token.exclude_source (str | None) – Drop rows whose
sourcescontains this token.limit (int) – Maximum rows to return. Default 100, max 1000.
offset (int) – Number of filtered rows to skip before returning. Default 0.
session (AsyncSession)
- Return type:
list[App]
- async list_drift(vendor: str | None = None, source: str | None = None, limit: int = Query(100), offset: int = Query(0), session: AsyncSession = Depends(dependency=<function get_session>, use_cache=True, scope=None)) DriftResponse[source]¶
Scan the catalog for cross-source version drift.
Iterates apps with at least two versioned sources, compares their reported versions, and returns those whose sources disagree on what “latest” means. Drift is computed in Python after a single SQL fetch (drift cannot be expressed as a SQL filter without materializing the JSON payloads).
total_scannedreflects the number of apps with enough sources to assess drift;total_with_driftis the unpaged count of disagreements.- Parameters:
vendor (str | None) – Case-insensitive exact vendor match. None disables.
source (str | None) – When set, drop entries where this source did not participate in the disagreement.
limit (int) – Max entries on this page. Default 100, max 1000.
offset (int) – Entries to skip before the page. Default 0.
session (AsyncSession)
- Return type:
- async get_app(slug: str, session: AsyncSession = Depends(dependency=<function get_session>, use_cache=True, scope=None)) App[source]¶
- Parameters:
slug (str)
session (AsyncSession)
- Return type:
App
- async get_app_sources(slug: str, session: AsyncSession = Depends(dependency=<function get_session>, use_cache=True, scope=None)) AppSources[source]¶
- Parameters:
slug (str)
session (AsyncSession)
- Return type:
- async get_app_drift(slug: str, session: AsyncSession = Depends(dependency=<function get_session>, use_cache=True, scope=None)) DriftEntry | None[source]¶
Drift detection for a single app.
Returns
nullwhen the app exists but has no drift (either fewer than two versioned sources, or every source agrees). 404 if the slug is unknown.- Parameters:
slug (str) – URL-friendly app identifier.
session (AsyncSession)
- Return type:
DriftEntry | None
- async generate_label(slug: str, session: AsyncSession = Depends(dependency=<function get_session>, use_cache=True, scope=None)) GenerateLabelResponse[source]¶
Generate an Installomator label for
slug.Projects the app’s Homebrew Cask + Installomator source payloads into an Installomator label fragment that consumers can drop into their Installomator deployments. Returns the label plus provenance metadata (which sources contributed) and any warnings about fields that couldn’t be resolved (most commonly
expectedTeamIDfor Cask-only apps).- Parameters:
slug (str) – URL-friendly app identifier.
session (sqlalchemy.ext.asyncio.AsyncSession) – Async SQLAlchemy session (injected).
- Raises:
HTTPException – 404 if
slugdoesn’t exist; 422 if the app has no source detail attached (rare — usually a leftover seed record).- Returns:
The generated label content + metadata.
- Return type:
admin¶
Write surface used by the macOS resolver runner to push resolved label values back into the catalog. Token-gated and fail-closed when no token is configured.
- class ResolvedIngestSummary(*, received: int, updated: int, skipped_not_ok: int, skipped_invalid: int, skipped_unknown: int, malformed_lines: int)[source]¶
Per-upload accounting returned to the runner so a CI step can assert on it.
Create a new model by parsing and validating input data from keyword arguments.
Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.
self is explicitly positional-only to allow self as a field name.
- class UnresolvedLabels(*, labels: list[str])[source]¶
Worklist the macOS runner fetches: label names that need macOS resolution.
Create a new model by parsing and validating input data from keyword arguments.
Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.
self is explicitly positional-only to allow self as a field name.
- require_admin(authorization: str | None = Header(None)) None[source]¶
Gate a route on the shared admin secret.
Fail-closed: an unset
PATCHER_API_ADMIN_TOKENreturns 503 (endpoint disabled) rather than allowing the request, so forgetting to configure the secret can never silently open the write surface.compare_digestkeeps the check constant-time.- Parameters:
authorization (str | None)
- Return type:
None
- async list_unresolved_labels(session: AsyncSession = Depends(dependency=<function get_session>, use_cache=True, scope=None)) UnresolvedLabels[source]¶
The macOS runner’s worklist: labels the Linux resolver couldn’t resolve, plus the ones macOS already owns (re-resolved each run to stay fresh).
The read half of the resolver handshake — the runner GETs this, resolves only these labels, and POSTs the results back to
/labels/resolved. This keeps each runner pass to the ~gap Linux can’t cover instead of every label.- Parameters:
session (AsyncSession)
- Return type:
- async ingest_resolved_labels(request: Request, session: AsyncSession = Depends(dependency=<function get_session>, use_cache=True, scope=None)) ResolvedIngestSummary[source]¶
Ingest macOS-resolved label values from an NDJSON stream.
Body is
application/x-ndjson: oneresolveLabel.sh --jsonrecord per line. For each record we update only thedownload_url/app_new_versionof the label the Linux ingest already created — never creating rows (the Linux ingest is the sole owner of row existence and the structural columns). The rules:ok:falserecords are skipped, so a failed vendor scrape never clobbers a previously-good value.each value must pass the same sanity check the API serves through (
looks_like_clean_http_url()/looks_like_clean_version()); a field that fails is dropped, not written.rows updated are stamped
resolution_source="macos"+resolved_atso the Linux refresh defers to them while they’re fresh.
After the batch the catalog is re-stitched (so
/appsreflects the new values) and the ETag recomputed (so caches don’t pin to the pre-upload hash).- Parameters:
request (Request)
session (AsyncSession)
- Return type: