diff --git a/chipfoundry_cli/__init__.py b/chipfoundry_cli/__init__.py index 206d981..70b90de 100644 --- a/chipfoundry_cli/__init__.py +++ b/chipfoundry_cli/__init__.py @@ -1,2 +1,2 @@ """ChipFoundry CLI package: Automate project submission to SFTP.""" -__version__ = "2.4.6" \ No newline at end of file +__version__ = "2.5.3" \ No newline at end of file diff --git a/chipfoundry_cli/main.py b/chipfoundry_cli/main.py index 719d588..cae9f97 100644 --- a/chipfoundry_cli/main.py +++ b/chipfoundry_cli/main.py @@ -3,7 +3,11 @@ import hashlib from typing import Optional, List, Tuple from chipfoundry_cli.check_refs import PRECHECK_CHECKS -from chipfoundry_cli.remote_precheck_git import RemotePrecheckGitError, verify_remote_precheck_repo +from chipfoundry_cli.remote_precheck_git import ( + RemotePrecheckGitError, + verify_remote_job_repo, + verify_remote_precheck_repo, +) from chipfoundry_cli.version_check import maybe_warn_outdated from chipfoundry_cli.utils import ( collect_project_files, ensure_cf_directory, update_or_create_project_json, @@ -3587,10 +3591,12 @@ def maybe_abort_no_space(err, step_label): check=True, capture_output=True ) - - # Determine the python executable path in venv - venv_python = str(venv_cocotb / 'bin' / 'python3') - + + venv_python = str(venv_cocotb / 'bin' / 'python3') + + # Install (or reinstall) when the package is missing or --overwrite was used. + # The venv may already exist from a previous partial failure. + if not is_installed or overwrite: console.print("[cyan]Installing caravel-cocotb from source (chipfoundry/caravel-sim-infrastructure)...[/cyan]") subprocess.run( [venv_python, '-m', 'pip', 'install', '--upgrade', '--no-cache-dir', 'pip'], @@ -3695,7 +3701,208 @@ def maybe_abort_no_space(err, step_label): console.print("[bold green]Installation complete![/bold green]") else: console.print("[bold green]Setup complete![/bold green]") - + + +def _is_nix_toolchain_noise_line(line: str) -> bool: + """Match Nix fetch/build chatter posted by the PNR runner.""" + s = line.strip() + if not s: + return True + bare = s[len("[stderr]") :].lstrip() if s.startswith("[stderr]") else s + low = bare.lower() + if low.startswith("copying path"): + return True + if low.startswith("building '/nix/store/") or low.startswith('building "/nix/store/'): + return True + if low.startswith("downloading '") or low.startswith('downloading "'): + return True + if low.startswith("unpacking 'github:") or low.startswith('unpacking "github:'): + return True + if "paths will be fetched" in low or "paths will be copied" in low: + return True + if low.startswith("copying ") and " path" in low: + return True + if low.startswith("waiting for lock") or low.startswith("waiting for locks"): + return True + if bare.startswith("/nix/store/") or bare.startswith(" /nix/store/"): + return True + return False + + +def _print_remote_progress_message(msg: object, *, style: str = "dim") -> None: + """Print worker progress, collapsing Nix toolchain noise into a one-liner.""" + text = str(msg) + lines = text.splitlines() + kept: List[str] = [] + noise = 0 + for line in lines: + if _is_nix_toolchain_noise_line(line): + noise += 1 + else: + kept.append(line) + if noise and not kept: + console.print( + Text( + f"Nix toolchain resolving… ({noise} build/fetch lines suppressed)", + style="dim", + ) + ) + return + if noise: + kept.append(f"(also suppressed {noise} Nix build/fetch lines)") + out = "\n".join(kept).rstrip() + if out: + console.print(Text(out, style=style)) + + +def _queue_and_maybe_poll_remote_job( + *, + create_path: str, + job_get_path_template: str, + params: list, + dry_run: bool, + poll: bool, + wait_timeout: int, + label: str, +) -> None: + """POST a remote platform job and optionally poll until terminal status.""" + import time + from urllib.parse import urlencode + + import httpx as httpx_remote + + if dry_run: + console.print(f"[cyan]Would POST[/cyan] {create_path}?" + urlencode(params)) + return + if poll and wait_timeout < 0: + console.print( + "[red]✗[/red] --wait-timeout must be >= 0 (0 means no limit while polling)." + ) + raise SystemExit(1) + config = load_user_config() + api_key = config.get('api_key') + if not api_key: + console.print("[yellow]Not logged in.[/yellow] Run [bold]cf login[/bold] first.") + raise SystemExit(1) + api_url = _get_api_url() + client = httpx_remote.Client( + base_url=f"{api_url}/api/v1", + headers={ + 'Authorization': f'Bearer {api_key}', + 'User-Agent': _cf_user_agent(), + }, + timeout=120.0, + ) + try: + resp = client.post(create_path, params=params) + if resp.status_code == 401: + console.print("[red]✗[/red] API key is invalid or expired. Run [bold]cf login[/bold].") + raise SystemExit(1) + if not resp.is_success: + try: + detail = resp.json().get("detail", resp.text) + except Exception: + detail = resp.text + console.print(f"[red]✗[/red] {detail}") + raise SystemExit(1) + job = resp.json() + jid = job["id"] + st0 = job.get("status") or "unknown" + if st0 == "failed": + console.print(f"[cyan]{label}[/cyan] job_id={jid} status={st0}") + elif st0 == "running": + console.print(f"[cyan]{label} started[/cyan] job_id={jid} status={st0}") + else: + console.print(f"[cyan]Queued {label.lower()}[/cyan] job_id={jid} status={st0}") + if job.get("status") == "failed" and job.get("error_message"): + console.print(f"[red]✗[/red] {job['error_message']}") + raise SystemExit(1) + if job.get("status") == "completed": + console.print(f"[green]✓[/green] {label} completed") + if job.get("github_pr_url"): + console.print(f" Pull request: {job['github_pr_url']}") + return + if not poll: + console.print( + f"[dim]Not waiting: use [bold]--remote --poll[/bold] to stream progress " + f"([bold]--wait-timeout 0[/bold] = no time limit while polling).[/dim]" + ) + return + deadline = None if wait_timeout == 0 else time.monotonic() + wait_timeout + if wait_timeout == 0: + console.print("[dim]Polling until the job completes (no timeout).[/dim]") + else: + console.print( + f"[dim]Polling every 5s; stops after {wait_timeout}s if still queued or running. " + f"Use [bold]--wait-timeout 0[/bold] for no limit.[/dim]" + ) + last_status_seen = st0 + terminal = None + github_pr_url = None + fail_message = None + progress_emitted = 0 + get_path = job_get_path_template.format(jid=jid) + console.print("[dim]Worker log batches appear below as the platform receives them (5s poll).[/dim]") + while True: + if deadline is not None and time.monotonic() > deadline: + console.print( + f"[yellow]⚠[/yellow] Timed out waiting for {label.lower()} (job still queued or running)." + ) + console.print( + f"[dim]job_id={jid} — open the project in the portal or run [bold]cf status[/bold].[/dim]" + ) + console.print( + "[dim]Cancel a stuck run in the portal, or retry with e.g. " + "[bold]--remote --poll --wait-timeout 14400[/bold].[/dim]" + ) + raise SystemExit(1) + time.sleep(5) + r2 = client.get(get_path) + if r2.status_code == 401: + console.print("[red]✗[/red] API key is invalid or expired.") + raise SystemExit(1) + r2.raise_for_status() + j2 = r2.json() + st = j2.get("status") + prog = j2.get("progress") + if isinstance(prog, list) and len(prog) > progress_emitted: + for row in prog[progress_emitted:]: + if not isinstance(row, dict): + continue + msg = row.get("message") + if msg: + _print_remote_progress_message(msg) + progress_emitted = len(prog) + if st == "completed": + terminal = "completed" + github_pr_url = j2.get("github_pr_url") + break + if st == "failed": + terminal = "failed" + fail_message = j2.get("error_message") or "unknown error" + break + if st != last_status_seen: + console.print( + f"[dim]… job status[/dim] [cyan]{st or 'unknown'}[/cyan]" + ) + last_status_seen = st + + if terminal == "completed": + console.print(f"[green]✓[/green] {label} completed") + if github_pr_url: + console.print(f" Pull request: {github_pr_url}") + elif terminal == "failed": + console.print(f"[red]✗[/red] {label} failed: {fail_message}") + raise SystemExit(1) + except SystemExit: + raise + except Exception as e: + console.print(f"[red]✗[/red] {label} request failed: {e}") + raise SystemExit(1) + finally: + client.close() + + @main.command( 'harden', cls=CategorizedCommand, @@ -3704,6 +3911,7 @@ def maybe_abort_no_space(err, step_label): ("Run Controls", ["tag", "from_step"]), ("GUI Modes", ["open_in_openroad", "open_in_klayout"]), ("Execution Backend", ["pdk", "use_nix", "use_docker"]), + ("Remote", ["dry_run", "remote", "poll", "git_ref", "wait_timeout"]), ], ) @click.argument('macro', required=False) @@ -3717,8 +3925,46 @@ def maybe_abort_no_space(err, step_label): @click.option('--pdk', help='PDK to use (defaults to sky130A)') @click.option('--use-nix', is_flag=True, help='Force use of Nix (fails if Nix not available)') @click.option('--use-docker', is_flag=True, help='Force use of Docker (fails if Docker not available)') -def harden(macro, project_root, list_designs, list_from_steps, tag, from_step, open_in_openroad, open_in_klayout, pdk, use_nix, use_docker): - """Harden a macro using LibreLane (OpenLane 2).""" +@click.option('--dry-run', is_flag=True, help='Show the configuration without running') +@click.option('--remote', is_flag=True, help='Queue PNR on the ChipFoundry platform (requires cf login + linked project)') +@click.option( + '--poll', + is_flag=True, + help='With --remote: poll until the job finishes and print progress (5s interval).', +) +@click.option('--git-ref', default='main', show_default=True, help='Git branch or tag for remote PNR') +@click.option( + '--wait-timeout', + type=int, + default=7200, + show_default=True, + help='With --remote --poll: max seconds to wait (0 = no limit). Ignored without --poll.', +) +def harden( + macro, + project_root, + list_designs, + list_from_steps, + tag, + from_step, + open_in_openroad, + open_in_klayout, + pdk, + use_nix, + use_docker, + dry_run, + remote, + poll, + git_ref, + wait_timeout, +): + """Harden a macro using LibreLane (OpenLane 2). + + Examples: + cf harden user_proj_example + cf harden --list + cf harden user_proj_example --remote --poll + """ from datetime import datetime # If .cf/project.json exists in cwd, use it as default project_root @@ -3729,6 +3975,49 @@ def harden(macro, project_root, list_designs, list_from_steps, tag, from_step, o project_root = os.getcwd() project_root_path = Path(project_root) + + if poll and not remote: + console.print("[red]✗[/red] --poll requires --remote.") + raise SystemExit(1) + + if remote: + if list_designs or not macro: + console.print("[red]✗[/red] --remote requires a macro name (cannot use --list).") + raise SystemExit(1) + if list_from_steps or from_step or open_in_openroad or open_in_klayout: + console.print( + "[red]✗[/red] --remote cannot be combined with --from, --list-from-steps, or GUI modes." + ) + raise SystemExit(1) + if not check_project_initialized(project_root_path, 'harden', dry_run=dry_run, allow_graceful=True): + console.print(f"[red]✗[/red] Project not initialized. Please run 'cf init' first.") + return + platform_id = _load_project_platform_id(str(project_root_path)) + if not platform_id: + console.print( + "[red]✗[/red] Link this repo to a platform project (set platform_project_id via [bold]cf link[/bold])." + ) + raise SystemExit(1) + try: + verify_remote_job_repo(project_root_path, git_ref) + except RemotePrecheckGitError as e: + console.print(f"[red]✗[/red] {e}") + raise SystemExit(1) + remote_params = [("macro", macro), ("git_ref", git_ref)] + if pdk: + remote_params.append(("pdk", pdk)) + if tag: + remote_params.append(("run_tag", tag)) + _queue_and_maybe_poll_remote_job( + create_path=f"/projects/{platform_id}/pnr-jobs", + job_get_path_template=f"/projects/{platform_id}/pnr-jobs/{{jid}}", + params=remote_params, + dry_run=dry_run, + poll=poll, + wait_timeout=wait_timeout, + label="Remote PNR", + ) + return # Check if project is initialized (skip check for --list or when no macro specified, allow graceful return) if not list_designs and macro: @@ -4491,9 +4780,9 @@ def precheck(project_root, skip_checks, magic_drc, checks, list_checks, dry_run, isinstance(det, dict) and det.get("event") == "check_done" ): - console.print(Text(str(msg), style="bold")) + _print_remote_progress_message(msg, style="bold") else: - console.print(Text(str(msg), style="dim")) + _print_remote_progress_message(msg) progress_emitted = len(prog) if st == "completed": terminal = "completed" @@ -4668,7 +4957,21 @@ def precheck(project_root, skip_checks, magic_drc, checks, list_checks, dry_run, @click.option('--all', 'run_all', is_flag=True, help='Run all tests') @click.option('--tag', help='Test list tag/yaml file (e.g., all_tests or user_proj_tests)') @click.option('--dry-run', is_flag=True, help='Show the configuration without running') -def verify(test, project_root, sim, list_tests, run_all, tag, dry_run): +@click.option('--remote', is_flag=True, help='Queue simulation on the ChipFoundry platform (requires cf login + linked project)') +@click.option( + '--poll', + is_flag=True, + help='With --remote: poll until the job finishes and print progress (5s interval).', +) +@click.option('--git-ref', default='main', show_default=True, help='Git branch or tag for remote simulation') +@click.option( + '--wait-timeout', + type=int, + default=7200, + show_default=True, + help='With --remote --poll: max seconds to wait (0 = no limit). Ignored without --poll.', +) +def verify(test, project_root, sim, list_tests, run_all, tag, dry_run, remote, poll, git_ref, wait_timeout): """Run cocotb verification tests. Examples: @@ -4677,6 +4980,8 @@ def verify(test, project_root, sim, list_tests, run_all, tag, dry_run): cf verify counter_la --sim gl # Run gate-level simulation cf verify --all # Run all tests cf verify --tag all_tests # Run tests from a yaml list + cf verify counter_la --remote + cf verify --all --remote --poll """ # If .cf/project.json exists in cwd, use it as default project_root cwd_root, _ = get_project_json_from_cwd() @@ -4686,6 +4991,53 @@ def verify(test, project_root, sim, list_tests, run_all, tag, dry_run): project_root = os.getcwd() project_root_path = Path(project_root) + + if poll and not remote: + console.print("[red]✗[/red] --poll requires --remote.") + raise SystemExit(1) + + if remote: + if list_tests: + console.print("[red]✗[/red] --remote cannot be combined with --list.") + raise SystemExit(1) + if not check_project_initialized(project_root_path, 'verify', dry_run=dry_run, allow_graceful=True): + console.print(f"[red]✗[/red] Project not initialized. Please run 'cf init' first.") + return + if not test and not run_all and not tag: + console.print("[red]Error: Specify a test name, use --all, or --tag [/red]") + raise SystemExit(1) + mode_count = int(bool(test)) + int(bool(run_all)) + int(bool(tag)) + if mode_count != 1: + console.print("[red]Error: Use exactly one of: test name, --all, or --tag[/red]") + raise SystemExit(1) + platform_id = _load_project_platform_id(str(project_root_path)) + if not platform_id: + console.print( + "[red]✗[/red] Link this repo to a platform project (set platform_project_id via [bold]cf link[/bold])." + ) + raise SystemExit(1) + try: + verify_remote_job_repo(project_root_path, git_ref) + except RemotePrecheckGitError as e: + console.print(f"[red]✗[/red] {e}") + raise SystemExit(1) + remote_params = [("git_ref", git_ref), ("sim_type", (sim or "rtl").lower())] + if run_all: + remote_params.append(("run_all", "true")) + elif tag: + remote_params.append(("test_list_tag", tag)) + else: + remote_params.append(("test", test)) + _queue_and_maybe_poll_remote_job( + create_path=f"/projects/{platform_id}/simulation-jobs", + job_get_path_template=f"/projects/{platform_id}/simulation-jobs/{{jid}}", + params=remote_params, + dry_run=dry_run, + poll=poll, + wait_timeout=wait_timeout, + label="Remote simulation", + ) + return # Check if project is initialized (skip check if just listing tests, allow graceful return) if not list_tests: diff --git a/chipfoundry_cli/remote_precheck_git.py b/chipfoundry_cli/remote_precheck_git.py index 241ab2a..bd9cf7b 100644 --- a/chipfoundry_cli/remote_precheck_git.py +++ b/chipfoundry_cli/remote_precheck_git.py @@ -228,6 +228,47 @@ def verify_remote_precheck_repo( ) +def verify_remote_job_repo(project_root: Path, git_ref: str) -> None: + """ + Thin git consistency check for remote harden / verify. + + Ensures origin/{git_ref} tip matches HEAD. Does not require wrapper GDS or + other precheck layout inputs (those may not exist yet when hardening). + """ + repo = project_root.resolve() + git_marker = repo / ".git" + if not (git_marker.is_dir() or git_marker.is_file()): + raise RemotePrecheckGitError( + "Remote jobs require a git checkout with .git " + "(clone your GitHub repo, not a plain folder copy)." + ) + + remote_sha = _resolve_origin_tip_sha(repo, git_ref) + head_sha = _local_head_sha(repo) + if head_sha != remote_sha: + raise RemotePrecheckGitError( + f"Local HEAD ({head_sha[:7]}) must match origin {git_ref!r} ({remote_sha[:7]}). " + f"git checkout {git_ref} && git pull, or push your commits, then retry." + ) + + # If .cf/project.json is tracked, require it clean so the remote clone matches. + if _path_tracked_in_git(repo, CF_PROJECT_JSON_REL): + dirty = _porcelain_paths(repo) + for entry in dirty: + path = entry[2:] if entry.startswith("??") else entry + if path == CF_PROJECT_JSON_REL: + raise RemotePrecheckGitError( + f"{CF_PROJECT_JSON_REL!r} has uncommitted changes. " + "Commit or stash before queueing a remote job." + ) + r = _run_git(repo, "diff-index", "--quiet", "HEAD", "--", CF_PROJECT_JSON_REL) + if r.returncode != 0: + raise RemotePrecheckGitError( + f"{CF_PROJECT_JSON_REL!r} has uncommitted changes. " + "Commit or stash before queueing a remote job." + ) + + class RemotePushGitError(Exception): """Local repository state is not consistent with origin for remote push.""" diff --git a/pyproject.toml b/pyproject.toml index b0f2901..4ab1a51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "chipfoundry-cli" -version = "2.5.1" +version = "2.5.3" description = "CLI tool to automate ChipFoundry project submission to SFTP server" authors = ["ChipFoundry "] readme = "README.md" diff --git a/tests/test_remote_precheck_git.py b/tests/test_remote_precheck_git.py index 7f2e35c..9982025 100644 --- a/tests/test_remote_precheck_git.py +++ b/tests/test_remote_precheck_git.py @@ -107,3 +107,44 @@ def test_verify_skips_user_defines_when_only_xor(tmp_path: Path) -> None: ud.write_text("//changed\n") verify_remote_precheck_repo(work, "main", checks=("xor",), skip_checks=()) + + +def test_verify_remote_job_repo_skips_gds(tmp_path: Path) -> None: + """Remote harden/verify must not require wrapper GDS.""" + bare = tmp_path / "origin.git" + work = tmp_path / "work" + subprocess.run(["git", "init", "--bare", str(bare)], check=True, capture_output=True) + subprocess.run(["git", "clone", str(bare), str(work)], check=True, capture_output=True) + cf = work / ".cf" + cf.mkdir() + (cf / "project.json").write_text('{"project":{"type":"digital"}}') + (work / "README.md").write_text("no gds yet\n") + _git(work, "add", ".") + _git(work, "commit", "-m", "init") + _git(work, "branch", "-M", "main") + _git(work, "push", "-u", "origin", "main") + + from chipfoundry_cli.remote_precheck_git import verify_remote_job_repo + + verify_remote_job_repo(work, "main") + + +def test_verify_remote_job_repo_fails_when_ahead(tmp_path: Path) -> None: + bare = tmp_path / "origin.git" + work = tmp_path / "work" + subprocess.run(["git", "init", "--bare", str(bare)], check=True, capture_output=True) + subprocess.run(["git", "clone", str(bare), str(work)], check=True, capture_output=True) + (work / "README.md").write_text("x\n") + _git(work, "add", ".") + _git(work, "commit", "-m", "init") + _git(work, "branch", "-M", "main") + _git(work, "push", "-u", "origin", "main") + + (work / "README.md").write_text("y\n") + _git(work, "add", "README.md") + _git(work, "commit", "-m", "ahead") + + from chipfoundry_cli.remote_precheck_git import verify_remote_job_repo + + with pytest.raises(RemotePrecheckGitError, match="must match origin"): + verify_remote_job_repo(work, "main")