Metadata-Version: 2.4
Name: lvi-script-sandbox-utils
Version: 0.12.0
Summary: Convenience utilities for LogicVein Script Sandbox Python scripts
License-Expression: Apache-2.0
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Dynamic: license-file

# LogicVein Script Sandbox Utils

Convenience helpers for Python scripts that LogicVein Playbooks run in the
Script Sandbox container. They wrap the sandbox's file-and-stdout contract:
a Playbook hands your script its inputs in `in.json`, and your script returns
results by printing JSON to stdout.

## Set up the Script Sandbox

Create a directory for the sandbox on the Docker host, and run the
container from it, so that its `scripts/` and `packages/` directories live
there:

```bash
mkdir -p lvi_script_sandbox/scripts lvi_script_sandbox/packages
cd lvi_script_sandbox
```

Creating `scripts/` and `packages/` yourself keeps them owned by you;
Docker would otherwise create them owned by root.

| On the host | In the sandbox | What goes there |
|---|---|---|
| `lvi_script_sandbox/scripts` | `/shared` (read-only) | Files scripts read: playbooks, certificates, ... |
| `lvi_script_sandbox/packages` | `/packages` (on `PYTHONPATH`) | Python packages scripts import |

Then start the sandbox with `docker run`:

```bash
docker run -d \
  --name lvi-script-sandbox \
  --read-only \
  --tmpfs /tmp:size=1G \
  --cap-add=SYS_ADMIN \
  --security-opt no-new-privileges \
  -p 2022:2022 \
  -v "$PWD/scripts:/shared:ro" \
  -v "$PWD/packages:/packages" \
  harbor.logicvein.com/lvi/lvi-script-sandbox:latest
```

or with Docker Compose, using this `lvi_script_sandbox/docker-compose.yaml`
and `docker compose up -d`:

```yaml
services:
  lvi-script-sandbox:
    image: harbor.logicvein.com/lvi/lvi-script-sandbox:latest
    container_name: lvi-script-sandbox
    restart: no
    read_only: true
    cap_add:
      - SYS_ADMIN
    security_opt:
      - no-new-privileges:true
    ports:
      - "2022:2022"
    tmpfs:
      - /tmp:size=1G
    volumes:
      - ./scripts:/shared:ro
      - ./packages:/packages
```

Port 2022 is the sandbox's SSH API (`ssh -p 2022 root@<sandbox> ...`), used
below to install packages and set the ThirdEye API key.

## Installation

Install the package with the sandbox's `pip`, which installs into
`/packages`:

```bash
ssh -p 2022 root@<sandbox> pip install lvi-script-sandbox-utils
```

or from the host, into `lvi_script_sandbox/packages`:

```bash
pip install --target ./packages lvi-script-sandbox-utils
```

Pre-release versions are on TestPyPI:

```bash
pip install --target ./packages \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  lvi-script-sandbox-utils
```

The library has no third-party dependencies (standard library only), so
installing from the host is safe; packages with compiled parts should be
installed with the sandbox's `pip`, so they're built for its Python.

## Usage

```python
import lvi_script_sandbox_utils as lvi

text = lvi.get_result()                       # the previous node's output
lvi.log("checking", lvi.device.hostname)      # debugging message

lvi.set_globals(
    line_count=len(text.splitlines()),
    **lvi.device.to_globals(["hostname", "model"]),
)
```

### Read the previous node's output

`lvi.get_result()` returns the previous node's result, such as a CLI
command's text. In a Playbook's first script node there is none, and it
returns `""` (or `get_result(default=...)`).

### Read the device

`lvi.device` is the Playbook's device. Its properties use LogicVein's names:
`hostname`, `ipAddress`, `model`, `osVersion`, `serialNumber`, `custom1`
.. `custom5`, `uuid`, and so on (your editor can list them). It's built the
first time you use it, and is the same object every time.

- Unset values are `None`.
- End-of-life/sale fields (`endOfLife`, `endOfSale`, `softwareEndOfLife`,
  `softwareEndOfSale`) are `YYYY-MM-DD` dates, and `lastBackup`,
  `lastChange`, `lastDiscovery`, `lastTelemetry` are `YYYY-MM-DDTHH:MM:SSZ`
  timestamps, all in UTC.
- `traits` lists the device's capabilities (`ssh`, `snmp`, `ncm`, ...).
- `lvi.device.to_dict()` gives all properties as a dict.

### Read other Playbook variables

Variables set earlier in the Playbook, by a Set Variables node or as a
Device Search node's replacement value, are globals:

```python
lvi.get("message")                     # short for lvi.get_global("message")
lvi.get_global("hostname")             # KeyError, listing what is set, if missing
lvi.get_global("site", default="")
lvi.get_globals()                      # all of them, as a dict
lvi.metadata.playbook                  # details of this run: playbook, executor,
lvi.metadata.execution_id              # execution_id, job_id, node, started_at, ...
```

### Return results

Call `lvi.set_globals(...)` **once**, at the end of the script, with
everything the Playbook should get back as global variables. Calling it (or
`set_raw()`) a second time raises `RuntimeError`, so build up results in a
dict and pass `**results`.

Playbook globals are strings. Numbers and booleans are fine, `None` is sent
as `""`, and a list, dict, device or metadata object is sent as its JSON
text, which a later script node can read back with
`json.loads(lvi.get_global(name))`. For a different format, convert it
yourself, e.g. `traits=", ".join(lvi.device.traits)`.

To return device properties, use `lvi.device.to_globals()`, all of them, or
`to_globals(["hostname", "model"])` for some. It names `hostname` and
`network` `device_hostname` and `device_network`, so they don't overwrite the
Playbook's own `hostname` and `network` globals.

`lvi.set(...)` is a shorter `set_globals()` that also takes variables,
sending each under its own name:

```python
message = lvi.get("message")
replacement_message = lvi.get("replacement_message")
line_count = len(lvi.get_result().splitlines())

lvi.set(message, replacement_message, line_count, status="ok")
# same as lvi.set_globals(message=message, replacement_message=replacement_message,
#                         line_count=line_count, status="ok")
```

Pass only plain variables that way; for anything else, give it a name:
`lvi.set(model=lvi.device.model)`. Use it as `lvi.set`: importing it
bare (`from lvi_script_sandbox_utils import set`) hides Python's own `set()`
in the script.

`lvi.set_raw(text)` returns text as the node's raw output instead.

### Fail the node

When the script can't do its job, call `lvi.fail("why")` instead of
`set_globals()`. The Playbook marks the node failed with that message, and the
script stops there. Any `log()` messages are still returned.

An uncaught exception does the same automatically: the node fails with the
error, e.g. `ValueError: invalid literal for int() ... (line 4 of script.py)`,
instead of a traceback on stderr, which would fail the run with no message.

### Call the ThirdEye API and get device credentials

Once the sandbox has an API key (`ssh -p 2022 root@<sandbox> setkey <key>`),
scripts can call the ThirdEye server's JSON-RPC API:

```python
configs = lvi.call_api("Credentials.getAllCredentialConfigs", network="Default")

creds = lvi.get_credentials()          # what LogicVein last logged in to lvi.device with
creds.username, creds.password, creds.enablePassword, creds.protocols
lvi.get_credentials("192.0.2.12", "Default")   # another device
```

`get_credentials()` raises `LookupError` if LogicVein has never logged in to
the device. Printing or logging a `Credentials` object hides its passwords,
keys and community strings, and `set_globals()` refuses to send one. Any
script in the sandbox can read every device's credentials this way, so treat
the API key accordingly.

The server's TLS certificate is verified. If ThirdEye uses a self-signed
certificate, save it (PEM) as `thirdeye-ca.pem` in the scripts volume, which
the sandbox mounts at `/shared`, or set `API_CA_FILE` to its path. To run a
script outside a Playbook, set `API_HOST` and `API_KEY` yourself.

To skip certificate verification instead, set `API_VERIFY_TLS=false`, e.g.
at the top of the script:

```python
import os

os.environ["API_VERIFY_TLS"] = "false"
```

Without verification, anyone who can intercept the connection to ThirdEye
gets the API key and any credentials the script reads, so prefer trusting
the certificate, and keep this to labs.

### Run an Ansible playbook against the device

With Ansible installed in the sandbox, a script can run a playbook against
the device, logging in with its LogicVein credentials:

```python
result = lvi.run_playbook("ansible/show_version.yml")
if not result.ok:
    lvi.fail(result.message)
lvi.set_globals(**result.to_globals())
```

See [Running Ansible playbooks from a LogicVein Playbook](docs/ansible.md)
for setting up the sandbox, writing the playbook, and reading the results.

### Debugging

A script can't print while it runs in a Playbook: stdout carries the
results the Playbook reads, and the Script Sandbox fails the run if the
script writes anything to stderr. That includes Python warnings, which go to
stderr too.

Use `lvi.log(...)` instead. It takes the same arguments as `print()`, keeps
the message, and `set_globals()` returns all of them, one per line, as the
`script_log` global, so they show up with the node's results. Messages are
lost if the script never reaches `set_globals()`, and `set_raw()` doesn't
return them.

```python
lvi.log("parsed", len(lines), "lines")
lvi.set_globals(total="61 MB")      # also sends script_log="parsed 12 lines"
```

To run a script outside a Playbook, save an `in.json` from a run and point
the package at it:

```bash
LVI_SANDBOX_INPUT=./samples/in.json python my_script.py
```

When `LVI_SANDBOX_INPUT` is set, `lvi.log()` also prints each message to the
terminal (stderr) as it happens.

`lvi.load_input()` returns the whole `in.json` as a dict, if you need
something the functions above don't cover. Treat it as read-only.

## Development and releases

See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and
[PUBLISHING.md](PUBLISHING.md) for how releases are published.
