Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions docs/netlab/api.md
Original file line number Diff line number Diff line change
@@ -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 <addr>] [--port <port>] [--auth-user <user>] [--auth-password <password>]
[--tls-cert <path>] [--tls-key <path>]
```

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=<path>` – list YAML templates in a directory.
- `GET /templates?dir=<path>` – 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

Expand All @@ -32,19 +38,15 @@ netlab api [--bind <addr>] [--port <port>] [--auth-user <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

Expand Down
140 changes: 100 additions & 40 deletions netsim/cli/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand 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):
Expand All @@ -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):
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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')