From 9d499e0c42b1778692bd89ed83e6986d5ccb6693 Mon Sep 17 00:00:00 2001 From: Hannu Valtonen Date: Wed, 28 Oct 2026 19:07:55 +0000 Subject: [PATCH 1/3] cli: reduce mirror clone authentication overhead --- README.md | 8 +-- avrea_cli/commands/repo.py | 13 +++-- avrea_cli/git_mirror.py | 22 +++++++- docs/REFERENCE.md | 5 +- docs/reference.json | 2 +- docs/starlight/index.md | 8 +-- docs/starlight/reference/repo.md | 5 +- man/man1/avr-repo-clone.1 | 2 +- tests/test_repo_clone.py | 90 +++++++++++++++++++++++++++----- 9 files changed, 123 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 12880015..bc53df90 100644 --- a/README.md +++ b/README.md @@ -93,10 +93,10 @@ avr repo clone foo/faa my-checkout --depth 1 --branch main avr repo clone foo/faa --placement https://git.fi.a.avrea.io ``` -Avrea discovers the repository's placements and picks the first mirror that -responds successfully from your machine. `--placement` accepts an advertised -mirror URL or hostname to select one explicitly. Give the `owner/repository` name -or an Avrea repository ID (`rep-...`). +Avrea uses a single available placement directly. With multiple placements, +it picks the first mirror that responds successfully from your machine. +`--placement` accepts an advertised mirror URL or hostname to select one +explicitly. Give the `owner/repository` name or an Avrea repository ID (`rep-...`). The repository must already have Git mirroring enabled and a usable placement. `origin` stays on the selected read-only mirror. The clone installs a diff --git a/avrea_cli/commands/repo.py b/avrea_cli/commands/repo.py index 9fc53266..cf752a7e 100644 --- a/avrea_cli/commands/repo.py +++ b/avrea_cli/commands/repo.py @@ -193,8 +193,9 @@ def repo_clone( """Clone a repository from its nearest available Avrea mirror. REPOSITORY is an owner/repository name or an Avrea repository ID (rep-...). - Available placements are raced from this client; the first to serve the - repository wins. Use --placement to select an exact URL or hostname. + When several placements are available, the first to serve the repository + wins. A single placement is used directly. Use --placement to select an + exact URL or hostname. The origin remote stays on the selected read-only mirror. A repository-local Avrea credential helper obtains fresh Git JWTs for subsequent fetch and pull @@ -253,12 +254,16 @@ def repo_clone( raise click.ClickException("Avrea returned invalid Git mirror placements.") endpoints = list(dict.fromkeys(mirror_endpoint(endpoint) for endpoint in endpoints)) endpoint = select_placement(endpoints, placement) if placement is not None else None + if endpoint is None and len(endpoints) == 1: + endpoint = endpoints[0] + token, _ = git_token(client, full_name) if endpoint is None: - token, _ = git_token(client, full_name) click.echo("Finding the nearest available Git mirror…", err=True) endpoint = asyncio.run(nearest_mirror(endpoints, full_name, token)) click.echo(f"Cloning {full_name} from {endpoint}", err=True) - ctx.exit(clone_mirror(config.public_api_url, endpoint, full_name, directory, branch=branch, depth=depth)) + ctx.exit( + clone_mirror(config.public_api_url, endpoint, full_name, directory, token=token, branch=branch, depth=depth) + ) @repo.command("list") diff --git a/avrea_cli/git_mirror.py b/avrea_cli/git_mirror.py index a4325b3f..235558f3 100644 --- a/avrea_cli/git_mirror.py +++ b/avrea_cli/git_mirror.py @@ -162,7 +162,10 @@ def credential_config(api_host: str, url: str, full_name: str) -> list[str]: ) # Also fail closed if the installed executable has since moved or startup # fails before the credential adapter runs. Git appends the operation. - helper = f'!f() {{ {command} "$@" || {{ printf "quit=1\\n\\n"; exit 1; }}; }}; f' + helper = ( + '!f() { case "$1" in store|erase) return 0;; esac; ' + f'{command} "$@" || {{ printf "quit=1\\n\\n"; exit 1; }}; }}; f' + ) return [ # Reset inherited helpers for this URL, so they neither supply stale # JWTs nor store the temporary credentials returned by our helper. @@ -179,6 +182,7 @@ def clone_mirror( full_name: str, directory: str | None, *, + token: str, branch: str | None, depth: int | None, ) -> int: @@ -191,7 +195,21 @@ def clone_mirror( env["GIT_TERMINAL_PROMPT"] = "0" env["GIT_ASKPASS"] = "" env["SSH_ASKPASS"] = "" - argv = ["git", "-c", f"http.{url}.extraHeader=", "clone"] + # Reuse the initial token without persisting it or exposing it in argv. + # These process-only overrides also keep Git from invoking any credential + # helper during this clone; the repository-local helper serves later fetches. + env["AVR_GIT_CLONE_AUTHORIZATION"] = f"Authorization: Bearer {token}" + argv = [ + "git", + "-c", + f"http.{url}.extraHeader=", + f"--config-env=http.{url}.extraHeader=AVR_GIT_CLONE_AUTHORIZATION", + "-c", + f"credential.{url}.helper=", + "-c", + f"http.{url}.followRedirects=false", + "clone", + ] for setting in credential_config(api_host, url, full_name): argv.extend(["--config", setting]) if branch is not None: diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 99cd9728..b2de7d78 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -2338,8 +2338,9 @@ avr repo clone [OPTIONS] REPOSITORY [DIRECTORY] ``` REPOSITORY is an owner/repository name or an Avrea repository ID (rep-...). -Available placements are raced from this client; the first to serve the -repository wins. Use --placement to select an exact URL or hostname. +When several placements are available, the first to serve the repository +wins. A single placement is used directly. Use --placement to select an +exact URL or hostname. The origin remote stays on the selected read-only mirror. A repository-local Avrea credential helper obtains fresh Git JWTs for subsequent fetch and pull diff --git a/docs/reference.json b/docs/reference.json index 914982b3..67068029 100644 --- a/docs/reference.json +++ b/docs/reference.json @@ -10187,7 +10187,7 @@ "full": "avr repo clone", "is_group": false, "short_help": "Clone a repository from its nearest available Avrea mirror.", - "help": "Clone a repository from its nearest available Avrea mirror.\n\nREPOSITORY is an owner/repository name or an Avrea repository ID (rep-...).\nAvailable placements are raced from this client; the first to serve the\nrepository wins. Use --placement to select an exact URL or hostname.\n\nThe origin remote stays on the selected read-only mirror. A repository-local\nAvrea credential helper obtains fresh Git JWTs for subsequent fetch and pull\noperations using your Avrea login. Push changes to the source repository\nusing a separate remote. Tokens normally last one hour; scoped API keys have\na shorter server-enforced lifetime. Tokens are never saved in the repository.\n\n\b\nExamples:\n avr repo clone foo/faa\n avr repo clone foo/faa my-checkout --depth 1\n avr repo clone foo/faa --placement https://git.fi.a.avrea.io\n", + "help": "Clone a repository from its nearest available Avrea mirror.\n\nREPOSITORY is an owner/repository name or an Avrea repository ID (rep-...).\nWhen several placements are available, the first to serve the repository\nwins. A single placement is used directly. Use --placement to select an\nexact URL or hostname.\n\nThe origin remote stays on the selected read-only mirror. A repository-local\nAvrea credential helper obtains fresh Git JWTs for subsequent fetch and pull\noperations using your Avrea login. Push changes to the source repository\nusing a separate remote. Tokens normally last one hour; scoped API keys have\na shorter server-enforced lifetime. Tokens are never saved in the repository.\n\n\b\nExamples:\n avr repo clone foo/faa\n avr repo clone foo/faa my-checkout --depth 1\n avr repo clone foo/faa --placement https://git.fi.a.avrea.io\n", "epilog": null, "deprecated": false, "hidden": false, diff --git a/docs/starlight/index.md b/docs/starlight/index.md index 371c31d7..d6e1293e 100644 --- a/docs/starlight/index.md +++ b/docs/starlight/index.md @@ -98,10 +98,10 @@ avr repo clone foo/faa my-checkout --depth 1 --branch main avr repo clone foo/faa --placement https://git.fi.a.avrea.io ``` -Avrea discovers the repository's placements and picks the first mirror that -responds successfully from your machine. `--placement` accepts an advertised -mirror URL or hostname to select one explicitly. Give the `owner/repository` name -or an Avrea repository ID (`rep-...`). +Avrea uses a single available placement directly. With multiple placements, +it picks the first mirror that responds successfully from your machine. +`--placement` accepts an advertised mirror URL or hostname to select one +explicitly. Give the `owner/repository` name or an Avrea repository ID (`rep-...`). The repository must already have Git mirroring enabled and a usable placement. `origin` stays on the selected read-only mirror. The clone installs a diff --git a/docs/starlight/reference/repo.md b/docs/starlight/reference/repo.md index 5bc77ff3..c1317420 100644 --- a/docs/starlight/reference/repo.md +++ b/docs/starlight/reference/repo.md @@ -20,8 +20,9 @@ avr repo clone [OPTIONS] REPOSITORY [DIRECTORY] ``` REPOSITORY is an owner/repository name or an Avrea repository ID (rep-...). -Available placements are raced from this client; the first to serve the -repository wins. Use --placement to select an exact URL or hostname. +When several placements are available, the first to serve the repository +wins. A single placement is used directly. Use --placement to select an +exact URL or hostname. The origin remote stays on the selected read-only mirror. A repository-local Avrea credential helper obtains fresh Git JWTs for subsequent fetch and pull diff --git a/man/man1/avr-repo-clone.1 b/man/man1/avr-repo-clone.1 index 0842df46..eb155b89 100644 --- a/man/man1/avr-repo-clone.1 +++ b/man/man1/avr-repo-clone.1 @@ -7,7 +7,7 @@ avr-repo-clone \- Clone a repository from its nearest available Avrea mirror. .SH DESCRIPTION Clone a repository from its nearest available Avrea mirror. .PP -REPOSITORY is an owner/repository name or an Avrea repository ID (rep-...). Available placements are raced from this client; the first to serve the repository wins. Use --placement to select an exact URL or hostname. +REPOSITORY is an owner/repository name or an Avrea repository ID (rep-...). When several placements are available, the first to serve the repository wins. A single placement is used directly. Use --placement to select an exact URL or hostname. .PP The origin remote stays on the selected read-only mirror. A repository-local Avrea credential helper obtains fresh Git JWTs for subsequent fetch and pull operations using your Avrea login. Push changes to the source repository using a separate remote. Tokens normally last one hour; scoped API keys have a shorter server-enforced lifetime. Tokens are never saved in the repository. .PP diff --git a/tests/test_repo_clone.py b/tests/test_repo_clone.py index 1768ce7b..9dfa28e1 100644 --- a/tests/test_repo_clone.py +++ b/tests/test_repo_clone.py @@ -36,6 +36,8 @@ class GitServerControls(TypedDict): redirect: str | None token_status: int + endpoints: list[str] | None + unauthorized_requests: int def token_response(token=TOKEN): @@ -86,9 +88,8 @@ def test_clone_requests_mirrors_by_name_and_races_advertised_placements(runner, nearest.assert_awaited_once_with(ENDPOINTS, "foo/faa", TOKEN) argv, kwargs = clone_process[0] assert argv[-7:] == ["--branch", "main", "--depth", "1", "--", ENDPOINTS[1] + "/foo/faa.git", "my checkout"] - assert "AVR_GIT_AUTHORIZATION" not in kwargs["env"] - assert TOKEN not in str(kwargs["env"]) - assert not any(arg.startswith("--config-env") for arg in argv) + assert kwargs["env"]["AVR_GIT_CLONE_AUTHORIZATION"] == f"Authorization: Bearer {TOKEN}" + assert "AVR_GIT_CLONE_AUTHORIZATION" not in os.environ assert TOKEN not in " ".join(argv) assert TOKEN not in result.output assert ENDPOINTS[1] in result.stderr @@ -103,10 +104,26 @@ def test_explicit_placement_skips_race(runner, monkeypatch, clone_api, clone_pro assert clone_process[0][0][-3:] == ["--", ENDPOINTS[0] + "/foo/faa.git", "faa"] assert clone_api[1] == [ ("GET", SESSION_PATH, None), + ("POST", "/tokens/git", {"repos": ["foo/faa"]}), ] nearest.assert_not_called() +@pytest.mark.parametrize("duplicates", [1, 2]) +def test_single_mirror_skips_probe_and_mints_one_clone_token(runner, monkeypatch, clone_api, clone_process, duplicates): + clone_api[0]["endpoints"] = [ENDPOINTS[0]] * duplicates + nearest = AsyncMock(side_effect=AssertionError("one mirror needs no selection probe")) + monkeypatch.setattr("avrea_cli.commands.repo.nearest_mirror", nearest) + result = runner.invoke(cli, ["repo", "clone", "foo/faa"]) + assert result.exit_code == 0, result.output + assert clone_api[1] == [ + ("GET", SESSION_PATH, None), + ("POST", "/tokens/git", {"repos": ["foo/faa"]}), + ] + nearest.assert_not_called() + assert clone_process[0][1]["env"]["AVR_GIT_CLONE_AUTHORIZATION"] == f"Authorization: Bearer {TOKEN}" + + def test_unknown_placement_fails_before_minting_token(runner, clone_api, clone_process): result = runner.invoke(cli, ["repo", "clone", "foo/faa", "--placement", "https://untrusted.example"]) assert result.exit_code == 2 @@ -128,6 +145,7 @@ def test_clone_uses_full_name_without_organization_configuration( assert result.exit_code == 0, result.output assert clone_api[1][:2] == [ ("GET", SESSION_PATH, None), + ("POST", "/tokens/git", {"repos": ["foo/faa"]}), ] assert clone_process @@ -358,6 +376,23 @@ def test_helper_does_not_store_or_revoke_credentials(runner, clone_api, operatio assert clone_api[1] == [] +@pytest.mark.parametrize("operation", ["approve", "reject"]) +def test_persisted_helper_handles_store_and_erase_without_starting_cli(tmp_path, monkeypatch, operation): + monkeypatch.setattr("avrea_cli.git_mirror.shutil.which", lambda _: str(tmp_path / "must-not-start-avr")) + settings = credential_config("https://api.avrea.com", ENDPOINTS[0] + "/foo/faa.git", "foo/faa") + helper = settings[1].split("=", 1)[1] + result = subprocess.run( + ["git", "-c", "credential.helper=", "-c", f"credential.helper={helper}", "credential", operation], + input=f"protocol=https\nhost=git.fi.example.com\npath=foo/faa.git\nusername=avr\npassword={TOKEN}\n\n", + capture_output=True, + text=True, + timeout=10, + ) + assert result.returncode == 0 + assert result.stdout == "" + assert result.stderr == "" + + @pytest.fixture def git_server(tmp_path, monkeypatch): """A real smart-HTTP Git backend and a fake Avrea token/session API.""" @@ -383,7 +418,12 @@ def git_server(tmp_path, monkeypatch): subprocess.run(["git", "clone", "--bare", str(source), str(bare)], check=True, capture_output=True) issued = [] authenticated = [] - controls: GitServerControls = {"redirect": None, "token_status": 200} + controls: GitServerControls = { + "redirect": None, + "token_status": 200, + "endpoints": None, + "unauthorized_requests": 0, + } class Handler(BaseHTTPRequestHandler): def log_message(self, *args): @@ -397,7 +437,9 @@ def send_json(self, data): def do_GET(self): if self.path in {SESSION_PATH, f"/repos/{REPO_ID}/mirror/session"}: - self.send_json({"browsable": True, "full_name": "foo/faa", "endpoints": [base_url]}) + self.send_json( + {"browsable": True, "full_name": "foo/faa", "endpoints": controls["endpoints"] or [base_url]} + ) else: self.git_backend() @@ -428,6 +470,7 @@ def git_backend(self): else: token = authorization.removeprefix("Bearer ") if not issued or token != issued[-1]: + controls["unauthorized_requests"] += 1 self.send_response(401) self.send_header("WWW-Authenticate", 'Basic realm="mirror"') self.end_headers() @@ -478,8 +521,13 @@ def git_backend(self): @pytest.mark.parametrize("repository", ["foo/faa", REPO_ID]) -def test_real_clone_and_later_fetch_use_fresh_tokens(runner, monkeypatch, tmp_path, git_server, repository): - base_url, issued, authenticated, _ = git_server +@pytest.mark.parametrize("multiple_mirrors", [False, True]) +def test_real_clone_and_later_fetch_use_fresh_tokens( + runner, monkeypatch, tmp_path, git_server, redirect_listener, repository, multiple_mirrors +): + base_url, issued, authenticated, controls = git_server + if multiple_mirrors: + controls["endpoints"] = [base_url, redirect_listener[0]] monkeypatch.setenv("AVR_HOST", base_url) stored_credentials = tmp_path / "stored credentials" seeded = base_url.replace("://", "://stale:stale@") + "/foo/faa.git\n" @@ -495,7 +543,8 @@ def test_real_clone_and_later_fetch_use_fresh_tokens(runner, monkeypatch, tmp_pa assert (destination / "README").read_text() == "mirror content\n" origin = subprocess.check_output(["git", "-C", str(destination), "remote", "get-url", "origin"], text=True).strip() assert origin == base_url + "/foo/faa.git" - assert issued == ["git-jwt-1", "git-jwt-2"] + assert issued == ["git-jwt-1"] + assert controls["unauthorized_requests"] == 0 config = (destination / ".git/config").read_text() assert "git-jwt-" not in config assert "test-token" not in config @@ -508,7 +557,7 @@ def test_real_clone_and_later_fetch_use_fresh_tokens(runner, monkeypatch, tmp_pa pushed = subprocess.run(["git", "-C", str(destination), "push"], capture_output=True, text=True, timeout=15) assert pushed.returncode != 0 assert "403" in pushed.stderr - assert issued == [f"git-jwt-{i}" for i in range(1, 6)] + assert issued == [f"git-jwt-{i}" for i in range(1, 5)] assert set(authenticated) == set(issued) assert (tmp_path / "global-config").read_text() == global_config assert stored_credentials.read_text() == seeded @@ -520,7 +569,7 @@ def missing(*args, **kwargs): monkeypatch.setattr("avrea_cli.git_mirror.subprocess.run", missing) with pytest.raises(click.ClickException, match="Git is required"): - clone_mirror("https://api.avrea.com", ENDPOINTS[0], "foo/faa", None, branch=None, depth=None) + clone_mirror("https://api.avrea.com", ENDPOINTS[0], "foo/faa", None, token=TOKEN, branch=None, depth=None) @pytest.mark.parametrize( @@ -620,18 +669,20 @@ def test_valid_dns_and_ip_endpoints(endpoint): def test_clone_strips_git_suffix_from_rep_prefixed_owner(runner, api, clone_process): path = "/repos/rep-acme/bar/mirror/session" api.reply("GET", path, json={"browsable": True, "full_name": "rep-acme/bar", "endpoints": ENDPOINTS}) + api.reply("POST", "/tokens/git", json=token_response()) result = runner.invoke(cli, ["repo", "clone", "rep-acme/bar.git", "--placement", ENDPOINTS[0]]) assert result.exit_code == 0, result.output - assert [(r.method, r.url.path) for r in api.requests] == [("GET", path)] + assert [(r.method, r.url.path) for r in api.requests] == [("GET", path), ("POST", "/tokens/git")] def test_scoped_clone_uses_full_name_without_org(runner, monkeypatch, api, clone_process): monkeypatch.setenv("AVR_TOKEN", "avs_test-token") monkeypatch.delenv("AVR_ORG") api.reply("GET", SESSION_PATH, json={"browsable": True, "full_name": "foo/faa", "endpoints": ENDPOINTS}) + api.reply("POST", "/tokens/git", json=token_response()) result = runner.invoke(cli, ["repo", "clone", "foo/faa", "--placement", ENDPOINTS[0]]) assert result.exit_code == 0, result.output - assert [request.url.path for request in api.requests] == [SESSION_PATH] + assert [request.url.path for request in api.requests] == [SESSION_PATH, "/tokens/git"] assert api.requests[0].headers["Authorization"] == "Bearer avs_test-token" @@ -749,6 +800,21 @@ def test_native_git_does_not_follow_mirror_redirects(runner, monkeypatch, tmp_pa assert requests == [] +def test_initial_clone_does_not_forward_token_to_redirect(runner, monkeypatch, tmp_path, git_server, redirect_listener): + base_url, issued, _, controls = git_server + sink_url, requests = redirect_listener + controls["redirect"] = sink_url + monkeypatch.setenv("AVR_HOST", base_url) + monkeypatch.setenv("GIT_CONFIG_COUNT", "1") + monkeypatch.setenv("GIT_CONFIG_KEY_0", f"http.{base_url}/foo/faa.git.followRedirects") + monkeypatch.setenv("GIT_CONFIG_VALUE_0", "true") + result = runner.invoke(cli, ["repo", "clone", "foo/faa", str(tmp_path / "checkout")]) + assert result.exit_code != 0 + assert issued == ["git-jwt-1"] + assert requests == [] + assert issued[0] not in result.output + + @pytest.mark.parametrize("failure", [401, 503, "missing-helper"]) def test_native_git_stops_without_askpass_on_helper_failure(runner, monkeypatch, tmp_path, git_server, failure): base_url, _, _, controls = git_server From 31830c311f4e683467a604a90e4ded410b9a95b6 Mon Sep 17 00:00:00 2001 From: Hannu Valtonen Date: Wed, 28 Oct 2026 19:20:05 +0000 Subject: [PATCH 2/3] cli: overlap mirror discovery and token issuance --- avrea_cli/commands/repo.py | 84 ++++++++++++++++----------- avrea_cli/git_mirror.py | 15 ++++- tests/test_repo_clone.py | 116 ++++++++++++++++++++++++++++++------- 3 files changed, 158 insertions(+), 57 deletions(-) diff --git a/avrea_cli/commands/repo.py b/avrea_cli/commands/repo.py index cf752a7e..08bd378e 100644 --- a/avrea_cli/commands/repo.py +++ b/avrea_cli/commands/repo.py @@ -35,6 +35,7 @@ from avrea_cli.output import output_list from avrea_cli.repo_context import resolve_repo from avrea_cli.repo_context import resolve_repo_or_detect +from concurrent.futures import ThreadPoolExecutor from typing import Any from urllib.parse import quote import asyncio @@ -223,40 +224,55 @@ def repo_clone( client: ApiClient = ctx.obj["client"] config: CliConfig = ctx.obj["config"] ensure_authenticated(config) - try: - session = client.public_get(f"/repos/{quote(repository, safe='/')}/mirror/session") - except httpx.HTTPStatusError as exc: - handle_http_error(exc, "discover Git mirror placements") - except httpx.HTTPError: - raise click.ClickException("Could not reach Avrea to discover Git mirror placements.") from None - if not isinstance(session, dict) or not isinstance(session.get("browsable"), bool): - raise click.ClickException("Avrea returned an invalid Git mirror session.") - if not session["browsable"]: - match session.get("unavailable_reason"): - case "mirror_off": - name = repository if "/" in repository else "OWNER/REPO" - raise click.ClickException( - "Git mirroring is disabled. List locations with `avr repo mirror locations`, then run " - f"`avr repo mirror create {name} --location `." - ) - case "not_ready": - raise click.ClickException( - "No Git mirror placement is ready. Check `avr repo mirror status` and try again." - ) - case reason: - raise click.ClickException(f"Avrea reported an unknown mirror unavailability reason: {reason!r}.") - full_name = session.get("full_name") - endpoints = session.get("endpoints") - if not isinstance(full_name, str) or not isinstance(endpoints, list) or not endpoints: - raise click.ClickException("Avrea returned an incomplete Git mirror session.") - full_name = validate_repo_name(full_name) - if not all(isinstance(endpoint, str) for endpoint in endpoints): - raise click.ClickException("Avrea returned invalid Git mirror placements.") - endpoints = list(dict.fromkeys(mirror_endpoint(endpoint) for endpoint in endpoints)) - endpoint = select_placement(endpoints, placement) if placement is not None else None - if endpoint is None and len(endpoints) == 1: - endpoint = endpoints[0] - token, _ = git_token(client, full_name) + with ThreadPoolExecutor(max_workers=1) as executor: + # Names can be authorized while discovery runs. IDs need discovery's + # canonical name first. Only HTTP I/O runs in the worker so discovery + # errors take precedence over a speculative token request's errors. + pending = ( + executor.submit(client.public_post, "/tokens/git", json={"repos": [repository]}) + if "/" in repository + else None + ) + try: + session = client.public_get(f"/repos/{quote(repository, safe='/')}/mirror/session") + except httpx.HTTPStatusError as exc: + handle_http_error(exc, "discover Git mirror placements") + except httpx.HTTPError: + raise click.ClickException("Could not reach Avrea to discover Git mirror placements.") from None + if not isinstance(session, dict) or not isinstance(session.get("browsable"), bool): + raise click.ClickException("Avrea returned an invalid Git mirror session.") + if not session["browsable"]: + match session.get("unavailable_reason"): + case "mirror_off": + name = repository if "/" in repository else "OWNER/REPO" + raise click.ClickException( + "Git mirroring is disabled. List locations with `avr repo mirror locations`, then run " + f"`avr repo mirror create {name} --location `." + ) + case "not_ready": + raise click.ClickException( + "No Git mirror placement is ready. Check `avr repo mirror status` and try again." + ) + case reason: + raise click.ClickException(f"Avrea reported an unknown mirror unavailability reason: {reason!r}.") + full_name = session.get("full_name") + endpoints = session.get("endpoints") + if not isinstance(full_name, str) or not isinstance(endpoints, list) or not endpoints: + raise click.ClickException("Avrea returned an incomplete Git mirror session.") + full_name = validate_repo_name(full_name) + if not all(isinstance(endpoint, str) for endpoint in endpoints): + raise click.ClickException("Avrea returned invalid Git mirror placements.") + endpoints = list(dict.fromkeys(mirror_endpoint(endpoint) for endpoint in endpoints)) + endpoint = select_placement(endpoints, placement) if placement is not None else None + if endpoint is None and len(endpoints) == 1: + endpoint = endpoints[0] + if pending is not None and full_name != repository: + # Finish and discard the speculative request, including any error. + # A renamed or recased repository needs credentials for its exact + # advertised name; never send the old name's token to the mirror. + pending.exception() + pending = None + token, _ = git_token(client, full_name, pending=pending) if endpoint is None: click.echo("Finding the nearest available Git mirror…", err=True) endpoint = asyncio.run(nearest_mirror(endpoints, full_name, token)) diff --git a/avrea_cli/git_mirror.py b/avrea_cli/git_mirror.py index 235558f3..db5d49a9 100644 --- a/avrea_cli/git_mirror.py +++ b/avrea_cli/git_mirror.py @@ -3,8 +3,10 @@ from avrea_cli.api_client import ApiClient from avrea_cli.config import require_secure_host from avrea_cli.helpers import handle_http_error +from concurrent.futures import Future from datetime import UTC from datetime import datetime +from typing import Any from urllib.parse import quote from urllib.parse import urlsplit import asyncio @@ -60,10 +62,17 @@ def mirror_endpoint(value: str) -> str: return value -def git_token(client: ApiClient, full_name: str) -> tuple[str, datetime]: - """The issuer owns the TTL: one hour, or less for scoped API keys.""" +def git_token( + client: ApiClient, full_name: str, *, pending: Future[dict[str, Any]] | None = None +) -> tuple[str, datetime]: + """Obtain a token, optionally finishing a request already made for full_name. + + The issuer owns the TTL: one hour, or less for scoped API keys. + """ try: - response = client.public_post("/tokens/git", json={"repos": [full_name]}) + response = ( + pending.result() if pending is not None else client.public_post("/tokens/git", json={"repos": [full_name]}) + ) except httpx.HTTPStatusError as exc: handle_http_error(exc, "issue a Git token") except httpx.HTTPError: diff --git a/tests/test_repo_clone.py b/tests/test_repo_clone.py index 9dfa28e1..d79eca1b 100644 --- a/tests/test_repo_clone.py +++ b/tests/test_repo_clone.py @@ -12,6 +12,7 @@ from http.server import BaseHTTPRequestHandler from http.server import ThreadingHTTPServer from pathlib import Path +from threading import Barrier from threading import Thread from typing import TypedDict from unittest.mock import AsyncMock @@ -75,14 +76,72 @@ def run(argv, **kwargs): return calls +def test_clone_overlaps_discovery_and_token_requests(runner, monkeypatch, clone_process): + requests_started = Barrier(2, timeout=3) + completed = set() + + def respond(request): + assert not clone_process + requests_started.wait() + completed.add(request.url.path) + if request.method == "GET": + return httpx.Response(200, json={"browsable": True, "full_name": "foo/faa", "endpoints": ENDPOINTS}) + assert request.url.path == "/tokens/git" + assert json.loads(request.content) == {"repos": ["foo/faa"]} + return httpx.Response(200, json=token_response()) + + async def nearest(endpoints, full_name, token): + assert completed == {SESSION_PATH, "/tokens/git"} + assert token == TOKEN + return endpoints[0] + + monkeypatch.setattr("avrea_cli.api_client.CompressingTransport", lambda: httpx.MockTransport(respond)) + monkeypatch.setattr("avrea_cli.commands.repo.nearest_mirror", nearest) + result = runner.invoke(cli, ["repo", "clone", "foo/faa"]) + assert result.exit_code == 0, result.output + assert len(clone_process) == 1 + + +@pytest.mark.parametrize("original", ["Foo/Faa", "old/name"]) +@pytest.mark.parametrize("speculative_status", [200, 403]) +def test_clone_refreshes_token_if_discovery_changes_name(runner, api, clone_process, original, speculative_status): + api.reply( + "GET", + f"/repos/{original}/mirror/session", + json={"browsable": True, "full_name": "foo/faa", "endpoints": [ENDPOINTS[0]]}, + ) + api.reply("POST", "/tokens/git", speculative_status, json=token_response("old-name-token")) + api.reply("POST", "/tokens/git", json=token_response()) + result = runner.invoke(cli, ["repo", "clone", original]) + assert result.exit_code == 0, result.output + assert [json.loads(request.content) for request in api.sent("POST", "/tokens/git")] == [ + {"repos": [original]}, + {"repos": ["foo/faa"]}, + ] + assert clone_process[0][1]["env"]["AVR_GIT_CLONE_AUTHORIZATION"] == f"Authorization: Bearer {TOKEN}" + assert "old-name-token" not in result.output + assert "Error:" not in result.output + + +def test_discovery_error_takes_precedence_over_concurrent_token_error(runner, api, clone_process): + api.reply("GET", SESSION_PATH, 404, json={"detail": "repository missing"}) + api.reply("POST", "/tokens/git", 401, json={"detail": "token request failed"}) + result = runner.invoke(cli, ["repo", "clone", "foo/faa"]) + assert result.exit_code == 1 + assert "repository missing" in result.stderr + assert "token request failed" not in result.stderr + assert "avr auth login" not in result.stderr + assert not clone_process + + def test_clone_requests_mirrors_by_name_and_races_advertised_placements(runner, monkeypatch, clone_api, clone_process): nearest = AsyncMock(return_value=ENDPOINTS[1]) monkeypatch.setattr("avrea_cli.commands.repo.nearest_mirror", nearest) - result = runner.invoke(cli, ["repo", "clone", "Foo/Faa", "my checkout", "--depth", "1", "-b", "main"]) + result = runner.invoke(cli, ["repo", "clone", "foo/faa", "my checkout", "--depth", "1", "-b", "main"]) assert result.exit_code == 0, result.output _, calls = clone_api - assert calls == [ - ("GET", "/repos/Foo/Faa/mirror/session", None), + assert sorted(calls, key=lambda call: call[0]) == [ + ("GET", SESSION_PATH, None), ("POST", "/tokens/git", {"repos": ["foo/faa"]}), ] nearest.assert_awaited_once_with(ENDPOINTS, "foo/faa", TOKEN) @@ -102,7 +161,7 @@ def test_explicit_placement_skips_race(runner, monkeypatch, clone_api, clone_pro result = runner.invoke(cli, ["repo", "clone", "foo/faa", "--placement", placement]) assert result.exit_code == 0, result.output assert clone_process[0][0][-3:] == ["--", ENDPOINTS[0] + "/foo/faa.git", "faa"] - assert clone_api[1] == [ + assert sorted(clone_api[1], key=lambda call: call[0]) == [ ("GET", SESSION_PATH, None), ("POST", "/tokens/git", {"repos": ["foo/faa"]}), ] @@ -116,7 +175,7 @@ def test_single_mirror_skips_probe_and_mints_one_clone_token(runner, monkeypatch monkeypatch.setattr("avrea_cli.commands.repo.nearest_mirror", nearest) result = runner.invoke(cli, ["repo", "clone", "foo/faa"]) assert result.exit_code == 0, result.output - assert clone_api[1] == [ + assert sorted(clone_api[1], key=lambda call: call[0]) == [ ("GET", SESSION_PATH, None), ("POST", "/tokens/git", {"repos": ["foo/faa"]}), ] @@ -124,12 +183,15 @@ def test_single_mirror_skips_probe_and_mints_one_clone_token(runner, monkeypatch assert clone_process[0][1]["env"]["AVR_GIT_CLONE_AUTHORIZATION"] == f"Authorization: Bearer {TOKEN}" -def test_unknown_placement_fails_before_minting_token(runner, clone_api, clone_process): +def test_unknown_placement_fails_before_using_token(runner, monkeypatch, clone_api, clone_process): + nearest = AsyncMock() + monkeypatch.setattr("avrea_cli.commands.repo.nearest_mirror", nearest) result = runner.invoke(cli, ["repo", "clone", "foo/faa", "--placement", "https://untrusted.example"]) assert result.exit_code == 2 assert "Available placements" in result.output assert ENDPOINTS[0] in result.output - assert not any(call[0] == "POST" for call in clone_api[1]) + nearest.assert_not_called() + assert TOKEN not in result.output assert not clone_process @@ -143,7 +205,7 @@ def test_clone_uses_full_name_without_organization_configuration( monkeypatch.setenv("AVR_ORG", default_org) result = runner.invoke(cli, ["repo", "clone", "foo/faa", "--placement", ENDPOINTS[0]]) assert result.exit_code == 0, result.output - assert clone_api[1][:2] == [ + assert sorted(clone_api[1], key=lambda call: call[0]) == [ ("GET", SESSION_PATH, None), ("POST", "/tokens/git", {"repos": ["foo/faa"]}), ] @@ -191,14 +253,17 @@ def test_clone_by_id_uses_canonical_name(runner, monkeypatch, api, clone_process ({"browsable": True, "full_name": "foo/faa", "endpoints": [None]}, "invalid Git mirror placements"), ], ) -def test_unavailable_mirror_does_not_mint_token(runner, clone_api, clone_process, session, message): +def test_unavailable_mirror_does_not_use_token(runner, monkeypatch, clone_api, clone_process, session, message): + nearest = AsyncMock() + monkeypatch.setattr("avrea_cli.commands.repo.nearest_mirror", nearest) clone_api[0].clear() clone_api[0].update(session) result = runner.invoke(cli, ["repo", "clone", "foo/faa"]) assert result.exit_code == 1 assert isinstance(result.exception, SystemExit) assert message in result.output - assert not any(call[0] == "POST" for call in clone_api[1]) + nearest.assert_not_called() + assert TOKEN not in result.output assert not clone_process @@ -229,9 +294,11 @@ def test_mirror_endpoints_must_be_secure_origins(url): mirror_endpoint(url) -@pytest.mark.parametrize("status", [401, 403, 503]) +@pytest.mark.parametrize("status", [401, 403, 503, "connection"]) def test_token_errors_are_actionable(runner, monkeypatch, clone_api, clone_process, status): def post(self, path, json=None): + if status == "connection": + raise httpx.ConnectError("private transport details") response = httpx.Response(status, request=httpx.Request("POST", "https://api.avrea.com/tokens/git")) response.raise_for_status() @@ -240,6 +307,9 @@ def post(self, path, json=None): assert isinstance(result.exception, SystemExit) assert ("avr auth login" if status == 401 else "Error:") in result.stderr assert result.exit_code == (4 if status == 401 else 1) + if status == "connection": + assert "Could not reach Avrea to issue a Git token." in result.stderr + assert "private transport details" not in result.stderr assert not clone_process @@ -582,16 +652,18 @@ def missing(*args, **kwargs): ("full_name", "foo/faa?secret"), ], ) -def test_clone_rejects_unsafe_session_before_any_credential_use( +def test_clone_rejects_unsafe_session_before_sending_credentials_to_mirror( runner, monkeypatch, clone_api, clone_process, field, value ): - monkeypatch.setattr("avrea_cli.commands.repo.nearest_mirror", AsyncMock(return_value=ENDPOINTS[0])) + nearest = AsyncMock() + monkeypatch.setattr("avrea_cli.commands.repo.nearest_mirror", nearest) clone_api[0][field] = value result = runner.invoke(cli, ["repo", "clone", "foo/faa"]) assert result.exit_code != 0 assert isinstance(result.exception, SystemExit) assert "Error:" in result.stderr - assert all(call[0] != "POST" for call in clone_api[1]) + nearest.assert_not_called() + assert TOKEN not in result.output assert not clone_process @@ -672,7 +744,7 @@ def test_clone_strips_git_suffix_from_rep_prefixed_owner(runner, api, clone_proc api.reply("POST", "/tokens/git", json=token_response()) result = runner.invoke(cli, ["repo", "clone", "rep-acme/bar.git", "--placement", ENDPOINTS[0]]) assert result.exit_code == 0, result.output - assert [(r.method, r.url.path) for r in api.requests] == [("GET", path), ("POST", "/tokens/git")] + assert sorted((r.method, r.url.path) for r in api.requests) == [("GET", path), ("POST", "/tokens/git")] def test_scoped_clone_uses_full_name_without_org(runner, monkeypatch, api, clone_process): @@ -682,25 +754,29 @@ def test_scoped_clone_uses_full_name_without_org(runner, monkeypatch, api, clone api.reply("POST", "/tokens/git", json=token_response()) result = runner.invoke(cli, ["repo", "clone", "foo/faa", "--placement", ENDPOINTS[0]]) assert result.exit_code == 0, result.output - assert [request.url.path for request in api.requests] == [SESSION_PATH, "/tokens/git"] - assert api.requests[0].headers["Authorization"] == "Bearer avs_test-token" + assert sorted(request.url.path for request in api.requests) == [SESSION_PATH, "/tokens/git"] + assert all(request.headers["Authorization"] == "Bearer avs_test-token" for request in api.requests) @pytest.mark.parametrize("status", [404, 503]) def test_clone_discovery_failure_does_not_clone(runner, api, clone_process, status): api.reply("GET", SESSION_PATH, status, json={"detail": "Cannot access repository"}) + api.reply("POST", "/tokens/git", json=token_response()) result = runner.invoke(cli, ["repo", "clone", "foo/faa", "--placement", ENDPOINTS[0]]) assert result.exit_code == 1 - assert {request.url.path for request in api.requests} == {SESSION_PATH} + assert {request.url.path for request in api.requests} == {SESSION_PATH, "/tokens/git"} + assert TOKEN not in result.output assert not clone_process def test_discovery_authentication_failure_exits_with_auth_required(runner, api, clone_process): api.reply("GET", SESSION_PATH, 401, json={"detail": "Unauthorized"}) + api.reply("POST", "/tokens/git", 401, json={"detail": "Unauthorized"}) result = runner.invoke(cli, ["repo", "clone", "foo/faa"]) assert result.exit_code == 4 assert "avr auth login" in result.stderr - assert [request.url.path for request in api.requests] == [SESSION_PATH] + assert {request.url.path for request in api.requests} == {SESSION_PATH, "/tokens/git"} + assert result.stderr.count("avr auth login") == 1 assert not clone_process @@ -715,7 +791,7 @@ def fail(request): result = runner.invoke(cli, ["repo", "clone", "foo/faa"]) assert result.exit_code == 1 assert "Could not reach Avrea to discover Git mirror placements." in result.stderr - assert {request.url.path for request in requests} == {SESSION_PATH} + assert {request.url.path for request in requests} == {SESSION_PATH, "/tokens/git"} assert not clone_process From 832e223c613d19c3db0c71efa4118a179d1bafef Mon Sep 17 00:00:00 2001 From: Hannu Valtonen Date: Fri, 30 Oct 2026 12:34:04 +0000 Subject: [PATCH 3/3] cli: restore mirror clone failure diagnostics Probe directly selected mirrors after clone failures while preserving Git output and exit codes. Document Git 2.31+, clarify child-process token visibility, and cover recovery hints with native Git tests. --- README.md | 3 +- avrea_cli/commands/repo.py | 15 +++++-- avrea_cli/git_mirror.py | 4 +- docs/REFERENCE.md | 2 +- docs/reference.json | 2 +- docs/starlight/index.md | 3 +- docs/starlight/reference/repo.md | 2 +- man/man1/avr-repo-clone.1 | 2 +- tests/test_repo_clone.py | 67 ++++++++++++++++++++++++++++++++ 9 files changed, 90 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index bc53df90..2f92c91b 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,8 @@ Avrea uses a single available placement directly. With multiple placements, it picks the first mirror that responds successfully from your machine. `--placement` accepts an advertised mirror URL or hostname to select one explicitly. Give the `owner/repository` name or an Avrea repository ID (`rep-...`). -The repository must already have Git mirroring enabled and a usable placement. +Requires Git 2.31 or newer. The repository must already have Git mirroring +enabled and a usable placement. `origin` stays on the selected read-only mirror. The clone installs a repository-local credential helper so ordinary `git fetch` and `git pull` diff --git a/avrea_cli/commands/repo.py b/avrea_cli/commands/repo.py index 08bd378e..60ceb512 100644 --- a/avrea_cli/commands/repo.py +++ b/avrea_cli/commands/repo.py @@ -196,7 +196,7 @@ def repo_clone( REPOSITORY is an owner/repository name or an Avrea repository ID (rep-...). When several placements are available, the first to serve the repository wins. A single placement is used directly. Use --placement to select an - exact URL or hostname. + exact URL or hostname. Requires Git 2.31 or newer. The origin remote stays on the selected read-only mirror. A repository-local Avrea credential helper obtains fresh Git JWTs for subsequent fetch and pull @@ -273,13 +273,22 @@ def repo_clone( pending.exception() pending = None token, _ = git_token(client, full_name, pending=pending) + selected_directly = endpoint is not None if endpoint is None: click.echo("Finding the nearest available Git mirror…", err=True) endpoint = asyncio.run(nearest_mirror(endpoints, full_name, token)) click.echo(f"Cloning {full_name} from {endpoint}", err=True) - ctx.exit( - clone_mirror(config.public_api_url, endpoint, full_name, directory, token=token, branch=branch, depth=depth) + returncode = clone_mirror( + config.public_api_url, endpoint, full_name, directory, token=token, branch=branch, depth=depth ) + if returncode > 0 and selected_directly: + # Diagnose failures without adding a request to successful direct clones. + # A healthy mirror leaves Git's own error (e.g. a local path conflict) alone. + try: + asyncio.run(nearest_mirror([endpoint], full_name, token)) + except click.ClickException as exc: + exc.show() + ctx.exit(returncode) @repo.command("list") diff --git a/avrea_cli/git_mirror.py b/avrea_cli/git_mirror.py index db5d49a9..f4c91346 100644 --- a/avrea_cli/git_mirror.py +++ b/avrea_cli/git_mirror.py @@ -197,7 +197,7 @@ def clone_mirror( ) -> int: url = clone_url(endpoint, full_name) env = os.environ.copy() - # Secrets must not appear in argv, Git config, or user-enabled trace logs. + # Disable tracing enabled through the inherited environment. for key in list(env): if key.startswith("GIT_TRACE") or key == "GIT_CURL_VERBOSE": del env[key] @@ -205,6 +205,8 @@ def clone_mirror( env["GIT_ASKPASS"] = "" env["SSH_ASKPASS"] = "" # Reuse the initial token without persisting it or exposing it in argv. + # Git re-exports it in GIT_CONFIG_PARAMETERS to child processes, including + # user-configured hooks and checkout filters. # These process-only overrides also keep Git from invoking any credential # helper during this clone; the repository-local helper serves later fetches. env["AVR_GIT_CLONE_AUTHORIZATION"] = f"Authorization: Bearer {token}" diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index b2de7d78..1f515f4c 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -2340,7 +2340,7 @@ avr repo clone [OPTIONS] REPOSITORY [DIRECTORY] REPOSITORY is an owner/repository name or an Avrea repository ID (rep-...). When several placements are available, the first to serve the repository wins. A single placement is used directly. Use --placement to select an -exact URL or hostname. +exact URL or hostname. Requires Git 2.31 or newer. The origin remote stays on the selected read-only mirror. A repository-local Avrea credential helper obtains fresh Git JWTs for subsequent fetch and pull diff --git a/docs/reference.json b/docs/reference.json index 67068029..f100fbf9 100644 --- a/docs/reference.json +++ b/docs/reference.json @@ -10187,7 +10187,7 @@ "full": "avr repo clone", "is_group": false, "short_help": "Clone a repository from its nearest available Avrea mirror.", - "help": "Clone a repository from its nearest available Avrea mirror.\n\nREPOSITORY is an owner/repository name or an Avrea repository ID (rep-...).\nWhen several placements are available, the first to serve the repository\nwins. A single placement is used directly. Use --placement to select an\nexact URL or hostname.\n\nThe origin remote stays on the selected read-only mirror. A repository-local\nAvrea credential helper obtains fresh Git JWTs for subsequent fetch and pull\noperations using your Avrea login. Push changes to the source repository\nusing a separate remote. Tokens normally last one hour; scoped API keys have\na shorter server-enforced lifetime. Tokens are never saved in the repository.\n\n\b\nExamples:\n avr repo clone foo/faa\n avr repo clone foo/faa my-checkout --depth 1\n avr repo clone foo/faa --placement https://git.fi.a.avrea.io\n", + "help": "Clone a repository from its nearest available Avrea mirror.\n\nREPOSITORY is an owner/repository name or an Avrea repository ID (rep-...).\nWhen several placements are available, the first to serve the repository\nwins. A single placement is used directly. Use --placement to select an\nexact URL or hostname. Requires Git 2.31 or newer.\n\nThe origin remote stays on the selected read-only mirror. A repository-local\nAvrea credential helper obtains fresh Git JWTs for subsequent fetch and pull\noperations using your Avrea login. Push changes to the source repository\nusing a separate remote. Tokens normally last one hour; scoped API keys have\na shorter server-enforced lifetime. Tokens are never saved in the repository.\n\n\b\nExamples:\n avr repo clone foo/faa\n avr repo clone foo/faa my-checkout --depth 1\n avr repo clone foo/faa --placement https://git.fi.a.avrea.io\n", "epilog": null, "deprecated": false, "hidden": false, diff --git a/docs/starlight/index.md b/docs/starlight/index.md index d6e1293e..d1a88b87 100644 --- a/docs/starlight/index.md +++ b/docs/starlight/index.md @@ -102,7 +102,8 @@ Avrea uses a single available placement directly. With multiple placements, it picks the first mirror that responds successfully from your machine. `--placement` accepts an advertised mirror URL or hostname to select one explicitly. Give the `owner/repository` name or an Avrea repository ID (`rep-...`). -The repository must already have Git mirroring enabled and a usable placement. +Requires Git 2.31 or newer. The repository must already have Git mirroring +enabled and a usable placement. `origin` stays on the selected read-only mirror. The clone installs a repository-local credential helper so ordinary `git fetch` and `git pull` diff --git a/docs/starlight/reference/repo.md b/docs/starlight/reference/repo.md index c1317420..d1a56a16 100644 --- a/docs/starlight/reference/repo.md +++ b/docs/starlight/reference/repo.md @@ -22,7 +22,7 @@ avr repo clone [OPTIONS] REPOSITORY [DIRECTORY] REPOSITORY is an owner/repository name or an Avrea repository ID (rep-...). When several placements are available, the first to serve the repository wins. A single placement is used directly. Use --placement to select an -exact URL or hostname. +exact URL or hostname. Requires Git 2.31 or newer. The origin remote stays on the selected read-only mirror. A repository-local Avrea credential helper obtains fresh Git JWTs for subsequent fetch and pull diff --git a/man/man1/avr-repo-clone.1 b/man/man1/avr-repo-clone.1 index eb155b89..5477197e 100644 --- a/man/man1/avr-repo-clone.1 +++ b/man/man1/avr-repo-clone.1 @@ -7,7 +7,7 @@ avr-repo-clone \- Clone a repository from its nearest available Avrea mirror. .SH DESCRIPTION Clone a repository from its nearest available Avrea mirror. .PP -REPOSITORY is an owner/repository name or an Avrea repository ID (rep-...). When several placements are available, the first to serve the repository wins. A single placement is used directly. Use --placement to select an exact URL or hostname. +REPOSITORY is an owner/repository name or an Avrea repository ID (rep-...). When several placements are available, the first to serve the repository wins. A single placement is used directly. Use --placement to select an exact URL or hostname. Requires Git 2.31 or newer. .PP The origin remote stays on the selected read-only mirror. A repository-local Avrea credential helper obtains fresh Git JWTs for subsequent fetch and pull operations using your Avrea login. Push changes to the source repository using a separate remote. Tokens normally last one hour; scoped API keys have a shorter server-enforced lifetime. Tokens are never saved in the repository. .PP diff --git a/tests/test_repo_clone.py b/tests/test_repo_clone.py index d79eca1b..3d275d83 100644 --- a/tests/test_repo_clone.py +++ b/tests/test_repo_clone.py @@ -37,6 +37,7 @@ class GitServerControls(TypedDict): redirect: str | None token_status: int + git_status: int endpoints: list[str] | None unauthorized_requests: int @@ -326,6 +327,8 @@ def test_invalid_token_does_not_leak(response): def test_clone_preserves_git_exit_code_and_disables_secret_tracing(runner, monkeypatch, clone_api): captured = {} + nearest = AsyncMock(return_value=ENDPOINTS[0]) + monkeypatch.setattr("avrea_cli.commands.repo.nearest_mirror", nearest) def run(argv, **kwargs): captured.update(kwargs) @@ -337,12 +340,22 @@ def run(argv, **kwargs): monkeypatch.setattr("avrea_cli.git_mirror.subprocess.run", run) result = runner.invoke(cli, ["repo", "clone", "foo/faa", "--placement", ENDPOINTS[0]]) assert result.exit_code == 128 + nearest.assert_awaited_once_with([ENDPOINTS[0]], "foo/faa", TOKEN) assert not any(key.startswith("GIT_TRACE") for key in captured["env"]) assert "GIT_CURL_VERBOSE" not in captured["env"] assert captured["env"]["GIT_ASKPASS"] == "" assert captured["env"]["SSH_ASKPASS"] == "" +def test_failed_clone_does_not_repeat_mirror_race(runner, monkeypatch, clone_api): + nearest = AsyncMock(return_value=ENDPOINTS[0]) + monkeypatch.setattr("avrea_cli.commands.repo.nearest_mirror", nearest) + monkeypatch.setattr("avrea_cli.commands.repo.clone_mirror", lambda *args, **kwargs: 128) + result = runner.invoke(cli, ["repo", "clone", "foo/faa"]) + assert result.exit_code == 128 + nearest.assert_awaited_once_with(ENDPOINTS, "foo/faa", TOKEN) + + @pytest.mark.asyncio async def test_race_skips_empty_mirror_and_cancels_unresponsive_one(monkeypatch): waiting = asyncio.Event() @@ -491,6 +504,7 @@ def git_server(tmp_path, monkeypatch): controls: GitServerControls = { "redirect": None, "token_status": 200, + "git_status": 200, "endpoints": None, "unauthorized_requests": 0, } @@ -546,6 +560,12 @@ def git_backend(self): self.end_headers() return authenticated.append(token) + if controls["git_status"] != 200: + self.send_response(controls["git_status"]) + if controls["git_status"] == 401: + self.send_header("WWW-Authenticate", 'Basic realm="mirror"') + self.end_headers() + return url = urlsplit(self.path) if "git-receive-pack" in self.path: self.send_response(403) @@ -633,6 +653,53 @@ def test_real_clone_and_later_fetch_use_fresh_tokens( assert stored_credentials.read_text() == seeded +@pytest.mark.parametrize( + ("status", "hint"), + [ + (401, "login and repository permissions"), + (403, "login and repository permissions"), + (404, "avr repo mirror status"), + (503, "connectivity and `avr health`"), + ], +) +@pytest.mark.parametrize("explicit_placement", [False, True]) +def test_failed_direct_clone_reports_mirror_recovery_hint( + runner, monkeypatch, tmp_path, git_server, capfd, status, hint, explicit_placement +): + base_url, issued, authenticated, controls = git_server + controls["git_status"] = status + monkeypatch.setenv("AVR_HOST", base_url) + args = ["repo", "clone", "foo/faa", str(tmp_path / "checkout")] + if explicit_placement: + controls["endpoints"] = [base_url, ENDPOINTS[0]] + args.extend(["--placement", base_url]) + result = runner.invoke(cli, args) + git_stderr = capfd.readouterr().err + assert result.exit_code == 128 + assert "fatal:" in git_stderr + assert f"HTTP {status}" in result.stderr + assert hint in result.stderr + assert issued == ["git-jwt-1"] + assert authenticated == [issued[0], issued[0]] + assert issued[0] not in result.output + git_stderr + + +def test_failed_direct_clone_of_healthy_mirror_preserves_git_error(runner, monkeypatch, tmp_path, git_server, capfd): + base_url, issued, authenticated, _ = git_server + monkeypatch.setenv("AVR_HOST", base_url) + destination = tmp_path / "occupied" + destination.mkdir() + (destination / "existing").write_text("keep this file") + result = runner.invoke(cli, ["repo", "clone", "foo/faa", str(destination)]) + assert result.exit_code == 128 + assert "already exists and is not an empty directory" in capfd.readouterr().err + assert "No mirror could serve" not in result.stderr + assert "Error:" not in result.stderr + assert (destination / "existing").read_text() == "keep this file" + assert issued == ["git-jwt-1"] + assert authenticated == issued # Only the post-failure probe reaches the mirror. + + def test_clone_mirror_handles_missing_git(monkeypatch): def missing(*args, **kwargs): raise FileNotFoundError