From d2de4c3a506c4c92ba452e933e382149dc8d8748 Mon Sep 17 00:00:00 2001 From: Denis Hamon Date: Tue, 18 Aug 2026 17:02:04 +0200 Subject: [PATCH 01/11] fix(service-info): send only the renewal settings the operator asked to change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- doc/ovhcloud_vps_service-info_edit.md | 10 +-- ...loud_webhosting_cdn_service-info_update.md | 10 +-- ...ebhosting_extra-sql_service-info_update.md | 10 +-- ..._local-seo_location_service-info_update.md | 10 +-- ...ovhcloud_webhosting_service-info_update.md | 10 +-- internal/cmd/vps.go | 6 +- internal/cmd/vps_test.go | 58 +++++++++++++ internal/cmd/webhosting.go | 24 +----- internal/services/common/common.go | 16 +--- internal/services/common/service_info.go | 82 +++++++++++++++++++ internal/services/vps/vps.go | 4 +- internal/services/webhosting/webhosting.go | 33 +------- 12 files changed, 179 insertions(+), 94 deletions(-) create mode 100644 internal/services/common/service_info.go diff --git a/doc/ovhcloud_vps_service-info_edit.md b/doc/ovhcloud_vps_service-info_edit.md index ca6431a61..0d4ed8f78 100644 --- a/doc/ovhcloud_vps_service-info_edit.md +++ b/doc/ovhcloud_vps_service-info_edit.md @@ -11,11 +11,11 @@ ovhcloud vps service-info edit [flags] ``` --editor Use a text editor to define parameters -h, --help help for edit - --renew-automatic Enable automatic renewal - --renew-delete-at-expiration Delete service at expiration - --renew-forced Force renewal - --renew-manual-payment Enable manual payment for renewal - --renew-period int Renewal period (in months) + --renew-automatic Renew the service automatically + --renew-delete-at-expiration Delete the service when it expires + --renew-forced Force the renewal + --renew-manual-payment Pay the renewal manually + --renew-period int Renewal period, in months ``` ### Options inherited from parent commands diff --git a/doc/ovhcloud_webhosting_cdn_service-info_update.md b/doc/ovhcloud_webhosting_cdn_service-info_update.md index 3090d068b..550fb8c24 100644 --- a/doc/ovhcloud_webhosting_cdn_service-info_update.md +++ b/doc/ovhcloud_webhosting_cdn_service-info_update.md @@ -12,11 +12,11 @@ ovhcloud webhosting cdn service-info update [flags] --editor Use a text editor to define parameters --from-file string File containing parameters -h, --help help for update - --renew-automatic Enable automatic renewal - --renew-delete-at-expiration Delete service at expiration - --renew-forced Force renewal - --renew-manual-payment Enable manual payment for renewal - --renew-period int Renewal period in months + --renew-automatic Renew the service automatically + --renew-delete-at-expiration Delete the service when it expires + --renew-forced Force the renewal + --renew-manual-payment Pay the renewal manually + --renew-period int Renewal period, in months ``` ### Options inherited from parent commands diff --git a/doc/ovhcloud_webhosting_extra-sql_service-info_update.md b/doc/ovhcloud_webhosting_extra-sql_service-info_update.md index a0885eabf..8b6c4192c 100644 --- a/doc/ovhcloud_webhosting_extra-sql_service-info_update.md +++ b/doc/ovhcloud_webhosting_extra-sql_service-info_update.md @@ -12,11 +12,11 @@ ovhcloud webhosting extra-sql service-info update [flags] --editor Use a text editor to define parameters --from-file string File containing parameters -h, --help help for update - --renew-automatic Enable automatic renewal - --renew-delete-at-expiration Delete service at expiration - --renew-forced Force renewal - --renew-manual-payment Enable manual payment for renewal - --renew-period int Renewal period in months + --renew-automatic Renew the service automatically + --renew-delete-at-expiration Delete the service when it expires + --renew-forced Force the renewal + --renew-manual-payment Pay the renewal manually + --renew-period int Renewal period, in months ``` ### Options inherited from parent commands diff --git a/doc/ovhcloud_webhosting_local-seo_location_service-info_update.md b/doc/ovhcloud_webhosting_local-seo_location_service-info_update.md index fc847ba79..01a04c78d 100644 --- a/doc/ovhcloud_webhosting_local-seo_location_service-info_update.md +++ b/doc/ovhcloud_webhosting_local-seo_location_service-info_update.md @@ -12,11 +12,11 @@ ovhcloud webhosting local-seo location service-info update [ --editor Use a text editor to define parameters --from-file string File containing parameters -h, --help help for update - --renew-automatic Enable automatic renewal - --renew-delete-at-expiration Delete service at expiration - --renew-forced Force renewal - --renew-manual-payment Enable manual payment for renewal - --renew-period int Renewal period in months + --renew-automatic Renew the service automatically + --renew-delete-at-expiration Delete the service when it expires + --renew-forced Force the renewal + --renew-manual-payment Pay the renewal manually + --renew-period int Renewal period, in months ``` ### Options inherited from parent commands diff --git a/doc/ovhcloud_webhosting_service-info_update.md b/doc/ovhcloud_webhosting_service-info_update.md index 6ee3bad93..821effd28 100644 --- a/doc/ovhcloud_webhosting_service-info_update.md +++ b/doc/ovhcloud_webhosting_service-info_update.md @@ -12,11 +12,11 @@ ovhcloud webhosting service-info update [flags] --editor Use a text editor to define parameters --from-file string File containing parameters -h, --help help for update - --renew-automatic Enable automatic renewal - --renew-delete-at-expiration Delete service at expiration - --renew-forced Force renewal - --renew-manual-payment Enable manual payment for renewal - --renew-period int Renewal period in months + --renew-automatic Renew the service automatically + --renew-delete-at-expiration Delete the service when it expires + --renew-forced Force the renewal + --renew-manual-payment Pay the renewal manually + --renew-period int Renewal period, in months ``` ### Options inherited from parent commands diff --git a/internal/cmd/vps.go b/internal/cmd/vps.go index acb2ea78f..f452ef0c6 100644 --- a/internal/cmd/vps.go +++ b/internal/cmd/vps.go @@ -221,11 +221,7 @@ func init() { ValidArgsFunction: completion.ServiceList("/v1/vps"), Run: vps.EditVpsServiceInfo, } - serviceInfoEditCmd.Flags().BoolVar(&common.ServiceInfoSpec.Renew.Automatic, "renew-automatic", false, "Enable automatic renewal") - serviceInfoEditCmd.Flags().BoolVar(&common.ServiceInfoSpec.Renew.DeleteAtExpiration, "renew-delete-at-expiration", false, "Delete service at expiration") - serviceInfoEditCmd.Flags().BoolVar(&common.ServiceInfoSpec.Renew.Forced, "renew-forced", false, "Force renewal") - serviceInfoEditCmd.Flags().BoolVar(&common.ServiceInfoSpec.Renew.ManualPayment, "renew-manual-payment", false, "Enable manual payment for renewal") - serviceInfoEditCmd.Flags().IntVar(&common.ServiceInfoSpec.Renew.Period, "renew-period", 0, "Renewal period (in months)") + common.AddServiceInfoRenewFlags(serviceInfoEditCmd) addInteractiveEditorFlag(serviceInfoEditCmd) serviceInfoCmd.AddCommand(serviceInfoEditCmd) diff --git a/internal/cmd/vps_test.go b/internal/cmd/vps_test.go index fef023dc5..467362437 100644 --- a/internal/cmd/vps_test.go +++ b/internal/cmd/vps_test.go @@ -6,6 +6,8 @@ package cmd_test import ( "encoding/json" + "io" + "net/http" "github.com/jarcoal/httpmock" "github.com/maxatome/go-testdeep/td" @@ -63,3 +65,59 @@ func (ms *MockSuite) TestVpsGetCmd(assert, require *td.T) { } }`)) } + +// registerVpsServiceInfos wires a service whose renewal is currently automatic, +// and captures whatever the CLI decides to write back. +func registerVpsServiceInfos(captured *map[string]any) { + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/v1/vps/fakeVps/serviceInfos", + httpmock.NewStringResponder(200, `{ + "serviceId": 1, + "domain": "fakeVps", + "renew": {"automatic": true, "deleteAtExpiration": false, "forced": false, "manualPayment": false, "period": 1} + }`), + ) + httpmock.RegisterResponder("PUT", "https://eu.api.ovh.com/v1/vps/fakeVps/serviceInfos", + func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + var sent map[string]any + if err := json.Unmarshal(body, &sent); err != nil { + return nil, err + } + *captured = sent + return httpmock.NewStringResponse(200, `null`), nil + }, + ) +} + +// Editing the renewal period used to send every other renewal setting along +// with it, at its zero value: a service that renewed itself automatically for +// years stopped doing so, and nothing in the output said it had changed. +func (ms *MockSuite) TestVpsServiceInfoEditSendsOnlyWhatWasAsked(assert, require *td.T) { + var sent map[string]any + registerVpsServiceInfos(&sent) + + _, err := cmd.Execute("vps", "service-info", "edit", "fakeVps", "--renew-period", "12") + + require.CmpNoError(err) + renew, _ := sent["renew"].(map[string]any) + require.NotNil(renew, "the renewal block must be written") + assert.Cmp(renew["period"], float64(12), "the period the operator asked for") + assert.Cmp(renew["automatic"], true, "automatic renewal must survive untouched") +} + +// The flag being absent and the flag being set to false are different +// intentions, and pflag can tell them apart: an explicit false must be sent. +func (ms *MockSuite) TestVpsServiceInfoEditSendsAnExplicitFalse(assert, require *td.T) { + var sent map[string]any + registerVpsServiceInfos(&sent) + + _, err := cmd.Execute("vps", "service-info", "edit", "fakeVps", "--renew-automatic=false") + + require.CmpNoError(err) + renew, _ := sent["renew"].(map[string]any) + require.NotNil(renew) + assert.Cmp(renew["automatic"], false, "the operator asked for it, so it is sent") +} diff --git a/internal/cmd/webhosting.go b/internal/cmd/webhosting.go index b2baf62c0..7e5096d0f 100644 --- a/internal/cmd/webhosting.go +++ b/internal/cmd/webhosting.go @@ -605,11 +605,7 @@ func init() { ValidArgsFunction: completion.ServiceList("/v1/hosting/web"), Run: webhosting.UpdateExtraSqlServiceInfo, } - extraSQLServiceInfoUpdateCmd.Flags().BoolVar(&common.ServiceInfoSpec.Renew.Automatic, "renew-automatic", false, "Enable automatic renewal") - extraSQLServiceInfoUpdateCmd.Flags().BoolVar(&common.ServiceInfoSpec.Renew.DeleteAtExpiration, "renew-delete-at-expiration", false, "Delete service at expiration") - extraSQLServiceInfoUpdateCmd.Flags().BoolVar(&common.ServiceInfoSpec.Renew.Forced, "renew-forced", false, "Force renewal") - extraSQLServiceInfoUpdateCmd.Flags().BoolVar(&common.ServiceInfoSpec.Renew.ManualPayment, "renew-manual-payment", false, "Enable manual payment for renewal") - extraSQLServiceInfoUpdateCmd.Flags().IntVar(&common.ServiceInfoSpec.Renew.Period, "renew-period", 0, "Renewal period in months") + common.AddServiceInfoRenewFlags(extraSQLServiceInfoUpdateCmd) addParameterFileFlags(extraSQLServiceInfoUpdateCmd, true, nil, "", "", "", nil) addInteractiveEditorFlag(extraSQLServiceInfoUpdateCmd) extraSQLServiceInfoCmd.AddCommand(extraSQLServiceInfoUpdateCmd) @@ -1330,11 +1326,7 @@ func init() { ValidArgsFunction: completion.ServiceList("/v1/hosting/web"), Run: webhosting.UpdateCdnServiceInfo, } - cdnServiceInfoUpdateCmd.Flags().BoolVar(&common.ServiceInfoSpec.Renew.Automatic, "renew-automatic", false, "Enable automatic renewal") - cdnServiceInfoUpdateCmd.Flags().BoolVar(&common.ServiceInfoSpec.Renew.DeleteAtExpiration, "renew-delete-at-expiration", false, "Delete service at expiration") - cdnServiceInfoUpdateCmd.Flags().BoolVar(&common.ServiceInfoSpec.Renew.Forced, "renew-forced", false, "Force renewal") - cdnServiceInfoUpdateCmd.Flags().BoolVar(&common.ServiceInfoSpec.Renew.ManualPayment, "renew-manual-payment", false, "Enable manual payment for renewal") - cdnServiceInfoUpdateCmd.Flags().IntVar(&common.ServiceInfoSpec.Renew.Period, "renew-period", 0, "Renewal period in months") + common.AddServiceInfoRenewFlags(cdnServiceInfoUpdateCmd) addParameterFileFlags(cdnServiceInfoUpdateCmd, true, nil, "", "", "", nil) addInteractiveEditorFlag(cdnServiceInfoUpdateCmd) cdnServiceInfoCmd.AddCommand(cdnServiceInfoUpdateCmd) @@ -1468,11 +1460,7 @@ func init() { ValidArgsFunction: completion.ServiceList("/v1/hosting/web"), Run: webhosting.UpdateServiceInfo, } - serviceInfoUpdateCmd.Flags().BoolVar(&common.ServiceInfoSpec.Renew.Automatic, "renew-automatic", false, "Enable automatic renewal") - serviceInfoUpdateCmd.Flags().BoolVar(&common.ServiceInfoSpec.Renew.DeleteAtExpiration, "renew-delete-at-expiration", false, "Delete service at expiration") - serviceInfoUpdateCmd.Flags().BoolVar(&common.ServiceInfoSpec.Renew.Forced, "renew-forced", false, "Force renewal") - serviceInfoUpdateCmd.Flags().BoolVar(&common.ServiceInfoSpec.Renew.ManualPayment, "renew-manual-payment", false, "Enable manual payment for renewal") - serviceInfoUpdateCmd.Flags().IntVar(&common.ServiceInfoSpec.Renew.Period, "renew-period", 0, "Renewal period in months") + common.AddServiceInfoRenewFlags(serviceInfoUpdateCmd) addParameterFileFlags(serviceInfoUpdateCmd, true, nil, "", "", "", nil) addInteractiveEditorFlag(serviceInfoUpdateCmd) serviceInfoCmd.AddCommand(serviceInfoUpdateCmd) @@ -1583,11 +1571,7 @@ func init() { ValidArgsFunction: completion.ServiceList("/v1/hosting/web"), Run: webhosting.UpdateLocalSeoLocationServiceInfo, } - localSeoLocationServiceInfoUpdateCmd.Flags().BoolVar(&common.ServiceInfoSpec.Renew.Automatic, "renew-automatic", false, "Enable automatic renewal") - localSeoLocationServiceInfoUpdateCmd.Flags().BoolVar(&common.ServiceInfoSpec.Renew.DeleteAtExpiration, "renew-delete-at-expiration", false, "Delete service at expiration") - localSeoLocationServiceInfoUpdateCmd.Flags().BoolVar(&common.ServiceInfoSpec.Renew.Forced, "renew-forced", false, "Force renewal") - localSeoLocationServiceInfoUpdateCmd.Flags().BoolVar(&common.ServiceInfoSpec.Renew.ManualPayment, "renew-manual-payment", false, "Enable manual payment for renewal") - localSeoLocationServiceInfoUpdateCmd.Flags().IntVar(&common.ServiceInfoSpec.Renew.Period, "renew-period", 0, "Renewal period in months") + common.AddServiceInfoRenewFlags(localSeoLocationServiceInfoUpdateCmd) addParameterFileFlags(localSeoLocationServiceInfoUpdateCmd, true, nil, "", "", "", nil) addInteractiveEditorFlag(localSeoLocationServiceInfoUpdateCmd) localSeoLocationServiceInfoCmd.AddCommand(localSeoLocationServiceInfoUpdateCmd) diff --git a/internal/services/common/common.go b/internal/services/common/common.go index 7a1729c99..c92516fcb 100644 --- a/internal/services/common/common.go +++ b/internal/services/common/common.go @@ -24,20 +24,8 @@ import ( "github.com/spf13/cobra" ) -var ( - //go:embed templates/service_info.tmpl - ServiceInfoTemplate string - - ServiceInfoSpec struct { - Renew struct { - Automatic bool `json:"automatic"` - DeleteAtExpiration bool `json:"deleteAtExpiration"` - Forced bool `json:"forced"` - ManualPayment bool `json:"manualPayment"` - Period int `json:"period"` - } `json:"renew"` - } -) +//go:embed templates/service_info.tmpl +var ServiceInfoTemplate string func ManageListRequest(path, idField string, columnsToDisplay, filters []string) { body, err := httpLib.FetchExpandedArray(path, idField) diff --git a/internal/services/common/service_info.go b/internal/services/common/service_info.go new file mode 100644 index 000000000..9ce0b4c5b --- /dev/null +++ b/internal/services/common/service_info.go @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package common + +import "github.com/spf13/cobra" + +// The renewal flags shared by every `service-info edit` command, paired with +// the field each one sets in the API object. +// +// The registration and the payload builder live side by side on purpose: the +// flag name is the only thing that ties them together, so a rename that +// touches one and not the other would silently stop sending a setting rather +// than fail to compile. +var serviceInfoRenewFlags = []struct { + name string + field string + usage string +}{ + {"renew-automatic", "automatic", "Renew the service automatically"}, + {"renew-delete-at-expiration", "deleteAtExpiration", "Delete the service when it expires"}, + {"renew-forced", "forced", "Force the renewal"}, + {"renew-manual-payment", "manualPayment", "Pay the renewal manually"}, + {"renew-period", "period", "Renewal period, in months"}, +} + +// AddServiceInfoRenewFlags registers the renewal flags on a `service-info +// edit` command. +func AddServiceInfoRenewFlags(cmd *cobra.Command) { + for _, flag := range serviceInfoRenewFlags { + if flag.field == "period" { + cmd.Flags().Int(flag.name, 0, flag.usage) + continue + } + cmd.Flags().Bool(flag.name, false, flag.usage) + } +} + +// ServiceInfoRenewPayload returns the renewal settings the operator actually +// asked to change, and nothing else. +// +// The distinction matters more than it looks. These settings are booleans +// bound to a struct with no `omitempty`, so building the payload from that +// struct sends every one of them on every call — and a merge that lets the +// command line win then turns `--renew-period 12` into "set the period to 12 +// AND switch automatic renewal off". The service kept renewing itself for +// years; one unrelated edit stopped it, and nothing said so. +// +// Reading `Changed` rather than the values also keeps `--renew-automatic=false` +// working: pflag records a flag as changed whatever value it was given, so an +// explicit false is sent while an absent flag stays absent. +func ServiceInfoRenewPayload(cmd *cobra.Command) map[string]any { + renew := map[string]any{} + + for _, flag := range serviceInfoRenewFlags { + if !cmd.Flags().Changed(flag.name) { + continue + } + + if flag.field == "period" { + period, err := cmd.Flags().GetInt(flag.name) + if err != nil { + continue + } + renew[flag.field] = period + continue + } + + value, err := cmd.Flags().GetBool(flag.name) + if err != nil { + continue + } + renew[flag.field] = value + } + + if len(renew) == 0 { + return map[string]any{} + } + + return map[string]any{"renew": renew} +} diff --git a/internal/services/vps/vps.go b/internal/services/vps/vps.go index c06a59100..5178be5db 100644 --- a/internal/services/vps/vps.go +++ b/internal/services/vps/vps.go @@ -310,11 +310,13 @@ func GetVpsServiceInfo(_ *cobra.Command, args []string) { } func EditVpsServiceInfo(cmd *cobra.Command, args []string) { + renewPayload := common.ServiceInfoRenewPayload(cmd) + if err := common.EditResource( cmd, "/vps/{serviceName}/serviceInfos", fmt.Sprintf("/v1/vps/%s/serviceInfos", url.PathEscape(args[0])), - common.ServiceInfoSpec, + renewPayload, assets.VpsOpenapiSchema, ); err != nil { display.OutputError(&flags.OutputFormatConfig, "%s", err) diff --git a/internal/services/webhosting/webhosting.go b/internal/services/webhosting/webhosting.go index b24ec8b4b..f8aabc6fb 100644 --- a/internal/services/webhosting/webhosting.go +++ b/internal/services/webhosting/webhosting.go @@ -940,7 +940,7 @@ func GetExtraSqlServiceInfo(_ *cobra.Command, args []string) { } func UpdateExtraSqlServiceInfo(cmd *cobra.Command, args []string) { - payload := buildServiceInfoRenewPayload(cmd) + payload := common.ServiceInfoRenewPayload(cmd) if len(payload) == 0 && !flags.ParametersViaEditor && flags.ParametersFile == "" { display.OutputInfo(&flags.OutputFormatConfig, nil, "🟠 No parameters given, nothing to edit") return @@ -2532,7 +2532,7 @@ func GetCdnServiceInfo(_ *cobra.Command, args []string) { } func UpdateCdnServiceInfo(cmd *cobra.Command, args []string) { - payload := buildServiceInfoRenewPayload(cmd) + payload := common.ServiceInfoRenewPayload(cmd) if len(payload) == 0 && !flags.ParametersViaEditor && flags.ParametersFile == "" { display.OutputInfo(&flags.OutputFormatConfig, nil, "🟠 No parameters given, nothing to edit") return @@ -2735,31 +2735,6 @@ func buildCdnOptionConfig(cmd *cobra.Command) map[string]any { return config } -func buildServiceInfoRenewPayload(cmd *cobra.Command) map[string]any { - renew := map[string]any{} - if cmd.Flags().Changed("renew-automatic") { - renew["automatic"] = common.ServiceInfoSpec.Renew.Automatic - } - if cmd.Flags().Changed("renew-delete-at-expiration") { - renew["deleteAtExpiration"] = common.ServiceInfoSpec.Renew.DeleteAtExpiration - } - if cmd.Flags().Changed("renew-forced") { - renew["forced"] = common.ServiceInfoSpec.Renew.Forced - } - if cmd.Flags().Changed("renew-manual-payment") { - renew["manualPayment"] = common.ServiceInfoSpec.Renew.ManualPayment - } - if cmd.Flags().Changed("renew-period") { - renew["period"] = common.ServiceInfoSpec.Renew.Period - } - - if len(renew) == 0 { - return map[string]any{} - } - - return map[string]any{"renew": renew} -} - func formatQuota(value any) (string, bool) { quotaMap, ok := value.(map[string]any) if !ok { @@ -2980,7 +2955,7 @@ func GetServiceInfo(_ *cobra.Command, args []string) { } func UpdateServiceInfo(cmd *cobra.Command, args []string) { - payload := buildServiceInfoRenewPayload(cmd) + payload := common.ServiceInfoRenewPayload(cmd) if len(payload) == 0 && !flags.ParametersViaEditor && flags.ParametersFile == "" && !utils.IsInputFromPipe() { display.OutputInfo(&flags.OutputFormatConfig, nil, "🟠 No parameters given, nothing to edit") return @@ -3240,7 +3215,7 @@ func GetLocalSeoLocationServiceInfo(_ *cobra.Command, args []string) { } func UpdateLocalSeoLocationServiceInfo(cmd *cobra.Command, args []string) { - payload := buildServiceInfoRenewPayload(cmd) + payload := common.ServiceInfoRenewPayload(cmd) if len(payload) == 0 && !flags.ParametersViaEditor && flags.ParametersFile == "" { display.OutputInfo(&flags.OutputFormatConfig, nil, "🟠 No parameters given, nothing to edit") return From d44f8a2fa8c1a14f99a454ff0a1d0df69b296235 Mon Sep 17 00:00:00 2001 From: Denis Hamon Date: Wed, 19 Aug 2026 10:46:22 +0200 Subject: [PATCH 02/11] refactor(service-info): register the renewal flags from the command layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-Authored-By: Claude Opus 5 (1M context) --- internal/cmd/service_info.go | 28 +++++++++++ internal/cmd/vps.go | 3 +- internal/cmd/webhosting.go | 9 ++-- internal/services/common/service_info.go | 64 +++++++++++------------- 4 files changed, 63 insertions(+), 41 deletions(-) create mode 100644 internal/cmd/service_info.go diff --git a/internal/cmd/service_info.go b/internal/cmd/service_info.go new file mode 100644 index 000000000..e08a19d1b --- /dev/null +++ b/internal/cmd/service_info.go @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/ovh/ovhcloud-cli/internal/services/common" + "github.com/spf13/cobra" +) + +// addServiceInfoRenewFlags registers the renewal flags on a `service-info edit` +// command. +// +// It sits here, beside the other shared flag helpers, rather than in the +// service package: declaring cobra flags is the command layer's job, and +// internal/services/common was the only service package doing it. The flag +// names still come from common.ServiceInfoRenewFlags, which is also what the +// payload builder reads — one table, so the two halves cannot drift. +func addServiceInfoRenewFlags(cmd *cobra.Command) { + for _, flag := range common.ServiceInfoRenewFlags { + if flag.Period { + cmd.Flags().Int(flag.Name, 0, flag.Usage) + continue + } + cmd.Flags().Bool(flag.Name, false, flag.Usage) + } +} diff --git a/internal/cmd/vps.go b/internal/cmd/vps.go index f452ef0c6..f112381a0 100644 --- a/internal/cmd/vps.go +++ b/internal/cmd/vps.go @@ -11,7 +11,6 @@ import ( "github.com/ovh/ovhcloud-cli/internal/assets" "github.com/ovh/ovhcloud-cli/internal/completion" "github.com/ovh/ovhcloud-cli/internal/flags" - "github.com/ovh/ovhcloud-cli/internal/services/common" "github.com/ovh/ovhcloud-cli/internal/services/vps" "github.com/spf13/cobra" ) @@ -221,7 +220,7 @@ func init() { ValidArgsFunction: completion.ServiceList("/v1/vps"), Run: vps.EditVpsServiceInfo, } - common.AddServiceInfoRenewFlags(serviceInfoEditCmd) + addServiceInfoRenewFlags(serviceInfoEditCmd) addInteractiveEditorFlag(serviceInfoEditCmd) serviceInfoCmd.AddCommand(serviceInfoEditCmd) diff --git a/internal/cmd/webhosting.go b/internal/cmd/webhosting.go index 7e5096d0f..e98a7e7f7 100644 --- a/internal/cmd/webhosting.go +++ b/internal/cmd/webhosting.go @@ -10,7 +10,6 @@ import ( "strings" "github.com/ovh/ovhcloud-cli/internal/completion" - "github.com/ovh/ovhcloud-cli/internal/services/common" "github.com/ovh/ovhcloud-cli/internal/services/webhosting" "github.com/spf13/cobra" ) @@ -605,7 +604,7 @@ func init() { ValidArgsFunction: completion.ServiceList("/v1/hosting/web"), Run: webhosting.UpdateExtraSqlServiceInfo, } - common.AddServiceInfoRenewFlags(extraSQLServiceInfoUpdateCmd) + addServiceInfoRenewFlags(extraSQLServiceInfoUpdateCmd) addParameterFileFlags(extraSQLServiceInfoUpdateCmd, true, nil, "", "", "", nil) addInteractiveEditorFlag(extraSQLServiceInfoUpdateCmd) extraSQLServiceInfoCmd.AddCommand(extraSQLServiceInfoUpdateCmd) @@ -1326,7 +1325,7 @@ func init() { ValidArgsFunction: completion.ServiceList("/v1/hosting/web"), Run: webhosting.UpdateCdnServiceInfo, } - common.AddServiceInfoRenewFlags(cdnServiceInfoUpdateCmd) + addServiceInfoRenewFlags(cdnServiceInfoUpdateCmd) addParameterFileFlags(cdnServiceInfoUpdateCmd, true, nil, "", "", "", nil) addInteractiveEditorFlag(cdnServiceInfoUpdateCmd) cdnServiceInfoCmd.AddCommand(cdnServiceInfoUpdateCmd) @@ -1460,7 +1459,7 @@ func init() { ValidArgsFunction: completion.ServiceList("/v1/hosting/web"), Run: webhosting.UpdateServiceInfo, } - common.AddServiceInfoRenewFlags(serviceInfoUpdateCmd) + addServiceInfoRenewFlags(serviceInfoUpdateCmd) addParameterFileFlags(serviceInfoUpdateCmd, true, nil, "", "", "", nil) addInteractiveEditorFlag(serviceInfoUpdateCmd) serviceInfoCmd.AddCommand(serviceInfoUpdateCmd) @@ -1571,7 +1570,7 @@ func init() { ValidArgsFunction: completion.ServiceList("/v1/hosting/web"), Run: webhosting.UpdateLocalSeoLocationServiceInfo, } - common.AddServiceInfoRenewFlags(localSeoLocationServiceInfoUpdateCmd) + addServiceInfoRenewFlags(localSeoLocationServiceInfoUpdateCmd) addParameterFileFlags(localSeoLocationServiceInfoUpdateCmd, true, nil, "", "", "", nil) addInteractiveEditorFlag(localSeoLocationServiceInfoUpdateCmd) localSeoLocationServiceInfoCmd.AddCommand(localSeoLocationServiceInfoUpdateCmd) diff --git a/internal/services/common/service_info.go b/internal/services/common/service_info.go index 9ce0b4c5b..b678696f0 100644 --- a/internal/services/common/service_info.go +++ b/internal/services/common/service_info.go @@ -6,35 +6,31 @@ package common import "github.com/spf13/cobra" -// The renewal flags shared by every `service-info edit` command, paired with -// the field each one sets in the API object. -// -// The registration and the payload builder live side by side on purpose: the -// flag name is the only thing that ties them together, so a rename that -// touches one and not the other would silently stop sending a setting rather -// than fail to compile. -var serviceInfoRenewFlags = []struct { - name string - field string - usage string -}{ - {"renew-automatic", "automatic", "Renew the service automatically"}, - {"renew-delete-at-expiration", "deleteAtExpiration", "Delete the service when it expires"}, - {"renew-forced", "forced", "Force the renewal"}, - {"renew-manual-payment", "manualPayment", "Pay the renewal manually"}, - {"renew-period", "period", "Renewal period, in months"}, +// ServiceInfoRenewFlag describes one renewal flag: what it is called on the +// command line, and which field of the API object it sets. +type ServiceInfoRenewFlag struct { + Name string + Field string + Usage string + + // Period is the one flag that carries a number rather than a yes or no. + Period bool } -// AddServiceInfoRenewFlags registers the renewal flags on a `service-info -// edit` command. -func AddServiceInfoRenewFlags(cmd *cobra.Command) { - for _, flag := range serviceInfoRenewFlags { - if flag.field == "period" { - cmd.Flags().Int(flag.name, 0, flag.usage) - continue - } - cmd.Flags().Bool(flag.name, false, flag.usage) - } +// ServiceInfoRenewFlags is the single description of the renewal flags shared +// by every `service-info edit` command. +// +// It is exported rather than kept private because the command layer registers +// the flags and this layer reads them back: the flag name is the only thing +// tying the two halves together, so they must not each hold their own copy of +// it. One table, read twice — a rename in it changes both sides at once, and a +// rename anywhere else does not compile. +var ServiceInfoRenewFlags = []ServiceInfoRenewFlag{ + {Name: "renew-automatic", Field: "automatic", Usage: "Renew the service automatically"}, + {Name: "renew-delete-at-expiration", Field: "deleteAtExpiration", Usage: "Delete the service when it expires"}, + {Name: "renew-forced", Field: "forced", Usage: "Force the renewal"}, + {Name: "renew-manual-payment", Field: "manualPayment", Usage: "Pay the renewal manually"}, + {Name: "renew-period", Field: "period", Usage: "Renewal period, in months", Period: true}, } // ServiceInfoRenewPayload returns the renewal settings the operator actually @@ -53,25 +49,25 @@ func AddServiceInfoRenewFlags(cmd *cobra.Command) { func ServiceInfoRenewPayload(cmd *cobra.Command) map[string]any { renew := map[string]any{} - for _, flag := range serviceInfoRenewFlags { - if !cmd.Flags().Changed(flag.name) { + for _, flag := range ServiceInfoRenewFlags { + if !cmd.Flags().Changed(flag.Name) { continue } - if flag.field == "period" { - period, err := cmd.Flags().GetInt(flag.name) + if flag.Period { + period, err := cmd.Flags().GetInt(flag.Name) if err != nil { continue } - renew[flag.field] = period + renew[flag.Field] = period continue } - value, err := cmd.Flags().GetBool(flag.name) + value, err := cmd.Flags().GetBool(flag.Name) if err != nil { continue } - renew[flag.field] = value + renew[flag.Field] = value } if len(renew) == 0 { From 8d3527e51252f6e1ff9e973bf37316bc3330dd6b Mon Sep 17 00:00:00 2001 From: Denis Date: Thu, 20 Aug 2026 12:38:46 +0200 Subject: [PATCH 03/11] feat(account): read an invoice, and say what a machine actually costs The /me schema carries 147 billing paths and the CLI exposed none of them. An operator who wanted to know what a server costs, or what the last invoice charged for, had to leave the terminal. Three measurements shaped this rather than the schema. The date window is a guard, not a convenience. The account this was built against holds 2215 invoices; a year is 396 and a month is 40. Listing expands one HTTP call per invoice, so an unbounded `bill list` is 2215 requests. Without --from the window is the current month, and past 400 matches the command says how many rather than making them. The download link is a bearer credential, and that is measured rather than assumed: a real pdfUrl returns the PDF in full with no API token at all -- 200, application/pdf, 298 KB -- and the same URL with its esign parameter altered returns HTML instead. So password, url and pdfUrl are fingerprinted unless --reveal, with the substitution applied to the object and not in the template. A mask that only covers the human-readable output covers nothing, because it is the pipeline that logs. Usage is one row per element because the entry-level price is null on every current-period entry measured, and the amount lives in the elements. A table built on the entry would have shown a column of nothing. `baremetal cost` exists because dedicated servers appear nowhere in usage -- 0 of 12 tested -- being billed flat rather than per use. Summing invoice lines for one machine costs 1109 requests for a single month; /v1/services answers in six, and answers better: a server is four or five billable services, so the price shown is the machine as configured, not its base plan. Three servers on this account carry a component billed at 70, 10 and 8 euros, which is why the components are added rather than listed for decoration. It reads renewal from /v1/services and not from serviceInfos, deliberately. Six of the 35 servers are child services -- a parentServiceId, renewalType "option", no renewal block of their own -- and on exactly those six serviceInfos returns contradictory values for renew.automatic on consecutive reads: 30 reads in 13 seconds gave 16 transitions with no convergence, every response a 200. Filed as PUBM-55135. So this reports what is true, that the parent carries the renewal, and names it, instead of deriving a boolean that would be right half the time. The billing surfaces that are empty on this account -- deposit, withdrawal, reverse bill, down payment, credit balance, billing group, purchase order, payment transaction, voucher -- are left out. Listing them is demonstrable; their details are not, on this account. Signed-off-by: Denis --- doc/ovhcloud_account.md | 3 + doc/ovhcloud_account_bill.md | 36 ++ doc/ovhcloud_account_bill_details.md | 44 ++ doc/ovhcloud_account_bill_get.md | 38 ++ doc/ovhcloud_account_bill_list.md | 57 +++ doc/ovhcloud_account_refund.md | 35 ++ doc/ovhcloud_account_refund_get.md | 38 ++ doc/ovhcloud_account_refund_list.md | 48 ++ doc/ovhcloud_account_usage.md | 52 +++ doc/ovhcloud_baremetal.md | 1 + doc/ovhcloud_baremetal_cost.md | 52 +++ internal/cmd/account.go | 93 ++++ internal/cmd/account_billing_test.go | 293 ++++++++++++ internal/cmd/baremetal.go | 12 + internal/services/account/account.go | 6 + internal/services/account/billing.go | 416 ++++++++++++++++++ internal/services/account/billing_test.go | 157 +++++++ internal/services/account/templates/bill.tmpl | 28 ++ .../services/account/templates/refund.tmpl | 23 + internal/services/baremetal/cost.go | 234 ++++++++++ internal/services/baremetal/cost_test.go | 124 ++++++ .../services/baremetal/templates/cost.tmpl | 15 + 22 files changed, 1805 insertions(+) create mode 100644 doc/ovhcloud_account_bill.md create mode 100644 doc/ovhcloud_account_bill_details.md create mode 100644 doc/ovhcloud_account_bill_get.md create mode 100644 doc/ovhcloud_account_bill_list.md create mode 100644 doc/ovhcloud_account_refund.md create mode 100644 doc/ovhcloud_account_refund_get.md create mode 100644 doc/ovhcloud_account_refund_list.md create mode 100644 doc/ovhcloud_account_usage.md create mode 100644 doc/ovhcloud_baremetal_cost.md create mode 100644 internal/cmd/account_billing_test.go create mode 100644 internal/services/account/billing.go create mode 100644 internal/services/account/billing_test.go create mode 100644 internal/services/account/templates/bill.tmpl create mode 100644 internal/services/account/templates/refund.tmpl create mode 100644 internal/services/baremetal/cost.go create mode 100644 internal/services/baremetal/cost_test.go create mode 100644 internal/services/baremetal/templates/cost.tmpl diff --git a/doc/ovhcloud_account.md b/doc/ovhcloud_account.md index 236febf05..bac67ede2 100644 --- a/doc/ovhcloud_account.md +++ b/doc/ovhcloud_account.md @@ -31,6 +31,9 @@ Manage your account * [ovhcloud](ovhcloud.md) - CLI to manage your OVHcloud services * [ovhcloud account api](ovhcloud_account_api.md) - Manage your API credentials +* [ovhcloud account bill](ovhcloud_account_bill.md) - Read your invoices * [ovhcloud account get](ovhcloud_account_get.md) - Retrieve basic personal information +* [ovhcloud account refund](ovhcloud_account_refund.md) - Read your refunds * [ovhcloud account ssh-key](ovhcloud_account_ssh-key.md) - Manage your SSH keys +* [ovhcloud account usage](ovhcloud_account_usage.md) - Show what is running against your next invoice diff --git a/doc/ovhcloud_account_bill.md b/doc/ovhcloud_account_bill.md new file mode 100644 index 000000000..773ffd983 --- /dev/null +++ b/doc/ovhcloud_account_bill.md @@ -0,0 +1,36 @@ +## ovhcloud account bill + +Read your invoices + +### Options + +``` + -h, --help help for bill +``` + +### Options inherited from parent commands + +``` + -d, --debug Activate debug mode (will log all HTTP requests details) + -e, --ignore-errors Ignore errors in API calls when it is not fatal to the execution + -o, --output string Output format: json, yaml, interactive, or a custom format expression (using https://github.com/PaesslerAG/gval syntax) + Examples: + --output json + --output yaml + --output interactive + --output 'id' (to extract a single field) + --output 'nested.field.subfield' (to extract a nested field) + --output '[id, "name"]' (to extract multiple fields as an array) + --output '{"newKey": oldKey, "otherKey": nested.field}' (to extract and rename fields in an object) + --output 'name+","+type' (to extract and concatenate fields in a string) + --output '(nbFieldA + nbFieldB) * 10' (to compute values from numeric fields) + --profile string Use a specific profile from the configuration file +``` + +### SEE ALSO + +* [ovhcloud account](ovhcloud_account.md) - Manage your account +* [ovhcloud account bill details](ovhcloud_account_bill_details.md) - List what one invoice charges for +* [ovhcloud account bill get](ovhcloud_account_bill_get.md) - Get one invoice +* [ovhcloud account bill list](ovhcloud_account_bill_list.md) - List your invoices + diff --git a/doc/ovhcloud_account_bill_details.md b/doc/ovhcloud_account_bill_details.md new file mode 100644 index 000000000..22a6d251e --- /dev/null +++ b/doc/ovhcloud_account_bill_details.md @@ -0,0 +1,44 @@ +## ovhcloud account bill details + +List what one invoice charges for + +``` +ovhcloud account bill details [flags] +``` + +### Options + +``` + --filter stringArray Filter results by any property using https://github.com/PaesslerAG/gval syntax + Examples: + --filter 'state=="running"' + --filter 'name=~"^my.*"' + --filter 'nested.property.subproperty>10' + --filter 'startDate>="2023-12-01"' + --filter 'name=~"something" && nbField>10' + -h, --help help for details +``` + +### Options inherited from parent commands + +``` + -d, --debug Activate debug mode (will log all HTTP requests details) + -e, --ignore-errors Ignore errors in API calls when it is not fatal to the execution + -o, --output string Output format: json, yaml, interactive, or a custom format expression (using https://github.com/PaesslerAG/gval syntax) + Examples: + --output json + --output yaml + --output interactive + --output 'id' (to extract a single field) + --output 'nested.field.subfield' (to extract a nested field) + --output '[id, "name"]' (to extract multiple fields as an array) + --output '{"newKey": oldKey, "otherKey": nested.field}' (to extract and rename fields in an object) + --output 'name+","+type' (to extract and concatenate fields in a string) + --output '(nbFieldA + nbFieldB) * 10' (to compute values from numeric fields) + --profile string Use a specific profile from the configuration file +``` + +### SEE ALSO + +* [ovhcloud account bill](ovhcloud_account_bill.md) - Read your invoices + diff --git a/doc/ovhcloud_account_bill_get.md b/doc/ovhcloud_account_bill_get.md new file mode 100644 index 000000000..f46295227 --- /dev/null +++ b/doc/ovhcloud_account_bill_get.md @@ -0,0 +1,38 @@ +## ovhcloud account bill get + +Get one invoice + +``` +ovhcloud account bill get [flags] +``` + +### Options + +``` + -h, --help help for get + --reveal Print the download link and the PDF password instead of their fingerprints +``` + +### Options inherited from parent commands + +``` + -d, --debug Activate debug mode (will log all HTTP requests details) + -e, --ignore-errors Ignore errors in API calls when it is not fatal to the execution + -o, --output string Output format: json, yaml, interactive, or a custom format expression (using https://github.com/PaesslerAG/gval syntax) + Examples: + --output json + --output yaml + --output interactive + --output 'id' (to extract a single field) + --output 'nested.field.subfield' (to extract a nested field) + --output '[id, "name"]' (to extract multiple fields as an array) + --output '{"newKey": oldKey, "otherKey": nested.field}' (to extract and rename fields in an object) + --output 'name+","+type' (to extract and concatenate fields in a string) + --output '(nbFieldA + nbFieldB) * 10' (to compute values from numeric fields) + --profile string Use a specific profile from the configuration file +``` + +### SEE ALSO + +* [ovhcloud account bill](ovhcloud_account_bill.md) - Read your invoices + diff --git a/doc/ovhcloud_account_bill_list.md b/doc/ovhcloud_account_bill_list.md new file mode 100644 index 000000000..cc22f100c --- /dev/null +++ b/doc/ovhcloud_account_bill_list.md @@ -0,0 +1,57 @@ +## ovhcloud account bill list + +List your invoices + +### Synopsis + +List your invoices. + +Without --from, the window is the current month. That is a guard, not a +default for comfort: an account can hold thousands of invoices, and each +one listed is one request to detail it. + +``` +ovhcloud account bill list [flags] +``` + +### Options + +``` + --category string Keep only one category of invoice + --filter stringArray Filter results by any property using https://github.com/PaesslerAG/gval syntax + Examples: + --filter 'state=="running"' + --filter 'name=~"^my.*"' + --filter 'nested.property.subproperty>10' + --filter 'startDate>="2023-12-01"' + --filter 'name=~"something" && nbField>10' + --from string Start of the window, YYYY-MM-DD or RFC3339 (default: first day of the current month) + -h, --help help for list + --order-id int Keep only what was billed for this order + --reveal Print the download links instead of their fingerprints + --to string End of the window, YYYY-MM-DD or RFC3339 +``` + +### Options inherited from parent commands + +``` + -d, --debug Activate debug mode (will log all HTTP requests details) + -e, --ignore-errors Ignore errors in API calls when it is not fatal to the execution + -o, --output string Output format: json, yaml, interactive, or a custom format expression (using https://github.com/PaesslerAG/gval syntax) + Examples: + --output json + --output yaml + --output interactive + --output 'id' (to extract a single field) + --output 'nested.field.subfield' (to extract a nested field) + --output '[id, "name"]' (to extract multiple fields as an array) + --output '{"newKey": oldKey, "otherKey": nested.field}' (to extract and rename fields in an object) + --output 'name+","+type' (to extract and concatenate fields in a string) + --output '(nbFieldA + nbFieldB) * 10' (to compute values from numeric fields) + --profile string Use a specific profile from the configuration file +``` + +### SEE ALSO + +* [ovhcloud account bill](ovhcloud_account_bill.md) - Read your invoices + diff --git a/doc/ovhcloud_account_refund.md b/doc/ovhcloud_account_refund.md new file mode 100644 index 000000000..42ca0a4df --- /dev/null +++ b/doc/ovhcloud_account_refund.md @@ -0,0 +1,35 @@ +## ovhcloud account refund + +Read your refunds + +### Options + +``` + -h, --help help for refund +``` + +### Options inherited from parent commands + +``` + -d, --debug Activate debug mode (will log all HTTP requests details) + -e, --ignore-errors Ignore errors in API calls when it is not fatal to the execution + -o, --output string Output format: json, yaml, interactive, or a custom format expression (using https://github.com/PaesslerAG/gval syntax) + Examples: + --output json + --output yaml + --output interactive + --output 'id' (to extract a single field) + --output 'nested.field.subfield' (to extract a nested field) + --output '[id, "name"]' (to extract multiple fields as an array) + --output '{"newKey": oldKey, "otherKey": nested.field}' (to extract and rename fields in an object) + --output 'name+","+type' (to extract and concatenate fields in a string) + --output '(nbFieldA + nbFieldB) * 10' (to compute values from numeric fields) + --profile string Use a specific profile from the configuration file +``` + +### SEE ALSO + +* [ovhcloud account](ovhcloud_account.md) - Manage your account +* [ovhcloud account refund get](ovhcloud_account_refund_get.md) - Get one refund +* [ovhcloud account refund list](ovhcloud_account_refund_list.md) - List your refunds + diff --git a/doc/ovhcloud_account_refund_get.md b/doc/ovhcloud_account_refund_get.md new file mode 100644 index 000000000..a14a080e9 --- /dev/null +++ b/doc/ovhcloud_account_refund_get.md @@ -0,0 +1,38 @@ +## ovhcloud account refund get + +Get one refund + +``` +ovhcloud account refund get [flags] +``` + +### Options + +``` + -h, --help help for get + --reveal Print the download link and the PDF password instead of their fingerprints +``` + +### Options inherited from parent commands + +``` + -d, --debug Activate debug mode (will log all HTTP requests details) + -e, --ignore-errors Ignore errors in API calls when it is not fatal to the execution + -o, --output string Output format: json, yaml, interactive, or a custom format expression (using https://github.com/PaesslerAG/gval syntax) + Examples: + --output json + --output yaml + --output interactive + --output 'id' (to extract a single field) + --output 'nested.field.subfield' (to extract a nested field) + --output '[id, "name"]' (to extract multiple fields as an array) + --output '{"newKey": oldKey, "otherKey": nested.field}' (to extract and rename fields in an object) + --output 'name+","+type' (to extract and concatenate fields in a string) + --output '(nbFieldA + nbFieldB) * 10' (to compute values from numeric fields) + --profile string Use a specific profile from the configuration file +``` + +### SEE ALSO + +* [ovhcloud account refund](ovhcloud_account_refund.md) - Read your refunds + diff --git a/doc/ovhcloud_account_refund_list.md b/doc/ovhcloud_account_refund_list.md new file mode 100644 index 000000000..a4d9d9a3d --- /dev/null +++ b/doc/ovhcloud_account_refund_list.md @@ -0,0 +1,48 @@ +## ovhcloud account refund list + +List your refunds + +``` +ovhcloud account refund list [flags] +``` + +### Options + +``` + --filter stringArray Filter results by any property using https://github.com/PaesslerAG/gval syntax + Examples: + --filter 'state=="running"' + --filter 'name=~"^my.*"' + --filter 'nested.property.subproperty>10' + --filter 'startDate>="2023-12-01"' + --filter 'name=~"something" && nbField>10' + --from string Start of the window, YYYY-MM-DD or RFC3339 (default: first day of the current month) + -h, --help help for list + --order-id int Keep only what was refunded for this order + --reveal Print the download links instead of their fingerprints + --to string End of the window, YYYY-MM-DD or RFC3339 +``` + +### Options inherited from parent commands + +``` + -d, --debug Activate debug mode (will log all HTTP requests details) + -e, --ignore-errors Ignore errors in API calls when it is not fatal to the execution + -o, --output string Output format: json, yaml, interactive, or a custom format expression (using https://github.com/PaesslerAG/gval syntax) + Examples: + --output json + --output yaml + --output interactive + --output 'id' (to extract a single field) + --output 'nested.field.subfield' (to extract a nested field) + --output '[id, "name"]' (to extract multiple fields as an array) + --output '{"newKey": oldKey, "otherKey": nested.field}' (to extract and rename fields in an object) + --output 'name+","+type' (to extract and concatenate fields in a string) + --output '(nbFieldA + nbFieldB) * 10' (to compute values from numeric fields) + --profile string Use a specific profile from the configuration file +``` + +### SEE ALSO + +* [ovhcloud account refund](ovhcloud_account_refund.md) - Read your refunds + diff --git a/doc/ovhcloud_account_usage.md b/doc/ovhcloud_account_usage.md new file mode 100644 index 000000000..5409eebb5 --- /dev/null +++ b/doc/ovhcloud_account_usage.md @@ -0,0 +1,52 @@ +## ovhcloud account usage + +Show what is running against your next invoice + +### Synopsis + +Show what is running against your next invoice. + +Dedicated servers do not appear here: they are billed at a flat rate, not +per usage. What a machine costs is answered by: ovhcloud baremetal cost + +``` +ovhcloud account usage [flags] +``` + +### Options + +``` + --filter stringArray Filter results by any property using https://github.com/PaesslerAG/gval syntax + Examples: + --filter 'state=="running"' + --filter 'name=~"^my.*"' + --filter 'nested.property.subproperty>10' + --filter 'startDate>="2023-12-01"' + --filter 'name=~"something" && nbField>10' + --forecast Show the forecast for the period instead of the usage so far + -h, --help help for usage +``` + +### Options inherited from parent commands + +``` + -d, --debug Activate debug mode (will log all HTTP requests details) + -e, --ignore-errors Ignore errors in API calls when it is not fatal to the execution + -o, --output string Output format: json, yaml, interactive, or a custom format expression (using https://github.com/PaesslerAG/gval syntax) + Examples: + --output json + --output yaml + --output interactive + --output 'id' (to extract a single field) + --output 'nested.field.subfield' (to extract a nested field) + --output '[id, "name"]' (to extract multiple fields as an array) + --output '{"newKey": oldKey, "otherKey": nested.field}' (to extract and rename fields in an object) + --output 'name+","+type' (to extract and concatenate fields in a string) + --output '(nbFieldA + nbFieldB) * 10' (to compute values from numeric fields) + --profile string Use a specific profile from the configuration file +``` + +### SEE ALSO + +* [ovhcloud account](ovhcloud_account.md) - Manage your account + diff --git a/doc/ovhcloud_baremetal.md b/doc/ovhcloud_baremetal.md index af6a25e45..406d6fb07 100644 --- a/doc/ovhcloud_baremetal.md +++ b/doc/ovhcloud_baremetal.md @@ -35,6 +35,7 @@ Retrieve information and manage your Bare Metal services * [ovhcloud baremetal boot](ovhcloud_baremetal_boot.md) - Manage boot options for the given baremetal * [ovhcloud baremetal catalog](ovhcloud_baremetal_catalog.md) - List orderable servers, their availability and their price * [ovhcloud baremetal confirm-termination](ovhcloud_baremetal_confirm-termination.md) - Confirm the termination of the given baremetal +* [ovhcloud baremetal cost](ovhcloud_baremetal_cost.md) - Show what a server costs and when it renews * [ovhcloud baremetal doctor](ovhcloud_baremetal_doctor.md) - Report what is wrong with a server, or with every server * [ovhcloud baremetal edit](ovhcloud_baremetal_edit.md) - Update the given baremetal * [ovhcloud baremetal get](ovhcloud_baremetal_get.md) - Retrieve information of a specific baremetal diff --git a/doc/ovhcloud_baremetal_cost.md b/doc/ovhcloud_baremetal_cost.md new file mode 100644 index 000000000..2d4815ba5 --- /dev/null +++ b/doc/ovhcloud_baremetal_cost.md @@ -0,0 +1,52 @@ +## ovhcloud baremetal cost + +Show what a server costs and when it renews + +### Synopsis + +Show what a server costs and when it renews. + +A server resolves to several billable services: the machine, and the +components sold with it. This lists them all, so the price shown is the +price of the machine as configured rather than of its base plan. + +``` +ovhcloud baremetal cost [flags] +``` + +### Options + +``` + --filter stringArray Filter results by any property using https://github.com/PaesslerAG/gval syntax + Examples: + --filter 'state=="running"' + --filter 'name=~"^my.*"' + --filter 'nested.property.subproperty>10' + --filter 'startDate>="2023-12-01"' + --filter 'name=~"something" && nbField>10' + -h, --help help for cost +``` + +### Options inherited from parent commands + +``` + -d, --debug Activate debug mode (will log all HTTP requests details) + -e, --ignore-errors Ignore errors in API calls when it is not fatal to the execution + -o, --output string Output format: json, yaml, interactive, or a custom format expression (using https://github.com/PaesslerAG/gval syntax) + Examples: + --output json + --output yaml + --output interactive + --output 'id' (to extract a single field) + --output 'nested.field.subfield' (to extract a nested field) + --output '[id, "name"]' (to extract multiple fields as an array) + --output '{"newKey": oldKey, "otherKey": nested.field}' (to extract and rename fields in an object) + --output 'name+","+type' (to extract and concatenate fields in a string) + --output '(nbFieldA + nbFieldB) * 10' (to compute values from numeric fields) + --profile string Use a specific profile from the configuration file +``` + +### SEE ALSO + +* [ovhcloud baremetal](ovhcloud_baremetal.md) - Retrieve information and manage your Bare Metal services + diff --git a/internal/cmd/account.go b/internal/cmd/account.go index 1f69eddf5..d9c26721a 100644 --- a/internal/cmd/account.go +++ b/internal/cmd/account.go @@ -107,5 +107,98 @@ func init() { addInteractiveEditorFlag(oauth2ClientEditCmd) oauth2ClientCmd.AddCommand(oauth2ClientEditCmd) + // Billing commands + billCmd := &cobra.Command{ + Use: "bill", + Short: "Read your invoices", + } + accountCmd.AddCommand(billCmd) + + billListCmd := withFilterFlag(&cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List your invoices", + Long: "List your invoices.\n\n" + + "Without --from, the window is the current month. That is a guard, not a\n" + + "default for comfort: an account can hold thousands of invoices, and each\n" + + "one listed is one request to detail it.", + Run: account.ListBills, + }) + addBillingWindowFlags(billListCmd) + billListCmd.Flags().StringVar(&account.BillCategory, "category", "", "Keep only one category of invoice") + billListCmd.Flags().Int64Var(&account.BillOrderID, "order-id", 0, "Keep only what was billed for this order") + billListCmd.RegisterFlagCompletionFunc("category", account.CompleteBillCategory) + billListCmd.Flags().BoolVar(&account.RevealBillSecrets, "reveal", false, + "Print the download links instead of their fingerprints") + billCmd.AddCommand(billListCmd) + + billGetCmd := &cobra.Command{ + Use: "get ", + Short: "Get one invoice", + Args: cobra.ExactArgs(1), + Run: account.GetBill, + } + billGetCmd.Flags().BoolVar(&account.RevealBillSecrets, "reveal", false, + "Print the download link and the PDF password instead of their fingerprints") + billCmd.AddCommand(billGetCmd) + + billCmd.AddCommand(withFilterFlag(&cobra.Command{ + Use: "details ", + Short: "List what one invoice charges for", + Args: cobra.ExactArgs(1), + Run: account.ListBillDetails, + })) + + // Refunds + refundCmd := &cobra.Command{ + Use: "refund", + Short: "Read your refunds", + } + accountCmd.AddCommand(refundCmd) + + refundListCmd := withFilterFlag(&cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List your refunds", + Run: account.ListRefunds, + }) + addBillingWindowFlags(refundListCmd) + refundListCmd.Flags().Int64Var(&account.BillOrderID, "order-id", 0, "Keep only what was refunded for this order") + refundListCmd.Flags().BoolVar(&account.RevealBillSecrets, "reveal", false, + "Print the download links instead of their fingerprints") + refundCmd.AddCommand(refundListCmd) + + refundGetCmd := &cobra.Command{ + Use: "get ", + Short: "Get one refund", + Args: cobra.ExactArgs(1), + Run: account.GetRefund, + } + refundGetCmd.Flags().BoolVar(&account.RevealBillSecrets, "reveal", false, + "Print the download link and the PDF password instead of their fingerprints") + refundCmd.AddCommand(refundGetCmd) + + // Usage running against the next invoice + usageCmd := withFilterFlag(&cobra.Command{ + Use: "usage", + Short: "Show what is running against your next invoice", + Long: "Show what is running against your next invoice.\n\n" + + "Dedicated servers do not appear here: they are billed at a flat rate, not\n" + + "per usage. What a machine costs is answered by: ovhcloud baremetal cost ", + Run: account.ShowUsage, + }) + usageCmd.Flags().BoolVar(&account.BillUsageForecast, "forecast", false, + "Show the forecast for the period instead of the usage so far") + accountCmd.AddCommand(usageCmd) + rootCmd.AddCommand(accountCmd) } + +// addBillingWindowFlags gives a listing its window. Both bounds accept a plain +// YYYY-MM-DD as well as a full RFC3339 timestamp. +func addBillingWindowFlags(cmd *cobra.Command) { + cmd.Flags().StringVar(&account.BillFrom, "from", "", + "Start of the window, YYYY-MM-DD or RFC3339 (default: first day of the current month)") + cmd.Flags().StringVar(&account.BillTo, "to", "", + "End of the window, YYYY-MM-DD or RFC3339") +} diff --git a/internal/cmd/account_billing_test.go b/internal/cmd/account_billing_test.go new file mode 100644 index 000000000..afb890926 --- /dev/null +++ b/internal/cmd/account_billing_test.go @@ -0,0 +1,293 @@ +// SPDX-FileCopyrightText: 2026 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + "fmt" + "net/http" + "strings" + + "github.com/jarcoal/httpmock" + "github.com/maxatome/go-testdeep/td" + "github.com/ovh/ovhcloud-cli/internal/cmd" +) + +const ( + billsURL = "https://eu.api.ovh.com/v1/me/bill" + refundsURL = "https://eu.api.ovh.com/v1/me/refund" + usageURL = "https://eu.api.ovh.com/v1/me/consumption/usage/current" + forecastURL = "https://eu.api.ovh.com/v1/me/consumption/usage/forecast" + servicesURL = "https://eu.api.ovh.com/v1/services" +) + +// captureQuery answers a collection and records the query it was asked with. +func captureQuery(url, body string, seen *string) { + httpmock.RegisterResponder(http.MethodGet, url, + func(req *http.Request) (*http.Response, error) { + *seen = req.URL.RawQuery + return httpmock.NewStringResponse(200, body), nil + }) +} + +func registerOneBill(id string) { + httpmock.RegisterResponder(http.MethodGet, billsURL+"/"+id, + httpmock.NewStringResponder(200, fmt.Sprintf(`{"billId":%q,"date":"2026-08-01T08:19:43+02:00", + "category":"autorenew","orderId":255190117, + "priceWithTax":{"text":"104.99 €","value":104.99,"currencyCode":"EUR"}, + "priceWithoutTax":{"text":"104.99 €","value":104.99,"currencyCode":"EUR"}, + "tax":{"text":"0.00 €","value":0,"currencyCode":"EUR"}, + "password":"B1llP4ssEx", + "url":"https://www.ovh.com/cgi-bin/order/facture.pdf?esign=SIGNATURE&reference=x×tamp=1", + "pdfUrl":"https://www.ovh.com/cgi-bin/order/facture.pdf?esign=SIGNATURE&reference=x×tamp=1"}`, id))) +} + +// Without --from the window is the current month: the account this was built +// against holds 2215 invoices, and each one listed is one request to detail it. +func (ms *MockSuite) TestBillListDefaultsToTheCurrentMonth(assert, require *td.T) { + var query string + captureQuery(billsURL, `[]`, &query) + + _, err := cmd.Execute("account", "bill", "list") + + require.CmpNoError(err) + assert.Cmp(query, td.Contains("date.from=")) + assert.Cmp(query, td.Contains("-01T00%3A00%3A00Z"), "the window starts on the first of the month") +} + +// The category is a server-side filter. Applying it after the fact would list +// everything first, which is what the window guard exists to prevent. +func (ms *MockSuite) TestBillListSendsTheCategoryToTheApi(assert, require *td.T) { + var query string + captureQuery(billsURL, `[]`, &query) + + _, err := cmd.Execute("account", "bill", "list", "--category", "purchase-servers") + + require.CmpNoError(err) + assert.Cmp(query, td.Contains("category=purchase-servers")) +} + +// The accepted values are read from the embedded schema, not retyped. +func (ms *MockSuite) TestBillListRefusesACategoryTheApiDoesNotKnow(assert, require *td.T) { + captureQuery(billsURL, `[]`, new(string)) + + _, err := cmd.Execute("account", "bill", "list", "--category", "purchase-unicorns") + + require.CmpError(err) + assert.Cmp(err.Error(), td.Contains("purchase-unicorns")) + assert.Cmp(err.Error(), td.Contains("purchase-servers"), "the refusal lists what is accepted") + assert.Cmp(httpmock.GetTotalCallCount(), 0, "nothing is asked of the API before the flag is checked") +} + +// Expanding a listing is one request per invoice. Past the guard it says how +// many rather than making them. +func (ms *MockSuite) TestBillListRefusesAWindowTooWideToExpand(assert, require *td.T) { + ids := make([]string, 0, 401) + for i := range 401 { + ids = append(ids, fmt.Sprintf("%q", fmt.Sprintf("PI_FR%d", i))) + } + captureQuery(billsURL, "["+strings.Join(ids, ",")+"]", new(string)) + + _, err := cmd.Execute("account", "bill", "list", "--from", "2019-01-01") + + require.CmpError(err) + assert.Cmp(err.Error(), td.Contains("401 invoices match")) + assert.Cmp(err.Error(), td.Contains("--from")) + assert.Cmp(httpmock.GetTotalCallCount(), 1, "not one invoice is expanded once the count is known") +} + +// The link returns the PDF with no API token at all. A mask that only covers +// the human-readable output covers nothing, so it is applied to the object. +func (ms *MockSuite) TestBillGetHidesTheLinkAndPasswordEvenInJson(assert, require *td.T) { + registerOneBill("PI_FR1") + + out, err := cmd.Execute("account", "bill", "get", "PI_FR1", "-o", "json") + + require.CmpNoError(err) + assert.Cmp(out, td.Not(td.Contains("SIGNATURE")), "the signature must never reach stdout") + assert.Cmp(out, td.Not(td.Contains("B1llP4ssEx"))) + assert.Cmp(out, td.Contains(`"hidden": true`)) +} + +func (ms *MockSuite) TestBillGetRevealsOnDemand(assert, require *td.T) { + registerOneBill("PI_FR1") + + out, err := cmd.Execute("account", "bill", "get", "PI_FR1", "--reveal", "-o", "json") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("SIGNATURE")) + assert.Cmp(out, td.Contains("B1llP4ssEx")) +} + +func (ms *MockSuite) TestBillDetailsListsWhatWasCharged(assert, require *td.T) { + httpmock.RegisterResponder(http.MethodGet, billsURL+"/PI_FR1/details", + httpmock.NewStringResponder(200, `["PI_FR2"]`)) + httpmock.RegisterResponder(http.MethodGet, billsURL+"/PI_FR1/details/PI_FR2", + httpmock.NewStringResponder(200, `{"billDetailId":"PI_FR2","domain":"ns1.example", + "description":"ADVANCE-1 rental","quantity":"1", + "totalPrice":{"text":"104.99 €","value":104.99,"currencyCode":"EUR"}}`)) + + out, err := cmd.Execute("account", "bill", "details", "PI_FR1") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("ns1.example")) + assert.Cmp(out, td.Contains("104.99")) +} + +func (ms *MockSuite) TestRefundListHidesItsLinksToo(assert, require *td.T) { + captureQuery(refundsURL, `["API_FR1"]`, new(string)) + httpmock.RegisterResponder(http.MethodGet, refundsURL+"/API_FR1", + httpmock.NewStringResponder(200, `{"refundId":"API_FR1","date":"2026-03-05T14:22:17+01:00", + "originalBillId":"PI_FR16249716","orderId":246409141, + "priceWithTax":{"text":"-71.43 €","value":-71.43,"currencyCode":"EUR"}, + "password":"SECRETPASS","pdfUrl":"https://www.ovh.com/x?esign=SIGNATURE"}`)) + + out, err := cmd.Execute("account", "refund", "list", "-o", "json") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("API_FR1")) + assert.Cmp(out, td.Not(td.Contains("SIGNATURE"))) + assert.Cmp(out, td.Not(td.Contains("SECRETPASS"))) +} + +// The entry-level price is null on every current-usage entry measured; the +// amount lives in the elements. One row per entry would have shown a column +// of nothing. +func (ms *MockSuite) TestUsageShowsOneRowPerElement(assert, require *td.T) { + httpmock.RegisterResponder(http.MethodGet, usageURL, + httpmock.NewStringResponder(200, `[{"serviceId":74756498,"price":null, + "beginDate":"2026-08-06T00:00:00Z","endDate":"2026-08-19T00:00:00Z", + "elements":[ + {"planCode":"okms-servicekey-monthly-consumption","planFamily":"okms-servicekey", + "quantity":9,"price":{"text":"0.54 €"}}, + {"planCode":"okms-secret-monthly-consumption","planFamily":"okms-secret", + "quantity":13,"price":{"text":"0.39 €"}}]}]`)) + + out, err := cmd.Execute("account", "usage") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("okms-servicekey-monthly-consumption")) + assert.Cmp(out, td.Contains("okms-secret-monthly-consumption")) + assert.Cmp(out, td.Contains("0.54")) + assert.Cmp(out, td.Contains("0.39")) + assert.Cmp(out, td.Contains("2026-08-06 → 2026-08-19")) +} + +// A service that reports no element still has a line, and its missing amount +// is a dash rather than a blank cell. +func (ms *MockSuite) TestUsageKeepsAServiceThatReportsNoElement(assert, require *td.T) { + httpmock.RegisterResponder(http.MethodGet, usageURL, + httpmock.NewStringResponder(200, `[{"serviceId":73553170,"price":null, + "beginDate":"2026-07-10T00:00:00Z","endDate":"2026-07-10T00:00:00Z","elements":[]}]`)) + + out, err := cmd.Execute("account", "usage") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("73553170")) + assert.Cmp(out, td.Contains("—")) +} + +func (ms *MockSuite) TestUsageForecastReadsTheOtherRoute(assert, require *td.T) { + httpmock.RegisterResponder(http.MethodGet, forecastURL, + httpmock.NewStringResponder(200, `[{"serviceId":134043784, + "beginDate":"2026-06-01T00:00:00Z","endDate":"2026-08-19T00:00:00Z", + "price":{"text":"0.12 €"},"elements":[]}]`)) + + out, err := cmd.Execute("account", "usage", "--forecast") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("0.12")) + assert.Cmp(httpmock.GetCallCountInfo()["GET "+usageURL], 0, "the current period is not read") +} + +// --- baremetal cost --- + +// A server bills as several services: the machine, and the components sold +// with it. The price of the machine alone would not be the price paid. +func registerServerServices(server string, bodies map[string]string) { + ids := make([]string, 0, len(bodies)) + for id := range bodies { + ids = append(ids, fmt.Sprintf("%q", id)) + } + httpmock.RegisterResponder(http.MethodGet, servicesURL, + func(req *http.Request) (*http.Response, error) { + if req.URL.Query().Get("resourceName") != server { + return httpmock.NewStringResponse(200, `[]`), nil + } + return httpmock.NewStringResponse(200, "["+strings.Join(ids, ",")+"]"), nil + }) + for id, body := range bodies { + httpmock.RegisterResponder(http.MethodGet, servicesURL+"/"+id, + httpmock.NewStringResponder(200, body)) + } +} + +func (ms *MockSuite) TestCostAddsUpTheMachineAndWhatIsIncluded(assert, require *td.T) { + registerServerServices("ns1.example", map[string]string{ + "1": `{"serviceId":1,"parentServiceId":null,"route":{"path":"/dedicated/server/{serviceName}"}, + "resource":{"displayName":"ns1.example"}, + "billing":{"plan":{"invoiceName":"ADVANCE-1 | AMD EPYC 4245P"}, + "pricing":{"description":"rental for 1 month","price":{"text":"104.99 €","value":104.99,"currencyCode":"EUR"}}, + "renew":{"current":{"mode":"automatic","nextDate":"2026-09-01T10:29:33Z"}}}}`, + "2": `{"serviceId":2,"route":null,"resource":{"product":{"description":"2x SSD NVMe 1.92TB"}}, + "billing":{"plan":{"invoiceName":"2x SSD NVMe 1.92TB Datacenter Class Soft RAID"}, + "pricing":{"description":"rental for 1 month","price":{"text":"70.00 €","value":70,"currencyCode":"EUR"}}}}`, + }) + + out, err := cmd.Execute("baremetal", "cost", "ns1.example") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("ADVANCE-1")) + assert.Cmp(out, td.Contains("2x SSD NVMe 1.92TB"), "what is included is listed, not summed away") + // A billed component is not hypothetical: three servers on the account + // measured carry one, at 70, 10 and 8 euros. Counting only the machine + // would under-report the bill by exactly that much. + assert.Cmp(out, td.Contains("174.99 EUR"), "the total is the machine plus its components") + assert.Cmp(out, td.Not(td.Contains("104.99 EUR")), "the machine alone is not the price paid") + assert.Cmp(out, td.Contains("Renews automatic on 2026-09-01")) +} + +// serviceInfos.renew.automatic contradicts itself on exactly these services +// (PUBM-55135). Naming the parent is true; deriving a boolean is not. +func (ms *MockSuite) TestCostSaysWhenTheParentCarriesTheRenewal(assert, require *td.T) { + registerServerServices("ns2.example", map[string]string{ + "9": `{"serviceId":9,"parentServiceId":133558145,"route":{"path":"/dedicated/server/{serviceName}"}, + "resource":{"displayName":"ns2.example"}, + "billing":{"plan":{"invoiceName":"HGR-HCI-1"}, + "pricing":{"description":"rental for 1 month","price":{"text":"1106.00 €","value":1106,"currencyCode":"EUR"}}, + "renew":null}}`, + }) + + out, err := cmd.Execute("baremetal", "cost", "ns2.example") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("133558145")) + assert.Cmp(out, td.Contains("carried by parent service")) + assert.Cmp(out, td.Not(td.Contains("Renews automatic"))) +} + +func (ms *MockSuite) TestCostSaysWhereToLookWhenNothingIsBilled(assert, require *td.T) { + httpmock.RegisterResponder(http.MethodGet, servicesURL, + httpmock.NewStringResponder(200, `[]`)) + + _, err := cmd.Execute("baremetal", "cost", "ns-unknown.example") + + require.CmpError(err) + assert.Cmp(err.Error(), td.Contains("nothing is billed for ns-unknown.example")) + assert.Cmp(err.Error(), td.Contains("baremetal list")) +} + +// Services answered, but none is the machine. That is not "costs nothing". +func (ms *MockSuite) TestCostRefusesWhenNoServiceIsTheMachine(assert, require *td.T) { + registerServerServices("ns3.example", map[string]string{ + "7": `{"serviceId":7,"route":null,"billing":{"plan":{"invoiceName":"32GB DDR5"}, + "pricing":{"price":{"text":"0.00 €","value":0,"currencyCode":"EUR"}}}}`, + }) + + _, err := cmd.Execute("baremetal", "cost", "ns3.example") + + require.CmpError(err) + assert.Cmp(err.Error(), td.Contains("none of which is the machine")) + assert.Cmp(err.Error(), td.Contains("/dedicated/server/{serviceName}")) +} diff --git a/internal/cmd/baremetal.go b/internal/cmd/baremetal.go index 9ae7a7fd4..a21ce328e 100644 --- a/internal/cmd/baremetal.go +++ b/internal/cmd/baremetal.go @@ -977,6 +977,18 @@ sending. --dry-run prints the whole message instead of sending it.`, addConfirmationFlags(baremetalBackupAgentDeleteCmd, "Print the call that would be made without making it") baremetalBackupAgentCmd.AddCommand(baremetalBackupAgentDeleteCmd) + baremetalCmd.AddCommand(withFilterFlag(&cobra.Command{ + Use: "cost ", + Short: "Show what a server costs and when it renews", + Long: "Show what a server costs and when it renews.\n\n" + + "A server resolves to several billable services: the machine, and the\n" + + "components sold with it. This lists them all, so the price shown is the\n" + + "price of the machine as configured rather than of its base plan.", + Args: cobra.ExactArgs(1), + ValidArgsFunction: completion.ServiceList("/v1/dedicated/server"), + Run: baremetal.ShowBaremetalCost, + })) + rootCmd.AddCommand(baremetalCmd) } diff --git a/internal/services/account/account.go b/internal/services/account/account.go index 48e2615b9..a1054935b 100644 --- a/internal/services/account/account.go +++ b/internal/services/account/account.go @@ -24,6 +24,12 @@ var ( //go:embed templates/me.tmpl meTemplate string + //go:embed templates/bill.tmpl + billTemplate string + + //go:embed templates/refund.tmpl + refundTemplate string + sshKeysColumnsToDisplay = []string{"keyName name", "key"} Oauth2ClientSpec struct { diff --git a/internal/services/account/billing.go b/internal/services/account/billing.go new file mode 100644 index 000000000..75ac77e1b --- /dev/null +++ b/internal/services/account/billing.go @@ -0,0 +1,416 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package account + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + "sync" + "time" + + "github.com/ovh/ovhcloud-cli/internal/assets" + "github.com/ovh/ovhcloud-cli/internal/display" + "github.com/ovh/ovhcloud-cli/internal/flags" + httpLib "github.com/ovh/ovhcloud-cli/internal/http" + "github.com/ovh/ovhcloud-cli/internal/openapi" + "github.com/ovh/ovhcloud-cli/internal/services/common" + "github.com/spf13/cobra" +) + +const ( + billsPath = "/v1/me/bill" + refundsPath = "/v1/me/refund" + usagePath = "/v1/me/consumption/usage/current" + forecastPath = "/v1/me/consumption/usage/forecast" +) + +var ( + // BillFrom and BillTo bound the window a listing covers. Empty means + // the current month, which is a guard rather than a convenience: the + // account measured for this work carries 2215 invoices, and listing + // them expands one HTTP call per invoice. + BillFrom string + BillTo string + + // BillCategory filters on billing.CategoryEnum, read from the schema. + BillCategory string + + // BillOrderID keeps only what was billed for one order. + BillOrderID int64 + + // RevealBillSecrets prints the download link and the PDF password + // instead of their fingerprints. Both are bearer secrets: the link + // returns the PDF with no API token at all, measured. + RevealBillSecrets bool + + // BillUsageForecast reads the forecast instead of the current usage. + BillUsageForecast bool +) + +// billTooManyToExpand is the point past which a listing is refused rather +// than turned into that many HTTP calls. 2215 invoices exist on the account +// this was measured on; a year of them is 396. +const billTooManyToExpand = 400 + +var billColumnsToDisplay = []string{"billId", "date", "category", "priceWithTax.text price", "orderId"} + +var refundColumnsToDisplay = []string{"refundId", "date", "originalBillId", "priceWithTax.text price", "orderId"} + +var billDetailColumns = []string{"billDetailId", "domain", "description", "quantity", "totalPrice.text total"} + +var usageColumns = []string{"serviceId", "plan", "family", "quantity", "price", "period"} + +// billCategories reads billing.CategoryEnum from the embedded schema rather +// than repeating it here. The game protocols of #256 were copied by hand and +// were already stale when they shipped. +var billCategories = sync.OnceValues(func() ([]string, error) { + return openapi.GetComponentEnum(assets.MeOpenapiSchema, "billing.CategoryEnum") +}) + +// CompleteBillCategory offers the categories on . +func CompleteBillCategory(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return common.CompleteEnum(billCategories) +} + +// billingWindow returns the from/to pair a listing runs on. An empty --from +// means the first day of the current month: without a window the call is +// answered in full, and the expansion that follows is one request per invoice. +func billingWindow(from, to string) (string, string, error) { + if from == "" { + now := time.Now().UTC() + from = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339) + } else { + parsed, err := parseWindowBound(from) + if err != nil { + return "", "", fmt.Errorf("--from is not a date: %w", err) + } + from = parsed + } + + if to != "" { + parsed, err := parseWindowBound(to) + if err != nil { + return "", "", fmt.Errorf("--to is not a date: %w", err) + } + to = parsed + } + + if to != "" && to < from { + return "", "", fmt.Errorf("--to (%s) is before --from (%s)", to, from) + } + + return from, to, nil +} + +// parseWindowBound accepts a plain day as well as a full timestamp, because +// nobody types an RFC3339 stamp to ask for last month. +func parseWindowBound(value string) (string, error) { + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + return parsed.UTC().Format(time.RFC3339), nil + } + + parsed, err := time.Parse("2006-01-02", value) + if err != nil { + return "", fmt.Errorf("%q is neither YYYY-MM-DD nor a full RFC3339 timestamp", value) + } + + return parsed.UTC().Format(time.RFC3339), nil +} + +// billQuery builds the query string of a billing collection. The filters are +// applied by the API; nothing is filtered after the fact. +func billQuery(from, to, category string, orderID int64) (string, error) { + values := url.Values{} + values.Set("date.from", from) + if to != "" { + values.Set("date.to", to) + } + + if category != "" { + if err := common.CheckEnumFlag("category", category, billCategories); err != nil { + return "", err + } + values.Set("category", category) + } + + if orderID != 0 { + values.Set("orderId", fmt.Sprint(orderID)) + } + + return "?" + values.Encode(), nil +} + +// ListBills lists the invoices of a window. +func ListBills(_ *cobra.Command, _ []string) { + from, to, err := billingWindow(BillFrom, BillTo) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + query, err := billQuery(from, to, BillCategory, BillOrderID) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + ids, err := httpLib.FetchArray(billsPath+query, "") + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to list invoices: %s", err) + return + } + + if len(ids) > billTooManyToExpand { + display.OutputError(&flags.OutputFormatConfig, + "%d invoices match, which is %d requests to detail them.\n Narrow the window with --from and --to, or a category with --category.", + len(ids), len(ids)) + return + } + + bills, err := httpLib.FetchObjectsParallel[map[string]any](billsPath+"/%s", ids, flags.IgnoreErrors) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to read invoices: %s", err) + return + } + + rows := make([]map[string]any, 0, len(bills)) + for _, bill := range bills { + rows = append(rows, billSecretsView(bill)) + } + + display.RenderTable(rows, billColumnsToDisplay, &flags.OutputFormatConfig) +} + +// GetBill shows one invoice. +func GetBill(_ *cobra.Command, args []string) { + bill, err := fetchBillingObject(billsPath, args[0]) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to read invoice %s: %s", args[0], err) + return + } + + display.OutputObject(billSecretsView(bill), args[0], billTemplate, &flags.OutputFormatConfig) +} + +// ListBillDetails shows what one invoice charges for, line by line. +func ListBillDetails(_ *cobra.Command, args []string) { + common.ManageListRequest( + fmt.Sprintf("%s/%s/details", billsPath, url.PathEscape(args[0])), + "", + billDetailColumns, + flags.GenericFilters, + ) +} + +// ListRefunds lists the refunds of a window. +func ListRefunds(_ *cobra.Command, _ []string) { + from, to, err := billingWindow(BillFrom, BillTo) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + values := url.Values{} + values.Set("date.from", from) + if to != "" { + values.Set("date.to", to) + } + if BillOrderID != 0 { + values.Set("orderId", fmt.Sprint(BillOrderID)) + } + + ids, err := httpLib.FetchArray(refundsPath+"?"+values.Encode(), "") + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to list refunds: %s", err) + return + } + + refunds, err := httpLib.FetchObjectsParallel[map[string]any](refundsPath+"/%s", ids, flags.IgnoreErrors) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to read refunds: %s", err) + return + } + + rows := make([]map[string]any, 0, len(refunds)) + for _, refund := range refunds { + rows = append(rows, billSecretsView(refund)) + } + + display.RenderTable(rows, refundColumnsToDisplay, &flags.OutputFormatConfig) +} + +// GetRefund shows one refund. +func GetRefund(_ *cobra.Command, args []string) { + refund, err := fetchBillingObject(refundsPath, args[0]) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to read refund %s: %s", args[0], err) + return + } + + display.OutputObject(billSecretsView(refund), args[0], refundTemplate, &flags.OutputFormatConfig) +} + +// usageEntry is one service's usage over the running period. +type usageEntry struct { + ServiceID int64 `json:"serviceId"` + BeginDate string `json:"beginDate"` + EndDate string `json:"endDate"` + Price *struct { + Text string `json:"text"` + } `json:"price"` + Elements []struct { + PlanCode string `json:"planCode"` + PlanFamily string `json:"planFamily"` + Quantity any `json:"quantity"` + Price *struct { + Text string `json:"text"` + } `json:"price"` + } `json:"elements"` +} + +// ShowUsage reads what is running against the current invoice, or what it is +// forecast to cost. +// +// One row per element rather than one per service: on the current period the +// entry-level price is null on every entry measured, and the amount lives in +// the elements. A table built on the entry would have shown a column of +// nothing. The forecast fills both, so the entry price is used as the fallback +// for a service that reports no element. +func ShowUsage(_ *cobra.Command, _ []string) { + path := usagePath + if BillUsageForecast { + path = forecastPath + } + + entries, err := httpLib.FetchArray(path, "") + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to read usage: %s", err) + return + } + + rows := make([]map[string]any, 0, len(entries)) + for _, raw := range entries { + entry, err := decodeUsageEntry(raw) + if err != nil { + continue + } + + period := day(entry.BeginDate) + " → " + day(entry.EndDate) + + if len(entry.Elements) == 0 { + rows = append(rows, map[string]any{ + "serviceId": entry.ServiceID, + "plan": "—", + "family": "—", + "quantity": "—", + "price": priceText(entry.Price), + "period": period, + }) + continue + } + + for _, element := range entry.Elements { + rows = append(rows, map[string]any{ + "serviceId": entry.ServiceID, + "plan": element.PlanCode, + "family": element.PlanFamily, + "quantity": element.Quantity, + "price": priceText(element.Price), + "period": period, + }) + } + } + + display.RenderTable(rows, usageColumns, &flags.OutputFormatConfig) +} + +// decodeUsageEntry turns one raw entry into the shape this command reads. +func decodeUsageEntry(raw any) (usageEntry, error) { + encoded, err := json.Marshal(raw) + if err != nil { + return usageEntry{}, err + } + + var entry usageEntry + if err := json.Unmarshal(encoded, &entry); err != nil { + return usageEntry{}, err + } + + return entry, nil +} + +// priceText renders an amount that the API may not have filled in. +func priceText(price *struct { + Text string `json:"text"` +}) string { + if price == nil || price.Text == "" { + return "—" + } + + return price.Text +} + +// day keeps the date and drops the time. +func day(timestamp string) string { + if before, _, found := strings.Cut(timestamp, "T"); found { + return before + } + + return timestamp +} + +// fetchBillingObject reads one invoice or refund. +func fetchBillingObject(collection, id string) (map[string]any, error) { + if strings.TrimSpace(id) == "" { + return nil, fmt.Errorf("no identifier given") + } + + objects, err := httpLib.FetchObjectsParallel[map[string]any]( + collection+"/%s", []any{id}, false) + if err != nil { + return nil, err + } + if len(objects) == 0 { + return nil, fmt.Errorf("nothing returned") + } + + return objects[0], nil +} + +// billSecretsView replaces the download links and the PDF password with their +// fingerprints unless --reveal. +// +// These are not decorative. Measured on a real invoice: the pdfUrl returns the +// PDF in full with no API token whatsoever (HTTP 200, application/pdf), and the +// same URL with its esign parameter altered returns HTML instead. The link is +// therefore a bearer credential, and -o json would put it in a pipeline log. +// The substitution happens on the object and not in the template, for the same +// reason: a mask that only covers the human-readable output covers nothing. +func billSecretsView(object map[string]any) map[string]any { + view := map[string]any{} + for key, value := range object { + view[key] = value + } + + if RevealBillSecrets { + return view + } + + hidden := false + for _, field := range []string{"password", "url", "pdfUrl"} { + if value, set := view[field]; set && fmt.Sprint(value) != "" { + view[field] = common.Fingerprint(fmt.Sprint(value)) + hidden = true + } + } + + if hidden { + view["hidden"] = true + } + + return view +} diff --git a/internal/services/account/billing_test.go b/internal/services/account/billing_test.go new file mode 100644 index 000000000..235ed0a57 --- /dev/null +++ b/internal/services/account/billing_test.go @@ -0,0 +1,157 @@ +// SPDX-FileCopyrightText: 2026 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package account + +import ( + "strings" + "testing" + "time" + + "github.com/maxatome/go-testdeep/td" +) + +// Without a window the account measured for this returns 2215 invoices, and +// listing them is one request each. The default is a guard. +func TestAnEmptyWindowStartsAtTheFirstOfTheMonth(t *testing.T) { + assert := td.Assert(t) + + from, to, err := billingWindow("", "") + + assert.CmpNoError(err) + assert.Cmp(to, "") + + parsed, err := time.Parse(time.RFC3339, from) + assert.CmpNoError(err) + assert.Cmp(parsed.Day(), 1) + assert.Cmp(parsed.Hour(), 0) + assert.Cmp(parsed.Month(), time.Now().UTC().Month()) +} + +// Nobody types an RFC3339 stamp to ask for last month. +func TestAPlainDayIsAcceptedAsAWindowBound(t *testing.T) { + assert := td.Assert(t) + + from, to, err := billingWindow("2026-01-01", "2026-02-01T12:30:00Z") + + assert.CmpNoError(err) + assert.Cmp(from, "2026-01-01T00:00:00Z") + assert.Cmp(to, "2026-02-01T12:30:00Z") +} + +func TestAWindowThatEndsBeforeItStartsIsRefused(t *testing.T) { + assert := td.Assert(t) + + _, _, err := billingWindow("2026-06-01", "2026-01-01") + + assert.CmpError(err) + assert.Cmp(err.Error(), td.Contains("is before")) +} + +func TestSomethingThatIsNotADateIsRefused(t *testing.T) { + assert := td.Assert(t) + + _, _, err := billingWindow("last month", "") + + assert.CmpError(err) + assert.Cmp(err.Error(), td.Contains("--from is not a date")) +} + +// The filters are applied by the API. Nothing is filtered after the fact. +func TestTheQueryCarriesEveryFilterGiven(t *testing.T) { + assert := td.Assert(t) + + query, err := billQuery("2026-08-01T00:00:00Z", "2026-08-31T00:00:00Z", "purchase-servers", 4242) + + assert.CmpNoError(err) + assert.Cmp(strings.HasPrefix(query, "?"), true) + assert.Cmp(query, td.Contains("category=purchase-servers")) + assert.Cmp(query, td.Contains("orderId=4242")) + assert.Cmp(query, td.Contains("date.from=2026-08-01T00%3A00%3A00Z")) + assert.Cmp(query, td.Contains("date.to=")) + // A colon left raw would end the value at the hour. + assert.Cmp(strings.Contains(query, "00:00:00"), false) +} + +// An absent bound is absent from the query, not sent empty. +func TestAnAbsentUpperBoundIsNotSent(t *testing.T) { + assert := td.Assert(t) + + query, err := billQuery("2026-08-01T00:00:00Z", "", "", 0) + + assert.CmpNoError(err) + assert.Cmp(strings.Contains(query, "date.to"), false) + assert.Cmp(strings.Contains(query, "category"), false) + assert.Cmp(strings.Contains(query, "orderId"), false) +} + +// The link returns the PDF with no API token at all, measured. -o json would +// put it in a pipeline log, so the substitution happens on the object. +func TestTheDownloadLinkAndThePasswordAreHiddenByDefault(t *testing.T) { + assert := td.Assert(t) + RevealBillSecrets = false + defer func() { RevealBillSecrets = false }() + + view := billSecretsView(map[string]any{ + "billId": "PI_FR1", + "password": "B1llP4ssEx", + "url": "https://www.ovh.com/cgi-bin/order/facture.pdf?esign=SECRET", + "pdfUrl": "https://www.ovh.com/cgi-bin/order/facture.pdf?esign=SECRET", + }) + + assert.Cmp(view["billId"], "PI_FR1") + assert.Cmp(view["hidden"], true) + for _, field := range []string{"password", "url", "pdfUrl"} { + got := view[field].(string) + assert.Cmp(strings.Contains(got, "esign"), false, field+" must not leak the signature") + assert.Cmp(got, td.Not("B1llP4ssEx")) + } +} + +func TestRevealPrintsTheSecretsAndDropsTheHiddenMarker(t *testing.T) { + assert := td.Assert(t) + RevealBillSecrets = true + defer func() { RevealBillSecrets = false }() + + view := billSecretsView(map[string]any{ + "password": "B1llP4ssEx", + "pdfUrl": "https://www.ovh.com/x?esign=SECRET", + }) + + assert.Cmp(view["password"], "B1llP4ssEx") + assert.Cmp(view["pdfUrl"], "https://www.ovh.com/x?esign=SECRET") + assert.Cmp(view["hidden"], nil) +} + +// Masking must not invent fields the API did not send. +func TestAnObjectWithoutSecretsIsNotMarkedHidden(t *testing.T) { + assert := td.Assert(t) + RevealBillSecrets = false + + view := billSecretsView(map[string]any{"billId": "PI_FR1"}) + + assert.Cmp(view["hidden"], nil) + assert.Cmp(len(view), 1) +} + +// The entry-level price is null on every current-usage entry measured. +func TestAMissingAmountIsSaidWithADashNotAnEmptyCell(t *testing.T) { + assert := td.Assert(t) + + assert.Cmp(priceText(nil), "—") + assert.Cmp(priceText(&struct { + Text string `json:"text"` + }{Text: ""}), "—") + assert.Cmp(priceText(&struct { + Text string `json:"text"` + }{Text: "320.00 €"}), "320.00 €") +} + +func TestADateIsShownWithoutItsTime(t *testing.T) { + assert := td.Assert(t) + + assert.Cmp(day("2026-09-01T10:29:33Z"), "2026-09-01") + assert.Cmp(day("2026-09-01"), "2026-09-01") + assert.Cmp(day(""), "") +} diff --git a/internal/services/account/templates/bill.tmpl b/internal/services/account/templates/bill.tmpl new file mode 100644 index 000000000..620ae6c2c --- /dev/null +++ b/internal/services/account/templates/bill.tmpl @@ -0,0 +1,28 @@ +🧾 Invoice {{ index .Result "billId" }} +======= + +**Date**: {{ index .Result "date" }} +**Category**: {{ index .Result "category" }} +{{- with index .Result "orderId" }} +**Order**: {{ . }} +{{- end }} + +## Amount + +**Excluding tax**: {{ index .Result "priceWithoutTax" "text" }} +**Tax**: {{ index .Result "tax" "text" }} +**Total**: {{ index .Result "priceWithTax" "text" }} + +## Document + +**Download**: {{ index .Result "pdfUrl" }} +**PDF password**: {{ index .Result "password" }} +{{ if index .Result "hidden" }} +The link and the password are hidden. Both are bearer secrets: the link returns +the PDF with no API token at all. Pass --reveal to print them. +{{ else }} +This link carries its own authorisation — anyone holding it can download the +invoice without logging in. +{{ end }} +Lines on this invoice: + ovhcloud account bill details {{ index .Result "billId" }} diff --git a/internal/services/account/templates/refund.tmpl b/internal/services/account/templates/refund.tmpl new file mode 100644 index 000000000..c91efa63c --- /dev/null +++ b/internal/services/account/templates/refund.tmpl @@ -0,0 +1,23 @@ +💶 Refund {{ index .Result "refundId" }} +======= + +**Date**: {{ index .Result "date" }} +**Refunds invoice**: {{ index .Result "originalBillId" }} +{{- with index .Result "orderId" }} +**Order**: {{ . }} +{{- end }} + +## Amount + +**Excluding tax**: {{ index .Result "priceWithoutTax" "text" }} +**Tax**: {{ index .Result "tax" "text" }} +**Total**: {{ index .Result "priceWithTax" "text" }} + +## Document + +**Download**: {{ index .Result "pdfUrl" }} +**PDF password**: {{ index .Result "password" }} +{{ if index .Result "hidden" }} +The link and the password are hidden. Both are bearer secrets. Pass --reveal to +print them. +{{ end }} diff --git a/internal/services/baremetal/cost.go b/internal/services/baremetal/cost.go new file mode 100644 index 000000000..a1075bb1b --- /dev/null +++ b/internal/services/baremetal/cost.go @@ -0,0 +1,234 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package baremetal + +import ( + _ "embed" + "fmt" + "net/url" + "sort" + "strings" + + "github.com/ovh/ovhcloud-cli/internal/display" + "github.com/ovh/ovhcloud-cli/internal/flags" + httpLib "github.com/ovh/ovhcloud-cli/internal/http" + "github.com/spf13/cobra" +) + +// dedicatedServerRoute is how the billing catalogue names a dedicated server. +// A server resolves to four or five billable services — itself plus the +// components sold with it — and only the machine carries this route. The +// others carry none, so this is what separates the server from its options. +const dedicatedServerRoute = "/dedicated/server/{serviceName}" + +//go:embed templates/cost.tmpl +var costTemplate string + +// billableService is the part of /v1/services this command reads. +type billableService struct { + ServiceID int64 `json:"serviceId"` + ParentServiceID *int64 `json:"parentServiceId"` + Route *struct { + Path string `json:"path"` + } `json:"route"` + Resource struct { + DisplayName string `json:"displayName"` + State string `json:"state"` + Product struct { + Name string `json:"name"` + Description string `json:"description"` + } `json:"product"` + } `json:"resource"` + Billing struct { + NextBillingDate string `json:"nextBillingDate"` + ExpirationDate string `json:"expirationDate"` + Plan struct { + Code string `json:"code"` + InvoiceName string `json:"invoiceName"` + } `json:"plan"` + Pricing struct { + Description string `json:"description"` + Duration string `json:"duration"` + Price struct { + CurrencyCode string `json:"currencyCode"` + Text string `json:"text"` + Value float64 `json:"value"` + } `json:"price"` + } `json:"pricing"` + Renew *struct { + Current *struct { + Mode string `json:"mode"` + NextDate string `json:"nextDate"` + Period string `json:"period"` + } `json:"current"` + } `json:"renew"` + Engagement *struct { + EndDate string `json:"endDate"` + } `json:"engagement"` + } `json:"billing"` +} + +// isTheMachine says whether this service is the server rather than one of the +// components billed alongside it. +func (s billableService) isTheMachine() bool { + return s.Route != nil && s.Route.Path == dedicatedServerRoute +} + +// label names a line the way an invoice would. +func (s billableService) label() string { + if s.Billing.Plan.InvoiceName != "" { + return s.Billing.Plan.InvoiceName + } + if s.Resource.Product.Description != "" { + return s.Resource.Product.Description + } + + return s.Billing.Plan.Code +} + +// ShowBaremetalCost says what a server costs, what is included in that price, +// and when it renews. +func ShowBaremetalCost(_ *cobra.Command, args []string) { + server := args[0] + + services, err := servicesOf(server) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + var machine *billableService + var included []billableService + var total float64 + currency := "" + + for _, service := range services { + if service.isTheMachine() { + copied := service + machine = &copied + } else { + included = append(included, service) + } + total += service.Billing.Pricing.Price.Value + if service.Billing.Pricing.Price.CurrencyCode != "" { + currency = service.Billing.Pricing.Price.CurrencyCode + } + } + + if machine == nil { + display.OutputError(&flags.OutputFormatConfig, + "%d billable service(s) answer for %s, none of which is the machine itself.\n The billing catalogue reached them, but none carries the route %s.", + len(services), server, dedicatedServerRoute) + return + } + + sort.Slice(included, func(i, j int) bool { + return included[i].label() < included[j].label() + }) + + lines := make([]map[string]any, 0, len(services)) + lines = append(lines, costRow(*machine, "server")) + for _, service := range included { + lines = append(lines, costRow(service, "included")) + } + + // One rendering, not two: a table followed by a summary would put two + // documents on one stdout, and -o json would carry only the last. + display.OutputObject(map[string]any{ + "server": server, + "plan": machine.label(), + "total": money(total, currency), + "totalValue": total, + "currency": currency, + "services": len(services), + "renewal": renewalPhrase(*machine), + "nextBilling": machine.Billing.NextBillingDate, + "expiration": machine.Billing.ExpirationDate, + "lines": lines, + }, server, costTemplate, &flags.OutputFormatConfig) +} + +// costRow is one line of the table. +func costRow(service billableService, kind string) map[string]any { + price := service.Billing.Pricing.Price.Text + if price == "" { + price = "—" + } + + return map[string]any{ + "item": service.label(), + "kind": kind, + "price": price, + "billed": service.Billing.Pricing.Description, + "serviceId": service.ServiceID, + } +} + +// renewalPhrase says how the machine renews, and says it differently when the +// renewal is not the machine's own. +// +// A child service — one with a parentServiceId — carries no renewal block: the +// parent holds it. Reporting "unknown" there would be wrong, and reading +// serviceInfos.renew.automatic instead would be worse: on the six child +// services of the account measured for this, that field returns contradictory +// values on consecutive reads (PUBM-55135). So this says what is true — the +// parent decides — and names the parent. +func renewalPhrase(machine billableService) string { + if machine.Billing.Renew != nil && machine.Billing.Renew.Current != nil { + current := machine.Billing.Renew.Current + phrase := fmt.Sprintf("Renews %s", current.Mode) + if current.NextDate != "" { + phrase += " on " + day(current.NextDate) + } + if machine.Billing.Engagement != nil && machine.Billing.Engagement.EndDate != "" { + phrase += fmt.Sprintf(", committed until %s", day(machine.Billing.Engagement.EndDate)) + } + + return phrase + "." + } + + if machine.ParentServiceID != nil { + return fmt.Sprintf("Renewal is carried by parent service %d, not by this machine.", *machine.ParentServiceID) + } + + return "This service declares no renewal." +} + +// servicesOf resolves a server into the services billed for it. +func servicesOf(server string) ([]billableService, error) { + ids, err := httpLib.FetchArray("/v1/services?resourceName="+url.QueryEscape(server), "") + if err != nil { + return nil, fmt.Errorf("failed to find what is billed for %s: %w", server, err) + } + + if len(ids) == 0 { + return nil, fmt.Errorf("nothing is billed for %s.\n Check the name with: ovhcloud baremetal list", server) + } + + services, err := httpLib.FetchObjectsParallel[billableService]("/v1/services/%s", ids, flags.IgnoreErrors) + if err != nil { + return nil, fmt.Errorf("failed to read the services of %s: %w", server, err) + } + + return services, nil +} + +// money renders a total the way the API renders a single price. +func money(value float64, currency string) string { + if currency == "" { + return fmt.Sprintf("%.2f", value) + } + + return fmt.Sprintf("%.2f %s", value, currency) +} + +// day keeps the date and drops the time, which no billing question needs. +func day(timestamp string) string { + if before, _, found := strings.Cut(timestamp, "T"); found { + return before + } + + return timestamp +} diff --git a/internal/services/baremetal/cost_test.go b/internal/services/baremetal/cost_test.go new file mode 100644 index 000000000..5f3c73c02 --- /dev/null +++ b/internal/services/baremetal/cost_test.go @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2026 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package baremetal + +import ( + "testing" + + "github.com/maxatome/go-testdeep/td" +) + +// A server resolves to four or five billable services and only the machine +// carries the route. The components carry none. +func TestOnlyTheMachineCarriesTheDedicatedServerRoute(t *testing.T) { + assert := td.Assert(t) + + machine := billableService{} + machine.Route = &struct { + Path string `json:"path"` + }{Path: dedicatedServerRoute} + + component := billableService{} + + other := billableService{} + other.Route = &struct { + Path string `json:"path"` + }{Path: "/vps/{serviceName}"} + + assert.Cmp(machine.isTheMachine(), true) + assert.Cmp(component.isTheMachine(), false, "a component carries no route at all") + assert.Cmp(other.isTheMachine(), false, "another product's route is not this machine") +} + +func TestALineIsNamedTheWayAnInvoiceNamesIt(t *testing.T) { + assert := td.Assert(t) + + var withInvoiceName billableService + withInvoiceName.Billing.Plan.InvoiceName = "ADVANCE-1 | AMD EPYC 4245P" + withInvoiceName.Billing.Plan.Code = "26adv01-v1014" + assert.Cmp(withInvoiceName.label(), "ADVANCE-1 | AMD EPYC 4245P") + + var withProductOnly billableService + withProductOnly.Billing.Plan.Code = "softraid-2x960nvme" + withProductOnly.Resource.Product.Description = "2x SSD NVMe 960GB" + assert.Cmp(withProductOnly.label(), "2x SSD NVMe 960GB") + + var codeOnly billableService + codeOnly.Billing.Plan.Code = "26adv01-v1014" + assert.Cmp(codeOnly.label(), "26adv01-v1014") +} + +func TestAMachineWithItsOwnRenewalSaysWhenAndHow(t *testing.T) { + assert := td.Assert(t) + + var machine billableService + machine.Billing.Renew = &struct { + Current *struct { + Mode string `json:"mode"` + NextDate string `json:"nextDate"` + Period string `json:"period"` + } `json:"current"` + }{Current: &struct { + Mode string `json:"mode"` + NextDate string `json:"nextDate"` + Period string `json:"period"` + }{Mode: "automatic", NextDate: "2026-09-01T10:29:33Z"}} + + assert.Cmp(renewalPhrase(machine), "Renews automatic on 2026-09-01.") +} + +// The six child services of the account measured carry no renewal block, and +// serviceInfos.renew.automatic contradicts itself on them (PUBM-55135). Saying +// the parent decides is true; deriving a boolean would not be. +func TestAChildServiceSaysItsParentCarriesTheRenewal(t *testing.T) { + assert := td.Assert(t) + + parent := int64(133558145) + machine := billableService{ParentServiceID: &parent} + + phrase := renewalPhrase(machine) + + assert.Cmp(phrase, td.Contains("133558145")) + assert.Cmp(phrase, td.Contains("carried by parent service")) + assert.Cmp(phrase, td.Not(td.Contains("automatic")), "no renewal mode may be invented here") +} + +// No renewal block and no parent either: that is a third thing, and it is not +// "renews automatically". +func TestAServiceWithNeitherRenewalNorParentSaysSo(t *testing.T) { + assert := td.Assert(t) + + assert.Cmp(renewalPhrase(billableService{}), "This service declares no renewal.") +} + +func TestACommitmentIsNamedWithItsEndDate(t *testing.T) { + assert := td.Assert(t) + + var machine billableService + machine.Billing.Renew = &struct { + Current *struct { + Mode string `json:"mode"` + NextDate string `json:"nextDate"` + Period string `json:"period"` + } `json:"current"` + }{Current: &struct { + Mode string `json:"mode"` + NextDate string `json:"nextDate"` + Period string `json:"period"` + }{Mode: "automatic", NextDate: "2026-09-01T00:00:00Z"}} + machine.Billing.Engagement = &struct { + EndDate string `json:"endDate"` + }{EndDate: "2027-02-01T00:00:00Z"} + + assert.Cmp(renewalPhrase(machine), td.Contains("committed until 2027-02-01")) +} + +func TestATotalKeepsItsCurrency(t *testing.T) { + assert := td.Assert(t) + + assert.Cmp(money(104.99, "EUR"), "104.99 EUR") + assert.Cmp(money(1106, "EUR"), "1106.00 EUR") + assert.Cmp(money(12.5, ""), "12.50") +} diff --git a/internal/services/baremetal/templates/cost.tmpl b/internal/services/baremetal/templates/cost.tmpl new file mode 100644 index 000000000..1af6b2c93 --- /dev/null +++ b/internal/services/baremetal/templates/cost.tmpl @@ -0,0 +1,15 @@ +💶 {{ index .Result "server" }} — {{ index .Result "total" }} +======= + +{{ index .Result "plan" }} +{{ index .Result "renewal" }} + +## Billed for this machine + +| Item | Kind | Price | Billed | +|---|---|---|---| +{{- range $line := index .Result "lines" }} +| {{ index $line "item" }} | {{ index $line "kind" }} | {{ index $line "price" }} | {{ index $line "billed" }} | +{{- end }} + +{{ index .Result "services" }} billable service(s). Service IDs are in `-o json`. From c3590d05375dfb62c8a03f8af79d6664187caf4e Mon Sep 17 00:00:00 2001 From: Denis Date: Thu, 20 Aug 2026 13:12:26 +0200 Subject: [PATCH 04/11] test(account): stop using real account identifiers as fixtures A real invoice identifier read from the account under test ended up in the refund fixture, and the server address of #265 came along with the rebase. Neither is a secret and neither buys anything: replaced with invented values of the same shape, and with 203.0.113.7 from the range RFC 5737 reserves for documentation. Signed-off-by: Denis --- internal/cmd/account_billing_test.go | 2 +- internal/cmd/backupservices_test.go | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/cmd/account_billing_test.go b/internal/cmd/account_billing_test.go index afb890926..ffbf6d81b 100644 --- a/internal/cmd/account_billing_test.go +++ b/internal/cmd/account_billing_test.go @@ -139,7 +139,7 @@ func (ms *MockSuite) TestRefundListHidesItsLinksToo(assert, require *td.T) { captureQuery(refundsURL, `["API_FR1"]`, new(string)) httpmock.RegisterResponder(http.MethodGet, refundsURL+"/API_FR1", httpmock.NewStringResponder(200, `{"refundId":"API_FR1","date":"2026-03-05T14:22:17+01:00", - "originalBillId":"PI_FR16249716","orderId":246409141, + "originalBillId":"PI_FR9","orderId":246409141, "priceWithTax":{"text":"-71.43 €","value":-71.43,"currencyCode":"EUR"}, "password":"SECRETPASS","pdfUrl":"https://www.ovh.com/x?esign=SIGNATURE"}`)) diff --git a/internal/cmd/backupservices_test.go b/internal/cmd/backupservices_test.go index 0a705c391..4915aca66 100644 --- a/internal/cmd/backupservices_test.go +++ b/internal/cmd/backupservices_test.go @@ -38,7 +38,7 @@ func registerOneTenant() { // registerServer answers the v1 read a creation derives its spec from. func registerServerForAgent() { httpmock.RegisterResponder(http.MethodGet, "https://eu.api.ovh.com/v1/dedicated/server/ns1.example", - httpmock.NewStringResponder(200, `{"name":"ns1.example","ip":"51.68.100.165","region":"eu-west-rbx"}`)) + httpmock.NewStringResponder(200, `{"name":"ns1.example","ip":"203.0.113.7","region":"eu-west-rbx"}`)) } func registerAgents(body string) { @@ -132,7 +132,7 @@ func (ms *MockSuite) TestBackupDeployScriptSaysWhatTheLinksAre(assert, require * func (ms *MockSuite) TestBaremetalBackupAgentShowsWhatProtectsTheServer(assert, require *td.T) { registerOneTenant() registerAgents(`[{"id":"a-1","status":"NOT_INSTALLED","targetSpec":{"displayName":"agent-ns1.example","policy":""}, - "currentState":{"productResourceName":"ns1.example","ips":["51.68.100.165/32"],"type":"OVHCLOUD_BAREMETAL","policy":""}}, + "currentState":{"productResourceName":"ns1.example","ips":["203.0.113.7/32"],"type":"OVHCLOUD_BAREMETAL","policy":""}}, {"id":"a-2","status":"ENABLED","targetSpec":{"displayName":"agent-other"}, "currentState":{"productResourceName":"other.example"}}]`) @@ -178,7 +178,7 @@ func (ms *MockSuite) TestBaremetalBackupAgentCreateDerivesEverythingFromTheServe assert.Cmp(sent["displayName"], "agent-ns1.example") assert.Cmp(sent["productResourceName"], "ns1.example") assert.Cmp(sent["region"], "eu-west-rbx") - assert.Cmp(sent["ips"], []any{"51.68.100.165/32"}) + assert.Cmp(sent["ips"], []any{"203.0.113.7/32"}) } // A second agent for the same server is not something to create quietly. @@ -213,7 +213,7 @@ func (ms *MockSuite) TestBaremetalBackupAgentCreateRefusesAnUnknownRegion(assert func (ms *MockSuite) TestBaremetalBackupAgentEditCarriesTheRestOver(assert, require *td.T) { registerOneTenant() registerAgents(`[{"id":"a-1","status":"NOT_INSTALLED", - "targetSpec":{"displayName":"agent-ns1.example","ips":["51.68.100.165/32"],"policy":""}, + "targetSpec":{"displayName":"agent-ns1.example","ips":["203.0.113.7/32"],"policy":""}, "currentState":{"productResourceName":"ns1.example"}}]`) var sent map[string]any @@ -230,7 +230,7 @@ func (ms *MockSuite) TestBaremetalBackupAgentEditCarriesTheRestOver(assert, requ require.CmpNoError(err) assert.Cmp(sent["policy"], "14d_retention") assert.Cmp(sent["displayName"], "agent-ns1.example", "the name was not being changed and must survive") - assert.Cmp(sent["ips"], []any{"51.68.100.165/32"}, "nor were the addresses") + assert.Cmp(sent["ips"], []any{"203.0.113.7/32"}, "nor were the addresses") } // A policy the tenant does not define is refused with the ones it does. From 5927fc90650e428ae20a87bcc202ae80879c2684 Mon Sep 17 00:00:00 2001 From: Denis Hamon Date: Thu, 20 Aug 2026 16:15:06 +0200 Subject: [PATCH 05/11] chore: no account data as test fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-Authored-By: Claude Opus 5 (1M context) --- internal/cmd/account_billing_test.go | 4 ++-- internal/services/baremetal/logs_test.go | 2 +- internal/services/ip/move_test.go | 2 +- internal/services/vrack/attach_test.go | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/cmd/account_billing_test.go b/internal/cmd/account_billing_test.go index ffbf6d81b..dc04eee67 100644 --- a/internal/cmd/account_billing_test.go +++ b/internal/cmd/account_billing_test.go @@ -34,7 +34,7 @@ func captureQuery(url, body string, seen *string) { func registerOneBill(id string) { httpmock.RegisterResponder(http.MethodGet, billsURL+"/"+id, httpmock.NewStringResponder(200, fmt.Sprintf(`{"billId":%q,"date":"2026-08-01T08:19:43+02:00", - "category":"autorenew","orderId":255190117, + "category":"autorenew","orderId":900000006, "priceWithTax":{"text":"104.99 €","value":104.99,"currencyCode":"EUR"}, "priceWithoutTax":{"text":"104.99 €","value":104.99,"currencyCode":"EUR"}, "tax":{"text":"0.00 €","value":0,"currencyCode":"EUR"}, @@ -139,7 +139,7 @@ func (ms *MockSuite) TestRefundListHidesItsLinksToo(assert, require *td.T) { captureQuery(refundsURL, `["API_FR1"]`, new(string)) httpmock.RegisterResponder(http.MethodGet, refundsURL+"/API_FR1", httpmock.NewStringResponder(200, `{"refundId":"API_FR1","date":"2026-03-05T14:22:17+01:00", - "originalBillId":"PI_FR9","orderId":246409141, + "originalBillId":"PI_FR9","orderId":900000005, "priceWithTax":{"text":"-71.43 €","value":-71.43,"currencyCode":"EUR"}, "password":"SECRETPASS","pdfUrl":"https://www.ovh.com/x?esign=SIGNATURE"}`)) diff --git a/internal/services/baremetal/logs_test.go b/internal/services/baremetal/logs_test.go index 22c29be12..1991b25a3 100644 --- a/internal/services/baremetal/logs_test.go +++ b/internal/services/baremetal/logs_test.go @@ -18,7 +18,7 @@ func TestAStreamIdentifierIsRecognisedByItsShape(t *testing.T) { if !looksLikeUUID("00000000-6451-45de-808b-2b959c11a17e") { t.Fatal("a real stream identifier must be taken as one") } - if !looksLikeUUID("4459AF12-6451-45DE-808B-2B959C11A17E") { + if !looksLikeUUID("00000000-6451-45DE-808B-2B959C11A17E") { t.Fatal("the API answers in lowercase but accepts either") } diff --git a/internal/services/ip/move_test.go b/internal/services/ip/move_test.go index 54fc84d24..eee01daa8 100644 --- a/internal/services/ip/move_test.go +++ b/internal/services/ip/move_test.go @@ -26,7 +26,7 @@ func sample() []destination { // The service name is what the operator copies out of another command's // output, and case is not something they should have to reproduce. func TestPickDestinationIgnoresCase(t *testing.T) { - chosen, ok := pickDestination(sample(), "NS3118333.IP-51-68-100.EU") + chosen, ok := pickDestination(sample(), "NS0000006.IP-203-0-113.EU") td.Require(t).Cmp(ok, true) td.Cmp(t, chosen.Service, "ns0000006.ip-203-0-113.eu") diff --git a/internal/services/vrack/attach_test.go b/internal/services/vrack/attach_test.go index f3d52f30a..4302b517f 100644 --- a/internal/services/vrack/attach_test.go +++ b/internal/services/vrack/attach_test.go @@ -139,7 +139,7 @@ func TestInterfacesOfAcceptsTheNameItPrints(t *testing.T) { assert.Cmp(len(interfacesOf(fleet, "ns0000002.ip-203-0-113.eu")), 1, "by hostname") assert.Cmp(len(interfacesOf(fleet, "Mail relay - Paris")), 1, "and by the name its owner gave it") - assert.Cmp(len(interfacesOf(fleet, "yaniv - rise-1 - lim")), 1, "typed by a human, so case-insensitively") + assert.Cmp(len(interfacesOf(fleet, "mail relay - paris")), 1, "typed by a human, so case-insensitively") assert.Cmp(len(interfacesOf(fleet, "nothing")), 0) } From 965cb5dcb24be3b8fe4130908a56e3fee135e21c Mon Sep 17 00:00:00 2001 From: Denis Hamon Date: Thu, 20 Aug 2026 18:35:51 +0200 Subject: [PATCH 06/11] chore: the sweep took the identifiers and left the passwords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two OVH-generated bearer credentials read off a live account were still in test fixtures: an order-form password, which opens display-order.cgi?orderId=…&orderPassword=… with no API token — a property this very patch documents — and a bill password, one of the three fields the billing command masks by default precisely because it is a bearer secret. The account's default payment-method id went with them. The earlier sweep is what makes this worth writing down. It replaced the order ids, the invoice id and the esign signature, and left the passwords sitting next to them: in the bill fixture, orderId was already synthetic and esign was already SIGNATURE, and only `password` was still real. What names a thing was cleaned; what opens it was not. Removing a value from the tip does not remove it from a pushed commit. Both credentials must be treated as compromised regardless of this commit. Signed-off-by: Denis Hamon Co-Authored-By: Claude Opus 5 (1M context) From 2ceb31cbc0ebe40e20ccc8ead6ec24e03e54b970 Mon Sep 17 00:00:00 2001 From: Denis Hamon Date: Thu, 20 Aug 2026 22:50:08 +0200 Subject: [PATCH 07/11] fix(account): --filter was accepted, documented, and ignored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bill list`, `refund list` and `usage` registered --filter through withFilterFlag and then called display.RenderTable directly. RenderTable does not filter; withFilterFlag only binds the flag to flags.GenericFilters. 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 three now go through common.RenderFilteredTable. `baremetal cost` is the opposite case and gets the opposite fix. It renders one object through a template, so it has no rows to filter and the flag is removed rather than implemented. The test asserts the invoice 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. Signed-off-by: Denis Hamon Co-Authored-By: Claude Opus 5 (1M context) --- internal/cmd/account_billing_test.go | 17 +++++++++++++++++ internal/cmd/baremetal.go | 7 +++++-- internal/services/account/billing.go | 6 +++--- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/internal/cmd/account_billing_test.go b/internal/cmd/account_billing_test.go index dc04eee67..bad24859c 100644 --- a/internal/cmd/account_billing_test.go +++ b/internal/cmd/account_billing_test.go @@ -291,3 +291,20 @@ func (ms *MockSuite) TestCostRefusesWhenNoServiceIsTheMachine(assert, require *t assert.Cmp(err.Error(), td.Contains("none of which is the machine")) assert.Cmp(err.Error(), td.Contains("/dedicated/server/{serviceName}")) } + +// --category is a server-side filter and --filter is a client-side one; both +// are offered and the second reached nothing. The assertion that carries this +// test is the absence of the excluded invoice, since asserting only the kept +// one would pass just as well with no filtering. +func (ms *MockSuite) TestBillListIsFiltered(assert, require *td.T) { + httpmock.RegisterResponder(http.MethodGet, billsURL, + httpmock.NewStringResponder(200, `["FR1","FR2"]`)) + registerOneBill("FR1") + registerOneBill("FR2") + + out, err := cmd.Execute("account", "bill", "list", "--filter", `billId=="FR2"`) + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("FR2")) + assert.Cmp(out, td.Not(td.Contains("FR1")), "the invoice the filter excludes must not be printed") +} diff --git a/internal/cmd/baremetal.go b/internal/cmd/baremetal.go index 316730f0e..295c4bc6c 100644 --- a/internal/cmd/baremetal.go +++ b/internal/cmd/baremetal.go @@ -981,7 +981,10 @@ sending. --dry-run prints the whole message instead of sending it.`, addConfirmationFlags(baremetalBackupAgentDeleteCmd, "Print the call that would be made without making it") baremetalBackupAgentCmd.AddCommand(baremetalBackupAgentDeleteCmd) - baremetalCmd.AddCommand(withFilterFlag(&cobra.Command{ + // No --filter: `cost` renders one object through a template, not rows. + // Registering the flag would document it and accept it on a command that + // can only ignore it. + baremetalCmd.AddCommand(&cobra.Command{ Use: "cost ", Short: "Show what a server costs and when it renews", Long: "Show what a server costs and when it renews.\n\n" + @@ -991,7 +994,7 @@ sending. --dry-run prints the whole message instead of sending it.`, Args: cobra.ExactArgs(1), ValidArgsFunction: completion.ServiceList("/v1/dedicated/server"), Run: baremetal.ShowBaremetalCost, - })) + }) rootCmd.AddCommand(baremetalCmd) } diff --git a/internal/services/account/billing.go b/internal/services/account/billing.go index 75ac77e1b..0ed977ef6 100644 --- a/internal/services/account/billing.go +++ b/internal/services/account/billing.go @@ -182,7 +182,7 @@ func ListBills(_ *cobra.Command, _ []string) { rows = append(rows, billSecretsView(bill)) } - display.RenderTable(rows, billColumnsToDisplay, &flags.OutputFormatConfig) + common.RenderFilteredTable(rows, billColumnsToDisplay) } // GetBill shows one invoice. @@ -240,7 +240,7 @@ func ListRefunds(_ *cobra.Command, _ []string) { rows = append(rows, billSecretsView(refund)) } - display.RenderTable(rows, refundColumnsToDisplay, &flags.OutputFormatConfig) + common.RenderFilteredTable(rows, refundColumnsToDisplay) } // GetRefund shows one refund. @@ -325,7 +325,7 @@ func ShowUsage(_ *cobra.Command, _ []string) { } } - display.RenderTable(rows, usageColumns, &flags.OutputFormatConfig) + common.RenderFilteredTable(rows, usageColumns) } // decodeUsageEntry turns one raw entry into the shape this command reads. From 6181e979a124d65a44512b1f0426d6b4bc78fded Mon Sep 17 00:00:00 2001 From: Denis Hamon Date: Fri, 21 Aug 2026 12:41:52 +0200 Subject: [PATCH 08/11] fix(account): --ignore-errors padded the answer with empty invoices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FetchObjectsParallel preallocates one slot per identifier and only writes the ones that succeeded, so with --ignore-errors the failures stay in the slice as nil maps. ListBills and ListRefunds passed them straight through billSecretsView, which copies from a nil map, iterates nothing, and returns a non-nil empty map. One blank row per failed read in the table, one bare {} per failed read under -o json — and nothing saying how many were missing, which is worse than a short list because a short list is visible. --ignore-errors exists to make a partial failure survivable, not to invent entries. FetchExpandedArray, twenty lines away in the same file, already drops them and says so in a comment. These two call sites bypass it because they need a query string and a pre-flight count. The same latent defect exists at around forty other call sites of FetchObjectsParallel across baremetal, cloud, browser, webhosting, ip and support tickets — several of them structurally identical. Hoisting the filter into the helper is the real fix and belongs on main, not here: one of those callers takes a slice of structs, where a nil check does not apply at all. One test on the row count, red under a sabotage that removes the filter. Signed-off-by: Denis Hamon Co-Authored-By: Claude Opus 5 (1M context) --- internal/cmd/account_billing_test.go | 25 +++++++++++++++++++++++++ internal/services/account/billing.go | 16 ++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/internal/cmd/account_billing_test.go b/internal/cmd/account_billing_test.go index bad24859c..a76ddf7c8 100644 --- a/internal/cmd/account_billing_test.go +++ b/internal/cmd/account_billing_test.go @@ -5,6 +5,7 @@ package cmd_test import ( + "encoding/json" "fmt" "net/http" "strings" @@ -308,3 +309,27 @@ func (ms *MockSuite) TestBillListIsFiltered(assert, require *td.T) { assert.Cmp(out, td.Contains("FR2")) assert.Cmp(out, td.Not(td.Contains("FR1")), "the invoice the filter excludes must not be printed") } + +// --ignore-errors was meant to make a partial failure survivable. +// FetchObjectsParallel preallocates one slot per id and only writes the ones +// that succeeded, so the failures stayed in the slice as nil maps: one blank row +// per failed read, and a bare {} under -o json, with nothing saying how many +// were missing. That is worse than a short list, because a short list is +// visible. +func (ms *MockSuite) TestAccountBillListDropsTheReadsThatFailed(assert, require *td.T) { + httpmock.RegisterResponder("GET", `=~^https://eu\.api\.ovh\.com/v1/me/bill\?`, + httpmock.NewStringResponder(200, `["PI_FR1","PI_FR2"]`)) + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/v1/me/bill/PI_FR1", + httpmock.NewStringResponder(200, `{"billId":"PI_FR1","date":"2026-08-01","priceWithTax":{"text":"10.00 EUR"}}`)) + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/v1/me/bill/PI_FR2", + httpmock.NewStringResponder(500, `{"message":"Internal server error"}`)) + + out, err := cmd.Execute("account", "bill", "list", "--ignore-errors", "-o", "json") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("PI_FR1")) + assert.Cmp(out, td.Not(td.Contains("{}")), "a failed read must not become an empty object") + var rows []map[string]any + require.CmpNoError(json.Unmarshal([]byte(out), &rows)) + assert.Cmp(len(rows), 1, "one invoice was read, so one row") +} diff --git a/internal/services/account/billing.go b/internal/services/account/billing.go index 0ed977ef6..e0780d065 100644 --- a/internal/services/account/billing.go +++ b/internal/services/account/billing.go @@ -179,6 +179,18 @@ func ListBills(_ *cobra.Command, _ []string) { rows := make([]map[string]any, 0, len(bills)) for _, bill := range bills { + // FetchObjectsParallel preallocates one slot per id and only writes the + // ones that succeeded, so with --ignore-errors the failures stay in the + // slice as nil maps. Kept, they became a blank row in the table and a + // bare {} under -o json — one per failed read, with nothing saying how + // many were missing, which is worse than a short list. + // FetchExpandedArray in the same package already drops them; these two + // call sites bypass it because they need a query string and a count + // first. + if bill == nil { + continue + } + rows = append(rows, billSecretsView(bill)) } @@ -237,6 +249,10 @@ func ListRefunds(_ *cobra.Command, _ []string) { rows := make([]map[string]any, 0, len(refunds)) for _, refund := range refunds { + if refund == nil { + continue + } + rows = append(rows, billSecretsView(refund)) } From 1a1bda3155664c0a6546aaeb3b39b38dc63d2b5d Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 24 Aug 2026 12:03:02 +0200 Subject: [PATCH 09/11] fix(dry-run): stop printing the payload twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/cmd/baremetal_test.go | 50 ++++++++++++++++++++++++++++++ internal/services/common/common.go | 15 +++++++-- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/internal/cmd/baremetal_test.go b/internal/cmd/baremetal_test.go index de7da2116..4c243ae30 100644 --- a/internal/cmd/baremetal_test.go +++ b/internal/cmd/baremetal_test.go @@ -5,6 +5,9 @@ package cmd_test import ( + "bytes" + "log" + "github.com/jarcoal/httpmock" "github.com/maxatome/go-testdeep/td" "github.com/ovh/ovhcloud-cli/internal/cmd" @@ -166,3 +169,50 @@ func (ms *MockSuite) TestBaremetalReinstallDryRun(assert, require *td.T) { assert.Cmp(httpmock.GetCallCountInfo()["POST https://eu.api.ovh.com/v1/dedicated/server/fakeBaremetal/reinstall"], 0, "no reinstall call must reach the API") } + +// A dry run prints the payload once. It used to print it twice: the message +// carries it, and a log.Println just above the --dry-run 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 goes to stderr, so no assertion on stdout could ever have seen +// it — which is why it survived every green run. This one redirects the logger. +func (ms *MockSuite) TestBaremetalReinstallDryRunDoesNotLogTheParametersTwice(assert, require *td.T) { + httpmock.RegisterResponder("POST", "https://eu.api.ovh.com/v1/dedicated/server/fakeBaremetal/reinstall", + httpmock.NewStringResponder(200, `{"taskId": 123}`), + ) + var logged bytes.Buffer + previous := log.Writer() + log.SetOutput(&logged) + defer log.SetOutput(previous) + + out, err := cmd.Execute("baremetal", "reinstall", "fakeBaremetal", "--os", "debian12_64", "--dry-run") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains(`"operatingSystem": "debian12_64"`), + "the payload is still printed, once, as the message") + assert.Cmp(logged.String(), td.Not(td.Contains("Final parameters")), + "a dry run logs nothing: it sends nothing") +} + +// The positive control of the test above: a REAL run still logs what it is +// about to send. Without it, deleting the log line altogether would pass. +func (ms *MockSuite) TestBaremetalReinstallStillLogsTheParametersItSends(assert, require *td.T) { + httpmock.RegisterResponder("POST", "https://eu.api.ovh.com/v1/dedicated/server/fakeBaremetal/reinstall", + httpmock.NewStringResponder(200, `{"taskId": 123}`), + ) + var logged bytes.Buffer + previous := log.Writer() + log.SetOutput(&logged) + defer log.SetOutput(previous) + + _, err := cmd.Execute("baremetal", "reinstall", "fakeBaremetal", "--os", "debian12_64", "--yes") + + require.CmpNoError(err) + assert.Cmp(logged.String(), td.Contains("Final parameters")) + assert.Cmp(logged.String(), td.Contains(`"operatingSystem": "debian12_64"`)) +} diff --git a/internal/services/common/common.go b/internal/services/common/common.go index 49988d8aa..f1f2c7d31 100644 --- a/internal/services/common/common.go +++ b/internal/services/common/common.go @@ -178,8 +178,6 @@ func CreateResource(cmd *cobra.Command, path, endpoint, defaultExample string, return nil, fmt.Errorf("parameters cannot be marshalled: %w", err) } - log.Println("Final parameters: \n" + string(out)) - // --dry-run stops here: the caller sees exactly what would have been sent, // and nothing reaches the API. if flags.DryRun { @@ -198,6 +196,19 @@ func CreateResource(cmd *cobra.Command, path, endpoint, defaultExample string, return nil, nil } + // Logged only once the dry run is ruled out. A --dry-run already prints the + // payload as its message, so logging it here printed the same JSON twice, + // the second time 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" } + // + // A real run still logs what it is about to send, which is what this line + // was for. + log.Println("Final parameters: \n" + string(out)) + var createdResource map[string]any if err := httpLib.Client.Post(endpoint, parameters, &createdResource); err != nil { return nil, fmt.Errorf("error creating resource: %w", err) From c421ef4e382b1135ad08ebd796c3e3146d2afbc6 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 24 Aug 2026 12:06:07 +0200 Subject: [PATCH 10/11] test: move the dry-run log tests to their own file 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 --- internal/cmd/baremetal_test.go | 50 ----------------------- internal/cmd/dry_run_log_test.go | 68 ++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 50 deletions(-) create mode 100644 internal/cmd/dry_run_log_test.go diff --git a/internal/cmd/baremetal_test.go b/internal/cmd/baremetal_test.go index 4c243ae30..de7da2116 100644 --- a/internal/cmd/baremetal_test.go +++ b/internal/cmd/baremetal_test.go @@ -5,9 +5,6 @@ package cmd_test import ( - "bytes" - "log" - "github.com/jarcoal/httpmock" "github.com/maxatome/go-testdeep/td" "github.com/ovh/ovhcloud-cli/internal/cmd" @@ -169,50 +166,3 @@ func (ms *MockSuite) TestBaremetalReinstallDryRun(assert, require *td.T) { assert.Cmp(httpmock.GetCallCountInfo()["POST https://eu.api.ovh.com/v1/dedicated/server/fakeBaremetal/reinstall"], 0, "no reinstall call must reach the API") } - -// A dry run prints the payload once. It used to print it twice: the message -// carries it, and a log.Println just above the --dry-run 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 goes to stderr, so no assertion on stdout could ever have seen -// it — which is why it survived every green run. This one redirects the logger. -func (ms *MockSuite) TestBaremetalReinstallDryRunDoesNotLogTheParametersTwice(assert, require *td.T) { - httpmock.RegisterResponder("POST", "https://eu.api.ovh.com/v1/dedicated/server/fakeBaremetal/reinstall", - httpmock.NewStringResponder(200, `{"taskId": 123}`), - ) - var logged bytes.Buffer - previous := log.Writer() - log.SetOutput(&logged) - defer log.SetOutput(previous) - - out, err := cmd.Execute("baremetal", "reinstall", "fakeBaremetal", "--os", "debian12_64", "--dry-run") - - require.CmpNoError(err) - assert.Cmp(out, td.Contains(`"operatingSystem": "debian12_64"`), - "the payload is still printed, once, as the message") - assert.Cmp(logged.String(), td.Not(td.Contains("Final parameters")), - "a dry run logs nothing: it sends nothing") -} - -// The positive control of the test above: a REAL run still logs what it is -// about to send. Without it, deleting the log line altogether would pass. -func (ms *MockSuite) TestBaremetalReinstallStillLogsTheParametersItSends(assert, require *td.T) { - httpmock.RegisterResponder("POST", "https://eu.api.ovh.com/v1/dedicated/server/fakeBaremetal/reinstall", - httpmock.NewStringResponder(200, `{"taskId": 123}`), - ) - var logged bytes.Buffer - previous := log.Writer() - log.SetOutput(&logged) - defer log.SetOutput(previous) - - _, err := cmd.Execute("baremetal", "reinstall", "fakeBaremetal", "--os", "debian12_64", "--yes") - - require.CmpNoError(err) - assert.Cmp(logged.String(), td.Contains("Final parameters")) - assert.Cmp(logged.String(), td.Contains(`"operatingSystem": "debian12_64"`)) -} diff --git a/internal/cmd/dry_run_log_test.go b/internal/cmd/dry_run_log_test.go new file mode 100644 index 000000000..fcf9f15f3 --- /dev/null +++ b/internal/cmd/dry_run_log_test.go @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +// Ces deux tests vivent dans leur propre fichier, et non dans +// baremetal_test.go, pour une raison mecanique : 23 branches en aval ajoutent +// elles aussi des tests juste apres TestBaremetalReinstallDryRun et dans le +// meme bloc d'imports. Les y placer a fait echouer les 23 merges d'un coup, sur +// une adjacence et non sur un desaccord. Un fichier neuf ne peut entrer en +// collision qu'avec un fichier de meme nom. + +package cmd_test + +import ( + "bytes" + "log" + + "github.com/jarcoal/httpmock" + "github.com/maxatome/go-testdeep/td" + "github.com/ovh/ovhcloud-cli/internal/cmd" +) + +// A dry run prints the payload once. It used to print it twice: the message +// carries it, and a log.Println just above the --dry-run 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 goes to stderr, so no assertion on stdout could ever have seen +// it — which is why it survived every green run. This one redirects the logger. +func (ms *MockSuite) TestBaremetalReinstallDryRunDoesNotLogTheParametersTwice(assert, require *td.T) { + httpmock.RegisterResponder("POST", "https://eu.api.ovh.com/v1/dedicated/server/fakeBaremetal/reinstall", + httpmock.NewStringResponder(200, `{"taskId": 123}`), + ) + var logged bytes.Buffer + previous := log.Writer() + log.SetOutput(&logged) + defer log.SetOutput(previous) + + out, err := cmd.Execute("baremetal", "reinstall", "fakeBaremetal", "--os", "debian12_64", "--dry-run") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains(`"operatingSystem": "debian12_64"`), + "the payload is still printed, once, as the message") + assert.Cmp(logged.String(), td.Not(td.Contains("Final parameters")), + "a dry run logs nothing: it sends nothing") +} + +// The positive control of the test above: a REAL run still logs what it is +// about to send. Without it, deleting the log line altogether would pass. +func (ms *MockSuite) TestBaremetalReinstallStillLogsTheParametersItSends(assert, require *td.T) { + httpmock.RegisterResponder("POST", "https://eu.api.ovh.com/v1/dedicated/server/fakeBaremetal/reinstall", + httpmock.NewStringResponder(200, `{"taskId": 123}`), + ) + var logged bytes.Buffer + previous := log.Writer() + log.SetOutput(&logged) + defer log.SetOutput(previous) + + _, err := cmd.Execute("baremetal", "reinstall", "fakeBaremetal", "--os", "debian12_64", "--yes") + + require.CmpNoError(err) + assert.Cmp(logged.String(), td.Contains("Final parameters")) + assert.Cmp(logged.String(), td.Contains(`"operatingSystem": "debian12_64"`)) +} From 37e444bb318aabe3129e818ca7cc00d074416feb Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 24 Aug 2026 12:06:21 +0200 Subject: [PATCH 11/11] chore(doc): regenerate after bringing in the parent branch Signed-off-by: Denis --- doc/ovhcloud.md | 127 ++++++++--------------------------------- doc/ovhcloud_logout.md | 46 +++++++++++++++ 2 files changed, 70 insertions(+), 103 deletions(-) create mode 100644 doc/ovhcloud_logout.md diff --git a/doc/ovhcloud.md b/doc/ovhcloud.md index 8d00740f6..7ca1e6988 100644 --- a/doc/ovhcloud.md +++ b/doc/ovhcloud.md @@ -1,91 +1,37 @@ -# OVHcloud CLI (`ovhcloud`) Documentation +## ovhcloud ---- +CLI to manage your OVHcloud services -## Overview +### Options -`ovhcloud` is a single, unified command‑line interface for managing the full range of OVHcloud products and account resources directly from your terminal. Whether you need to automate provisioning, perform quick look‑ups, or integrate OVHcloud operations into CI/CD pipelines, `ovhcloud` offers fine‑grained commands and consistent output formats (table, JSON, YAML, or custom gval expressions). - ---- - -## Quick Start - -```bash -# Display the top‑level help -ovhcloud --help - -# Log in and create API credentials (interactive) -ovhcloud login - -# List your VPS instances as JSON -ohvcloud vps list -o json ``` - -Check out the [authentication page](authentication.md) for further information about the authentication means. - -You can manage multiple OVHcloud accounts using [profiles](profiles.md). Create a profile with `ovhcloud login --profile `, switch between them with `ovhcloud config profile switch `, or use `--profile ` on any command. - -### Generate Shell Completion - -```bash -# Bash -eval "$(./ovhcloud completion bash)" -# Zsh -eval "$(./ovhcloud completion zsh)" -# Fish -./ovhcloud completion fish | source -# PowerShell -./ovhcloud completion powershell | Out-String | Invoke-Expression + -d, --debug Activate debug mode (will log all HTTP requests details) + -h, --help help for ovhcloud + -e, --ignore-errors Ignore errors in API calls when it is not fatal to the execution + -o, --output string Output format: json, yaml, interactive, or a custom format expression (using https://github.com/PaesslerAG/gval syntax) + Examples: + --output json + --output yaml + --output interactive + --output 'id' (to extract a single field) + --output 'nested.field.subfield' (to extract a nested field) + --output '[id, "name"]' (to extract multiple fields as an array) + --output '{"newKey": oldKey, "otherKey": nested.field}' (to extract and rename fields in an object) + --output 'name+","+type' (to extract and concatenate fields in a string) + --output '(nbFieldA + nbFieldB) * 10' (to compute values from numeric fields) + --profile string Use a specific profile from the configuration file ``` -Add the appropriate line to your shell’s startup file (`~/.bashrc`, `~/.zshrc`, etc.) to enable persistent autocompletion. - ---- - -## Global Usage - -```text -ovhcloud [command] [flags] -``` - -### Global Flags - -| Flag | Description | -| ------------------ | ---------------------------------------------------- | -| `--debug` | Activate debug mode (logs all HTTP‑request details). | -| `--ignore-errors` | Ignore errors of API calls made when listing items. | -| `--filter ` | Filter lists output with a [gval] expression. | -| `-h`, `--help` | Display help for `ovhcloud` or a specific command. | -| `-o interactive` | Produce interactive (prompt‑based) output. | -| `-o json` | Output data in JSON format. | -| `-o yaml` | Output data in YAML format. | -| `-o ` | Format output with a [gval] expression. | - -[gval]: https://github.com/PaesslerAG/gval - -#### Filtering examples - -- Strict string equality: `--filter 'name=="something"'` -- String regexp comparison: `--filter 'name=~"something"'` -- Number comparison: `--filter 'bootId > 1'` - -#### Formatting example - -- Extract only one field: `-o 'ip'` -- Extract an object: `-o '{name: ip}'` - ---- - -## Command Reference - -Below is the full list of primary sub‑commands available at the time of writing. Each can be explored in depth with `ovhcloud --help`. +### SEE ALSO * [ovhcloud account](ovhcloud_account.md) - Manage your account * [ovhcloud alldom](ovhcloud_alldom.md) - Retrieve information and manage your AllDom services -* [ovhcloud baremetal](ovhcloud_baremetal.md) - Retrieve information and manage your Bare Metal services * [ovhcloud backup-services](ovhcloud_backup-services.md) - Retrieve information and manage your Veeam Backup services +* [ovhcloud baremetal](ovhcloud_baremetal.md) - Retrieve information and manage your Bare Metal services +* [ovhcloud browser](ovhcloud_browser.md) - Launch a TUI for the OVHcloud Manager - Public Cloud universe only [EXPERIMENTAL] * [ovhcloud cdn-dedicated](ovhcloud_cdn-dedicated.md) - Retrieve information and manage your dedicated CDN services * [ovhcloud cloud](ovhcloud_cloud.md) - Manage your projects and services in the Public Cloud universe (MKS, MPR, MRS, Object Storage...) +* [ovhcloud completion](ovhcloud_completion.md) - Generate shell completion scripts * [ovhcloud config](ovhcloud_config.md) - Manage your CLI configuration * [ovhcloud dedicated-ceph](ovhcloud_dedicated-ceph.md) - Retrieve information and manage your Dedicated Ceph services * [ovhcloud dedicated-cloud](ovhcloud_dedicated-cloud.md) - Retrieve information and manage your DedicatedCloud services @@ -103,6 +49,7 @@ Below is the full list of primary sub‑commands available at the time of writin * [ovhcloud ldp](ovhcloud_ldp.md) - Retrieve information and manage your LDP (Logs Data Platform) services * [ovhcloud location](ovhcloud_location.md) - Retrieve information and manage your Location services * [ovhcloud login](ovhcloud_login.md) - Login to your OVHcloud account to create API credentials +* [ovhcloud logout](ovhcloud_logout.md) - Revoke your API credentials and remove them from the configuration * [ovhcloud nutanix](ovhcloud_nutanix.md) - Retrieve information and manage your Nutanix services * [ovhcloud okms](ovhcloud_okms.md) - Retrieve information and manage your OKMS (Key Management Services) * [ovhcloud overthebox](ovhcloud_overthebox.md) - Retrieve information and manage your OverTheBox services @@ -114,6 +61,7 @@ Below is the full list of primary sub‑commands available at the time of writin * [ovhcloud storage-netapp](ovhcloud_storage-netapp.md) - Retrieve information and manage your Storage NetApp services * [ovhcloud support-tickets](ovhcloud_support-tickets.md) - Retrieve information and manage your support tickets * [ovhcloud telephony](ovhcloud_telephony.md) - Retrieve information and manage your Telephony services +* [ovhcloud upgrade](ovhcloud_upgrade.md) - Upgrade OVHcloud CLI to the latest version * [ovhcloud veeamcloudconnect](ovhcloud_veeamcloudconnect.md) - Retrieve information and manage your VeeamCloudConnect services * [ovhcloud veeamenterprise](ovhcloud_veeamenterprise.md) - Retrieve information and manage your VeeamEnterprise services * [ovhcloud version](ovhcloud_version.md) - Get OVHcloud CLI version @@ -125,30 +73,3 @@ Below is the full list of primary sub‑commands available at the time of writin * [ovhcloud webhosting](ovhcloud_webhosting.md) - Retrieve information and manage your WebHosting services * [ovhcloud xdsl](ovhcloud_xdsl.md) - Retrieve information and manage your XDSL services -> **Tip**  Use `-o json`, `-o yaml`, or `-o ` with a gval expression to integrate `ovhcloud` into scripts and automation pipelines. - ---- - -## Examples - -| Task | Command | -| ------------------------------------- | ---------------------------------------------- | -| Log in and save credentials | `ovhcloud login` | -| List VPS instances (tabular) | `ovhcloud vps list` | -| Fetch details of a single VPS in JSON | `ovhcloud vps get -o json` | -| Reinstall a baremetal interactively | `ovhcloud baremetal reinstall --editor` | - ---- - -## Troubleshooting - -* **Verbose output** — Use `--debug` to inspect raw API calls and responses. -* **Authentication issues** — Run `ovhcloud login` again to regenerate valid API keys. -* **Rate limits** — OVHcloud APIs impose rate limits; plan retries or exponential backoff in scripts. - ---- - -## Further Reading - -* OVHcloud API reference: [https://eu.api.ovh.com/console](https://eu.api.ovh.com/console) -* OVHcloud community guides and tutorials. diff --git a/doc/ovhcloud_logout.md b/doc/ovhcloud_logout.md new file mode 100644 index 000000000..c78e5c513 --- /dev/null +++ b/doc/ovhcloud_logout.md @@ -0,0 +1,46 @@ +## ovhcloud logout + +Revoke your API credentials and remove them from the configuration + +``` +ovhcloud logout [flags] +``` + +### Examples + +``` +ovhcloud logout +ovhcloud logout --yes +ovhcloud logout --profile work +``` + +### Options + +``` + -h, --help help for logout + -y, --yes Do not ask for confirmation +``` + +### Options inherited from parent commands + +``` + -d, --debug Activate debug mode (will log all HTTP requests details) + -e, --ignore-errors Ignore errors in API calls when it is not fatal to the execution + -o, --output string Output format: json, yaml, interactive, or a custom format expression (using https://github.com/PaesslerAG/gval syntax) + Examples: + --output json + --output yaml + --output interactive + --output 'id' (to extract a single field) + --output 'nested.field.subfield' (to extract a nested field) + --output '[id, "name"]' (to extract multiple fields as an array) + --output '{"newKey": oldKey, "otherKey": nested.field}' (to extract and rename fields in an object) + --output 'name+","+type' (to extract and concatenate fields in a string) + --output '(nbFieldA + nbFieldB) * 10' (to compute values from numeric fields) + --profile string Use a specific profile from the configuration file +``` + +### SEE ALSO + +* [ovhcloud](ovhcloud.md) - CLI to manage your OVHcloud services +