diff --git a/gso/__init__.py b/gso/__init__.py index 99f1d386081a807a5c7b34527ba890cafe7aab1e..beabac68199a877aecf491bab7d6325a34097714 100644 --- a/gso/__init__.py +++ b/gso/__init__.py @@ -65,11 +65,12 @@ def init_gso_app() -> OrchestratorCore: def init_cli_app() -> typer.Typer: """Initialise GSO as a CLI application.""" - from gso.cli import imports, netbox, schedule # noqa: PLC0415 + from gso.cli import imports, netbox, prechecks, schedule # noqa: PLC0415 cli_app.add_typer(imports.app, name="import-cli") cli_app.add_typer(netbox.app, name="netbox-cli") cli_app.add_typer(schedule.app, name="schedule-cli") + cli_app.add_typer(prechecks.app, name="precheck-cli") return cli_app() diff --git a/gso/cli/prechecks.py b/gso/cli/prechecks.py new file mode 100644 index 0000000000000000000000000000000000000000..55f5df698fde5462159ccedca9f8a41152d8ea48 --- /dev/null +++ b/gso/cli/prechecks.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""CLI for GSO pre-check using LSO remote exec endpoint.""" + +import json +import logging + +import click +import httpx +import structlog +import typer +from orchestrator.db import db +from orchestrator.db.database import transactional +from pydantic import ValidationError + +from gso import settings +from gso.db.models import BgpStatusPreCheckTable +from gso.utils.types.precheck import ExecutableRunResponse + +logger = structlog.get_logger(__name__) +app = typer.Typer() + +MAX_OUTPUT_LINES = 50 # Max lines to display before paging + + +@app.command() +def bgp_status( + host: str = typer.Argument(..., help="FQDN of the router to pre-check"), + nren: str = typer.Argument(..., help="NREN name for import file path"), +) -> None: + """Trigger the bgp_status_pre-check script on LSO, wait for it to finish. + + pretty-print the JSON result inline, + parse the `output` field as JSON-string and page it if large, + and optionally save to the database. + """ + oss = settings.load_oss_params() + p = oss.PROVISIONING_PROXY + payload = { + "executable_name": "bgp_status_pre_check.py", + "args": [host, nren], + "is_async": False, + } + url = f"{p.scheme}://{p.api_base}/api/execute/" + + # 1) Call LSO + try: + resp = httpx.post(url, json=payload, timeout=30) + resp.raise_for_status() + except Exception as e: + logger.exception("LSO call failed: %s") + typer.echo(f"Error: failed to call LSO: {e}", err=True) + raise typer.Exit(1) from e + + # 2) Validate response + try: + runner = ExecutableRunResponse(**resp.json()) + except ValidationError as e: + logger.exception("Invalid response from LSO") + typer.echo("Error: invalid JSON returned by LSO:", err=True) + typer.echo(str(e), err=True) + raise typer.Exit(1) from e + + # 3) Print full response inline + full = runner.model_dump(mode="json") + typer.echo(typer.style("\nFull LSO response:", fg=typer.colors.GREEN)) + typer.echo(json.dumps(full, indent=2)) + + # 4) Parse and pretty-print the `output` field, with pagination if large + output_str = runner.result.output if runner.result else "" + typer.echo(typer.style("\nParsed `result.output` as JSON:", fg=typer.colors.CYAN)) + try: + parsed = json.loads(output_str) + parsed_text = json.dumps(parsed, indent=2) + if parsed_text.count("\n") > MAX_OUTPUT_LINES: + click.echo_via_pager(parsed_text) + else: + typer.echo(parsed_text) + except json.JSONDecodeError: + typer.echo("(not valid JSON, raw string below)") + typer.echo(output_str) + + # 5) Save? + confirm_msg = ( + f"\nIf you are happy with the above output for router '{host}' (NREN: {nren}), " + "shall we save it to the database?" + ) + if typer.confirm(confirm_msg, default=False): + try: + with db.database_scope(), transactional(db, logger): + record = BgpStatusPreCheckTable( + router_fqdn=host, + nren=nren, + result=runner.result.model_dump(mode="json") if runner.result else {}, + ) + db.session.add(record) + except Exception as err: + logger.exception("Failed to save pre-check record") + typer.echo("Error: could not save pre-check to database.", err=True) + raise typer.Exit(2) from err + typer.echo("Pre-check result saved.") + else: + typer.echo("Alright, not saving. You can re-run when ready.") + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + app() diff --git a/gso/db/models.py b/gso/db/models.py index c6382b1c81d06f9192ed4f416e186a2d990a5a45..5ac6a46afea88e5cc3ed96b2d9c8716ce7232834 100644 --- a/gso/db/models.py +++ b/gso/db/models.py @@ -4,6 +4,7 @@ import structlog from orchestrator.db import UtcTimestamp from orchestrator.db.database import BaseModel from sqlalchemy import ( + JSON, String, text, ) @@ -25,3 +26,43 @@ class PartnerTable(BaseModel): updated_at = mapped_column( UtcTimestamp, server_default=text("current_timestamp"), nullable=False, onupdate=text("current_timestamp") ) + + +class BgpStatusPreCheckTable(BaseModel): + """Database table for storing per router BGP satus precheck results.""" + + __tablename__ = "bgp_status_pre_checks" + + pre_check_id = mapped_column( + String, + server_default=text("uuid_generate_v4"), + primary_key=True, + ) + router_fqdn = mapped_column( + String, + nullable=False, + index=True, + comment="The FQDN of the router under check", + ) + nren = mapped_column( + String, + nullable=False, + comment="Name of the NREN (used in import file path)", + ) + result = mapped_column( + JSON, + nullable=False, + comment="Raw JSON blob returned by LSO bgp_status_pre_check script", + ) + + created_at = mapped_column( + UtcTimestamp, + server_default=text("current_timestamp"), + nullable=False, + ) + updated_at = mapped_column( + UtcTimestamp, + server_default=text("current_timestamp"), + nullable=False, + onupdate=text("current_timestamp"), + ) diff --git a/gso/migrations/env.py b/gso/migrations/env.py index fb535946527dd0a487e75c52d3f6856b9f4eda34..198b6499028754c353efc9e76c1fb7eaa7822bc4 100644 --- a/gso/migrations/env.py +++ b/gso/migrations/env.py @@ -5,7 +5,7 @@ from orchestrator.db.database import BaseModel from orchestrator.settings import app_settings from sqlalchemy import engine_from_config, pool, text -from gso.db.models import PartnerTable # noqa: F401 +from gso.db.models import PartnerTable, BgpStatusPreCheckTable # noqa: F401 # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/gso/migrations/versions/2025-06-27_27308f1dd850_add_bgp_satatus_pre_check_table_.py b/gso/migrations/versions/2025-06-27_27308f1dd850_add_bgp_satatus_pre_check_table_.py new file mode 100644 index 0000000000000000000000000000000000000000..91503c32a88d06a788efb9a18ec293b8bb29ee19 --- /dev/null +++ b/gso/migrations/versions/2025-06-27_27308f1dd850_add_bgp_satatus_pre_check_table_.py @@ -0,0 +1,40 @@ +"""Add bgp_status_pre_checks table. + +Revision ID: 27308f1dd850 +Revises: 24858fd1d805 +Create Date: 2025-06-27 10:00:00.000000 + +""" +import sqlalchemy as sa +from alembic import op +from orchestrator.db import UtcTimestamp +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '27308f1dd850' +down_revision = '24858fd1d805' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'bgp_status_pre_checks', + sa.Column('pre_check_id', sa.String(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('router_fqdn', sa.String(), nullable=False), + sa.Column('nren', sa.String(), nullable=False), + sa.Column('result', postgresql.JSON(), nullable=False), # type: ignore[no-untyped-call] + sa.Column('created_at', UtcTimestamp(timezone=True), server_default=sa.text('current_timestamp'), nullable=False), + sa.Column('updated_at', UtcTimestamp(timezone=True), server_default=sa.text('current_timestamp'), + nullable=False, onupdate=sa.text('current_timestamp')), + sa.PrimaryKeyConstraint('pre_check_id'), + ) + # indexes for faster lookup + op.create_index('ix_bgp_status_pre_checks_router_fqdn', 'bgp_status_pre_checks', ['router_fqdn']) + + +def downgrade() -> None: + # drop indexes, then table + op.drop_index('ix_bgp_status_pre_checks_router_fqdn', table_name='bgp_status_pre_checks') + op.drop_index('ix_bgp_status_pre_checks_router_id', table_name='bgp_status_pre_checks') + op.drop_table('bgp_status_pre_checks') diff --git a/gso/utils/types/precheck.py b/gso/utils/types/precheck.py new file mode 100644 index 0000000000000000000000000000000000000000..db430fcb1a5f6d689947c2e9e4bb6bf1f4ebe55e --- /dev/null +++ b/gso/utils/types/precheck.py @@ -0,0 +1,28 @@ +"""This module defines types used for precheck operations.""" + +from enum import StrEnum +from uuid import UUID + +from pydantic import BaseModel + + +class JobStatus(StrEnum): + """Enumeration of possible job statuses.""" + + SUCCESSFUL = "successful" + FAILED = "failed" + + +class ExecutionResult(BaseModel): + """Model for capturing the result of an executable run.""" + + output: str + return_code: int + status: JobStatus + + +class ExecutableRunResponse(BaseModel): + """Response for running an arbitrary executable.""" + + job_id: UUID + result: ExecutionResult | None = None diff --git a/test/cli/test_pre_checks.py b/test/cli/test_pre_checks.py new file mode 100644 index 0000000000000000000000000000000000000000..a3d95f65fd4a6773bd6045a9f28e469ae89dd153 --- /dev/null +++ b/test/cli/test_pre_checks.py @@ -0,0 +1,141 @@ +import json + +import click +import httpx +import pytest +from orchestrator.db import db +from typer.testing import CliRunner + +from gso.cli.prechecks import app +from gso.db.models import BgpStatusPreCheckTable + +runner = CliRunner() + +# A valid LSO response payload +BASE_RESPONSE = { + "job_id": "2c19843b-c721-4662-8014-b1a7a22f1734", + "result": { + "output": json.dumps({"bgp": {"asn": 65001}, "neighbors": []}), + "return_code": 0, + "status": "successful", + }, +} + + +class DummyResponse: + def __init__(self, json_data, status=200): + self._json = json_data + self.status_code = status + + def json(self): + return self._json + + def raise_for_status(self): + if not (200 <= self.status_code < 300): + raise httpx.HTTPStatusError("error", request=None, response=self) # noqa: RUF100,EM101 + + +@pytest.fixture() +def mock_http_success(monkeypatch): + """Return a successful dummy response for httpx.post.""" + dummy_resp = DummyResponse(BASE_RESPONSE) + monkeypatch.setattr("httpx.post", lambda *args, **kwargs: dummy_resp) # noqa: ARG005 + return dummy_resp + + +@pytest.fixture() +def mock_http_error(monkeypatch): + """Simulate httpx.post throwing an exception.""" + + def raise_exc(*args, **kwargs): + """Raise an exception to simulate a network error.""" + msg = "timeout" + raise Exception(msg) # noqa: TRY002 + + monkeypatch.setattr("httpx.post", raise_exc) + return raise_exc + + +@pytest.fixture() +def mock_http_bad_shape(monkeypatch): + """Return JSON that does not fit ExecutableRunResponse.""" + bad = {"unexpected": "data"} + # create a DummyResponse and patch httpx.post + dummy_resp = DummyResponse(bad) + monkeypatch.setattr("httpx.post", lambda *args, **kwargs: dummy_resp) # noqa: ARG005 + return dummy_resp + + +def test_no_save_leaves_table_empty(mock_http_success): + """If user declines save, table remains empty.""" + result = runner.invoke(app, ["rt1.example.com", "SURF"], input="n\n") + assert result.exit_code == 0 + assert "not saving" in result.stdout.lower() + # Table should be empty + assert db.session.query(BgpStatusPreCheckTable).count() == 0 + + +def test_prompt_save_yes_persists_record(mock_http_success): + """Typing 'y' at prompt should also persist.""" + result = runner.invoke(app, ["rt1.example.com", "SURF"], input="y\n") + assert result.exit_code == 0 + assert db.session.query(BgpStatusPreCheckTable).count() == 1 + + +def test_http_failure_aborts(mock_http_error): + """Network/timeout errors should abort with exit code 1.""" + result = runner.invoke(app, ["rt1.example.com", "SURF"]) + assert result.exit_code == 1 + # Now stderr is separately captured: + assert "error: failed to call lso: timeout" in result.stdout.lower() + + # Table still empty + assert db.session.query(BgpStatusPreCheckTable).count() == 0 + + +def test_invalid_shape_aborts(mock_http_bad_shape): + """Malformed top-level JSON shape should abort.""" + result = runner.invoke(app, ["rt1.example.com", "SURF"]) + assert result.exit_code == 1 + assert "invalid JSON returned by LSO" in result.stdout + assert db.session.query(BgpStatusPreCheckTable).count() == 0 + + +def test_parse_output_nonjson(mock_http_success): + """If output is not valid JSON, we still complete without saving.""" + # Patch BASE_RESPONSE to use non-JSON output + bad = dict(BASE_RESPONSE) + bad["result"] = dict(bad["result"]) + bad["result"]["output"] = "not a json" + # monkeypatch + import httpx as _httpx + + _orig = _httpx.post + _httpx.post = lambda *args, **kwargs: DummyResponse(bad) # noqa: ARG005 + try: + result = runner.invoke(app, ["rt1.example.com", "SURF"], input="n\n") + assert result.exit_code == 0 + assert "(not valid JSON, raw string below)" in result.stdout + finally: + _httpx.post = _orig + + +def test_pagination_on_large_output(mock_http_success, monkeypatch): + """Parsed output >50 lines should trigger click.echo_via_pager.""" + # Build huge object + big = {"x": ["line"] * 100} + payload = dict(BASE_RESPONSE) + payload["result"] = dict(payload["result"]) + payload["result"]["output"] = json.dumps(big) + monkeypatch.setattr("httpx.post", lambda *args, **kwargs: DummyResponse(payload)) # noqa: ARG005 + + paged = False + + def fake_pager(text): + nonlocal paged + paged = True + + monkeypatch.setattr(click, "echo_via_pager", fake_pager) + result = runner.invoke(app, ["rt1.example.com", "SURF"], input="n\n") + assert result.exit_code == 0 + assert paged, "Expected parsed-output pager for large JSON"