* feat(initial-workspace): per-instance agnes init override Adds Initial Workspace Template — an admin-configurable per-instance override for the agnes init analyst workspace. When configured, agnes init downloads a server-rendered zip from a Git repo the admin registered and extracts it into the analyst's workspace, fully bypassing Agnes-default CLAUDE.md / settings.json / hooks / slash commands / AGNES_WORKSPACE.md. Repo layout convention: only the contents of a top-level `workspace/` subdirectory ship to analysts; admin docs (README, CI configs) at the repo root stay in the repo and never reach an analyst. Sync rejects repos without `workspace/` at root. Server side: - src/initial_workspace.py — clone (or fetch+reset), validate, build zip with strict path checks and reserved-path rejection (workspace/.claude/init-complete reserved by Agnes) - app/api/initial_workspace.py — admin CRUD + sync endpoint + analyst- facing status/zip/applied endpoints; config persists to instance.yaml overlay, PAT to .env_overlay - app/secrets.py — refactor: persist_overlay_token shared helper with threading.Lock for .env_overlay writes (closes pre-existing race between concurrent marketplaces saves) - app/web/templates/admin_server_config.html — new "Initial Workspace Template" section + modal + Sync/Edit/Delete/Download buttons (matches existing cfg-section visual language) CLI side: - cli/lib/override.py — single source of truth for is_override_workspace sentinel detection - cli/lib/initial_workspace.py — probe status, safe zip extraction with ../absolute/symlink rejection, typed-YES force confirmation - cli/commands/init.py — override branch (skips Agnes-default workspace writes); extended sentinel with override:true, template_source, template_sha so future agnes self-upgrade does not auto-refresh hooks - cli/lib/hooks.py + cli/lib/commands.py — short-circuit on override workspaces (install_claude_hooks, install_claude_commands, maybe_refresh_claude_hooks) Audit-event strategy: server writes initial_workspace.fetch_started inside GET /api/initial-workspace.zip (cannot be spoofed by PAT-holder); CLI POST /applied writes initial_workspace.applied as best-effort confirmation. Admin mutations log via the existing _audit pattern. Tests: 27 server (clone/validate/zip + workspace-subdir convention + concurrent persist_overlay_token + endpoint shapes + audit rows) + 29 CLI (override sentinel parse + probe fall-through + safe extraction + YES strictness + hook guards + e2e mocked init). Risk acceptance — documented in docs/initial-workspace-override.md + CHANGELOG Internal section so AI reviewers understand the deviations from defaults are intentional: - maybe_refresh_claude_hooks deliberately no-ops on override workspaces - --force on override does NOT back up CLAUDE.md (admin's repo is the source of truth) - .claude/CLAUDE.local.md IS overwritten by override extraction when admin's repo ships one * test+vendor-agnostic: drop Groupon tokens from #292 fixtures + extend admin-gate coverage Two fixes from the takeover review on #292: 1. **Vendor-agnostic OSS rule**: Replace `Groupon` / `groupon/template` tokens in test fixtures with `Acme` / `acme/template` (8 sites in test_cli_init_override.py + 1 in test_initial_workspace_api.py). Per CLAUDE.md "Vendor-agnostic OSS — no customer-specific content" rule: customer-specific tokens don't belong in shipped artifacts, even in test fixtures. The pre-existing FoundryAI mentions in test_instance_config.py + test_setup_instructions.py are out of scope for this PR (didn't introduce them). 2. **Admin-gate coverage gap**: `test_admin_endpoints_require_admin` only covered GET /api/admin/initial-workspace + POST .../sync. The register-write (POST .../initial-workspace) and delete (DELETE .../initial-workspace) endpoints used the same `Depends(require_admin)` wiring but had no regression test. Loop now covers all 4 verbs so a future refactor that drops the dependency from one endpoint fails here instead of silently exposing the write/delete paths to any analyst with a PAT. * release: 0.54.9 — Initial Workspace Template (per-instance agnes init override) Last commit on the PR per CLAUDE.md hard rule. Patch bump (0.54.8 → 0.54.9) for Mina's Initial Workspace Template feature. No DB migration (config lives in instance.yaml overlay). No mandatory operator action — empty default keeps OSS-default agnes init behavior. Operators wanting full template control link a Git repo on /admin/server-config → "Initial Workspace Template". See docs/initial-workspace-override.md for the full responsibility-transfer contract. --------- Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com> Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
684 lines
25 KiB
Python
684 lines
25 KiB
Python
"""Admin endpoints for marketplace git repositories.
|
||
|
||
CRUD + on-demand "Sync now" mirroring the /api/users shape. Tokens supplied
|
||
through the admin UI are persisted to data/state/.env_overlay (same pattern
|
||
as /api/admin/configure for Keboola/BigQuery) — never stored in the DB.
|
||
"""
|
||
|
||
import logging
|
||
import os
|
||
import re
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, List, Optional
|
||
|
||
import duckdb
|
||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||
from pydantic import BaseModel
|
||
|
||
from app.auth.access import require_admin
|
||
from app.auth.dependencies import _get_db
|
||
from app.resource_types import ResourceType
|
||
from app.secrets import persist_overlay_token
|
||
from src.marketplace import (
|
||
MarketplaceNotFound,
|
||
delete_marketplace_dir,
|
||
is_valid_slug,
|
||
sync_marketplaces,
|
||
sync_one,
|
||
)
|
||
from src.repositories.audit import AuditRepository
|
||
from src.repositories.marketplace_plugins import MarketplacePluginsRepository
|
||
from src.repositories.marketplace_registry import MarketplaceRegistryRepository
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter(prefix="/api/marketplaces", tags=["marketplaces"])
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Audit helper — same shape as app/api/users.py::_audit
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _audit(
|
||
conn: duckdb.DuckDBPyConnection,
|
||
actor_id: str,
|
||
action: str,
|
||
target_id: str,
|
||
params: Optional[dict] = None,
|
||
) -> None:
|
||
try:
|
||
safe_params = None
|
||
if params:
|
||
safe_params = {}
|
||
for k, v in params.items():
|
||
if isinstance(v, datetime):
|
||
safe_params[k] = v.isoformat()
|
||
else:
|
||
safe_params[k] = v
|
||
AuditRepository(conn).log(
|
||
user_id=actor_id,
|
||
action=action,
|
||
resource=f"marketplace:{target_id}",
|
||
params=safe_params,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Pydantic models
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class CreateMarketplaceRequest(BaseModel):
|
||
name: str
|
||
slug: str
|
||
url: str
|
||
branch: Optional[str] = None
|
||
description: Optional[str] = None
|
||
token: Optional[str] = None
|
||
# v32: required at create time. Surfaced on /marketplace cards + plugin
|
||
# detail in place of the historical owner_todo placeholder. The plan
|
||
# decision was to validate at the application layer (no DB NOT NULL)
|
||
# so existing rows survive the schema migration without forcing all
|
||
# admins to refill before the next request lands.
|
||
curator_name: Optional[str] = None
|
||
curator_email: Optional[str] = None
|
||
|
||
|
||
class UpdateMarketplaceRequest(BaseModel):
|
||
name: Optional[str] = None
|
||
url: Optional[str] = None
|
||
branch: Optional[str] = None
|
||
description: Optional[str] = None
|
||
# None = leave untouched; empty string = clear token; non-empty = rotate
|
||
token: Optional[str] = None
|
||
# Either field None = leave untouched; non-empty string = update.
|
||
# Empty string is treated the same as None on update so admins can't
|
||
# accidentally null out a curator by submitting an empty form input.
|
||
curator_name: Optional[str] = None
|
||
curator_email: Optional[str] = None
|
||
|
||
|
||
class MarketplaceResponse(BaseModel):
|
||
id: str
|
||
name: str
|
||
url: str
|
||
branch: Optional[str] = None
|
||
description: Optional[str] = None
|
||
registered_by: Optional[str] = None
|
||
registered_at: Optional[str] = None
|
||
last_synced_at: Optional[str] = None
|
||
last_commit_sha: Optional[str] = None
|
||
last_error: Optional[str] = None
|
||
has_token: bool = False
|
||
plugin_count: int = 0
|
||
curator_name: Optional[str] = None
|
||
curator_email: Optional[str] = None
|
||
|
||
|
||
# Liberal email regex — RFC 5322 is too permissive to be useful at the
|
||
# admin-form layer. Anchored, requires `local@domain.tld`, no whitespace,
|
||
# 1-254 chars total. Matches what /admin UI expects an admin to type.
|
||
_EMAIL_RE = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$")
|
||
|
||
|
||
def _to_response(row: dict, plugin_count: int = 0) -> MarketplaceResponse:
|
||
token_env = row.get("token_env") or ""
|
||
has_token = bool(token_env) and bool(os.environ.get(token_env, ""))
|
||
return MarketplaceResponse(
|
||
id=row["id"],
|
||
name=row["name"],
|
||
url=row["url"],
|
||
branch=row.get("branch"),
|
||
description=row.get("description"),
|
||
registered_by=row.get("registered_by"),
|
||
registered_at=str(row["registered_at"]) if row.get("registered_at") else None,
|
||
last_synced_at=str(row["last_synced_at"]) if row.get("last_synced_at") else None,
|
||
last_commit_sha=row.get("last_commit_sha"),
|
||
last_error=row.get("last_error"),
|
||
has_token=has_token,
|
||
plugin_count=plugin_count,
|
||
curator_name=row.get("curator_name"),
|
||
curator_email=row.get("curator_email"),
|
||
)
|
||
|
||
|
||
class PluginResponse(BaseModel):
|
||
name: str
|
||
description: Optional[str] = None
|
||
version: Optional[str] = None
|
||
author_name: Optional[str] = None
|
||
homepage: Optional[str] = None
|
||
category: Optional[str] = None
|
||
source_type: Optional[str] = None
|
||
source_spec: Optional[Any] = None
|
||
# v39: surfaced so the admin Details modal renders the SYSTEM pill
|
||
# + flips the "Mark as system" / "Unmark system" toggle button.
|
||
is_system: bool = False
|
||
|
||
|
||
class SystemFlagResponse(BaseModel):
|
||
"""Return shape of the mark/unmark_system endpoints."""
|
||
|
||
marketplace_id: str
|
||
plugin_name: str
|
||
is_system: bool
|
||
affected_groups: int = 0
|
||
affected_users: int = 0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Token env-var naming
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# Read-modify-write of `.env_overlay` lives in `app.secrets.persist_overlay_token`
|
||
# (single shared helper with a process-wide lock). Multiple admins clicking
|
||
# Save in /admin/marketplaces + /admin/server-config concurrently must not
|
||
# corrupt the overlay file.
|
||
|
||
|
||
def _token_env_name(slug: str) -> str:
|
||
"""Derive a conventional env-var name from a slug.
|
||
|
||
"foundry-ai" -> "AGNES_MARKETPLACE_FOUNDRY_AI_TOKEN"
|
||
"""
|
||
normalized = slug.upper().replace("-", "_")
|
||
return f"AGNES_MARKETPLACE_{normalized}_TOKEN"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Endpoints
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.get("", response_model=List[MarketplaceResponse])
|
||
async def list_marketplaces(
|
||
user: dict = Depends(require_admin),
|
||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||
):
|
||
counts = MarketplacePluginsRepository(conn).count_by_marketplace()
|
||
return [
|
||
_to_response(row, counts.get(row["id"], 0))
|
||
for row in MarketplaceRegistryRepository(conn).list_all()
|
||
]
|
||
|
||
|
||
@router.get("/{marketplace_id}/plugins", response_model=List[PluginResponse])
|
||
async def list_plugins(
|
||
marketplace_id: str,
|
||
user: dict = Depends(require_admin),
|
||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||
):
|
||
"""Return the cached plugin list for a marketplace.
|
||
|
||
Rows come from `marketplace_plugins`, which is refreshed from
|
||
`.claude-plugin/marketplace.json` on every successful sync. An
|
||
unsynced marketplace will return an empty list.
|
||
"""
|
||
if not MarketplaceRegistryRepository(conn).get(marketplace_id):
|
||
raise HTTPException(status_code=404, detail="marketplace not found")
|
||
rows = MarketplacePluginsRepository(conn).list_for_marketplace(marketplace_id)
|
||
return [
|
||
PluginResponse(
|
||
name=r["name"],
|
||
description=r.get("description"),
|
||
version=r.get("version"),
|
||
author_name=r.get("author_name"),
|
||
homepage=r.get("homepage"),
|
||
category=r.get("category"),
|
||
source_type=r.get("source_type"),
|
||
source_spec=r.get("source_spec"),
|
||
is_system=bool(r.get("is_system")),
|
||
)
|
||
for r in rows
|
||
]
|
||
|
||
|
||
@router.post("", response_model=MarketplaceResponse, status_code=201)
|
||
async def create_marketplace(
|
||
payload: CreateMarketplaceRequest,
|
||
request: Request,
|
||
user: dict = Depends(require_admin),
|
||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||
):
|
||
slug = (payload.slug or "").strip().lower()
|
||
if not is_valid_slug(slug):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="slug must match [a-z0-9][a-z0-9_-]{0,63} (1-64 chars, start with alnum)",
|
||
)
|
||
if not (payload.url or "").strip().lower().startswith("https://"):
|
||
raise HTTPException(status_code=400, detail="url must start with https://")
|
||
if not (payload.name or "").strip():
|
||
raise HTTPException(status_code=400, detail="name is required")
|
||
|
||
# v32: curator is mandatory at create time. Validation lives here (not in
|
||
# DB schema) so legacy rows that pre-date the column survive — admins
|
||
# patch them via the edit modal at their leisure.
|
||
curator_name = (payload.curator_name or "").strip()
|
||
curator_email = (payload.curator_email or "").strip()
|
||
if not curator_name:
|
||
raise HTTPException(
|
||
status_code=400, detail="curator_name is required",
|
||
)
|
||
if not curator_email:
|
||
raise HTTPException(
|
||
status_code=400, detail="curator_email is required",
|
||
)
|
||
if not _EMAIL_RE.match(curator_email):
|
||
raise HTTPException(
|
||
status_code=400, detail="curator_email is not a valid email address",
|
||
)
|
||
|
||
repo = MarketplaceRegistryRepository(conn)
|
||
if repo.get(slug):
|
||
raise HTTPException(status_code=409, detail=f"marketplace '{slug}' already exists")
|
||
|
||
token_env: Optional[str] = None
|
||
if payload.token:
|
||
token_env = _token_env_name(slug)
|
||
persist_overlay_token(token_env, payload.token)
|
||
|
||
repo.register(
|
||
id=slug,
|
||
name=payload.name.strip(),
|
||
url=payload.url.strip(),
|
||
branch=(payload.branch or "").strip() or None,
|
||
token_env=token_env,
|
||
description=payload.description,
|
||
registered_by=user.get("email"),
|
||
curator_name=curator_name,
|
||
curator_email=curator_email,
|
||
)
|
||
_audit(
|
||
conn,
|
||
user["id"],
|
||
"marketplace.create",
|
||
slug,
|
||
{"url": payload.url, "has_token": bool(payload.token)},
|
||
)
|
||
return _to_response(repo.get(slug))
|
||
|
||
|
||
@router.patch("/{marketplace_id}", response_model=MarketplaceResponse)
|
||
async def update_marketplace(
|
||
marketplace_id: str,
|
||
payload: UpdateMarketplaceRequest,
|
||
user: dict = Depends(require_admin),
|
||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||
):
|
||
repo = MarketplaceRegistryRepository(conn)
|
||
existing = repo.get(marketplace_id)
|
||
if not existing:
|
||
raise HTTPException(status_code=404, detail="marketplace not found")
|
||
|
||
# Start with the existing row; override fields the caller provided.
|
||
updated = {
|
||
"id": existing["id"],
|
||
"name": existing["name"],
|
||
"url": existing["url"],
|
||
"branch": existing.get("branch"),
|
||
"token_env": existing.get("token_env"),
|
||
"description": existing.get("description"),
|
||
"registered_by": existing.get("registered_by"),
|
||
"curator_name": existing.get("curator_name"),
|
||
"curator_email": existing.get("curator_email"),
|
||
}
|
||
changed: dict = {}
|
||
if payload.name is not None:
|
||
if not payload.name.strip():
|
||
raise HTTPException(status_code=400, detail="name cannot be empty")
|
||
updated["name"] = payload.name.strip()
|
||
changed["name"] = updated["name"]
|
||
if payload.url is not None:
|
||
url = payload.url.strip()
|
||
if not url.lower().startswith("https://"):
|
||
raise HTTPException(status_code=400, detail="url must start with https://")
|
||
updated["url"] = url
|
||
changed["url"] = url
|
||
if payload.branch is not None:
|
||
updated["branch"] = payload.branch.strip() or None
|
||
changed["branch"] = updated["branch"]
|
||
if payload.description is not None:
|
||
updated["description"] = payload.description
|
||
changed["description"] = payload.description
|
||
|
||
# Curator fields: empty-string treated as "no change" so an admin
|
||
# editing only the URL doesn't accidentally null out curator metadata.
|
||
if payload.curator_name is not None and payload.curator_name.strip():
|
||
updated["curator_name"] = payload.curator_name.strip()
|
||
changed["curator_name"] = updated["curator_name"]
|
||
if payload.curator_email is not None and payload.curator_email.strip():
|
||
if not _EMAIL_RE.match(payload.curator_email.strip()):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="curator_email is not a valid email address",
|
||
)
|
||
updated["curator_email"] = payload.curator_email.strip()
|
||
changed["curator_email"] = updated["curator_email"]
|
||
|
||
if payload.token is not None:
|
||
# None = untouched; "" = clear token_env binding; non-empty = rotate.
|
||
if payload.token == "":
|
||
if updated["token_env"]:
|
||
persist_overlay_token(updated["token_env"], "")
|
||
updated["token_env"] = None
|
||
changed["token"] = "cleared"
|
||
else:
|
||
env_name = _token_env_name(marketplace_id)
|
||
persist_overlay_token(env_name, payload.token)
|
||
updated["token_env"] = env_name
|
||
changed["token"] = "rotated"
|
||
|
||
# Mandatory curator on UPDATE too — legacy rows that pre-date v32 have
|
||
# NULL curator and survive the migration, but the moment an admin opens
|
||
# the edit modal they must fill the gap. The previous PATCH flow let
|
||
# URL/description tweaks persist indefinitely with OWNER_TODO_PLACEHOLDER
|
||
# showing on every /marketplace card. The DB column itself stays nullable
|
||
# so untouched legacy rows are not broken.
|
||
if not (updated.get("curator_name") or "").strip():
|
||
raise HTTPException(
|
||
status_code=400, detail="curator_name is required",
|
||
)
|
||
if not (updated.get("curator_email") or "").strip():
|
||
raise HTTPException(
|
||
status_code=400, detail="curator_email is required",
|
||
)
|
||
|
||
repo.register(
|
||
id=updated["id"],
|
||
name=updated["name"],
|
||
url=updated["url"],
|
||
branch=updated["branch"],
|
||
token_env=updated["token_env"],
|
||
description=updated["description"],
|
||
registered_by=updated["registered_by"],
|
||
curator_name=updated["curator_name"],
|
||
curator_email=updated["curator_email"],
|
||
)
|
||
_audit(conn, user["id"], "marketplace.update", marketplace_id, changed)
|
||
counts = MarketplacePluginsRepository(conn).count_by_marketplace()
|
||
return _to_response(repo.get(marketplace_id), counts.get(marketplace_id, 0))
|
||
|
||
|
||
@router.delete("/{marketplace_id}", status_code=204)
|
||
async def delete_marketplace(
|
||
marketplace_id: str,
|
||
purge: bool = False,
|
||
user: dict = Depends(require_admin),
|
||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||
):
|
||
repo = MarketplaceRegistryRepository(conn)
|
||
existing = repo.get(marketplace_id)
|
||
if not existing:
|
||
raise HTTPException(status_code=404, detail="marketplace not found")
|
||
|
||
# Also clear any overlay token binding so a re-created marketplace of the
|
||
# same slug doesn't accidentally inherit the old PAT.
|
||
if existing.get("token_env"):
|
||
persist_overlay_token(existing["token_env"], "")
|
||
|
||
repo.unregister(marketplace_id)
|
||
# Drop cached plugin rows and any resource grants that reference plugins
|
||
# from this marketplace. resource_grants stores resource_id as
|
||
# "<marketplace_slug>/<plugin_name>" — match the slash-prefix via
|
||
# starts_with(), not LIKE: marketplace slugs may contain '_' (validated
|
||
# by [a-z0-9][a-z0-9_-]{0,63}) and LIKE would interpret it as a
|
||
# single-char wildcard, silently dropping grants from sibling
|
||
# marketplaces whose slug differs by exactly one character.
|
||
try:
|
||
conn.execute(
|
||
"DELETE FROM marketplace_plugins WHERE marketplace_id = ?",
|
||
[marketplace_id],
|
||
)
|
||
conn.execute(
|
||
"DELETE FROM resource_grants "
|
||
"WHERE resource_type = ? AND starts_with(resource_id, ? || '/')",
|
||
[ResourceType.MARKETPLACE_PLUGIN.value, marketplace_id],
|
||
)
|
||
# Drop user subscriptions to plugins from this marketplace so a
|
||
# re-registered slug doesn't inherit stale subscribe state.
|
||
from src.repositories.user_curated_subscriptions import (
|
||
UserCuratedSubscriptionsRepository,
|
||
)
|
||
UserCuratedSubscriptionsRepository(conn).delete_for_marketplace(
|
||
marketplace_id
|
||
)
|
||
except Exception as e:
|
||
logger.warning("cleanup for marketplace %s failed: %s", marketplace_id, e)
|
||
purged = False
|
||
if purge:
|
||
try:
|
||
purged = delete_marketplace_dir(marketplace_id)
|
||
except Exception as e:
|
||
logger.warning("delete_marketplace_dir(%s) failed: %s", marketplace_id, e)
|
||
|
||
_audit(
|
||
conn,
|
||
user["id"],
|
||
"marketplace.delete",
|
||
marketplace_id,
|
||
{"purged_disk": purged},
|
||
)
|
||
|
||
|
||
@router.post("/{marketplace_id}/sync")
|
||
async def trigger_sync(
|
||
marketplace_id: str,
|
||
user: dict = Depends(require_admin),
|
||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||
):
|
||
try:
|
||
result = sync_one(marketplace_id)
|
||
except MarketplaceNotFound:
|
||
raise HTTPException(status_code=404, detail="marketplace not found")
|
||
except (RuntimeError, ValueError) as e:
|
||
_audit(conn, user["id"], "marketplace.sync_failed", marketplace_id, {"error": str(e)})
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
_audit(
|
||
conn,
|
||
user["id"],
|
||
"marketplace.sync",
|
||
marketplace_id,
|
||
{"commit": result["commit"], "action": result["action"]},
|
||
)
|
||
return result
|
||
|
||
|
||
@router.post("/sync-all")
|
||
def trigger_sync_all(
|
||
user: dict = Depends(require_admin),
|
||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||
):
|
||
"""Sync every registered marketplace.
|
||
|
||
Wired up so the scheduler service can drive the nightly refresh over
|
||
HTTP. The previous implementation called ``src.marketplace.sync_marketplaces``
|
||
in-process from the scheduler container, which conflicted with the app's
|
||
long-lived ``system.duckdb`` handle (DuckDB allows only one writer per
|
||
file across processes). Routing through the app inherits the existing
|
||
connection without contention.
|
||
|
||
Declared ``def`` (not ``async def``) so FastAPI runs it in a thread
|
||
pool — :func:`sync_marketplaces` does blocking I/O (subprocess git
|
||
clones with ``GIT_TIMEOUT_SEC=300`` per repo, DuckDB writes, a
|
||
process-wide threading.Lock) and would freeze the event loop for the
|
||
duration of a bulk sync if it ran on the asyncio thread. Health
|
||
checks, login redirects, and every other concurrent request keep
|
||
serving while the bulk sync churns through the registry.
|
||
|
||
One audit row per call summarises the outcome — per-marketplace details
|
||
live in ``marketplace_registry`` and the per-call result payload below.
|
||
"""
|
||
result = sync_marketplaces()
|
||
# _audit appends "marketplace:" to the target id when writing the
|
||
# resource column. "_all" produces "marketplace:_all" — a stable,
|
||
# greppable sentinel for bulk-sync rows; the real per-marketplace
|
||
# commit/error breakdown is in the params payload.
|
||
_audit(
|
||
conn,
|
||
user["id"],
|
||
"marketplace.sync_all",
|
||
"_all",
|
||
{
|
||
"synced": [r.get("id") for r in result.get("synced", [])],
|
||
"errors": [{"id": e.get("id"), "error": e.get("error")} for e in result.get("errors", [])],
|
||
},
|
||
)
|
||
return result
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# v39: system plugin mark / unmark
|
||
#
|
||
# A plugin marked as "system" is materialized into:
|
||
# * resource_grants — one row per existing user_groups row
|
||
# * user_plugin_optouts — one row per existing users row
|
||
# so the resolver's existing (rbac ∩ subscriptions) computation naturally
|
||
# includes it for every user. The UI then locks the corresponding controls
|
||
# (admin can't revoke per-group, user can't unsubscribe). Unmark flips the
|
||
# flag only — materialized rows survive so unmark cannot accidentally rip
|
||
# the plugin out of every user's stack mid-day; admin curates the cleanup
|
||
# afterwards via the standard resource_grants UI.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _invalidate_marketplace_etag() -> None:
|
||
"""Drop the served-marketplace ETag cache so the next ZIP / git fetch
|
||
rehashes against the post-mark/post-unmark RBAC view. Best-effort —
|
||
the cache layer survives an import error during early startup."""
|
||
try:
|
||
from app.marketplace_server import packager
|
||
packager.invalidate_etag_cache()
|
||
except Exception:
|
||
logger.exception("failed to invalidate marketplace etag cache")
|
||
|
||
|
||
@router.post(
|
||
"/{marketplace_id}/plugins/{plugin_name}/system",
|
||
response_model=SystemFlagResponse,
|
||
)
|
||
def mark_plugin_system(
|
||
marketplace_id: str,
|
||
plugin_name: str,
|
||
user: dict = Depends(require_admin),
|
||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||
):
|
||
"""Mark a plugin as system (mandatory for every user).
|
||
|
||
Idempotent — re-running a mark on an already-system plugin still
|
||
runs the fanout (cheap; ON CONFLICT DO NOTHING) so any user/group
|
||
that slipped past the creation hooks gets caught up.
|
||
"""
|
||
from src.repositories.resource_grants import ResourceGrantsRepository
|
||
from src.repositories.user_curated_subscriptions import (
|
||
UserCuratedSubscriptionsRepository,
|
||
)
|
||
|
||
plugin_row = conn.execute(
|
||
"SELECT 1 FROM marketplace_plugins WHERE marketplace_id = ? AND name = ?",
|
||
[marketplace_id, plugin_name],
|
||
).fetchone()
|
||
if not plugin_row:
|
||
raise HTTPException(status_code=404, detail="plugin not found")
|
||
|
||
resource_id = f"{marketplace_id}/{plugin_name}"
|
||
affected_groups = 0
|
||
affected_users = 0
|
||
|
||
conn.execute(
|
||
"UPDATE marketplace_plugins SET is_system = TRUE "
|
||
"WHERE marketplace_id = ? AND name = ?",
|
||
[marketplace_id, plugin_name],
|
||
)
|
||
|
||
# Pivot fanout: this plugin × every group / every user. We
|
||
# intentionally do NOT wrap the loop in a single BEGIN/COMMIT —
|
||
# DuckDB aborts the whole transaction on a ConstraintException, so
|
||
# an idempotent re-run (where most rows are duplicates) would die
|
||
# on the very first existing grant. Each row stands alone here:
|
||
# duplicate grants raise ConstraintException which we swallow
|
||
# (mirrors ``ON CONFLICT DO NOTHING`` semantics without DuckDB
|
||
# multi-target conflict resolution); duplicate subscriptions go
|
||
# through DuckDB's ON CONFLICT on the PK, which is well-supported.
|
||
# Partial-application is safe: a re-run completes the remainder.
|
||
groups = conn.execute("SELECT id FROM user_groups").fetchall()
|
||
grants_repo = ResourceGrantsRepository(conn)
|
||
actor_email = user.get("email") or user.get("id")
|
||
for (group_id,) in groups:
|
||
try:
|
||
grants_repo.create(
|
||
group_id=group_id,
|
||
resource_type=ResourceType.MARKETPLACE_PLUGIN.value,
|
||
resource_id=resource_id,
|
||
assigned_by=actor_email,
|
||
)
|
||
affected_groups += 1
|
||
except duckdb.ConstraintException:
|
||
continue
|
||
|
||
affected_users = UserCuratedSubscriptionsRepository(
|
||
conn,
|
||
).fanout_system_for_plugin(marketplace_id, plugin_name)
|
||
|
||
_audit(
|
||
conn,
|
||
user["id"],
|
||
"marketplace.plugin.mark_system",
|
||
f"{marketplace_id}/{plugin_name}",
|
||
{"affected_groups": affected_groups, "affected_users": affected_users},
|
||
)
|
||
_invalidate_marketplace_etag()
|
||
|
||
return SystemFlagResponse(
|
||
marketplace_id=marketplace_id,
|
||
plugin_name=plugin_name,
|
||
is_system=True,
|
||
affected_groups=affected_groups,
|
||
affected_users=affected_users,
|
||
)
|
||
|
||
|
||
@router.delete(
|
||
"/{marketplace_id}/plugins/{plugin_name}/system",
|
||
response_model=SystemFlagResponse,
|
||
)
|
||
def unmark_plugin_system(
|
||
marketplace_id: str,
|
||
plugin_name: str,
|
||
user: dict = Depends(require_admin),
|
||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||
):
|
||
"""Flip ``is_system`` to FALSE. Materialized grants/subscriptions
|
||
survive — admin curates cleanup via the standard /admin/access UI
|
||
(which immediately unlocks the checkboxes for this plugin) and
|
||
users can unsubscribe normally on /marketplace?tab=my.
|
||
"""
|
||
plugin_row = conn.execute(
|
||
"SELECT 1 FROM marketplace_plugins WHERE marketplace_id = ? AND name = ?",
|
||
[marketplace_id, plugin_name],
|
||
).fetchone()
|
||
if not plugin_row:
|
||
raise HTTPException(status_code=404, detail="plugin not found")
|
||
|
||
conn.execute(
|
||
"UPDATE marketplace_plugins SET is_system = FALSE "
|
||
"WHERE marketplace_id = ? AND name = ?",
|
||
[marketplace_id, plugin_name],
|
||
)
|
||
_audit(
|
||
conn,
|
||
user["id"],
|
||
"marketplace.plugin.unmark_system",
|
||
f"{marketplace_id}/{plugin_name}",
|
||
None,
|
||
)
|
||
_invalidate_marketplace_etag()
|
||
|
||
return SystemFlagResponse(
|
||
marketplace_id=marketplace_id,
|
||
plugin_name=plugin_name,
|
||
is_system=False,
|
||
)
|