599 Commits (006ce3568b57733b347bf3c4e3e45e15830273ae)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
006ce3568b |
Manage hostnames and watch their certificates from the console
files.clupilot.com is why this exists. DNS pointed at the right machine, the env var was set, the release was deployed — and there was still no certificate, because /etc/caddy/Caddyfile is maintained by hand and nobody thought of it as a second, separate step. Nothing in the console would have said so. Three settings looked correct and the address did not answer. So the page holds two things side by side. The WISH — which names should be served — and the REALITY: whether the name has a certificate and for how much longer. The second is measured by opening a TLS connection and reading the expiry, not by reading configuration, because the configuration is exactly what looked right while the address was dead. verify_peer stays on: a certificate that fails validation is not a certificate for this question, and a display that called it valid would be the fake R19 records. Applying goes through the existing agent, not a new channel. The console writes a request, the path unit wakes the agent within a second, and the agent calls one fixed command line of the root-owned helper. What that helper is allowed to do is the careful part. It fetches the list ITSELF rather than being handed one, and the list is HOSTNAMES, never Caddy blocks — `php artisan clupilot:proxy-hosts` prints `<name> <purpose>` and nothing else. Each name is matched against a strict pattern before it is used, and the template around it lives in the helper, which the service account cannot touch. install-agent.sh already states the principle for the sudoers grant: a grant is only worth anything if the holder cannot change what it grants. A service account that could write proxy configuration would have everything the proxy can do — redirects anywhere, files from any directory. Purpose is a column rather than a habit. A console name gets the network lock, a public one does not, and a console name published without it looks exactly like a working page. Removing takes the name out of the list and NOT out of the running proxy. Two decisions in one click, and the second one takes a site off the air. There is deliberately no "renew" button. Caddy renews on its own at two thirds of the lifetime; what an operator actually needs is a second attempt after an issuance has failed, and that is a reload — which is what Apply does. Thirty days is treated as a problem rather than a warning: at ninety days' lifetime, a renewal should long since have run, so anything under it is not a tight certificate but a renewal that is not happening. The ACME contact falls back to the owner's address, because a contact nobody reads is the step before expired customer certificates. CONTRACT and HOST_STEP_NEEDS both move to 2, which is what tells a server carrying the older helper to run the installer again — caught by the guard test that compares the two halves. 2035 tests pass, assets build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
a3b43c5175 |
Release v1.3.68
The reference Caddyfile now carries a block for FILES_HOST. Setting the variable alone was never enough: the proxy has to terminate TLS for that name and forward it, and without a site block Caddy never even asks for a certificate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
20d87c0474 |
Put the files hostname in the reference proxy configuration
FILES_HOST tells Laravel which hostname to bind the routes to. It does not make
the reverse proxy terminate TLS for that name or forward it anywhere, and the
live server showed exactly that gap: DNS correct, the right machine, port 80
answering — and a TLS handshake that fails because no certificate exists for the
name.
The block carries two things the other public names do not have to think about.
No console allowlist. The archive is fetched by a server in a RESCUE SYSTEM: a
machine with no tunnel and a public address that is in nobody's allow list. The
console's network lock here would be a lock against the only caller that needs
it. The protection lives in the application instead — no valid one-time code,
404 — and the legal documents are public by intent.
And no `http://… { abort }` either, unlike admin and ws below it. Those two hide
that they exist; this one is meant to be known, and killing port 80 takes the
path away from an ACME check on the day Caddy's own challenge handler is not the
first thing to answer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
398028a57d |
Rebuild Add host as a page with one job, and let downloads through the gate
The page was a wall. Six paragraphs of procedure stacked above a form, so the thing the page actually asks for — four fields — sat underneath an essay about what would happen afterwards. It answered everything and showed nothing. It is now built the way the settings page is built, because that page was rebuilt for the same reason and there is no case for a second idiom: an eyebrow, a title, a sticky rail on the left and panels of rows on the right. The rail carries the six steps as two-word labels rather than paragraphs. That is what the question at this moment actually is — where am I, how much is left — and it fits in 232 pixels. The numbers carry the state; a tick beside them would be a second sign for one statement, and a number can be counted. What a step MEANS now appears in the row where it is due, not six times in advance. The two provider steps get a panel of their own above the form, because they have to be done before you save: the one-time code starts expiring the moment you do. Everything after the form waits until there is something to say. After saving the command becomes the page. Full width, its own framed block with the warning in the header strip rather than floating above, and the two remaining steps below it as ordinary rows. Two token bugs went with it. `bg-canvas` does not exist — it was a class that compiled to nothing, which is part of why the block looked wrong. And `text-accent` on white is 2.9:1; the config says in as many words to use accent-text for anything read, so the current step number does. Also fixed, and it would have broken every takeover in production: PublicSiteGate is appended to the whole web group, so while the site is hidden a server in a rescue system fetching the archive would have received the 503 placeholder and piped it into tar. The operator would have seen an unpack error on a machine only reachable through the provider's console, with nothing pointing at a switch in the admin area — and they are by definition neither on a management network nor signed in, since leaving that state is the whole point. Exempted by ROUTE NAME, not by hostname: the docblock rightly warns that exempting by host means trusting a header the caller picks, but that warning is about the entire portal. Behind these two routes are documents that are public anyway and an archive that 404s without a valid one-time code. 2026 tests pass, assets build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
156de12c8c |
Give the downloads a hostname of their own, with two halves
A domain that exists only to serve one installer is not worth a certificate. One that also carries the AGB, the AV and the TOM is — those addresses go into contracts and onto invoices and have to still resolve in three years, which an address that moves with the next rebuild of the portal cannot promise. That reason is what changed the answer. files.… over cdn./storage./archiv.: a CDN is an edge network and this is not one, so the name would be a lie the day a real CDN goes in front of it. "storage" reads like object storage or customer data, and a customer seeing it will wonder whether their files live there. "archiv" says superseded, which the terms currently in force are not — and R13 keeps paths and names English anyway. Two halves on that host, with opposite rules, and that is the whole point of giving it its own name: Public — storage/app/files/public/, served to anyone, indexable, because somebody looking for the terms should find them. Versioned filenames: agb-2026-01.pdf, never agb.pdf, so a contract signed in January cannot come to point at conditions written in July. The rule is written where somebody will look for it rather than enforced, because a upload that rejects a filename helps nobody. Private — the installer, and it is not a file in that directory at all: it is built from deploy/bootstrap on demand. The gate is the one-time enrolment code that is ALREADY in the pasted line, resolved without being consumed, because the code is still needed for every progress report and for the registration at the end. No second secret: a dedicated download token would never expire, would sit in shell histories forever, and would travel in the same line as the WireGuard private key — protecting the least sensitive thing with the exposure of the most sensitive one. 404 rather than 403 on a bad code, and noindex on the response. Path traversal is answered before it starts: basename() only, no directory tree under public/ by design, and dotfiles refused. The test walks ../../.env three different ways. Empty FILES_HOST keeps the archive on the portal host exactly as before, so nothing breaks in the window between setting the variable and the DNS record existing. The hostname had to move into phpunit.xml rather than a config()->set(): routes are bound at boot, so a test that sets it afterwards is setting it too late. 2025 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
1e22d66639 |
Release v1.3.64
tests / pest (push) Failing after 8m53s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Two features reach a tag at once, because the host-takeover branch was cut from the operating-mode branch and carries all of it. The operating mode: a test and a live setting for the whole installation, every credential with a test slot beside its live one, a stored key that says which mode it belongs to, and a Stripe catalogue that refuses to act on objects from the other account. The host takeover: the bootstrap script that turns a machine in a rescue system into a finished Proxmox host, the archive that serves it, the one-time enrolment code, and the six-step guide in the console under Hosts and Add host. None of the script's steps has been accepted on real hardware yet — every hardware step in its plan is still unticked, and the console work is what this release makes visible. Also in here, found while writing the takeover: host names were being built from the CUSTOMER zone. RegisterHostDns says clupilot.com in its own docblock, and on this installation a host was actually called fsn-01.node.clupilot.cloud. There is a platform_zone now and the step uses it. 2017 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
447d6fe0f3 |
Merge main into the host-takeover branch
One real conflict, in SyncStripeCatalogue::handle(), and both sides were right. Main's parallel work resolves the adoption singletons and takes "before" counts so a run can tell created from adopted. The operating-mode work refuses a catalogue whose stored objects belong to the other mode, and records which mode the catalogue now belongs to. Kept both, with the mode guard first. It REFUSES, so it must not run behind anything that has already built state; the counters only need to be in place before the create loop, and they still are. Reversing that order would have the command resolve singletons and take counts on a catalogue it is about to reject. 2017 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ba4996f316 |
Keep the platform on .com and the customers on .cloud
The owner caught me putting the archive on clupilot.cloud. That is the customer
zone, and looking into it turned up the same confusion already sitting in the
code — in the one place that matters most.
RegisterHostDns says "fsn-01.node.clupilot.com" in its own docblock. The
validation comment in Datacenters says it. ServicesTest writes it out verbatim.
The step itself built the name from config('provisioning.dns.zone') — the
CUSTOMER zone — so on this installation a host was actually called
fsn-01.node.clupilot.cloud. Three places asserting one thing and the code doing
another.
OfficialDomains explains why that matters and is worth not weakening: two
registrable domains by design, the company's for site, portal and console, the
instance zone for customer workloads. A Nextcloud is third-party software that
strangers sign into, and on the same registrable domain as the portal it shares
cookie scope with it. A host name in that zone does not break the separation,
but it puts it in question, and the next slip is more expensive.
So there is now a platform_zone, derived from APP_URL when unset, and
RegisterHostDns uses it.
My own archiveUrl was broken for a second reason. It fell back to url() when
APP_HOST is empty — and APP_HOST is empty on most installations, because empty
means "the portal answers on any hostname" and that is the default. Called from
the console, url() would have produced the CONSOLE hostname, and the line would
have 404'd on a machine that is not allowed to reach the admin area at all. It
takes the host from APP_URL now.
The script no longer guesses its own name. It used reverse DNS, then the tunnel
address, then a hard-coded clupilot.net — a third domain that appears nowhere
else in this project and was simply invented. PrepareBaseSystem has the same
invention. A guessed name does not stay guessed: it ends up in /etc/hostname, in
/etc/hosts, in every log line and in every certificate request the machine ever
makes. CluPilot knows the name because it just assigned it, so it passes --fqdn
and the script refuses without it. The value now also survives the reboot in the
arguments file, which it would not have.
The ACME contact moved to .com for the same reason it was wrong: the operator
does not live in the customer zone.
Open, and NOT decided here: the owner also wants the host to get a public DNS
record and a certificate on the .com name. RegisterHostDns deliberately writes
host names only into the tunnel's dnsmasq, and says why — publishing them hands
every scanner the internal subnet and roughly how many hosts sit behind it.
Nothing in the current design needs a public certificate for a host's own name;
Traefik serves customer domains, not this one. Reversing that is a security
decision and belongs to the owner, not to this commit.
1992 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
2194ab8079 |
Put the instructions where somebody reads them before they start
The previous commit shipped the guide AFTER the host was created. Its first two steps happen at the provider — order the machine, boot the rescue system — and whoever reads them there has the one-time code already in the clipboard with its 24-hour window running. The instructions were correct and in the wrong place, which is its own kind of wrong. Six steps now, not three, and the two provider ones are named rather than assumed. They are marked "first" so the list does not read as something you can start at the top of and work down: step 2 is half an hour of waiting, and doing it inside a running code is exactly the mistake the page should prevent. All six are visible the whole time, including the done ones and the ones still to come. A guide that shows only the current step cannot answer "how much is left", which is the question somebody has while a server they are paying for sits in a rescue system. It is on the hosts list too, collapsed. Somebody standing there may not have ordered the machine yet — and that is step 1. Requiring them to click "add host" to find out what the procedure is puts the answer behind the action it describes. One component, two places, so the archive address in the instructions is the same string the command line will carry. Two copies of that would drift, and the difference would surface on a server that has already been paid for. The step-4 command box moved into the guide rather than sitting beside it, so after creating a host the whole procedure stays on screen. Somebody stuck at step 5 should not be looking at a page that has become a single box. 1991 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
903ebdd2b2 |
Give the operator one line to copy and three steps around it
The console could describe a takeover it had no way to start. This is the vertical slice that closes that: a one-time code, the archive the rescue system fetches, and the page that says what to do with both. The command carries EVERYTHING the script needs before the tunnel exists, because there is nothing to fetch — that is the whole point of spec §5. Which means CluPilot generates the WireGuard keypair and admits the peer at the hub before the machine has ever booted, and hands the private half over in the line. It is worthless within minutes: task 9 of the script replaces it with one generated on the machine. Shown exactly once. The database holds only the code's hash and never the private key, so leaving the page does not bring it back — it mints a new code, which invalidates the old one. That is deliberate: a glance at somebody's screen should be worth nothing an hour later. Which is also why save() no longer redirects. Sending the operator to the host detail page sends them away from the only value they need, and an existing test asserted that redirect — it now asserts the opposite, with the reason written next to it. SHA-256 rather than bcrypt for the code, and the reason is not speed. Both endpoints have to FIND the host by the code; with bcrypt that means trying every row. The code is 32 characters of CSPRNG output, so it has the entropy that stretching exists to manufacture. resolve() and claim() are separate because progress reports arrive BEFORE registration. If reporting consumed the code, a host could never register after its first message. The archive URL is always the public hostname. The console runs under admin.…, but this line executes on a machine that must not reach the admin area — it is locked down for exactly that reason — so route() from the console would emit a hostname that 404s on a server only reachable through the provider's console. The page warns about missing tunnel settings BEFORE the host is created, not after. An empty hub key produces a line that looks clean, copies fine, runs, and ends in a tunnel that never handshakes — discovered on the machine, after somebody has already paid for it. The three steps lead with the rescue system, because that is the one nobody knows by heart, and it says enabling is not the same as booting into it — the script refuses a running production machine, which is what a half-done switch looks like from the inside. 1986 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
668ed67de4 |
Write down what to do when the bootstrap stops
One entry per section: how you notice, where to look, what it usually is. Above all of them the rule the plan already states — a half installed machine is reinstalled, not repaired — with the reason, which is not convenience. The script is built so a second run on a freshly imaged machine always comes out the same; a run on a machine somebody straightened out by hand is no longer that. Repairing trades a repeatable result for a one-off, and the difference only shows up on the next host. rescue_checked is the stated exception, because nothing has been written yet. It is the only section where fixing in place is right, and the runbook says so rather than leaving the reader to infer it. Two things that are easy to get wrong are answered up front. Whether the reboot has happened is one `findmnt -no FSTYPE /` — tmpfs or overlay means still in the rescue system, zfs means the first-boot hook already resumed. And when a host is unreachable, the first question is whether the firewall had even run: it is applied last, so if `registered` is not done, the cause is the network and not the rules. The installer can be watched while it runs. If QEMU is still up, forwarding 5900 over SSH shows what the ISO is doing, and an input mask there means the answer file was not accepted and the installer has dropped into interactive mode where it will sit until the hour expires. That failure otherwise arrives as a timeout saying nothing. The last heading is the honest one: everything here is written from the script and the findings behind it, not from incidents. What actually stops on the first real run belongs in this file afterwards, quoted as it appeared in the console. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f45f290f00 |
Hand over the token and let the console take it from here
The last section carries three things, and none of them could sit anywhere else: the registration and key swap, Traefik's token plus the proof that a route table actually arrives, and the nftables lockdown that ran last in the old pipeline for a reason. `|| true` is gone from the pveum calls, and not by deleting it. `role add || true` applied the privilege list only on the run that first created the role, so a privilege added later reached new hosts and no existing one — which is exactly how Sys.Modify was missing everywhere, surfacing much later and somewhere else as a 403 on POST /cluster/backup that failed a paying customer at register_backup. So: add, and on failure modify, as two separate commands so a failure can be attributed. For the user and the ACL the tolerated `|| true` is replaced by looking: try, and if it fails, check whether the desired state holds anyway. A `|| true` hides "already there" and "went wrong" equally well. The privilege list is copied verbatim from config/provisioning.php and checked against it — 18 privileges, byte-identical, Sys.Modify included. It is granted on / because both endpoints that need it check / and nothing narrower satisfies them. The key swap follows the four steps this plan was amended to require. Register first and hold the answer, switch wg0 to the new private key, PROVE a fresh handshake, and only then let the old key go. If the handshake does not come, it puts the old key back and fails loudly. Ordering alone says when to discard, not whether the new key carries — and a host that locks itself out on the final step is the one failure nobody fixes remotely. Traefik's token is written here because here is where it exists, and the route table is then fetched directly with that token and that URL. Section 7 could not give this proof; it is not skipped, it is given where it can be. The lockdown comes dead last, with the full ruleset from SecureHostFirewall including the ICMP correction — ICMPv6 neighbour discovery and packet-too-big, IPv4 fragmentation-needed — because a bare policy drop takes IPv6 down within minutes and black-holes large transfers instead of failing them. The ruleset is validated with `nft -c` before it is applied, since a partially loaded ruleset means policy drop is in place and the exceptions are not. There is no automatic reopening: a firewall that reopens itself under failure is not a firewall, so the emergency release is a script for the provider's console. Verified without hardware: all seven files dash-clean; json_field reads full-tokenid, value and host_token without jq; the privilege list is identical to the config's; and the rendered ruleset carries policy drop, both ICMP families, 80/443, the DHCP rebind rule and the tunnel-only 22/8006. Step 2 unticked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
5d25018cc6 |
Build the golden template so nobody has to remember how
All three Block A traps are CHECKED here rather than assumed, because each one
has already cost a paid order and none of them shows a symptom until it is too
late.
Trap 2 is checked before anything is built: virt-filesystems is asked whether
the image uses LVM and whether root is the last partition, and the section
refuses if either is wrong. Debian's cloud image satisfies both by construction,
which is exactly why it is the base — but "satisfies it by construction" is a
claim about an artefact somebody else builds, and it gets verified rather than
trusted.
Traps 1 and 3 are checked AFTER the image is customised, because virt-customize
reports success even when apt quietly did not install something. qemu-guest-agent
has to be in /usr/bin or every provisioning run hangs in WaitForGuestAgent until
it times out, and the compose file inside the image has to carry `user:
www-data` or every occ call fails — including the acceptance check that decides
whether a customer's instance is usable.
The template is verified by its ATTRIBUTE, not its existence. VerifyVmTemplate
checks only that 9000 is there, which a VM that merely happens to be numbered
9000 also passes; `template: 1` is passed only by a template.
Docker comes from Docker's own repository, not Debian's. docker.io ships no
compose plugin and Debian's docker-compose is the old Python one, which does not
read this file at all.
The compose file is deliberately customer-independent: no password, no name, no
domain. Everything variable arrives in /opt/nextcloud/.env, written into the
guest by cloud-init at clone time. Baking a password into an image copies it
onto every host and into every instance, and leaves it there long after the
customer has changed it.
OVERWRITEPROTOCOL, OVERWRITEHOST and TRUSTED_PROXIES are set because TLS ends at
Traefik on the host. Without them Nextcloud builds its own URLs with http://,
the login loops, and WebDAV clients get handed an address that does not exist.
Storage is discovered rather than named: local-zfs is what the PVE installer
creates on ZFS, local-lvm on ext4, and `local` frequently cannot hold disks at
all — an importdisk there fails only after the copy.
Verified without hardware: dash-clean; the compose file parses, carries `user:
www-data` on the app service, publishes 80, and every credential is a ${...}
reference rather than a literal. Step 2 unticked, and it is the one the plan is
most insistent about: the template must actually be cloned, started, and asked
`occ status` as www-data. Without that clone it is not proven.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
a25e9c2fe6 |
Run Traefik as a service that fetches its own routes
Binary plus systemd unit, no Docker on the hypervisor: nftables stays the only owner of the host's firewall rules and a Docker daemon would bring its own chain. The names were read out of SshTraefikWriter::render() rather than guessed, which is what the handoff asks for in as many words. The route endpoint emits entryPoints ["websecure"] and certResolver "letsencrypt", so the static config declares exactly those. Name them differently here and the routers point at nothing while Traefik still reports a clean start — a failure with no symptom at the place it happens. An ordering problem this plan had not resolved: the http provider needs the durable host token, and that token only exists after POST /host/register, two sections later. Rather than reorder the section keys — they are the contract with the platform plan — the static config is written twice. Here with an empty token, so the service stands and holds 80 and 443, and again in Task 9 with the real one. The proof does not disappear, it moves to where it can be given. That proof asks the endpoint directly with the same token and URL the config carries, instead of counting Traefik's routers. A fresh host has no customers, so its table is legitimately empty, and "zero routers" would mean both "fine" and "never fetched". Ports are checked separately from the service. "Running" and "listening" are two claims, and Traefik starts cleanly even when a typo means an entryPoint was never created. The binary is checksummed against the release's own checksums file, for the same reason the ISO is: what listens on 80 and 443 and holds every customer's certificate does not get taken off the network unverified. The version is discovered at runtime, because a pinned number becomes a 404 mid-takeover. acme.json is created at 600 before Traefik ever runs. It holds the private keys of every customer certificate, and Traefik refuses wider permissions — rightly. The unit runs with CAP_NET_BIND_SERVICE and nothing else, ProtectSystem=strict, NoNewPrivileges: this is the one process on the box reachable from the open internet. Verified without hardware: dash-clean, the generated config parses as YAML, and it carries web/websecure/traefik as entryPoints, letsencrypt as the resolver, the Bearer header on the http provider, web redirecting to websecure, and 640 on the config with 600 on acme.json and the token file. Step 2 unticked — it wants a fetched route set, and that is Task 9's to show. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
c345f21e7d |
Join the tunnel, then tell the console everything so far
The three interface states come over from ConfigureWireguard.php exactly as that file works them out, because the one line they replace got two of them wrong. `systemctl enable --now wg-quick@wg0 || wg-quick up wg0` brought the interface up via its fallback WITHOUT the systemd enablement, so the tunnel did not come back after a reboot; and on the next attempt both halves failed with "wg0 already exists", so it retried forever against a tunnel that was working. Enable before start, because enable is the half that survives a reboot, and it is repaired even when the interface is already up by other means. The configuration is compared before it is written. The file used to be rewritten unconditionally with nothing reloading it, so correcting a wrong hub key showed a new file and identical behaviour — the running interface still held the old peer. Knowing whether it CHANGED is what lets an unchanged, working tunnel be left alone and a corrected one actually be applied. A change means a restart rather than `wg syncconf`, because syncconf applies peers only and a corrected Address or AllowedIPs would silently do nothing. AllowedIPs is computed, not copied. Writing the host's own address there would let the tunnel handshake and leave every other participant unreachable — a fault that looks like a routing problem somewhere else entirely. The handshake target is derived from --api rather than taking its own argument. CluPilot's tunnel address is already in there, and a second value meaning the same thing is a second value that eventually disagrees with the first. The tunnel counts only when it has been PROVEN, never when the file is on disk. A stored tunnel address without proof made every later connection attempt useless and the recovery was hand-editing the database. Once it is proven, flush_reports runs and the whole history from the rescue system onwards reaches the console carrying its original timestamps. Verified without hardware: dash-clean; the subnet arithmetic is right on octet boundaries and off them (192.168.5.130/25 gives 192.168.5.128/25, 172.16.4.9/12 gives 172.16.0.0/12); the API host parser handles a bare address, a scheme, a port and a path; and wg0.conf renders with AllowedIPs on the network rather than the host. Step 2 unticked — it wants all five earlier sections showing up in the console with real timestamps, and that needs a hub. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
9e485efe87 |
Write the firewall's running order into the plan, not just the commit
The previous commit moved the nftables lockdown out of Task 5 and explained why in its message. A commit message is not where the next person looks. Task 5 now carries it: the bridge is normally already there because the ISO installer writes it, the lockdown belongs in Task 9 because SecureHostFirewall ran last for a reason, and "check from outside" cannot mean what it says before the tunnel exists — so the script checks inside-out and says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
e9b2862331 |
Bridge the network, and take it back if the host goes quiet
Reading ConfigureProxmox.php changed what this section does. The missing vmbr0 that killed the first paid order is a consequence of installing Proxmox ON TOP OF DEBIAN — "only the ISO installer writes that bridge into /etc/network/interfaces". This design installs from the ISO, so the bridge is normally already there. The section verifies it instead of building it: exists, has an address, and actually carries the default route. The old step checked only the first of those, threw the result away, and its comment claimed it recorded the absence. When there is no bridge, it builds one, and only then is this the dangerous section the plan calls it. Backup, five-minute rollback timer, switch, look, cancel — and the timer is a systemd unit rather than a backgrounded sleep, because a background job dies with its session and the session is exactly what breaks when the switch goes wrong. It is set BEFORE the change; setting it after would mean setting it at the moment the connection is already gone. The three provider shapes differ in one line, and that line decides whether the machine is still there afterwards. A routed single address needs pointopoint, because its gateway is outside its own subnet and without it the kernel has no route to reach it. Which shape applies is decided by asking `ip route get` about the gateway rather than by doing subnet arithmetic here — same logic the kernel will apply later. The reachability check is named for what it is: inside-out. A true outside-in proof would need a counterpart, and before the tunnel there is none. It is enough because a bridge that takes the host off the network takes both directions with it, and what it does not cover is precisely what the timer covers. One deviation from the plan, written into it: the nftables lockdown does NOT belong here. SecureHostFirewall ran LAST in the old pipeline, deliberately, so the tunnel had already carried every earlier step before SSH-to-the-world was closed. Here it would sit BEFORE wireguard_joined and shut port 22 with no handshake yet proven — the ordering that makes a host permanently unreachable. It moves to Task 9, where it used to be. The self-rollback stays here and covers the network change; the firewall keeps its manual emergency release, because a firewall that reopens itself under failure is not a firewall. The datacenter firewall comes over verbatim, including why `enable 1` alone is a trap: the management ipset is seeded from the public subnet, so enabling it blindly drops the tunnel address that every later step arrives on. Three settings in order, then read back — a pvesh set returning 0 is not the same as the value being in cluster.fw, and a write that lost quorum is exactly the case where every customer VM's rules stay inert while onboarding reports success. Verified without hardware: all four files dash-clean, and the generated interfaces file is correct for subnet, routed (with pointopoint) and dhcp. Step 2 unticked — the self-rollback has to be triggered on purpose on real hardware, and the plan is right that without that it is an assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f214713113 |
Point Proxmox at the sources this Debian actually has
PVE arrives with the image now, so this section accepts it rather than installs it — but it needs every line of knowledge from InstallProxmoxVe.php to do that, just pointed the other way. The kernel is the claim, not the package. An installed proxmox-ve running on a Debian kernel is exactly the half state RebootIntoPveKernel exists because of, so uname is what gets checked. The codename table now verifies a pairing instead of creating one. An image whose PVE major does not match its Debian base was built wrong, and no amount of fixing package sources heals a PVE compiled against a different libc — that machine gets reinstalled with the right image. The other half of the table, codename to PVE major, is written down here because the ISO route needs it and the original file only had the suite side. Removing the subscription repositories is the part that everything after this depends on. A Proxmox image ships pve-enterprise, and without a subscription it fails every apt-get update — after which no package install in any later section succeeds. Both spellings are handled, because PVE 8 used one-line .list files and PVE 9 moved to deb822, and ceph is in the list because the same trap is set there a second time. The proof is a clean apt-get update at the end, which is the only thing that tells removal apart from overwriting. One thing changed after the first draft: when pveversion cannot be parsed, the pairing check used to be skipped in silence. That is the fake that R19 records as worse than no check at all, because it stops the next person from looking. It now says "Paarung UNGEPRÜFT" in the report and in the log. Verified without hardware: dash-clean; bookworm maps to PVE 8, trixie to PVE 9, forky is refused; the pveversion parser reads 9 and 8 out of both real formats and yields nothing for garbage; and against a copy of /etc/apt/sources.list.d holding all five subscription files plus an unrelated one, exactly the five go and the unrelated one stays. Step 2 unticked — there is no Proxmox here to accept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0f563545fd |
Write the image and survive the first reboot
The official Proxmox ISO, an answer file baked into it, and QEMU running it against the physical disks from the rescue system. Options were looked up rather than recalled: prepare-iso takes --fetch-from iso, --answer-file and --on-first-boot, and it names its own output, so the finished ISO is found by glob afterwards instead of being handed an --output flag I would have invented. validate-answer runs BEFORE the ISO is baked. An answer file with a typo drops the installer into interactive mode, where it then hangs invisibly inside QEMU until the hour runs out — the failure would arrive as a timeout and say nothing about the cause. The codename table comes over verbatim from InstallProxmoxVe.php, which the platform plan deletes. It is used here to install the assistant into the RESCUE system, so it keys off the rescue system's own codename. An unknown one aborts; not "trixie is newest, so trixie", because the next Debian will be unknown too and by then nobody is watching. The ISO is checksummed against the mirror's SHA256SUMS. Booting an operating system that could have become anything in transit is not a risk worth taking, and a truncated download is enough to cause it — malice is not required. The reboot carry-across is the gap this plan was amended for, and [first-boot] takes exactly one executable. So that one file carries everything: the script, its lib/, the progress file and the arguments, as a base64 archive in its own body. That also avoids importing the fresh ZFS pool from the rescue system to copy files into it. Two bugs found by running it rather than reading it. The staging copy put the work directory inside the state directory, so cp refused to copy a directory into itself — and had it not refused, the gigabyte ISO would have been base64'd into the hook that gets baked into that same ISO. The fix is not a better exclude: the two directories have opposite lifetimes, so they now live in different places, and a guard refuses loudly if anyone points them at each other again. Structure beats a rule someone has to remember. Verified without hardware: both files pass sh and dash. The generated answer file parses as TOML with the expected disk-list, zfs.raid, zfs.arc-max, first-boot and network.filter, and a 64-character root password. The codename table accepts bookworm and trixie and refuses bullseye and forky. The shim comes out at 26 KB with a 3 MB dummy ISO sitting in the work directory, proving it stayed out. Run in a fresh debian:13-slim container it unpacks to /opt/clupilot/bootstrap and /var/lib/clupilot, restores the progress file with its original timestamp, has 700 on the directory and 600 on the arguments that hold the WireGuard key, skips rescue_checked as already done, and fails cleanly on the missing qemu. Step 2 stays unticked: a container is not a rescue system, and nothing here has yet written to a disk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0b26e45147 |
Install Proxmox the way Proxmox says to, not the way I guessed
The previous commit put installimage with a Proxmox image into Task 3. That
image does not exist. Hetzner's installimage carries no Proxmox and lists it as
unsupported, so anyone following that instruction finds only Debian and ends up
back at the intermediate base system the same commit had just removed. Looked
up rather than remembered, which is what should have happened before writing it
down.
The mechanism the owner described does exist and is the official one since PVE
8.2: build the real Proxmox ISO with an embedded answer file via
proxmox-auto-install-assistant, run it under QEMU against the physical disks
from the rescue system, reboot into a finished node. Hetzner publishes a
tutorial for exactly this. No Debian, no VNC, no click — which is what the
owner said in the first place.
Two things fall out of the answer file that improve the plan.
The hand-written systemd resume service is gone. [first-boot] with
source = "from-iso" is the supported way to have the script pick itself up
after the reboot, and it is the only variant that can work before the tunnel
exists — a from-url hook would be pointing at a CluPilot it cannot reach yet.
The carry-across of the progress file stays necessary regardless: the rescue
system's copy still lives in RAM.
Trap 2 was being applied to the wrong machine. "Root partition last, not on
LVM" comes from the template traps, and the reason it exists is
GrowGuestFilesystem, which grows a GUEST filesystem. The template is built in
Task 8 from a Debian cloud image, not from the host's own layout, so the
justification written into Task 3 ("it applies to the host too, because the
template is built from here") does not hold. It remains binding for the
template, where it belongs. The host gets ZFS, and that it happens to involve
no LVM is a side effect rather than the reason — with arc-max pinned, because
the default takes half the RAM a host needs for its guests.
Also recorded: the answer file needs an fqdn and a root password that the
command line does not carry. The password is generated and thrown away, because
the platform no longer logs in over SSH and the plan already says a half
installed machine is reinstalled rather than repaired — keeping a secret for an
emergency the plan does not have only creates another place a secret lives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
8f943f3d1b |
Take the Debian step out of a plan that never needed one
The owner contradicted the plan, and the plan was wrong. It had the script write Debian 13 and then had Task 4 turn that into a Proxmox host. In reality you boot the provider's rescue system and install Proxmox directly — there is no intermediate Debian for anyone to convert. The section keys do NOT change. They are the only agreement with the platform plan, and they are not worth touching for a rename — especially since they still fit: Proxmox VE is Debian with the PVE kernel and packages on top. What changes is what they mean, written down so the console labels them honestly: debian_installed is "base system written", proxmox_installed is "PVE proven to run". Task 3 now uses installimage with a Proxmox image, and says the image name gets pinned during acceptance instead of guessed here — the list belongs to the provider and moves. No debootstrap and no hand-rolled partitioner: the provider knows its own hardware, and a self-built boot path on someone else's metal only comes back through their console. Hetzner dedicated only; a netcup path gets written when there is a netcup machine to accept it on, because an unproven fallback is exactly the assertion this plan avoids everywhere else (R22). Task 4 keeps every line of knowledge from InstallProxmoxVe.php and needs all of it — it just applies it to an image instead of a bare base. The codename table now VERIFIES the pairing rather than creating it, and the enterprise repo that ships with a Proxmox image has to go, because without a subscription it fails every apt-get update and therefore every package install in the sections that follow. Its acceptance now includes a clean apt-get update, which is the only way to tell removal from overwriting. Delivery is settled too, and was written down in neither document: a tar.gz unpacked to /opt/clupilot/bootstrap. A curl | sh cannot carry a lib/, and the separate files are load-bearing. A static archive is not an endpoint that answers questions, so spec §5 stands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
25bcab345d |
Refuse a machine that cannot do the job
Four checks, all of them before a single byte is written, because this is the
only moment the script still has the option of not overwriting a disk.
The expensive one is "is this actually a rescue system". It demands POSITIVE
evidence rather than the absence of a counter-argument: a rescue system runs
from RAM, so its root is tmpfs, an overlay or a ramdisk, and an installed
system has a real partition there. A running pveversion is refused outright —
that is a hypervisor with customers on it. Mounted partitions of real disks are
refused too, because at that point the script cannot tell an empty machine from
someone's data.
There is deliberately no flag to skip the check. Such a flag gets used exactly
once, on the evening it should not have been. A rescue system this does not
recognise belongs in the runbook, not in a bypass.
The disk floor is written as arithmetic rather than a number, because the repo
has no threshold to borrow — plans live in the database, not in
config/provisioning.php. Template ~20 GB, Proxmox and its swap and backups
~20 GB, one smallest customer ~50 GB, rounded up to 100. It is explicitly not
capacity planning; HostCapacity and reserve_pct do that after registration.
This only turns away the machine the whole thing cannot fit on.
Network and clock come from one plain-HTTP HEAD, deliberately without TLS —
otherwise a wrong clock would be checking itself and would report a certificate
error instead of the time. Where `date -d` is missing (busybox), the clock is
NOT checked and the report says so: a check that silently waves things through
is worse than none, because it stops the next person from looking. That is the
same lesson R19 records about ->timezone(config('app.timezone')).
Verified here, in both directions where a direction existed. On this VM (root
on /dev/sda1, 80 GB, /dev/kvm present): refuses with three findings at once,
exits 1, writes nothing. In a debian:13-slim container (root on overlay, no
/dev/kvm): the overlay root is accepted as rescue-like and the missing
/dev/kvm is caught, which is the abort case the plan names. Against a receiver
serving a Date header two years off: 63074141 seconds of drift, refused, with
the exact `date -u -s` line to fix it. Step 2 stays unticked — none of these is
a rescue system on a dedicated server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
af284d7d26 |
Give the bootstrap a spine and a way to report
Argument parsing, the state directory, and the half of the script that knows the way back to CluPilot. No sections are hooked up yet; main lists the nine it will get, in the order from spec §7, so what is missing stays visible. report() writes locally first and only then tries to send, because before wireguard_joined there is no path at all — the lines queue up and flush_reports delivers them later WITH THEIR OWN TIMESTAMPS. That last part is the whole point: a twenty-minute install that arrives in one batch looks like one second in the console, and nobody can see which section was slow. Every network operation is explicitly caught. The script runs under `set -e`, and a failing send is the normal case for the first five sections, not an error — a report that aborts the run would be the diagnosis that kills the patient. What is sent had no agreed shape. The section keys were the only agreement between the two plans; the envelope around them was not written down anywhere, so it is written down here, at the top of report.sh, for the platform side to read once. There is deliberately no "running" state: the console derives "open" from the absence of a report (spec §7), and a third state would be a second truth about the same thing. The sent-marker is a line count in its own file rather than a flag rewritten into progress.jsonl. That keeps the log append-only, so a power cut mid-write costs at most a partial last line instead of a rewritten file — and after the reboot in Task 3 that file is the only thing that remembers anything. Verified here, without hardware: both syntax checks pass; two reports against an unreachable CluPilot queue up without aborting and leave progress.sent at 0; a receiver brought up afterwards gets both in one batch, carrying 17:28:55 and 17:28:57 rather than the 17:29:22 they arrived at; a message containing quotes, a backslash, a tab and a newline survives as valid JSON; and section_done counts a `done` as done and a `failed` as not. Step 2 of this task is NOT ticked — none of that is a rescue system, and the plan is right to insist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ecfced9257 | Merge branch 'main' into claude/cool-sammet-368fee | |
|
|
79654333a8 |
Name the recurring filter, and stop the spec contradicting itself
Three sentences left over from the wave before, all of the kind this branch exists to end. - "Every active Price" was one word short. activePricesFor() sends `type: recurring` as well as `active: true`, so a one-time Price is never listed — which made the hand-built-payment-link sentence false for a one-time link: neither listed nor archived. Both places say `recurring` now, and the docblock says why the filter is not a gap: createPrice() always sends `recurring[interval]` and is the only call that mints a Price at one of our Products, so the filter can only ever hide somebody else's one-time Price. A filter left out of a sentence is a gap the next reader goes looking for. - The sweep test file still opened "The orphans nobody can adopt" — the exact narrowing the command's own docblock had to be corrected away from. It happens to describe the fixtures, which are all metadata-less, so it now says that instead of claiming it of the command. - The design doc called --archive "harmlos" seven lines above the correction establishing that it is not, for an adoptable orphan. A document that contradicts itself seven lines apart teaches nobody anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
61c2501136 |
Close three gaps before they become three bugs in the script
Read on review, before a line of shell was written. All three would have been found on hardware instead — two of them late, and one of them only by noticing something that was silently absent. The progress file did not survive the reboot. Task 1 writes it in the rescue system, whose root lives in RAM; Task 3 installs Debian and reboots. The plan said the resuming service uses "the same progress file" without saying how it gets there. Because flush_reports first runs in Task 6, AFTER the reboot, rescue_checked and debian_installed would simply never have reached the console — and nothing would have pointed at the reboot as the reason. Task 3 now carries the file, the script, and the arguments across, the last one root-readable only because the WireGuard key is in it. RebootIntoPveKernel.php was on the platform plan's delete list but not on this plan's read list. Six files were named; seven are deleted. This is the one that proves the boot path BEFORE issuing the one irreversible command in the whole run: a -pve kernel image present, its initramfs present (a full /boot leaves exactly the half-written one), and update-grub checked on its exit status, which its predecessor discarded. Losing that would have cost a machine that does not come back, and on a dedicated server that means the provider's console. Reading it also corrected a rule this plan states. Task 9 rightly refuses `pveum ... || true`, but that is not a rule against `|| true` — RebootIntoPveKernel tolerates one deliberately, with the reason written next to it, and a script that applies the ban literally breaks the kernel removal in Task 3. The rule is now stated as what it is: no `|| true` over a command whose failure means something. The key swap in Task 9 had no proven handshake. Task 6 insists a tunnel counts only with one, then Task 9 re-keys that same tunnel and discards the old key on ordering alone. Ordering says when to discard, not whether the new key carries. Four numbered steps now, with the proof third and the discard fourth, and a fallback to the old key if the handshake does not come — the one failure nobody fixes remotely, at the end of a run that did everything else right. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
fe07fa2e77 |
Break the host takeover into two plans that can run side by side
Platform: ten tasks, all provable by the suite. Enrolment code and key pair, the tunnel-only restriction (its test is the reason the design looks the way it does), register, progress, routes, retiring SshTraefikWriter, the console, the customer's own subdomain, shrinking the pipeline to six steps that only look, and pulling the readiness checks along. Script: ten tasks, none provable by the suite — a script that installs an operating system cannot be run in SQLite. Each one ends on real hardware instead, and two of them insist on it: the bridge section must have its self-revert triggered on purpose, and the template must actually be cloned and booted before it counts. The one real coupling is written into both: platform task 9 DELETES the seven step files the script plan reads its knowledge from. Do not run it before that knowledge has been lifted — git log keeps them, but nobody reads history they do not know to look for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
a43be4186a |
Say what the sweep did not check, before telling anyone to archive
stripe:sweep-orphan-prices described itself as listing "what AdoptStripePrice will not take over". It computes nothing of the sort: it lists every active Price at one of our Products that no register row knows, adoptable or not — the only question it asks is whether a row holds the id. And then it invited the destructive path, with no word anywhere about running stripe:sync-catalogue first. Following that advice can make a plan unsellable. Archive an orphan the next sync would have adopted and the sync can no longer see it: activePricesFor() asks Stripe for active prices only, so adoption returns null and createPrice() fires under the same key the crashed run used. For twenty-four hours Stripe answers a repeated key by replaying the stored response — the archived Price's id, in the body it had while it was live. The register writes it down as live, and the checkout is refused. This is the same class of defect the branch exists against: a comment that was eloquent and wrong. - The docblock now says what the command lists, names the twenty-four-hour replay so nobody has to rediscover it, and the ordering requirement is in $description and printed on every path that found something, --archive included. The reassurance that archiving is harmless is now scoped to what THIS codebase sells; a payment link an operator built by hand against one of our Products is withdrawn all the same, and says so. - stripe:sync-catalogue --dry-run says "created or adopted" again. A dry run counts intents without calling ensure(), so it cannot know which Stripe already holds — and a dry run is the first thing an operator runs after an interrupted one. The live line keeps both figures, where they are knowable. - The sweep's plan side gets a test: an orphan at a family Product is reported and a known plan Price never is. Deleting either half of the lookup turns it red; the five tests it joins all stayed green for the products() half. - The throttle test pinned "once" and neither "per price" nor "per day". A key mutated to a constant would have silenced every orphan after the first and kept it green — the silent no-op the branch is built against. It now plants a second orphan, and travels a day to hear the first one again. - Two of my own documents corrected. The spec sentence claiming an adoptable orphan never appears in this list is what the implementation faithfully built; it holds only if a sync has run since the orphan appeared, which nothing enforced. And the two adoption classes do not carry the same run log: $adoptions on the Price side, $duplicates on the Product side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
32016d9b80 |
Write down the network boundary, the subdomain, and two decided follow-ups
The route pull stays inside the tunnel. The operator's criterion was not availability but "publicly reachable, yes or no" — and an endpoint that survives a tunnel outage must by definition be reachable outside it. So nothing new opens from outside, on the host or here. The subdomain becomes the customer's choice at checkout, with suggestions from their own name and a sentence saying the address is permanently public: every Let's Encrypt certificate lands in the Certificate Transparency logs, which are public, searchable and never deleted. Also recorded, both decided and both getting their own spec: resetting a Nextcloud to factory state with a 14-day safety backup, and an outage mail to the operator after two consecutive failed checks — measured today that neither an incident nor a mail exists, so a customer VM can fail unnoticed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
571acd0013 |
Check Stripe is configured on every path the sweep actually uses
The dry-run guard was copied from stripe:sync-catalogue, where it is right: that command's --dry-run touches nothing, because everything it compares already lives in our own tables. This command has no such local-only mode — the report IS a live activePricesFor() call, dry-run or not — so `! $dryRun &&` made the guard fire only on the one path that would have worked anyway. A --dry-run against an unconfigured account fell straight into HttpStripeClient's own exception instead of the clean error and self::FAILURE. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
277a6bab83 |
Give the operator something to do about an orphan nobody can adopt
The recognition step refuses a price that proves nothing about itself, and that is right — adopting a stranger's price is worse than minting a second one. It left an operator with a log line and no way to act on it. Reports by default and touches nothing. With --archive the orphans stop being sold, which is harmless for the reason it always is here: Stripe goes on billing every subscription already on an archived price, and nothing is sold on a price no row knows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
c0cd367a0e |
Drop the public endpoint: the command line already carries what the host needs
The operator was right to challenge POST /enrol. The three values a script needs before the tunnel exists — hub key, hub address, assigned tunnel IP — are all known to the console when the host is created, so they travel in the copied command instead of being fetched. The one thing that forced an endpoint was the hub needing the host's public key before the tunnel is up. So the console generates the pair and hands it over, and the host swaps in a freshly generated key through the tunnel afterwards. The key that passed through a clipboard lives for minutes. Net effect: the network boundary is unchanged. No new way in from outside. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
048e5ba81f |
Subtract only what was counted, not everything a singleton adopted
A figure may only subtract what it counted as an intent in the first place. The family loop counts a Product before knowing whether it will be adopted or minted, so an adoption there could honestly be subtracted back out — but syncModules() has never counted a module's own Product as an intent at all; it only counts a module's Prices. Reading AdoptStripeProduct::adoptions as one run-wide delta could not tell the two apart, so a module Product adopted from an interrupted run silently inflated "adopted" and shrank "created" by exactly one, for a Product the command never claimed to have made in the first place. The fix counts the family side locally, at the one call site that already counts the intent, and leaves a module's Product out of both figures entirely — adopted or minted, it was never counted, so neither number may move for it. AdoptStripeProduct::$adoptions is gone with it: nothing reads a singleton-wide total that cannot be attributed to one side or the other, and keeping it around unread would be exactly the kind of state this codebase does not leave lying about. The duplicate-product report had the same shape of bug one line down: it read straight off the singleton's list with no before/after snapshot, so a second handle() call in one process would reprint a duplicate an earlier run already named. Sliced to what this run itself added, the same way the counters beside it already were. Also: the unread `$charged` line in the adoption test that TDD had already exercised as dead weight, and its now-unused PlanPrices import. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0f1f397823 |
Design a host that installs itself and a platform that only takes it over
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
69a9322a4e |
Release v1.3.63
tests / pest (push) Failing after 8m36s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
5883989552 |
Give settings the shape a settings page actually has
Four attempts, and this is what was wrong with the last one: full-width panels stacked down the page, each carrying its own title INSIDE it. Every group had two frames — the title bar and the border — so the page read as a column of long boxes with nothing saying where one topic ended and the next began, and a phone number got a field a thousand pixels wide. The shape now: - **A section list on the left**, sticky, so the sections stay reachable however far a form runs. Below `lg` it becomes a scrolling strip of chips, because a vertical list on a telephone is four rows of nothing. Under it, who is signed in — on a shared office machine that is a real question, and a settings page is where it gets asked. - **A content column of 820px**, not the whole 1240 shell. - **The heading OUTSIDE the card it belongs to.** A section is a heading, a sentence, and then a card of rows. That single move is what stops them reading as boxes: the border now frames the fields, not the topic. - **What cannot be undone is last, in a card whose border says so** — the deletion deadlines and the close button together, at the foot of the contract section, in danger colours. The rows are unchanged (label left, control right) and so is every binding; what changed is the frame around them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
df7da334ee |
Say what was adopted, not that it was created
The count is taken before ensure() runs, so the sweep reported objects created in Stripe when it had made none — and after the next interrupted run, that line is the first thing a human reads. Telling the two apart is the whole point of the recognition step. Comparing the price id before and after the call does not distinguish them either: there is no id before, in either case. AdoptStripePrice counts its own adoptions instead, which is why it and its product sibling are now singletons — resolved per call, a counter on the instance would never pass one. Duplicate products are named in the report as well as the log. We deliberately do not deactivate them, so only a person can resolve one, and a person reads this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
880b5f1998 |
Merge main into the operating-mode branch
Three real conflicts, and one file that did not conflict and mattered more.
Overview::notices(): both sides added notices. Kept all of them, and pointed
main's mail check at MailboxTransport::NON_DELIVERING — its own comment already
named the constant while the code carried a copy of the list.
billing.blade.php: main wrapped the page in a contract branch. The "payment is
not set up" error moved OUTSIDE it, because the customer most likely to meet
that message is the one buying for the first time, who has no contract yet and
would never have seen it from inside the @else.
ConsoleReportsRealDataTest: kept both sides rather than choosing. The renamed
test and its docblock explain why admin.systems_ok can no longer be asserted on
a bare install; main's mail pin still keeps "clean" clean in the mail
dimension. Picking one would have quietly weakened the other's claim.
HostStepsTest: git combined main's config()->set('provisioning.dns.zone',
'clupilot.com') with this branch's assertion on clupilot.cloud, and produced a
test that contradicted itself with no marker. Main's approach is the better one
— it pins the zone in the test and asserts the SHAPE of the name rather than
this box's domain — so its assertion stands.
HttpStripeClient merged silently and correctly: secret() still throws,
isConfigured() still reads the vault directly. Had it taken main's
filled($this->secret()), six callers that ask in order NOT to get an exception
would have become exception throwers, and the suite would have stayed green.
CheckoutWithoutStripeKeyTest is the lock that would have caught it.
StripeIdempotencyKeyTest (new on main) leaned on the environment fallback for
stripe.secret. That entry is strict now — no fallback in either direction, so
the .env cannot be a back door for a live key in test mode. It stores a real
vault row instead.
1966 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
d6ec09a9b4 |
Recognise the product Stripe already has
The gap beside the guard. A run that creates a product and dies before storing its id leaves an orphan exactly as a price does, and past the key's twenty-four hours the next run makes a second one — after which activePricesFor() is asked about the wrong product, price recognition goes blind for the whole family, and every orphaned price on the first product is unreachable. Two things are unlike the price side, deliberately. No money gate: a product carries no amount, so its metadata is the whole of the proof. And a duplicate is reported, never deactivated — archiving a price is provably harmless because Stripe keeps billing subscriptions already on it, but deactivating a product makes its prices unsellable and contracts can be running on those. Silent about strangers, too. This asks for every active product on the account, so an operator selling something else through it is an ordinary state, not a finding worth a line on every run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
4b0eebeccc |
Release v1.3.62
tests / pest (push) Failing after 8m35s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
2a97d76b2d |
Rebuild the settings page out of panels and rows
**The withdrawal was invisible in two different ways.** It rendered only while the fourteen days were still running, so the moment they expired the whole block vanished — indistinguishable from a feature nobody built. And the account it was looked for on has no contract at all: no package, no subscription, nothing to withdraw from, so there was never anything for it to be about. It is now a row for every CONSUMER who has a contract, open or not: while it runs, the deadline and the button; afterwards, the sentence saying why it is over. A business never sees it — WithdrawalRight answers that, and Settings::withdraw() refuses them again on the server. And an account with no package says so and offers the packages, instead of a dead sentence in a box. **The page is built out of two pieces now.** x-ui.panel is a group: a card with a header that names it. x-ui.row is a setting inside it: what it is on the left, the control on the right, dividers between rows. That is the shape every settings page worth copying uses, and it is the shape that uses the width — a 280px label column with the control beside it fills the line, where a stack of full-width inputs leaves two thirds of it empty and a grid of cards of different heights walks down the screen as a staircase. All four tabs are rebuilt on it, so the page reads as one designed thing rather than four pages that happen to share a tab bar. Below `sm` the rows stack, because on a telephone a label belongs above its field. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
2d915be003 |
Put back the docblock space pint took on the way past
One character in a @return line of webhooks(), untouched by anything this branch does. It was only reformatted because the file happened to be on the pint path of the prefix-rule commit. A style fix to an unrelated line is noise in a diff a reviewer reads line by line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
786318b6d4 |
Never tell anyone to delete a catalogue their contracts bill on
The refusal added in the previous commit had a state it read wrongly, and
the wrong reading was destructive.
record() sat at the END of handle() and in no try/finally, while
createProduct() and ensure() throw uncaught. A run that died after the
first object left hasStoredObjects() true and recorded() null. The same
state arises with no failure at all: CheckoutController → PlanPrices::
ensure() and BookAddon → SyncStripeAddonItems → AddonPrices::ensure()
mint missing prices and never call record().
In that state the next run — in the SAME mode — refused with the foreign
account message and its "Clear plan_families.stripe_product_id,
plan_prices.stripe_price_id and the … registers first", and
billing.catalogue_synced blocked with the same text. The objects were
from the account in force. The right move was to resume, which is what
the idempotency keys exist for; instead an operator was handed a delete
instruction for a catalogue live contracts are billed on.
Two changes:
1. record() moves ahead of the creation loop, right behind the refusal.
There it is already proved that either nothing is stored or what is
stored belongs to the active account, so the moment carries the
claim just as well — and a run that dies part-way can no longer
leave a state that contradicts itself.
2. "Origin never recorded" gets its own sentence and its own cure,
separate from "established: other account".
StripeCatalogueMode::matchesActiveMode() becomes
belongsToAnotherMode(), which is only true where the other account
is fact. The check still blocks — the origin cannot be proved — but
it says "run the sync again", and it names no register to empty.
Red first:
⨯ it takes up a catalogue whose origin was never written down
Failed asserting that 1 matches expected 0.
⨯ it leaves no half-built catalogue that contradicts itself when a run dies
Failed asserting that null is identical to an object of class "App\Support\OperatingMode".
⨯ it tells an unrecorded origin apart from a foreign account
The two states are held apart by assertion, not by wording: only the
foreign-account sentence may name stripe_plan_prices, and the unrecorded
one must name stripe:sync-catalogue instead. The existing foreign-account
test keeps its teeth.
Full suite: 1817 passed, 6366 assertions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
f25a0030f0 |
Release v1.3.61
tests / pest (push) Failing after 8m45s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
18193a731d |
Stop asking for a signature the law does not want, and lay out the tab
**The owner is right about the agreement.** Art. 28 wants a CONTRACT, not a ceremony — and a contract is concluded by incorporating the agreement into the terms the customer accepts at checkout, which is exactly how every hoster they have bought from does it. Nothing in the regulation asks for a second, separate click. What it does ask is that the agreement is in writing (electronic form included, Art. 28(9)), that it is the version in force, and that the customer can obtain it. So: the terms now say the agreement is part of the contract and needs no separate signing, and name where it is. The card states that rather than flagging the customer as outstanding — the warning chip is gone. The button stays, reworded to "Zusätzlich bestätigen": a practice or a firm that gets audited often wants an explicit record, and it costs a click. The console's counter says how many confirmed rather than how many are "open", because none of them are. **The contract tab was a staircase** — a short card, a wide one spanning both columns, then a short one again. It is one column of full-width sections now, each with the same shape: a header row carrying the state and its action, then the body. Nothing tracks sideways any more. The package section states its own state in the header instead of a paragraph in the body, the withdrawal sits under it as a row rather than a second card, and the agreement's four files each have a button — reading and keeping, agreement and measures, which the single "Herunterladen" could not express. Also: the generated version no longer carries "Aus dem Repository erzeugt (clupilot:publish-dpa)" in the console's list. That is the command line, not information. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
fcea7ff3a8 |
Let the catalogue ask Stripe what products it already has
The price half of this question was answered; the product half was left open and named as a known gap. It is the more consequential one: a second product makes every activePricesFor() ask about the wrong one, so price recognition goes blind for that whole family and every orphan on the first product is unreachable. The whole active list comes back with no metadata filter, because Stripe cannot search metadata and an account holds a handful of products. Deciding which are ours belongs to the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
976637afb2 |
Release v1.3.60
tests / pest (push) Failing after 8m51s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
52aacddc56 |
Store the agreement where the web process can actually read it
Every link on the agreement page answered 404 — the download visibly, the two inline ones just as dead — and nothing anywhere said why. The files were there, whole and correct. An artisan run inside a container is root; PHP-FPM is www-data. Laravel's local disk creates a PRIVATE directory, which is 0700, so `dpa/` came out as root-only. The web process could not enter it, `Storage::exists()` answered false, and the routes did exactly what they were told: abort 404. No exception, no log line, a page full of links to nothing. Both writers — the command and the console's upload form — now store with "public" visibility, which is the file MODE and nothing to do with the web: 0755/0644 instead of 0700/0600. The disk's root is outside the document root either way, and the two routes still check who is asking. The command also warns if a file it just wrote is not readable, because the alternative is finding out from a customer. The download itself now looks like one: the `download` attribute on both anchors, and in the console a bordered control rather than a third link in a row of two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
4b99c6d5c6 |
Make the fake behave like Stripe where it matters
Three ways it did not. It replaced metadata where Stripe merges — and merging is the property AdoptStripePrice's "write only when it differs" comparison is built around. It handed metadata back uncast where the wire returns strings, so a test could pass on an int that production's strict === rejects forever. And a tie on `created` fell to insertion order, so two runs could adopt different prices. Paging now throws instead of stopping when Stripe says there is another page and the last item carries no id. invoiceLines() stops there and it costs a short document; here it costs an unseen orphan and a second live price for one figure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
26cf1a72a0 |
Remember which Stripe account the catalogue was built in
The mode switches the credentials. It does not switch what was made from them: a Price id and a Product id belong to the account that issued them, and test and live are two accounts. Every credential got two slots on this branch; the ids derived from them sit in single-valued columns. So the planned sequence of this installation ended in the exact false green this page exists to rule out — sync in test, store the live key, switch, and billing.catalogue_synced went on reporting satisfied because it only asked whether the column was filled. "Bereit für Livebetrieb", and the first real order got "No such price". Detection, not repair. Stripe does not put the account in the id — prod_… and price_… look the same in both, only KEYS carry _test_/_live_ — so the origin cannot be read back out of a stored id, and asking Stripe is out: this page reaches nothing over the network on a page load. What is left is to write it down at sync time, which is what App\Support\StripeCatalogueMode does. One setting for the whole catalogue is only honest because stripe:sync-catalogue now REFUSES a run into a catalogue that belongs to the other account. Without that, the run would skip every row that already carries an id, answer "already in step", and record an account it never touched — the same lie one level down. The registers count as stored objects too: inStep() takes a registered row as proof on its own for the reverse-charge half. The `breaks` sentence says what happens (checkout fails, no order) and what actually helps, including the awkward half: re-running the sync is not enough, the pointers and both registers have to be cleared first. The slot migration backfills the one case it can prove: whatever is at Stripe was made with the one key this installation has ever stored, so it belongs to the account that key opens. Otherwise a long-synced catalogue would read as "origin unknown" and the page would demand a re-sync nobody needs. Red first: ⨯ it does not call the catalogue synced when its ids belong to the other account ⨯ it says the sale is refused and a fresh sync is needed, not that a column is empty ⨯ it records the mode its objects were created in ⨯ it refuses to work into a catalogue that belongs to the other account ⨯ it syncs into the new account once the stale ids are cleared Full suite: 1814 passed, 6357 assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |