Cette page existe aussi en français
feint is a local emulator for European cloud APIs: it answers in
place of Scaleway, Outscale and Exoscale on 127.0.0.1:4599, and
your official clients cannot tell the difference. One static Go binary, one
port, no account and no bill. This guide shows how to install it while checking
what you run, how to point scw, oapi-cli, exo and Terraform
at it, and where the emulation stops. Audience: developers and platform teams
who test cloud code without paying for an account on every run.
What you will learn
- Install feint while verifying its Sigstore signature and provenance.
- Run the emulator from a container image inside a CI pipeline.
- Diagnose the host before starting, with
feint doctor. - Know when a new version ships, without a forced network call.
- Read what the emulator served, on the page it exposes about itself.
- Measure what a real client expects, with the recording proxy.
- Point the three official CLIs at it without copying a variable by hand.
- Apply a Terraform configuration against the emulator.
- Run real machines carrying the address the API published.
- Open an SSH session on an emulated machine, on all three providers.
- Place the real coverage of each provider before you rely on it.
Why European clouds had no emulator
AWS has LocalStack, Azure has Azurite, and European clouds had nothing. In practice, a team writing Scaleway Terraform or Outscale SDK code tests against a paying account, or does not test at all. The first choice turns every CI run into a line on an invoice and every developer into the owner of forgotten resources; the second lets mistakes reach production.
An emulator answers that precise need: it replays the provider's protocol so the official client, the one you will actually use, works without leaving your machine. This site already covers the AWS equivalent with Floci: same logic, different scope.
feint currently serves three providers on a single port. Their URL spaces do
not overlap, which is what makes that possible: Scaleway serves
/<product>/v<N>/…, Outscale answers POST /api/v1/<Action>, Exoscale exposes
/v2/<resource>. One HTTP multiplexer hosts all three, and the server refuses to
start if two packs claim the same route.
Requirements
- A Linux or macOS machine, x86-64 or ARM64.
cosignandjqto verify the install,ghoptionally.- The official CLIs you want to drive:
scw,oapi-cli,exo, Terraform or OpenTofu. - Incus 6.0.4 minimum (7.2 recommended) for real machines only.
None of that is needed for the main path: the emulator is a static binary with no dependency, it holds its state in memory and needs no daemon.
How to install feint and verify what you run
Download the release binary, verify who published it, then verify the bytes.
The order matters: a checksum proves nothing if you do not know who produced the
list holding it. Release v0.8.0 publishes the binaries, a checksums.txt
file, its Sigstore signature and a SLSA provenance attestation.
-
Fetch the release artefacts.
Fenêtre de terminal base=https://github.com/stephrobert/feint/releases/download/v0.8.0curl -fsSLO "$base/feint-linux-amd64"curl -fsSLO "$base/checksums.txt"curl -fsSLO "$base/checksums.txt.cosign.bundle" -
Verify the signature of the checksum list, before trusting a single line inside it.
Fenêtre de terminal 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.txtThe expected output is two words:
Verified OK. Anything else must stop the installation. Note how precise the identity pattern is: it names the release workflow and the tag prefix, where a broad pattern would accept any workflow in the repository. A compromised repository whose secondary workflow signed a binary would not pass this check. -
Verify the bytes against the signed list.
Fenêtre de terminal sha256sum -c checksums.txt --ignore-missingThe expected result is
feint-linux-amd64: OK. Without--ignore-missing, the command fails on every platform you did not download, which reads like a corrupted binary. -
Install the binary and confirm the version.
Fenêtre de terminal install -m 0755 feint-linux-amd64 ~/.local/bin/feintfeint versionThe output must show
v0.8.0.
How to run feint in a container inside CI
An image is published with every release, and it serves the control plane only. It is the shortest way to wire the emulator into a pipeline: one service, one port, no binary to install on the runner.
docker run --rm -p 127.0.0.1:4599:4599 \ ghcr.io/stephrobert/feint:v0.8.0@sha256:f19c61d60cfbd57e654d530c2c1d06090a4670ad7d26932bb9ff21a06b3f2fc3curl -s http://127.0.0.1:4599/_feint/health | jq -c '{status, machines}'{"status":"ok","machines":"none"}The image runs no machines, and it says so: it runs feint serve --vm off,
because real machines remain a property of the binary on an Incus host. An image
claiming otherwise would be exactly the half-truth this project refuses
elsewhere.
In a pipeline, the image is declared as an ordinary service:
services: feint: image: ghcr.io/stephrobert/feint:v0.8.0@sha256:f19c61d60cfbd57e654d530c2c1d06090a4670ad7d26932bb9ff21a06b3f2fc3 ports: - 4599:4599The runner holds the steps until the image's own healthcheck answers, so the first step can talk to the emulator immediately.
services: - name: ghcr.io/stephrobert/feint:v0.8.0@sha256:f19c61d60cfbd57e654d530c2c1d06090a4670ad7d26932bb9ff21a06b3f2fc3 alias: feintHow to check the host is ready
feint doctor inspects the machine before you waste time on it. It checks
the port, the available machine runtime and what it can prove, the
presence of the official clients, and it reports the known traps of the
environment.
feint doctor 
Reading it is direct: ok validates a point, warn flags something
that does not stop you from working. Two warnings on the capture above deserve
an explanation. The first, no contracts/ directory here, means responses
will not be checked against the providers' API descriptions, which only
happens when you run feint from the cloned repository. The second, the
ProxyJump on 10.*, is a real trap: that range is the one used by the
emulated private networks, and an SSH rule that is too broad makes the
connection fail with timed out during banner exchange while the SSH daemon is
listening normally.
A warning never fails doctor. Only a broken item produces a non-zero exit
code, which makes the command usable as-is in a bootstrap script.
How to start the emulator
feint start detaches the process itself, waits until it answers and prints
where the logs are. No &, no container, no service manager: a static Go
binary can background itself, where an emulator written in Java or Python
delegates that to docker run -d.
feint startfeint listening on 127.0.0.1:4599 (pid 2667884) logs: /run/user/1000/feint/127.0.0.1_4599/feint.logState is read with feint status, which answers what is running, what is
mounted, and what a client has actually driven since startup:
running on 127.0.0.1:4599 (pid 2667884, since 2026-08-10T19:35:43Z) resources 12 machines none
provider routes driven by a client outscale 72 3 scaleway 102 7 exoscale 46 3The driven by a client column is more useful than it looks: it separates a
mounted route from an exercised one, so it tells you what your test
actually went through. When a machine runtime is on, the machines line also
carries what the mode can prove, incus-ovn (isolation: true) for instance.
The commands at a glance
The binary carries twenty-one verbs, half of them for daily use and the other half to measure the emulator itself. The table splits them by that use, so you know at once which ones concern you.
| Command | What it does | Use |
|---|---|---|
feint serve | Serves the three clouds in the foreground | daily |
feint start / stop / restart | Same in the background, and stopping it | daily |
feint wait | Polls until it answers | CI |
feint status | What runs, what is mounted, what a client drove | daily |
feint logs | The detached run's log | daily |
feint ui | Opens the emulator's page, read-only | daily |
feint env <provider> | The environment an official client needs, for eval | daily |
feint doctor | Diagnoses the host: port, runtime, clients, SSH trap | daily |
feint snapshot | save, load, list, rm of the current state | daily |
feint clean | Removes machines, networks and rules the emulator created | daily |
feint version | Prints the version, --check compares with the latest | daily |
feint catalog | Prints the emulated inventory a client reads before creating | inspection |
feint probe | Drives every mounted route from its API description | inspection |
feint coverage | Compares the upstream surface with what the packs serve | measurement |
feint proxy | Sits between a client and a cloud, and records | measurement |
feint transcript | Reads a recording by verbs | measurement |
feint shapes | Records and checks the field tree of a real cloud | measurement |
feint evidence | Writes the coverage artefact CI keeps | project |
feint docs | Regenerates the repository's coverage tables | project |
How to point the Scaleway CLI at it
feint env scaleway produces the environment the official client needs. The
command writes only export lines on standard output, which makes eval safe;
notes and warnings go to standard error.
eval "$(feint env scaleway)"scw instance server create name=web-01 type=DEV1-S image=ubuntu_jammy zone=fr-par-1scw instance server list zone=fr-par-1 
The variables point SCW_API_URL at http://127.0.0.1:4599 and provide
well-formed but fictional credentials. That is enough: SDKs validate the
shape of credentials client-side before sending anything, and the emulator
checks no signature.
The created server shows up as running in the list although the
protocol returns stopped on creation. The explanation is not in the
emulator: scw instance server create sends a power-on action right after
creating. Queried directly, the emulator answers what Scaleway would answer:
{"name":"etat-initial","state":"stopped"}Block storage and the golden image sequence
Since 0.8.0, Scaleway also serves block: volumes, their snapshots and the
volume-type catalogue. Two details of form are worth knowing because they fail
the command before it reaches the emulator.
scw block volume create name=data from-empty.size=10G perf-iops=5000 zone=fr-par-1{"name":"data","type":"sbs","size":10000000000,"class":"sbs","iops":5000,"status":"available"}The size is written 10G, a raw byte count being refused by the CLI where
the instance command accepts one, and perf-iops is mandatory. One
implementation detail matters if you write scripts: scw calls
/block/v1alpha1 where the Terraform provider calls /block/v1. Both
spellings answer, which spares you a choice between two official clients of
the same cloud.
The golden image sequence is served end to end: a snapshot of the volume, an image cut from that snapshot, then deletion in the order the API imposes.
["image-doree","ubuntu_jammy","debian_bookworm","debian_trixie","alpine","ubuntu_noble"]The image you create appears beside the fixed catalogue, and the deletion order is the real cloud's: while the image exists, the snapshot it was cut from refuses to go, with a message naming the culprit.
{"precondition":"resource_still_in_use","resource":"snapshot", "help_message":"the image 7f1db7e3-… is cut from this snapshot"}That is exactly the order Terraform walks when one plan removes both. An image cut here boots nothing, however: the bytes of a disk are the one thing an emulator cannot provide, and the limit is reported at boot rather than at creation.
How to point Outscale and Exoscale at it
The other two providers are wired the same way, with one nuance per client.
feint env reports it on standard error instead of letting you discover it.
The oapi-cli client reads a JSON profile rather than the environment.
Pass it --config with endpoints.api set to http://127.0.0.1:4599,
the client appending /api/v1 itself.
eval "$(feint env outscale)"oapi-cli CreateNet --IpRange 10.0.0.0/16oapi-cli ReadNets{ "Nets":[ { "IpRange":"10.0.0.0/16", "NetId":"vpc-3a31b260", "State":"available", "Tags":[], "Tenancy":"default" } ], "ResponseContext":{"RequestId":"3b25f637-a288-4839-b4aa-c3b1a709f6bb"}}The exo CLI takes its endpoint from its configuration file, where
endpoint must be http://127.0.0.1:4599/v2. Note the /v2 suffix, which
belongs to the address with this provider.
eval "$(feint env exoscale)"exo compute instance-type list| ID | FAMILY | SIZE ||--------------------------------------|----------|-------|| 21624abb-764e-4def-81d7-9fc54b5957fb | standard | tiny || b6cd1ff5-3a2f-4e9d-a4d1-8988c1191fe8 | standard | small |Two instance types only: that is the emulator's fixed catalogue, not Exoscale's inventory. The section on limits covers that point.
Quotas answer too, and they are more interesting than they look: the
limit is an invented value, like the catalogue, but usage is counted
from the internal store. exo limits on a fresh emulator reports zero
instances, and one more after each create.
exo limits| RESOURCE | USED | MAX ||----------------------------|------|-----|| Compute instances | 0 | 100 || Compute instance snapshots | 0 | 100 || Compute instance templates | 0 | 100 |Applying a Terraform configuration
Terraform and OpenTofu apply against the emulator on Scaleway and Outscale,
and the Exoscale provider is deliberately refused to avoid an apply straddling a
billed account. Scaleway is redirected with api_url, Outscale with an api
block whose path carries /api/v1, and the full cycle passes, empty second plan
included.
Seeing what the emulator actually served
The binary serves its own page, at http://127.0.0.1:4599/_feint/ui, opened
by feint ui. It shows side by side what is mounted, what has been
probed and what a real client has driven, without ever adding them up,
plus the session inventory and a live call log.
Getting real machines behind the API
With an Incus runtime, a server created through the API becomes a real
machine carrying the published address, enforcing security groups and
accepting an SSH session. Four modes exist and do not deliver the same thing:
only --vm incus-ovn isolates two VPCs, at the cost of a private address the
host can no longer reach.
How to know a new version is out
feint version --check asks GitHub whether a newer release exists, and prints
the command to install it. Two principles govern this command, and both are
worth knowing before you run it: nothing goes over the network until the flag
is typed, and the binary never updates itself.
feint version --checkThree answers are possible depending on what you run. When a newer version is published, the output gives the install command pinned to that exact version:
a newer release is available: v0.8.0
base=https://github.com/stephrobert/feint/releases/download/v0.8.0 curl -fsSLO "$base/feint-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/')" curl -fsSLO "$base/checksums.txt" sha256sum -c checksums.txt --ignore-missing
release notes: https://github.com/stephrobert/feint/releases/tag/v0.8.0The suggested command names the version, never latest. The reason is the
one that governs the install procedure above: a mutable reference downloads
whatever is newest at call time, and that content can no longer be checked
against the checksum written just below it.
If you are already up to date, the command restates your version first, then concludes in one line:
v0.8.0this is the latest releaseThe third case covers locally built binaries:
the latest release is v0.8.0 (this build reports "dev", which is not a released version) https://github.com/stephrobert/feint/releases/tag/v0.8.0A binary compiled from source carries dev, and a go install build carries
a timestamped pseudo-version. Neither compares to a version tag, so the tool
reports the latest release without claiming your copy is stale.
What feint does not do
Coverage is partial, and it is measured rather than claimed. In v0.8.0, 220 routes are mounted in total. The table shows the maturity the project declares and how each provider splits the surface its API publishes:
| Provider | Routes mounted | Served | Declined | Untriaged | Maturity | Proven by, in CI |
|---|---|---|---|---|---|---|
| Scaleway | 102 | 32 % | 67 % | 0 % | usable | Terraform, OpenTofu, scw |
| Outscale | 72 | 27 % | 65 % | 6 % | starter | Terraform, OpenTofu, oapi-cli |
| Exoscale | 46 | 12 % | 67 % | 20 % | starter | exo |
The three percentage columns are read together, and the third is the one that counts. Declined is a written decision: an operation nobody intends to emulate, with its reason in the code. Untriaged is what nobody has ruled on yet, and Scaleway is at zero there. An operation appearing in that column fails continuous integration until a human decides.
Denominators differ between providers, 315 operations published on the Scaleway side against 263 for Outscale and 374 for Exoscale: comparing percentages across columns of one row makes sense, comparing them across rows much less.
Five limits are worth knowing before you rely on the tool. All of them can be checked in a few commands.
Identifiers are validated against nothing. A create naming a non-existent image succeeds, where the real cloud would refuse:
scw instance server create name=ghost type=DEV1-S \ image=00000000-dead-beef-0000-000000000000 zone=fr-par-1This is deliberate, because the emulator has no inventory: refusing unknown identifiers would break any configuration referencing a production image. The cost is real: a typo in an image identifier passes here and is caught in production.
Boot refuses, though. With a machine runtime, an identifier no catalogue
knows no longer boots a substitute image: the server stays stopped and the
log names the offending identifier.
level=ERROR msg="refusing to boot: the image identifier resolves to nothing thisemulator can run" provider=scaleway image=00000000-dead-beef-0000-000000000000reason="the identifier is in no catalogue"The distinction is the one that matters for a test: creation stays allowed so a real configuration is not broken, but the emulator refuses to pass off as Ubuntu an image you did not ask for.
The catalogue is fiction. Server types, prices, images and zones are a small fixed table. It exists because clients read it before creating anything, but any capacity, price or availability answer is decoration.
Authentication is accepted, never verified. No signature is checked, on any provider:
curl -s -o /dev/null -w '%{http_code}\n' \ "http://127.0.0.1:4599/instance/v1/zones/fr-par-1/servers"The answer is 200, without a single token. feint must therefore never
be exposed on a network you do not control: it is a development tool that
grants everything to everyone, by design.
The tool does more than listen on 127.0.0.1 by default: feint serve
refuses a non-loopback address unless --expose-to-network is passed
explicitly. The reason is mechanical rather than moral: off loopback, the guard
against local DNS rebinding can no longer tell what is local, so it stops
protecting anything. With a machine runtime on, what becomes reachable from the
network is a container engine.
Lifecycle transitions are immediate. A server goes from stopped to
running during the call. The states clients check are preserved, though:
deleting a running server is refused with {"type":"transient_state"}, because
Terraform relies on that error.
Object storage is not emulated. The Scaleway Terraform provider builds the
S3 address in its own code, so redirecting it would need DNS interception and a
TLS certificate the provider accepts. The CLI and SDKs honour SCW_S3_ENDPOINT
and can already point at a local MinIO.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
404 Not Found on scw instance server-type list | The CLI queries /product-catalog/v2alpha1/, a recent API the emulator does not mount | Use scw marketplace local-image list, or read curl -s localhost:4599/_feint/routes |
Feint does not serve /marketplace/v2/images | The requested route is not in the pack; the message says so | Check the route in /_feint/routes first |
feint: no contract under contracts on feint probe | probe needs the API descriptions shipped with the repository | Run from the cloned repository, or pass --contracts <dir> |
warn no contracts/ directory here in doctor | Same cause, as a warning | Harmless unless you validate responses against the contracts |
gh attestation verify prints nothing | Outside an interactive terminal it speaks only through its exit code | Check echo $? or add --format json |
timed out during banner exchange over SSH | A ProxyJump in ~/.ssh/config captures the emulated 10.* range | Narrow the Host pattern, or use ssh -F /dev/null |
Multiple variable sources detected with Terraform | A Scaleway profile already exists on the machine | Read the Currently using column: the provider block wins |
A --vm mode does not separate two VPCs | Bridge mode provides no isolation | Read capabilities.isolation in /_feint/health, and switch to --vm incus-ovn |
the Exoscale Terraform provider only honours… as a 400 | That provider splits its calls between the emulator and the real cloud | Use the exo CLI, or accept the split with FEINT_EXOSCALE_ALLOW_TERRAFORM=1 |
| Terraform Outscale retries for six minutes then times out | The api block's endpoint lacks the /api/v1 segment | Write http://127.0.0.1:4599/api/v1, version included |
feint serve refuses the address | A non-loopback address is refused by default | Stay on 127.0.0.1, or pass --expose-to-network knowingly |
REMOTE HOST IDENTIFICATION HAS CHANGED over SSH | Addresses are reallocated from the start of the block on every lab | Add -o UserKnownHostsFile=/dev/null, or purge the entry with ssh-keygen -R <address> |
| A private address does not answer from the host | The OVN router translates connections coming from the host | Read capabilities.private_from_host, or go through the public address |
The /_feint/ui page answers 404 | It is mounted on the loopback interface only | Open it from the machine running the emulator |
Key points
- feint emulates Scaleway, Outscale and Exoscale on a single port,
127.0.0.1:4599, with no account and no bill. - A signed container image wires the emulator into CI in three lines, control plane only.
- A static Go binary that backgrounds itself: no Docker, no daemon, no external dependency.
- Official CLIs work as they are:
feint env <provider>produces the expected environment,evalis enough. - Terraform drives Scaleway and Outscale end to end,
apply, empty second plan anddestroyincluded. - The Exoscale Terraform provider is refused with a 400: it would split the apply between the emulator and a billed account.
feint uiserves a page from the binary: served against driven against probed, never added up, read-only and on loopback.feint proxyandfeint transcriptmeasure what a real client calls and the exact shape of the responses, credentials redacted.- The machine mode is real: the container carries the address the API published, and
feint cleanremoves only what the emulator created. - An SSH session opens on an emulated machine at all three providers, on the address the API publishes.
- Only
--vm incus-ovnisolates two VPCs, at the cost of a private address the host can no longer reach; askcapabilitiesrather than assume. feint version --checkreports a new version and gives the pinned command;FEINT_NO_UPDATE_CHECK=1stops every network call.- Coverage is partial and owned: 220 routes mounted, 32 % of the Scaleway surface served, 27 % for Outscale, 12 % for Exoscale.
- The untriaged surface is measured: 0 % awaiting a decision for Scaleway, 6 % for Outscale, 20 % for Exoscale.
- No signature is verified, and
feint serverefuses a non-loopback address without--expose-to-network.