diff --git a/CLAUDE.md b/CLAUDE.md index 0166132..f3d966a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,6 +91,11 @@ CLI surface (`paramify `, all accept `--json`): - `upload [run-dir]` — push one run's evidence to Paramify via the `paramify_evidence` uploader (default: latest run under `--output-dir`; `--dry-run`/`--config`) +- `scripts sync` — push each fetcher's entry script to Paramify and CONNECT it + to that fetcher's evidence set via the `paramify_scripts` uploader + (provisioning, separate from `upload`; `--dry-run`/`--force`/`--reassociate`/ + `--config`). Reconciles by a marker in the script description; the fetcher.yaml + `version` is the update signal, a content hash guards undeclared drift. - `manifest ` — build/edit a manifest (`-f/--file`, default `./manifest.yaml`; every sub emits `{ok,path,errors}` under `--json`): `init [--output-dir DIR]`, `new `, `add `, @@ -119,7 +124,8 @@ CLI surface (`paramify `, all accept `--json`): additive change for `api_failures` is the exception, not the start of a refactor) - Paramify issues uploader (`uploaders/paramify_issues/` is an empty stub; - the evidence uploader `uploaders/paramify_evidence/` IS built) + the evidence uploader `uploaders/paramify_evidence/` and the scripts uploader + `uploaders/paramify_scripts/` are both built) - Comparators (`depends_on` is in the schema but the runner doesn't honor it yet — only `comparators/_template` exists; logger.py, retry.py, dependency_graph.py are still empty stubs) diff --git a/framework/api.py b/framework/api.py index 42f5c58..b8cae5a 100644 --- a/framework/api.py +++ b/framework/api.py @@ -650,6 +650,94 @@ def upload_run( ) +# --------------------------------------------------------------------------- # +# Scripts — Paramify scripts sync facade (powers CLI; provisioning, not per-run) +# --------------------------------------------------------------------------- # + +def _load_paramify_scripts_uploader(root: Path): + """Load the source-tree scripts uploader without packaging uploaders/.""" + path = Path(root) / "uploaders" / "paramify_scripts" / "uploader.py" + if not path.exists(): + raise RuntimeError(f"Paramify scripts uploader not found at {path}") + spec = importlib.util.spec_from_file_location("paramify_scripts_uploader", path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Could not load Paramify scripts uploader from {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def scripts_sync_preflight( + root: Path, + config_path: Optional[Path] = None, + *, + dry_run: bool = False, +) -> dict: + """Inspect scripts-sync readiness without making Paramify API calls.""" + uploader = _load_paramify_scripts_uploader(root) + uploader.load_dotenv() + config = uploader.load_config(str(config_path)) if config_path else {} + paramify_cfg = config.get("paramify") or {} + base_url = ( + paramify_cfg.get("base_url") + or os.environ.get("PARAMIFY_API_BASE_URL") + or uploader.DEFAULT_BASE_URL + ) + + errors: List[str] = [] + fetcher_count = 0 + for f in discover_fetchers(root).values(): + if f.evidence_set and f.entry_path.exists(): + fetcher_count += 1 + if fetcher_count == 0: + errors.append("No fetchers with an evidence_set and a readable entry file to sync") + + url_error = uploader._base_url_error(base_url) + if url_error: + errors.append(url_error) + + token_present = bool(os.environ.get("PARAMIFY_UPLOAD_API_TOKEN")) + # A token is required to write; dry-run still wants one to diff the tenant, + # but tolerates its absence (it then reports every fetcher as a create). + if not token_present and not dry_run: + errors.append("PARAMIFY_UPLOAD_API_TOKEN is not set") + + return { + "ok": not errors, + "base_url": base_url, + "fetcher_count": fetcher_count, + "token_present": token_present, + "dry_run": dry_run, + "errors": errors, + } + + +def scripts_sync( + root: Path, + config_path: Optional[Path] = None, + *, + dry_run: bool = False, + force: bool = False, + reassociate: bool = False, + on_event: Optional[Callable[[dict], None]] = None, +) -> dict: + """Sync fetcher entry scripts to Paramify and associate them to evidence sets. + + Fires sync_start / sync_item / sync_complete so front-ends can render + progress. Raises ValueError for setup errors; returns the uploader summary. + """ + uploader = _load_paramify_scripts_uploader(root) + config = uploader.load_config(str(config_path)) if config_path else {} + return uploader.sync_scripts( + root, + config=config, + dry_run=dry_run, + force=force, + reassociate=reassociate, + on_event=on_event, + ) + + # --------------------------------------------------------------------------- # # Evidence — read produced run outputs (powers the TUI evidence browser) # --------------------------------------------------------------------------- # diff --git a/framework/cli.py b/framework/cli.py index 6e1b08c..d4d0270 100644 --- a/framework/cli.py +++ b/framework/cli.py @@ -73,6 +73,13 @@ ) app.add_typer(manifest_app, name="manifest") +scripts_app = typer.Typer( + no_args_is_help=True, + context_settings=_HELP_OPTS, + help="Sync fetcher entry scripts to Paramify and associate them to evidence sets.", +) +app.add_typer(scripts_app, name="scripts") + # --------------------------------------------------------------------------- # # Small shared helpers (ported verbatim from the previous argparse CLI) @@ -548,6 +555,85 @@ def upload_cmd( raise typer.Exit(0 if summary["ok"] else 1) +# --------------------------------------------------------------------------- # +# Scripts — sync fetcher entry scripts to Paramify + associate to evidence sets +# --------------------------------------------------------------------------- # + +def _human_scripts_printer(): + """Return an on_event callback for Paramify scripts-sync progress.""" + _marks = { + "create": "NEW", "update": "UPD", "noop": "OK", "drift_skipped": "DRIFT", + "would_create": "DRY+", "would_update": "DRY~", "would_noop": "DRY=", + "would_drift": "DRY!", "error": "FAIL", + } + + def on_event(ev: dict) -> None: + kind = ev["event"] + if kind == "sync_start": + mode = " (dry-run)" if ev.get("dry_run") else "" + typer.echo(f"Sync {ev['fetchers']} fetcher script(s) → {ev['base_url']}{mode}\n") + elif kind == "sync_item": + mark = _marks.get(ev.get("outcome"), "?") + assoc = " +assoc" if ev.get("associated") else "" + reason = ev.get("reason") or ev.get("error") + suffix = f" {reason}" if reason else "" + typer.echo(f" [{mark}] {ev.get('fetcher', '?')} set={ev.get('reference_id')}{assoc}{suffix}") + elif kind == "sync_complete": + typer.echo( + "\nDone: " + f"created={ev['created']} updated={ev['updated']} drift={ev['drift']} " + f"noop={ev['noop']} associated={ev['associated']} errors={ev['errors']}" + ) + return on_event + + +@scripts_app.command("sync") +def scripts_sync_cmd( + config: Optional[str] = typer.Option(None, "--config", help="Uploader config YAML (base_url, overrides)"), + dry_run: bool = typer.Option(False, "--dry-run", help="Report the plan; read-only (no writes)"), + force: bool = typer.Option(False, "--force", help="Push scripts whose code drifted without a version bump"), + reassociate: bool = typer.Option(False, "--reassociate", help="Ensure the association for every fetcher, not just changed ones"), + json_out: bool = typer.Option(False, "--json", help="Emit JSON summary"), +): + """Sync fetcher entry scripts to Paramify and associate them to evidence sets.""" + root = api.find_repo_root() + config_path = Path(config).resolve() if config else None + try: + preflight = api.scripts_sync_preflight(root, config_path, dry_run=dry_run) + except Exception as e: # noqa: BLE001 — surface setup errors to CLI users + if json_out: + typer.echo(json.dumps({"ok": False, "errors": [str(e)]}, indent=2)) + else: + _err(f"Scripts sync setup failed: {e}") + raise typer.Exit(1) + if not preflight["ok"]: + if json_out: + typer.echo(json.dumps(preflight, indent=2, default=str)) + else: + for e in preflight["errors"]: + _err(f" ERROR {e}") + raise typer.Exit(1) + + try: + summary = api.scripts_sync( + root, + config_path, + dry_run=dry_run, + force=force, + reassociate=reassociate, + on_event=None if json_out else _human_scripts_printer(), + ) + except Exception as e: # noqa: BLE001 + if json_out: + typer.echo(json.dumps({"ok": False, "errors": [str(e)]}, indent=2)) + else: + _err(f"Scripts sync failed: {e}") + raise typer.Exit(1) + if json_out: + typer.echo(json.dumps(summary, indent=2, default=str)) + raise typer.Exit(0 if summary["ok"] else 1) + + # --------------------------------------------------------------------------- # # Manifest editing — every mutator emits {"ok", "path", "errors"} under --json # --------------------------------------------------------------------------- # @@ -801,7 +887,13 @@ def tui_cmd( try: from framework.tui.__main__ import launch except ImportError as e: - _err(f"The TUI requires the 'tui' extra (textual). Install it: pip install 'paramify[tui]'\n ({e})") + _err( + "The TUI requires the 'tui' extra (textual). Install it from the repo " + "root: pip install -e '.[tui]'\n" + " (do NOT run `pip install 'paramify[tui]'` — this project isn't on " + "PyPI, and that name resolves to an unrelated package.)\n" + f" ({e})" + ) raise typer.Exit(1) launch(manifest, at) diff --git a/framework/tui/screens/upload.py b/framework/tui/screens/upload.py index 55ecba8..840d501 100644 --- a/framework/tui/screens/upload.py +++ b/framework/tui/screens/upload.py @@ -1,7 +1,12 @@ -"""Paramify upload page. +"""Paramify page — push to Paramify. -This is the final TUI step after Evidence: users can inspect the collected run, -then explicitly upload that run to Paramify once the story looks right. +Two write actions share this screen because they share all their plumbing +(token, base_url, overrides config, the event-stream shape): + + * Upload — attach a completed run's evidence to its evidence sets (run-scoped; + follows Evidence in the tab flow). + * Sync Scripts — push each fetcher's entry script and associate it to its + evidence set (repo-scoped provisioning; independent of the selected run). """ from __future__ import annotations @@ -12,7 +17,7 @@ from textual.binding import Binding from textual.containers import Horizontal, Vertical from textual.message import Message -from textual.widgets import Button, DataTable, RichLog, Static +from textual.widgets import Button, Checkbox, DataTable, RichLog, Static from framework import api from framework.tui.modals import ConfirmModal @@ -26,31 +31,48 @@ def __init__(self, ev: dict) -> None: super().__init__() +class ScriptsSyncEvent(Message): + """Carries one api.scripts_sync() event dict from the worker thread.""" + + def __init__(self, ev: dict) -> None: + self.ev = ev + super().__init__() + + class UploadPage(Vertical): - HINTS = [("ctrl+r", "refresh"), ("ctrl+u", "upload")] + HINTS = [("ctrl+r", "refresh"), ("ctrl+u", "upload"), ("ctrl+s", "sync scripts")] BINDINGS = [ Binding("ctrl+r", "refresh_upload", "Refresh"), Binding("ctrl+u", "upload_run", "Upload"), + Binding("ctrl+s", "sync_scripts", "Sync Scripts"), ] def compose(self) -> ComposeResult: with Horizontal(id="upload-top"): yield Button("Refresh", id="upload-refresh") yield Button("Upload to Paramify", variant="primary", id="upload-submit", disabled=True) + yield Button("Sync Scripts", variant="primary", id="scripts-submit", disabled=True) yield Static("", id="upload-banner") + with Horizontal(id="upload-options"): + yield Static("scripts sync:", classes="options-label") + yield Checkbox("dry-run", id="scripts-dry") + yield Checkbox("force", id="scripts-force") + yield Checkbox("reassociate", id="scripts-reassociate") with Horizontal(id="upload-body"): with Vertical(id="upload-summary-panel", classes="panel"): yield Static("ready to upload", classes="panel-title") yield DataTable(id="upload-summary") with Vertical(id="upload-log-panel", classes="panel"): - yield Static("upload log", classes="panel-title") + yield Static("activity log", classes="panel-title") yield RichLog(id="upload-log", markup=False, wrap=True, highlight=False) def on_mount(self) -> None: self._uploading = False + self._syncing = False self._run_dir: str | None = None self._preflight: dict | None = None + self._scripts_preflight: dict | None = None table = self.query_one("#upload-summary", DataTable) table.cursor_type = "row" table.zebra_stripes = True @@ -65,6 +87,10 @@ def focus_default(self) -> None: else: self.query_one("#upload-refresh", Button).focus() + @property + def _busy(self) -> bool: + return self._uploading or self._syncing + # -- data ------------------------------------------------------------- # def _output_dir(self) -> str: @@ -72,56 +98,75 @@ def _output_dir(self) -> str: return run.get("output_dir") or "./evidence" def rebuild(self) -> None: - if self._uploading: + if self._busy: return - self._run_dir = None - self._preflight = None table = self.query_one("#upload-summary", DataTable) table.clear() + self._rebuild_evidence(table) + table.add_row("", "") + self._rebuild_scripts(table) + + def _rebuild_evidence(self, table: DataTable) -> None: + """Evidence-upload readiness (run-scoped). Sets self._run_dir/_preflight + and the upload button; never returns early from rebuild().""" + self._run_dir = None + self._preflight = None upload = self.query_one("#upload-submit", Button) upload.disabled = True out = self._output_dir() - table.add_row("step", "Review evidence in tab 4, then upload the latest run here.") + table.add_row("EVIDENCE", "attach a run's files to its evidence sets") table.add_row("output dir", out) try: runs = api.list_runs(out) except Exception as exc: - self._set_banner(Text(f"cannot list runs: {exc}", style="red")) table.add_row("status", Text(f"cannot list runs: {exc}", style="red")) return - if not runs: - self._set_banner(Text(f"no runs found under {out}", style="dim")) - table.add_row("status", Text("no runs found", style="dim")) + table.add_row("status", Text("no runs found — collect in the Run tab first", style="dim")) return latest = runs[0] self._run_dir = latest["dir"] table.add_row("selected run", latest["run_id"]) - table.add_row("completed", latest.get("completed_at") or latest.get("started_at") or "unknown") table.add_row("result", self._result_text(latest)) - table.add_row("evidence files", str(len(latest.get("files") or []))) try: preflight = api.upload_preflight(self._run_dir, self.app.root_path) except Exception as exc: - self._set_banner(Text(f"upload setup failed: {exc}", style="red")) table.add_row("preflight", Text(str(exc), style="red")) return self._preflight = preflight - table.add_row("Paramify API", preflight["base_url"]) - table.add_row("API token", Text("present", style="green") if preflight["token_present"] else Text("missing", style="red")) table.add_row("upload files", str(preflight["file_count"])) - if preflight["ok"]: upload.disabled = False - self._set_banner(Text("ready — upload after reviewing the evidence", style="green")) + self._set_banner(Text("ready — upload evidence, or sync scripts", style="green")) else: for err in preflight["errors"]: table.add_row("preflight error", Text(err, style="red")) - self._set_banner(Text("upload preflight failed", style="red")) + + def _rebuild_scripts(self, table: DataTable) -> None: + """Scripts-sync readiness (repo-scoped). Enabled whenever there are + fetchers to sync — independent of any run selection.""" + self._scripts_preflight = None + sync = self.query_one("#scripts-submit", Button) + sync.disabled = True + + table.add_row("SCRIPTS", "push fetcher entry scripts + associate to sets") + try: + pf = api.scripts_sync_preflight(self.app.root_path, dry_run=False) + except Exception as exc: + table.add_row("scripts preflight", Text(str(exc), style="red")) + return + + self._scripts_preflight = pf + table.add_row("fetchers", str(pf["fetcher_count"])) + table.add_row("API token", Text("present", style="green") if pf["token_present"] else Text("missing (dry-run ok)", style="yellow")) + table.add_row("Paramify API", pf["base_url"]) + # Enabled if there's anything to sync; a real (non-dry-run) sync still + # checks for the token at click time. + sync.disabled = pf["fetcher_count"] == 0 @staticmethod def _result_text(run: dict) -> Text: @@ -134,7 +179,7 @@ def _result_text(run: dict) -> Text: return Text(f"{ok}/{total} ok, {fail} failed", style="yellow") return Text(f"{ok}/{total} ok", style="green") - # -- actions ---------------------------------------------------------- # + # -- actions: evidence upload ---------------------------------------- # @on(Button.Pressed, "#upload-refresh") def _on_refresh(self) -> None: @@ -146,11 +191,11 @@ def _on_upload(self) -> None: def action_refresh_upload(self) -> None: self.rebuild() - self.notify("Upload page refreshed.") + self.notify("Paramify page refreshed.") def action_upload_run(self) -> None: - if self._uploading: - self.notify("An upload is already in progress.") + if self._busy: + self.notify("A Paramify operation is already in progress.") return if not self._run_dir or not self._preflight or not self._preflight.get("ok"): self.notify("No upload-ready run selected.") @@ -169,8 +214,7 @@ def go(ok: bool) -> None: def _start_upload(self, run_dir: str) -> None: self._uploading = True - self.query_one("#upload-refresh", Button).disabled = True - self.query_one("#upload-submit", Button).disabled = True + self._disable_actions() self.query_one("#upload-log", RichLog).clear() self._set_banner(Text("uploading to Paramify...", style="yellow")) self._upload_worker(run_dir, self.app.root_path) @@ -178,13 +222,69 @@ def _start_upload(self, run_dir: str) -> None: @work(thread=True, exclusive=True) def _upload_worker(self, run_dir: str, root) -> None: try: - api.upload_run( - run_dir, + api.upload_run(run_dir, root, on_event=lambda ev: self.post_message(UploadEvent(ev))) + except Exception as exc: + self.post_message(UploadEvent({"event": "_upload_failed", "error": str(exc)})) + + # -- actions: scripts sync ------------------------------------------- # + + @on(Button.Pressed, "#scripts-submit") + def _on_sync(self) -> None: + self.action_sync_scripts() + + def action_sync_scripts(self) -> None: + if self._busy: + self.notify("A Paramify operation is already in progress.") + return + pf = self._scripts_preflight + if not pf or pf.get("fetcher_count", 0) == 0: + self.notify("No fetcher scripts to sync.") + return + dry = self.query_one("#scripts-dry", Checkbox).value + if not dry and not pf.get("token_present"): + self.notify("API token missing — check dry-run or set PARAMIFY_UPLOAD_API_TOKEN.") + return + + if dry: + self._start_scripts() # read-only: no confirmation needed + return + + def go(ok: bool) -> None: + if ok: + self._start_scripts() + + self.app.push_screen( + ConfirmModal( + f"Sync {pf['fetcher_count']} fetcher script(s) to {pf['base_url']} " + "and associate them to their evidence sets?" + ), + go, + ) + + def _start_scripts(self) -> None: + self._syncing = True + self._disable_actions() + self.query_one("#upload-log", RichLog).clear() + self._set_banner(Text("syncing scripts to Paramify...", style="yellow")) + self._scripts_worker( + self.app.root_path, + dry_run=self.query_one("#scripts-dry", Checkbox).value, + force=self.query_one("#scripts-force", Checkbox).value, + reassociate=self.query_one("#scripts-reassociate", Checkbox).value, + ) + + @work(thread=True, exclusive=True) + def _scripts_worker(self, root, dry_run: bool, force: bool, reassociate: bool) -> None: + try: + api.scripts_sync( root, - on_event=lambda ev: self.post_message(UploadEvent(ev)), + dry_run=dry_run, + force=force, + reassociate=reassociate, + on_event=lambda ev: self.post_message(ScriptsSyncEvent(ev)), ) except Exception as exc: - self.post_message(UploadEvent({"event": "_upload_failed", "error": str(exc)})) + self.post_message(ScriptsSyncEvent({"event": "_scripts_failed", "error": str(exc)})) # -- events ----------------------------------------------------------- # @@ -199,7 +299,6 @@ def _handle_upload_event(self, ev: dict) -> None: mode = " (dry-run)" if ev.get("dry_run") else "" self._set_banner(Text(f"uploading {ev.get('files', 0)} file(s) to {ev.get('base_url', '')}{mode}", style="yellow")) log.write(Text(f"upload {ev.get('files', 0)} file(s) from {ev.get('run_dir', '')}{mode}", style="bold")) - elif etype == "upload_file": outcome = ev.get("outcome") if outcome == "uploaded": @@ -212,33 +311,89 @@ def _handle_upload_event(self, ev: dict) -> None: reason = ev.get("reason") or ev.get("error") suffix = f" {reason}" if reason else "" log.write(Text(f" [{icon}] {ev.get('file', '?')} {outcome}{ref}{suffix}", style=style)) - elif etype == "upload_complete": self._finalize_upload(ev) - elif etype == "_upload_failed": self._uploading = False - self.query_one("#upload-refresh", Button).disabled = False - self.query_one("#upload-submit", Button).disabled = self._preflight is None or not self._preflight.get("ok") + self._restore_actions() log.write(Text(f"upload failed: {ev.get('error', '')}", style="bold red")) self._set_banner(Text(f"upload failed: {ev.get('error', '')}", style="red")) def _finalize_upload(self, ev: dict) -> None: self._uploading = False - self.query_one("#upload-refresh", Button).disabled = False - self.query_one("#upload-submit", Button).disabled = False + self._restore_actions() ok = ev.get("ok") - style = "green" if ok else "red" msg = Text( "upload complete — " f"uploaded={ev.get('uploaded', 0)} " f"duplicates={ev.get('skipped_duplicate', 0)} " f"errors={ev.get('errors', 0)}", - style=style, + style="green" if ok else "red", ) if ev.get("log_path"): msg.append(f" {ev['log_path']}", style="dim") self._set_banner(msg) + def on_scripts_sync_event(self, message: ScriptsSyncEvent) -> None: + self._handle_scripts_event(message.ev) + + _SCRIPT_MARKS = { + "create": ("NEW", "green"), "update": ("UPD", "green"), "noop": ("OK", "dim"), + "drift": ("DRIFT", "yellow"), "drift_skipped": ("DRIFT", "yellow"), "error": ("FAIL", "red"), + "would_create": ("NEW?", "cyan"), "would_update": ("UPD?", "cyan"), + "would_noop": ("OK?", "dim"), "would_drift": ("DRIFT?", "yellow"), + } + + def _handle_scripts_event(self, ev: dict) -> None: + etype = ev.get("event") + log = self.query_one("#upload-log", RichLog) + + if etype == "sync_start": + mode = " (dry-run)" if ev.get("dry_run") else "" + self._set_banner(Text(f"syncing {ev.get('fetchers', 0)} script(s) to {ev.get('base_url', '')}{mode}", style="yellow")) + log.write(Text(f"sync {ev.get('fetchers', 0)} fetcher script(s){mode}", style="bold")) + elif etype == "sync_item": + icon, style = self._SCRIPT_MARKS.get(ev.get("outcome"), ("?", "white")) + ref = f" set={ev.get('reference_id')}" if ev.get("reference_id") else "" + assoc = " +assoc" if ev.get("associated") else "" + reason = ev.get("reason") or ev.get("error") + suffix = f" {reason}" if reason else "" + log.write(Text(f" [{icon}] {ev.get('fetcher', '?')}{ref}{assoc}{suffix}", style=style)) + elif etype == "sync_complete": + self._finalize_scripts(ev) + elif etype == "_scripts_failed": + self._syncing = False + self._restore_actions() + log.write(Text(f"scripts sync failed: {ev.get('error', '')}", style="bold red")) + self._set_banner(Text(f"scripts sync failed: {ev.get('error', '')}", style="red")) + + def _finalize_scripts(self, ev: dict) -> None: + self._syncing = False + self._restore_actions() + msg = Text( + "scripts sync complete — " + f"created={ev.get('created', 0)} " + f"updated={ev.get('updated', 0)} " + f"drift={ev.get('drift', 0)} " + f"noop={ev.get('noop', 0)} " + f"associated={ev.get('associated', 0)} " + f"errors={ev.get('errors', 0)}", + style="green" if ev.get("ok") else "red", + ) + self._set_banner(msg) + + # -- button state ----------------------------------------------------- # + + def _disable_actions(self) -> None: + for bid in ("#upload-refresh", "#upload-submit", "#scripts-submit"): + self.query_one(bid, Button).disabled = True + + def _restore_actions(self) -> None: + self.query_one("#upload-refresh", Button).disabled = False + self.query_one("#upload-submit", Button).disabled = not (self._preflight and self._preflight.get("ok")) + self.query_one("#scripts-submit", Button).disabled = not ( + self._scripts_preflight and self._scripts_preflight.get("fetcher_count", 0) > 0 + ) + def _set_banner(self, renderable) -> None: self.query_one("#upload-banner", Static).update(renderable) diff --git a/framework/tui/screens/workspace.py b/framework/tui/screens/workspace.py index 95af120..773f9a4 100644 --- a/framework/tui/screens/workspace.py +++ b/framework/tui/screens/workspace.py @@ -1,7 +1,7 @@ """WorkspaceScreen — the five-tab fetcher workspace. Hosts the shared chrome (AppHeader + HintFooter) and the Catalog / Manifest / -Run / Evidence / Upload tabs. The App pushes this once a manifest is chosen +Run / Evidence / Paramify tabs. The App pushes this once a manifest is chosen (from the welcome front door, or directly via --manifest). The active manifest can be swapped in place with the quick-picker (`m`), which reloads the pages. """ @@ -35,7 +35,7 @@ class WorkspaceScreen(Screen): Binding("2", "go_tab(1)", "Manifest"), Binding("3", "go_tab(2)", "Run"), Binding("4", "go_tab(3)", "Evidence"), - Binding("5", "go_tab(4)", "Upload"), + Binding("5", "go_tab(4)", "Paramify"), Binding("slash", "focus_search", "Search"), Binding("escape", "unfocus", "Unfocus", show=False), Binding("m", "switch_manifest", "Manifest…"), @@ -54,7 +54,7 @@ def compose(self) -> ComposeResult: yield RunPage(id="run-page") with TabPane("Evidence", id="tab-evidence"): yield EvidencePage(id="evidence-page") - with TabPane("Upload", id="tab-upload"): + with TabPane("Paramify", id="tab-upload"): yield UploadPage(id="upload-page") yield HintFooter() diff --git a/framework/tui/styles/index.tcss b/framework/tui/styles/index.tcss index 5a0a1a3..a3ac235 100644 --- a/framework/tui/styles/index.tcss +++ b/framework/tui/styles/index.tcss @@ -162,6 +162,17 @@ UploadPage { margin-right: 1; } +#upload-options { + height: auto; + padding: 0 1 0 1; + align: left middle; +} + +#upload-options .options-label { + padding: 1 1 0 0; + color: $text-muted; +} + #upload-banner { width: 1fr; padding: 1 0 0 1; diff --git a/tests/test_cli.py b/tests/test_cli.py index 269944b..11b8356 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -39,28 +39,31 @@ # --------------------------------------------------------------------------- # def _registered(): - """Return (top_level_command_names, manifest_subcommand_names).""" + """Return (top_level, manifest_subcommand, scripts_subcommand) names.""" cmd = get_command(app) top = set(cmd.commands.keys()) manifest = set(cmd.commands["manifest"].commands.keys()) - return top, manifest + scripts = set(cmd.commands["scripts"].commands.keys()) + return top, manifest, scripts EXPECTED_TOP = { "list", "catalog", "describe", "manifests", "runs", "evidence", - "validate", "run", "upload", "manifest", "tui", + "validate", "run", "upload", "manifest", "scripts", "tui", } EXPECTED_MANIFEST = { "init", "new", "add", "remove", "set-config", "set-secret", "add-target", "remove-target", "set-platform-config", "set-passthrough", "set-output-dir", "show", } +EXPECTED_SCRIPTS = {"sync"} def test_all_expected_commands_registered(): - top, manifest = _registered() + top, manifest, scripts = _registered() assert EXPECTED_TOP <= top, f"missing top-level commands: {EXPECTED_TOP - top}" assert EXPECTED_MANIFEST <= manifest, f"missing manifest subcommands: {EXPECTED_MANIFEST - manifest}" + assert EXPECTED_SCRIPTS <= scripts, f"missing scripts subcommands: {EXPECTED_SCRIPTS - scripts}" # --------------------------------------------------------------------------- # @@ -106,6 +109,8 @@ def _tui_api_calls() -> set[str]: "run": "run", "upload_preflight": "upload", "upload_run": "upload", + "scripts_sync_preflight": "scripts sync", + "scripts_sync": "scripts sync", "list_runs": "runs", "read_evidence": "evidence", } @@ -131,14 +136,15 @@ def test_parity_every_tui_api_call_has_a_cli_home(): def test_parity_mapped_commands_actually_exist(): """Every concrete command named in API_TO_CLI is really registered.""" - top, manifest = _registered() + top, manifest, scripts = _registered() + groups = {"manifest": manifest, "scripts": scripts} for api_fn, where in API_TO_CLI.items(): if where.startswith(" + version: + sha256: + ``` + It indexes existing scripts by the `paramify-fetcher` line (there is no + server-side name/marker filter, so it lists all scripts and matches locally). +- **versioning** — the fetcher.yaml `version` is the update signal; git is the + history of record; the app just holds "current". +- **drift guard** — the `sha256` catches a code edit that forgot to bump the + version: **warn and skip by default**, `--force` to push it anyway. + +## What it does per fetcher + +For every fetcher that declares an `evidence_set` and has a readable entry file: + +1. reads the entry file (`fetcher.py` / `fetcher.sh`) — **shared modules are + ignored**; only the entry script is pushed, +2. get-or-creates the script by its marker key: + - **create** — not in the tenant yet, + - **update** — the fetcher.yaml `version` changed, + - **drift** — code changed but version did not (warn/skip, `--force` to push), + - **no-op** — version and hash both match, +3. **CONNECTs** the script to the fetcher's evidence set — the set is + get-or-created by `reference_id` (the same identity the evidence uploader + uses), so scripts land on the same set the evidence does. + +Only `SCRIPT` associations are automated. Solution-capability, control, and +validator linkage stays Paramify-side and is out of scope here. + +## Usage + +```bash +export PARAMIFY_UPLOAD_API_TOKEN=... # any source: .env, secret manager, CI + +# Preview the plan — read-only, makes no writes (needs a token to diff the tenant): +paramify scripts sync --dry-run + +# Apply: +paramify scripts sync + +# Push a script whose code drifted without a version bump: +paramify scripts sync --force + +# Ensure every fetcher's association (heals a script created without its link): +paramify scripts sync --reassociate + +# Equivalent direct invocation: +python uploaders/paramify_scripts/uploader.py --dry-run +``` + +Exits non-zero if any fetcher failed to sync. + +## Config (`--config`, optional) + +Shares the shape of the evidence uploader config so overrides stay consistent: +- `paramify.base_url` — default `https://app.paramify.com/api/v0`. +- `overrides..reference_id` (and optionally `name`) — must match + the evidence uploader's overrides so the script associates to the **same** + evidence set the evidence lands on. + +## Required tooling + +Python with `requests`, `python-dotenv` (already in `requirements.txt`), and an +importable `framework` package (editable install) for fetcher discovery. diff --git a/uploaders/paramify_scripts/uploader.py b/uploaders/paramify_scripts/uploader.py new file mode 100644 index 0000000..93a5d04 --- /dev/null +++ b/uploaders/paramify_scripts/uploader.py @@ -0,0 +1,414 @@ +#!/usr/bin/env python3 +"""Sync fetcher entry scripts to Paramify and associate them to evidence sets. + +A provisioning stage, separate from evidence upload (see docs/uploader_design.md): +evidence upload runs every collection; this runs only when `fetchers/**` changes. +It reconciles the tenant to the repo — GitOps style — because the Paramify +`/scripts` API has no stable external key and no server-side versioning: + + * identity — a marker stored in the script's `description` field + (`paramify-fetcher: `), since scripts have no + referenceId to get-or-create against. + * versioning — the fetcher.yaml `version` is the update signal; git is the + history of record; the app just holds "current". + * drift — a sha256 of the entry file guards against a code edit that + forgot to bump the version: warn (skip) by default, --force to push. + +Per fetcher (one that declares an `evidence_set`): + 1. read the entry file (fetcher.py / fetcher.sh) — shared modules are ignored, + 2. get-or-create the script by its marker key (create / update / no-op), + 3. CONNECT the script to the fetcher's evidence set (get-or-created by + reference_id, the same identity the evidence uploader uses). + +Only SCRIPT associations are automated; solution-capability / control / +validator linkage stays Paramify-side. + +Auth: PARAMIFY_UPLOAD_API_TOKEN (source-agnostic env — .env, secret manager, CI). +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import logging +import os +import sys +from pathlib import Path +from typing import Callable, Dict, List, Optional +from urllib.parse import urlparse + +import requests +from dotenv import load_dotenv + +logger = logging.getLogger("paramify_scripts_uploader") + +DEFAULT_BASE_URL = "https://app.paramify.com/api/v0" +_REQUEST_TIMEOUT = 30 +_MARKER_KEY = "paramify-fetcher" + + +# --------------------------------------------------------------------------- # +# Paramify API client — /scripts + the association endpoint +# --------------------------------------------------------------------------- # +class ParamifyError(RuntimeError): + pass + + +class ParamifyScriptsClient: + """Thin client over the Paramify REST API v0 scripts + association endpoints.""" + + def __init__(self, token: str, base_url: str, timeout: int = _REQUEST_TIMEOUT): + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.session = requests.Session() + self.session.headers.update({"Authorization": f"Bearer {token}"}) + + # -- scripts ---------------------------------------------------------- # + def list_scripts(self) -> List[Dict]: + """All scripts in the tenant. The API offers no name/marker filter, so we + fetch once and index client-side by the marker in each description.""" + r = self.session.get(f"{self.base_url}/scripts", timeout=self.timeout) + r.raise_for_status() + return r.json().get("scripts", []) + + def create_script(self, name: str, description: str, code: str) -> Dict: + body = {"name": name, "description": description, "code": code} + r = self.session.post(f"{self.base_url}/scripts", json=body, timeout=self.timeout) + if r.status_code not in (200, 201): + raise ParamifyError(f"create script {name!r} failed (HTTP {r.status_code}): {r.text[:300]}") + return r.json() + + def update_script(self, script_id: str, name: str, description: str, code: str) -> Dict: + body = {"name": name, "description": description, "code": code} + r = self.session.patch(f"{self.base_url}/scripts/{script_id}", json=body, timeout=self.timeout) + if r.status_code not in (200, 201): + raise ParamifyError(f"update script {script_id} failed (HTTP {r.status_code}): {r.text[:300]}") + return r.json() + + # -- evidence set (mirrors paramify_evidence; kept minimal on purpose) - # + def find_evidence_set(self, reference_id: str) -> Optional[str]: + r = self.session.get( + f"{self.base_url}/evidence", + params={"referenceId": reference_id}, + timeout=self.timeout, + ) + r.raise_for_status() + for ev in r.json().get("evidences", []): + if ev.get("referenceId") == reference_id: + return ev.get("id") + return None + + def create_evidence_set(self, reference_id: str, name: str) -> Optional[str]: + body = {"referenceId": reference_id, "name": name, "automated": True} + r = self.session.post(f"{self.base_url}/evidence", json=body, timeout=self.timeout) + if r.status_code in (200, 201): + return r.json().get("id") + if r.status_code == 400 and "already exists" in r.text.lower(): + return self.find_evidence_set(reference_id) + raise ParamifyError( + f"create evidence set {reference_id} failed (HTTP {r.status_code}): {r.text[:300]}" + ) + + def get_or_create_evidence_set(self, reference_id: str, name: str) -> Optional[str]: + return self.find_evidence_set(reference_id) or self.create_evidence_set(reference_id, name) + + # -- association ------------------------------------------------------ # + def associate_script(self, evidence_id: str, script_id: str) -> None: + """CONNECT a script to an evidence set. Tolerant of an already-connected + state (the API has no pre-check endpoint), so re-runs are idempotent.""" + body = {"associationType": "CONNECT", "subjectType": "SCRIPT", "subjectId": script_id} + r = self.session.post( + f"{self.base_url}/evidence/{evidence_id}/associate", + json=body, + timeout=self.timeout, + ) + if r.status_code in (200, 201, 204): + return + if r.status_code in (400, 409) and any(t in r.text.lower() for t in ("already", "exists", "connected")): + return # already associated — treat as success + raise ParamifyError( + f"associate script {script_id} -> evidence {evidence_id} failed " + f"(HTTP {r.status_code}): {r.text[:300]}" + ) + + +# --------------------------------------------------------------------------- # +# Marker helpers — identity + version + hash live in the script `description` +# --------------------------------------------------------------------------- # +def code_hash(code: str) -> str: + return hashlib.sha256(code.encode("utf-8")).hexdigest() + + +def build_description(fetcher_name: str, version: str, sha: str) -> str: + return "\n".join([ + f"{_MARKER_KEY}: {fetcher_name}", + f"version: {version}", + f"sha256: {sha}", + "", + "Managed by paramify-fetchers; edits here are overwritten on the next sync.", + ]) + + +def parse_marker(description: Optional[str]) -> Dict[str, str]: + out: Dict[str, str] = {} + for line in (description or "").splitlines(): + if ":" in line: + k, v = line.split(":", 1) + out[k.strip()] = v.strip() + return out + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # +def _emit(on_event: Optional[Callable[[dict], None]], event: dict) -> None: + if on_event is not None: + on_event(event) + + +def _base_url_error(base_url: str) -> Optional[str]: + parsed = urlparse(base_url) + if parsed.scheme != "https" and (parsed.hostname or "") not in ("localhost", "127.0.0.1", "::1"): + return ( + "base_url must be https to protect the API token " + f"(got {base_url!r}); only localhost may use http" + ) + return None + + +def load_config(path: Optional[str]) -> Dict: + if not path: + return {} + import yaml # local import: keeps the client importable without pyyaml + data = yaml.safe_load(Path(path).read_text()) + return data or {} + + +def _resolve_reference(fetcher_name: str, es: Dict, overrides: Dict) -> Dict: + """Apply any per-fetcher reference_id/name override so scripts associate to the + SAME evidence set the evidence uploader targets (overrides share that config).""" + ov = overrides.get(fetcher_name, {}) or {} + return { + "reference_id": ov.get("reference_id", es["reference_id"]), + "name": ov.get("name", es["name"]), + } + + +def _discover_specs(root: Path) -> List[Dict]: + """Build one script spec per fetcher that declares an evidence set and has a + readable entry file. Discovery lives here (not the client) so the client stays + pure API I/O.""" + from framework.config_loader import discover_fetchers # lazy: repo-side only + + specs: List[Dict] = [] + for f in sorted(discover_fetchers(root).values(), key=lambda x: x.name): + if not f.evidence_set: + logger.warning("%s: no evidence_set; skipping (nothing to associate a script to)", f.name) + continue + entry = f.entry_path + if not entry.exists(): + logger.warning("%s: entry file %s not found; skipping", f.name, f.runtime_entry) + continue + try: + code = entry.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as e: + logger.warning("%s: cannot read entry file (%s); skipping", f.name, e) + continue + specs.append({ + "fetcher_name": f.name, + "version": str(f.version), + "entry": f.runtime_entry, + "code": code, + "evidence_set": {"reference_id": f.evidence_set.reference_id, "name": f.evidence_set.name}, + }) + return specs + + +# --------------------------------------------------------------------------- # +# Sync +# --------------------------------------------------------------------------- # +def sync_scripts( + root, + *, + config: Optional[Dict] = None, + token: Optional[str] = None, + base_url: Optional[str] = None, + dry_run: bool = False, + force: bool = False, + reassociate: bool = False, + on_event: Optional[Callable[[dict], None]] = None, +) -> Dict: + """Reconcile every fetcher's entry script into Paramify and associate it. + + Actions per fetcher: create (new), update (version bumped), drift (code + changed but version did not — warn, skip unless --force), noop. Association + is ensured on create/update (and on every fetcher when --reassociate). + """ + load_dotenv() + config = config or {} + paramify_cfg = config.get("paramify") or {} + base_url = ( + paramify_cfg.get("base_url") or base_url + or os.environ.get("PARAMIFY_API_BASE_URL") or DEFAULT_BASE_URL + ) + url_error = _base_url_error(base_url) + if url_error: + logger.error(url_error) + raise ValueError(url_error) + + overrides = config.get("overrides") or {} + token = token or os.environ.get("PARAMIFY_UPLOAD_API_TOKEN") + if not token and not dry_run: + msg = "PARAMIFY_UPLOAD_API_TOKEN is not set" + logger.error(msg) + raise ValueError(msg) + + root = Path(root) + specs = _discover_specs(root) + + logger.info("Syncing %d fetcher script(s) → %s%s", len(specs), base_url, " (dry-run)" if dry_run else "") + _emit(on_event, {"event": "sync_start", "base_url": base_url, "dry_run": dry_run, "fetchers": len(specs)}) + + # A client is created whenever a token is available — even in dry-run, where + # it makes only read-only GETs so the plan reflects the real tenant state. + client = ParamifyScriptsClient(token, base_url) if token else None + index: Dict[str, Dict] = {} + if client is not None: + for s in client.list_scripts(): + m = parse_marker(s.get("description")) + key = m.get(_MARKER_KEY) + if key: + index[key] = {"id": s.get("id"), "version": m.get("version"), "sha256": m.get("sha256")} + + results: List[Dict] = [] + counts = {"created": 0, "updated": 0, "drift": 0, "noop": 0, "associated": 0, "errors": 0} + + def add_result(result: Dict) -> None: + results.append(result) + _emit(on_event, {"event": "sync_item", **result}) + + for spec in specs: + name = spec["fetcher_name"] + sha = code_hash(spec["code"]) + cur = index.get(name) + ref = _resolve_reference(name, spec["evidence_set"], overrides) + description = build_description(name, spec["version"], sha) + display_name = ref["name"] # decided: script display name == evidence_set name + + # Decide the action from the tenant marker (create when we couldn't list). + if cur is None: + action = "create" + elif (cur.get("version") or "") != spec["version"]: + action = "update" + elif (cur.get("sha256") or "") != sha: + action = "drift" + else: + action = "noop" + + base = {"fetcher": name, "reference_id": ref["reference_id"], "action": action} + + if dry_run: + note = None + if action == "drift": + note = "entry file changed but version not bumped" + (" (would push: --force)" if force else " (skipped)") + add_result({**base, "outcome": f"would_{action}" if action != "drift" else "would_drift", "reason": note}) + continue + + try: + script_id = cur["id"] if cur else None + do_associate = reassociate + + if action == "create": + script_id = client.create_script(display_name, description, spec["code"]).get("id") + counts["created"] += 1 + do_associate = True + elif action == "update": + client.update_script(script_id, display_name, description, spec["code"]) + counts["updated"] += 1 + do_associate = True + elif action == "drift": + counts["drift"] += 1 + if force: + client.update_script(script_id, display_name, description, spec["code"]) + do_associate = True + logger.warning("%s: entry changed without a version bump — pushed (--force)", name) + else: + logger.warning("%s: entry file changed but version %s not bumped — skipped (use --force)", name, spec["version"]) + add_result({**base, "outcome": "drift_skipped", + "reason": "entry changed but version not bumped; rerun with --force"}) + continue + else: # noop + counts["noop"] += 1 + + if do_associate and script_id: + evidence_id = client.get_or_create_evidence_set(ref["reference_id"], ref["name"]) + if not evidence_id: + raise ParamifyError(f"could not get or create evidence set {ref['reference_id']}") + client.associate_script(evidence_id, script_id) + counts["associated"] += 1 + add_result({**base, "outcome": action, "script_id": script_id, "evidence_id": evidence_id, "associated": True}) + else: + add_result({**base, "outcome": action, "script_id": script_id, "associated": False}) + + except Exception as e: # noqa: BLE001 — one bad fetcher must not abort the sweep + logger.error("%s: sync failed: %s", name, e) + counts["errors"] += 1 + add_result({**base, "outcome": "error", "error": str(e)[:300]}) + continue + + summary = { + "base_url": base_url, + "dry_run": dry_run, + "fetchers": len(specs), + **counts, + "results": results, + "ok": counts["errors"] == 0, + } + logger.info( + "Done: created=%d updated=%d drift=%d noop=%d associated=%d errors=%d", + counts["created"], counts["updated"], counts["drift"], counts["noop"], counts["associated"], counts["errors"], + ) + _emit(on_event, {"event": "sync_complete", **summary}) + return summary + + +# --------------------------------------------------------------------------- # +# Main +# --------------------------------------------------------------------------- # +def main(argv=None) -> int: + logging.basicConfig( + level=os.environ.get("LOG_LEVEL", "INFO"), + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) + load_dotenv() + + parser = argparse.ArgumentParser(description="Sync fetcher entry scripts to Paramify and associate them to evidence sets") + parser.add_argument("--root", help="Repo root (default: auto-detected)") + parser.add_argument("--config", help="Uploader config YAML (base_url, overrides)") + parser.add_argument("--dry-run", action="store_true", help="Report the plan; read-only (no writes)") + parser.add_argument("--force", action="store_true", help="Push scripts whose code drifted without a version bump") + parser.add_argument("--reassociate", action="store_true", help="Ensure the script↔evidence-set association for every fetcher, not just changed ones") + args = parser.parse_args(argv) + + if args.root: + root = Path(args.root) + else: + from framework.api import find_repo_root + root = find_repo_root() + + try: + summary = sync_scripts( + root, + config=load_config(args.config), + dry_run=args.dry_run, + force=args.force, + reassociate=args.reassociate, + ) + except ValueError: + return 1 + return 0 if summary["ok"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uploaders/paramify_scripts/uploader.yaml b/uploaders/paramify_scripts/uploader.yaml new file mode 100644 index 0000000..176d9ce --- /dev/null +++ b/uploaders/paramify_scripts/uploader.yaml @@ -0,0 +1,25 @@ +name: paramify_scripts +version: 0.1.0 +description: > + Syncs each fetcher's entry script (fetcher.py / fetcher.sh) to Paramify and + CONNECTs it to that fetcher's evidence set, so the tenant shows how the + evidence was generated. A provisioning step, separate from evidence upload: + run it when fetchers/** change, not on every collection. Reconciles the tenant + to the repo (create / update / no-op) keyed on a marker in the script + description; the fetcher.yaml version is the update signal. + +runtime: + type: python + entry: uploader.py + +# Source-agnostic: the token can come from .env, a secret manager, CI vars, etc. +secrets: + - name: api_token + env: PARAMIFY_UPLOAD_API_TOKEN + +config_schema: + base_url: + type: string + default: https://app.paramify.com/api/v0 + env: PARAMIFY_API_BASE_URL + description: Paramify REST API v0 base URL.