Packages

Elixir client for the Incus (linuxcontainers.org) REST API

Current section

Files

Jump to
ex_incus README.md
Raw

README.md

# ExIncus
Elixir client for the [Incus](https://linuxcontainers.org/incus/) REST API
([API reference](https://linuxcontainers.org/incus/docs/main/api/)), built on
[Req](https://hexdocs.pm/req).
Covers instances (containers and VMs), snapshots, networks, profiles, images,
projects, storage pools/volumes, certificates, server config and background
operations. Anything not wrapped yet is reachable through
`ExIncus.Client.request/4`.
## Installation
```elixir
def deps do
[
{:ex_incus, "~> 1.0"}
]
end
```
## Connecting
```elixir
# Local Unix socket (default: /var/lib/incus/unix.socket)
client = ExIncus.connect()
# Remote server over HTTPS with TLS client certificate auth
client =
ExIncus.connect(
base_url: "https://incus.example.net:8443",
certfile: "/path/client.crt",
keyfile: "/path/client.key"
)
# Scope everything to an Incus project
client = ExIncus.connect(project: "staging")
```
The client is a plain struct - build it once, share it freely.
## Usage
```elixir
# Create + start a container from images.linuxcontainers.org
{:ok, _} = ExIncus.launch(client, "web1", "debian/12",
config: %{"limits.memory" => "1GiB"})
# Start / stop / restart
{:ok, _} = ExIncus.stop(client, "web1")
{:ok, _} = ExIncus.start(client, "web1")
# Give an interface a static IP on a managed bridge
{:ok, _} =
ExIncus.Instances.set_device(client, "web1", "eth0", %{
"type" => "nic",
"network" => "incusbr0",
"ipv4.address" => "10.140.100.10"
})
# Point the container at our DNS server
{:ok, _} =
ExIncus.push_file(client, "web1", "/etc/resolv.conf",
"nameserver 10.140.100.1\n", uid: 0, gid: 0, mode: 0o644)
# Run commands
{:ok, result} = ExIncus.exec(client, "web1", ["ip", "-4", "addr"])
result.exit_code #=> 0
result.stdout #=> "1: lo: ..."
# Import a locally converted OCI image and (re)point an alias at it
# (equivalent of: incus image import img.tar.gz --alias myapp --reuse)
{:ok, image} = ExIncus.import_image(client, "/tmp/converted.tar.gz",
alias: "myapp", reuse: true)
# ...then launch from the local alias
{:ok, _} = ExIncus.launch(client, "app1", %{"type" => "image", "alias" => "myapp"})
# Change config (merged, other keys untouched)
{:ok, _} = ExIncus.Instances.update_config(client, "web1", %{"boot.autostart" => true})
# Inspect runtime state (addresses, usage, ...)
{:ok, state} = ExIncus.Instances.state(client, "web1")
# Tear down
{:ok, _} = ExIncus.stop(client, "web1", force: true)
{:ok, _} = ExIncus.delete_instance(client, "web1")
```
### Live events (no polling)
For dashboards and monitors, `ExIncus.EventListener` subscribes to the
`/1.0/events` websocket and pushes events to your processes. Run one listener
per Incus server in your supervision tree:
```elixir
children = [
{ExIncus.EventListener,
name: MyApp.IncusEvents,
connect: [socket_path: "/var/lib/incus/unix.socket"],
types: [:lifecycle, :operation]}
]
```
Then, from any process (a LiveView, say):
```elixir
{:ok, status} = ExIncus.EventListener.subscribe(MyApp.IncusEvents, types: [:lifecycle])
# messages arriving in handle_info/2:
{:ex_incus_event, %ExIncus.Event{action: "instance-started", name: "web1"}}
{:ex_incus_connected, listener} # fires on every (re)connect
{:ex_incus_disconnected, listener, reason}
```
Incus does not replay missed events, so the pattern for a correct live view
is: subscribe → snapshot (`ExIncus.Instances.list/2`) → apply events → on
`{:ex_incus_connected, _}` snapshot again. The listener reconnects itself
with backoff.
To bridge into `Phoenix.PubSub` instead, pass a handler:
```elixir
{ExIncus.EventListener,
connect: [socket_path: "/var/lib/incus/unix.socket"],
handler: fn msg -> Phoenix.PubSub.broadcast(MyApp.PubSub, "incus", msg) end}
```
Note on "booted" vs "started": `instance-started` fires when init launches.
For a genuine ready signal, have the guest report readiness (a one-liner
against `/dev/incus/sock` inside the container) and watch for the
`instance-ready` lifecycle action.
### Conventions
* Every call takes the client first and returns `{:ok, result}` or
`{:error, %ExIncus.Error{}}` - never raises.
* State-changing calls are asynchronous on the Incus side. By default the
library waits for the background operation to finish (`timeout:` seconds,
default 300, or `:infinity`). Pass `wait: false` to get the
`ExIncus.Operation` back immediately and deal with it yourself via
`ExIncus.Operations`.
* A command that exits non-zero inside `exec` is still `{:ok, result}` -
check `result.exit_code`.
### Modules
| Module | Covers |
| --- | --- |
| `ExIncus` | shortcuts for the common instance operations |
| `ExIncus.Instances` | lifecycle, state, config/devices, exec, files, logs |
| `ExIncus.EventListener` / `ExIncus.Event` | live event stream over websocket |
| `ExIncus.Snapshots` | instance snapshots incl. restore |
| `ExIncus.Networks` | networks, DHCP leases, state |
| `ExIncus.Profiles` | profiles |
| `ExIncus.Images` | images, tarball import/export, aliases |
| `ExIncus.Projects` | projects |
| `ExIncus.StoragePools` / `ExIncus.StorageVolumes` | storage |
| `ExIncus.Certificates` | trust store |
| `ExIncus.Server` | server info/config, hardware resources |
| `ExIncus.Operations` | list/get/wait/cancel background operations |
| `ExIncus.Client` | connection handling, raw `request/4` escape hatch |
## Testing
The unit tests stub the HTTP layer with `Req.Test` and run anywhere (no Incus
needed - they pass on macOS):
```sh
mix test
```
Integration tests exercise a real Incus server and are excluded by default.
On a machine with Incus:
```sh
mix test --only integration
```
Configured via environment variables:
| Variable | Meaning | Default |
| --- | --- | --- |
| `EX_INCUS_TEST_SOCKET` | Unix socket path | `/var/lib/incus/unix.socket` |
| `EX_INCUS_TEST_URL` | remote `https://host:8443` (takes precedence) | - |
| `EX_INCUS_TEST_CERTFILE` / `EX_INCUS_TEST_KEYFILE` | client cert for remote | - |
| `EX_INCUS_TEST_IMAGE` | image alias to launch | `alpine/3.21` |
They create instances named `ex-incus-test-*` and clean up after themselves.
Note the socket is usually owned by root/incus-admin, so run as a user in the
`incus-admin` group.
## Not (yet) implemented
* Interactive/streaming exec over websockets (`exec` uses `record-output`
instead, which suits scripted use)
* Split (metadata + rootfs) image tarballs - unified tarballs only
* Cluster management endpoints
* ETag / `If-Match` concurrency control (pass `headers: [{"if-match", etag}]`
yourself where needed)
## License
Copyright 2026 Nippy Networks
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.