Feint itself needs nothing: one Go binary, no dependency, no daemon.
Version 0.13.0, named rather than fetched as latest: a mutable reference
downloads whatever is newest, which is a binary neither of us can name
afterwards and a release adopted the day it is published. This line moves when
the CHANGELOG does, and feint docs --check fails until it has.
Needs cosign (or gh, further down) and nothing else. Every file is fetched
to disk and verified before anything runs it.
base=https://github.com/stephrobert/feint/releases/download/v0.13.0
# The published binary for this machine, or a refusal: no default, because
# the wrong architecture fails at exec with a message naming neither.
case "$(uname -s)-$(uname -m)" in
Darwin-x86_64) asset=feint-darwin-amd64 ;;
Darwin-arm64) asset=feint-darwin-arm64 ;;
Linux-x86_64) asset=feint-linux-amd64 ;;
Linux-aarch64) asset=feint-linux-arm64 ;;
*) echo "no published binary for $(uname -s)-$(uname -m)" >&2; exit 1 ;;
esac
curl -fsSLO "$base/$asset"
curl -fsSLO "$base/checksums.txt"
curl -fsSLO "$base/checksums.txt.cosign.bundle"
# Who produced the list, before trusting a single hash inside it. One
# signature covers every artefact, because every hash is in this file.
#
# The identity names the release workflow and the tag ref, not the
# repository: 'github.com/<slug>/.*' would accept any workflow here that
# ever gets id-token: write, which is a claim about who owns the
# repository rather than about what built this file. Anchored at both
# ends, because an unanchored pattern matches anywhere in the string.
cosign verify-blob --bundle checksums.txt.cosign.bundle \
--certificate-identity-regexp '^https://github\.com/stephrobert/feint/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
checksums.txt
# Then the bytes against the list. Without --ignore-missing this fails on
# every platform you did not download, which reads like a bad binary.
sha256sum -c checksums.txt --ignore-missing
install -m 0755 "$asset" ~/.local/bin/feintWith gh, which checks the build provenance instead: it proves which
workflow and which commit produced the binary, where the signature above
proves who published the list. --signer-workflow is what makes the
difference between an identity and a provenance: without it the check
accepts anything this repository attested, whichever workflow did it.
gh release download v0.13.0 --repo stephrobert/feint --pattern 'feint-linux-amd64'
gh attestation verify feint-linux-amd64 --repo stephrobert/feint \
--signer-workflow stephrobert/feint/.github/workflows/release.yml| Binary | Control plane | --vm: real machines |
|---|---|---|
feint-darwin-amd64 |
exercised: tests and race detector, skipped on private forks | impossible: Incus publishes no macOS server |
feint-darwin-arm64 |
exercised: tests and race detector, skipped on private forks | impossible: Incus publishes no macOS server |
feint-linux-amd64 |
exercised: tests, race detector, and it answers | possible; a nightly CI job proves it, and no pull-request gate does |
feint-linux-arm64 |
exercised: tests and race detector, skipped on private forks | Incus is packaged for it; the nightly runtime job runs amd64 only |
--vm is off by default, and no pull-request gate turns it on. Starting
machines is a side effect on whoever's host runs it, so it is asked for rather
than assumed, and the conformance suite has to stay runnable where no runtime
exists — which is every pull request. The network suites skip themselves there
and say so.
A nightly job does run it: .github/workflows/runtime-proof.yml installs Incus
and OVN on a GitHub-hosted runner and drives the network, ssh and crash suites
in both modes. It is advisory until its promotion criterion is met, which is
why no pull request depends on it. Locally, FEINT_VM=incus-ovn mise run conformance on a host with Incus proves the same thing.
The non-amd64 runners are free here and billed on a private repository, so the job skips on a private fork rather than spending somebody's minutes without asking. A fork that wants that coverage removes the condition, which is one line.
Older versions stay downloadable at their own tag: nothing here rewrites what a previous release published.
feint serveBuilding it instead needs a Go toolchain and gives up every check above, since nothing is signed until it is released:
go install github.com/stephrobert/feint/cmd/feint@latestThat gives you the control plane — every API answers, scw, octl and exo
drive their own packs, Terraform and OpenTofu drive Scaleway and Outscale, and
nothing runs. It is what CI uses and it needs no prerequisite at all.
brew install stephrobert/feint/feintFor the reader who installs everything with brew and does not go looking for a
tarball. It is a binary formula: it downloads the published
feint-darwin-arm64 or feint-darwin-amd64 — or their Linux counterparts on
Linuxbrew — and installs those bytes. Nothing is rebuilt locally, so what
Homebrew verifies is what the release signed, which is the same reasoning the
container image follows.
What it checks, and what it does not. Homebrew compares the SHA-256 of the
file it downloaded against the digest in the formula before installing, and
refuses on a mismatch. That is one of the two guarantees the commands above
give: the missing one is cosign verify-blob, which proves who produced the
digest list. So the honest ranking is that brew is the fastest correct
install, and the block at the top of this page is the strongest one. A reader
who needs the provenance runs the gh attestation verify command above on the
installed binary.
The digests are derived, never typed. The tap's formula is the output of
mise run release:formula, which reads the release's own cosign-signed
checksums.txt and renders the file. .github/workflows/tap.yml derives it
again every day and exits 2 while the tap serves anything else — so a tap left
behind by a release is named within a day rather than two versions later. The
refusals that keep a fetched checksums list from becoming an arbitrary URL live
in internal/release/formula.go, with the tests that fail without them.
Older versions. The tap carries the current release only. An older one is still installed by the commands at the top of this page, which name their tag.
Measured, not assumed. On 2026-08-20, Homebrew 5.1.15, against the
published tap rather than a rendered file: brew install stephrobert/feint/feint fetched the v0.9.0 binary, the installed bytes hashed
to the digest in the release's signed checksums.txt, feint version answered
v0.9.0, and brew test passed. One flipped byte in a digest made the same
install fail with Formula reports different checksum instead of installing —
which is the guarantee this section claims, seen refusing.
Publishing it is also what found the one defect rendering had not: brew audit
refused the first formula for declaring version 0.9.0 when the download URL
already carries it. The stanza is gone, and the formula's own test do is what
makes its absence safe — it asserts that the version brew scanned equals the one
the installed binary reports, so a scan that ever goes wrong fails brew test
rather than publishing a wrong version quietly. brew audit now exits 0.
For the CI that consumes an image rather than a binary. What it can and cannot
do is generated below, because the section names an image, a tag and a signing
identity, and each of those is a claim about what the release workflow does:
the conformance suite drives the emulator inside this image on every pull
request, and the release refuses to push one whose feint version is not the
tag.
Control plane only. The image runs feint serve with --vm off and
emulates nothing but the three control planes. Real machines behind --vm need
the binary on a host with Incus, exactly as above: an image that promised to
start containers from inside a container would be the half-truth this project
refuses. The image exists so the emulator can enter a services: block or a
compose file; the self-detaching binary stays the nominal mode.
One tag per release, the release's own, and nothing mutable: no latest,
for the same reason the commands above name a version.
docker run --rm -p 127.0.0.1:4599:4599 ghcr.io/stephrobert/feint:v0.13.0
curl http://127.0.0.1:4599/_feint/healthIn a GitHub Actions job — the runner holds the steps until the image's own healthcheck answers, so the first step can talk to it immediately:
services:
feint:
image: ghcr.io/stephrobert/feint:v0.13.0
ports:
- 4599:4599In GitLab CI:
services:
- name: ghcr.io/stephrobert/feint:v0.13.0
alias: feintThen point the client at the port: the same endpoint settings the
conformance suites under tools/conformance/ pass to scw, Terraform,
octl and exo work unchanged against the container.
The image is signed and attested by the same release workflow as the
binaries, under the same identity — and this recipe is executed, not
published on faith: the release workflow runs it against the image it has
just pushed, and tools/release/preflight.sh runs it against the previous
release before a tag exists.
cosign verify ghcr.io/stephrobert/feint:v0.13.0 \
--certificate-identity-regexp '^https://github\.com/stephrobert/feint/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
gh attestation verify oci://ghcr.io/stephrobert/feint:v0.13.0 \
--repo stephrobert/feint \
--signer-workflow stephrobert/feint/.github/workflows/release.ymlThe rest of this page is about the other mode: real machines behind the API, on a real bridge, with the address the API published, and two VPCs that cannot reach each other. That needs Incus and OVN, and how to install them differs enough per distribution to be worth measuring rather than remembering.
Generated from the same constants feint doctor checks against, so a release
cannot leave a recommended version behind on the page. The table is identical to
the README's on purpose: one source, spliced into both.
| What | Version | What it buys you |
|---|---|---|
| Go | 1.26 | Building from source. A released binary needs nothing. |
| Incus | 7.2 recommended, 6.0.4 minimum | --vm: powered-on servers become real containers or KVM machines you can ssh into. |
OVN (ovn-central, ovn-host, Open vSwitch) |
optional | --vm incus-ovn: subnets that are actually separate, so two VPCs cannot reach each other. |
6.0.4 is a floor rather than a preference: below it the runtime refuses ACLs on a
NIC, and the failure reads like a Feint bug rather than a missing feature. Ubuntu
24.04 ships 6.0.0 and will not move past it, so the Zabbly stable channel is the
practical way to a supported version. feint doctor checks all of this against
the same 6.0.4, and says what to install, which is the point of having it.
Every command below was executed on a fresh virtual machine of the release
named, by tools/install/ansible/site.yml. The check is not "the packages
installed", and it is not the health endpoint on its own either.
capabilities.isolation used to follow the mode alone, so a host with no OVN
at all reported true and failed at the first network creation. Since #181 the
capability is verified against the host before it is published: an OVN mode
whose northbound database is absent refuses at startup when it was asked for by
name, and is passed over by --vm auto with its reason printed.
The probe asks the right question in the right order, and getting that wrong
cost an afternoon on this project's own station: an unset
network.ovn.northbound_connection is the default applying, never an absence.
Incus does not store a key at its documented default, so incus config get
answers empty on a perfectly wired host, and the first version of the probe read
that as "no OVN" and refused a station where ovn-nbctl show answered. Setting
the key by hand changes nothing, for the same reason. So the probe resolves the
effective connection string, falling back to the documented default
unix:/run/ovn/ovnnb_db.sock, and asks whether that socket exists — never
whether this process can connect to it, because the socket is root-owned and
incusd is what talks to it. That probe is necessary and not sufficient, and the difference is
this section's whole point: a host whose northbound is wired and whose uplink
is misconfigured still reports true and still cannot create a subnet.
So what the play asserts is a real CreateNet followed by a real CreateSubnet
through the emulated API — the second is the call that needs OVN actually wired,
uplink included — and only then the isolation capability, which is the claim the
whole setup exists for: two subnets of two different VPCs unable to reach each
other.
| Release | Incus from | OVN from | Verdict |
|---|---|---|---|
| Debian 13 | Zabbly | distribution | proven: subnet created on incus-ovn, four clients passed |
| Debian 12 | Zabbly | distribution | installs; the subnet proof predates a driver fix and is being re-run |
| Ubuntu 24.04 LTS | Zabbly | distribution | installs; same |
| Ubuntu 26.04 LTS | Zabbly | distribution | installs; same |
| Fedora 43 | distribution | distribution | installs; same |
| Fedora 44 | distribution | distribution | installs; same |
| Rocky 9 | neelc/incus COPR |
CentOS NFV SIG | installs; same |
| Rocky 10 | neelc/incus COPR (rhel+epel-10) |
CentOS NFV SIG | not measured: the chroot appeared after this table was taken |
Six of those say installs rather than proven, and the distinction is deliberate. They were measured against a build whose OVN driver created its uplink without delegating any route, so the packages went on and the first emulated subnet would have failed. The driver was fixed and Debian 13 re-run from a clean machine; the rest are being redone one at a time. A row here says what was measured, not what is expected.
Rocky 10 moved between two checks of this page. When the table was first
taken, no packaged Incus existed for Enterprise Linux 10; re-checked on
2026-07-30, two of the three findings still hold — EPEL
carries no incus at any version, and Zabbly publishes Debian packages only
(pkgs.zabbly.com/incus/stable/ holds dists/ and pool/ and no RPM at all) —
but the neelc/incus COPR now publishes rhel+epel-10 chroots alongside
epel-9 (COPR API, same date; note the chroot name — the epel-10 repo URL
answers 404 where rhel+epel-10 answers 200). The same unofficial-COPR caveat
as Rocky 9 applies, and no install from it has been measured here yet: the row
says not measured, not works.
Run as root. Every download is fetched to a file and verified before it runs: nothing here is piped into a shell.
Both distributions package Incus, and neither packages what this page was
measured on. Ubuntu 24.04 ships 6.0.0, below the floor in the table above, where
NIC ACLs start being accepted: a security group then attaches and enforces
nothing, which looks exactly like a bug in feint. Debian 13 is not in that case —
trixie ships 6.0.4-2, clearing that floor exactly — so "too old" would be wrong
about it. What no distribution ships is the recommended series, the one every
measurement here and every file-and-line citation in docs/limits.md was taken
on. So the packaging the Incus maintainer publishes is used on all four releases,
which also leaves one procedure to follow instead of one per release.
apt-get update
apt-get install -y --no-install-recommends ca-certificates curl gpg uidmap
# The key in its own keyring, never the system trust store: a key in
# /etc/apt/keyrings signs the repository that names it and nothing else.
mkdir -p /etc/apt/keyrings
curl -fsSL https://pkgs.zabbly.com/key.asc -o /etc/apt/keyrings/zabbly.asc
gpg --show-keys --with-fingerprint /etc/apt/keyrings/zabbly.asc # check what you just trusted
cat > /etc/apt/sources.list.d/zabbly-incus.sources <<EOF
Enabled: yes
Types: deb
URIs: https://pkgs.zabbly.com/incus/stable
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: main
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/zabbly.asc
EOF
apt-get update
apt-get install -y --no-install-recommends \
incus incus-client ovn-central ovn-host openvswitch-switchThe one place this is simple: both are in Fedora's own repositories.
dnf install -y incus incus-agent ovn ovn-central ovn-host openvswitchThree repositories, none enabled by default, and one of them is not official.
# The key before the package it signs. Chicken and egg otherwise: epel-release is
# signed by the EPEL key, and that key arrives *with* epel-release.
rpm --import https://dl.fedoraproject.org/pub/epel/RPM-GPG-KEY-EPEL-9
dnf install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm
dnf config-manager --set-enabled crb
# OVN lives in the CentOS NFV SIG, which versions every package name it
# publishes: there is no `ovn`, there is `ovn24.09`. Read the series off the
# machine rather than pinning it — it moves.
dnf install -y centos-release-nfv-openvswitch
ovn=$(dnf -q list --available 'ovn*-central' | awk '{print $1}' \
| grep -E '^ovn[0-9.]+-central' | sed 's/-central.*//' | sort -V | tail -1)
ovs=$(dnf -q list --available 'openvswitch*' | awk '{print $1}' \
| grep -E '^openvswitch[0-9.]+\.' | sed 's/\.[a-z0-9_]*$//' | sort -V -u | tail -1)
# Incus is not packaged for Enterprise Linux by EPEL or by Zabbly. The Incus
# documentation names one route, and says what it is: an unofficial COPR, "not an
# official project of Incus nor Rocky Linux". You are trusting somebody the Incus
# project does not vouch for; it is worth knowing.
dnf install -y 'dnf-command(copr)'
dnf -y copr enable neelc/incus
dnf install -y incus incus-agent "$ovn" "${ovn}-central" "${ovn}-host" "$ovs"
restorecon -R /var/lib/incus # SELinux stays enforcing; relabel, never setenforce 0
systemctl enable --now incus.socket incus.serviceThis part belongs to OVN rather than to any distribution, and it is the part
nobody guesses. Without both keys, incus network create --type=ovn fails with
an error that names neither.
# Start them, then wait: the units return before the databases listen.
systemctl enable --now openvswitch-switch ovn-central ovn-host # Debian/Ubuntu
# systemctl enable --now openvswitch ovn-northd ovn-controller # Fedora
# systemctl enable --now "$ovs" ovn-northd ovn-controller # Rocky 9
while [ ! -S /run/ovn/ovnnb_db.sock ]; do sleep 1; done
# Open vSwitch needs to know where the southbound database is.
ovs-vsctl set open_vswitch . \
external_ids:ovn-remote=unix:/run/ovn/ovnsb_db.sock \
external_ids:ovn-encap-type=geneve \
external_ids:ovn-encap-ip=127.0.0.1
incus admin init --minimal
# Incus finds the northbound database on its own: network.ovn.northbound_connection
# defaults to unix:/run/ovn/ovnnb_db.sock, which is where ovn-central puts it.
# Setting it explicitly is a no-op — Incus does not store a key at its default —
# so it is left out here rather than shown as a step that appears to do nothing.
# Only a northbound on another host or another path needs the line.
# An OVN network needs an uplink, and the uplink must be a managed bridge
# carrying ipv4.ovn.ranges. `incus admin init --minimal` creates incusbr0
# without it, so an OVN network created afterwards is refused for a reason that
# points at the network rather than at the bridge.
#
# The ranges must sit inside the bridge's own block, and init picks that block
# at random — so the address is pinned first, in a separate command: the ranges
# are validated against the *current* address, and a single command checks the
# new ranges against the old one.
incus network set incusbr0 ipv4.address=10.108.0.1/24
incus network set incusbr0 \
ipv4.dhcp.ranges=10.108.0.10-10.108.0.99 \
ipv4.ovn.ranges=10.108.0.100-10.108.0.199Leave AppArmor alone. Nothing here needs it disabled, and the temptation to
try is real: the OVN project's own CI runs aa-teardown and
systemctl disable --now apparmor.service before its system tests, which is easy
to read as a prerequisite. It is not. That step cites
an Ubuntu AppArmor bug
and works around it for binaries their CI builds from source, outside the paths
any packaged profile covers.
Measured on the machine this page was written on: AppArmor loaded with 180
profiles, four of them Incus's own, and incus, ovn-central, ovn-host and
openvswitch-switch all active and serving the network suite. A packaged install
ships the profiles it needs. If something is denied, the profile is what to fix,
the same position this page takes on SELinux for Rocky: relabel, never
setenforce 0. Turning a mandatory access control system off to make an emulator
work is a trade nobody should make for a test tool.
You do not delegate routes by hand. An OVN network must sit inside its
uplink's ipv4.routes, and Incus says so in terms that name the network rather
than the setting — "Uplink network doesn't contain 10.191.4.0/24 in its
routes". Feint creates its own uplink and adds each block to it as the network
is created, one at a time. Delegating a whole range up front looks tidier and
breaks the host: Incus turns every route into a real route, and 10.0.0.0/8
collides with whatever already lives there — measured on a machine whose bridge
sat at 10.108.0.0/24, where the uplink then refused to come up at all.
feint doctor --vm autoIt reports the port, the runtime it would pick and what that mode can prove, the
Incus version against the 6.0.4 floor, which clients are installed, and the
ProxyJump in ~/.ssh/config that captures the runtime's private range and
kills ssh with "timed out during banner exchange" while sshd is running and
listening.
The verdict that matters:
feint start --vm auto
curl -s localhost:4599/_feint/health | jq .capabilities
# "isolation": true -> two VPCs cannot reach each other
# "isolation": false -> everything else works; subnets are not separatedisolation: false means OVN is installed and not wired, or not installed at all.
docs/limits.md says exactly what the bridge mode can and cannot do:
one assertion of the whole network suite changes verdict with it, and it is that
one.
The table above is not written from memory. Eight virtual machines, declared in Terraform, configured and verified by Ansible whose inventory is read from Terraform's own state — so a release added in one place appears in the other with no second list to keep in step.
cd tools/install/terraform && terraform init && terraform apply
cd ../ansible && ansible-playbook site.ymlThe last task of the playbook is the assertion, not a summary: a release where
Feint builds, starts, and reports isolation: false fails the run.