Using saltext-incus

This extension manages Incus instances from Salt. It has two surfaces:

  1. Instance lifecycle (create, start, stop, configure, delete) driven through the incus command line.

  2. Agentless in-instance state application: apply SLS inside an instance over incus exec with no resident minion, the way docker.sls works for containers.

Requirements

Run these modules on the minion that is the Incus host (the machine where the incus client talks to the local daemon). That minion needs the incus binary on its PATH. The control node runs Salt 3007 or 3008.

For the in-instance apply functions (incus.call, incus.sls, and the sls_applied state) with the default thin transport, the target instance needs only a Python 3 interpreter. No Salt installation in the instance is required. If the instance already has Salt installed, you can use transport: baked to run its own salt-call instead of shipping the thin.

Configuration

Optional settings live under the incus: namespace in the minion config, grains or pillar, and are read with config.get:

incus:
  # Extra Python modules to fold into the shipped thin, same meaning as in
  # salt-ssh. Usually unnecessary.
  thin_extra_mods: ""
  thin_so_mods: ""

Most calls take an optional project argument to scope the operation to a non-default Incus project. Projects, networks, storage pools and profiles are referenced by name; this version does not manage them as their own resources.

Execution module

All examples target a single Incus host minion. Replace 'incus-host' with your own target, or use salt-call locally on the host.

Lifecycle

# Create from an image and start it
salt 'incus-host' incus.create web01 images:alpine/edge start=True

# Create with config and a profile, leave it stopped
salt 'incus-host' incus.create web01 images:alpine/edge \
    profiles='[default, web]' \
    config='{limits.cpu: "2", boot.autostart: true}'

# Start, stop, restart
salt 'incus-host' incus.start web01
salt 'incus-host' incus.stop web01 timeout=30
salt 'incus-host' incus.restart web01

# Inspect: returns the full instance dict (status, config, devices, profiles)
salt 'incus-host' incus.info web01
salt 'incus-host' incus.exists web01
salt 'incus-host' incus.list

# Config keys
salt 'incus-host' incus.config_set web01 boot.autostart true
salt 'incus-host' incus.config_unset web01 boot.autostart

# Devices
salt 'incus-host' incus.device_add web01 data disk source=/srv/web01 path=/data
salt 'incus-host' incus.device_remove web01 data

# Delete
salt 'incus-host' incus.delete web01

Running a command inside an instance

incus.call runs a single Salt execution function inside the instance:

salt 'incus-host' incus.call web01 test.ping
salt 'incus-host' incus.call web01 cmd.run 'id -un'
salt 'incus-host' incus.call web01 pkg.install nginx

Applying SLS inside an instance

incus.sls applies one or more SLS modules inside the instance and returns the highstate result. The SLS and any pillar are resolved on the control node; the instance does not need a master or a minion.

# Apply two SLS modules
salt 'incus-host' incus.sls web01 mods=access.users,access.sshd

# Dry run (nothing changes inside the instance)
salt 'incus-host' incus.sls web01 mods=hardening test=True

# Precompiled mode: no SLS source or Jinja ever reaches the instance
salt 'incus-host' incus.sls web01 mods=hardening precompiled=True

Key arguments:

  • mods: SLS modules, as a comma-separated string on the CLI or a list in a state.

  • pillar: pillar data, already resolved on the control node. It is shipped as a root-only file and never placed on the command line.

  • test: run in test mode.

  • transport: thin (default) ships the thin per run; baked uses the instance’s own salt-call.

  • precompiled: False (default) ships SLS source and renders in-instance; True compiles on the control node and applies a self-contained tarball. See Choosing a render strategy below.

  • cleanup: True (default) removes the in-instance staging directory after the run. Set False to leave it for debugging.

Building an image

incus.sls_build launches a throwaway instance from a base image, applies SLS inside it, stops it, publishes it as a local image, and always deletes the throwaway instance afterward:

# Build and publish an image aliased mycorp/web
salt 'incus-host' incus.sls_build mycorp/web base=images:alpine/edge mods=web,hardening

# Dry run: apply in test mode, do not publish
salt 'incus-host' incus.sls_build mycorp/web base=images:alpine/edge mods=web test=True

State module

incus.present

present ensures an instance exists with the declared config and devices. It reconciles additively: it sets the config keys and devices you declare and leaves everything else alone. It does not remove the volatile.* and image.* keys Incus injects, and it does not remove devices inherited from profiles. Profiles are attached at creation and are not reconciled afterward.

web01 present:
  incus.present:
    - name: web01
    - image: images:alpine/edge
    - running: true
    - profiles:
      - default
      - web
    - config:
        limits.cpu: "2"
        limits.memory: 2GiB
        boot.autostart: true
    - devices:
        data:
          type: disk
          source: /srv/web01-data
          path: /data

running: true ensures the instance is started, running: false ensures it is stopped, and omitting it leaves the run state untouched. image is required only when the instance must be created.

incus.running, incus.stopped, incus.absent

web01 running:
  incus.running:
    - name: web01

batch01 stopped:
  incus.stopped:
    - name: batch01

old01 absent:
  incus.absent:
    - name: old01

running and stopped fail if the instance does not exist. absent is a no-op if it is already gone.

incus.sls_applied

sls_applied applies SLS inside a running instance through the agentless thin path. The instance must already exist and be running, so compose it after present or running with a requisite. The in-instance highstate result is mapped onto this state: any failed inner state fails this state, test=True yields a None result, and the inner changes are reported through changes.

web01 present:
  incus.present:
    - name: web01
    - image: images:alpine/edge
    - running: true

web01 configured:
  incus.sls_applied:
    - name: web01
    - mods:
      - access.users
      - access.sshd
      - nginx
    - require:
      - incus: web01 present

A complete sls_applied example: nginx on Alpine

If you have not used sls_applied before, here is the whole chain end to end. The goal: stand up an Alpine instance and, inside it, install nginx and enable it as a boot service. Nothing runs an agent inside the instance; Salt ships itself in for the duration of the run and cleans up after.

There are two pieces: the ordinary SLS that describes the in-instance state, and the orchestrating SLS that runs on the Incus host minion.

1. The in-instance SLS lives in your control node’s file_roots, exactly like any other SLS you would apply to a minion. Save this as nginx/init.sls (so it is referenced by the mod name nginx):

# salt://nginx/init.sls
nginx:
  pkg.installed: []

nginx service:
  service.running:
    - name: nginx
    - enable: true
    - require:
      - pkg: nginx

This is not Incus-specific in any way. pkg.installed installs the nginx package, and service.running with enable: true starts it and adds it to the boot runlevel. The require makes sure the package is installed before Salt tries to start the service.

2. The orchestrating SLS runs on the minion that is the Incus host. Save it as incus_web.sls:

# salt://incus_web.sls   (applied on the Incus host minion)

web01 present:
  incus.present:
    - name: web01
    - image: images:alpine/3.20
    - running: true

web01 has python3:
  cmd.run:
    - name: incus exec web01 -- apk add --no-cache python3
    - unless: incus exec web01 -- /bin/sh -c "command -v python3"
    - require:
      - incus: web01 present

web01 nginx configured:
  incus.sls_applied:
    - name: web01
    - mods:
      - nginx
    - require:
      - cmd: web01 has python3

The middle state is the one Alpine-specific wrinkle: stock Alpine images do not ship a Python interpreter, and the default thin transport needs Python 3 inside the instance to run salt-call. This cmd.run installs it once (the unless guard skips it on later runs), using the instance’s outbound network, which the default Incus bridge provides. An image that already includes Python 3 would not need this step.

Apply it on the host:

salt 'incus-host' state.apply incus_web

What happens, in order:

  1. web01 present runs on the host. Salt calls the incus CLI to create web01 from images:alpine/3.20 and start it. On the first run this reports the instance as created; on later runs it finds the instance already matches and reports no changes.

  2. web01 has python3 runs apk add python3 inside the instance so the thin has an interpreter to run under.

  3. web01 nginx configured is the sls_applied call. Under the hood it:

    1. Confirms web01 exists and is running (it fails fast if not, which is why the requisites above start it first).

    2. On the control node, gathers the nginx SLS source from your file_roots.

    3. Ships the Salt thin, that SLS source, and a small masterless minion config into a root-only directory under /run inside the instance.

    4. Runs salt-call --local state.apply nginx inside the instance. Salt detects the OS as Alpine, so pkg.installed resolves to apk add nginx and service.running uses OpenRC (rc-service nginx start plus rc-update add nginx).

    5. Removes the staging directory from the instance.

    6. Returns the in-instance highstate to the host. sls_applied maps it onto its own result: every inner state succeeded, so this state’s result is True, and the inner changes surface under changes.

Reading the result. The host-side output for the last state looks roughly like this on the first run:

----------
          ID: web01 nginx configured
    Function: incus.sls_applied
      Result: True
     Comment: applied 2 in-instance states (2 changed)
     Changes:
              ----------
              nginx:
                  ----------
                  new:
                      <installed nginx version>
                  old:
              nginx service:
                  ----------
                  nginx:
                      True

The Changes block is the in-instance highstate folded up: the nginx package was installed, and the nginx service state reports the service is now running and enabled.

Idempotency. Apply the same SLS again and nothing changes. web01 present finds the instance already in its declared state, web01 has python3 is skipped by its unless, and web01 nginx configured runs the in-instance apply again but finds nginx already installed and the service already running and enabled. sls_applied then reports Result: True with applied 2 in-instance states (0 changed) and an empty Changes block. It is safe to run on every highstate.

Test mode. To preview without changing anything, add test=True:

salt 'incus-host' state.apply incus_web test=True

incus.present reports what it would create or change, and sls_applied runs the in-instance apply in test mode so each inner state reports what it would do with a None result, while nothing is actually installed or started inside the instance.

Passing pillar to sls_applied

Pillar is resolved on the control node and passed through the pillar argument. The idiomatic way to forward a pillar subtree is the json Jinja filter, which emits a JSON object that is also valid YAML, so the state receives a real dict:

web01 configured:
  incus.sls_applied:
    - name: web01
    - mods:
      - access.users
    - pillar:
        access: {{ salt['pillar.get']('access', {}) | json }}
    - require:
      - incus: web01 present

Note

Decrypt any GPG or Vault values before passing them, since the resolved values are what gets shipped (as a root-only file) into the instance.

Precompiled application in a state

Set precompiled: true to compile on the control node and ship low state instead of SLS source:

web01 hardened:
  incus.sls_applied:
    - name: web01
    - mods:
      - hardening
    - precompiled: true
    - require:
      - incus: web01 present

Choosing a render strategy

incus.sls and sls_applied support two strategies, selected by precompiled.

Use the default (precompiled: false) for most cases. The requested SLS source is shipped into the instance and state.apply runs there, so Jinja and map.jinja render against the instance’s own grains. The one limitation is that only the requested mods’ top-level component directories are shipped (applying access.users ships all of salt://access/). A salt:// file source that lives in a different component is not shipped by this strategy.

Use precompiled: true when you need files from other components shipped, or when you want no SLS source, Jinja or macros to reach the instance at all. The low state is compiled on the control node using the instance’s grains (gathered first), referenced files are gathered, and a self-contained tarball is applied with state.pkg. The trade-off is that the state set must compile cleanly off-instance.

Debugging

Set cleanup: false (state) or cleanup=False (CLI) to leave the in-instance staging directory in place after a run so you can inspect it. It lives under /run/salt.incus.<random> and is owned by root with mode 0700.

If the instance already has Salt installed and you want to use it instead of shipping the thin, pass transport: baked.