diff --git a/docs/netlab/api.md b/docs/netlab/api.md index e13a5463cc..fb636d7648 100644 --- a/docs/netlab/api.md +++ b/docs/netlab/api.md @@ -1,26 +1,32 @@ (netlab-api)= # netlab API Server -The **netlab api** command starts a lightweight HTTP server that wraps common -CLI actions. It is intended for automation systems that need to invoke *netlab* -operations without shelling out to the CLI. +The **netlab api** command starts a lightweight HTTP server that wraps common CLI actions. It is intended for automation systems that need to invoke *netlab* operations without executing CLI commands. ```text netlab api [--bind ] [--port ] [--auth-user ] [--auth-password ] [--tls-cert ] [--tls-key ] ``` +The HTTP(S) server is started on the address specified in the `--bind` parameter or NETLAB_API_BIND environment variable. If you start the API server in an SSH session, the server uses the `SSH_CONNECTION`  environment variable to find the local IP address to bind to. Lacking all other options, the API server is started on the loopback interface. + ## Endpoints - `GET /healthz` – health check. -- `GET /templates?dir=` – list YAML templates in a directory. +- `GET /templates?dir=` – list YAML templates (topologies) in a directory. - `POST /jobs` – start a job. Body is JSON with fields such as `action`, `workdir`, `workspaceRoot`, `topologyPath`, or `topologyUrl`. - `GET /jobs` – list jobs. - `GET /jobs/{id}` – job details. - `GET /jobs/{id}/log` – job log output. - `POST /jobs/{id}/cancel` – mark a job as canceled. -- `GET /status` – netlab status output. +- `GET /status[?output=text]` – the overall status of netlab lab instances on the server as generated by **netlab status --all**. +- `GET /status/{instance}[?output=text]` – netlab status output of the selected instance as generated by **netlab status --instance _instance_**. + + +```{tip} +You can use the `?output=text` query string or set the `NETLAB_API_STATUS_OUTPUT` environment variable to `text` to get the status results in the format printed by the **netlab status** command. Otherwise, the lab status is returned as a JSON document as generated by the **‌netlab status --format json** command. +``` ## Actions @@ -32,19 +38,15 @@ netlab api [--bind ] [--port ] [--auth-user ] [--auth-password - `collect` - `status` -The API runs the same Python CLI modules used by `netlab`, so behavior and -output are consistent with the CLI. +The API uses the same Python CLI modules as `netlab`, so its behavior and output are consistent with the CLI commands. ## Authentication -If `NETLAB_API_USER` and `NETLAB_API_PASSWORD` (or the `--auth-user` and -`--auth-password` flags) are set, the server requires HTTP Basic Auth on -all endpoints. +If `NETLAB_API_USER` and `NETLAB_API_PASSWORD` environment variables (or the `--auth-user` and `--auth-password` flags) are set, the server requires HTTP Basic Auth on all endpoints. ## TLS -If `NETLAB_API_TLS_CERT` and `NETLAB_API_TLS_KEY` (or the `--tls-cert` and -`--tls-key` flags) are set, the server enables HTTPS using those files. +If `NETLAB_API_TLS_CERT` and `NETLAB_API_TLS_KEY` environment variables (or the `--tls-cert` and `--tls-key` flags) are set, the server enables HTTPS using those files. ## Working Directory diff --git a/netsim/cli/api.py b/netsim/cli/api.py index 80685ce05f..b3fce61a0b 100644 --- a/netsim/cli/api.py +++ b/netsim/cli/api.py @@ -26,6 +26,7 @@ from . import collect as netlab_collect from . import create as netlab_create from . import down as netlab_down +from . import external_commands from . import status as netlab_status from . import up as netlab_up @@ -292,22 +293,31 @@ def parse_json_body(handler: BaseHTTPRequestHandler) -> Dict[str, Any]: except (json.JSONDecodeError, UnicodeDecodeError) as exc: raise ValueError("invalid JSON body") from exc +def send_reply( + handler: BaseHTTPRequestHandler, + status: int, ctype: str = 'text/plain', reply: bytes = b'') -> None: + handler.send_response(status) + handler.send_header("Content-Type", ctype) + handler.send_header("Content-Length", str(len(reply))) + handler.end_headers() + handler.wfile.write(reply) def send_json(handler: BaseHTTPRequestHandler, status: int, payload: Any) -> None: data = json.dumps(payload).encode("utf-8") - handler.send_response(status) - handler.send_header("Content-Type", "application/json") - handler.send_header("Content-Length", str(len(data))) - handler.end_headers() - handler.wfile.write(data) + send_reply(handler,status,'application/json',data) +def send_error( + handler: BaseHTTPRequestHandler, + status: int = HTTPStatus.INTERNAL_SERVER_ERROR, + error: str = 'Failed miserably') -> None: + send_json(handler,status,{"error": error}) class NetlabHandler(BaseHTTPRequestHandler): def log_message(self, format: str, *args: Any) -> None: return def _not_found(self) -> None: - send_json(self, HTTPStatus.NOT_FOUND, {"error": "not found"}) + send_error(self, HTTPStatus.NOT_FOUND,"not found") def do_GET(self) -> None: if not require_auth(self): @@ -325,48 +335,88 @@ def get_templates() -> None: template_dir = query.get("dir", [""])[0] send_json(self, HTTPStatus.OK, {"templates": list_templates(template_dir)}) - def get_jobs() -> None: - with JOB_LOCK: - jobs = [job_public(j) for j in JOBS.values()] - send_json(self, HTTPStatus.OK, {"jobs": jobs}) - - def get_status() -> None: + def get_status(*parts: Any) -> None: out = io.StringIO() - with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out): - netlab_status.run(["--all"]) - send_json(self, HTTPStatus.OK, {"status": out.getvalue()}) - - handlers: Dict[Tuple[str, ...], Callable[[], None]] = { - ("healthz",): get_healthz, - ("templates",): get_templates, - ("jobs",): get_jobs, - ("status",): get_status, - } + args = [] + query = urllib.parse.parse_qs(parsed.query) + o_format_qs = query.get("output") + if not o_format_qs: + o_format = os.environ.get("NETLAB_API_STATUS_OUTPUT","json") + else: + o_format = o_format_qs[0] - key = tuple(parts) - if key in handlers: - handlers[key]() - return + if o_format != "text": + args += ['--format','json'] + + if parts: + args += ['--instance', parts[0]] + else: + args += ['--all'] + + try: + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out): + netlab_status.run(args) + status_code = HTTPStatus.OK + except SystemExit: + status_code = HTTPStatus.NOT_FOUND + + status = out.getvalue() + try: + j_status = json.loads(status) + send_json(self, status_code, j_status) + except json.JSONDecodeError: + send_json(self, status_code, {"status": status}) + except Exception as ex: + send_error(self, HTTPStatus.INTERNAL_SERVER_ERROR, str(ex)) + + def get_jobs(job_id: Optional[str] = None, fmt: Optional[str] = None, *args: Any) -> None: + if args: + return self._not_found() + + if not job_id: + with JOB_LOCK: + jobs = [job_public(j) for j in JOBS.values()] + return send_json(self, HTTPStatus.OK, {"jobs": jobs}) - if parts[0] == "jobs" and len(parts) >= 2: - job_id = parts[1] with JOB_LOCK: - job: Optional[Dict[str, Any]] = JOBS.get(job_id) + job = JOBS.get(job_id) + if job is None: - return self._not_found() - if len(parts) == 2: - send_json(self, HTTPStatus.OK, job_public(job)) - return - if len(parts) == 3 and parts[2] == "log": + return send_error(self,HTTPStatus.NOT_FOUND,f'Job {job_id} not found') + + if not fmt: + return send_json(self, HTTPStatus.OK, job_public(job)) + if fmt == "log": try: with open(job["logPath"], "r", encoding="utf-8") as fp: content = fp.read() except FileNotFoundError: content = "" - send_json(self, HTTPStatus.OK, {"log": content}) - return + return send_json(self, HTTPStatus.OK, {"log": content}) + else: + return send_error(self,HTTPStatus.NOT_IMPLEMENTED,f'Invalid parameter {fmt}') - self._not_found() + simple_handlers: Dict[str, Callable] = { + "healthz": get_healthz, + "templates": get_templates, + } + + path_handlers: Dict[str, Callable] = { + "jobs": get_jobs, + "status": get_status, + } + + key = parts.pop(0) + try: + log.init_log_system(header=False) + if key in simple_handlers and not parts: + return simple_handlers[key]() + elif key in path_handlers: + return path_handlers[key](*parts) + else: + return self._not_found() + except SystemExit: + return send_json(self, HTTPStatus.NOT_FOUND,{"error": log._ERROR_LOG}) def do_POST(self) -> None: if not require_auth(self): @@ -404,7 +454,7 @@ def api_parse_args() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="netlab API server") parser.add_argument( "--bind", - default=os.getenv("NETLAB_API_BIND", "127.0.0.1"), + default=os.getenv("NETLAB_API_BIND", external_commands.get_local_addr()), help="Bind address (NETLAB_API_BIND)", ) parser.add_argument( @@ -436,7 +486,7 @@ def api_parse_args() -> argparse.ArgumentParser: return parser -def run(cli_args: List[str]) -> None: +def run_api(cli_args: List[str]) -> None: parser = api_parse_args() args = parser.parse_args(cli_args) auth_user = args.auth_user.strip() or None @@ -456,5 +506,15 @@ def run(cli_args: List[str]) -> None: context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) context.load_cert_chain(certfile=tls_cert, keyfile=tls_key) server.socket = context.wrap_socket(server.socket, server_side=True) - log.section_header("Starting", f"netlab API on {args.bind}:{args.port}") + http_proto = 'https' + else: + http_proto = 'http' + log.section_header("Starting", f"netlab API on {http_proto}://{args.bind}:{args.port}") server.serve_forever() + +def run(cli_args: List[str]) -> None: + try: + run_api(cli_args) + except KeyboardInterrupt: + print() + log.info('Exiting the API server')