Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

deploy/upcloud/src/lib.rs

//! **Re-image the live gunnar appliance on UpCloud**, from an installer ISO on
//! this disk to a box that answers `SSH-2.0-gunnar_…` on port 2222 — over the
//! UpCloud API 1.3, in Rust, with no shell anywhere on the path.
//!
//! The door is `cargo xtask reinstall-appliance`; see `xtask/src/reinstall.rs`
//! for why the verb spawns this crate instead of doing the work itself.
//!
//! # ⚠ THE VIRTIO TRAP — THE MOST EXPENSIVE FACT IN THIS FILE
//!
//! **The same ISO has to be uploaded TWICE and attached TWICE: once as a
//! `cdrom`, once as a virtio `disk`.** One of them is not enough, and the half
//! that is missing is not the one an operator expects.
//!
//! `korp-installer` runs as PID 1 and probes **only virtio block devices** for
//! the ISO-9660 `CD001` magic to find its own payload. It never looks at an IDE
//! cdrom. The appliance kernel is the `nuked` preset and has no ATA/AHCI/SCSI
//! at all, so `sr0` does not exist for it to look at even if it wanted to.
//!
//! Attached ONLY as a cdrom, the machine boots the kernel perfectly — firmware
//! is happy, the kernel is happy, PID 1 starts — and then dies with:
//!
//! ```text
//! INSTALLER: volume: no block device carries the ISO-9660 magic (CD001) ...
//!            (probed: vda2, vdd, vdb, vde1, ...)
//! INSTALLER: FAILED payload: /run/installer/BOOTX64.EFI: No such file or directory
//! ```
//!
//! So the medium rides **twice**: the cdrom is what the SeaBIOS firmware boots
//! (UpCloud is SeaBIOS-only), and the virtio disk is what the installer reads
//! the payload out of. That is why the account carries both
//! `gunnar-installer-open-cdrom` **and** `gunnar-installer-open-virtio`, and why
//! [`plan::Plan`] creates two storages from one file.
//!
//! This is not an UpCloud quirk. It is the same fact `draupnir 519afcf` added
//! `MediumAttach::{Cdrom, Virtio, Both}` for, that `installtest/Cargo.toml`
//! records as MEASURED 2026-08-15 with the identical `CD001` line, and that
//! `installtest/src/mother_kvm.rs` states as "the medium rides as cdrom AND
//! read-only virtio, the way every harness install does". Three harnesses knew;
//! the cloud path had to learn it again, by hand, at the cost of an hour.
//!
//! # …AND IT IS DERIVED NOW, NOT REMEMBERED
//!
//! The paragraph above is history. [`plan::reinstall`] no longer *knows* that
//! the ISO rides twice: it asks [`machine`], which reads the target-preset
//! roster ([`nornir_build_thing::target::presets`]) and answers from the
//! platform's own `MediumBring` word. `upcloud` says `IsoCdrom`, so two faces.
//!
//! The half that could not be got any other way: `scaleway-instance` presents
//! the IDENTICAL device set — virtio-blk + virtio-net — and takes a qcow2
//! snapshot import, so it has no ISO-attach door at all. Pointed at it, this
//! verb is now REFUSED BY NAME before the first API call, where before it would
//! have uploaded two storages and stopped a live appliance first. No check on
//! the SHAPE could have told those two machines apart; only the name can.
//!
//! # THE MEDIUM RIDES TWICE — BUT IT DOES NOT WAIT TWICE
//!
//! Two faces means two storages, and each one has to be `online` before it can
//! be attached. That was written as two calls to one function that ended in its
//! own wait, so the two waits were SERIAL. MEASURED on the live estate
//! 2026-09-20, on the re-image the owner was watching:
//!
//! ```text
//! POST /storage                    a 1 GiB seed
//! POST /storage/{uuid}/import      direct_upload, an uploader session URL
//! PUT  …/uploader/session/<uuid>   created 09:36:16Z, completed 09:36:21Z    5 s
//! GET  /storage/{uuid}             "syncing" at t+65 s … "syncing" at t+97 s
//!                                  "online" at ~t+110 s
//! ```
//!
//! …and then all of it again, for the SAME 43 485 184 bytes and the same
//! `sha256 d5b627cc…c99921`. About 220 s of every bring-up, for no work.
//!
//! **The transfer was never the cost.** The import object and the storage keep
//! two different clocks, which is why this hid for so long: the import said
//! `completed` five seconds in, while the storage was still `syncing` half a
//! minute later. Whoever read the upload log saw a 5 s upload and looked
//! elsewhere.
//!
//! UpCloud provisions the two volumes IN PARALLEL. Only this crate was serial.
//! So [`plan::reinstall`] now STARTS both media — create, import, upload,
//! verify, per face — and then waits for both in one loop
//! (`plan::wait_media_online`). Two × ~110 s becomes ~110 s, measured in clock
//! time by `tests/reinstall_flow.rs::the_two_syncs_overlap_and_the_saving_is_one_whole_sync`,
//! which is RED against the old serial shape.
//!
//! Nothing about the guarantee moved: each medium's `sha256sum` is still
//! compared against the local ISO at the moment it is uploaded, per face,
//! before anything is waited for or attached — see [`upload::verify_upload`].
//! What overlapped is the WAITING, never the proving.
//!
//! ## AND NO, THE SECOND COPY SHOULD NOT BE A CLONE — MEASURED, NOT ARGUED
//!
//! The obvious next move is to upload once and `POST /storage/{uuid}/clone` the
//! second face. MEASURED 2026-09-20 on the live account, one throwaway volume:
//! a clone reaches `online` in **47 s** and never passes through `syncing` at
//! all, against **114 s** for importing the same 43 485 184 bytes. The clone is
//! less than half the cost. Every instinct says take it.
//!
//! Take it and the re-image gets ~46 s SLOWER. A clone cannot start until its
//! source is `online`, so the clone road is serial by construction — 114 + 47 =
//! 161 s — while two imports that depend on nothing run together for ~115 s.
//! The trap is that each individual step of the clone road is faster than the
//! step it replaces, so it reads as an optimisation right up until the wall
//! clock disagrees.
//!
//! [`api::Client::clone_storage`] is therefore a door with a measurement
//! attached and nothing built on it. `tests/clone_probe.rs` is the measurement,
//! and it exists so this does not get re-opened from the same plausible
//! reasoning a third time.
//!
//! # ⚠ AND THE OTHER HALF OF IT: DETACH BOTH
//!
//! After the install, **both** devices come off before the box is booted from
//! disk. Leave either attached with a `cdrom` boot order and the appliance
//! **reinstalls itself on its next boot** — silently, destroying whatever the
//! tenant has pushed since. [`plan::detach_plan`] refuses to detach one without
//! the other; that refusal is a test, not a comment.
//!
//! # The procedure, as executed
//!
//! Every step below was run by hand against the live box on 2026-09-07 and
//! worked. This crate is that record turned into a mechanism.
//!
//! 1. `POST /storage` — a 1 GiB seed storage per copy. Two of them.
//! 2. `POST /storage/{uuid}/import` `{"source":"direct_upload"}` → an upload URL.
//! 3. `PUT` the ISO bytes at that URL. The reply carries `written_bytes` and
//!    `sha256sum`; **both are checked against the local file** and a mismatch is
//!    a refusal, not a warning ([`upload::verify_upload`]).
//! 4. `GET /storage/{uuid}` until `state == "online"` (it passes through
//!    `maintenance`).
//! 5. `POST /server/{uuid}/stop` soft, then poll until `stopped`.
//! 6. `POST /server/{uuid}/storage/attach` twice — `cdrom`, then `disk`.
//! 7. `PUT /server/{uuid}` `{"boot_order":"cdrom"}`, `POST …/start`.
//! 8. Wait for the install. The console prints `INSTALL-OK ■ rebooting to
//!    installed korp`; nothing here can read a framebuffer, so the wait is the
//!    SSH banner appearing on 2222 within a declared budget. **That budget
//!    expiring is not a verdict** — this box has come up past it and served
//!    perfectly — so it is carried to step 10 and reported there as `late, N s`
//!    against the banner that did arrive. See [`plan::InstallWait`] for the
//!    contradiction that cost an operator a re-run, and `--strict-install-wait`
//!    for the setting that refuses instead.
//! 9. Hard stop, detach BOTH, `{"boot_order":"disk"}`, start.
//! 10. Read the SSH banner off the box and print the version it actually
//!     carries. **The API is not evidence** — an appliance that never came back
//!     leaves every one of the calls above at `200 OK`.
//! 11. Restart the FRONT's three services over SSH, and read back that each one
//!     is `active` and answering ([`front`]). A re-image replaces the box those
//!     services hold gRPC channels to, and a channel to a dead peer is still a
//!     channel: MEASURED on 2026-09-08, `/login/repos` answered `[]` for a
//!     public repository until `gunnar-ui-server` was restarted by hand. The
//!     re-image is not finished when the banner is read — it is finished when
//!     the front is serving the NEW appliance.
//!
//! # What is testable without credentials, and how
//!
//! Everything except the bytes on the wire. Every UpCloud call goes through
//! `upcloud_api::UpCloudApi` (edda), the constellation's ONE UpCloud surface,
//! so the procedure is generic over WHICH cloud answers: the account, a
//! `mock-upcloud` reached with `--mock-api` over the very same wire code, or a
//! scripted world in `tests/` whose recorded `(method, path)` sequence IS the
//! assertion about the state machine. [`wait::Clock`] and
//! [`banner::BannerProbe`] are traits for the same reason: a timeout is proved
//! by a fake clock in microseconds, and "no banner ⇒ FAIL" is proved by a probe
//! that never answers.

pub mod api;
pub mod banner;
pub mod console;
pub mod front;
pub mod kept;
pub mod machine;
pub mod medium;
pub mod plan;
pub mod site;
pub mod token;
pub mod upload;
pub mod vnc;
pub mod wait;