Aller au contenu
Cloud medium

feint: emulate Scaleway, Outscale and Exoscale locally

2 min de lecture

Cette page existe aussi en français

feint logo

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.
  • cosign and jq to verify the install, gh optionally.
  • 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.

  1. Fetch the release artefacts.

    Fenêtre de terminal
    base=https://github.com/stephrobert/feint/releases/download/v0.8.0
    curl -fsSLO "$base/feint-linux-amd64"
    curl -fsSLO "$base/checksums.txt"
    curl -fsSLO "$base/checksums.txt.cosign.bundle"
  2. 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.txt

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

  3. Verify the bytes against the signed list.

    Fenêtre de terminal
    sha256sum -c checksums.txt --ignore-missing

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

  4. Install the binary and confirm the version.

    Fenêtre de terminal
    install -m 0755 feint-linux-amd64 ~/.local/bin/feint
    feint version

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

Fenêtre de terminal
docker run --rm -p 127.0.0.1:4599:4599 \
ghcr.io/stephrobert/feint:v0.8.0@sha256:f19c61d60cfbd57e654d530c2c1d06090a4670ad7d26932bb9ff21a06b3f2fc3
curl -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:4599

The runner holds the steps until the image's own healthcheck answers, so the first step can talk to the emulator immediately.

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

Fenêtre de terminal
feint doctor

feint doctor output: free port, Incus runtime detected, official clients found, warnings about contracts and ProxyJump

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.

Fenêtre de terminal
feint start
feint listening on 127.0.0.1:4599 (pid 2667884)
logs: /run/user/1000/feint/127.0.0.1_4599/feint.log

State 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 3

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

CommandWhat it doesUse
feint serveServes the three clouds in the foregrounddaily
feint start / stop / restartSame in the background, and stopping itdaily
feint waitPolls until it answersCI
feint statusWhat runs, what is mounted, what a client drovedaily
feint logsThe detached run's logdaily
feint uiOpens the emulator's page, read-onlydaily
feint env <provider>The environment an official client needs, for evaldaily
feint doctorDiagnoses the host: port, runtime, clients, SSH trapdaily
feint snapshotsave, load, list, rm of the current statedaily
feint cleanRemoves machines, networks and rules the emulator createddaily
feint versionPrints the version, --check compares with the latestdaily
feint catalogPrints the emulated inventory a client reads before creatinginspection
feint probeDrives every mounted route from its API descriptioninspection
feint coverageCompares the upstream surface with what the packs servemeasurement
feint proxySits between a client and a cloud, and recordsmeasurement
feint transcriptReads a recording by verbsmeasurement
feint shapesRecords and checks the field tree of a real cloudmeasurement
feint evidenceWrites the coverage artefact CI keepsproject
feint docsRegenerates the repository's coverage tablesproject

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.

Fenêtre de terminal
eval "$(feint env scaleway)"
scw instance server create name=web-01 type=DEV1-S image=ubuntu_jammy zone=fr-par-1
scw instance server list zone=fr-par-1

The official Scaleway CLI driving the emulator: creating then listing a DEV1-S server

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.

Fenêtre de terminal
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.

Fenêtre de terminal
eval "$(feint env outscale)"
oapi-cli CreateNet --IpRange 10.0.0.0/16
oapi-cli ReadNets
{
"Nets":[
{
"IpRange":"10.0.0.0/16",
"NetId":"vpc-3a31b260",
"State":"available",
"Tags":[],
"Tenancy":"default"
}
],
"ResponseContext":{"RequestId":"3b25f637-a288-4839-b4aa-c3b1a709f6bb"}
}

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.

Fenêtre de terminal
feint version --check

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

The 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.0
this is the latest release

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

A 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:

ProviderRoutes mountedServedDeclinedUntriagedMaturityProven by, in CI
Scaleway10232 %67 %0 %usableTerraform, OpenTofu, scw
Outscale7227 %65 %6 %starterTerraform, OpenTofu, oapi-cli
Exoscale4612 %67 %20 %starterexo

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:

Fenêtre de terminal
scw instance server create name=ghost type=DEV1-S \
image=00000000-dead-beef-0000-000000000000 zone=fr-par-1

This 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 this
emulator can run" provider=scaleway image=00000000-dead-beef-0000-000000000000
reason="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:

Fenêtre de terminal
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

SymptomCauseFix
404 Not Found on scw instance server-type listThe CLI queries /product-catalog/v2alpha1/, a recent API the emulator does not mountUse scw marketplace local-image list, or read curl -s localhost:4599/_feint/routes
Feint does not serve /marketplace/v2/imagesThe requested route is not in the pack; the message says soCheck the route in /_feint/routes first
feint: no contract under contracts on feint probeprobe needs the API descriptions shipped with the repositoryRun from the cloned repository, or pass --contracts <dir>
warn no contracts/ directory here in doctorSame cause, as a warningHarmless unless you validate responses against the contracts
gh attestation verify prints nothingOutside an interactive terminal it speaks only through its exit codeCheck echo $? or add --format json
timed out during banner exchange over SSHA ProxyJump in ~/.ssh/config captures the emulated 10.* rangeNarrow the Host pattern, or use ssh -F /dev/null
Multiple variable sources detected with TerraformA Scaleway profile already exists on the machineRead the Currently using column: the provider block wins
A --vm mode does not separate two VPCsBridge mode provides no isolationRead capabilities.isolation in /_feint/health, and switch to --vm incus-ovn
the Exoscale Terraform provider only honours… as a 400That provider splits its calls between the emulator and the real cloudUse the exo CLI, or accept the split with FEINT_EXOSCALE_ALLOW_TERRAFORM=1
Terraform Outscale retries for six minutes then times outThe api block's endpoint lacks the /api/v1 segmentWrite http://127.0.0.1:4599/api/v1, version included
feint serve refuses the addressA non-loopback address is refused by defaultStay on 127.0.0.1, or pass --expose-to-network knowingly
REMOTE HOST IDENTIFICATION HAS CHANGED over SSHAddresses are reallocated from the start of the block on every labAdd -o UserKnownHostsFile=/dev/null, or purge the entry with ssh-keygen -R <address>
A private address does not answer from the hostThe OVN router translates connections coming from the hostRead capabilities.private_from_host, or go through the public address
The /_feint/ui page answers 404It is mounted on the loopback interface onlyOpen 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, eval is enough.
  • Terraform drives Scaleway and Outscale end to end, apply, empty second plan and destroy included.
  • The Exoscale Terraform provider is refused with a 400: it would split the apply between the emulator and a billed account.
  • feint ui serves a page from the binary: served against driven against probed, never added up, read-only and on loopback.
  • feint proxy and feint transcript measure 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 clean removes 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-ovn isolates two VPCs, at the cost of a private address the host can no longer reach; ask capabilities rather than assume.
  • feint version --check reports a new version and gives the pinned command; FEINT_NO_UPDATE_CHECK=1 stops 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 serve refuses a non-loopback address without --expose-to-network.

Next steps