7 Commits (ecfced9257f573e08274b59aa033513134a123a9)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
5b63fdb86c |
Deliver the storage a customer actually buys
A booked storage pack was a row in `subscription_addons` and nothing else: priced, frozen, charged every month, and delivered nowhere. Nextcloud's quota came from `instances.quota_gb`, which is the package alone; the disk was sized for the package alone; and on the one occasion a disk did grow — a plan upgrade — the guest never saw it, because nothing stretched the partition or the filesystem over the new space. One authority for the allowance. StorageAllowance adds the package and the booked packs, and everything that needs the figure asks it: the quota step, the downgrade check, the portal, the console, the repair command. It is DERIVED rather than stored — a column would have to be rewritten by booking, cancelling, granting, a grant expiring and a plan change, and the day one of those paths forgot it would be silently wrong in whichever direction costs somebody money. `quota_applied_gb` keeps its own meaning: what the guest was last actually told, which is how a machine with the allowance enforced is told from one where the figure has only ever been a row in our database. Buying it delivers it. BookAddon asks ApplyStorageAllowance for a run on booking AND on cancellation; the new `storage` pipeline grows the virtual disk to the allowance plus the package's own overhead (read off the package — 20/40/50/100 GB on the four catalogue packages — never a ratio invented here), then GrowGuestFilesystem makes the guest see it, then the quota is applied. No cold boot is involved: the data disk is scsi0 and Proxmox's resize on a running guest is a qemu block_resize, so the capacity reaches the guest while it runs. The new step rescans, growparts and grows the filesystem with the tool its type actually needs — ext2/3/4, xfs, btrfs — and fails loudly on one it does not know rather than handing it to resize2fs and hoping. Idempotent end to end: NOCHANGE from growpart is not a failure, and every tool here exits 0 when there is nothing left to grow. The same step now runs in the plan-change pipeline, where an upgrade used to stop one step short of the customer. A downgrade blocked by data gets a way out. The block stays — it is correct — but the check now measures against the target package PLUS the packs the customer already owns, and reports the numbers behind the refusal: what is stored, what would be allowed, how much has to go, and how many packs would cover it instead. The portal offers both routes: book exactly those packs (confirmed in a modal, R23), or delete data and have the fill level measured on demand rather than waiting for the nightly sampler. That reading is taken by DiskUsageProbe, which CollectInstanceTraffic now uses too, so there is one notion of "how full is it" and not two. A plan change keeps booked packs. They were paid for separately and have nothing to do with which package the customer is on; the new package's disk and quota are sized with them included. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
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> |
|
|
|
34874adec3 |
Apply a bought plan change instead of only pricing it
tests / pest (push) Failing after 8m6s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
PlanChange could say what a move would cost and whether it was allowed, and that was all it could do. Billing::purchase() wrote an upgrade order and nothing ever consumed it: same snapshot, same machine, same quota. A customer could pay for a bigger package and receive nothing. ApplyPlanChange is now the single place a change lands — it moves the contract onto the target's current version, writes one register row, settles the custom domain, and starts a run that resizes the machine. Applying the same order twice is a no-op, enforced by a unique event key rather than by a check two callers could both pass. Two things a plan change must not do, and now does not. A disk is never shrunk — Proxmox cannot, so the QUOTA shrinks instead, which is what was sold anyway. And a live machine is never rebooted as a side effect: cores and RAM are written, and where they need a restart the instance says so where an operator and the customer can both see it. The storage allowance also joins the build pipeline. ApplyStorageQuota was written for plan changes and ran only there, so a brand-new customer still got no quota at all — quota_gb reached the instance row and stopped, and every package delivered the whole disk. The test guarding that pipeline only counted its steps, which is how a list missing the one step that makes a package's storage real stayed green for its whole life. It now names the step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
49a3ca2e33 |
Keep the shop window off the portal's front door
tests / pest (push) Failing after 7m35s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
The landing page was registered host-agnostically, so app.clupilot.com served it: marketing copy, a pricing table and a "sign up" call to action, at the address where a customer's servers live. Reported exactly that way. SITE_HOST binds the website to its own hostname. Every other host — the portal, a bare IP — answers "/" with the product: the dashboard for somebody signed in, the sign-in page for everybody else. Never a 404 there: "/" is what people type from memory, and turning that into an error page to make a point about hostnames helps nobody. Empty keeps today's behaviour, which every development machine reached by IP depends on. The console is unaffected — its routes are registered first and win on their own host, the ordering the status page above already documents. The legal pages are deliberately not bound. An imprint has to be reachable from wherever the reader is standing, and a mail footer links to it from whichever host sent the mail. Also drops "just reply to this message" from two mails. A reply lands in the billing or provisioning mailbox rather than in the support queue somebody actually works through — so it now says to raise it in the portal, where it gets a place in that queue instead of getting lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
b1ab0f7f31 |
Put every mail in one design, and confirm an order when the money arrives
tests / pest (push) Failing after 7m48s
Details
tests / assets (push) Successful in 25s
Details
tests / release (push) Has been skipped
Details
Four templates, one layout. Two of them — the maintenance announcement and its cancellation — still wore Laravel's markdown component, which brings its own logo, its own button and its own footer: the product was sending mail that looked like two different companies, one of which was the framework. New: an order confirmation, sent when the payment lands rather than when provisioning finishes. Those are minutes apart at best and can be much longer, and somebody who has just been charged and heard nothing assumes the worst about both the charge and the product. It deliberately promises no time for the cloud itself — provisioning takes what it takes, CloudReady announces the end, and a promised minute that slips is worse than no promise. Queued after the transaction, never inside it, and never allowed to fail the webhook: Stripe retries anything that is not a 2xx, and a retry would re-enter provisioning over a mail server having a bad minute. The finished-cloud mail now names what was actually built — address, plan, storage, location — because somebody who ordered a fortnight ago is checking it against what they bought, and that is a comparison rather than a sentence. Still no credential in it. Two tests earn their place. One holds every template on the shared layout, so a new one written in a hurry cannot copy-paste its way back to the framework's. The other proves the finished-cloud mail carries no password — asserted against the data the mail is given and the HTML it renders, not against the template's source, because the first version of that test tripped over the word in a comment and would have passed a template that leaked the value through a variable. It tested the prose, not the mail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
75b4a47768 |
Stop mailing the initial admin password, hold it in the panel until noted
The password no longer travels through third-party mail servers or sits forever in an inbox: it moves onto the instance (encrypted, same as nc_admin_ref) and shows on the dashboard until the customer clicks "Notiert", which deletes it for good. |
|
|
|
3e19778046 |
chore(engine-b): provisioning step/mail i18n (DE+EN) + demo seed data
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |