Metadata-Version: 2.4
Name: pyinfra-lxd-api-connector
Version: 0.1.1
Summary: pyinfra connector targeting LXD containers via the LXD HTTPS API (no SSH hop, no CLI subprocess, no websockets)
Author-email: Christian Rishøj <christian@rishoj.net>
License: MIT
Project-URL: Homepage, https://github.com/crishoj/pyinfra-lxd-api-connector
Project-URL: Issues, https://github.com/crishoj/pyinfra-lxd-api-connector/issues
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pyinfra<4,>=3
Requires-Dist: httpx>=0.27
Requires-Dist: pyyaml>=6
Requires-Dist: click>=8
Requires-Dist: typing_extensions>=4.4
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-cov>=5; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Requires-Dist: mypy>=1.10; extra == "dev"
Requires-Dist: types-pyyaml; extra == "dev"
Requires-Dist: cryptography>=42; extra == "dev"
Dynamic: license-file

# pyinfra-lxd-api-connector

A [pyinfra](https://pyinfra.com/) connector that targets LXD containers via the LXD HTTPS API directly — no SSH hop, no paramiko, no `lxc` CLI subprocess, no websockets. One kept-alive HTTPS connection per host, exec via the [`container_exec_recording`](https://documentation.ubuntu.com/lxd/en/latest/api-extensions/#container-exec-recording) API extension, file transfers via the native files API.

## Why?

The obvious way to drive pyinfra against LXD is to shell out to the `lxc` CLI for every command. That approach pays a per-command cost of ~6–7 fresh TCP+TLS connections (capabilities probe + events websocket + exec POST + 4× stdio websockets + operation poll) — measured at **~870 ms per command** over Tailscale from a remote laptop.

Talking to the API directly with `record-output: true` mode collapses all of that to a single kept-alive HTTPS connection with **zero websockets**. Measured at **~150 ms per `run_shell_command`** from the same vantage point — ~5–6× faster, and within the same order of magnitude as warm SSH-multiplex.

Tracks pyinfra issue [#677](https://github.com/pyinfra-dev/pyinfra/issues/677). Per pyinfra's [contributing guide](https://docs.pyinfra.com/en/latest/contributing.html), connectors live as separate packages rather than in the pyinfra core repo.

## Performance

Per-call latency over Tailscale from a remote laptop (~27 ms RTT to the cluster), measured via `smoke_test.py` against a real container:

| Operation | Wall time |
|---|---|
| `connect()` (cold TLS + capability probe + container check) | ~335 ms |
| `run_shell_command` (warm, kept-alive) | ~130–260 ms |
| `put_file` (small payload) | ~80 ms |
| `get_file` (small payload) | ~30 ms |

For comparison, an `lxc exec`-based connector pays ~870 ms per `run_shell_command` from the same vantage point. From a node inside the cluster the difference doesn't matter; from a laptop driving deploys over a WAN it dominates wall time.

## Install

```fish
uv tool install pyinfra --with pyinfra-lxd-api-connector
```

## Usage

Prereq: an `lxc remote` configured locally:

```fish
lxc remote add mycluster https://your-cluster:8443 --token <token>
lxc list mycluster:        # verify
```

The connector reads the standard LXD client config at `~/.config/lxc/`:

- `config.yml` — remote URL
- `client.crt` + `client.key` — mTLS client identity
- `servercerts/<remote>.crt` — pinned server cert

Inventory:

```python
hosts = [
    "@lxd_api/mycluster:php01",       # explicit remote
    "@lxd_api/web1",                   # uses default-remote from lxc config
    "@lxd_api/some-other-cluster:web1",
]
```

A bare `@lxd_api/<container>` resolves the remote via the `default-remote` field in `~/.config/lxc/config.yml` — the same field `lxc` itself consults when called without a remote qualifier. Switch the default with `lxc remote switch <name>`. If no default is set, the connector raises an `InventoryError` pointing you at the qualified form.

## Requirements

- LXD server with the `container_exec_recording` API extension (LXD 5.0+).
- Local LXD client config at `~/.config/lxc/`. `lxc remote add` sets all of this up.

## Status

Alpha. In production use against a 32-container LXD cluster since 2026-04-28. Feedback / bug reports welcome.

## Known limitations

- **No interactive / PTY support** — the connector raises `NotImplementedError` if `_get_pty=True`. pyinfra never needs PTY for facts/operations, so this is fine in practice; if you need an interactive shell, use `lxc shell` directly.
- **Per-command stdout/stderr is buffered, not streamed** — `record-output` mode means output arrives at the end of the command. For pyinfra's typical workload (facts and one-shot operations) this is invisible; for long-running commands you won't see live progress.
- **Run-time HTTP calls (exec, file transfer) don't retry** — only the two `connect()` GETs retry on transient errors. Mid-run network blips on `run_shell_command` / `put_file` / `get_file` will fail the operation. Per-call retry there is operation-dependent (e.g. `POST /exec` is unsafe to blindly retry once it's reached the server). Tracked in [#2](https://github.com/crishoj/pyinfra-lxd-api-connector/issues/2).

## AI assistance

Per the [pyinfra AI usage policy](https://github.com/pyinfra-dev/pyinfra/blob/3.x/AI_POLICY.md), disclosing how this package was authored:

The initial draft of `pyinfra_lxd_api_connector.py` was generated by Claude (Anthropic) in collaboration with the maintainer (Christian Rishøj). Specifically:

- Christian identified the original problem — a silent SFTP-truncation bug in an earlier `lxc exec`-based driver — and ran the empirical analysis showing that the LXD `record-output: true` API path was the right fast alternative.
- Claude drafted the connector module against pyinfra's `BaseConnector` interface.
- Christian reviewed every line, integrated and ran it against a 32-container production cluster, and iterated through several rounds of correctness, latency, and ergonomics fixes.
- All subsequent maintenance is human-driven.

The code in this repository is fully understood and reviewed by the maintainer; AI assistance is a drafting tool, not a substitute for human judgment.

## License

MIT — see [LICENSE](LICENSE).
