Skip to content

feat(baremetal): make the storage block of a reinstall discoverable - #250

Draft
Denis-hamon wants to merge 20 commits into
ovh:feat/vrack-attachfrom
Denis-hamon:feat/baremetal-install-discovery
Draft

feat(baremetal): make the storage block of a reinstall discoverable#250
Denis-hamon wants to merge 20 commits into
ovh:feat/vrack-attachfrom
Denis-hamon:feat/baremetal-install-discovery

Conversation

@Denis-hamon

Copy link
Copy Markdown
Contributor

Stacked on #249only the last commit (201de18) belongs to this PR.

Partitioning was not blocked

The audit this work follows lists partitioning as the costliest case of "the CLI exposes only the fields mapped to flags". Measured against the live API, that premise is wrong: --from-file carries the whole storage block — hardware RAID, layout, LVM and ZFS extras — untouched into the request body. --dry-run shows it going out verbatim. And the sample this CLI ships already contains a full partitioning layout.

So this adds no partitioning flags. storage is an array of objects each holding an array of partitions with five fields; expressing that as flags is how a CLI becomes unusable. The file is the right instrument and it already works.

What was missing is every way of finding out what to put in it. On a reinstall, finding out at install time means the disks are already wiped.

ovhcloud baremetal list-partition-schemes <server> --os <template>
ovhcloud baremetal raid-profile <server>

Two commands, and one deliberately absent

list-partition-schemes is asked per template, because that is what the answer depends on — hence --os is required rather than silently defaulted.

raid-profile answers the question you must answer before writing a hardwareRaid block. All fourteen servers checked on a real account have no controller, and the API says so with a 403:

$ ovhcloud baremetal raid-profile ns30704xx…
ns30704xx… has no hardware RAID controller.
Use software RAID instead: set `raidLevel` on the partitions of the storage layout.

That is an answer, not a failure, so it is reported as one — while any other error stays an error, and there is a test for each.

There is no command for sizing a RAID configuration, on purpose. install/hardwareRaidSize is in the embedded schema and badged "Stable production version". It does not exist:

request result
hardwareRaidSize with both required params 404 Got an invalid (or empty) URL
… with one param, or none 404, same
… on 4 different servers, v1 and v2 404, same
hardwareRaidProfile, same servers (control) 403 with a business message

A routing 404 next to a sibling that resolves means the route is not served. A command for it would have failed every single time it ran. Worth reporting to whoever owns that schema.

The discovery path itself was broken

--init-file is how somebody writing a script obtains the example that documents the request body. It always opened a picker, which needs a terminal — so in exactly the scripted context it serves it failed with could not open a new TTY and wrote nothing.

Outside a terminal it now writes one example and says which: the "default" one this CLI ships, since for a reinstall that is the only one carrying a storage block; otherwise the first by name, sorted, because the examples are a map and ranging over it would write a different file on each run.

The whole loop now runs unattended:

baremetal list-compatible-os        → which templates
baremetal list-partition-schemes    → which schemes for that template
baremetal raid-profile              → hardware RAID, or software RAID instead
baremetal reinstall --init-file     → the skeleton, storage block included
baremetal reinstall --from-file --dry-run → exactly what would be sent

Two findings worth carrying beyond this PR

  • --init-file runs in a PreRun ending with os.Exit(0), and that exit is load-bearing: without it the command would continue into Run, so --init-file on a reinstall would reinstall the server. It is not a testability wart to remove.
  • display.OutputWarning terminates the process — it sets Warning, which reaches ExitFunc(0). The first version of this fix printed its message and never wrote the file. Anything after an OutputWarning is dead code, the same way it is after OutputError.

Seven tests. Four sabotages, four failures; two more did not compile and were discarded rather than counted.

doc/ovhcloud_logout.md remains untracked and unseen by the doc gate — git diff --exit-code does not look at untracked files. Not this change's business, but the hole is still there.

🤖 Generated with Claude Code

Denis-hamon and others added 3 commits August 18, 2026 17:02
…to change

`vps service-info edit myvps --renew-period 12` used to send this:

    {"renew":{"automatic":false,"deleteAtExpiration":false,"forced":false,
              "manualPayment":false,"period":12}}

The renewal settings are booleans bound to a shared struct carrying no
`omitempty`, so every one of them was marshalled at its zero value and won the
merge against the fetched resource. Changing the renewal period therefore also
switched automatic renewal off — on a service that had been renewing itself for
years, without a word in the output saying so.

Webhosting already built its payload from `cmd.Flags().Changed`, and did not
have the defect. This promotes that builder into `common`, so `vps` gets it too
and the next `service-info edit` cannot reintroduce the bug by reaching for the
struct.

Reading `Changed` rather than the values keeps `--renew-automatic=false`
working: pflag records a flag as changed whatever value it was given, so an
explicit false is still sent while an absent flag stays absent. Both cases are
covered by a test, and each test was checked against the failure it exists to
catch.

The shared mutable `ServiceInfoSpec` goes away with the last thing that read
it, and the five flag registrations repeated across four commands become one
call, which also settles the two spellings of the period's help text.

Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
…ayer

Declaring cobra flags is the command layer's job, and internal/services/common
was the only service package doing it — the shared flag helpers all live in
internal/cmd. Raised in review of this PR.

The registration moves; the table does not. Both halves need the flag name —
one to declare it, the other to read whether the operator set it — and that
name is the only thing tying them together. Splitting it into two copies would
mean a rename could touch one side and leave the other silently no longer
sending a setting, which is the exact failure this PR exists to fix. So
common.ServiceInfoRenewFlags becomes the exported description, internal/cmd
registers from it, and the payload builder keeps reading it.

Checked by renaming an entry in that table and watching the service-info tests
fall: the two halves still move together.

Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Partitioning was reported as unreachable from this CLI. It is not: `--from-file`
carries the whole `storage` block — hardware RAID, layout, LVM and ZFS extras —
untouched into the request body, verified with `--dry-run` against the live API,
and the sample this CLI ships already shows a full layout. So this adds no
partitioning flags. `storage` is an array of objects each holding an array of
partitions with five fields; expressing that as command-line flags is how a CLI
becomes unusable, and the file already works.

What was missing is every way of finding out what to put in it.

  ovhcloud baremetal list-partition-schemes <server> --os <template>
  ovhcloud baremetal raid-profile <server>

The first is asked per template, because that is what the answer depends on.
The second answers a question worth asking before writing a `hardwareRaid`
block: every one of the fourteen servers checked on a real account has no
controller, and the API says so with a 403. That is an answer, not a failure —
it means software RAID in the partitioning layout — so it is reported as one,
while any other error stays an error.

There is no command for sizing a RAID configuration. `install/hardwareRaidSize`
is in the embedded schema, badged "Stable production version", and does not
exist: it answers 404 "Got an invalid (or empty) URL" — a routing error, not a
business one — on four different servers, in v1 and v2, with and without its
parameters, while `install/hardwareRaidProfile` resolves on those same servers.
A command for it would have failed every time it was run. Worth reporting to
whoever owns that schema.

And the discovery path itself was broken. `--init-file` always opened a picker,
which needs a terminal, so the one way to obtain the example that documents the
`storage` shape failed with "could not open a new TTY" in exactly the scripted
context it serves, and wrote nothing. Outside a terminal it now writes one
example and says which: the "default" one this CLI ships, since for a reinstall
that is the only one carrying a storage block, and otherwise the first by name —
sorted, because the examples are a map and ranging over it would produce a
different file on each run.

Two things about that fix are worth knowing beyond this change. `--init-file`
runs in a PreRun that ends with `os.Exit(0)`, and that exit is load-bearing:
without it the command would go on to run, so `--init-file` on a reinstall would
reinstall the server. And `display.OutputWarning` terminates the process — it
sets Warning, which reaches ExitFunc(0) — so the first version of this printed
its message and never wrote the file. It uses `log` now.

Seven tests. Four sabotages, four failures; two more did not compile and were
discarded rather than counted.

Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Denis-hamon and others added 4 commits August 20, 2026 16:14
A hostname, an address, an order id and a colleague's name read off a live
account had been used as fixtures and as examples in comments. This repository
is public: what goes in stays in. The values are replaced with synthetic ones
of the same shape — RFC 5737 documentation addresses, hostnames built on them,
identifiers of the same length — so the tests keep exercising the same parsing.

This is the fourth time in this series that live account data reached a commit,
after a live IP migration token, a test server's address, and a real invoice
id. The first three were fixed one at a time as they were noticed; this is the
sweep that should have followed the first one.

Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
The first sweep replaced only the exact spelling. A test that checks a name can
be copied back without reproducing its case carried the uppercase form of a
real hostname, which therefore survived — and, once its neighbour was
replaced, made that test fail. A real value does not stop being one because one
of its spellings was removed.

Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
@Denis-hamon
Denis-hamon changed the base branch from main to feat/vrack-attach August 20, 2026 15:18
Denis-hamon and others added 9 commits August 20, 2026 22:42
`list-partition-schemes` and `raid-profile` both register --filter through
withFilterFlag, and both then called display.RenderTable directly. RenderTable
does not filter: withFilterFlag only binds the flag to flags.GenericFilters,
and ManageListRequest is what normally passes it to filters.FilterLines. These
two build their rows themselves, so nothing ever read the flag. It was
accepted by cobra, written into doc/ by docgen, and inert — the operator reads
a list they believe was narrowed.

An audit of every withFilterFlag call site in the tree found the same shape on
twenty-one commands this series added, so the three lines are extracted rather
than copied again: common.RenderFilteredTable filters, then renders. The
`internal/services/ip` package already carried a private copy of exactly these
lines; it is left alone, since it does the right thing, but the shared one is
where new commands should reach.

The tests assert the row the filter EXCLUDES is absent, not merely that the
kept row is present — the second passes just as well with the filtering
removed. Both go red when the helper is made to ignore the flag.

Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
Same behaviour, one function more. It exists for the callers that do not render
a table: a few wrap their list in an object and hand it to a template, and there
the filter has to run before the wrapping — once the list is inside an object
there are no rows left to select from.

None of those callers are on this branch. The split is here so that this helper
is byte-identical to the one the same defect required on `main`, where twenty
commands of cloud, vps and webhosting had the same inert flag and two of them
are exactly that template-wrapping shape. The two patches touch the same lines
of the same file; identical bodies make the merge a no-op instead of a conflict
someone has to arbitrate.

Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
capacity and speed are complexType.UnitAndValue in the schema, not scalars, and
they were declared `any`. So they reached the table as the decoded map and
rendered as "map[unit:GB value:1000]", truncated to the cell width — on every
server that actually has a hardware RAID controller, which is none of the
fourteen measured. That is why a wrong column shipped unseen.

Both shapes are accepted, and the reason is that the object shape cannot be
confirmed from here: hardwareRaidProfile answers 403 on all 35 servers of the
account, so nobody has seen a populated response. The schema says object, the
fixture this test file already carried assumed a bare string. Decoding into a
struct alone would have turned an ugly cell into a broken command if the API
sends the scalar; a small UnmarshalJSON takes either and cannot be wrong. An
absent quantity renders empty rather than as a zero the API never sent.

speed gains a column while it is here: it was read, discarded, and would have
rendered the same way.

Two tests. The first sabotage was inert and said so — replacing .String() with
the value changes nothing, because the type implements Stringer, which is the
proof that the type is the fix. Reverting the fields to `any` turns both red.

Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
ovh#243 moved the Cobra registration of the renewal flags out of
internal/services/common and into the command layer, and exported the
descriptor table so both halves keep reading one list. That commit landed after
this branch had already taken its parent, so this branch still carried the
version where a service package declares flags — and being the more recent side
of the merge, it would have won and quietly undone the refactor.

Merged rather than rebased: nothing is rewritten, so the review threads on this
PR stay attached to their lines.

Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
@Denis-hamon

Copy link
Copy Markdown
Contributor Author

Human review — Doubt

C5 · Finding a partition scheme without reading the docs
Dimension: Discoverability

Expected: The sequence “which schemes does this template accept” then “write me an example” is guessable. Outside a terminal, --init-file writes a file and says which.

┌─────────┬─────────────┐
│ name │ template │
├─────────┼─────────────┤
│ default │ debian12_64 │
└─────────┴─────────────┘
exit 0

— Denis (Product Manager), through CLI sandbox review page

@Denis-hamon

Copy link
Copy Markdown
Contributor Author

Answering the doubt on C5.

The single row is genuine, not a truncated list: that server accepts exactly one partition scheme for debian12_64, and the API returns one. The check is about the sequence being guessable — "which schemes does this template accept", then "write me an example" — rather than about the number of rows, so a one-row answer does not weaken it.

One thing did change underneath, in #241: an empty or near-empty table used to print its headers with no padding at all, because only data cells carried it. That is fixed, so a short table now reads the same as a long one.

@Denis-hamon

Copy link
Copy Markdown
Contributor Author

Human review — Doubt

C5 · Finding a partition scheme without reading the docs
Dimension: Discoverability

Expected: The sequence “which schemes does this template accept” then “write me an example” is guessable. Outside a terminal, --init-file writes a file and says which.

┌─────────┬─────────────┐
│ name │ template │
├─────────┼─────────────┤
│ default │ debian12_64 │
└─────────┴─────────────┘
exit 0

— Denis (Product Manager), through CLI sandbox review page

A --dry-run already carries the parameters in its message, and a
log.Println sitting just above the branch repeated the same JSON behind
a Go timestamp no other command in this CLI emits:

    🔍 Dry run: nothing was sent. This would have been posted to …
    { "operatingSystem": "debian12_64" }
    2026/08/23 23:47:22 Final parameters:
    { "operatingSystem": "debian12_64" }

The log line moves below the branch. A real run still logs what it is
about to send, which is what it was for; a dry run logs nothing, because
it sends nothing.

The line goes to stderr, so no assertion on stdout could ever have seen
it — which is why it survived every green run. Two tests now redirect the
logger: one that a dry run does not log, and its positive control that a
real run still does, so deleting the line outright would not pass.

Signed-off-by: Denis <denis.hamon@ovhcloud.com>
Mechanical, not a change of behaviour. Placing them just after
TestBaremetalReinstallDryRun put them in the one region of
baremetal_test.go that 23 downstream branches also append to, and in the
import block they all touch as well. Merging the parent into those
branches failed 23 times out of 23, every one of them on adjacency
rather than on a disagreement.

baremetal_test.go goes back to what it was before the previous commit,
so this branch now leaves that file untouched. A new file can only clash
with a file of the same name, and nothing else carries this one.

Signed-off-by: Denis <denis.hamon@ovhcloud.com>
Signed-off-by: Denis <denis.hamon@ovhcloud.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant