caravan: spec moving a server off the firewall being deleted
Clearing `inbound_tcp` plans `Delete_firewall` as its only consequence,
but the server was created with that security group and a provider does
not delete a group a server still holds: Scaleway answers the group
delete with a precondition failure, the invariant uniboot's
`delete_group` already retries against. The cleared declaration
therefore never converges, since every pass plans the same refused
delete. Nothing in the suite saw it, because the in-memory provider
ignores the firewall handed to `add_instance` and the observed model
records no association between a server and its group, so a held
firewall and a free one look alike.
Give the observation that association (`Instance.firewall`) and spec
what a cleared declaration owes when the target's own instance holds the
firewall: the instance is moved onto the provider's project default
(`Detach_firewall`) and only then is the firewall deleted. The order is
the whole of it -- the same two steps the other way round are the same
two requests and the same refusal -- so the test compares the plan
verbatim rather than its membership. The unheld case is pinned beside
it: no instance reporting the firewall, or one reporting another, keeps
today's bare delete.
The end-to-end leg drives the cleared spec through the Scaleway backend
against a project double that enforces the hold, refusing the group
delete while the server points at it and reading the group the server
update asks to move onto. What proves the cycle closed is the project
itself: no managed group left, and the server on the project default
rather than pointing at something deleted. Serving that needs the fake
to answer whole requests rather than targets alone, because a read and a
delete of one path are the same target and different requests, so
`Scaleway_fake.with_api` is what `with_server` now defers to.
Three failures, and all three are the spec: the planner plans no
reassignment, the Scaleway mapping drops the group the server record
names, and the cycle stops at the refused delete. The engine arm for the
new action fails hard rather than reporting a success it did not
perform, since a firewall the instance still holds would then be deleted
by the step that follows.
caravan: the secret store is a module of its own
Half of caravan_scaleway.ml was a second reconciler. The backend implements
Caravan.PROVIDER over instances, volumes, firewalls and addresses; the secret
store manages IAM applications, policies and keys and Secret Manager secrets,
none of which is a resource Caravan.Observed.t can hold or PROVIDER has an
operation for. It has its own spec, observation, conflicts, plan and apply, and
shares with the provider only the client the calls go through. Two reconcilers
in one file read as one module with two vocabularies, and the provider contract
was five hundred lines further down than the file said.
Secret_store is now its own module, aliased into Caravan_scaleway, so
Caravan_scaleway.Secret_store.v and the rest are what they were for every
caller. The two shapes the modules have to agree on are Caravan_scaleway_intf's:
the backend record they call through, and SECRET_STORE, the store's API with
that backend abstract, which the facade ascribes its own t to. That is what lets
both modules see the representation whole while the published type stays
abstract.
Apply comes apart with the module. As one nested function it was a hundred
lines; it is now one function per provider mutation, dispatched by perform,
over a record of what the apply has created so far rather than three refs. The
tests follow the split: the thirteen secret-store cases are now
test_secret_store.ml, verbatim, beside the ten the resource mapping and the
project double own.
Behaviour is untouched: same plans, same refusals, same apply, same 23 cases.
caravan: a tag knows its own namespace
The planner matched an instance's provision tags with String.starts_with
on <namespace>:provision:, which claims every tag of every namespace that
begins like this one, while the digest behind them was read from the last
marker. An instance under caravan carrying
caravan:provision:a:provision:b -- a state of somebody else's namespace --
was read as this target's own, and the operator repairing the drift was
pointed at a tag that is not this target's to remove.
Tag is the one place the grammar is written and read now, so the whole
path takes the last marker: check_instance selects the states of the
spec's own namespace, sorted and deduplicated as before, and
Tags.record_provision supersedes the family of the tag it writes rather
than one named beside it -- a prefix and a tag that can disagree is a
caller stripping one family and writing another -- which is what lets
PROVIDER.record_provision drop its ~prefix argument.
The tags a spec derives are tags: Spec.identity_tag and
Spec.provision_tag, the actions carrying one (Stage_release,
Activate_release, Record_provision, Record_identity), the state a
provision conflict wants, and every provider verb that writes one. What a
provider stores is text, so a backend spells its tag out at its own
boundary, and what it reports back stays a string: an observed state, a
foreign tag and a stale claim are read as the provider holds them.
A spec pins a state of its own namespace, and one of another namespace is
refused where it is written, because the planner reads only this
namespace's states off an instance: a tag of another would want a state no
instance can ever be observed in. That retires the spool's marked-tag
case, whose target pinned caravan:provision:a:provision:<digest> under
caravan; the digest reading it was about is pinned next door, on the tags
a device reports.
Release loses what was only ever the tag grammar: provision_tag_prefix,
digest_of_provision_tag, and is_digest, which the spool used for three
store-key and outbox-name checks of its own and now spells there as a
sha256 hex round trip.
caravan: declarative provisioning that converges a provider to a spec
The InfraKit-shaped companion to uniboot and uplink: uniboot builds
and publishes the immutable image, caravan makes the provider
substrate that runs it exist and match a declared target, uplink moves
signed updates onto machines already running. The planner is a port of
the SpaceOS provisioner's substrate planner, made provider-neutral and
relicensed ISC by its copyright holder; the spec tests come with it.
The planner is pure -- spec and observation in, ordered actions or a
conflict out -- and three rules shape every plan. The data volume is
never destroyed: drift that only destruction can close is a conflict,
and resolving one is an explicit act (apply --force runs only the
non-destructive resolutions: detaching a stale holder, adopting a
pre-caravan resource by tagging it). Compute is disposable: a changed
provision input replaces the instance, preserving its volume and
address. Routine image updates are out of scope: an instance that
updated itself over the air keeps its provision tag and is left alone.
There is no state file. Every resource caravan creates carries the
identity tag namespace:name, and resolution requires name and tag
together, so a same-named resource caravan did not create -- a stale
leftover or a plant aimed at becoming the node's persistent state --
is a Foreign_resource conflict instead of a silent adoption. The
provision input rides on the instance as a second tag, the way
InfraKit recorded its config SHA.
Backends implement one PROVIDER contract; Caravan.Make executes plans
over any of them, resolving the ids of resources created earlier in
the same apply. caravan.scaleway is the first backend, and the CLI
(plan, apply, status) prices billable steps from the provider's
server-type catalog and totals them. The engine tests run over an
in-memory provider and require convergence: after an apply, the next
plan is empty.
caravan: the secret store is a module of its own
Half of caravan_scaleway.ml was a second reconciler. The backend implements
Caravan.PROVIDER over instances, volumes, firewalls and addresses; the secret
store manages IAM applications, policies and keys and Secret Manager secrets,
none of which is a resource Caravan.Observed.t can hold or PROVIDER has an
operation for. It has its own spec, observation, conflicts, plan and apply, and
shares with the provider only the client the calls go through. Two reconcilers
in one file read as one module with two vocabularies, and the provider contract
was five hundred lines further down than the file said.
Secret_store is now its own module, aliased into Caravan_scaleway, so
Caravan_scaleway.Secret_store.v and the rest are what they were for every
caller. The two shapes the modules have to agree on are Caravan_scaleway_intf's:
the backend record they call through, and SECRET_STORE, the store's API with
that backend abstract, which the facade ascribes its own t to. That is what lets
both modules see the representation whole while the published type stays
abstract.
Apply comes apart with the module. As one nested function it was a hundred
lines; it is now one function per provider mutation, dispatched by perform,
over a record of what the apply has created so far rather than three refs. The
tests follow the split: the thirteen secret-store cases are now
test_secret_store.ml, verbatim, beside the ten the resource mapping and the
project double own.
Behaviour is untouched: same plans, same refusals, same apply, same 23 cases.