diff --git a/specs/cli/ping.md b/specs/cli/ping.md new file mode 100644 index 0000000..c0eba5b --- /dev/null +++ b/specs/cli/ping.md @@ -0,0 +1,230 @@ +# CLI: `ping` + +## Overview + +Implements a top-level `regshape ping` command that performs `GET /v2/` against a target registry to verify connectivity, authentication, and OCI Distribution API support. This is a connectivity check command — it does not operate on repositories or images. + +## Usage + +``` +regshape ping --registry [--json] +``` + +## Arguments + +None. + +## Options + +| Option | Short | Type | Default | Description | +|--------|-------|------|---------|-------------| +| `--registry` | `-r` | string | required | Registry hostname (e.g. `ghcr.io`, `localhost:5000`) | +| `--json` | | flag | false | Output as JSON | + +## Library Layer + +### Module: `src/regshape/libs/ping/` + +#### `ping(client: RegistryClient) -> PingResult` + +Issues `GET /v2/` via the provided `RegistryClient` and returns a `PingResult` describing the outcome. + +**Parameters:** +- `client` — A configured `RegistryClient` instance targeting the registry. + +**Returns:** `PingResult` dataclass. + +**Raises:** +- `AuthError` — When the registry returns `401 Unauthorized`. +- `PingError` — When the registry is unreachable (DNS failure, connection refused, timeout). + +#### `PingResult` dataclass + +```python +@dataclasses.dataclass +class PingResult: + reachable: bool # True if HTTP 200 + status_code: int # HTTP status code returned + api_version: str | None # Docker-Distribution-API-Version header value + latency_ms: float # Round-trip time in milliseconds + + def to_dict(self) -> dict: ... +``` + +### Error Class: `PingError` + +Added to `src/regshape/libs/errors.py`: + +```python +class PingError(RegShapeError): + """Error caused by a failed registry ping (connection, DNS, timeout).""" + pass +``` + +## Protocol Flow + +``` +Client Registry + | | + |--- GET /v2/ ----------------->| + |<-- 200 OK + headers ----------| +``` + +The `GET /v2/` endpoint is the OCI Distribution Spec API version check. A `200 OK` response confirms the registry is reachable and speaks the Distribution API. The response may include a `Docker-Distribution-API-Version` header (typically `registry/2.0`). + +### Behavior by HTTP Status + +| Status | Interpretation | Result | +|--------|---------------|--------| +| `200` | Registry reachable and authenticated | `PingResult(reachable=True, ...)` | +| `401` | Authentication required or failed | Raises `AuthError` | +| Other 4xx/5xx | Registry responded but endpoint unsupported | `PingResult(reachable=False, ...)` | +| Connection error / timeout | Registry unreachable | Raises `PingError` | + +### Latency Measurement + +Round-trip latency is measured using `time.monotonic()` around the `client.get()` call and reported in milliseconds. + +## CLI Layer + +### File: `src/regshape/cli/ping.py` + +A **top-level command** (not a command group), registered directly on the `regshape` group in `main.py`. + +**Implementation pattern** (follows `tag.py`): +- Creates `RegistryClient(TransportConfig(registry=registry, insecure=insecure))` +- Calls `ping(client)` from the operations module +- Catches `(AuthError, PingError, requests.exceptions.RequestException)` +- Uses `@telemetry_options` and `@track_scenario("ping")` decorators + +### Registration in `main.py` + +```python +from regshape.cli.ping import ping +regshape.add_command(ping) +``` + +## Examples + +```bash +# Basic ping +regshape ping --registry ghcr.io + +# Ping with JSON output +regshape ping --registry ghcr.io --json + +# Ping an insecure (HTTP) registry +regshape --insecure ping --registry localhost:5000 +``` + +## Output Format + +### Plain text (default) + +Success: + +``` +Registry ghcr.io is reachable + API Version: registry/2.0 + Latency: 42ms +``` + +Failure (non-200 response): + +``` +Error: Registry ghcr.io is not reachable (HTTP 503) +``` + +Failure (connection error): + +``` +Error: Registry ghcr.io is not reachable: Connection refused +``` + +Failure (auth error): + +``` +Error: Registry ghcr.io requires authentication: 401 Unauthorized +``` + +### JSON (`--json`) + +Success: + +```json +{ + "registry": "ghcr.io", + "reachable": true, + "status_code": 200, + "api_version": "registry/2.0", + "latency_ms": 42.3 +} +``` + +Failure: + +```json +{ + "registry": "ghcr.io", + "reachable": false, + "error": "503 Service Unavailable" +} +``` + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Registry is reachable (HTTP 200 or authentication required, e.g. HTTP 401/403) | +| 1 | Registry is not reachable or error occurred | + +## Error Messages + +| Scenario | Message | +|----------|---------| +| Connection refused | `Error: Registry is not reachable: Connection refused` | +| DNS resolution failed | `Error: Registry is not reachable: Name resolution failed` | +| Timeout | `Error: Registry is not reachable: Connection timed out` | +| 401 Unauthorized | `Error: Registry requires authentication: 401 Unauthorized` | +| Non-200 HTTP status | `Error: Registry is not reachable (HTTP )` | + +## Files to Create/Modify + +| File | Action | +|------|--------| +| `src/regshape/libs/ping/__init__.py` | Create — re-exports `ping` and `PingResult` | +| `src/regshape/libs/ping/operations.py` | Create — `ping()` function and `PingResult` dataclass | +| `src/regshape/libs/errors.py` | Modify — add `PingError` | +| `src/regshape/cli/ping.py` | Create — Click command | +| `src/regshape/cli/main.py` | Modify — import and register `ping` command | +| `src/regshape/tests/test_ping_operations.py` | Create — operations layer tests | +| `src/regshape/tests/test_ping_cli.py` | Create — CLI layer tests | + +## Test Plan + +### Operations tests (`test_ping_operations.py`) + +- Mock `RegistryClient.get()` returning 200 with `Docker-Distribution-API-Version` header → `PingResult(reachable=True, api_version="registry/2.0")` +- Mock returning 200 without `Docker-Distribution-API-Version` header → `PingResult(reachable=True, api_version=None)` +- Mock returning non-200 status (e.g. 503) → `PingResult(reachable=False, status_code=503)` +- Mock raising `AuthError` (401) → propagated to caller +- Mock raising `ConnectionError` → `PingError` +- Mock raising `Timeout` → `PingError` +- Verify latency is measured (non-negative value) + +### CLI tests (`test_ping_cli.py`) + +- Successful ping → exit code 0, plain text contains "is reachable" +- Successful ping with `--json` → exit code 0, valid JSON with expected keys +- Unreachable registry → exit code 1, error message +- Auth failure → exit code 0, message indicates registry is reachable but authentication failed +- `--registry` option is required → exit code 2 (Click usage error) + +## Dependencies + +- Internal: `regshape.libs.transport` (`RegistryClient`, `TransportConfig`), `regshape.libs.errors`, `regshape.libs.decorators` +- External: `requests`, `click` + +## Open Questions + +- [ ] Should `--registry` also accept a full image reference (and extract the registry), or strictly a hostname? Current design uses hostname-only since this is a connectivity check, not a repository operation. diff --git a/src/regshape/cli/main.py b/src/regshape/cli/main.py index 7ec4733..2a7f2d9 100644 --- a/src/regshape/cli/main.py +++ b/src/regshape/cli/main.py @@ -20,6 +20,7 @@ from regshape.cli.docker import docker from regshape.cli.layout import layout from regshape.cli.manifest import manifest +from regshape.cli.ping import ping from regshape.cli.referrer import referrer from regshape.cli.tag import tag @@ -72,6 +73,7 @@ def regshape( regshape.add_command(docker) regshape.add_command(layout) regshape.add_command(manifest) +regshape.add_command(ping) regshape.add_command(referrer) regshape.add_command(tag) diff --git a/src/regshape/cli/ping.py b/src/regshape/cli/ping.py new file mode 100644 index 0000000..4e558a4 --- /dev/null +++ b/src/regshape/cli/ping.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 + +""" +:mod:`regshape.cli.ping` - CLI command for OCI registry ping +============================================================== + +.. module:: regshape.cli.ping + :platform: Unix, Windows + :synopsis: Top-level Click command that pings an OCI registry via + ``GET /v2/`` to verify connectivity and API support. + +.. moduleauthor:: ToddySM +""" + +import json +import sys + +import click +import requests + +from regshape.libs.decorators import telemetry_options +from regshape.libs.decorators.scenario import track_scenario +from regshape.libs.errors import AuthError, PingError +from regshape.libs.ping import ping as ping_registry +from regshape.libs.transport import RegistryClient, TransportConfig + + +# =========================================================================== +# ping command (top-level, not a group) +# =========================================================================== + +@click.command() +@telemetry_options +@click.option( + "--registry", + "-r", + required=True, + metavar="REGISTRY", + help="Registry hostname (e.g. ghcr.io, localhost:5000).", +) +@click.option( + "--json", + "as_json", + is_flag=True, + default=False, + help="Output as JSON.", +) +@click.pass_context +@track_scenario("ping") +def ping(ctx, registry, as_json): + """Ping an OCI registry to verify connectivity and API support. + + Issues ``GET /v2/`` against the target REGISTRY and reports whether + the registry is reachable, the API version it advertises, and the + round-trip latency. Credentials are resolved automatically from + the credential store populated by ``auth login``. + """ + insecure = ctx.obj.get("insecure", False) if ctx.obj else False + + client = RegistryClient(TransportConfig(registry=registry, insecure=insecure)) + + try: + result = ping_registry(client) + except AuthError as exc: + # A 401/403 during token negotiation means the registry *is* + # reachable but requires credentials. Report success with a hint. + if as_json: + click.echo(json.dumps({ + "registry": registry, + "reachable": True, + "api_version": None, + "latency_ms": None, + "note": "Registry requires authentication", + "error": str(exc), + }, indent=2)) + else: + click.echo(f"Registry {registry} is reachable") + click.echo(" Note: Registry requires authentication. " + "Run 'regshape auth login' to configure credentials.") + return + except PingError as exc: + detail = str(exc.__cause__) if getattr(exc, "__cause__", None) is not None else str(exc) + _error(registry, detail, as_json) + sys.exit(1) + except requests.exceptions.RequestException as exc: + _error(registry, str(exc), as_json) + sys.exit(1) + + if as_json: + output = { + "registry": registry, + "reachable": False, + "status_code": result.status_code, + "api_version": result.api_version, + "latency_ms": result.latency_ms, + "error": f"HTTP {result.status_code}", + } + click.echo(json.dumps(output, indent=2), err=True) + else: + _error(registry, f"HTTP {result.status_code}", as_json) + _error(registry, f"HTTP {result.status_code}", as_json) + sys.exit(1) + + if as_json: + output = result.to_dict() + output["registry"] = registry + click.echo(json.dumps(output, indent=2)) + else: + click.echo(f"Registry {registry} is reachable") + if result.api_version: + click.echo(f" API Version: {result.api_version}") + click.echo(f" Latency: {result.latency_ms:.0f}ms") + + +# =========================================================================== +# Helpers +# =========================================================================== + + +def _error(registry: str, detail: str, as_json: bool = False) -> None: + """Print an error message to stderr.""" + if as_json: + click.echo(json.dumps({ + "registry": registry, + "reachable": False, + "error": detail, + }, indent=2), err=True) + else: + click.echo(f"Error: Registry {registry} is not reachable: {detail}", err=True) diff --git a/src/regshape/libs/errors.py b/src/regshape/libs/errors.py index a332ff7..8a5e935 100644 --- a/src/regshape/libs/errors.py +++ b/src/regshape/libs/errors.py @@ -90,4 +90,11 @@ class DockerError(RegShapeError): """ Error caused by a Docker daemon interaction failure. """ + pass + + +class PingError(RegShapeError): + """ + Error caused by a failed registry ping (connection, DNS, timeout). + """ pass \ No newline at end of file diff --git a/src/regshape/libs/ping/__init__.py b/src/regshape/libs/ping/__init__.py new file mode 100644 index 0000000..b2086fe --- /dev/null +++ b/src/regshape/libs/ping/__init__.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 + +""" +:mod:`regshape.libs.ping` - Domain operations for OCI registry ping +===================================================================== + +.. module:: regshape.libs.ping + :platform: Unix, Windows + :synopsis: Library-level functions for OCI registry ping operations. + +.. moduleauthor:: ToddySM +""" + +from regshape.libs.ping.operations import ( + PingResult, + ping, +) + +__all__ = [ + "PingResult", + "ping", +] diff --git a/src/regshape/libs/ping/operations.py b/src/regshape/libs/ping/operations.py new file mode 100644 index 0000000..a8c3967 --- /dev/null +++ b/src/regshape/libs/ping/operations.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 + +""" +:mod:`regshape.libs.ping.operations` - OCI registry ping operations +===================================================================== + +.. module:: regshape.libs.ping.operations + :platform: Unix, Windows + :synopsis: Library-level function for pinging an OCI Distribution-compliant + registry via ``GET /v2/``. + +.. moduleauthor:: ToddySM + +The function accepts a :class:`~regshape.libs.transport.RegistryClient` +instance that is already initialised with the target registry, credentials, +and transport settings. It is intentionally free of Click/CLI concerns — +error reporting is the caller's responsibility. +""" + +import dataclasses +import time + +import requests + +from regshape.libs.errors import AuthError, PingError +from regshape.libs.transport import RegistryClient + + +# =========================================================================== +# Data models +# =========================================================================== + + +@dataclasses.dataclass +class PingResult: + """Result of a ``GET /v2/`` ping against an OCI registry. + + :param reachable: ``True`` if the registry returned HTTP 200. + :param status_code: HTTP status code returned by the registry. + :param api_version: Value of the ``Docker-Distribution-API-Version`` + response header, or ``None`` if absent. + :param latency_ms: Round-trip time in milliseconds. + """ + + reachable: bool + status_code: int + api_version: str | None + latency_ms: float + + def to_dict(self) -> dict: + """Serialise the result to a plain dictionary.""" + return { + "reachable": self.reachable, + "status_code": self.status_code, + "api_version": self.api_version, + "latency_ms": self.latency_ms, + } + + +# =========================================================================== +# Public domain operations +# =========================================================================== + + +def ping(client: RegistryClient) -> PingResult: + """Ping the registry by issuing ``GET /v2/``. + + A successful 200 response confirms that the registry is reachable and + speaks the OCI Distribution API. + + :param client: Authenticated transport client for the target registry. + :returns: A :class:`PingResult` describing the outcome. + :raises AuthError: On HTTP 401 (authentication required or failed). + :raises PingError: On connection, DNS, or timeout errors. + """ + try: + start = time.monotonic() + response = client.get("/v2/") + elapsed_ms = (time.monotonic() - start) * 1000 + except AuthError: + raise + except requests.exceptions.ConnectionError as exc: + raise PingError( + f"Registry {client.config.registry} is not reachable", + str(exc), + ) from exc + except requests.exceptions.Timeout as exc: + raise PingError( + f"Registry {client.config.registry} is not reachable", + "Connection timed out", + ) from exc + except requests.exceptions.RequestException as exc: + raise PingError( + f"Registry {client.config.registry} is not reachable", + str(exc), + ) from exc + + if response.status_code == 401: + raise AuthError( + f"Registry {client.config.registry} requires authentication", + f"HTTP {response.status_code}", + ) + + api_version = response.headers.get("Docker-Distribution-API-Version") + + return PingResult( + reachable=response.status_code == 200, + status_code=response.status_code, + api_version=api_version, + latency_ms=round(elapsed_ms, 1), + ) diff --git a/src/regshape/tests/test_ping_cli.py b/src/regshape/tests/test_ping_cli.py new file mode 100644 index 0000000..10c4d9d --- /dev/null +++ b/src/regshape/tests/test_ping_cli.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 + +"""Tests for :mod:`regshape.cli.ping`.""" + +import json + +import pytest +import requests +from click.testing import CliRunner +from unittest.mock import MagicMock, patch + +from regshape.cli.main import regshape +from regshape.libs.errors import AuthError, PingError +from regshape.libs.ping.operations import PingResult + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +REGISTRY = "ghcr.io" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _ping_result( + reachable: bool = True, + status_code: int = 200, + api_version: str | None = "registry/2.0", + latency_ms: float = 42.3, +) -> PingResult: + return PingResult( + reachable=reachable, + status_code=status_code, + api_version=api_version, + latency_ms=latency_ms, + ) + + +def _runner(): + return CliRunner() + + +# =========================================================================== +# TestPingCommand +# =========================================================================== + + +class TestPingCommand: + + def test_success_plain_text(self): + with patch("regshape.cli.ping.ping_registry", return_value=_ping_result()): + result = _runner().invoke(regshape, ["ping", "-r", REGISTRY]) + assert result.exit_code == 0 + assert "is reachable" in result.output + assert "registry/2.0" in result.output + assert "42ms" in result.output + + def test_success_no_api_version(self): + with patch("regshape.cli.ping.ping_registry", + return_value=_ping_result(api_version=None)): + result = _runner().invoke(regshape, ["ping", "-r", REGISTRY]) + assert result.exit_code == 0 + assert "is reachable" in result.output + assert "API Version" not in result.output + + def test_success_json(self): + with patch("regshape.cli.ping.ping_registry", return_value=_ping_result()): + result = _runner().invoke(regshape, ["ping", "-r", REGISTRY, "--json"]) + assert result.exit_code == 0 + parsed = json.loads(result.output) + assert parsed["registry"] == REGISTRY + assert parsed["reachable"] is True + assert parsed["status_code"] == 200 + assert parsed["api_version"] == "registry/2.0" + assert parsed["latency_ms"] == 42.3 + + def test_not_reachable_exits_1(self): + with patch("regshape.cli.ping.ping_registry", + return_value=_ping_result(reachable=False, status_code=503)): + result = _runner().invoke(regshape, ["ping", "-r", REGISTRY]) + assert result.exit_code == 1 + + def test_auth_error_exits_0_reachable(self): + """Auth failure means registry is reachable but requires credentials.""" + with patch("regshape.cli.ping.ping_registry", + side_effect=AuthError("Authentication failed", "HTTP 401")): + result = _runner().invoke(regshape, ["ping", "-r", REGISTRY]) + assert result.exit_code == 0 + assert "is reachable" in result.output + assert "requires authentication" in result.output + + def test_ping_error_exits_1(self): + with patch("regshape.cli.ping.ping_registry", + side_effect=PingError("Connection refused", "details")): + result = _runner().invoke(regshape, ["ping", "-r", REGISTRY]) + assert result.exit_code == 1 + + def test_request_exception_exits_1(self): + with patch("regshape.cli.ping.ping_registry", + side_effect=requests.exceptions.ConnectionError("connection error")): + result = _runner().invoke(regshape, ["ping", "-r", REGISTRY]) + assert result.exit_code == 1 + + def test_registry_option_required(self): + result = _runner().invoke(regshape, ["ping"]) + assert result.exit_code == 2 + assert "Missing option" in result.output or "required" in result.output.lower() + + def test_insecure_flag_propagated(self): + with patch("regshape.cli.ping.ping_registry", return_value=_ping_result()), \ + patch("regshape.cli.ping.RegistryClient") as mock_client_cls: + mock_client_cls.return_value = MagicMock() + _runner().invoke(regshape, ["--insecure", "ping", "-r", REGISTRY]) + config = mock_client_cls.call_args[0][0] + assert config.insecure is True + + def test_error_json_format(self): + with patch("regshape.cli.ping.ping_registry", + side_effect=PingError("Connection refused", "details")): + result = _runner().invoke(regshape, ["ping", "-r", REGISTRY, "--json"]) + assert result.exit_code == 1 + + def test_auth_error_json_shows_reachable(self): + with patch("regshape.cli.ping.ping_registry", + side_effect=AuthError("Token failed", "403")): + result = _runner().invoke(regshape, ["ping", "-r", REGISTRY, "--json"]) + assert result.exit_code == 0 + parsed = json.loads(result.output) + assert parsed["reachable"] is True + assert parsed["registry"] == REGISTRY + assert "authentication" in parsed["note"].lower() + + def test_not_reachable_json_exits_1(self): + with patch("regshape.cli.ping.ping_registry", + return_value=_ping_result(reachable=False, status_code=503)): + result = _runner().invoke(regshape, ["ping", "-r", REGISTRY, "--json"]) + assert result.exit_code == 1 diff --git a/src/regshape/tests/test_ping_operations.py b/src/regshape/tests/test_ping_operations.py new file mode 100644 index 0000000..0a569bf --- /dev/null +++ b/src/regshape/tests/test_ping_operations.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 + +"""Tests for :mod:`regshape.libs.ping.operations`.""" + +import pytest +import requests +from unittest.mock import MagicMock + +from regshape.libs.errors import AuthError, PingError +from regshape.libs.ping.operations import PingResult, ping +from regshape.libs.transport import RegistryClient, TransportConfig + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +REGISTRY = "ghcr.io" + + +def _mock_client() -> MagicMock: + client = MagicMock(spec=RegistryClient) + config = MagicMock(spec=TransportConfig) + config.registry = REGISTRY + client.config = config + return client + + +def _make_response( + status_code: int, + headers: dict | None = None, +) -> MagicMock: + resp = MagicMock(spec=requests.Response) + resp.status_code = status_code + resp.headers = headers or {} + return resp + + +# =========================================================================== +# PingResult +# =========================================================================== + + +class TestPingResult: + + def test_to_dict(self): + result = PingResult( + reachable=True, + status_code=200, + api_version="registry/2.0", + latency_ms=42.3, + ) + d = result.to_dict() + assert d["reachable"] is True + assert d["status_code"] == 200 + assert d["api_version"] == "registry/2.0" + assert d["latency_ms"] == 42.3 + + def test_to_dict_no_api_version(self): + result = PingResult( + reachable=True, + status_code=200, + api_version=None, + latency_ms=10.0, + ) + assert result.to_dict()["api_version"] is None + + +# =========================================================================== +# ping() +# =========================================================================== + + +class TestPing: + + def test_success_with_api_version_header(self): + client = _mock_client() + client.get.return_value = _make_response( + 200, + headers={"Docker-Distribution-API-Version": "registry/2.0"}, + ) + result = ping(client) + + client.get.assert_called_once_with("/v2/") + assert result.reachable is True + assert result.status_code == 200 + assert result.api_version == "registry/2.0" + assert result.latency_ms >= 0 + + def test_success_without_api_version_header(self): + client = _mock_client() + client.get.return_value = _make_response(200, headers={}) + + result = ping(client) + assert result.reachable is True + assert result.api_version is None + + def test_non_200_returns_not_reachable(self): + client = _mock_client() + client.get.return_value = _make_response(503) + + result = ping(client) + assert result.reachable is False + assert result.status_code == 503 + + def test_401_raises_auth_error(self): + client = _mock_client() + client.get.return_value = _make_response(401) + + with pytest.raises(AuthError, match="requires authentication"): + ping(client) + + def test_connection_error_raises_ping_error(self): + client = _mock_client() + client.get.side_effect = requests.exceptions.ConnectionError("Connection refused") + + with pytest.raises(PingError, match="not reachable"): + ping(client) + + def test_timeout_raises_ping_error(self): + client = _mock_client() + client.get.side_effect = requests.exceptions.Timeout("timed out") + + with pytest.raises(PingError, match="not reachable"): + ping(client) + + def test_generic_request_exception_raises_ping_error(self): + client = _mock_client() + client.get.side_effect = requests.exceptions.RequestException("something went wrong") + + with pytest.raises(PingError, match="not reachable"): + ping(client) + + def test_auth_error_from_client_propagated(self): + """AuthError raised by the client (e.g. middleware) propagates directly.""" + client = _mock_client() + client.get.side_effect = AuthError("Auth failed", "HTTP 401") + + with pytest.raises(AuthError, match="Auth failed"): + ping(client) + + def test_latency_is_measured(self): + client = _mock_client() + client.get.return_value = _make_response(200) + + result = ping(client) + # Latency should be a non-negative number + assert isinstance(result.latency_ms, float) + assert result.latency_ms >= 0