The Scaleway dynamic inventory
stephrobert.scaleway.compute builds an Ansible inventory from a Scaleway
account. It discovers machines product by product, then links them to their
private networks and their VPCs.
Everything this document claims is measured: the unit tests live in
tests/unit/inventory/,
and the scenario played on every mise run integration is
tests/integration/feint/inventaire.scaleway.yml.
The configuration file
Ansible recognises an inventory plugin by the file name. It must end in
scaleway.yml or scw.yml, otherwise it is silently ignored:
ansible-inventory -i production.scaleway.yml --graph
The minimum fits on one line, and the usual Scaleway profile decides the rest:
plugin: stephrobert.scaleway.compute
Credentials
Credentials are read from the environment, from the Scaleway configuration file, or from the inventory file, in that order:
export SCW_ACCESS_KEY=... SCW_SECRET_KEY=...
SCW_API_URL is honoured: pointing it at a local emulator builds an inventory
with no account and no spend. That is how this plugin is actually tested.
Products
Three hosting products are discovered:
|
what gets listed |
|---|---|
|
Instances |
|
Elastic Metal servers |
|
Apple Silicon machines |
all, the default, means those three, not every existing Scaleway API. An
unknown name is refused rather than ignored: a misspelled product would produce
a silently partial inventory.
plugin: stephrobert.scaleway.compute
products:
- instance
- elastic_metal
Zones and regions
Without zones, each product queries the ten zones it declares. A zone where
the product does not exist removes itself, because the API says so.
regions only serves the network index: it is derived from the zones, and only
needs writing to widen it.
zones:
- fr-par-1
- fr-par-2
Machine names
hostnames gives the sources of inventory_hostname, in order:
hostnames:
- tag:role # reads a "role=web" tag
- name
- id
Accepted are name, id, public_ipv4, public_ipv6, private_ipv4,
private_ipv6 and tag:KEY. The names hostname, vpc_ipv4 and vpc_ipv6
used by the official collection are still understood, so an existing
configuration keeps working.
Collisions do not lose a machine. A Scaleway name is unique neither within
a zone nor within a project: two machines can be called web01. The second
then receives web01_fr-par-1, and the third web01_<identifier>. Every
disambiguation is reported by a warning, and the result is the same from one
run to the next.
Where Ansible reaches the machine
ansible_host is chosen in the order of the requested families:
address_priority:
- private_ipv4
- public_ipv4
- private_ipv6
- public_ipv6
That is the default order: a controller managing a Scaleway fleet is most often inside the VPC, and a private address does not depend on a public IP being allocated. A workstation outside the VPC simply reverses the list.
A machine on several private networks is reached through the network you name, not through whichever comes first:
address:
private_network: backend # a name, or an identifier
scaleway_address_source carries, on each machine, the family retained or the
reason for the failure. -vvvv gives the full line, along the lines of
web01: 10.10.0.5 via private_ipv4, network backend.
A machine with no address stays in the inventory. It has no SSH route, but
it remains drivable through the API with delegate_to: localhost, which is the
common Day-2 case. require_address: true asks for the opposite.
Groups
group_by chooses the axes of the scw_* groups:
group_by:
- product # scw_product_instance
- project # scw_project_<identifier>
- region # scw_region_fr_par
- zone # scw_zone_fr_par_1
- state # scw_state_running
- tags # scw_tag_env_prod
- vpc # scw_vpc_socle
- private_network # scw_private_network_backend
Names are sanitised for Ansible: production/web becomes production_web,
pré-prod becomes pre_prod, and 2024 becomes _2024 rather than _024,
which is what Ansible would make of it on its own.
The product is an axis, not an injected tag: scaleway_tags carries what
Scaleway carries, and nothing else.
compose, groups and keyed_groups work, because they are called and not
merely documented:
compose:
ansible_user: scaleway_apple_silicon.ssh_username | default('root')
keyed_groups:
- prefix: type
key: scaleway_instance.commercial_type
Filtering
organizations:
- 99999999-9999-4999-8999-999999999999
tags:
- env=production
tags_match: all # "any" by default; the API can only express "any"
states:
- running
exclude:
tags:
- canary
states:
- stopping
What can be filtered API-side is filtered API-side, so as not to transfer what
will be thrown away. The rest is decided locally, and -vvv says why each
excluded machine was excluded.
organizations is passed to the API when exactly one is requested, never in
a loop: several organisations would multiply the calls by the zones, the states
and the projects. Local filtering remains the guarantee in every case, and it is
not decorative: an API ignoring that parameter would return the whole fleet, and
the inventory would silently be wider than asked.
Cache
cache: true
cache_plugin: jsonfile
cache_connection: ~/.ansible/tmp/scaleway
The cache key covers everything that changes the result: the profile, the API URL, the products, the zones, the projects, the filters, the name sources, the address policy and the group axes. Two different configurations never share an inventory, even from the same directory.
Strict mode, and a trap that is not ours
strict: true, the default, fails the inventory when a provider fails, instead
of returning an incomplete fleet. Failures are classified:
what happens |
what the plugin makes of it |
|---|---|
credentials refused |
fatal, immediately, without trying the other zones |
missing permission on a product |
named error; a token may hold only part of the fleet |
product absent from a zone |
warning, because that is not an outage |
any other API failure |
named error, with the product and the zone |
But Ansible downgrades that failure to a warning. Measured: against an
unreachable endpoint, the plugin raises with a message naming the cause, and
ansible-inventory still exits 0 with an empty inventory. An inventory
source that cannot be parsed is not an error for Ansible by default. For a
failure to stop a CI run, you have to ask:
export ANSIBLE_INVENTORY_ANY_UNPARSED_IS_FAILED=True
or, in ansible.cfg:
[inventory]
any_unparsed_is_failed = True
Without that, strict: true writes the reason and blocks nothing. This check is
played both ways on every mise run integration.
The variables set on each machine
variable |
contents |
|---|---|
|
the identifier, the one the collection’s modules expect |
|
|
|
the Scaleway name, before disambiguation |
|
the location |
|
the state as the API returns it |
|
the scope |
|
the Scaleway tags, and nothing else |
|
every address, not the first |
|
the same, across all networks |
|
per network: identifier, name, VPC, addresses, MAC |
|
why this |
|
what belongs to that product alone |
|
the raw response, only with |
scaleway_<product> files what is specific to each API under its own prefix,
instead of pouring it into the global namespace: commercial_type for
Instance, offer_name for Elastic Metal, ssh_username for Apple Silicon.
Chaining onto the collection’s modules
That is what the inventory is for, and the three identity variables are enough, with no extra lookup:
- name: Shut down the pre-production machines
hosts: scw_tag_env_preprod
gather_facts: false
tasks:
- name: Power off
stephrobert.scaleway.instance_server_action:
zone: "{{ scaleway_zone }}"
server_id: "{{ scaleway_id }}"
action: poweroff
wait: true
delegate_to: localhost
Diagnosing
ansible-inventory -i production.scaleway.yml --graph
ansible-inventory -i production.scaleway.yml --list -vvv # the report
ansible-inventory -i production.scaleway.yml --list -vvvv # and every address
The report gives the number of API calls, the number of IPAM addresses indexed, the number of machines per product, then every warning and every exclusion.
One reading detail: --list serialises strings coming from a plugin as
{"__ansible_unsafe": "..."}. That is Ansible’s marking, not an anomaly;
--graph and playbooks see the string.
How many API calls
Private networks are listed once per region, then joined in memory. The official collection queries IPAM once per network card, that is two thousand calls for a thousand two-card Instances, and gets less out of it: it keeps only one private address per machine, without saying which network it belongs to.
Here the cost of the join is linear in network cards, and that is proven by counting index lookups, not with a stopwatch.
The index itself is only built if a requested product carries private network
cards. products: [apple_silicon] therefore no longer pays for the twelve calls
it would have made nothing of. The filtering deliberately stops there: cutting
it as well when address_priority names only public families would silently
empty scaleway_private_ipv4 and scaleway_private_networks, which a compose
may depend on. Not calling and returning nothing are not the same thing.
A missing permission on IPAM or VPC is a warning, not an error: a token without that permission builds a perfectly good inventory of public machines, and failing the whole inventory in strict mode over an enrichment nobody needed would be a misplaced refusal.
What this plugin does not do yet
managed resources (Kubernetes, Load Balancers, databases) are not discovered; only machines are;
discovery is sequential: no parallelism between products yet;
Elastic Metal and Apple Silicon are proven by tests with simulated responses, for lack of an emulator serving them (feint#631, feint#632). Instance is proven end to end against a real emulator, and against a real account.