Skip to content

feat(baremetal): send a server's logs somewhere, from the terminal - #263

Draft
Denis-hamon wants to merge 25 commits into
ovh:fix/schemas-v2-targetfrom
Denis-hamon:feat/baremetal-logs
Draft

feat(baremetal): send a server's logs somewhere, from the terminal#263
Denis-hamon wants to merge 25 commits into
ovh:fix/schemas-v2-targetfrom
Denis-hamon:feat/baremetal-logs

Conversation

@Denis-hamon

Copy link
Copy Markdown
Contributor

What this is

Seven v2 routes send a dedicated server's logs to a Log Data Platform stream. None of them was reachable from this CLI — not skipped, invisible: the repository could only fetch v1 schemas, so the surface never appeared in any inventory. #262 gives it a refresh target; this wires the commands.

ovhcloud baremetal logs kinds <server>
ovhcloud baremetal logs url <server> [--kind]
ovhcloud baremetal logs subscription list <server> [--kind]
ovhcloud baremetal logs subscription get <server> <id>
ovhcloud baremetal logs subscribe <server> --stream <title|id> [--kind] [--wait] [--yes|--dry-run]
ovhcloud baremetal logs unsubscribe <server> <id> [--wait] [--yes|--dry-run]

Every one of these operations is badged Alpha version upstream.

What the measurement changed in the design

Measured against a real account on 20 August 2026, before any of this was written.

It is not a tail, so it is not called one. POST .../log/url returns a signed https://get.logs.ovh.com/search address into the Log Data Platform search interface — not a socket, not a chunked stream. It is valid for exactly thirty minutes. The command is logs url, it prints the link, and it says when the link dies and that it carries its own authorisation. A command called tail that printed a web address would be lying in its own name.

There is one kind of log, and --kind is still optional both ways. install — the operating system installation logs, on 8 servers out of 8. Requiring an operator to type the only possible value is friction; defaulting to the string "install" in Go would keep reaching that one kind on the day a second appears. So the flag is left empty by default and the server is asked: one kind is taken silently, several are refused with their names, none is reported as an answer rather than an error.

The subscription carries its own Log Data Platform service. A subscription object is {subscriptionId, kind, streamId, serviceName, resource} where serviceName is the LDP service, not the server. The POST answers {operationId, serviceName} for the same reason, so --wait is told where to poll and needs no lookup of its own.

The stream resolution is the command

An operator has a server name and wants "my logs, over there". They do not have a stream UUID. This account carries 25 LDP services and 59 streams, one service holding 21 on its own, with titles like Prestashop nginx logs (filebeat) and datastream_test.

--stream therefore takes a title or an identifier. Same three-case shape as vrack attach resolving a server to its vRack interface (#249) and baremetal traffic resolving it to its network controllers (#254):

$ ovhcloud baremetal logs subscribe ns3118333.ip-51-68-100.eu --stream test_iplb --dry-run
🛑 2 streams are called "test_iplb", on different Log Data Platform services.
   Name the one you mean by its identifier:
     1e66c04a-040d-4fac-af46-ba79077a00ce  (service ldp-ay-10969)
     55033697-8afa-4f5b-8fd1-3f9ba129a312  (service ldp-hf-26139)

That refusal is not defensive: three titles on this account are carried by two streams each (test_iplb, test, Stream1). And the account holds both Test and test, so the match is case-sensitive — folding case would silently pick one of two different streams.

A miss says what does exist, capped and counted rather than truncated in silence:

🛑 no stream is called "Stream". Close to: "Data-Stream-Pierrick-PCC" (service ldp-pu-66281),
   "My first data stream" (service ldp-wq-88992), "Stream 2" (service ldp-qz-47460),
   "Stream1" (service ldp-qz-47460), "Stream1" (service ldp-tg-34397), and 3 more.
   List them with: ovhcloud ldp list

The sweep is 85 requests and 2.15 seconds, and none of it is cached. A stale identifier resolved from a cache would send a machine's logs to the wrong place — the same reason #249 removed a cached name from a confirmation prompt. Only the shell-completion suggestions are cached, where staleness costs a suggestion.

--wait crosses both catalogues, and reads the state afterwards

The change is made against the server on /v2 and returns an operation identifier, and no v2 route can follow it. It is followed on GET /v1/dbaas/logs/{ldpService}/operation/{id}, whose dbaas.logs.OperationStateEnumFAILURE, PENDING, RECEIVED, RETRY, REVOKED, RUNNING, STARTED, SUCCESS — is a sixth status vocabulary in this repository and shares nothing with the five already here.

And as everywhere in this series, the operation reporting SUCCESS is not the subscription existing. The operation object carries subscriptionId, so the wait is handed the identifier to read the state back with; the removal checks the other way, that the subscription is actually gone.

Two traps avoided by name

The --kind filter belongs to the collection call. The expansion builds one URL per subscription as path + "/%s", so a query string carried over would produce .../log/subscription?kind=install/<id> — a route that does not exist, for a filter that would have looked like it worked. Same class as the --filter ordering trap of #256 and the ticket filters of #261. A test asserts no requested URL contains kind=install/.

The dry run shows the body. common.ReportDryRun prints the method and the path, which is the whole story for the DELETE and half of it for the two POSTs: what an operator checks before agreeing to a subscription is which stream and which kind, and neither is in the URL. Built as one document rather than two messages, because under -o json two messages are two JSON documents on one stdout — the reason baremetal ticket does the same.

Verified against the API

Full cycle on ns3118333.ip-51-68-100.eu, against a stream its owner had already named TO REMOVE 1:

step measured
state before, read outside the CLI []
subscribe --stream "TO REMOVE 1" --wait --yes 7 s, exit 0, title resolved to 69813166-… on ldp-nx-19421
read back outside the CLI the subscription is there
unsubscribe --wait --yes 6 s, exit 0
state after, read outside the CLI [] — back to the start
removing the same id again refused, with the command that lists

Reads exercised against the real API too: logs kinds (one kind, its display name and its four extra fields), logs url (a live 30-minute link), logs subscription list on the one server on this account that already had a subscription, and the --kind filter both matching and not.

Refusals executed against the real API: ambiguous title, near-miss title, unknown title, no --yes on a non-interactive stream, and --dry-run on both writes.

Gate

go build, go vet, make wasm, go test ./..., docgen — all clean.

20 tests (6 unit, 14 cobra) and 12 sabotages, 12 red — each on the test it was meant to exercise, with restoration checked by digest.

One of the twelve first came back red for the wrong reason: it broke the build instead of the test, which proves nothing about the test's teeth. Re-aimed so it compiles and the read-back test is what falls. The count above is the corrected run.

Stacked on #262, which is stacked on #261.

What this does not do

  • baremetal list --tag, which the same v2 schema makes possible via the iamTags filter, is left to its own change: it alters a command everybody already uses, and that deserves its own diff.
  • The v2 schema is embedded whole rather than curated. All seven of its paths are either used here or are the two server routes; there is no subset to pick.

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>
Seven v2 routes subscribe a dedicated server's logs to a Log Data Platform
stream, and none of them was reachable here. Not skipped — invisible: the
repository could only fetch v1 schemas, so the surface never appeared in any
inventory of what was missing.

    ovhcloud baremetal logs kinds <server>
    ovhcloud baremetal logs url <server>
    ovhcloud baremetal logs subscription list|get <server> [id]
    ovhcloud baremetal logs subscribe <server> --stream <title|id> [--wait]
    ovhcloud baremetal logs unsubscribe <server> <id> [--wait]

Three things measured against the real account changed the design before a line
was written.

It is not a tail, so it is not called one. POST .../log/url returns a signed
get.logs.ovh.com/search address into the Log Data Platform search interface,
valid for exactly thirty minutes. The command prints the link, says when it
dies, and says that it carries its own authorisation.

There is one kind of log — "install" — and --kind is optional in both
directions. Making somebody type the only possible value is friction; putting
"install" in Go as a default would keep reaching that one kind on the day a
second appears. The server is asked instead: one kind is taken, several are
refused with their names, none is an answer rather than an error.

The subscription carries its own LDP service in serviceName, and so does the
POST response, so --wait is told where to poll.

The stream resolution is the command. An operator has a server name and wants
"my logs, over there"; they do not have a stream UUID, and this account holds 59
streams across 25 services. --stream therefore takes a title or an identifier,
resolved the way `vrack attach` resolves an interface. A title carried by more
than one stream is refused with both identifiers and their services — three
titles on this account really are, and the account holds both "Test" and "test",
so the match is case-sensitive. The sweep is 85 requests and 2.15 seconds, and
none of it is cached: a stale identifier would send a machine's logs to the
wrong place. Only the completion suggestions are.

--wait crosses both catalogues. The change is made on /v2 and no v2 route can
follow it; it is followed on /v1/dbaas/logs/{service}/operation/{id}, whose
state vocabulary is a sixth one in this repository and shares nothing with the
five already here. And SUCCESS is not the subscription existing: the operation
carries subscriptionId, so the wait reads the state back, and the removal checks
the other way round.

Two traps avoided by name. The --kind filter stays on the collection call: the
expansion builds one URL per subscription as path + "/%s", so a query carried
over would produce ".../log/subscription?kind=install/<id>", a route that does
not exist for a filter that would have looked like it worked. And the dry run
shows the body, because what an operator checks before agreeing is which stream
and which kind, and neither is in the URL.

Verified on ns3118333.ip-51-68-100.eu against a stream its owner had named
"TO REMOVE 1": subscribe --wait in 7s, read back outside the CLI, unsubscribe
--wait in 6s, final state read outside the CLI identical to the start, and the
same removal refused a second time.

20 tests (6 unit, 14 cobra), 12 sabotages, 12 red.

Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
Denis-hamon and others added 4 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>
Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
@Denis-hamon
Denis-hamon changed the base branch from main to fix/schemas-v2-target August 20, 2026 15:19
Denis-hamon and others added 11 commits August 20, 2026 22:45
Signed-off-by: Denis Hamon <denis.hamon@ovhcloud.com>
`baremetal logs subscription list` registered the flag and rendered its rows directly.

withFilterFlag only binds --filter to flags.GenericFilters; display.RenderTable
does not read it, and ManageListRequest — which normally passes it to
filters.FilterLines — is not on this path because the rows are assembled here.
So the flag was accepted by cobra, written into doc/ by docgen, and inert. An
operator reading a list they believe was narrowed is worse served than one who
was never offered the flag.

These go through common.RenderFilteredTable, added for the same defect earlier
in the stack.

The test asserts the row the filter EXCLUDES is absent, not merely that the
kept one is present: the second passes just as well with the filtering removed.

Found by a review pass over this branch, then confirmed by an audit of every
withFilterFlag call site in the tree — twenty-one commands this series added
carried the same defect.

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>
…ng else

An unknown stream title sent the operator to `ovhcloud ldp list`, which lists
Log Data Platform services — `ldp-aa-1`, `ldp-bb-2` — and not Graylog streams.
No command of this tree lists streams at all; the only surface that exposes them
is the completion on this very flag. So the remedy pointed at the wrong object,
on an account where 59 streams live across 25 services and three titles are
carried by two streams each.

It points at completion now. nearbyTitles already says how many there are and
which ones are close, so nothing is lost.

The sibling refusal ten lines up does name `ldp list`, and correctly: there it is
talking about services.

One test, red under a sabotage that puts the old sentence back.

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

Copy link
Copy Markdown
Contributor Author

Human review — Doubt

K1 · logs url, not logs tail
Dimension: Naming

Expected: The name does not promise a stream. A tail that is not one would be a command lying in its own name.

est ce qu'il y' a bien Log data platform installé sur ce serveur ?

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

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

Answering the doubt on K1: "est-ce qu'il y a bien Log data platform installé sur ce serveur ?"

No, and logs url does not need it. Measured on the test machine on 24/08:

  • baremetal logs kinds returns exactly one kind, installOperating system installation logs;
  • baremetal logs subscription list returns zero subscriptions.

logs url reads the install logs OVHcloud hosts itself, and returns a signed get.logs.ovh.com/search address valid for thirty minutes. Log Data Platform only enters the picture with logs subscribe, which is the next check in this area — a subscription sends the same logs to an LDP stream you own.

So the naming question stands on its own: the route hands back a search page, not a socket, and url says that where tail would not. The sandbox's protocol for this check now starts with logs kinds, which is a plain read and answers the question without leaving the 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>
@Denis-hamon

Copy link
Copy Markdown
Contributor Author

Human review — Doubt

K2 · Is the expiry stated?
Dimension: Product fit

Expected: The expiry date is printed, and it is stated that the link carries its own authorisation.

Find in the test a server with Log already settled

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

@Denis-hamon

Copy link
Copy Markdown
Contributor Author

Human review — Doubt

K1 · logs url, not logs tail
Dimension: Naming

Expected: The name does not promise a stream. A tail that is not one would be a command lying in its own name.

Find in the test a server with Log already settled

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

@Denis-hamon

Copy link
Copy Markdown
Contributor Author

Human review — Doubt

K3 · You give a stream title, not a UUID
Dimension: Discoverability

Expected: --stream accepts a title. Resolution costs 85 requests in 2.15s and nothing is cached: a stale identifier would send a machine's logs to the wrong place.

no stream is called "prod nginx". This account has 59 of them.
Complete them with: ovhcloud baremetal logs subscribe --stream

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

@Denis-hamon

Copy link
Copy Markdown
Contributor Author

Human review — Doubt

K4 · Ambiguous title: refusal or silent pick?
Dimension: Error message

Expected: A refusal listing the candidates with their LDP service. Matching is case sensitive.

🛑 2 streams are called "test", on different Log Data Platform services.
Name the one you mean by its identifier:
5e0256d4-d9c5-4575-9cbc-24ea170b0c49 (service ldp-mj-88337)
42534b63-00d3-4087-9e7d-3fab501909f3 (service ldp-tg-34397)

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

K3, from the review sandbox. Asking for a stream that matches nothing
answered "no stream is called \"prod nginx\". This account has 59 of
them" — a count that names nothing, followed by a hint that needs a TAB
key a script, a CI log or a web console does not have.

nearbyTitles already lists near misses when there are any; the
no-near-miss branch now shows three real titles as well, sorted so the
same account gives the same examples twice running. An error message
that reshuffles itself between two identical calls reads as instability
rather than as an example.

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

`--stream "prod nginx"` takes a title because nobody has a stream UUID at hand.
The resolution then happened in silence: the confirmation prompt named it, and a
dry run never shows the prompt, so the only trace left was a UUID inside the
request body.

That is the one thing worth checking before this call. Measured on the test
account, 2026-08-25: 59 Graylog streams across 25 Log Data Platform services,
one service carrying 21 of them, and three titles shared by two streams each
("test_iplb", "test", "Stream1"). Picking the wrong one sends a machine's logs
to another service, and it looks like a success.

So the dry run now leads with what the title became:

  🔍 Dry run: nothing was sent.
    "Tech_writer_private_cloud" resolves to stream ca06a2f5-… on Log Data
    Platform service ldp-cg-65382
  This would have been called:
    POST /v2/dedicated/server/{sn}/log/subscription

An identifier resolves to itself, so the line does not appear: a note printed on
every run is noise, and noise gets skipped exactly when it matters.

The success and failure messages move from the bare UUID to streamLabel(), which
already carried the title and the service.

Tested by sabotage, twice — dropping the line, and dropping the service name
from it. Both turn the new tests red.
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