Skip to content

Latest commit

 

History

History
505 lines (408 loc) · 23.6 KB

File metadata and controls

505 lines (408 loc) · 23.6 KB

Installing

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/feint

With 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 serve

Building 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@latest

That 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.

With Homebrew

brew install stephrobert/feint/feint

For 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.

The container image

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/health

In 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:4599

In GitLab CI:

services:
  - name: ghcr.io/stephrobert/feint:v0.13.0
    alias: feint

Then 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.yml

The 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.

The versions this page is written against

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.

What was actually proven, and where

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.


Prerequisites

Run as root. Every download is fetched to a file and verified before it runs: nothing here is piped into a shell.

Debian 12, Debian 13, Ubuntu 24.04, Ubuntu 26.04

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-switch

Fedora 43, Fedora 44

The one place this is simple: both are in Fedora's own repositories.

dnf install -y incus incus-agent ovn ovn-central ovn-host openvswitch

Rocky 9

Three 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.service

Wiring, which is the same everywhere

This 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.199

Leave 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.

Then check it, rather than believe it

feint doctor --vm auto

It 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 separated

isolation: 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.


Reproducing this page

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.yml

The last task of the playbook is the assertion, not a summary: a release where Feint builds, starts, and reports isolation: false fails the run.