Skip to content

feat(baremetal): add power off/on/status, and say what powering off leaves behind - #248

Draft
Denis-hamon wants to merge 14 commits into
ovh:feat/baremetal-catalogfrom
Denis-hamon:feat/baremetal-power
Draft

feat(baremetal): add power off/on/status, and say what powering off leaves behind#248
Denis-hamon wants to merge 14 commits into
ovh:feat/baremetal-catalogfrom
Denis-hamon:feat/baremetal-power

Conversation

@Denis-hamon

Copy link
Copy Markdown
Contributor

Stacked on #245only the last commit (7e9fdfd) belongs to this PR; the three below it are the catalog work under review there.

The API has no power endpoint. It has the verb anyway.

There is no /power, no /shutdown, no /poweroff in the 126 paths of dedicated.server, which is why "power a server off" reads as impossible from the CLI. It is spelled differently: dedicated.server.BootTypeEnum carries power as a first-class boot type, all 35 servers reachable from my account have an entry of that type (95083 in Europe, 95644 in Canada), and bootId is writable.

PUT  /dedicated/server/{n}  {"bootId": 95083}
POST /dedicated/server/{n}/reboot

The machine reboots into an entry whose only job is to shut it down.

ovhcloud baremetal power off <server> [--wait] [--timeout 10m] [--yes | --dry-run]
ovhcloud baremetal power on  <server> [--wait] [--timeout 10m] [--boot N] [--dry-run]
ovhcloud baremetal power status <server>

Measured on real hardware, twice

First with a raw script, then through the CLI itself, on a spare RISE-2 with no OS installed. Starting state read before anything was touched, and returned to it:

$ ovhcloud baremetal power off ns30704xx… --yes --wait
✅ ns30704xx… is off.                                              (2 min 15 s)

$ ovhcloud baremetal power status ns30704xx…
ns30704xx… is poweroff, set to boot on "Power-off server" (bootId 95083)
⚠️  This server is set to power off: any reboot, including one from the
    manager, will shut it down again.

$ ovhcloud baremetal power on ns30704xx… --wait
✅ ns30704xx… is on, booting on 230242 (the boot it was on before it
   was powered off).                                               (4 min)

Three measurements that shaped the code

A done task does not mean the machine obeyed. The hardReboot task reported done at 60 s; powerState only flipped at t+143 s, and at t+207 s on the way back up. So --wait polls powerState and never the task. Waiting on the task would have shipped a command that reports success while the server is still running.

Powering off rewrites the boot configuration, durably. The server stays on the power-off entry and shuts down again at every later reboot — including one triggered from the manager, with nothing on screen explaining why. Hence: power off records the previous boot, power on puts it back, and power status warns whenever a server sits on a power-off entry.

The previous boot is not guessable. My test server was in rescue (230242), not on disk — I had assumed disk, and restoring the assumption would have put the machine in a state it had never been in. The value is read before it is changed; when this machine has no record of it (a power on run from a different laptop than the power off), the command falls back to the disk entry and says so in the message rather than presenting a guess as a memory.

One defect this surfaced in internal/cache

cache.Write calls PurgeExpired(namespace, ttl) with the ttl it is handed, so writing an empty entry with a zero ttl deletes every other entry in the namespace. Forgetting one server therefore forgot where all the others boot — while they were still powered off, which is exactly when that memory is the only thing between them and a boot they were never on. Fixed with a cache.Remove, with a test for the two-server case.

This is worth knowing beyond this PR: cache.Write(ns, key, nil, 0) is not a way to delete an entry.

Notes

  • power on takes --dry-run but no --yes: it interrupts nothing, so there is no prompt to skip. Caught by reading the generated docs, not the code.
  • Ten tests, on responses recorded from the live API. Each was sabotaged and watched fail before being kept.
  • doc/ovhcloud_logout.md is generated by docgen and has never been committed on main; it is untracked, so git diff --exit-code in the doc gate does not see it. Left out of this PR — it is not this change's business, but the gate has a hole.

🤖 Generated with Claude Code

Denis-hamon and others added 2 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>
…behind

The API has no power endpoint. There is no `/power`, no `/shutdown`, no
`/poweroff` anywhere in the 126 paths of `dedicated.server`, and a first pass
over that list concluded the CLI could not offer the verb at all.

The verb is there, spelled differently. `dedicated.server.BootTypeEnum` carries
`power` as a first-class boot type, every one of the 35 servers reachable from
this account has an entry of that type — 95083 in Europe, 95644 in Canada — and
`bootId` is writable. Powering a server off is `PUT {bootId: <power entry>}`
followed by `POST /reboot`: the machine reboots into an entry whose whole job is
to shut it down. Measured end to end on ns3070493, twice.

Three things came out of that measurement, and each one is in the code.

A completed task does not mean the machine obeyed. The `hardReboot` task
reported `done` at sixty seconds; `powerState` only flipped at t+143s, and on
the way back up at t+207s. So `--wait` polls `powerState` and never the task
status. Waiting on the task would have produced a command that returns success
while the server is still running — a gate proving the call was made, not that
anything happened.

Powering off rewrites the boot configuration, durably. The server stays on the
power-off entry and shuts down again at every later reboot, including one
triggered from the manager, with nothing on screen to explain why. `power off`
therefore records the boot the server was on before touching anything, `power
on` puts it back, and `power status` warns whenever a server is sitting on a
power-off entry.

The boot a server was on is not guessable. ns3070493 was in rescue (230242), not
on disk — I had assumed disk, and assuming it would have "restored" the machine
into a state it had never been in. So the value is read before it is changed,
and when this machine has no record of it — a `power on` run from a different
laptop than the `power off` — the command falls back to the disk entry and says
so in the message rather than presenting a guess as a memory.

Two details worth their lines. `forgetBoot` deletes its cache entry through a
new `cache.Remove` instead of writing an empty one, because `cache.Write`
purges the namespace against the ttl it is handed: forgetting one server with a
zero ttl took every other remembered server with it, so powering server A back
on made the CLI forget where B boots — while B was still off. There is a test
for exactly that. And `power on` takes `--dry-run` but no `--yes`: it interrupts
nothing, so there is no prompt to skip, and a flag that skips nothing is one
more thing to learn that means nothing.

Ten tests, on responses recorded from the live API. Each was sabotaged and
watched fail before being kept.

Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.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>
@Denis-hamon
Denis-hamon marked this pull request as draft August 20, 2026 13:09
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>
@Denis-hamon
Denis-hamon changed the base branch from main to feat/baremetal-catalog August 20, 2026 15:18
Denis-hamon and others added 4 commits August 21, 2026 01:01
`power status` answers about one server and renders one object through
OutputInfo. withFilterFlag binds --filter to flags.GenericFilters and nothing
more, so on this command the flag was accepted by cobra, documented by docgen,
and inert — the operator could believe the answer had been narrowed when
nothing had looked at it.

There are no rows here to filter, so the flag is removed rather than
implemented. A test pins the refusal: a flag that can only be ignored is worse
than an absent one.

Found by a review pass over this branch, then confirmed by an audit of every
withFilterFlag call site in the tree.

Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`power off` remembers the boot entry the server was on, because the PUT that
follows overwrites it and only `power on` knows that value mattered. It recorded
whatever the API answered at that moment — including the power-off entry itself.

That is not a race, it is the ordinary case. The PUT lands at once and the
machine only goes dark about 143 seconds later, measured on 18 August 2026, and
for that whole window the API reports powerState "poweron" sitting on the
power-off bootId. Anyone typing `power off` twice, because the first one seemed
to do nothing, replaced the record with the entry that shuts the machine down.
`power on` then restored a power-off and announced it as "the boot it was on
before it was powered off". The real entry — a rescue system, in the case that
found this — was gone, and nothing on screen said so.

The record is now left alone when the server is already on the power-off entry,
and previousBootId reports what is actually known rather than the entry that was
just written. It is absent rather than zero when nothing is known, so a caller
reading -o json can tell the two apart.

Two tests, both red under a sabotage that restores the unconditional write.

Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`power on` puts the boot configuration back so that a later reboot does not shut
the machine down again. It sent that PUT unconditionally, without ever looking at
the bootId the server currently reports — so on a machine already sitting on the
target entry it wrote a value that was already there, and then announced "its
boot is now N", which was untrue: the boot had not moved.

`power off`, three functions above, already compares state.BootID against the
entry it is about to use before deciding what to record. Not doing it here was an
omission, not a difference between the two commands.

When neither the boot nor the power state needs changing, the command now says so
and sends nothing. Dropping the record of the previous boot is itself a change,
so --dry-run does not do it either: a preview that quietly forgets which boot the
machine came from has the one property a preview must not have.

Three tests, three sabotages, three reds — and the inverted-comparison sabotage
also fails two tests that were already there.

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 marked this pull request as ready for review August 21, 2026 12:50
@Denis-hamon
Denis-hamon marked this pull request as draft August 21, 2026 15:01
@Denis-hamon

Copy link
Copy Markdown
Contributor Author

Human review — Does not work

B2 · Switching off a production machine
Dimension: Guard rail

Expected: A confirmation prompt, the machine named, and it is stated that the machine will not come back on its own.

The output :
🛑 failed to read the state of ns0000001.ip-203-0-113.eu: OVHcloud API error (status code 401): Client::Unauthorized: "You must login first" (X-OVH-Query-Id: EU.ext-3.6a8975b6.1312250.fc780d3974b76dec0eb4cbece12b1ac3)

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

@Denis-hamon

Copy link
Copy Markdown
Contributor Author

Human review — Does not work

B4 · Nothing to do: the command says so instead of acting
Dimension: Product fit

Expected: No write call is sent, and the command says “nothing to do” while naming the entry.

This command can write, so live mode will not run it as is.

Add --dry-run: the command then describes the calls it would make,
without sending any. Everything before the write is still exercised --
the flags, the validation, the guard, the message.

baremetal power on ns0000002.ip-203-0-113.eu --dry-run

If the command has no --dry-run, it will say so itself.

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

@Denis-hamon

Copy link
Copy Markdown
Contributor Author

Followed up on today's two "Does not work" findings from the human review (B2, B4) — both trace back to the same cause, not to this PR's code.

B2 (power off, 401 "You must login first" while reading the machine's state) and the sandbox's own account get were failing identically at review time: the sandbox's OVH_ACCESS_TOKEN had died (it carries no exp claim, but a previous one lasted ~27h — this one was already past that). Confirmed by decoding it: 26.6h old at the moment of the 401.

B4 (power on --dry-run, message "This command can write... Add --dry-run") is the sandbox's own generic guard (freemode.py), shown for any write command run without --dry-run — it's templated boilerplate, not something this binary prints.

Token refreshed today. Rebuilt this branch and replayed both live against the test/integration account, on ns3118333.ip-51-68-100.eu (the machine Denis cleared for unrestricted testing):

$ ovhcloud baremetal power off ns3118333.ip-51-68-100.eu --dry-run
🔍 Dry run: nothing was sent. This would have been called:
  PUT /v1/dedicated/server/ns3118333.ip-51-68-100.eu
  POST /v1/dedicated/server/ns3118333.ip-51-68-100.eu/reboot

$ ovhcloud baremetal power on ns3118333.ip-51-68-100.eu --dry-run
ns3118333.ip-51-68-100.eu is already running on boot 1 (the disk, because this
machine has no record of the previous boot). Nothing to do.

Both work as intended — B4 in particular is exactly the "nothing to do, and the entry is named" behaviour the check asked for. No code change needed here.

@Denis-hamon

Copy link
Copy Markdown
Contributor Author

Human review — Does not work

B3 · Powering on restores the previous boot entry
Dimension: Product fit

Expected: The behaviour — “I hand the machine back the way you left it” — is what you expect. A defect found here: the remembered entry was overwritten by the power-off entry, so power on restored a “power off”.

🛑 failed to read the state of ns0000001.ip-203-0-113.eu: OVHcloud API error (status code 404): Client::NotFound: "This service does not exist" (X-OVH-Query-Id: EU.ext-4.6a89a9d1.1101808.34160f0a4a397999e8833d9b0b29811a)

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

@Denis-hamon

Copy link
Copy Markdown
Contributor Author

Human review — Does not work

B4 · Nothing to do: the command says so instead of acting
Dimension: Product fit

Expected: No write call is sent, and the command says “nothing to do” while naming the entry.

This command can write, so live mode will not run it as is.

Add --dry-run: the command then describes the calls it would make,
without sending any. Everything before the write is still exercised --
the flags, the validation, the guard, the message.

baremetal power on ns0000002.ip-203-0-113.eu --dry-run

If the command has no --dry-run, it will say so itself.

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

@Denis-hamon

Copy link
Copy Markdown
Contributor Author

Both of today's 15:53 findings come from the review sandbox, not from this branch. Fixed on the sandbox side.

B3 — 404 "This service does not exist". The check's steps offer ovhcloud baremetal power status ns0000001.ip-203-0-113.eu as a clickable button, and ns0000001.ip-203-0-113.eu is a documentation-range placeholder (RFC 5737). Correct in the prose; wrong the moment the button is fired in live mode, where it hits a real account on which no such server exists. So the 404 was the page aiming at nothing — you clicked exactly what it offered.

The sandbox now rewrites, at startup, every command live mode would actually run to a real test machine (ns31633811.ip-162-19-234.eu, RISE-1, lim3), covering both a check's own command and the `ovhcloud …` commands inside its steps — each of which becomes its own button. Commands the guards refuse are left untouched: they only ever replay, and recordings are keyed by the exact string, so each rewrite also aliases the recording. Verified through the sandbox's own API:

$ baremetal power status ns31633811.ip-162-19-234.eu
ns31633811.ip-162-19-234.eu is poweron, set to boot on "Boot to disk" (bootId 1)
rc = 0

Which is what B3 asks for: it names the entry it is on, so you can see for yourself which one it took.

B4 — the "add --dry-run" guard text, again. power on writes, so live mode refuses it without --dry-run; the message is the sandbox's generic guard, not this binary's. With the flag, on a machine that is already up:

$ baremetal power on ns31633811.ip-162-19-234.eu --dry-run
ns31633811.ip-162-19-234.eu is already running on boot 1 (the disk, because this
machine has no record of the previous boot). Nothing to do.
rc = 0

No write call sent, the entry named, and phrased as a statement rather than an error — the three things B4 asks for. Note the check's own command still says ns0000002… in the prose because power on is replay-only, but its button now aims at the real machine.

Separately: the sandbox was running a binary built on 21/08 17:21 and has been rebuilt from the current integration stack, so what the page shows is what these branches actually produce.

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>
The flag registration arrived here as a plain commit, so the content matched
but git had no way of knowing where it came from: merging ovh#243 into main and
then this branch would have replayed the same change and conflicted over it.

This merge carries no content — it only states the parentage that was missing.

Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
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