Skip to content

feat: images from cofoundry, permissions and roles, and server migration - #168

Open
ericwang401 wants to merge 34 commits into
mainfrom
integration/v5-workstreams
Open

ericwang401 wants to merge 34 commits into
mainfrom
integration/v5-workstreams

Conversation

@ericwang401

Copy link
Copy Markdown
Collaborator

Four workstreams, built in parallel and integrated here. Needs
ConvoyPanel/anchor#1 for the migration transport.

Images

Cofoundry replaced vzdump archives with disk images, and the panel had no way to
consume them.

  • Catalogue import. convoy.registry.url defaults to the published
    registry. A browsable catalogue with one-click import, idempotent by content:
    identity is the set of disk sha256s, so re-importing an unchanged entry adds
    nothing and a rebuild lands as a new version on the same image.
  • Chunked upload. The old upload took the whole file in one request, which
    breaks on Cloudflare's body limit and PHP's own ceilings. Now open → PUT
    chunk at Upload-Offset → finalize, resumable, with the server's offset
    authoritative and a sweeper for abandoned uploads. Hand-rolled; tus-php,
    laravel-chunk-upload and the tus clients were each evaluated and rejected in
    the commit message.
  • TPM. The catalogue says a Windows guest needs a TPM by carrying
    tpm: v2.0 and shipping no tpmstate0 disk, because the state volume must be
    unique per guest. The import dropped the key, so windows-server-2022 and 2025
    imported without a TPM, which Server 2025 requires to boot. Now allocated.
  • Build numbers. Versions were derived from the build date, so two builds of
    one recipe in a day collided and the collision was resolved by naming the
    wrong day. built_at is stored as itself and the number is the panel's own
    count of what it holds.

Permissions, sub-users and roles

  • 21 per-server permissions, each derived from a real endpoint rather than from
    the issue's wishlist.
  • Guests are a distinct users.type, separated everywhere an admin looks, and
    gated behind one global switch that also signs existing guests out.
  • Blanket root admin is replaced by named roles. users.root_admin is dropped
    behind an accessor, with a migration that leaves existing root admins as
    Superadmin and nobody locked out.
  • Fixes drift found on the way: TokenAbilities::RESOURCES still listed the
    retired template-groups and omitted 11 live admin resources, all of which
    fell through to *.

Migration and VM import

Design notes in docs/ipam-migration-research.md and
docs/migration-anchor-contract.md.

  • Two transports, chosen automatically. Same cluster uses qm migrate;
    anything else goes over Anchor as an offline export/restore. qm remote-migrate is deliberately not used: Proxmox marks it EXPERIMENTAL feature! in its own source and documents it nowhere.
  • The source guest is never destroyed until the destination is verified.
    Every earlier failure rolls back by restarting a guest that was never touched.
  • Adoption of existing guests, derived from what the poll already observes,
    with the address reconciliation cases the research doc enumerates.
  • Fixes a confirmed bug where ServerPlacementService::rehome() matched a
    destination bridge by name alone and cleared the flag, leaving a server with
    addresses unroutable from its new node.

UI verification

A frame-by-frame harness (.sbx/dev/uiverify) that records what the compositor
actually painted, because a single still cannot show a sub-second glitch. Used
to check every admin route at desktop and phone width, in both themes.

Verification

1145 tests, PHPStan, Pint, typecheck and build all clean.

Driven end to end against two live Proxmox 9.2.2 nodes: a real guest adopted off
one node and migrated three times, both directions, each run leaving it present
on the destination, gone from the source, and no artifact behind.

Four bugs were found only by that live run, each of which every unit test had
passed over: an auth mismatch between the two repos, a discard URL the agent
does not serve, a failed cleanup aborting an already-successful migration, and a
destroyGuest that always failed silently because Proxmox rejects a DELETE
carrying a body. That last one is why a failed migration left the same server on
two nodes.

…hunk at a time

Two sources for an image the panel did not build itself.

A catalogue is a URL, not a concept. The panel reads the registry JSON,
shows what it lists, and copies an entry into an ordinary image definition
the operator then owns. Nothing subscribes, nothing syncs, and no registry
entity exists: what an import leaves behind is the slug it came from, which
is enough to recognise the same entry later and to say whether it has been
rebuilt since.

Identity is the set of disk hashes, not the version number, because the
catalogue publishes no version at all. So re-importing an unchanged entry
adds nothing, and a rebuild lands as a new version on the same definition
rather than a second definition beside it. The number itself comes from the
build date (2026.9.4), which orders correctly through the existing integer
triple.

Uploads are opened, appended to, and finished. One request was the wrong
shape for a ten-gigabyte file: Cloudflare rejects a body over 100 MB on most
plans, PHP has its own ceilings, and a connection that dropped at 90% cost
the whole transfer. The server's offset is authoritative, a short write is
rolled back rather than recorded, the assembled file is hashed streaming,
and the response the version form consumes is unchanged.

Disk `options` now survive both paths. `discard`/`ssd` and the varstore's
`efitype`/`pre-enrolled-keys`/`ms-cert` describe the image that was built;
the create call previously hardcoded `efitype=4m` and dropped the rest,
which silently changed TRIM behaviour and Secure Boot state.
…nd resume

The catalogue reads as the images list it feeds, because that is what an entry
becomes: a row says what it costs to transfer and what it provisions, and
importing writes an ordinary group, definition and version. A row the panel
already holds says so, and one that has been rebuilt offers the update.

Uploading gets the controls a multi-gigabyte transfer actually needs. The
server's offset is authoritative, so Resume asks where the file got to and
carries on from there rather than starting over, and Cancel gives the disk
back instead of leaving the bytes for the sweep.

`apiFetch` grew headers, an abort signal and upload progress, which is what
the chunk endpoint needs and what the old uploader was reaching around it to
get with a hand-rolled axios call.
`ServerPlacementService::rehome()` repointed `network_interface_id` at the
bridge with the same name on the destination node and, finding one, cleared
`flagged_at`. Name is all PVE needs; it is not what makes an address routable.
Convoy records that in `address_block_group_to_network_interface`, and `vmbr0`
exists on nearly every node, so the common case matched a bridge fronting a
different pool, resolved the flag, and left the server holding addresses its
new node cannot reach. `ServerNetworkService::syncSettings()` only refuses when
the server is flagged, so the next sync would write one of those addresses into
`ipconfig0` and PVE would re-bake it into a fresh cloud-init drive on the
destination.

The invariant existed only as validation closures in `UpdateAddressRequest` and
`StoreServerRequest`, both creation-time and both driven by operator input, so
nothing re-ran it on a placement change. It is now
`AddressReachabilityService`: both requests call it, and so does `rehome()`,
which flags naming the stranded pools instead of silently accepting the match.

The service also carries `blocksReachableFrom()` and `interfacesCarrying()`,
which migration preflight and adoption need next.
`$x?->y ?? $z` is redundant: the null-coalescing operator already suppresses
a property read on null, so the nullsafe operator only hides that from the
analyser. The rejected-value check in `diskOptions` was testing for a null
the declared type says cannot arrive; `blank()` says what it meant.
…guest

Two features with the same hard part: an address is reachable from some nodes
and not others, and neither a placement change nor a guest arriving from
outside the panel has ever touched the address rows.

Migration. The address stays bound to its block and the server rebinds to a
node, which is Neutron's port/binding split; nothing gains a `node_id`. The
disposition is computed rather than asserted by the operator, because Convoy
already records which pools a bridge fronts and an operator ticking a
"preserve IP" box is asserting something they cannot see:

  - same-named bridge attached to every pool the addresses live in: preserved,
    and a running guest moves online.
  - same-named bridge not attached to them: refused. PVE treats same-named
    bridges as one network and Convoy does not, and the likeliest explanation
    is an incomplete topology model. Attaching the pool is one click; an
    unroutable address is an outage.
  - no bridge of that name: the networks really are different, so the addresses
    go back to their pool and the server gets new ones from the destination.

`GET /nodes/{node}/qemu/{vmid}/migrate` supplies PVE's own verdict, so storage
availability, passed-through devices and HA affinity are not re-derived. The
reallocated addresses are shown before the operator commits, computed by
running the allocator inside a transaction that is rolled back, and the change
needs an explicit acknowledgement. Writes are ordered the Neutron way: the
destination binding is reserved (`state_reason = migration`) before the task is
issued, the source is not released until PVE reports the guest landed, and a
failure frees the reservation.

Adoption. `/cluster/resources` minus the servers table by (cluster, vmid) is
the list, so nobody hand-types a node and a vmid the poll already sees. The
nine reconciliation cases for an address found on `ipconfig0` are implemented
against blocks scoped to what the guest's node can actually route. Adoption
never writes to the guest: no smbios stamp, no ipconfig rewrite, no NIC
rewrite, no disk resize, which is why `should_create_vm: false` is not reused.
An address it cannot resolve leaves the server flagged rather than rejecting
the whole guest, and `servers.ipconfig_managed` stops a later network sync from
overwriting an address the panel does not own.

Out of scope, deliberately: remote_migrate, SDN, DHCP/SLAAC leases, MTU, bulk
and HA migration, LXC, in-guest re-IP, auto-created blocks. Each is refused
with a reason at the point of use.
`ValidatePostSize` rejects any request whose `Content-Length` exceeds
`post_max_size`, PUT included, and a stock php.ini sets that to 8M. A panel
on default settings would have failed every upload with a 413 that names
neither the limit nor the setting. The advertised chunk is now the
configured size or what PHP will actually take, whichever is smaller.
The vocabulary listed `template-groups`, which lost its routes when templates
became images, and omitted eleven resources that do have routes: version,
audit-logs, clusters, storages, server-presets, backups, images, image-groups,
isos, relays and settings.

`ScopedTokenAbilities::requiredFor()` demands `*` for an unrecognised path
segment, so each omission made that resource unreachable by any scoped token
rather than merely unscopable -- a caller could not grant access to it at all.
A coverage test now asserts the vocabulary and the route table agree in both
directions.
Migrate sits in the server's PageToolbar and opens a destination picker whose
rows carry the verdict rather than making the operator work it out: keeps its
addresses, gets new addresses, or unavailable with the reason from Proxmox or
from Convoy's own pool-to-bridge model. Picking a node that reallocates lists
the exact addresses the server releases and the ones it gets, and the Migrate
button stays disabled until the operator ticks that the guest's IP changes.
Nothing is asserted by the operator that the panel can compute. The preview
costs a second preflight call, so it is only fetched for a reallocation. The
dialog also states, once, that it is cluster members only.

Unmanaged Guests lists every QEMU guest on a registered node that no server
owns, with a per-row Adopt. A guest that cannot be adopted shows the reason in
place of the button rather than a disabled control with the reason hidden in a
tooltip. A node that could not be asked gets its own line, because an empty
list and an unanswered question read the same and mean opposite things.

Adopting shows everything read off the guest before the operator commits: its
resources, bridge and storage, and one row per address saying what the panel
would do with it and why. The NICs past net0 are named as left alone, since a
second interface silently ignored is worse than a second interface named. The
only thing the operator supplies is the owner.

Also carries the research doc the two features were designed from, which several
docblocks cite by path.
…oles

Three connected models, all shaped `<resource>.<action>` and all derived from
the routes that exist rather than from a wishlist.

Per-server sub-users. `server_subusers` holds a json list of
`ServerPermission` values; `ServerPolicy`'s `__call` stops being a no-op and
answers from that grant, with an unmapped ability denying so a new endpoint is
closed rather than open. `Server::scopeOwnedBy` gains the share, which its own
docblock nominated as the one place to change. `BackupPolicy` is deleted: every
backup request calls `can(..., $server)`, so `ServerPolicy` was already the only
policy consulted, and its three ambiguous abilities are renamed to
`createBackup`/`restoreBackup`/`deleteBackup`. Sharing itself is owner-only --
a permission to grant permissions would be a permission to grant every one.

Guest accounts. `users.type` separates accounts the operator provisioned from
ones that exist only because a customer shared a server: filterable, sortable,
counted apart on the overview, and on every payload. A guest cannot own a
server, be signed in to over SSO, or hold an admin role -- the last enforced by
a CHECK constraint, not only by the request layer. `PermissionSettings
::$allow_guest_accounts` is a kill switch in both directions: off means no guest
is created and no existing guest signs in, with their shares kept so switching
it back on restores what was there. Unknown invitees reuse `UserInviteService`;
no second invite system.

Admin roles. `admin_roles` plus `users.admin_role_id` replace `users.root_admin`,
which is dropped; a `root_admin` accessor keeps `p:make-user`, the seeders and the
Application API's own field meaning the Superadmin role. `AdminPermissions`
derives the required permission from the request the way `ScopedTokenAbilities`
derives a token ability, and both gates are enforced, so a token can never widen
what its owner may do. `servers.power` and `users.impersonate` are carved out of
their resources because support staff and billing staff are not the people who
should hold `manage`. Four roles ship; a fifth is a duplicate-and-edit away.

Existing root admins migrate onto Superadmin, so nobody is locked out and no
operator has to do anything at upgrade time.
A poll landing between the PVE task finishing and the rebind job running sees
the guest on the destination, re-homes the row itself, and — for a reallocating
migration — flags it, because the destination bridge legitimately does not carry
a pool the server has not given up yet. That flag is stale the moment it is
written and would block the network sync the rebind is about to run, so the
rebind clears it along with the bindings it knows are correct.

Migration is also refused outright while a server is flagged. Every Proxmox URL
a migration builds comes from `node_id`, and a flag is the panel saying it is
not sure that column is true.

And the adoptable-guest list no longer warns about a cluster whose first member
was unreachable but whose second answered. The failure is recorded against the
scope and dropped when any member of it answers: saying the list may be
incomplete when it is not is how a warning gets ignored.
A single still cannot show a sub-second glitch, so this records the frames
the compositor actually produced (CDP screencast, which emits only on visual
change) and lays them out as a contact sheet composed in-browser -- no ffmpeg.

Also lands the IPAM migration/import research that briefs the migration work,
and documents the traps that produce false findings: page.goto() paints a real
white frame, the collapsed sidebar has a hover flyout, "Add node" is a link to
a page rather than a dialog, theme entries are menuitemradio, and dnd-kit needs
priming past its activation constraint.
Client. A Sharing tab on a server the account owns: who it is shared with, what
each of them can do, and a dialog of boxed permission checkboxes grouped the way
the server's own sidebar is. An invitee with no account comes back with the link,
shown rather than toasted, because that link is the whole handover on an install
with no SMTP. Every other tab is now gated on the permission behind it, so a
sub-user sees the parts of the server they can actually use; the nav reads
`permissions` off the server payload, which the owner and an operator receive in
full.

Admin. A Roles screen, where a built-in role is duplicated and edited rather than
rewritten -- that is what keeps one-role-per-account from costing anything when
somebody needs Support plus Network. The user form swaps its administrator
checkbox for a role select and an account type; the list gains a Guest badge, a
type column and a type filter, and the detail page says which role an account
holds instead of "Administrator" or "User". The admin sidebar, the account menu,
the command palette and the admin route guard all read `adminPermissions` now, so
a narrower role lands somewhere it can use rather than being bounced to the
client area.

Settings. A Sharing section with the guest-account switch, whose description
carries what turning it off costs and how many accounts that is.

The application-token ability picker picks up the eleven resources the vocabulary
was missing.
ddev exports CACHE_STORE, QUEUE_CONNECTION and SESSION_DRIVER as real container
environment variables, which Laravel's Env reads from $_SERVER before PHPUnit's
<env> entries -- the same precedence problem tests/bootstrap.php already fixes
for DB_DATABASE. The suite was therefore sharing one Redis with the development
app, and `admin:overview`'s fifteen-second cache outlived the test that wrote it:
the overview count assertions passed or failed depending on how recently the
suite had last run.

Also stops ServerSubuserData costing a query per row for `isPending`.
…laries

The adoption routes and the permission/ability catalogues were written on
separate branches, so the new routes reached integration in neither. Both
coverage tests caught it, which is what they are for.

Adopting an unmanaged guest is how a server comes into existence without a
build, so it folds into `servers` rather than becoming a surface of its own.
The Role column fills, and under table-layout: auto its width: 100% beats a
width declared on any other column, so Grants computed to 94px and wrapped a
short summary over seven lines. Every row was 145-165px tall.

Refusing to wrap makes this column's min-content width the thing the fill
column yields to: Grants is now 355px and rows are a uniform 63px. Declaring
w-[22rem] does not work here and was tried first.
… screens

Covers what a happy-path click would not show: the chunked upload is driven
with a deliberately short chunk so the server's offset is what the client
resumes from, and a stale offset is asserted to be refused rather than
silently appended.
The catalogue says a guest needs a TPM by carrying `tpm: v2.0` in its hardware
and shipping no `tpmstate0` disk: the image is generalized, and one shipped
state volume would give every guest built from it the same endorsement key. So
the consumer allocates it. Both of cofoundry's own installers do exactly that
(`src/registry/create.ts` marks `tpm` synthesized and emits
`--tpmstate0 <storage>:0,version=`; `cf-cluster-templates.sh` the same).

The panel was the odd one out: the import dropped the key, so
windows-server-2022 and windows-server-2025 imported without a TPM. Server 2025
requires one to boot, and the failure lands at first power-on rather than at
import.

`tpm` now survives into the overlay as a synthesized key, is stripped before
the create payload (Proxmox has no `tpm` parameter and would 400), and becomes
a freshly allocated tpmstate0. Validation accepts only v1.2/v2.0, since the
schema this rule normally consults has no `tpm` key to check against.
The catalogue numbers nothing, so the import derived a version from the build
date: `2026.9.4`. Two builds of one recipe in a day then collided on a column
that is unique per definition, and the collision was resolved by incrementing
the patch. That produced `2026.9.5` for a build made on the 4th, and took the
number a genuine build on the 5th would want, so the drift compounded.

The identity was already published. `built_at` is second-resolution and unique
per build; truncating it to a date is what manufactured the collision. So it is
stored as itself, and the version goes back to being what it is for a catalogue
image: which build the panel holds, counting up.

Deliberately NOT fixed by adding a `build` field to the catalogue. That would
need durable state its generator does not have (the registry is assembled by
scanning per-build sidecars), would break on a fork or a clean republish, and
would bump `schema_version`, forcing the panel, coport and every other consumer
to land together -- all to replace an identifier already published for free.

The build number is taken from the highest build currently held, so deleting
the newest frees its number. That is safe rather than overlooked: a deployment
records its version by foreign key and never by label, and a version cannot be
deleted while a deployment points at it.

Existing registry rows are renumbered in the order they already sort in.
`built_at` is left null for them rather than guessed back out of the old label,
since the collided ones encode the wrong day; a re-import repairs it, because
an unchanged build is matched by content and would otherwise never create a row
to carry it.

The catalogue and the versions list now show the real build date, so the number
is never the only thing on screen.
`qm migrate` is intra-cluster only and clustering standing nodes is not
available to us: a node holding guests cannot join, joining replaces
/etc/pve, and a two-member cluster drops to read-only whenever its peer is
down. `qm remote-migrate` needs no cluster but Proxmox marks it
`EXPERIMENTAL feature!` and documents it nowhere. So a destination outside
the source's cluster takes a second transport: `vzdump` on the source, a
direct node-to-node download, `qmrestore` on the destination, all driven
over Anchor. It is deliberately offline; the guest is down for the whole
transfer and nothing here pretends otherwise.

The transport is derived, not chosen. The operator picks a destination and
the panel decides how the guest gets there, exactly as it already decides
what happens to the addresses. The address disposition logic is reused
unchanged -- the only thing skipped for an Anchor destination is PVE's
`allowed_nodes`, which is the source cluster's opinion of its own members
and would block every node outside it.

The ordering is the whole design: stop, export, install, verify, discard,
destroy the source, rebind, start. The source guest is not destroyed until
the destination guest is verified present with the disks it left with, so
every failure before that point is undone by starting a guest nobody
touched. AnchorMigrationRollbackService does exactly that, plus freeing the
destination reservation and removing whatever a half-finished restore left
behind -- which is what stops a retry finding the VMID taken and ending up
with two guests.

Two deltas from docs/migration-anchor-contract.md, both deliberate:

- `Discard` runs before the destroy rather than after it. A step that runs
  once the source is gone has no recovery, and the artifact is redundant
  the moment the destination is verified, so moving it one step earlier
  keeps the point of no return in exactly one place.
- An artifact that has expired or been swept by an Anchor restart is a
  re-export, not a failure. The source guest is still there, so the answer
  is another dump; it is capped at two so a node that loses every artifact
  stops the migration rather than exporting forever.

Both nodes must be enrolled and must advertise `migration.export` /
`migration.install`; an enrolled but older agent is refused by name rather
than accepted, because the install pipeline finishes by running
`qm template` on what it restored and an agent that does not know it is
servicing a migration would turn the guest into a template.
… fails

Almost all of these assert an order rather than a set. "The source was
destroyed" is not the property that matters; "the source was destroyed
after the destination was verified" is, so the suite reads Http::recorded()
back as a labelled sequence and asserts on that.

Nine injected failures -- export fails, export names no artifact, hash
mismatch, install fails, cancellation on either side, no guest at the
destination VMID, wrong disk size, discard refused -- share one set of
assertions, because they all have to leave the same three things true: the
source guest still on the source node and running if it was running, the
destination reservation back in the pool, and no destroy of the source.
The discard case is the reason the rollback boundary is
`source_destroyed_at` and not `verified_at`: it gets past the verification
and is still recoverable, because the source is still there to go back to.

Runs the real chain on the sync queue rather than asserting on a list of
jobs a test assembled itself, which is how the "dispatch inside the
transaction" bug this fixes was found -- an inline failure was rolling back
the rows the failure exists to leave behind. The one place that would prove
less than driving the job directly is the poll loop, because the sync queue
makes release() a no-op, so the phase-polling test drives ExportGuestJob a
call at a time.

The two plan tests that encoded the cluster-only world are updated rather
than deleted: a standalone node now gets an Anchor candidate instead of an
empty list, and "no other member" is now "no other node".
A destination outside the cluster means the guest is stopped while its
whole disk is copied to another node, and that is not something to learn
from a progress bar once it is running. The consequence goes on the control
it belongs to, with the figure the panel actually has: "Stopped and copied
across, up to 32 GiB", beside the "Moves without stopping" a cluster
destination already showed.

Drops the note claiming cluster members are the only destinations, which
stopped being true one commit ago.
The agent's implementation diverged in ways a consumer has to know about: the
enum is TemplateAction not Command, download auth reuses TemplateClaims with a
new Fetch action rather than a separate token type, export status lives on its
own route, there is a new `dumping` job status, multi-range returns 200 rather
than 206, and an artifact is cleared on agent restart as well as by TTL -- so
"artifact gone" has to be a recoverable re-export.
Anchor's install pipeline exists to import images, and Convoy offers a guest
only when it reports `template: 1`, so a restore ends with `qm template`
unless told otherwise. A migration restores the tenant's own machine, so the
default would hand them back something that cannot be started, after a
migration that reported success.

The agent defaults `finalize_as_template` to true so every existing import
caller stays correct, which is exactly why the panel has to send false
explicitly. The agent advertises `migration.install` only when it honours the
flag, so an agent too old to understand it is refused a guest rather than
quietly templating one.

The guard test reads the bearer token's `template` claim, because that is
where the work order travels; asserting on the request body passes vacuously.
Exercises the panel's own HTTP API with a real session, so nothing in the path
is mocked: discover the node's storages, adopt a running guest off Proxmox, read
the migration plan, and commit it.
The agent serves GET and DELETE on one route, /artifacts/{artifact}. Discard
was addressed to /api/v1/templates/artifacts/{artifact}, which exists nowhere,
so the agent's router answered 404 -- and a 404 from discard reads exactly like
an artifact already swept by its TTL, so the failure looked like a race rather
than a wrong URL.

The tests pinned the invented path too, which is why every one of them passed
while a live migration lost its cleanup step. They now match on the real route
and on the method, so the download fake cannot answer the DELETE.
A live run addressed discard to a route the agent does not serve; the agent
answered 404 and a migration whose destination had already been verified was
marked failed, leaving the guest present on both nodes for an operator to
reconcile by hand.

Cleanup is surfaced but no longer fatal. The archive carries a TTL and is swept
whether or not anyone asks, so carrying on costs at worst a temporary file that
outlives its use by a day; aborting costs a duplicate guest and a manual
reconciliation.
…behind

Proxmox rejects a DELETE carrying a body with "Unexpected content for method
'DELETE'". `destroyGuest` sent `purge` as one, so the call always failed --
and the rollback swallows its failures as warnings, so nothing surfaced. A
failed migration therefore left the guest on the destination as well as the
source, stranding the same server on two nodes.

Found by running a rollback against the live API with a real guest in place:
the guest survived and no qmdestroy task was ever issued. Every other DELETE in
the Proxmox namespace sends no body at all; this was the one that did.

The test drives the client directly, because this path only runs during a
rollback -- the happy path's DELETE is the source destroy, a different method
that takes no purge, and asserting through a migration matches that one
instead.
`MailConfigurator::isConfigured()` consults MailSettings, not the mail config.
With `mail.default` on smtp, `managedElsewhere()` is false and the stored host
decides -- so setting `mail.mailers.smtp.host` left it empty and no invitation
was sent.

The test passed anyway wherever the database already had mail configured, and
failed on CI's fresh one. It now sets the setting it depends on, the same way it
already sets the permission setting beside it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant