Commit Graph

7 Commits (2277dfe4cb7edcbeec2fc74ade23e1dbae544e34)

Author SHA1 Message Date
nexxo d76de5ada3 Restart a machine, enforce the quota that was sold, end a route that ended
Three places where the product said one thing and did another.

**Nothing could restart a virtual machine.** ProxmoxClient had startVm and no
way to stop anything, so `restart_required_since` — set by every plan change
that grows a running guest — could only ever be cleared by a resize step that
happened to find the machine stopped, which nothing in the product could
arrange. A paid upgrade's cores and RAM could stay unreached for the life of the
contract, and the cloud page's "Neu starten" was a toast.

There is now a `restart` pipeline: shutdown, start, wait for the agent, confirm.
A shutdown and a start rather than a reboot, because only a cold boot makes qemu
read the VM definition again — a reset would take the machine round and bring it
back exactly as small as it was. The shutdown is a REQUEST, bounded at ten
minutes, and there is no escalation: ProxmoxClient deliberately does not expose
Proxmox's forceStop, because the guest is a Nextcloud and cutting power to a
database mid-write to apply a CPU change trades an inconvenience for a restore
from backup. A guest that ignores ACPI for ten minutes fails the run instead, and
a person decides what happens to it.

`restart_required_since` is cleared by the last step, from what the RUNNING guest
reports (`cpus`/`maxmem`) — not by the button, and not by the run merely
finishing. A machine that comes back smaller than it was sold fails the run.

Started by App\Actions\RestartInstance, which re-checks authorisation itself
rather than trusting a hidden button: an operator holding the new
`instances.restart` may restart anything, a portal user their own machine and
nothing else. Refused while another run is in flight against the same order.
Confirmed in a modal on both sides (R23), addressed by uuid (R11).

**Existing instances had no storage quota.** ApplyStorageQuota reaches new and
changed machines; everything built before it kept the whole disk whatever it had
paid for, and nothing recorded which was which. `instances.quota_applied_gb` is
now what the guest was actually told, written by the step only after the occ call
succeeds — so "sold" and "enforced" are separate facts and can be compared.
`clupilot:apply-quotas` sweeps the estate: one `quota` run per instance through
the same step the pipelines use, skipping what it should and saying why, a no-op
once an allowance is enforced, and inert under --dry-run. Deliberately NOT
scheduled — a nightly sweep would quietly cover for the pipeline step the day it
stopped working, which is how the original hole survived this long.

**A Traefik router was never torn down.** TraefikWriter::remove() had no caller
anywhere. What was missing was the moment: ConfirmCancelPackage wrote a date into
`service_ends_at` and nothing ever went back to it, so every route this platform
ever wrote was written for good — pointing at a guest address the host is free to
reassign. `clupilot:end-due-services` (hourly) keeps that appointment through
App\Actions\EndInstanceService. "Ended" means cancellation_scheduled AND
`service_ends_at` passed, never the day a cancellation is scheduled: that
customer has paid to the end of the term and is working in it. The DNS record
goes with the route — it is in our zone, pointing at a host that serves other
people, which is the shape of every subdomain takeover there has ever been. The
customer's own domain is in their zone and is not ours to touch; the virtual
machine is left alone, because the cancellation flow promises a data export first
and destroying disks is not this change's decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 18:28:28 +02:00
nexxo 602602864a Let a subscription or add-on be opened for free, with who and why on the row
A grant is an order without money, not a second code path: GrantSubscription
and GrantAddon create the same Order/Subscription/SubscriptionAddon rows a
paid purchase does, with stripe_subscription_id left null and price_cents set
to whatever the customer actually pays. OpenSubscription and BookAddon gained
an optional overrides parameter so the price and provenance (who granted it,
when, a note, an optional end date, and the catalogue price for legibility)
land on the same snapshot every real purchase goes through, not a duplicate.

New customers.grant_plan capability, Owner-only like secrets.manage — giving
away service is exactly the kind of blast radius that precedent reserves for
the Owner.
2026-07-29 12:41:06 +02:00
nexxo 495d314aa5 Preflight every customer conflict before this migration mutates anything
tests / pest (push) Failing after 7m24s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
tests / pest (pull_request) Failing after 7m32s Details
tests / assets (pull_request) Successful in 20s Details
tests / release (pull_request) Has been skipped Details
2026-07-28 16:07:11 +02:00
nexxo 4ae5281c91 Carry operators into the RBAC move via direct permissions too, not only roles
operatorUserIds() only discovered console users through model_has_roles. A
pre-existing account granted console.view straight through
model_has_permissions, holding no role at all, was invisible to the
worklist — meanwhile the permission's own guard is switched to `operator`
a few lines above, so that account would silently lose console access
while staying behind as a working portal login. Worklist is now the
deduplicated, sorted union of both tables; orderBy('model_id') kept on
each side since carryAcross() remaps in place and the ordering still
matters regardless of which table an id came from.
2026-07-28 14:20:49 +02:00
nexxo 54fb6deb03 Make the RBAC move retry-safe and order-safe, and round-trip permissions
Three faults in the same migration:

- up()'s removal loop was driven by operatorUserIds() (model_has_roles
  WHERE model_type=User) via the $carried array — but carryAcross()
  repoints model_has_roles to Operator for every operator before a
  single users row is deleted. A run that stops partway (the
  customer-conflict abort, or a dropped connection on real MariaDB,
  which this file deliberately runs without a transaction) leaves that
  worklist empty on the next attempt, so `migrate` records the
  migration as applied while every leftover users row still grants a
  live portal login on the operator's own password. Now driven by
  `operators` joined to `users` on email: that finds every
  carried-but-not-yet-deleted row regardless of which run carried it.

- operatorUserIds() had no ORDER BY. carryAcross() remaps four owned
  columns in place, keyed on the old users.id, while operators.id hands
  out new values in the same order the list is walked — unordered, a
  later operator's freshly assigned id can equal an earlier operator's
  still-unprocessed users.id, and the second remap catches a row the
  first one already rewrote. One operator ends up silently owning
  another operator's VPN peer. Added ->orderBy('model_id').

- down() restored model_has_roles but not model_has_permissions, so an
  operator holding a direct (not role-granted) permission lost it on
  rollback. Mirrored the same move carryAcross() does forward.

Also removes seed_roles_and_permissions' now-stale
User::OPERATOR_ROLES/hasAnyRole()/assignRole() bootstrap loop — all
three gone from User per this branch's own work — which only a deep
rollback (down() of the move migration recreates is_admin users) would
ever have reached, but would fatal when it did.
2026-07-28 13:31:34 +02:00
nexxo f54d00b3e7 Move the value remap and constraint swap before the delete, and fix the seeder 2026-07-28 11:00:09 +02:00
nexxo 87c49de6e5 Move the console's identity out of the customer table 2026-07-28 10:31:43 +02:00