Adds whole-Store backup/restore primitives so an external CI/CD job can
mirror the Store to a git repo (and restore back from one).
REST:
- GET /api/store/bundle.zip — deterministic ZIP of all (filtered) Store
entities. Layout: manifest.json + entities/<id>/{plugin,assets}/.
Manifest carries owner_email for cross-instance restore. Auth: any
authenticated user (Store is community-open).
- POST /api/store/import-bundle — admin-only restore. Modes
merge|replace|skip; owner resolution by email with stub-disabled-user
fallback when the email is unknown on the target instance.
CLI:
- agnes store update <id> [--description X] [--zip PATH] ... — in-place
edit (server PUT permits owner OR admin per F4). Closes the missing
edit affordance for analysts who want to fix a typo or push a new
ZIP without losing install_count.
- agnes store pull [-o store.zip] [--unpack DIR] — download the bundle.
--unpack streams + extracts so an external git-backup workflow can
drop the tree straight into a repo and `git add .`.
- agnes store info [--json] — counts + size summary.
- agnes admin store push <zip-or-dir> [--mode ...] — admin-only restore.
Auto-zips a directory client-side so a working-tree → server
round-trip is one command.
cli/v2_client.py gains api_get_stream helper for binary downloads.
Tests: 5 new server tests (bundle shape + filters + round-trip + stub
user creation + skip mode + admin-only gate) + 11 new CLI tests
(update, pull/unpack, info, admin push). 66/66 store-related tests
green locally.
104 lines
3.8 KiB
Python
104 lines
3.8 KiB
Python
"""`agnes admin store push` — admin-only Store bulk restore.
|
|
|
|
Wraps ``POST /api/store/import-bundle`` (admin-gated). Read paths
|
|
(``pull`` / ``info``) live under user-namespace ``agnes store`` because the
|
|
server endpoint for the export is open to any authenticated user (the
|
|
Store is community-readable).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import tempfile
|
|
import zipfile
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import typer
|
|
|
|
from cli.v2_client import V2ClientError, api_post_multipart
|
|
|
|
admin_store_app = typer.Typer(help="Admin: Store bulk restore (push)")
|
|
|
|
|
|
@admin_store_app.command("push")
|
|
def push_bundle(
|
|
source: Path = typer.Argument(
|
|
..., exists=True, readable=True,
|
|
help="Bundle to upload — either a *.zip file or a directory "
|
|
"containing manifest.json + entities/. A directory is "
|
|
"zipped client-side before upload.",
|
|
),
|
|
mode: str = typer.Option(
|
|
"merge", "--mode",
|
|
help="merge (default — upsert by entity_id; replace when version "
|
|
"differs) | replace (overwrite every existing row in the "
|
|
"bundle) | skip (insert only entities not already present)",
|
|
),
|
|
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
):
|
|
"""Upload a Store bundle ZIP for bulk restore. Admin only."""
|
|
if mode not in {"merge", "replace", "skip"}:
|
|
typer.echo(f"--mode must be merge|replace|skip, got {mode!r}", err=True)
|
|
raise typer.Exit(2)
|
|
|
|
# If source is a directory, zip it client-side. The expected layout is
|
|
# the same as `agnes store pull --unpack` produces: manifest.json at
|
|
# the top, entities/<id>/ subtrees.
|
|
cleanup: Optional[Path] = None
|
|
try:
|
|
if source.is_dir():
|
|
if not (source / "manifest.json").is_file():
|
|
typer.echo(
|
|
f"{source} does not contain manifest.json — is this a Store bundle directory?",
|
|
err=True,
|
|
)
|
|
raise typer.Exit(2)
|
|
scratch = Path(tempfile.mkdtemp(prefix="agnes_store_push_"))
|
|
cleanup = scratch
|
|
zip_path = scratch / "bundle.zip"
|
|
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
for f in sorted(p for p in source.rglob("*") if p.is_file()):
|
|
rel = f.relative_to(source).as_posix()
|
|
zf.write(f, arcname=rel)
|
|
zip_to_send = zip_path
|
|
else:
|
|
zip_to_send = source
|
|
|
|
if not yes:
|
|
confirm = typer.confirm(
|
|
f"Upload bundle from {source} with mode={mode}? "
|
|
f"This may modify existing Store entities."
|
|
)
|
|
if not confirm:
|
|
raise typer.Abort()
|
|
|
|
files = {
|
|
"file": (zip_to_send.name, zip_to_send.read_bytes(), "application/zip"),
|
|
}
|
|
try:
|
|
body = api_post_multipart(
|
|
"/api/store/import-bundle",
|
|
files=files, data={"mode": mode},
|
|
)
|
|
except V2ClientError as e:
|
|
typer.echo(str(e), err=True)
|
|
raise typer.Exit(1)
|
|
typer.echo(
|
|
f"imported={body.get('imported', 0)} "
|
|
f"replaced={body.get('replaced', 0)} "
|
|
f"skipped={body.get('skipped', 0)} "
|
|
f"stub_users_created={body.get('stub_users_created', 0)}"
|
|
)
|
|
errs = body.get("errors") or []
|
|
if errs:
|
|
typer.echo(f"\n{len(errs)} entries had errors:", err=True)
|
|
for e in errs[:10]:
|
|
typer.echo(f" - {json.dumps(e)}", err=True)
|
|
if len(errs) > 10:
|
|
typer.echo(f" ... and {len(errs) - 10} more", err=True)
|
|
raise typer.Exit(1)
|
|
finally:
|
|
if cleanup is not None:
|
|
shutil.rmtree(cleanup, ignore_errors=True)
|