Skip to content

feat(vrack): attach and detach dedicated servers, and show what a vRack holds - #249

Draft
Denis-hamon wants to merge 18 commits into
ovh:feat/baremetal-powerfrom
Denis-hamon:feat/vrack-attach
Draft

feat(vrack): attach and detach dedicated servers, and show what a vRack holds#249
Denis-hamon wants to merge 18 commits into
ovh:feat/baremetal-powerfrom
Denis-hamon:feat/vrack-attach

Conversation

@Denis-hamon

Copy link
Copy Markdown
Contributor

Stacked on #248only the last commit (84ec8a1) belongs to this PR.

The vrack domain could name a private network, not put anything in it

Three of the API's forty-seven vrack endpoints were reachable: list, get, rename. This adds the two gestures the object exists for.

ovhcloud vrack get    <vrack>
ovhcloud vrack attach <vrack> <server|uuid> [--interface <uuid>] [--wait] [--yes|--dry-run]
ovhcloud vrack detach <vrack> <server|uuid> [--wait] [--yes|--dry-run]

ovhcloud baremetal vrack show   <server>
ovhcloud baremetal vrack attach <server> <vrack> [--interface <uuid>] [--wait] [--yes|--dry-run]
ovhcloud baremetal vrack detach <server> [<vrack>] [--wait] [--yes|--dry-run]

Both trees, one implementation. The network view is the object's home; the server view is where somebody holding a machine looks — this CLI already has a command nobody finds for that reason, ip reverse set, invisible from baremetal --help.

Measured before it was written

On a live account: 71 vRacks, 35 dedicated servers, read-only.

vRacks holding interfaces 9 of 71
vRacks holding a server via the legacy dedicatedServer endpoint 0 — that path is not used
servers with no virtual network interface at all 7 of 35
servers carrying a customer-chosen display name 23 of 35
vRacks with a name or description of their own 13 and 14 of 71

allowedServices is read rather than eligibleServices. Both claim to answer "what can be attached"; the latter is an async job whose stored result was a day old and reported nothing at all, while the former listed eleven interfaces with their owning server.

Three things that shaped the code

Two silences that look the same. A server missing from allowedServices either has no vRack interface or already has one attached elsewhere — and, with a typo, may not exist. One in five servers falls in the first case, so this is the command's likeliest output, not a defensive branch. It asks the server itself and says which happened.

A machine's name is not its hostname. iam.displayName holds what its owner calls it — "Yaniv - RISE-1 - LIM", "BMAD_Repo + Paperclip backup" — while /dedicated/server/{n}'s name field repeats the hostname on all 35. Both are printed and both are accepted as input: the display name says whether this is the right machine, the hostname says it is the only one. Note for anyone reusing this: a server keeps its chosen name in IAM, a vRack keeps its own in name/description with iam.displayName null. Reading "the display name" uniformly would show one of the two and silently blank the other.

--wait polls the membership, never the task. One attach produced two tasks (addDedicatedServerInterfaceToVrack, addVlanToVrackOnNode), so the task returned by the POST is not the operation. The task is still read for the one thing state cannot report — a cancelled operation ends the wait instead of running to the timeout.

get now answers the question it is asked

It used to print name, description and IAM URN, so a vRack with two servers and a Cloud Connect looked identical to an empty one. It leads with a summary, then lists all eleven attachable types — not the two this command drives, because 32 of the 71 vRacks hold only a cloud project and each would have printed as empty. A type that fails to list is counted and said rather than folded into "This vRack is empty", which is a claim, and ten serial GETs give it ten chances to be wrong.

vRack pn-1086219 — vr-seb
Testing Cloud to Cloud connection between OVHcloud, AWS & GCP

2 dedicated servers · 1 OVHcloud Connect

Dedicated servers
  Name                  | Server                   | Interface
  ----------------------|--------------------------|------------------
  Yaniv - RISE-1 - LIM  | ns3141022.ip-51-77-67.eu | d0:50:99:d7:55:0b

The Name column appears only when at least one server has a name of its own; otherwise it repeated its neighbour and cost the width that pushes a row onto two lines.

Verified on real hardware

Attached in 3 min 28 s, detached in 2 min 01 s, on an empty vRack and a spare RISE-2. Starting state recorded before and returned to after, confirmed by a read that did not go through this CLI.

Notes

  • json.Number: go-ovh decodes with UseNumber, and a type switch does not widen it to float64. Without that case the cancelled-task check was dead code — the same class of bug this repo already fixed once in baremetal.go.
  • Nothing is cached. One GET beats showing an hour-old name in the sentence read before cutting a machine off its network, and beats two accounts sharing a cache through --profile.
  • Seventeen tests on recorded responses. 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 the doc gate's git diff --exit-code does not see it. 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>
…ck holds

The vrack domain exposed three of the API's forty-seven endpoints: list, get,
rename. A customer could name their private network from the terminal but not
put a machine in it, which is the only reason the object exists.

## Attachment is by interface, and an operator does not know the interface

`POST /vrack/{n}/dedicatedServerInterface` takes a UUID. Nobody types
1688f939-b93f-47a2-9648-b270f8150a53 from memory, so the command takes a server
and resolves it: allowedServices lists what may still be attached, with the
owning server's hostname, and dedicatedServerInterfaceDetails does the same for
what already is. Measured on 71 vRacks: 9 hold interfaces, 0 hold a server
through the legacy `dedicatedServer` endpoint, which is why that path is not
used.

allowedServices rather than eligibleServices, though both claim to answer this
question. eligibleServices is an asynchronous job; its stored result was a day
old when this was measured, and reported no attachable interface at all while
allowedServices listed eleven.

Resolution refuses rather than guesses. A server with two interfaces — an
aggregation and a plain one, which do not carry the same traffic — is a choice
the operator makes with --interface, and that flag is checked against the named
server's own interfaces instead of being passed through, so a UUID belonging to
another machine cannot attach something nobody named.

## Two silences that look identical

A server absent from allowedServices either has no vRack interface at all or
has one that is already attached somewhere. Same absence, opposite actions, and
the first is not rare: 7 of the 35 servers measured have no virtual network
interface whatsoever. So the failure path asks the server itself and says which
of the two happened — including the third case, a name that matches no server,
which used to read as a hardware problem.

## Naming the machine an operator would recognise

The hostname is an address. `iam.displayName` is what its owner calls it, 23 of
the 35 servers carry one, and they read like "Yaniv - RISE-1 - LIM" or
"BMAD_Repo + Paperclip backup". Both are printed everywhere the command names a
machine, because neither replaces the other: the display name says whether this
is the right one, the hostname says it is the only one. And both are accepted
as input — a name the CLI prints and refuses to take back is half a feature.
A display name held by two machines is refused, with the hostnames listed.

The two objects store this in opposite places, which is worth knowing before
reusing the code: a server's chosen name is in IAM, while a vRack's is in its
own `name` and `description`, and its `iam.displayName` is null. Nothing is
cached — one GET is cheaper than showing an hour-old name in the sentence
somebody reads before cutting a machine off its network, or than two accounts
sharing one cache through --profile.

## get answers the question it is asked

`vrack get` printed name, description and IAM URN, so a vRack holding two
servers and a Cloud Connect looked exactly like an empty one. It now leads with
what is inside, then lists it — all eleven attachable types, not the two this
command can act on, because 32 of the 71 vRacks measured hold only a cloud
project and would each have printed as empty. A type that cannot be read is
counted and said, rather than folded into "This vRack is empty": that sentence
is a claim, and ten serial GETs give it ten chances to be false.

## Waiting

--wait polls the membership, never the task. One attach produced two tasks
(addDedicatedServerInterfaceToVrack and addVlanToVrackOnNode), so the task
returned by the POST is not the whole operation; and on a sibling command a
task reported done 83 seconds before the machine obeyed. The task is still read
for the one thing state cannot say — a cancelled operation stops the wait
instead of running to the timeout. That path needed `json.Number`: go-ovh
decodes with UseNumber, and a type switch does not widen it to float64, so
without that case the check was dead code.

Measured end to end against the live API: attached in 3 min 28 s, detached in
2 min 01 s, starting state read before and returned to after, verified by a
read that did not go through this CLI.

Reviewed adversarially by two models before opening; the display-name input
contract, the empty-vRack claim, the json.Number case, the multi-vRack server
and the already-attached message all come from that pass. Seventeen tests on
recorded responses, each 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>
@Denis-hamon
Denis-hamon requested a review from a team as a code owner August 19, 2026 08:32
…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 and others added 2 commits August 20, 2026 16:14
Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
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-power August 20, 2026 15:18
Denis-hamon and others added 8 commits August 20, 2026 22:42
`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>
Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
…iled

Reading a vRack means reading eleven things: the object, the attached server
interfaces, and nine other content types. The comment above that code said a
failure costs the section and never the command. It did the opposite.

display.OutputWarning is not a message, it is an exit: OutputWithFormat ends on
ExitFunc(0) for a warning exactly as it ends on ExitFunc(1) for an error, and
ExitFunc is os.Exit. Called in the middle of GetVrack, it took the process down
before the vRack was printed. Whenever one of those lists failed — a 500, a rate
limit, a dropped connection — `vrack get` answered nothing whatsoever and exited
0, so a script could not tell it apart from a vRack it had read.

The failure is now folded into the object: it is printed under the summary, it
is in -o json, and it is counted with the other unreadable sections so the
summary stops short of "This vRack is empty" — the claim this command must never
make about a list it could not read.

Everything after an OutputWarning or an OutputError is dead code in the shipped
binary, and the suite could not see it: cmd_test.go replaces ExitFunc with a
no-op, so under test the command kept running and printed what an operator never
saw. executeWithRealExit restores the real semantics for one command — the stop
is a panic rather than os.Exit, but the point is only that execution does not
continue — and the test for this fix uses it, because that stub is precisely
what hid the defect.

An AST pass over the whole tree found no other call of this shape introduced
here, out of 1575 examined. Four remain on main and are left to their owners:
three in Logout, where a warning ends the process before the local credentials
are deleted, and one in formatContainerRegistryPlans.

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 attached list is one entry per vRack interface, and a dedicated server can
carry several — this package knows it everywhere else: interfacesOf sorts the
interfaces of one server, attach refuses when a server has more than one, and
detach counts distinct servers before deciding. Only the summary counted rows.

So a machine with two NICs in the vRack printed "2 dedicated servers" above a
two-line table, and nothing in the output let the operator see that both lines
name the same host. distinctServers already existed, ten lines away.

One test, red under a sabotage that puts len() back.

Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…one page

`vrack get` listed ten of the thirteen types vrack.AllowedServiceEnum declares.
One of the three left out is read by the servers section
(dedicatedServerInterface); the other two were simply absent:

  - dedicatedServer, the legacy attachment — a server bound to the vRack
    directly rather than through one of its interfaces. It is the only path
    under which such a vRack shows up at all, so a vRack holding a production
    machine that way printed "This vRack is empty."
  - dedicatedCloudDatacenter.

Zero of the 71 vRacks on the account measured use the legacy attachment, which
is why the omission was invisible rather than harmless. Both paths answer 200
with a list, verified against the API.

The list of paths is now package-level and two tests hold it against the schema
enum in both directions: every declared type is listed, and nothing is listed
that the schema does not declare — an invented path errors on every vRack, and
errors are counted as "could not be read", so it would permanently stop the
summary from ever saying a vRack is empty.

Second fix, same file: serverDisplayNames read /v2/iam/resource with a plain GET.
It is a v2 route and v2 routes paginate by cursor — measured, with
X-Pagination-Size: 10 the route answers ten objects and an
X-Pagination-Cursor-Next. With 35 servers on this account a single GET happened
to see all of them, so the defect was latent rather than absent; past one page a
machine loses its display name, and that is the name an operator recognises in
the sentence asking them to cut its network. It now reads through FetchArray,
which already follows the cursor.

Four tests, four sabotages, four reds.

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
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>
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