From d2de4c3a506c4c92ba452e933e382149dc8d8748 Mon Sep 17 00:00:00 2001 From: Denis Hamon Date: Tue, 18 Aug 2026 17:02:04 +0200 Subject: [PATCH 01/13] 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/13] 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 313b5043dad21e6c46a317d4b825934395e20943 Mon Sep 17 00:00:00 2001 From: Denis Hamon Date: Thu, 20 Aug 2026 03:31:53 +0200 Subject: [PATCH 03/13] feat(backup-services): the Veeam backup surface, reached from the machine it protects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventeen v2 paths manage the Veeam Backup product — tenants, storage vaults, the Service Provider Console that drives the agents — and none had a command. Not skipped: invisible, because this repository could only fetch v1 schemas until #262. Every operation is badged "Beta version" upstream. ovhcloud backup-services tenant|vault|vspc list|get|edit ovhcloud backup-services vault buckets ovhcloud backup-services policies ovhcloud backup-services deploy-script ovhcloud backup-services licenses list|servers ovhcloud baremetal backup-agent show|create|edit|delete The hierarchy is three UUIDs deep and nobody knows any of them, so the levels are resolved when they are not ambiguous — one tenant is taken, several are refused with their names and identifiers, none is an answer rather than an error. --tenant and --vspc exist for the accounts where there is a choice. An agent is reached from the machine it protects. backup.AgentProductTypeEnum lists OVHCLOUD_BAREMETAL as a first-class product type and the agent names its server in productResourceName, so `baremetal backup-agent` starts where an operator starts. Measured on 20 August 2026: all nine agents of this account are OVHCLOUD_BAREMETAL, named agent-, with ips equal to the server's own address in a /32 and region equal to the server's region — nine out of nine. That correspondence is why `create` needs nothing but the server name. Three things the measurement decided. An agent is created NOT_INSTALLED, and that is where it stops: the object exists and the software is not on the machine. All nine agents of this account are in that state, with an empty policy, while two retention policies sit beside them — nine provisioned, none deployed, none retaining. So NOT_INSTALLED is a settled status for --wait, not a transition, and the success message says what is left to do rather than "created". The PUT replaces the target spec, so `edit` carries over what it is not changing. Sending only --policy would blank the display name and the addresses, which is the trap `iam resource edit --tag` still has. `vault bucket credentials` is deliberately absent. The schema declares a GET and the API answers 405 on all three buckets of this account, with no Allow header, while the same path answers 404 for a bucket that does not exist and the parent bucket route answers 200. The route resolves and then refuses the method, so the command would always fail — the same reason install/hardwareRaidSize was left out in #250. Two columns exist because the raw object hides what somebody is looking for. A resource carries the tasks running on it and there is no task route to poll, so the tasks column is the only place a failure appears; it names the operation, because "ERROR" answers "error doing what?" with nothing. And allowedIps is deliberately not a vault column: it looks like a field of a vault and is not, and reading it off the vault answers 0 where the truth is 9. That column found something. On this account three BACKUP_VAULT_CREATE tasks are in ERROR and a VSPC_AGENT_UPDATE beside them, with two VSPC_TENANT_UPDATE stuck in PENDING — sampled six times over five minutes, same identifiers, same statuses — on resources that all report READY. Nothing surfaced them before. 10 unit tests, 15 cobra tests, 15 sabotages, 15 red. Signed-off-by: Denis Hamon --- doc/ovhcloud.md | 1 + doc/ovhcloud_backup-services.md | 49 + doc/ovhcloud_backup-services_deploy-script.md | 45 + doc/ovhcloud_backup-services_licenses.md | 37 + doc/ovhcloud_backup-services_licenses_list.md | 46 + ...hcloud_backup-services_licenses_servers.md | 46 + doc/ovhcloud_backup-services_policies.md | 46 + doc/ovhcloud_backup-services_tenant.md | 37 + doc/ovhcloud_backup-services_tenant_get.md | 39 + doc/ovhcloud_backup-services_tenant_list.md | 46 + doc/ovhcloud_backup-services_vault.md | 39 + doc/ovhcloud_backup-services_vault_buckets.md | 46 + doc/ovhcloud_backup-services_vault_edit.md | 42 + doc/ovhcloud_backup-services_vault_get.md | 39 + doc/ovhcloud_backup-services_vault_list.md | 46 + doc/ovhcloud_backup-services_vspc.md | 38 + doc/ovhcloud_backup-services_vspc_edit.md | 42 + doc/ovhcloud_backup-services_vspc_get.md | 39 + doc/ovhcloud_backup-services_vspc_list.md | 46 + doc/ovhcloud_baremetal.md | 1 + doc/ovhcloud_baremetal_backup-agent.md | 37 + doc/ovhcloud_baremetal_backup-agent_create.md | 49 + doc/ovhcloud_baremetal_backup-agent_delete.md | 40 + doc/ovhcloud_baremetal_backup-agent_edit.md | 42 + doc/ovhcloud_baremetal_backup-agent_show.md | 44 + .../assets/api-schemas/backupservices_v2.json | 3720 +++++++++++++++++ internal/assets/assets.go | 3 + internal/assets/assets_wasm.go | 1 + internal/cmd/backupservices.go | 159 + internal/cmd/backupservices_test.go | 283 ++ internal/cmd/baremetal.go | 68 + .../services/backupservices/backupservices.go | 480 +++ .../backupservices/backupservices_test.go | 112 + internal/services/backupservices/resolve.go | 167 + internal/services/baremetal/backupagent.go | 546 +++ .../services/baremetal/backupagent_test.go | 65 + 36 files changed, 6586 insertions(+) create mode 100644 doc/ovhcloud_backup-services.md create mode 100644 doc/ovhcloud_backup-services_deploy-script.md create mode 100644 doc/ovhcloud_backup-services_licenses.md create mode 100644 doc/ovhcloud_backup-services_licenses_list.md create mode 100644 doc/ovhcloud_backup-services_licenses_servers.md create mode 100644 doc/ovhcloud_backup-services_policies.md create mode 100644 doc/ovhcloud_backup-services_tenant.md create mode 100644 doc/ovhcloud_backup-services_tenant_get.md create mode 100644 doc/ovhcloud_backup-services_tenant_list.md create mode 100644 doc/ovhcloud_backup-services_vault.md create mode 100644 doc/ovhcloud_backup-services_vault_buckets.md create mode 100644 doc/ovhcloud_backup-services_vault_edit.md create mode 100644 doc/ovhcloud_backup-services_vault_get.md create mode 100644 doc/ovhcloud_backup-services_vault_list.md create mode 100644 doc/ovhcloud_backup-services_vspc.md create mode 100644 doc/ovhcloud_backup-services_vspc_edit.md create mode 100644 doc/ovhcloud_backup-services_vspc_get.md create mode 100644 doc/ovhcloud_backup-services_vspc_list.md create mode 100644 doc/ovhcloud_baremetal_backup-agent.md create mode 100644 doc/ovhcloud_baremetal_backup-agent_create.md create mode 100644 doc/ovhcloud_baremetal_backup-agent_delete.md create mode 100644 doc/ovhcloud_baremetal_backup-agent_edit.md create mode 100644 doc/ovhcloud_baremetal_backup-agent_show.md create mode 100644 internal/assets/api-schemas/backupservices_v2.json create mode 100644 internal/cmd/backupservices.go create mode 100644 internal/cmd/backupservices_test.go create mode 100644 internal/services/backupservices/backupservices.go create mode 100644 internal/services/backupservices/backupservices_test.go create mode 100644 internal/services/backupservices/resolve.go create mode 100644 internal/services/baremetal/backupagent.go create mode 100644 internal/services/baremetal/backupagent_test.go diff --git a/doc/ovhcloud.md b/doc/ovhcloud.md index 7d79d6150..8d00740f6 100644 --- a/doc/ovhcloud.md +++ b/doc/ovhcloud.md @@ -83,6 +83,7 @@ Below is the full list of primary sub‑commands available at the time of writin * [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 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 config](ovhcloud_config.md) - Manage your CLI configuration diff --git a/doc/ovhcloud_backup-services.md b/doc/ovhcloud_backup-services.md new file mode 100644 index 000000000..2177b8e78 --- /dev/null +++ b/doc/ovhcloud_backup-services.md @@ -0,0 +1,49 @@ +## ovhcloud backup-services + +Retrieve information and manage your Veeam Backup services + +### Synopsis + +Retrieve information and manage your Veeam Backup services. + +A backup tenant holds storage vaults and a Veeam Service Provider Console tenant; the console tenant is what drives the agents installed on your machines. Both levels are resolved when the account has only one of them, so --tenant and --vspc are only needed when there is a choice to make. + +The agents themselves are managed from the machine they protect: see `ovhcloud baremetal backup-agent`. + +### Options + +``` + -h, --help help for backup-services + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) +``` + +### 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 +* [ovhcloud backup-services deploy-script](ovhcloud_backup-services_deploy-script.md) - Show the command that installs the backup agent on a machine +* [ovhcloud backup-services licenses](ovhcloud_backup-services_licenses.md) - Show the Veeam licences held by a VSPC tenant +* [ovhcloud backup-services policies](ovhcloud_backup-services_policies.md) - List the retention policies an agent can be put on +* [ovhcloud backup-services tenant](ovhcloud_backup-services_tenant.md) - Show the backup tenants of the account +* [ovhcloud backup-services vault](ovhcloud_backup-services_vault.md) - Show and rename the storage vaults of a backup tenant +* [ovhcloud backup-services vspc](ovhcloud_backup-services_vspc.md) - Show and rename the Veeam Service Provider Console tenants + diff --git a/doc/ovhcloud_backup-services_deploy-script.md b/doc/ovhcloud_backup-services_deploy-script.md new file mode 100644 index 000000000..93d5ac6c1 --- /dev/null +++ b/doc/ovhcloud_backup-services_deploy-script.md @@ -0,0 +1,45 @@ +## ovhcloud backup-services deploy-script + +Show the command that installs the backup agent on a machine + +### Synopsis + +Show the command that installs the backup agent on a machine. + +An agent created through the API exists as an object and protects nothing until this script has run on the machine. The links carry their own authorisation. + +``` +ovhcloud backup-services deploy-script [flags] +``` + +### Options + +``` + -h, --help help for deploy-script +``` + +### 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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) +``` + +### SEE ALSO + +* [ovhcloud backup-services](ovhcloud_backup-services.md) - Retrieve information and manage your Veeam Backup services + diff --git a/doc/ovhcloud_backup-services_licenses.md b/doc/ovhcloud_backup-services_licenses.md new file mode 100644 index 000000000..c05a2f4eb --- /dev/null +++ b/doc/ovhcloud_backup-services_licenses.md @@ -0,0 +1,37 @@ +## ovhcloud backup-services licenses + +Show the Veeam licences held by a VSPC tenant + +### Options + +``` + -h, --help help for licenses +``` + +### 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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) +``` + +### SEE ALSO + +* [ovhcloud backup-services](ovhcloud_backup-services.md) - Retrieve information and manage your Veeam Backup services +* [ovhcloud backup-services licenses list](ovhcloud_backup-services_licenses_list.md) - List the Veeam licences +* [ovhcloud backup-services licenses servers](ovhcloud_backup-services_licenses_servers.md) - List the backup servers driven by a licence + diff --git a/doc/ovhcloud_backup-services_licenses_list.md b/doc/ovhcloud_backup-services_licenses_list.md new file mode 100644 index 000000000..d7f509a03 --- /dev/null +++ b/doc/ovhcloud_backup-services_licenses_list.md @@ -0,0 +1,46 @@ +## ovhcloud backup-services licenses list + +List the Veeam licences + +``` +ovhcloud backup-services licenses 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' + -h, --help help for list +``` + +### 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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) +``` + +### SEE ALSO + +* [ovhcloud backup-services licenses](ovhcloud_backup-services_licenses.md) - Show the Veeam licences held by a VSPC tenant + diff --git a/doc/ovhcloud_backup-services_licenses_servers.md b/doc/ovhcloud_backup-services_licenses_servers.md new file mode 100644 index 000000000..305ca48b4 --- /dev/null +++ b/doc/ovhcloud_backup-services_licenses_servers.md @@ -0,0 +1,46 @@ +## ovhcloud backup-services licenses servers + +List the backup servers driven by a licence + +``` +ovhcloud backup-services licenses servers [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 servers +``` + +### 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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) +``` + +### SEE ALSO + +* [ovhcloud backup-services licenses](ovhcloud_backup-services_licenses.md) - Show the Veeam licences held by a VSPC tenant + diff --git a/doc/ovhcloud_backup-services_policies.md b/doc/ovhcloud_backup-services_policies.md new file mode 100644 index 000000000..522b63de3 --- /dev/null +++ b/doc/ovhcloud_backup-services_policies.md @@ -0,0 +1,46 @@ +## ovhcloud backup-services policies + +List the retention policies an agent can be put on + +``` +ovhcloud backup-services policies [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 policies +``` + +### 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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) +``` + +### SEE ALSO + +* [ovhcloud backup-services](ovhcloud_backup-services.md) - Retrieve information and manage your Veeam Backup services + diff --git a/doc/ovhcloud_backup-services_tenant.md b/doc/ovhcloud_backup-services_tenant.md new file mode 100644 index 000000000..83a429946 --- /dev/null +++ b/doc/ovhcloud_backup-services_tenant.md @@ -0,0 +1,37 @@ +## ovhcloud backup-services tenant + +Show the backup tenants of the account + +### Options + +``` + -h, --help help for tenant +``` + +### 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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) +``` + +### SEE ALSO + +* [ovhcloud backup-services](ovhcloud_backup-services.md) - Retrieve information and manage your Veeam Backup services +* [ovhcloud backup-services tenant get](ovhcloud_backup-services_tenant_get.md) - Show one backup tenant +* [ovhcloud backup-services tenant list](ovhcloud_backup-services_tenant_list.md) - List your backup tenants + diff --git a/doc/ovhcloud_backup-services_tenant_get.md b/doc/ovhcloud_backup-services_tenant_get.md new file mode 100644 index 000000000..9af8b54b1 --- /dev/null +++ b/doc/ovhcloud_backup-services_tenant_get.md @@ -0,0 +1,39 @@ +## ovhcloud backup-services tenant get + +Show one backup tenant + +``` +ovhcloud backup-services tenant get [] [flags] +``` + +### Options + +``` + -h, --help help for get +``` + +### 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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) +``` + +### SEE ALSO + +* [ovhcloud backup-services tenant](ovhcloud_backup-services_tenant.md) - Show the backup tenants of the account + diff --git a/doc/ovhcloud_backup-services_tenant_list.md b/doc/ovhcloud_backup-services_tenant_list.md new file mode 100644 index 000000000..9f65e0fdc --- /dev/null +++ b/doc/ovhcloud_backup-services_tenant_list.md @@ -0,0 +1,46 @@ +## ovhcloud backup-services tenant list + +List your backup tenants + +``` +ovhcloud backup-services tenant 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' + -h, --help help for list +``` + +### 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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) +``` + +### SEE ALSO + +* [ovhcloud backup-services tenant](ovhcloud_backup-services_tenant.md) - Show the backup tenants of the account + diff --git a/doc/ovhcloud_backup-services_vault.md b/doc/ovhcloud_backup-services_vault.md new file mode 100644 index 000000000..637bad0a6 --- /dev/null +++ b/doc/ovhcloud_backup-services_vault.md @@ -0,0 +1,39 @@ +## ovhcloud backup-services vault + +Show and rename the storage vaults of a backup tenant + +### Options + +``` + -h, --help help for vault +``` + +### 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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) +``` + +### SEE ALSO + +* [ovhcloud backup-services](ovhcloud_backup-services.md) - Retrieve information and manage your Veeam Backup services +* [ovhcloud backup-services vault buckets](ovhcloud_backup-services_vault_buckets.md) - List the buckets behind a storage vault +* [ovhcloud backup-services vault edit](ovhcloud_backup-services_vault_edit.md) - Rename a storage vault +* [ovhcloud backup-services vault get](ovhcloud_backup-services_vault_get.md) - Show one storage vault +* [ovhcloud backup-services vault list](ovhcloud_backup-services_vault_list.md) - List the storage vaults + diff --git a/doc/ovhcloud_backup-services_vault_buckets.md b/doc/ovhcloud_backup-services_vault_buckets.md new file mode 100644 index 000000000..6680844b4 --- /dev/null +++ b/doc/ovhcloud_backup-services_vault_buckets.md @@ -0,0 +1,46 @@ +## ovhcloud backup-services vault buckets + +List the buckets behind a storage vault + +``` +ovhcloud backup-services vault buckets [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 buckets +``` + +### 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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) +``` + +### SEE ALSO + +* [ovhcloud backup-services vault](ovhcloud_backup-services_vault.md) - Show and rename the storage vaults of a backup tenant + diff --git a/doc/ovhcloud_backup-services_vault_edit.md b/doc/ovhcloud_backup-services_vault_edit.md new file mode 100644 index 000000000..f2dba3cda --- /dev/null +++ b/doc/ovhcloud_backup-services_vault_edit.md @@ -0,0 +1,42 @@ +## ovhcloud backup-services vault edit + +Rename a storage vault + +``` +ovhcloud backup-services vault edit [flags] +``` + +### Options + +``` + --dry-run Print the call that would be made without making it + -h, --help help for edit + --name string New name of the vault + -y, --yes Skip the confirmation prompt (required for unattended runs) +``` + +### 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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) +``` + +### SEE ALSO + +* [ovhcloud backup-services vault](ovhcloud_backup-services_vault.md) - Show and rename the storage vaults of a backup tenant + diff --git a/doc/ovhcloud_backup-services_vault_get.md b/doc/ovhcloud_backup-services_vault_get.md new file mode 100644 index 000000000..25e887659 --- /dev/null +++ b/doc/ovhcloud_backup-services_vault_get.md @@ -0,0 +1,39 @@ +## ovhcloud backup-services vault get + +Show one storage vault + +``` +ovhcloud backup-services vault get [flags] +``` + +### Options + +``` + -h, --help help for get +``` + +### 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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) +``` + +### SEE ALSO + +* [ovhcloud backup-services vault](ovhcloud_backup-services_vault.md) - Show and rename the storage vaults of a backup tenant + diff --git a/doc/ovhcloud_backup-services_vault_list.md b/doc/ovhcloud_backup-services_vault_list.md new file mode 100644 index 000000000..53bc2f948 --- /dev/null +++ b/doc/ovhcloud_backup-services_vault_list.md @@ -0,0 +1,46 @@ +## ovhcloud backup-services vault list + +List the storage vaults + +``` +ovhcloud backup-services vault 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' + -h, --help help for list +``` + +### 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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) +``` + +### SEE ALSO + +* [ovhcloud backup-services vault](ovhcloud_backup-services_vault.md) - Show and rename the storage vaults of a backup tenant + diff --git a/doc/ovhcloud_backup-services_vspc.md b/doc/ovhcloud_backup-services_vspc.md new file mode 100644 index 000000000..2fabca57a --- /dev/null +++ b/doc/ovhcloud_backup-services_vspc.md @@ -0,0 +1,38 @@ +## ovhcloud backup-services vspc + +Show and rename the Veeam Service Provider Console tenants + +### Options + +``` + -h, --help help for vspc +``` + +### 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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) +``` + +### SEE ALSO + +* [ovhcloud backup-services](ovhcloud_backup-services.md) - Retrieve information and manage your Veeam Backup services +* [ovhcloud backup-services vspc edit](ovhcloud_backup-services_vspc_edit.md) - Rename a VSPC tenant +* [ovhcloud backup-services vspc get](ovhcloud_backup-services_vspc_get.md) - Show one VSPC tenant +* [ovhcloud backup-services vspc list](ovhcloud_backup-services_vspc_list.md) - List the VSPC tenants + diff --git a/doc/ovhcloud_backup-services_vspc_edit.md b/doc/ovhcloud_backup-services_vspc_edit.md new file mode 100644 index 000000000..91fdb0148 --- /dev/null +++ b/doc/ovhcloud_backup-services_vspc_edit.md @@ -0,0 +1,42 @@ +## ovhcloud backup-services vspc edit + +Rename a VSPC tenant + +``` +ovhcloud backup-services vspc edit [flags] +``` + +### Options + +``` + --dry-run Print the call that would be made without making it + -h, --help help for edit + --name string New name of the VSPC tenant + -y, --yes Skip the confirmation prompt (required for unattended runs) +``` + +### 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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) +``` + +### SEE ALSO + +* [ovhcloud backup-services vspc](ovhcloud_backup-services_vspc.md) - Show and rename the Veeam Service Provider Console tenants + diff --git a/doc/ovhcloud_backup-services_vspc_get.md b/doc/ovhcloud_backup-services_vspc_get.md new file mode 100644 index 000000000..6a43a9bfd --- /dev/null +++ b/doc/ovhcloud_backup-services_vspc_get.md @@ -0,0 +1,39 @@ +## ovhcloud backup-services vspc get + +Show one VSPC tenant + +``` +ovhcloud backup-services vspc get [] [flags] +``` + +### Options + +``` + -h, --help help for get +``` + +### 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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) +``` + +### SEE ALSO + +* [ovhcloud backup-services vspc](ovhcloud_backup-services_vspc.md) - Show and rename the Veeam Service Provider Console tenants + diff --git a/doc/ovhcloud_backup-services_vspc_list.md b/doc/ovhcloud_backup-services_vspc_list.md new file mode 100644 index 000000000..d2a492b7d --- /dev/null +++ b/doc/ovhcloud_backup-services_vspc_list.md @@ -0,0 +1,46 @@ +## ovhcloud backup-services vspc list + +List the VSPC tenants + +``` +ovhcloud backup-services vspc 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' + -h, --help help for list +``` + +### 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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) +``` + +### SEE ALSO + +* [ovhcloud backup-services vspc](ovhcloud_backup-services_vspc.md) - Show and rename the Veeam Service Provider Console tenants + diff --git a/doc/ovhcloud_baremetal.md b/doc/ovhcloud_baremetal.md index 42710243c..af6a25e45 100644 --- a/doc/ovhcloud_baremetal.md +++ b/doc/ovhcloud_baremetal.md @@ -31,6 +31,7 @@ Retrieve information and manage your Bare Metal services * [ovhcloud](ovhcloud.md) - CLI to manage your OVHcloud services * [ovhcloud baremetal backup](ovhcloud_baremetal_backup.md) - Manage the backup spaces of a dedicated server +* [ovhcloud baremetal backup-agent](ovhcloud_baremetal_backup-agent.md) - Manage the Veeam backup agent protecting this server * [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 diff --git a/doc/ovhcloud_baremetal_backup-agent.md b/doc/ovhcloud_baremetal_backup-agent.md new file mode 100644 index 000000000..ad8a90522 --- /dev/null +++ b/doc/ovhcloud_baremetal_backup-agent.md @@ -0,0 +1,37 @@ +## ovhcloud baremetal backup-agent + +Manage the Veeam backup agent protecting this server + +### Options + +``` + -h, --help help for backup-agent +``` + +### 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 +* [ovhcloud baremetal backup-agent create](ovhcloud_baremetal_backup-agent_create.md) - Provision a Veeam backup agent for this server +* [ovhcloud baremetal backup-agent delete](ovhcloud_baremetal_backup-agent_delete.md) - Remove the backup agent of this server — its restore points go with it +* [ovhcloud baremetal backup-agent edit](ovhcloud_baremetal_backup-agent_edit.md) - Change the backup agent of this server +* [ovhcloud baremetal backup-agent show](ovhcloud_baremetal_backup-agent_show.md) - Show the backup agent protecting this server + diff --git a/doc/ovhcloud_baremetal_backup-agent_create.md b/doc/ovhcloud_baremetal_backup-agent_create.md new file mode 100644 index 000000000..f449f5428 --- /dev/null +++ b/doc/ovhcloud_baremetal_backup-agent_create.md @@ -0,0 +1,49 @@ +## ovhcloud baremetal backup-agent create + +Provision a Veeam backup agent for this server + +### Synopsis + +Provision a Veeam backup agent for this server. + +The name, the addresses and the region are derived from the server itself, so nothing but the server name is needed. The agent is created NOT_INSTALLED: it protects nothing until the agent software runs on the machine, and retains nothing until it is put on a policy with `backup-agent edit --policy`. + +``` +ovhcloud baremetal backup-agent create [flags] +``` + +### Options + +``` + --display-name string Name of the agent (default: agent-) + --dry-run Print the call that would be made without making it + -h, --help help for create + --ip stringArray Address the agent is reached at (default: the server's own address, in a /32) + --region string Region the agent operates in (default: the server's region) + --wait Wait until the agent has settled before exiting + -y, --yes Skip the confirmation prompt (required for unattended runs) +``` + +### 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 backup-agent](ovhcloud_baremetal_backup-agent.md) - Manage the Veeam backup agent protecting this server + diff --git a/doc/ovhcloud_baremetal_backup-agent_delete.md b/doc/ovhcloud_baremetal_backup-agent_delete.md new file mode 100644 index 000000000..500ba1d25 --- /dev/null +++ b/doc/ovhcloud_baremetal_backup-agent_delete.md @@ -0,0 +1,40 @@ +## ovhcloud baremetal backup-agent delete + +Remove the backup agent of this server — its restore points go with it + +``` +ovhcloud baremetal backup-agent delete [flags] +``` + +### Options + +``` + --dry-run Print the call that would be made without making it + -h, --help help for delete + --wait Wait until the agent is actually gone before exiting + -y, --yes Skip the confirmation prompt (required for unattended runs) +``` + +### 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 backup-agent](ovhcloud_baremetal_backup-agent.md) - Manage the Veeam backup agent protecting this server + diff --git a/doc/ovhcloud_baremetal_backup-agent_edit.md b/doc/ovhcloud_baremetal_backup-agent_edit.md new file mode 100644 index 000000000..a9d922555 --- /dev/null +++ b/doc/ovhcloud_baremetal_backup-agent_edit.md @@ -0,0 +1,42 @@ +## ovhcloud baremetal backup-agent edit + +Change the backup agent of this server + +``` +ovhcloud baremetal backup-agent edit [flags] +``` + +### Options + +``` + --display-name string New name of the agent + --dry-run Print the call that would be made without making it + -h, --help help for edit + --ip stringArray Addresses the agent is reached at + --policy string Retention policy to put the agent on (empty takes it off retention) + -y, --yes Skip the confirmation prompt (required for unattended runs) +``` + +### 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 backup-agent](ovhcloud_baremetal_backup-agent.md) - Manage the Veeam backup agent protecting this server + diff --git a/doc/ovhcloud_baremetal_backup-agent_show.md b/doc/ovhcloud_baremetal_backup-agent_show.md new file mode 100644 index 000000000..513b021df --- /dev/null +++ b/doc/ovhcloud_baremetal_backup-agent_show.md @@ -0,0 +1,44 @@ +## ovhcloud baremetal backup-agent show + +Show the backup agent protecting this server + +``` +ovhcloud baremetal backup-agent show [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 show +``` + +### 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 backup-agent](ovhcloud_baremetal_backup-agent.md) - Manage the Veeam backup agent protecting this server + diff --git a/internal/assets/api-schemas/backupservices_v2.json b/internal/assets/api-schemas/backupservices_v2.json new file mode 100644 index 000000000..88c299783 --- /dev/null +++ b/internal/assets/api-schemas/backupservices_v2.json @@ -0,0 +1,3720 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "OVHcloud API specification", + "description": "Specification for OVHcloud API", + "version": "1.0", + "contact": { + "name": "OVH", + "email": "api@ml.ovh.net" + } + }, + "servers": [ + { + "url": "https://eu.api.ovh.com/v2" + } + ], + "components": { + "schemas": { + "any": { + "description": "Can be anything (text, int, bool, json, html, etc.)", + "example": "This is a content of any type !" + }, + "backup.AddonEnum": { + "type": "string", + "description": "Add-on features that can be enabled on a VSPC tenant", + "enum": [ + "BACKUP_AGENT", + "BACKUP_LICENSES" + ] + }, + "backup.AgentProductTypeEnum": { + "type": "string", + "description": "Backup agent target product type for your environment", + "enum": [ + "ON_PREMISE", + "OVHCLOUD_BAREMETAL", + "OVHCLOUD_PUBLIC_CLOUD", + "OVHCLOUD_VPS" + ] + }, + "backup.AgentStatusEnum": { + "type": "string", + "description": "Indicates the status of the backup agent", + "enum": [ + "CREATING", + "DISABLED", + "ENABLED", + "NOT_CONFIGURED", + "NOT_INSTALLED", + "UPDATING" + ] + }, + "backup.BackupServerOsTypeEnum": { + "type": "string", + "description": "Operating system of the backup server as reported by VSPC discovery", + "enum": [ + "LINUX_APPLIANCE", + "OTHER", + "ROCKY_LINUX", + "WINDOWS" + ] + }, + "backup.BackupServerStatusEnum": { + "type": "string", + "description": "Indicates the status of the backup server (extends backup.AgentStatusEnum with ERROR)", + "enum": [ + "CREATING", + "DELETING", + "DISABLED", + "ENABLED", + "ERROR", + "NOT_CONFIGURED", + "NOT_INSTALLED", + "UPDATING" + ] + }, + "backup.BucketPerformanceEnum": { + "type": "string", + "description": "Defines performance levels for a bucket", + "enum": [ + "HIGH_PERF", + "STANDARD" + ] + }, + "backup.BucketRoleEnum": { + "type": "string", + "description": "Roles that a bucket can assume", + "enum": [ + "PRIMARY", + "REPLICA" + ] + }, + "backup.LicenseStatusEnum": { + "type": "string", + "description": "Indicates the installation status of the Veeam license on the backup server", + "enum": [ + "CREATING", + "DELETING", + "EXPIRED", + "INSTALLED", + "NOT_INSTALLED", + "NOT_SUPPORTED", + "UPDATING" + ] + }, + "backup.LicenseTypeEnum": { + "type": "string", + "description": "Veeam license type applied to a backup server", + "enum": [ + "VEEAM_BACKUP_REPLICATION_ENTERPRISE_PLUS", + "VEEAM_DATA_PLATFORM_ADVANCED", + "VEEAM_DATA_PLATFORM_FOUNDATION", + "VEEAM_DATA_PLATFORM_PREMIUM" + ] + }, + "backup.ManagementAgentStatusEnum": { + "type": "string", + "description": "Indicates the status of the VSPC management agent on the backup server", + "enum": [ + "CONNECTED", + "ERROR", + "INSTALLED", + "NOT_CONNECTED", + "NOT_INSTALLED", + "UPDATING" + ] + }, + "backup.RegionCodeEnum": { + "type": "string", + "description": "Short S3 region code of the Object Storage endpoint, entered in lower case", + "enum": [ + "ap-south-mum", + "ap-southeast-syd", + "bhs", + "ca-east-tor", + "de", + "eu-south-mil", + "eu-west-par", + "gra", + "rbx", + "sbg", + "sgp", + "uk", + "waw" + ] + }, + "backup.VaultProductLineEnum": { + "type": "string", + "description": "Product line that owns the vault", + "enum": [ + "BACKUP_AGENT", + "BACKUP_LICENSES" + ] + }, + "backup.VaultTypeEnum": { + "type": "string", + "description": "Indicates a vault billing type", + "enum": [ + "BUNDLE", + "PAYGO" + ] + }, + "backup.VspcTypeEnum": { + "type": "string", + "description": "VSPC infrastructure type backing the tenant, set by the platform at provisioning", + "enum": [ + "ADVANCED", + "BASIC" + ] + }, + "backup.tenant": { + "type": "object", + "description": "Backup tenant resource", + "properties": { + "createdAt": { + "type": "string", + "description": "Date and time the backup tenant is created", + "format": "date-time", + "readOnly": true + }, + "currentState": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.tenant.CurrentState" + } + ], + "description": "Current operational state of the backup tenant", + "readOnly": true + }, + "currentTasks": { + "type": "array", + "description": "List of tasks that are currently running for the backup tenant", + "items": { + "$ref": "#/components/schemas/common.CurrentTask" + }, + "readOnly": true + }, + "id": { + "type": "string", + "description": "Unique identifier of the backup tenant", + "format": "uuid", + "readOnly": true + }, + "resourceStatus": { + "allOf": [ + { + "$ref": "#/components/schemas/common.ResourceStatusEnum" + } + ], + "description": "Readiness status of the backup tenant", + "readOnly": true + }, + "targetSpec": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.tenant.TargetSpec" + } + ], + "description": "Specification of the target state of the backup tenant", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "description": "Date and time the backup tenant is last updated", + "format": "date-time", + "readOnly": true + } + } + }, + "backup.tenant.CurrentState": { + "type": "object", + "description": "Shows the current state of a backup tenant with its associated VSPC tenants and vaults", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the backup tenant", + "format": "uuid", + "readOnly": true + }, + "name": { + "type": "string", + "description": "Name of the backup tenant", + "readOnly": true + }, + "vaults": { + "type": "array", + "description": "List of vault resource names associated with the backup tenant", + "items": { + "type": "string" + }, + "readOnly": true + }, + "vspcTenants": { + "type": "array", + "description": "List of VSPC tenant resource names associated with the backup tenant", + "items": { + "type": "string" + }, + "readOnly": true + } + } + }, + "backup.tenant.TargetSpec": { + "type": "object", + "description": "Specifies the target state of your backup tenant", + "properties": { + "name": { + "type": "string", + "description": "The display name of your backup tenant", + "readOnly": true + } + } + }, + "backup.tenant.vault": { + "type": "object", + "description": "Represents a backup tenant vault", + "properties": { + "createdAt": { + "type": "string", + "description": "Date and time when you created the vault", + "format": "date-time", + "readOnly": true + }, + "currentState": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.tenant.vault.CurrentState" + } + ], + "description": "Current state of your vault", + "readOnly": true + }, + "currentTasks": { + "type": "array", + "description": "List of tasks currently running on your vault", + "items": { + "$ref": "#/components/schemas/common.CurrentTask" + }, + "readOnly": true + }, + "id": { + "type": "string", + "description": "Unique identifier of your vault", + "format": "uuid", + "readOnly": true + }, + "resourceStatus": { + "allOf": [ + { + "$ref": "#/components/schemas/common.ResourceStatusEnum" + } + ], + "description": "Indicates the readiness of your vault", + "readOnly": true + }, + "targetSpec": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.tenant.vault.TargetSpec" + } + ], + "description": "Specification of your vault target", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "description": "Date and time when you last updated the vault", + "format": "date-time", + "readOnly": true + } + } + }, + "backup.tenant.vault.CurrentState": { + "type": "object", + "description": "Provides vault resource for a backup tenant", + "properties": { + "buckets": { + "type": "array", + "description": "Provides list of vault buckets", + "items": { + "$ref": "#/components/schemas/backup.tenant.vault.bucket" + }, + "readOnly": true + }, + "id": { + "type": "string", + "description": "Identifies the vault uniquely", + "format": "uuid", + "readOnly": true + }, + "includedSoftQuotaGb": { + "type": "integer", + "description": "Included soft quota in GB for a Backup Licenses BUNDLE vault; null for PAYGO, BACKUP_AGENT or when not applicable", + "nullable": true, + "readOnly": true + }, + "name": { + "type": "string", + "description": "Specifies the vault name", + "readOnly": true + }, + "region": { + "allOf": [ + { + "$ref": "#/components/schemas/common.RegionEnum" + } + ], + "description": "Specifies the region where the vault is located", + "readOnly": true + }, + "resourceName": { + "type": "string", + "description": "Specifies the vault resource name", + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/common.ResourceStatusEnum" + } + ], + "description": "Indicates the current status of the vault", + "readOnly": true + }, + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.VaultTypeEnum" + } + ], + "description": "Specifies the vault type", + "readOnly": true + }, + "vaultProductLine": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.VaultProductLineEnum" + } + ], + "description": "Product line that owns the vault; interpret it together with type to tell the two PAYGO offers apart. Null for vaults created before this field existed", + "nullable": true, + "readOnly": true + }, + "vspcTenants": { + "type": "array", + "description": "Lists resource names of VSPC tenants linked to the vault", + "items": { + "type": "string" + }, + "readOnly": true + } + } + }, + "backup.tenant.vault.TargetSpec": { + "type": "object", + "description": "Specifies the target state of your vault", + "properties": { + "name": { + "type": "string", + "description": "Display name of your vault" + } + }, + "required": [ + "name" + ] + }, + "backup.tenant.vault.bucket": { + "type": "object", + "description": "Represents a bucket resource for a vault", + "properties": { + "id": { + "type": "string", + "description": "Specifies the unique identifier of the bucket", + "format": "uuid", + "readOnly": true + }, + "name": { + "type": "string", + "description": "Specifies the name of the bucket", + "readOnly": true + }, + "performance": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.BucketPerformanceEnum" + } + ], + "description": "Specifies the performance tier of the bucket", + "readOnly": true + }, + "region": { + "allOf": [ + { + "$ref": "#/components/schemas/common.RegionEnum" + } + ], + "description": "Specifies the geographic region where the bucket is located", + "readOnly": true + }, + "role": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.BucketRoleEnum" + } + ], + "description": "Specifies the role assigned to the bucket", + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/common.ResourceStatusEnum" + } + ], + "description": "Specifies the current status of the bucket", + "readOnly": true + } + } + }, + "backup.tenant.vault.bucket.Credentials": { + "type": "object", + "description": "S3 credentials and connection endpoint of a vault bucket; credentials are sensitive, never log or cache them", + "properties": { + "accessKey": { + "type": "string", + "description": "S3 access key", + "readOnly": true + }, + "bucketName": { + "type": "string", + "description": "Name of the bucket these credentials belong to", + "readOnly": true + }, + "endpoint": { + "type": "string", + "description": "S3 endpoint URL to configure in your environment", + "readOnly": true + }, + "regionCode": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.RegionCodeEnum" + } + ], + "description": "Short region code of the bucket", + "readOnly": true + }, + "secretKey": { + "type": "string", + "description": "S3 secret key, sensitive value", + "format": "password", + "readOnly": true + } + } + }, + "backup.tenant.vaultWithIAM": { + "type": "object", + "description": "Represents a backup tenant vault", + "properties": { + "createdAt": { + "type": "string", + "description": "Date and time when you created the vault", + "format": "date-time", + "readOnly": true + }, + "currentState": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.tenant.vault.CurrentState" + } + ], + "description": "Current state of your vault", + "readOnly": true + }, + "currentTasks": { + "type": "array", + "description": "List of tasks currently running on your vault", + "items": { + "$ref": "#/components/schemas/common.CurrentTask" + }, + "readOnly": true + }, + "iam": { + "allOf": [ + { + "$ref": "#/components/schemas/iam.ResourceMetadata" + } + ], + "description": "IAM resource metadata", + "nullable": true, + "readOnly": true + }, + "id": { + "type": "string", + "description": "Unique identifier of your vault", + "format": "uuid", + "readOnly": true + }, + "resourceStatus": { + "allOf": [ + { + "$ref": "#/components/schemas/common.ResourceStatusEnum" + } + ], + "description": "Indicates the readiness of your vault", + "readOnly": true + }, + "targetSpec": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.tenant.vault.TargetSpec" + } + ], + "description": "Specification of your vault target", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "description": "Date and time when you last updated the vault", + "format": "date-time", + "readOnly": true + } + } + }, + "backup.tenant.vspc": { + "type": "object", + "description": "Represents a VSPC tenant", + "properties": { + "createdAt": { + "type": "string", + "description": "Date and time when the tenant is created", + "format": "date-time", + "readOnly": true + }, + "currentState": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.tenant.vspc.CurrentState" + } + ], + "description": "Current state of the tenant", + "readOnly": true + }, + "currentTasks": { + "type": "array", + "description": "List of tasks currently running for the tenant", + "items": { + "$ref": "#/components/schemas/common.CurrentTask" + }, + "readOnly": true + }, + "id": { + "type": "string", + "description": "Unique identifier of the tenant", + "format": "uuid", + "readOnly": true + }, + "resourceStatus": { + "allOf": [ + { + "$ref": "#/components/schemas/common.ResourceStatusEnum" + } + ], + "description": "Indicates whether the vault is ready", + "readOnly": true + }, + "targetSpec": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.tenant.vspc.TargetSpec" + } + ], + "description": "Specification of the target for the tenant", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "description": "Date and time when the tenant is last updated", + "format": "date-time", + "readOnly": true + } + } + }, + "backup.tenant.vspc.CurrentState": { + "type": "object", + "description": "Details about a VSPC tenant", + "properties": { + "accessUrl": { + "type": "string", + "description": "URL you use to access the tenant", + "readOnly": true + }, + "backupAgents": { + "type": "array", + "description": "Backup agents associated with the tenant", + "items": { + "$ref": "#/components/schemas/backup.tenant.vspc.backupAgent.CurrentState" + }, + "readOnly": true + }, + "backupLicensesId": { + "type": "string", + "description": "Backup Licenses subscription identifier of the tenant; null when the Backup Licenses add-on is not enabled", + "nullable": true, + "readOnly": true + }, + "companyName": { + "type": "string", + "description": "Name of your tenant company in VSPC", + "readOnly": true + }, + "enabledAddons": { + "type": "array", + "description": "Add-ons active on this tenant", + "items": { + "$ref": "#/components/schemas/backup.AddonEnum" + }, + "readOnly": true + }, + "id": { + "type": "string", + "description": "Unique identifier of the tenant", + "format": "uuid", + "readOnly": true + }, + "name": { + "type": "string", + "description": "Display name of the tenant", + "readOnly": true + }, + "region": { + "allOf": [ + { + "$ref": "#/components/schemas/common.RegionEnum" + } + ], + "description": "Region where the tenant is located", + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/common.ResourceStatusEnum" + } + ], + "description": "Current status of the tenant", + "readOnly": true + }, + "vaults": { + "type": "array", + "description": "Vaults linked to the tenant", + "items": { + "$ref": "#/components/schemas/backup.tenant.vspc.vault" + }, + "readOnly": true + }, + "vspcType": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.VspcTypeEnum" + } + ], + "description": "VSPC infrastructure type backing this tenant; BASIC carries backup agents, ADVANCED carries all others services", + "readOnly": true + } + } + }, + "backup.tenant.vspc.TargetSpec": { + "type": "object", + "description": "Specifies the target state of your VSPC tenant", + "properties": { + "name": { + "type": "string", + "description": "Display name of the tenant" + } + }, + "required": [ + "name" + ] + }, + "backup.tenant.vspc.backupAgent": { + "type": "object", + "description": "Represents a VSPC backup agent", + "properties": { + "createdAt": { + "type": "string", + "description": "Date and time when you create the backup agent", + "format": "date-time", + "readOnly": true + }, + "currentState": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.tenant.vspc.backupAgent.CurrentState" + } + ], + "description": "Current state of the backup agent", + "readOnly": true + }, + "currentTasks": { + "type": "array", + "description": "Current tasks that the backup agent performs", + "items": { + "$ref": "#/components/schemas/common.CurrentTask" + }, + "readOnly": true + }, + "id": { + "type": "string", + "description": "Unique identifier of the backup agent", + "format": "uuid", + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.AgentStatusEnum" + } + ], + "description": "Shows whether the backup agent is ready", + "readOnly": true + }, + "targetSpec": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.tenant.vspc.backupAgent.TargetSpec" + } + ], + "description": "Target specification for the backup agent", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "description": "Date and time when you update the backup agent", + "format": "date-time", + "readOnly": true + } + } + }, + "backup.tenant.vspc.backupAgent.CreateSpec": { + "type": "object", + "description": "Creates a VSPC backup agent", + "properties": { + "displayName": { + "type": "string", + "description": "Specifies the name of the backup agent" + }, + "ips": { + "type": "array", + "description": "Specifies the IP addresses assigned to the backup agent", + "items": { + "$ref": "#/components/schemas/ipBlock" + } + }, + "productResourceName": { + "type": "string", + "description": "Specifies the product resource name that the agent backs up" + }, + "region": { + "allOf": [ + { + "$ref": "#/components/schemas/common.RegionEnum" + } + ], + "description": "Specifies the geographic region where the backup agent operates" + } + } + }, + "backup.tenant.vspc.backupAgent.CurrentState": { + "type": "object", + "description": "Represents a VSPC backup agent", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier of the backup agent", + "format": "uuid", + "readOnly": true + }, + "ips": { + "type": "array", + "description": "List of IP addresses assigned to the backup agent", + "items": { + "$ref": "#/components/schemas/ipBlock" + }, + "readOnly": true + }, + "name": { + "type": "string", + "description": "Name of the backup agent", + "readOnly": true + }, + "policy": { + "type": "string", + "description": "VSPC backup policy configured for the agent", + "readOnly": true + }, + "productResourceName": { + "type": "string", + "description": "Resource name of the product backed up by the agent", + "readOnly": true + }, + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.AgentProductTypeEnum" + } + ], + "description": "Type of the product managed by the agent", + "readOnly": true + }, + "vaultId": { + "type": "string", + "description": "Unique identifier of the vault storing the backup agent data", + "nullable": true, + "format": "uuid", + "readOnly": true + } + } + }, + "backup.tenant.vspc.backupAgent.TargetSpec": { + "type": "object", + "description": "Specifies the desired target state of a backup agent", + "properties": { + "displayName": { + "type": "string", + "description": "Human readable name of the backup agent" + }, + "ips": { + "type": "array", + "description": "IP addresses assigned to the backup agent", + "items": { + "$ref": "#/components/schemas/ipBlock" + } + }, + "policy": { + "type": "string", + "description": "Name of the backup policy applied to the agent" + } + } + }, + "backup.tenant.vspc.backupLicenses": { + "type": "object", + "description": "Represents the Backup Licenses subscription", + "properties": { + "createdAt": { + "type": "string", + "description": "Date and time when the subscription is created", + "format": "date-time", + "readOnly": true + }, + "currentState": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.tenant.vspc.backupLicenses.CurrentState" + } + ], + "description": "Current state of the subscription", + "readOnly": true + }, + "currentTasks": { + "type": "array", + "description": "Current tasks running on the subscription", + "items": { + "$ref": "#/components/schemas/common.CurrentTask" + }, + "readOnly": true + }, + "id": { + "type": "string", + "description": "Unique identifier of the Backup Licenses subscription", + "readOnly": true + }, + "resourceStatus": { + "allOf": [ + { + "$ref": "#/components/schemas/common.ResourceStatusEnum" + } + ], + "description": "Indicates the readiness of the subscription", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "description": "Date and time when the subscription is last updated", + "format": "date-time", + "readOnly": true + } + } + }, + "backup.tenant.vspc.backupLicenses.CurrentState": { + "type": "object", + "description": "Current state of the Backup Licenses subscription with a flat summary of its backup servers", + "properties": { + "backupServers": { + "type": "array", + "description": "Backup servers registered as members of the subscription", + "items": { + "$ref": "#/components/schemas/backup.tenant.vspc.backupLicenses.backupServer.Summary" + }, + "readOnly": true + }, + "id": { + "type": "string", + "description": "Unique identifier of the Backup Licenses subscription", + "readOnly": true + } + } + }, + "backup.tenant.vspc.backupLicenses.backupServer": { + "type": "object", + "description": "Represents a backup server, member of the Backup Licenses subscription", + "properties": { + "createdAt": { + "type": "string", + "description": "Date and time when you register the backup server", + "format": "date-time", + "readOnly": true + }, + "currentState": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.tenant.vspc.backupLicenses.backupServer.CurrentState" + } + ], + "description": "Current state of the backup server", + "readOnly": true + }, + "currentTasks": { + "type": "array", + "description": "Current tasks running on the backup server", + "items": { + "$ref": "#/components/schemas/common.CurrentTask" + }, + "readOnly": true + }, + "id": { + "type": "string", + "description": "Unique identifier of the backup server", + "format": "uuid", + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.BackupServerStatusEnum" + } + ], + "description": "Shows whether the backup server is ready", + "readOnly": true + }, + "targetSpec": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.tenant.vspc.backupLicenses.backupServer.TargetSpec" + } + ], + "description": "Target specification for the backup server", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "description": "Date and time when you update the backup server", + "format": "date-time", + "readOnly": true + } + } + }, + "backup.tenant.vspc.backupLicenses.backupServer.CurrentState": { + "type": "object", + "description": "Observed state of a backup server registered under the Backup Licenses subscription", + "properties": { + "backupServerVersion": { + "type": "string", + "description": "Veeam Backup & Replication version reported by discovery", + "nullable": true, + "readOnly": true + }, + "displayName": { + "type": "string", + "description": "Name of the backup server", + "readOnly": true + }, + "externalIps": { + "type": "array", + "description": "External IP addresses of the backup server used for the cloud gateway allowlist", + "items": { + "$ref": "#/components/schemas/ipBlock" + }, + "readOnly": true + }, + "id": { + "type": "string", + "description": "Unique identifier of the backup server", + "format": "uuid", + "readOnly": true + }, + "licenseStatus": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.LicenseStatusEnum" + } + ], + "description": "Installation status of the Veeam license", + "readOnly": true + }, + "licenseType": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.LicenseTypeEnum" + } + ], + "description": "License type currently applied", + "nullable": true, + "readOnly": true + }, + "licenseTypeRequested": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.LicenseTypeEnum" + } + ], + "description": "License type requested by the customer, echo of the target specification", + "readOnly": true + }, + "managementAgentStatus": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.ManagementAgentStatusEnum" + } + ], + "description": "Status of the management agent on the backup server", + "readOnly": true + }, + "osType": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.BackupServerOsTypeEnum" + } + ], + "description": "Operating system reported by discovery", + "nullable": true, + "readOnly": true + }, + "privateIps": { + "type": "array", + "description": "Private IP addresses of the backup server used as VSPC discovery match key; null when not provided", + "nullable": true, + "items": { + "$ref": "#/components/schemas/ipBlock" + }, + "readOnly": true + } + } + }, + "backup.tenant.vspc.backupLicenses.backupServer.Summary": { + "type": "object", + "description": "Backup server details", + "properties": { + "displayName": { + "type": "string", + "description": "Name of the backup server", + "readOnly": true + }, + "externalIps": { + "type": "array", + "description": "External IP addresses of the backup server", + "items": { + "$ref": "#/components/schemas/ipBlock" + }, + "readOnly": true + }, + "id": { + "type": "string", + "description": "Unique identifier of the backup server", + "format": "uuid", + "readOnly": true + }, + "licenseStatus": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.LicenseStatusEnum" + } + ], + "description": "Installation status of the Veeam license", + "readOnly": true + }, + "licenseType": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.LicenseTypeEnum" + } + ], + "description": "License type currently applied", + "nullable": true, + "readOnly": true + }, + "licenseTypeRequested": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.LicenseTypeEnum" + } + ], + "description": "License type requested by the customer", + "readOnly": true + }, + "managementAgentStatus": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.ManagementAgentStatusEnum" + } + ], + "description": "Status of the management agent on the backup server", + "readOnly": true + }, + "osType": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.BackupServerOsTypeEnum" + } + ], + "description": "Operating system reported by discovery", + "nullable": true, + "readOnly": true + }, + "privateIps": { + "type": "array", + "description": "Private IP addresses of the backup server used as VSPC discovery match key; null when not provided", + "nullable": true, + "items": { + "$ref": "#/components/schemas/ipBlock" + }, + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.BackupServerStatusEnum" + } + ], + "description": "Shows whether the backup server is ready", + "readOnly": true + } + } + }, + "backup.tenant.vspc.backupLicenses.backupServer.TargetSpec": { + "type": "object", + "description": "Specifies the desired state of a backup server", + "properties": { + "displayName": { + "type": "string", + "description": "Name of the backup server" + }, + "externalIps": { + "type": "array", + "description": "External IP addresses of the backup server", + "items": { + "$ref": "#/components/schemas/ipBlock" + } + }, + "licenseType": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.LicenseTypeEnum" + } + ], + "description": "Requested license type" + }, + "privateIps": { + "type": "array", + "description": "Private IP addresses used for discovery", + "nullable": true, + "items": { + "$ref": "#/components/schemas/ipBlock" + } + } + }, + "required": [ + "externalIps", + "licenseType" + ] + }, + "backup.tenant.vspc.backupLicensesWithIAM": { + "type": "object", + "description": "Represents the Backup Licenses subscription", + "properties": { + "createdAt": { + "type": "string", + "description": "Date and time when the subscription is created", + "format": "date-time", + "readOnly": true + }, + "currentState": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.tenant.vspc.backupLicenses.CurrentState" + } + ], + "description": "Current state of the subscription", + "readOnly": true + }, + "currentTasks": { + "type": "array", + "description": "Current tasks running on the subscription", + "items": { + "$ref": "#/components/schemas/common.CurrentTask" + }, + "readOnly": true + }, + "iam": { + "allOf": [ + { + "$ref": "#/components/schemas/iam.ResourceMetadata" + } + ], + "description": "IAM resource metadata", + "nullable": true, + "readOnly": true + }, + "id": { + "type": "string", + "description": "Unique identifier of the Backup Licenses subscription", + "readOnly": true + }, + "resourceStatus": { + "allOf": [ + { + "$ref": "#/components/schemas/common.ResourceStatusEnum" + } + ], + "description": "Indicates the readiness of the subscription", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "description": "Date and time when the subscription is last updated", + "format": "date-time", + "readOnly": true + } + } + }, + "backup.tenant.vspc.managementAgent": { + "type": "object", + "description": "Provides the download links for the Management Agent for all the supported OS", + "properties": { + "linuxDeployScript": { + "type": "string", + "description": "URL of the script used to install the Linux Management Agent", + "nullable": true, + "readOnly": true + }, + "linuxUrl": { + "type": "string", + "description": "URL of the Management Agent download for Linux", + "readOnly": true + }, + "macUrl": { + "type": "string", + "description": "URL of the Management Agent download for MacOS", + "readOnly": true + }, + "windowsUrl": { + "type": "string", + "description": "URL of the Management Agent download for Windows", + "readOnly": true + } + } + }, + "backup.tenant.vspc.vault": { + "type": "object", + "description": "Represent a vault linked to your VSPC tenant", + "properties": { + "allowedIps": { + "type": "array", + "description": "Provides list of allowed IPs at the vault level", + "items": { + "$ref": "#/components/schemas/ipBlock" + }, + "readOnly": true + }, + "id": { + "type": "string", + "description": "Unique identifier of the vault", + "format": "uuid", + "readOnly": true + }, + "name": { + "type": "string", + "description": "Name of the vault", + "readOnly": true + }, + "performance": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.BucketPerformanceEnum" + } + ], + "description": "Performance tier of the vault", + "readOnly": true + }, + "region": { + "allOf": [ + { + "$ref": "#/components/schemas/common.RegionEnum" + } + ], + "description": "Geographic region where the vault is located", + "readOnly": true + }, + "resourceName": { + "type": "string", + "description": "Resource name of the vault as used in the system", + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/common.ResourceStatusEnum" + } + ], + "description": "Current status of the vault", + "readOnly": true + }, + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.VaultTypeEnum" + } + ], + "description": "Kind of vault", + "readOnly": true + } + } + }, + "backup.tenant.vspcWithIAM": { + "type": "object", + "description": "Represents a VSPC tenant", + "properties": { + "createdAt": { + "type": "string", + "description": "Date and time when the tenant is created", + "format": "date-time", + "readOnly": true + }, + "currentState": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.tenant.vspc.CurrentState" + } + ], + "description": "Current state of the tenant", + "readOnly": true + }, + "currentTasks": { + "type": "array", + "description": "List of tasks currently running for the tenant", + "items": { + "$ref": "#/components/schemas/common.CurrentTask" + }, + "readOnly": true + }, + "iam": { + "allOf": [ + { + "$ref": "#/components/schemas/iam.ResourceMetadata" + } + ], + "description": "IAM resource metadata", + "nullable": true, + "readOnly": true + }, + "id": { + "type": "string", + "description": "Unique identifier of the tenant", + "format": "uuid", + "readOnly": true + }, + "resourceStatus": { + "allOf": [ + { + "$ref": "#/components/schemas/common.ResourceStatusEnum" + } + ], + "description": "Indicates whether the vault is ready", + "readOnly": true + }, + "targetSpec": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.tenant.vspc.TargetSpec" + } + ], + "description": "Specification of the target for the tenant", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "description": "Date and time when the tenant is last updated", + "format": "date-time", + "readOnly": true + } + } + }, + "backup.tenantWithIAM": { + "type": "object", + "description": "Backup tenant resource", + "properties": { + "createdAt": { + "type": "string", + "description": "Date and time the backup tenant is created", + "format": "date-time", + "readOnly": true + }, + "currentState": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.tenant.CurrentState" + } + ], + "description": "Current operational state of the backup tenant", + "readOnly": true + }, + "currentTasks": { + "type": "array", + "description": "List of tasks that are currently running for the backup tenant", + "items": { + "$ref": "#/components/schemas/common.CurrentTask" + }, + "readOnly": true + }, + "iam": { + "allOf": [ + { + "$ref": "#/components/schemas/iam.ResourceMetadata" + } + ], + "description": "IAM resource metadata", + "nullable": true, + "readOnly": true + }, + "id": { + "type": "string", + "description": "Unique identifier of the backup tenant", + "format": "uuid", + "readOnly": true + }, + "resourceStatus": { + "allOf": [ + { + "$ref": "#/components/schemas/common.ResourceStatusEnum" + } + ], + "description": "Readiness status of the backup tenant", + "readOnly": true + }, + "targetSpec": { + "allOf": [ + { + "$ref": "#/components/schemas/backup.tenant.TargetSpec" + } + ], + "description": "Specification of the target state of the backup tenant", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "description": "Date and time the backup tenant is last updated", + "format": "date-time", + "readOnly": true + } + } + }, + "common.CurrentTask": { + "type": "object", + "description": "Asynchronous operation currently running", + "properties": { + "errors": { + "type": "array", + "description": "Errors that occured on the task", + "nullable": true, + "items": { + "$ref": "#/components/schemas/common.TaskError" + }, + "readOnly": true + }, + "id": { + "type": "string", + "description": "Identifier of the current task", + "format": "uuid", + "readOnly": true + }, + "link": { + "type": "string", + "description": "Link to the task details", + "readOnly": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/common.CurrentTaskStatusEnum" + } + ], + "description": "Current global status of the current task", + "nullable": true, + "readOnly": true + }, + "type": { + "type": "string", + "description": "Type of the current task", + "readOnly": true + } + } + }, + "common.CurrentTaskStatusEnum": { + "type": "string", + "description": "Current status of a task. A task in ERROR cannot be retried without your inputs. PENDING tasks will be executed as soon as possible. A RUNNING task is currently executing your original request. SCHEDULED is used for tasks that will be executed in the future", + "enum": [ + "ERROR", + "PENDING", + "RUNNING", + "SCHEDULED", + "WAITING_USER_INPUT" + ] + }, + "common.RegionEnum": { + "type": "string", + "description": "RegionEnum", + "enum": [ + "af-north-lz-rba", + "ap-south-mum", + "ap-southeast-sgp", + "ap-southeast-syd", + "ca-east-bhs", + "ca-east-tor", + "eu-central-lz-buh", + "eu-central-lz-prg", + "eu-central-lz-sof", + "eu-central-waw", + "eu-north-lz-cph", + "eu-north-lz-hel", + "eu-north-lz-osl", + "eu-north-lz-sto", + "eu-south-lz-lis", + "eu-south-lz-mad", + "eu-south-lz-mil", + "eu-south-mil", + "eu-west-eri", + "eu-west-gra", + "eu-west-gra-snc", + "eu-west-lim", + "eu-west-lz-ams", + "eu-west-lz-bru", + "eu-west-lz-dln", + "eu-west-lz-lux", + "eu-west-lz-mnc", + "eu-west-lz-mrs", + "eu-west-lz-vie", + "eu-west-lz-zrh", + "eu-west-par", + "eu-west-rbx", + "eu-west-rbx-snc", + "eu-west-sbg", + "eu-west-sbg-snc", + "us-central-lz-slc", + "us-central-lz-stl", + "us-east-lz-atl", + "us-east-lz-bos", + "us-east-lz-chi", + "us-east-lz-dal", + "us-east-lz-mia", + "us-east-lz-nyc", + "us-east-vin", + "us-west-hil", + "us-west-lz-den", + "us-west-lz-lax", + "us-west-lz-pao", + "us-west-lz-phx", + "us-west-lz-sea" + ] + }, + "common.ResourceStatusEnum": { + "type": "string", + "description": "ResourceStatusEnum", + "enum": [ + "CREATING", + "DELETING", + "ERROR", + "OUT_OF_SYNC", + "READY", + "SUSPENDED", + "UNKNOWN", + "UPDATING" + ] + }, + "common.TaskError": { + "type": "object", + "description": "Errors that occured on the task", + "properties": { + "message": { + "type": "string", + "description": "Error description", + "readOnly": true + } + } + }, + "duration": { + "type": "string", + "description": "Duration (e.g., P1Y2M3DT4H6M9S)", + "example": "P1Y2M3DT4H6M9S" + }, + "email": { + "type": "string", + "description": "Email address (e.g., api@ml.ovh.net)", + "format": "email", + "example": "api@ml.ovh.net" + }, + "iam.ResourceMetadata": { + "type": "object", + "description": "IAM resource metadata embedded in services models", + "properties": { + "displayName": { + "type": "string", + "description": "Resource display name", + "nullable": true, + "readOnly": true + }, + "id": { + "type": "string", + "description": "Unique identifier of the resource", + "format": "uuid", + "readOnly": true + }, + "state": { + "allOf": [ + { + "$ref": "#/components/schemas/iam.ResourceMetadata.StateEnum" + } + ], + "description": "Resource state", + "nullable": true, + "readOnly": true + }, + "tags": { + "type": "object", + "description": "Resource tags. Tags that were internally computed are prefixed with ovh:", + "nullable": true, + "additionalProperties": { + "type": "string" + }, + "readOnly": true + }, + "urn": { + "type": "string", + "description": "Unique resource name used in policies", + "readOnly": true + } + } + }, + "iam.ResourceMetadata.StateEnum": { + "type": "string", + "description": "Resource state", + "enum": [ + "EXPIRED", + "IN_CREATION", + "OK", + "SUSPENDED" + ] + }, + "iam.resource.TagFilter": { + "type": "object", + "description": "Resource tag filter", + "properties": { + "operator": { + "allOf": [ + { + "$ref": "#/components/schemas/iam.resource.TagFilter.OperatorEnum" + } + ], + "description": "Operator to use in order to filter on the value (defaults to 'EQ')", + "nullable": true, + "readOnly": true + }, + "value": { + "type": "string", + "description": "Value to use in order to filter tags", + "readOnly": true + } + } + }, + "iam.resource.TagFilter.OperatorEnum": { + "type": "string", + "description": "Operator that can be used in order to filter resources tags", + "enum": [ + "EQ", + "EXISTS", + "ILIKE", + "LIKE", + "NEQ", + "NEXISTS" + ] + }, + "internationalPhoneNumber": { + "type": "string", + "description": "International phone number", + "format": "phone-number" + }, + "ip": { + "type": "string", + "description": "IP address (e.g., 192.0.2.0)", + "format": "ipv4", + "example": "192.0.2.0" + }, + "ipBlock": { + "type": "string", + "description": "IP (v4 or v6) CIDR notation (e.g., 192.0.2.0/24)", + "format": "ip-block", + "example": "192.0.2.0/24" + }, + "ipInterface": { + "type": "string", + "description": "IP address (e.g., 192.0.2.0)", + "format": "ipv4", + "example": "192.0.2.0" + }, + "ipv4": { + "type": "string", + "description": "IPv4 address (e.g., 192.0.2.0)", + "format": "ipv4", + "example": "192.0.2.0" + }, + "ipv4Block": { + "type": "string", + "description": "IPv4 CIDR notation (e.g., 192.0.2.0/24)", + "format": "ipv4-block", + "example": "192.0.2.0/24" + }, + "ipv6": { + "type": "string", + "description": "IPv6 address (e.g., 2001:41d0:1:1994::1)", + "format": "ipv6", + "example": "2001:41d0:1:1994::1" + }, + "ipv6Block": { + "type": "string", + "description": "IPv6 CIDR notation (e.g., 2001:41d0::/128)", + "format": "ipv6-block", + "example": "2001:41d0::/128" + }, + "macAddress": { + "type": "string", + "description": "MAC address (e.g., 00:00:5e:00:53:01)", + "format": "mac-address", + "example": "00:00:5e:00:53:01" + }, + "phoneNumber": { + "type": "string", + "description": "Phone number", + "format": "phone-number" + }, + "time": { + "type": "string", + "description": "Time (e.g., 15:04:05)", + "format": "time", + "example": "15:04:05" + } + }, + "securitySchemes": { + "oAuth2AuthCode": { + "type": "oauth2", + "description": "Oauth2", + "x-client-id": "1bb9c7df371741c0", + "x-client-secret": "a5b4de870aca620d10fbf63cd18d205b", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://auth.eu.ovhcloud.com/oauth2/authorize", + "tokenUrl": "https://auth.eu.ovhcloud.com/oauth2/token", + "scopes": { + "account/all": "Manage your account", + "all": "Manage your whole account and all your services", + "services/all": "Manage your services lifecycle and billing" + } + } + } + } + } + }, + "paths": { + "/backupServices/tenant": { + "get": { + "summary": "Retrieves the backup tenants you manage", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "header", + "name": "X-Pagination-Cursor", + "description": "Pagination cursor", + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-Pagination-Size", + "description": "Pagination size", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "iamTags", + "description": "Filter resources on IAM tags", + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/iam.resource.TagFilter" + } + } + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/backup.tenantWithIAM" + } + } + } + } + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:get", + "required": true + } + ], + "x-expanded-response": "BackupTenantWithIAM" + } + }, + "/backupServices/tenant/{backupServicesId}": { + "get": { + "summary": "Retrieves the details of your backup tenant", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.tenantWithIAM" + } + } + } + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:get", + "required": true + } + ] + } + }, + "/backupServices/tenant/{backupServicesId}/vault": { + "get": { + "summary": "Lists vaults for your tenant", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "header", + "name": "X-Pagination-Cursor", + "description": "Pagination cursor", + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-Pagination-Size", + "description": "Pagination size", + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "iamTags", + "description": "Filter resources on IAM tags", + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/iam.resource.TagFilter" + } + } + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/backup.tenant.vaultWithIAM" + } + } + } + } + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vault/get", + "required": true + } + ], + "x-expanded-response": "BackupTenantVaultWithIAM" + } + }, + "/backupServices/tenant/{backupServicesId}/vault/{vaultId}": { + "get": { + "summary": "Retrieves specific vault details", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "vaultId", + "description": "Vault ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.tenant.vaultWithIAM" + } + } + } + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vault/get", + "required": true + } + ] + }, + "put": { + "summary": "Updates vault display name and cloud repository", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "vaultId", + "description": "Vault ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.tenant.vault.TargetSpec" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.tenant.vault" + } + } + } + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vault/edit", + "required": true + } + ] + } + }, + "/backupServices/tenant/{backupServicesId}/vault/{vaultId}/bucket": { + "get": { + "summary": "Lists buckets of the vault", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "header", + "name": "X-Pagination-Cursor", + "description": "Pagination cursor", + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-Pagination-Size", + "description": "Pagination size", + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "vaultId", + "description": "Vault ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/backup.tenant.vault.bucket" + } + } + } + } + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vault/get", + "required": true + } + ], + "x-response-identifier": "id", + "x-expanded-response": "BackupTenantVaultBucket" + } + }, + "/backupServices/tenant/{backupServicesId}/vault/{vaultId}/bucket/{bucketId}": { + "get": { + "summary": "Gets specific vault bucket details", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "bucketId", + "description": "Bucket ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "vaultId", + "description": "Vault ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.tenant.vault.bucket" + } + } + } + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vault/get", + "required": true + } + ], + "x-response-identifier": "id" + } + }, + "/backupServices/tenant/{backupServicesId}/vault/{vaultId}/bucket/{bucketId}/credentials": { + "get": { + "summary": "Retrieves S3™*-compatible credentials and connection endpoint for a vault bucket", + "description": "* S3 is a trademark filed by Amazon Technologies,Inc. OVHcloud's service is not sponsored by, endorsed by, or otherwise affiliated with Amazon Technologies,Inc.", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "bucketId", + "description": "Bucket ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "vaultId", + "description": "Vault ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.tenant.vault.bucket.Credentials" + } + } + } + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vault/credentials/get", + "required": true + } + ] + } + }, + "/backupServices/tenant/{backupServicesId}/vspc": { + "get": { + "summary": "Retrieves list of VSPC tenants", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "header", + "name": "X-Pagination-Cursor", + "description": "Pagination cursor", + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-Pagination-Size", + "description": "Pagination size", + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "iamTags", + "description": "Filter resources on IAM tags", + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/iam.resource.TagFilter" + } + } + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/backup.tenant.vspcWithIAM" + } + } + } + } + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vspc/get", + "required": true + } + ], + "x-expanded-response": "BackupTenantVspcWithIAM" + } + }, + "/backupServices/tenant/{backupServicesId}/vspc/{vspcTenantId}": { + "get": { + "summary": "Retrieves details of a specific VSPC tenant", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "vspcTenantId", + "description": "Vspc tenant ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.tenant.vspcWithIAM" + } + } + } + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vspc/get", + "required": true + } + ] + }, + "put": { + "summary": "Updates the display name of a VSPC tenant", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "vspcTenantId", + "description": "Vspc tenant ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.tenant.vspc.TargetSpec" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.tenant.vspc" + } + } + } + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vspc/edit", + "required": true + } + ] + } + }, + "/backupServices/tenant/{backupServicesId}/vspc/{vspcTenantId}/backupAgent": { + "get": { + "summary": "Lists backup agents", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "header", + "name": "X-Pagination-Cursor", + "description": "Pagination cursor", + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-Pagination-Size", + "description": "Pagination size", + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "vspcTenantId", + "description": "Vspc tenant ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/backup.tenant.vspc.backupAgent" + } + } + } + } + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vspc/get", + "required": true + } + ], + "x-response-identifier": "id", + "x-expanded-response": "BackupTenantVspcBackupAgent" + }, + "post": { + "summary": "Creates backup agent", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "vspcTenantId", + "description": "Vspc tenant ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.tenant.vspc.backupAgent.CreateSpec" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation" + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vspc/edit", + "required": true + } + ] + } + }, + "/backupServices/tenant/{backupServicesId}/vspc/{vspcTenantId}/backupAgent/{backupAgentId}": { + "delete": { + "summary": "Deletes backup agent", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "backupAgentId", + "description": "Backup agent ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "vspcTenantId", + "description": "Vspc tenant ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation" + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vspc/edit", + "required": true + } + ] + }, + "get": { + "summary": "Gets specific backup agent details", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "backupAgentId", + "description": "Backup agent ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "vspcTenantId", + "description": "Vspc tenant ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.tenant.vspc.backupAgent" + } + } + } + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vspc/get", + "required": true + } + ], + "x-response-identifier": "id" + }, + "put": { + "summary": "Updates backup agent", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "backupAgentId", + "description": "Backup agent ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "vspcTenantId", + "description": "Vspc tenant ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.tenant.vspc.backupAgent.TargetSpec" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation" + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vspc/edit", + "required": true + } + ] + } + }, + "/backupServices/tenant/{backupServicesId}/vspc/{vspcTenantId}/backupLicenses": { + "get": { + "summary": "Retrieves the Backup Licenses subscription list of the VSPC tenant", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "header", + "name": "X-Pagination-Cursor", + "description": "Pagination cursor", + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-Pagination-Size", + "description": "Pagination size", + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "iamTags", + "description": "Filter resources on IAM tags", + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/iam.resource.TagFilter" + } + } + } + }, + { + "in": "path", + "name": "vspcTenantId", + "description": "Vspc tenant ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/backup.tenant.vspc.backupLicensesWithIAM" + } + } + } + } + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vspc/backupLicenses/get", + "required": true + } + ], + "x-expanded-response": "BackupTenantVspcBackupLicensesWithIAM" + } + }, + "/backupServices/tenant/{backupServicesId}/vspc/{vspcTenantId}/backupLicenses/{backupLicensesId}": { + "get": { + "summary": "Retrieves the Backup Licenses subscription of the VSPC tenant", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "backupLicensesId", + "description": "Backup licenses ID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "vspcTenantId", + "description": "Vspc tenant ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.tenant.vspc.backupLicensesWithIAM" + } + } + } + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vspc/backupLicenses/get", + "required": true + } + ] + } + }, + "/backupServices/tenant/{backupServicesId}/vspc/{vspcTenantId}/backupLicenses/{backupLicensesId}/backupServer": { + "get": { + "summary": "Lists backup servers of the Backup Licenses subscription", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "header", + "name": "X-Pagination-Cursor", + "description": "Pagination cursor", + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-Pagination-Size", + "description": "Pagination size", + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "backupLicensesId", + "description": "Backup licenses ID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "vspcTenantId", + "description": "Vspc tenant ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/backup.tenant.vspc.backupLicenses.backupServer" + } + } + } + } + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vspc/backupLicenses/get", + "required": true + } + ], + "x-response-identifier": "id", + "x-expanded-response": "BackupTenantVspcBackupLicensesBackupServer" + }, + "post": { + "summary": "Registers an additional backup server under the Backup Licenses subscription", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "backupLicensesId", + "description": "Backup licenses ID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "vspcTenantId", + "description": "Vspc tenant ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.tenant.vspc.backupLicenses.backupServer.TargetSpec" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.tenant.vspc.backupLicenses.backupServer" + } + } + } + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vspc/backupLicenses/edit", + "required": true + } + ] + } + }, + "/backupServices/tenant/{backupServicesId}/vspc/{vspcTenantId}/backupLicenses/{backupLicensesId}/backupServer/{backupServerId}": { + "delete": { + "summary": "Deregisters a backup server from the Backup Licenses subscription; the subscription and license types are retained", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "backupLicensesId", + "description": "Backup licenses ID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "backupServerId", + "description": "Backup server ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "vspcTenantId", + "description": "Vspc tenant ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation" + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vspc/backupLicenses/delete", + "required": true + } + ] + }, + "get": { + "summary": "Gets specific backup server details", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "backupLicensesId", + "description": "Backup licenses ID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "backupServerId", + "description": "Backup server ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "vspcTenantId", + "description": "Vspc tenant ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.tenant.vspc.backupLicenses.backupServer" + } + } + } + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vspc/backupLicenses/get", + "required": true + } + ], + "x-response-identifier": "id" + }, + "put": { + "summary": "Updates a backup server; IP and display name changes apply immediately, a license type change is applied on the 1st of the next month", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "backupLicensesId", + "description": "Backup licenses ID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "backupServerId", + "description": "Backup server ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "vspcTenantId", + "description": "Vspc tenant ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.tenant.vspc.backupLicenses.backupServer.TargetSpec" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.tenant.vspc.backupLicenses.backupServer" + } + } + } + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vspc/backupLicenses/edit", + "required": true + } + ] + } + }, + "/backupServices/tenant/{backupServicesId}/vspc/{vspcTenantId}/backupPolicies": { + "get": { + "summary": "Retrieves the list of backup policies available in your VSPC", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "header", + "name": "X-Pagination-Cursor", + "description": "Pagination cursor", + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "X-Pagination-Size", + "description": "Pagination size", + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "vspcTenantId", + "description": "Vspc tenant ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vspc/get", + "required": true + } + ] + } + }, + "/backupServices/tenant/{backupServicesId}/vspc/{vspcTenantId}/managementAgent": { + "get": { + "summary": "Retrieves the download link for the management agent", + "security": [ + { + "oAuth2AuthCode": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "backupServicesId", + "description": "Backup services ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "path", + "name": "vspcTenantId", + "description": "Vspc tenant ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.tenant.vspc.managementAgent" + } + } + } + } + }, + "x-developer-hub-navigation": [ + [ + "Storage & Backup", + "Backup" + ] + ], + "x-badges": [ + { + "color": "blue", + "label": "Beta version" + } + ], + "x-iam-actions": [ + { + "name": "backupServices:apiovh:vspc/get", + "required": true + } + ] + } + } + } +} diff --git a/internal/assets/assets.go b/internal/assets/assets.go index e226b0eca..67e534884 100644 --- a/internal/assets/assets.go +++ b/internal/assets/assets.go @@ -26,6 +26,9 @@ var ( //go:embed api-schemas/baremetal_v2.json BaremetalV2OpenapiSchema []byte + //go:embed api-schemas/backupservices_v2.json + BackupservicesV2OpenapiSchema []byte + //go:embed api-schemas/dedicatedceph.json DedicatedcephOpenapiSchema []byte diff --git a/internal/assets/assets_wasm.go b/internal/assets/assets_wasm.go index 39343b853..15ce5a67e 100644 --- a/internal/assets/assets_wasm.go +++ b/internal/assets/assets_wasm.go @@ -12,6 +12,7 @@ var ( MeOpenapiSchema []byte BaremetalOpenapiSchema []byte BaremetalV2OpenapiSchema []byte + BackupservicesV2OpenapiSchema []byte DedicatedcephOpenapiSchema []byte DedicatednashaOpenapiSchema []byte DomainOpenapiSchema []byte diff --git a/internal/cmd/backupservices.go b/internal/cmd/backupservices.go new file mode 100644 index 000000000..3a1149267 --- /dev/null +++ b/internal/cmd/backupservices.go @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: 2026 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/ovh/ovhcloud-cli/internal/services/backupservices" + "github.com/spf13/cobra" +) + +func init() { + backupCmd := &cobra.Command{ + Use: "backup-services", + Short: "Retrieve information and manage your Veeam Backup services", + Long: "Retrieve information and manage your Veeam Backup services.\n\n" + + "A backup tenant holds storage vaults and a Veeam Service Provider Console tenant; " + + "the console tenant is what drives the agents installed on your machines. Both levels " + + "are resolved when the account has only one of them, so --tenant and --vspc are only " + + "needed when there is a choice to make.\n\n" + + "The agents themselves are managed from the machine they protect: see " + + "`ovhcloud baremetal backup-agent`.", + } + + // Both levels of the hierarchy are UUIDs, and both are resolved when there + // is only one. These flags exist for the accounts where there is not. + backupCmd.PersistentFlags().StringVar(&backupservices.Tenant, "tenant", "", + "Backup tenant to work on (default: the only one on the account)") + backupCmd.PersistentFlags().StringVar(&backupservices.Vspc, "vspc", "", + "VSPC tenant to work on (default: the only one in the backup tenant)") + + // Tenants + backupTenantCmd := &cobra.Command{ + Use: "tenant", + Short: "Show the backup tenants of the account", + } + backupCmd.AddCommand(backupTenantCmd) + + backupTenantCmd.AddCommand(withFilterFlag(&cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List your backup tenants", + Run: backupservices.ListTenants, + })) + + backupTenantCmd.AddCommand(&cobra.Command{ + Use: "get []", + Short: "Show one backup tenant", + Args: cobra.MaximumNArgs(1), + Run: backupservices.ShowTenant, + }) + + // Vaults + backupVaultCmd := &cobra.Command{ + Use: "vault", + Short: "Show and rename the storage vaults of a backup tenant", + } + backupCmd.AddCommand(backupVaultCmd) + + backupVaultCmd.AddCommand(withFilterFlag(&cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List the storage vaults", + Run: backupservices.ListVaults, + })) + + backupVaultCmd.AddCommand(&cobra.Command{ + Use: "get ", + Short: "Show one storage vault", + Args: cobra.ExactArgs(1), + Run: backupservices.ShowVault, + }) + + backupVaultCmd.AddCommand(withFilterFlag(&cobra.Command{ + Use: "buckets ", + Short: "List the buckets behind a storage vault", + Args: cobra.ExactArgs(1), + Run: backupservices.ListBuckets, + })) + + backupVaultEditCmd := &cobra.Command{ + Use: "edit ", + Short: "Rename a storage vault", + Args: cobra.ExactArgs(1), + Run: backupservices.EditVault, + } + backupVaultEditCmd.Flags().StringVar(&backupservices.EditSpec.Name, "name", "", "New name of the vault") + addConfirmationFlags(backupVaultEditCmd, "Print the call that would be made without making it") + backupVaultCmd.AddCommand(backupVaultEditCmd) + + // VSPC tenants + backupVspcCmd := &cobra.Command{ + Use: "vspc", + Short: "Show and rename the Veeam Service Provider Console tenants", + } + backupCmd.AddCommand(backupVspcCmd) + + backupVspcCmd.AddCommand(withFilterFlag(&cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List the VSPC tenants", + Run: backupservices.ListVspc, + })) + + backupVspcCmd.AddCommand(&cobra.Command{ + Use: "get []", + Short: "Show one VSPC tenant", + Args: cobra.MaximumNArgs(1), + Run: backupservices.ShowVspc, + }) + + backupVspcEditCmd := &cobra.Command{ + Use: "edit ", + Short: "Rename a VSPC tenant", + Args: cobra.ExactArgs(1), + Run: backupservices.EditVspc, + } + backupVspcEditCmd.Flags().StringVar(&backupservices.EditSpec.Name, "name", "", "New name of the VSPC tenant") + addConfirmationFlags(backupVspcEditCmd, "Print the call that would be made without making it") + backupVspcCmd.AddCommand(backupVspcEditCmd) + + // What hangs off a VSPC tenant + backupCmd.AddCommand(withFilterFlag(&cobra.Command{ + Use: "policies", + Short: "List the retention policies an agent can be put on", + Run: backupservices.ListPolicies, + })) + + backupCmd.AddCommand(&cobra.Command{ + Use: "deploy-script", + Short: "Show the command that installs the backup agent on a machine", + Long: "Show the command that installs the backup agent on a machine.\n\n" + + "An agent created through the API exists as an object and protects nothing until this " + + "script has run on the machine. The links carry their own authorisation.", + Run: backupservices.ShowDeployScript, + }) + + backupLicenseCmd := &cobra.Command{ + Use: "licenses", + Short: "Show the Veeam licences held by a VSPC tenant", + } + backupCmd.AddCommand(backupLicenseCmd) + + backupLicenseCmd.AddCommand(withFilterFlag(&cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List the Veeam licences", + Run: backupservices.ListLicenses, + })) + + backupLicenseCmd.AddCommand(withFilterFlag(&cobra.Command{ + Use: "servers ", + Short: "List the backup servers driven by a licence", + Args: cobra.ExactArgs(1), + Run: backupservices.ListLicenseServers, + })) + + rootCmd.AddCommand(backupCmd) +} diff --git a/internal/cmd/backupservices_test.go b/internal/cmd/backupservices_test.go new file mode 100644 index 000000000..985f1c4b6 --- /dev/null +++ b/internal/cmd/backupservices_test.go @@ -0,0 +1,283 @@ +// SPDX-FileCopyrightText: 2026 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + "encoding/json" + "net/http" + + "github.com/jarcoal/httpmock" + "github.com/maxatome/go-testdeep/td" + "github.com/ovh/ovhcloud-cli/internal/cmd" +) + +const ( + backupTenants = "https://eu.api.ovh.com/v2/backupServices/tenant" + backupTenant = backupTenants + "/t-1" + backupVspc = backupTenant + "/vspc/s-1" + backupAgents = backupVspc + "/backupAgent" +) + +// registerOneTenant gives the account the shape it has in reality: one backup +// tenant holding one VSPC tenant, which is why neither has to be named. +func registerOneTenant() { + httpmock.RegisterResponder(http.MethodGet, backupTenants, + httpmock.NewStringResponder(200, `[{"id":"t-1","resourceStatus":"READY","targetSpec":{"name":"t-1"}, + "currentState":{"name":"t-1","vaults":["v-1"],"vspcTenants":["s-1"]},"currentTasks":[]}]`)) + httpmock.RegisterResponder(http.MethodGet, backupTenant+"/vspc", + httpmock.NewStringResponder(200, `[{"id":"s-1","resourceStatus":"READY","targetSpec":{"name":"vspc-tenant-1"}, + "currentState":{"name":"vspc-tenant-1","vspcType":"BASIC","region":"eu-west-rbx", + "enabledAddons":["BACKUP_AGENT"],"backupAgents":[]},"currentTasks":[]}]`)) + httpmock.RegisterResponder(http.MethodGet, backupVspc+"/backupPolicies", + httpmock.NewStringResponder(200, `["30d_retention","14d_retention"]`)) +} + +// 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"}`)) +} + +func registerAgents(body string) { + httpmock.RegisterResponder(http.MethodGet, backupAgents, httpmock.NewStringResponder(200, body)) +} + +// "ERROR" answers "error doing what?" with nothing. +func (ms *MockSuite) TestBackupVaultListNamesTheFailedOperation(assert, require *td.T) { + registerOneTenant() + httpmock.RegisterResponder(http.MethodGet, backupTenant+"/vault", + httpmock.NewStringResponder(200, `[{"id":"v-1","resourceStatus":"READY","targetSpec":{"name":"vault-sbg"}, + "currentState":{"name":"vault-sbg","type":"PAYGO","vspcTenants":["s-1"], + "buckets":[{"region":"eu-west-sbg"}]}, + "currentTasks":[{"id":"k-1","type":"BACKUP_VAULT_CREATE","status":"ERROR"}]}]`)) + + out, err := cmd.Execute("backup-services", "vault", "list") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("BACKUP_VAULT_CREATE ERROR")) + assert.Cmp(out, td.Contains("eu-west-sbg"), "where a vault stores is what tells three of them apart") + assert.Cmp(out, td.Not(td.Contains("allowedIps")), + "the addresses allowed on a vault are not a field of a vault, and a column answering 0 would be wrong") +} + +// The account has one tenant, so nothing has to be named to reach what hangs +// off it. +func (ms *MockSuite) TestBackupPoliciesResolveTheHierarchyAlone(assert, require *td.T) { + registerOneTenant() + + out, err := cmd.Execute("backup-services", "policies") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("14d_retention")) + assert.Cmp(out, td.Contains("30d_retention")) +} + +// Two tenants means the command must not pick one, and the refusal has to be +// actionable: an identifier to paste and a name to recognise. +func (ms *MockSuite) TestBackupRefusesToChooseBetweenTenants(assert, require *td.T) { + httpmock.RegisterResponder(http.MethodGet, backupTenants, + httpmock.NewStringResponder(200, `[{"id":"t-1","targetSpec":{"name":"first"}},{"id":"t-2","targetSpec":{"name":"second"}}]`)) + + _, err := cmd.Execute("backup-services", "policies") + + require.CmpError(err) + assert.Cmp(err.Error(), td.Contains("--tenant")) + assert.Cmp(err.Error(), td.Contains("t-2")) + assert.Cmp(err.Error(), td.Contains("second")) +} + +// An account with no backup tenant is a state, and saying so beats a stack of +// failed lookups underneath. +func (ms *MockSuite) TestBackupSaysWhenThereIsNoTenant(assert, require *td.T) { + httpmock.RegisterResponder(http.MethodGet, backupTenants, httpmock.NewStringResponder(200, `[]`)) + + _, err := cmd.Execute("backup-services", "policies") + + require.CmpError(err) + assert.Cmp(err.Error(), td.Contains("no backup tenant")) +} + +// No licence on the tenant is an answer, not an empty table. +func (ms *MockSuite) TestBackupLicensesSaysWhenThereAreNone(assert, require *td.T) { + registerOneTenant() + httpmock.RegisterResponder(http.MethodGet, backupVspc+"/backupLicenses", + httpmock.NewStringResponder(200, `[]`)) + + out, err := cmd.Execute("backup-services", "licenses", "list") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("no Veeam licence")) +} + +// The deploy script is what puts the agent on a machine, and the links carry +// their own authorisation. +func (ms *MockSuite) TestBackupDeployScriptSaysWhatTheLinksAre(assert, require *td.T) { + registerOneTenant() + httpmock.RegisterResponder(http.MethodGet, backupVspc+"/managementAgent", + httpmock.NewStringResponder(200, `{"linuxDeployScript":"curl -sSL https://example/install.sh | sudo sh", + "linuxUrl":"https://example/linux","macUrl":"https://example/mac","windowsUrl":"https://example/win"}`)) + + out, err := cmd.Execute("backup-services", "deploy-script") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("curl -sSL")) + assert.Cmp(out, td.Contains("their own authorisation")) +} + +// The whole point of the shell: an operator types a server name and never sees +// a UUID. +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":""}}, + {"id":"a-2","status":"ENABLED","targetSpec":{"displayName":"agent-other"}, + "currentState":{"productResourceName":"other.example"}}]`) + + out, err := cmd.Execute("baremetal", "backup-agent", "show", "ns1.example") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("a-1")) + assert.Cmp(out, td.Not(td.Contains("a-2")), "another server's agent is not this server's") + assert.Cmp(out, td.Contains("none"), "an agent on no policy retains nothing, and the table says so") +} + +// A server with no agent gets the command that makes one, not an empty table. +func (ms *MockSuite) TestBaremetalBackupAgentSaysWhenThereIsNone(assert, require *td.T) { + registerOneTenant() + registerAgents(`[]`) + + out, err := cmd.Execute("baremetal", "backup-agent", "show", "ns1.example") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("backup-agent create ns1.example")) +} + +// Nine agents out of nine on this account are named agent-, carry the +// server's address in a /32 and sit in the server's region. Nothing but the +// server name should have to be typed. +func (ms *MockSuite) TestBaremetalBackupAgentCreateDerivesEverythingFromTheServer(assert, require *td.T) { + registerOneTenant() + registerServerForAgent() + registerAgents(`[]`) + + var sent map[string]any + httpmock.RegisterResponder(http.MethodPost, backupAgents, + func(req *http.Request) (*http.Response, error) { + if err := json.NewDecoder(req.Body).Decode(&sent); err != nil { + return nil, err + } + return httpmock.NewStringResponse(200, ``), nil + }) + + _, err := cmd.Execute("baremetal", "backup-agent", "create", "ns1.example", "--yes") + + require.CmpNoError(err) + 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"}) +} + +// A second agent for the same server is not something to create quietly. +func (ms *MockSuite) TestBaremetalBackupAgentCreateRefusesADuplicate(assert, require *td.T) { + registerOneTenant() + registerServerForAgent() + registerAgents(`[{"id":"a-1","status":"NOT_INSTALLED","currentState":{"productResourceName":"ns1.example"}}]`) + + _, err := cmd.Execute("baremetal", "backup-agent", "create", "ns1.example", "--yes") + + require.CmpError(err) + assert.Cmp(err.Error(), td.Contains("already has a backup agent")) + assert.Cmp(httpmock.GetCallCountInfo()["POST "+backupAgents], 0) +} + +// A region the backup service does not have is refused here rather than as a +// 400 the operator cannot read. +func (ms *MockSuite) TestBaremetalBackupAgentCreateRefusesAnUnknownRegion(assert, require *td.T) { + registerOneTenant() + registerServerForAgent() + registerAgents(`[]`) + + _, err := cmd.Execute("baremetal", "backup-agent", "create", "ns1.example", "--region", "eu-west-nowhere", "--yes") + + require.CmpError(err) + assert.Cmp(err.Error(), td.Contains("eu-west-rbx"), "the refusal lists what does exist") + assert.Cmp(httpmock.GetCallCountInfo()["POST "+backupAgents], 0) +} + +// The PUT replaces the target spec, so what is not being changed has to travel +// with what is. Sending only the policy would blank the name and the addresses. +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":""}, + "currentState":{"productResourceName":"ns1.example"}}]`) + + var sent map[string]any + httpmock.RegisterResponder(http.MethodPut, backupAgents+"/a-1", + func(req *http.Request) (*http.Response, error) { + if err := json.NewDecoder(req.Body).Decode(&sent); err != nil { + return nil, err + } + return httpmock.NewStringResponse(200, ``), nil + }) + + _, err := cmd.Execute("baremetal", "backup-agent", "edit", "ns1.example", "--policy", "14d_retention", "--yes") + + 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") +} + +// A policy the tenant does not define is refused with the ones it does. +func (ms *MockSuite) TestBaremetalBackupAgentEditRefusesAnUnknownPolicy(assert, require *td.T) { + registerOneTenant() + registerAgents(`[{"id":"a-1","status":"NOT_INSTALLED","targetSpec":{"displayName":"agent-ns1.example"}, + "currentState":{"productResourceName":"ns1.example"}}]`) + + _, err := cmd.Execute("baremetal", "backup-agent", "edit", "ns1.example", "--policy", "99d_retention", "--yes") + + require.CmpError(err) + assert.Cmp(err.Error(), td.Contains("14d_retention")) + assert.Cmp(httpmock.GetCallCountInfo()["PUT "+backupAgents+"/a-1"], 0) +} + +// An empty policy is how an agent is taken off retention, and it is the state +// all nine agents of this account are in — so it is not an unknown policy. +func (ms *MockSuite) TestBaremetalBackupAgentEditAcceptsNoPolicyAtAll(assert, require *td.T) { + registerOneTenant() + registerAgents(`[{"id":"a-1","status":"ENABLED","targetSpec":{"displayName":"agent-ns1.example","policy":"14d_retention"}, + "currentState":{"productResourceName":"ns1.example"}}]`) + + var sent map[string]any + httpmock.RegisterResponder(http.MethodPut, backupAgents+"/a-1", + func(req *http.Request) (*http.Response, error) { + if err := json.NewDecoder(req.Body).Decode(&sent); err != nil { + return nil, err + } + return httpmock.NewStringResponse(200, ``), nil + }) + + _, err := cmd.Execute("baremetal", "backup-agent", "edit", "ns1.example", "--policy", "", "--yes") + + require.CmpNoError(err) + assert.Cmp(sent["policy"], "") +} + +// The removal takes the strongest guard the CLI has, and a dry run sends +// nothing while still naming what would go. +func (ms *MockSuite) TestBaremetalBackupAgentDeleteIsPreviewedWithoutSending(assert, require *td.T) { + registerOneTenant() + registerAgents(`[{"id":"a-1","status":"ENABLED","targetSpec":{"displayName":"agent-ns1.example"}, + "currentState":{"productResourceName":"ns1.example","policy":"14d_retention"}}]`) + + out, err := cmd.Execute("baremetal", "backup-agent", "delete", "ns1.example", "--dry-run") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("DELETE")) + assert.Cmp(out, td.Contains("a-1")) + assert.Cmp(httpmock.GetCallCountInfo()["DELETE "+backupAgents+"/a-1"], 0) +} diff --git a/internal/cmd/baremetal.go b/internal/cmd/baremetal.go index 64147bd0c..9ae7a7fd4 100644 --- a/internal/cmd/baremetal.go +++ b/internal/cmd/baremetal.go @@ -909,6 +909,74 @@ sending. --dry-run prints the whole message instead of sending it.`, addConfirmationFlags(baremetalLogsUnsubscribeCmd, "Print the call that would be made without making it") baremetalLogsCmd.AddCommand(baremetalLogsUnsubscribeCmd) + // The Veeam backup agent belongs to a VSPC tenant reached by two UUIDs, but + // it protects one machine and says so. These commands start from the server + // and resolve the rest; the tenants themselves are under `backup-services`. + baremetalBackupAgentCmd := &cobra.Command{ + Use: "backup-agent", + Short: "Manage the Veeam backup agent protecting this server", + } + baremetalCmd.AddCommand(baremetalBackupAgentCmd) + + baremetalBackupAgentCmd.AddCommand(withFilterFlag(&cobra.Command{ + Use: "show ", + Short: "Show the backup agent protecting this server", + Args: cobra.ExactArgs(1), + ValidArgsFunction: completion.ServiceList("/v1/dedicated/server"), + Run: baremetal.ShowBackupAgent, + })) + + baremetalBackupAgentCreateCmd := &cobra.Command{ + Use: "create ", + Short: "Provision a Veeam backup agent for this server", + Long: "Provision a Veeam backup agent for this server.\n\n" + + "The name, the addresses and the region are derived from the server itself, so nothing " + + "but the server name is needed. The agent is created NOT_INSTALLED: it protects nothing " + + "until the agent software runs on the machine, and retains nothing until it is put on a " + + "policy with `backup-agent edit --policy`.", + Args: cobra.ExactArgs(1), + ValidArgsFunction: completion.ServiceList("/v1/dedicated/server"), + Run: baremetal.CreateBackupAgent, + } + baremetalBackupAgentCreateCmd.Flags().StringVar(&baremetal.BackupAgentName, "display-name", "", + "Name of the agent (default: agent-)") + baremetalBackupAgentCreateCmd.Flags().StringArrayVar(&baremetal.BackupAgentIPs, "ip", nil, + "Address the agent is reached at (default: the server's own address, in a /32)") + baremetalBackupAgentCreateCmd.Flags().StringVar(&baremetal.BackupAgentRegion, "region", "", + "Region the agent operates in (default: the server's region)") + baremetalBackupAgentCreateCmd.Flags().BoolVar(&baremetal.BackupAgentWait, "wait", false, + "Wait until the agent has settled before exiting") + addConfirmationFlags(baremetalBackupAgentCreateCmd, "Print the call that would be made without making it") + baremetalBackupAgentCmd.AddCommand(baremetalBackupAgentCreateCmd) + + baremetalBackupAgentEditCmd := &cobra.Command{ + Use: "edit ", + Short: "Change the backup agent of this server", + Args: cobra.ExactArgs(1), + ValidArgsFunction: completion.ServiceList("/v1/dedicated/server"), + Run: baremetal.EditBackupAgent, + } + baremetalBackupAgentEditCmd.Flags().StringVar(&baremetal.BackupAgentPolicy, "policy", "", + "Retention policy to put the agent on (empty takes it off retention)") + baremetalBackupAgentEditCmd.Flags().StringVar(&baremetal.BackupAgentName, "display-name", "", + "New name of the agent") + baremetalBackupAgentEditCmd.Flags().StringArrayVar(&baremetal.BackupAgentIPs, "ip", nil, + "Addresses the agent is reached at") + addConfirmationFlags(baremetalBackupAgentEditCmd, "Print the call that would be made without making it") + baremetalBackupAgentCmd.AddCommand(baremetalBackupAgentEditCmd) + + baremetalBackupAgentDeleteCmd := &cobra.Command{ + Use: "delete ", + Short: "Remove the backup agent of this server — its restore points go with it", + Args: cobra.ExactArgs(1), + ValidArgsFunction: completion.ServiceList("/v1/dedicated/server"), + Run: baremetal.DeleteBackupAgent, + } + baremetalBackupAgentDeleteCmd.Flags().BoolVar(&baremetal.BackupAgentWait, "wait", false, + "Wait until the agent is actually gone before exiting") + addConfirmationFlags(baremetalBackupAgentDeleteCmd, "Print the call that would be made without making it") + baremetalBackupAgentCmd.AddCommand(baremetalBackupAgentDeleteCmd) + rootCmd.AddCommand(baremetalCmd) } diff --git a/internal/services/backupservices/backupservices.go b/internal/services/backupservices/backupservices.go new file mode 100644 index 000000000..5131c1dc9 --- /dev/null +++ b/internal/services/backupservices/backupservices.go @@ -0,0 +1,480 @@ +// SPDX-FileCopyrightText: 2026 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package backupservices + +import ( + "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/ovh/ovhcloud-cli/internal/services/common" + "github.com/spf13/cobra" +) + +// The Veeam Backup service — tenants, storage vaults, the Service Provider +// Console that drives the agents — had no command at all. Its seventeen paths +// live in the v2 catalogue, which this repository could not fetch a schema for +// until #262, so the surface was invisible rather than skipped. +// +// Every operation of this API is badged "Beta version" upstream. + +// EditSpec carries what the two editable resources accept: a name, and nothing +// else. The API's own TargetSpec for a vault and for a VSPC tenant has exactly +// one property. +var EditSpec struct { + Name string `json:"name,omitempty"` +} + +// ListTenants shows the backup tenants of the account. +func ListTenants(_ *cobra.Command, _ []string) { + var tenants []resource + if err := httpLib.Client.Get(TenantsPath, &tenants); err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to list the backup tenants: %s", err) + return + } + + display.RenderTable(rowsOf(tenants, func(r resource) map[string]any { + state := r.CurrentState + return map[string]any{ + "vaults": countOf(state["vaults"]), + "vspcTenants": countOf(state["vspcTenants"]), + } + }), []string{"id", "name", "resourceStatus status", "vaults", "vspcTenants", "tasks"}, &flags.OutputFormatConfig) +} + +// ShowTenant reads one backup tenant, resolved when the account has only one. +func ShowTenant(_ *cobra.Command, args []string) { + tenant, err := tenantFromArgs(args) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + common.ManageObjectRequest(TenantsPath, tenant, "") +} + +// ListVaults shows the storage vaults of a tenant. +func ListVaults(_ *cobra.Command, _ []string) { + tenant, err := ResolveTenant() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + var vaults []resource + path := fmt.Sprintf("%s/%s/vault", TenantsPath, url.PathEscape(tenant)) + if err := httpLib.Client.Get(path, &vaults); err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to list the vaults of %s: %s", tenant, err) + return + } + + // allowedIps is deliberately absent. It looks like a field of a vault and it + // is not: the addresses allowed to reach one are listed in the VSPC + // tenant's own view of its vaults, and reading them off the vault resource + // answers 0 on an account where the real number is 9 — a column that is + // wrong reads worse than a column that is missing. + display.RenderTable(rowsOf(vaults, func(r resource) map[string]any { + return map[string]any{ + "buckets": countOf(r.CurrentState["buckets"]), + "vspcTenants": countOf(r.CurrentState["vspcTenants"]), + "type": r.CurrentState["type"], + "regions": strings.Join(bucketRegions(r), ", "), + } + }), []string{"id", "name", "resourceStatus status", "regions", "type", "buckets", "vspcTenants", "tasks"}, + &flags.OutputFormatConfig) +} + +// ShowVault reads one vault. +func ShowVault(_ *cobra.Command, args []string) { + tenant, err := ResolveTenant() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + common.ManageObjectRequest(fmt.Sprintf("%s/%s/vault", TenantsPath, url.PathEscape(tenant)), args[0], "") +} + +// ListBuckets shows the buckets behind a vault. +// +// There is no command to read one bucket on its own: the collection already +// answers with the whole object — identifier, name, region, performance, role +// and status — so a get would repeat a row of the table it was read from. +func ListBuckets(_ *cobra.Command, args []string) { + tenant, err := ResolveTenant() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + var buckets []map[string]any + path := VaultPath(tenant, args[0]) + "/bucket" + if err := httpLib.Client.Get(path, &buckets); err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to list the buckets of vault %s: %s", args[0], err) + return + } + + display.RenderTable(buckets, []string{"id", "name", "region", "performance", "role", "status"}, + &flags.OutputFormatConfig) +} + +// EditVault renames a vault. +func EditVault(cmd *cobra.Command, args []string) { + tenant, err := ResolveTenant() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + editNamedResource(cmd, VaultPath(tenant, args[0]), "vault "+args[0]) +} + +// ListVspc shows the Service Provider Console tenants. +func ListVspc(_ *cobra.Command, _ []string) { + tenant, err := ResolveTenant() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + vspcs, err := listVspc(tenant) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + display.RenderTable(rowsOf(vspcs, func(r resource) map[string]any { + state := r.CurrentState + addons, _ := state["enabledAddons"].([]any) + names := make([]string, 0, len(addons)) + for _, addon := range addons { + names = append(names, fmt.Sprint(addon)) + } + + return map[string]any{ + "vspcType": state["vspcType"], + "region": state["region"], + "accessUrl": state["accessUrl"], + "addons": strings.Join(names, ", "), + "agents": countOf(state["backupAgents"]), + } + }), []string{"id", "name", "resourceStatus status", "vspcType type", "region", "agents", "addons", "tasks"}, + &flags.OutputFormatConfig) +} + +// ShowVspc reads one VSPC tenant. +func ShowVspc(_ *cobra.Command, args []string) { + tenant, err := ResolveTenant() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + vspc := Vspc + if len(args) > 0 { + vspc = args[0] + } + if vspc == "" { + if vspc, err = ResolveVspc(tenant); err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + } + + common.ManageObjectRequest(fmt.Sprintf("%s/%s/vspc", TenantsPath, url.PathEscape(tenant)), vspc, "") +} + +// EditVspc renames a VSPC tenant. +func EditVspc(cmd *cobra.Command, args []string) { + tenant, err := ResolveTenant() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + editNamedResource(cmd, fmt.Sprintf("%s/%s/vspc/%s", TenantsPath, url.PathEscape(tenant), url.PathEscape(args[0])), + "VSPC tenant "+args[0]) +} + +// ListPolicies shows the retention policies an agent can be put on. +// +// It is a list of names and nothing else — the API answers with strings — and +// those names are what `baremetal backup-agent edit --policy` takes. +func ListPolicies(_ *cobra.Command, _ []string) { + tenant, vspc, err := ResolveBoth() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + policies, err := PoliciesOf(tenant, vspc) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + if len(policies) == 0 { + display.OutputInfo(&flags.OutputFormatConfig, map[string]any{"policies": []any{}}, + "This VSPC tenant defines no retention policy, so an agent has nothing to be put on.") + return + } + + rows := make([]map[string]any, 0, len(policies)) + for _, policy := range policies { + rows = append(rows, map[string]any{"policy": policy}) + } + + display.RenderTable(rows, []string{"policy"}, &flags.OutputFormatConfig) +} + +// PoliciesOf lists the retention policies of a VSPC tenant. +func PoliciesOf(tenant, vspc string) ([]string, error) { + var policies []string + + path := VspcPath(tenant, vspc) + "/backupPolicies" + if err := httpLib.Client.Get(path, &policies); err != nil { + return nil, fmt.Errorf("failed to list the retention policies: %w", err) + } + + sort.Strings(policies) + + return policies, nil +} + +// ShowDeployScript prints what puts the agent on a machine. +// +// This is the command that makes an agent installable from a terminal, and it +// is also the reason the whole lot is worth having: an agent created through +// the API is NOT_INSTALLED until this script has run on the machine. +// +// The links are pre-signed S3 URLs. They carry their own authorisation and, on +// this account, an X-Amz-Expires of seven days — so they are printed, because +// they are the answer, and what they are is said beside them. +func ShowDeployScript(_ *cobra.Command, _ []string) { + tenant, vspc, err := ResolveBoth() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + var agent struct { + LinuxDeployScript string `json:"linuxDeployScript"` + LinuxURL string `json:"linuxUrl"` + MacURL string `json:"macUrl"` + WindowsURL string `json:"windowsUrl"` + } + + path := VspcPath(tenant, vspc) + "/managementAgent" + if err := httpLib.Client.Get(path, &agent); err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to read the management agent: %s", err) + return + } + + message := fmt.Sprintf("Run this on the machine to install the backup agent:\n\n %s\n\n"+ + "Windows: %s\nmacOS: %s\n\n"+ + "These links carry their own authorisation — anyone holding one can install against this tenant.", + agent.LinuxDeployScript, agent.WindowsURL, agent.MacURL) + + display.OutputInfo(&flags.OutputFormatConfig, map[string]any{ + "linuxDeployScript": agent.LinuxDeployScript, + "linuxUrl": agent.LinuxURL, + "macUrl": agent.MacURL, + "windowsUrl": agent.WindowsURL, + }, "%s", message) +} + +// ListLicenses shows the Veeam licences held by a VSPC tenant. +func ListLicenses(_ *cobra.Command, _ []string) { + tenant, vspc, err := ResolveBoth() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + var licenses []resource + path := VspcPath(tenant, vspc) + "/backupLicenses" + if err := httpLib.Client.Get(path, &licenses); err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to list the backup licences: %s", err) + return + } + + if len(licenses) == 0 { + display.OutputInfo(&flags.OutputFormatConfig, map[string]any{"licenses": []any{}}, + "This VSPC tenant holds no Veeam licence, so it drives no backup server.") + return + } + + display.RenderTable(rowsOf(licenses, nil), + []string{"id", "name", "resourceStatus status", "tasks"}, &flags.OutputFormatConfig) +} + +// ListLicenseServers shows the backup servers driven by one licence. +func ListLicenseServers(_ *cobra.Command, args []string) { + tenant, vspc, err := ResolveBoth() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + var servers []resource + path := fmt.Sprintf("%s/backupLicenses/%s/backupServer", VspcPath(tenant, vspc), url.PathEscape(args[0])) + if err := httpLib.Client.Get(path, &servers); err != nil { + display.OutputError(&flags.OutputFormatConfig, + "failed to list the backup servers of licence %s: %s", args[0], err) + return + } + + display.RenderTable(rowsOf(servers, func(r resource) map[string]any { + return map[string]any{ + "licenseType": r.TargetSpec["licenseType"], + "displayName": r.TargetSpec["displayName"], + } + }), []string{"id", "displayName", "licenseType", "status", "tasks"}, &flags.OutputFormatConfig) +} + +// editNamedResource sends the one field these two resources accept. +func editNamedResource(cmd *cobra.Command, path, label string) { + if strings.TrimSpace(EditSpec.Name) == "" { + display.OutputError(&flags.OutputFormatConfig, + "--name is what this command changes, and a blank one is not a name") + return + } + + if !common.ConfirmAction(common.Disruptive, label, + fmt.Sprintf("This renames %s to %q.", label, EditSpec.Name)) { + display.OutputError(&flags.OutputFormatConfig, "rename of %s cancelled", label) + return + } + + if common.ReportDryRun(common.Call{Method: "PUT", Endpoint: path}) { + return + } + + if err := httpLib.Client.Put(path, map[string]any{"name": EditSpec.Name}, nil); err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to rename %s: %s", label, err) + return + } + + display.OutputInfo(&flags.OutputFormatConfig, + map[string]any{"name": EditSpec.Name}, "✅ %s is now named %q.", label, EditSpec.Name) +} + +// rowsOf flattens the resource shape into what a table can hold. +// +// Every object of this API answers with the same four parts, and three of them +// are nested: the name lives in the spec, the counts live in the current state, +// and what is happening lives in a list of tasks. A table that showed the raw +// object would show four columns of JSON. +func rowsOf(resources []resource, extra func(resource) map[string]any) []map[string]any { + rows := make([]map[string]any, 0, len(resources)) + for _, r := range resources { + row := map[string]any{ + "id": r.ID, + "name": r.name(), + "resourceStatus": statusOf(r), + "tasks": taskSummary(r.CurrentTasks), + "createdAt": r.CreatedAt, + "updatedAt": r.UpdatedAt, + } + if extra != nil { + for key, value := range extra(r) { + row[key] = value + } + } + rows = append(rows, row) + } + + return rows +} + +// statusOf reads whichever status field the resource carries. +// +// Most of them answer with resourceStatus, but an agent and a backup server +// answer with status instead — same idea, different field, different +// enumeration. Reading only one of the two would print an empty column on +// exactly the resources somebody is checking on. +func statusOf(r resource) string { + if r.ResourceStatus != "" { + return r.ResourceStatus + } + if status, ok := r.CurrentState["status"].(string); ok { + return status + } + + return "" +} + +// taskSummary says what is happening to a resource, in one cell. +// +// This generation of the API has no task route to poll: a resource carries the +// tasks running on it, and that list is the only place a failure is reported. +// An empty list is the normal case and reads as a dash rather than as nothing. +func taskSummary(tasks []currentTask) string { + if len(tasks) == 0 { + return "—" + } + + parts := make([]string, 0, len(tasks)) + for _, task := range tasks { + // The type is what says which operation is stuck; the status alone + // answers "ERROR" to the question "error doing what". Measured on this + // account: three BACKUP_VAULT_CREATE in ERROR and a VSPC_AGENT_UPDATE + // beside them, none of which the status column could have named. + part := task.Status + if task.Type != "" { + part = task.Type + " " + task.Status + } + // The schema declares errors on a task, and the API leaves the list + // empty even on a failed one, so this is printed when it is there and + // nothing pretends it will be. + if len(task.Errors) > 0 { + part += ": " + task.Errors[0].Message + } + parts = append(parts, part) + } + + return strings.Join(parts, ", ") +} + +func countOf(value any) int { + list, _ := value.([]any) + return len(list) +} + +// bucketRegions says where a vault actually stores, which is the thing that +// distinguishes three vaults whose names are otherwise generated. +func bucketRegions(r resource) []string { + buckets, _ := r.CurrentState["buckets"].([]any) + + seen := make(map[string]bool) + var regions []string + for _, item := range buckets { + bucket, ok := item.(map[string]any) + if !ok { + continue + } + region, _ := bucket["region"].(string) + if region == "" || seen[region] { + continue + } + seen[region] = true + regions = append(regions, region) + } + sort.Strings(regions) + + return regions +} + +func tenantFromArgs(args []string) (string, error) { + if len(args) > 0 { + return args[0], nil + } + + return ResolveTenant() +} diff --git a/internal/services/backupservices/backupservices_test.go b/internal/services/backupservices/backupservices_test.go new file mode 100644 index 000000000..0d697b3ac --- /dev/null +++ b/internal/services/backupservices/backupservices_test.go @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2026 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package backupservices + +import ( + "strings" + "testing" +) + +// "ERROR" answers "error doing what?" with nothing. The type is the half that +// says which operation is stuck, and this account carries three +// BACKUP_VAULT_CREATE in ERROR that the status alone could not have named. +func TestATaskSaysWhatFailedAndNotOnlyThatItDid(t *testing.T) { + summary := taskSummary([]currentTask{{Type: "BACKUP_VAULT_CREATE", Status: "ERROR"}}) + if summary != "BACKUP_VAULT_CREATE ERROR" { + t.Fatalf("got %q", summary) + } + + // The schema declares errors on a task and the API leaves the list empty + // even on a failed one. When it is there, it is shown. + withMessage := taskSummary([]currentTask{{ + Type: "VSPC_AGENT_UPDATE", + Status: "ERROR", + Errors: []struct { + Message string `json:"message"` + }{{Message: "agent unreachable"}}, + }}) + if !strings.Contains(withMessage, "agent unreachable") { + t.Fatalf("got %q", withMessage) + } + + // A task with no type still reports its status rather than an empty cell. + if bare := taskSummary([]currentTask{{Status: "PENDING"}}); bare != "PENDING" { + t.Fatalf("got %q", bare) + } +} + +// No task running is the normal case, and it reads as a dash rather than as a +// blank somebody has to decide the meaning of. +func TestNoTaskReadsAsADash(t *testing.T) { + if summary := taskSummary(nil); summary != "—" { + t.Fatalf("got %q", summary) + } +} + +// Most resources of this API answer with resourceStatus, but an agent and a +// backup server answer with status instead. Reading one of the two would print +// an empty column on exactly the resources somebody is checking on. +func TestStatusIsReadFromEitherFieldTheApiUses(t *testing.T) { + if got := statusOf(resource{ResourceStatus: "READY"}); got != "READY" { + t.Fatalf("got %q", got) + } + if got := statusOf(resource{CurrentState: map[string]any{"status": "NOT_INSTALLED"}}); got != "NOT_INSTALLED" { + t.Fatalf("got %q", got) + } + if got := statusOf(resource{}); got != "" { + t.Fatalf("nothing to report must stay empty, got %q", got) + } +} + +// A vault, a tenant and a VSPC tenant are all named in their target spec and +// echoed in their current state; a tenant on this account is named after its +// own identifier, which is not a name but is what there is. +func TestAResourceIsNamedByWhatItCarries(t *testing.T) { + both := resource{ID: "id-1", + TargetSpec: map[string]any{"name": "asked"}, + CurrentState: map[string]any{"name": "actual"}} + if both.name() != "actual" { + t.Fatalf("what is true now wins over what was asked: %q", both.name()) + } + + asked := resource{ID: "id-1", TargetSpec: map[string]any{"name": "asked"}} + if asked.name() != "asked" { + t.Fatalf("got %q", asked.name()) + } + + bare := resource{ID: "id-1"} + if bare.name() != "id-1" { + t.Fatalf("a resource with no name is named by its identifier, got %q", bare.name()) + } +} + +// Three vaults whose names are generated are told apart by where they store. +func TestVaultRegionsAreDedupedAndSorted(t *testing.T) { + vault := resource{CurrentState: map[string]any{"buckets": []any{ + map[string]any{"region": "eu-west-sbg"}, + map[string]any{"region": "ca-east-tor"}, + map[string]any{"region": "eu-west-sbg"}, + map[string]any{}, + "not an object", + }}} + + regions := bucketRegions(vault) + if strings.Join(regions, ",") != "ca-east-tor,eu-west-sbg" { + t.Fatalf("got %v", regions) + } +} + +// A missing list is not a list of zero things somebody should read as a fact. +func TestCountOfAnAbsentListIsZeroAndNotAGuess(t *testing.T) { + if countOf(nil) != 0 { + t.Fatal("absent counts as none") + } + if countOf([]any{1, 2, 3}) != 3 { + t.Fatal("three is three") + } + if countOf("not a list") != 0 { + t.Fatal("something that is not a list counts nothing") + } +} diff --git a/internal/services/backupservices/resolve.go b/internal/services/backupservices/resolve.go new file mode 100644 index 000000000..ce42630a8 --- /dev/null +++ b/internal/services/backupservices/resolve.go @@ -0,0 +1,167 @@ +// SPDX-FileCopyrightText: 2026 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package backupservices + +import ( + "fmt" + "net/url" + "strings" + + httpLib "github.com/ovh/ovhcloud-cli/internal/http" +) + +// The Veeam surface is a hierarchy of three UUIDs — tenant, then VSPC tenant, +// then agent — and none of them is a thing anybody knows by heart. An account +// has one tenant and one VSPC tenant to begin with, and typing two identifiers +// to reach the third is the kind of friction that sends people back to the web +// interface. +// +// So the levels are resolved when they are not ambiguous, exactly as +// `vrack attach` resolves a server to its interface and `baremetal logs` +// resolves a stream title. One is taken, several are refused with their names, +// none is an answer rather than an error. + +const ( + // TenantsPath is the collection every other path hangs from. + TenantsPath = "/v2/backupServices/tenant" +) + +var ( + // Tenant and Vspc override the resolution when an account has more than one. + Tenant string + Vspc string +) + +// resource is the shape this whole API answers with: an identity, what was +// asked for, what is true now, and what is happening to it. +type resource struct { + ID string `json:"id"` + ResourceStatus string `json:"resourceStatus"` + TargetSpec map[string]any `json:"targetSpec"` + CurrentState map[string]any `json:"currentState"` + CurrentTasks []currentTask `json:"currentTasks"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type currentTask struct { + ID string `json:"id"` + Type string `json:"type"` + Link string `json:"link"` + Status string `json:"status"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +// name reads the human name of a resource, which lives in the target spec and +// is echoed in the current state. +func (r resource) name() string { + if name, ok := r.CurrentState["name"].(string); ok && name != "" { + return name + } + if name, ok := r.TargetSpec["name"].(string); ok && name != "" { + return name + } + + return r.ID +} + +// ResolveTenant settles which backup tenant a command works on. +func ResolveTenant() (string, error) { + if Tenant != "" { + return Tenant, nil + } + + var tenants []resource + if err := httpLib.Client.Get(TenantsPath, &tenants); err != nil { + return "", fmt.Errorf("failed to list the backup tenants: %w", err) + } + + switch len(tenants) { + case 1: + return tenants[0].ID, nil + + case 0: + return "", fmt.Errorf("this account has no backup tenant, so there is nothing to back up to.\n" + + " Order one before using these commands") + + default: + return "", fmt.Errorf("this account has %d backup tenants; name one with --tenant:\n%s", + len(tenants), listResources(tenants)) + } +} + +// ResolveVspc settles which VSPC tenant a command works on, inside a tenant. +// +// The VSPC tenant is the Veeam Service Provider Console side of the product: it +// is what holds the agents, the licences and the deployment script, while the +// vaults beside it hold the storage. +func ResolveVspc(tenant string) (string, error) { + if Vspc != "" { + return Vspc, nil + } + + vspcs, err := listVspc(tenant) + if err != nil { + return "", err + } + + switch len(vspcs) { + case 1: + return vspcs[0].ID, nil + + case 0: + return "", fmt.Errorf("backup tenant %s has no VSPC tenant, so it holds no agent", tenant) + + default: + return "", fmt.Errorf("backup tenant %s has %d VSPC tenants; name one with --vspc:\n%s", + tenant, len(vspcs), listResources(vspcs)) + } +} + +// ResolveBoth is the pair every agent command needs. +func ResolveBoth() (tenant, vspc string, err error) { + if tenant, err = ResolveTenant(); err != nil { + return "", "", err + } + if vspc, err = ResolveVspc(tenant); err != nil { + return "", "", err + } + + return tenant, vspc, nil +} + +func listVspc(tenant string) ([]resource, error) { + var vspcs []resource + + path := fmt.Sprintf("%s/%s/vspc", TenantsPath, url.PathEscape(tenant)) + if err := httpLib.Client.Get(path, &vspcs); err != nil { + return nil, fmt.Errorf("failed to list the VSPC tenants of %s: %w", tenant, err) + } + + return vspcs, nil +} + +// listResources renders a refusal that can be acted on: an identifier to paste +// and a name to recognise it by. +func listResources(resources []resource) string { + lines := make([]string, 0, len(resources)) + for _, r := range resources { + lines = append(lines, fmt.Sprintf(" %s (%s)", r.ID, r.name())) + } + + return strings.Join(lines, "\n") +} + +// VspcPath is the prefix of everything that hangs off a VSPC tenant. +func VspcPath(tenant, vspc string) string { + return fmt.Sprintf("%s/%s/vspc/%s", TenantsPath, url.PathEscape(tenant), url.PathEscape(vspc)) +} + +// VaultPath is the prefix of everything that hangs off a vault. +func VaultPath(tenant, vault string) string { + return fmt.Sprintf("%s/%s/vault/%s", TenantsPath, url.PathEscape(tenant), url.PathEscape(vault)) +} diff --git a/internal/services/baremetal/backupagent.go b/internal/services/baremetal/backupagent.go new file mode 100644 index 000000000..dcc3956a9 --- /dev/null +++ b/internal/services/baremetal/backupagent.go @@ -0,0 +1,546 @@ +// SPDX-FileCopyrightText: 2026 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package baremetal + +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/backupservices" + "github.com/ovh/ovhcloud-cli/internal/services/common" + "github.com/spf13/cobra" +) + +// A backup agent belongs to a VSPC tenant, which belongs to a backup tenant, +// and it is reached by two UUIDs nobody knows. But it backs up one machine, and +// it says so: backup.AgentProductTypeEnum lists OVHCLOUD_BAREMETAL as a +// first-class product type, and the agent carries the server it protects in +// productResourceName. +// +// So these commands start where an operator starts, from a server name, and the +// hierarchy above is resolved. Measured on 20 August 2026, all nine agents of +// this account are OVHCLOUD_BAREMETAL, named agent-, with ips equal to +// the server's own address in a /32 and region equal to the server's region — +// nine times out of nine. That correspondence is what lets `create` need +// nothing but the server name. + +var ( + // BackupAgentPolicy is the retention policy to put the agent on. + BackupAgentPolicy string + + // BackupAgentName overrides the generated display name. + BackupAgentName string + + // BackupAgentIPs override the addresses derived from the server. + BackupAgentIPs []string + + // BackupAgentRegion overrides the region derived from the server. + BackupAgentRegion string + + // BackupAgentWait keeps the command running until the agent has settled. + BackupAgentWait bool +) + +const ( + backupAgentPollInterval = 5 * time.Second + backupAgentPollAttempts = 60 +) + +// backupAgentSettled: backup.AgentStatusEnum is CREATING, DISABLED, ENABLED, +// NOT_CONFIGURED, NOT_INSTALLED, UPDATING. Only two of those are transitions; +// the other four are places an agent stops, and NOT_INSTALLED is where a freshly +// created one stops — the object exists, the software is not on the machine yet. +var backupAgentTransient = map[string]bool{"CREATING": true, "UPDATING": true} + +// backupRegions are the regions the creation accepts. The API has two region +// enumerations and they are not interchangeable: common.RegionEnum spells +// eu-west-rbx and backup.RegionCodeEnum spells rbx. The creation takes the +// first, which is also what a dedicated server reports as its own region. +var backupRegions = sync.OnceValues(func() ([]string, error) { + return openapi.GetComponentEnum(assets.BackupservicesV2OpenapiSchema, "common.RegionEnum") +}) + +// backupAgent is one agent, flattened out of the resource shape. +type backupAgent struct { + ID string `json:"id"` + Status string `json:"status"` + TargetSpec map[string]any `json:"targetSpec"` + CurrentState map[string]any `json:"currentState"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +func (a backupAgent) protects() string { + name, _ := a.CurrentState["productResourceName"].(string) + return name +} + +func (a backupAgent) policy() string { + if policy, ok := a.CurrentState["policy"].(string); ok && policy != "" { + return policy + } + policy, _ := a.TargetSpec["policy"].(string) + return policy +} + +// ShowBackupAgent shows the backup agent protecting a server. +func ShowBackupAgent(_ *cobra.Command, args []string) { + server := args[0] + + tenant, vspc, err := backupservices.ResolveBoth() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + agents, err := agentsProtecting(tenant, vspc, server) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + if len(agents) == 0 { + display.OutputInfo(&flags.OutputFormatConfig, + map[string]any{"serviceName": server, "agents": []any{}}, + "%s has no backup agent.\n Create one with: ovhcloud baremetal backup-agent create %s", server, server) + return + } + + rows := make([]map[string]any, 0, len(agents)) + for _, agent := range agents { + rows = append(rows, map[string]any{ + "id": agent.ID, + "displayName": agent.TargetSpec["displayName"], + "status": agent.Status, + "policy": policyOrNone(agent.policy()), + "ips": agent.CurrentState["ips"], + "type": agent.CurrentState["type"], + "createdAt": agent.CreatedAt, + }) + } + + display.RenderTable(rows, []string{"id", "displayName", "status", "policy", "ips", "type"}, + &flags.OutputFormatConfig) +} + +// CreateBackupAgent provisions a backup agent for a server. +func CreateBackupAgent(_ *cobra.Command, args []string) { + server := args[0] + + tenant, vspc, err := backupservices.ResolveBoth() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + existing, err := agentsProtecting(tenant, vspc, server) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + if len(existing) > 0 { + display.OutputError(&flags.OutputFormatConfig, + "%s already has a backup agent (%s, %s).\n Change it with: ovhcloud baremetal backup-agent edit %s", + server, existing[0].ID, existing[0].Status, server) + return + } + + spec, err := agentSpecFor(server) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + // Creating an agent reaches past the server: on this account the allowed-IP + // list of every vault is exactly the set of agent addresses, so a new agent + // adds its address to them. Somebody agreeing to this should know that. + if !common.ConfirmAction(common.Disruptive, server, fmt.Sprintf( + "This provisions a Veeam backup agent for %s in %s, reachable at %s, and lists that address among the ones allowed on the backup vaults.", + server, spec["region"], joinAny(spec["ips"]))) { + display.OutputError(&flags.OutputFormatConfig, "creation of a backup agent for %s cancelled", server) + return + } + + endpoint := backupservices.VspcPath(tenant, vspc) + "/backupAgent" + if reportBackupAgentDryRun("POST", endpoint, spec) { + return + } + + if err := httpLib.Client.Post(endpoint, spec, nil); err != nil { + display.OutputError(&flags.OutputFormatConfig, + "failed to create a backup agent for %s: %s", server, err) + return + } + + if !BackupAgentWait { + display.OutputInfo(&flags.OutputFormatConfig, spec, + "⚡️ A backup agent is being created for %s. Follow it with: ovhcloud baremetal backup-agent show %s", + server, server) + return + } + + agent, err := waitForBackupAgent(tenant, vspc, server, true) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + // NOT_INSTALLED is where a created agent stops, and it is not a failure: the + // object exists and the software is not on the machine. Saying "created" and + // stopping there is how nine agents on this account ended up provisioned and + // none of them deployed. + display.OutputInfo(&flags.OutputFormatConfig, agent, + "✅ Backup agent %s created for %s, status %s.\n"+ + " It protects nothing until the agent software runs on the machine:\n"+ + " ovhcloud backup-services deploy-script\n"+ + " And nothing is retained until it is put on a policy:\n"+ + " ovhcloud baremetal backup-agent edit %s --policy ", + agent.ID, server, agent.Status, server) +} + +// EditBackupAgent changes the agent protecting a server. +func EditBackupAgent(cmd *cobra.Command, args []string) { + server := args[0] + + tenant, vspc, err := backupservices.ResolveBoth() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + agent, err := oneAgentProtecting(tenant, vspc, server) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + if cmd.Flags().NFlag() == 0 { + display.OutputInfo(&flags.OutputFormatConfig, nil, "🟠 No parameters given, nothing to edit") + return + } + + // The PUT replaces the target spec, so what is not being changed has to be + // carried over. Sending only --policy would blank the display name and the + // addresses, which is the trap `iam resource edit --tag` still has. + spec := map[string]any{ + "displayName": agent.TargetSpec["displayName"], + "ips": agent.TargetSpec["ips"], + "policy": agent.TargetSpec["policy"], + } + + if BackupAgentName != "" { + spec["displayName"] = BackupAgentName + } + if len(BackupAgentIPs) > 0 { + spec["ips"] = BackupAgentIPs + } + if cmd.Flags().Changed("policy") { + if err := checkBackupPolicy(tenant, vspc, BackupAgentPolicy); err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + spec["policy"] = BackupAgentPolicy + } + + if !common.ConfirmAction(common.Disruptive, server, fmt.Sprintf( + "This changes the backup agent of %s (%s).", server, agent.ID)) { + display.OutputError(&flags.OutputFormatConfig, "change of the backup agent of %s cancelled", server) + return + } + + endpoint := fmt.Sprintf("%s/backupAgent/%s", backupservices.VspcPath(tenant, vspc), url.PathEscape(agent.ID)) + if reportBackupAgentDryRun("PUT", endpoint, spec) { + return + } + + if err := httpLib.Client.Put(endpoint, spec, nil); err != nil { + display.OutputError(&flags.OutputFormatConfig, + "failed to change the backup agent of %s: %s", server, err) + return + } + + display.OutputInfo(&flags.OutputFormatConfig, spec, + "✅ The backup agent of %s has been changed.", server) +} + +// DeleteBackupAgent removes the backup agent of a server. +func DeleteBackupAgent(_ *cobra.Command, args []string) { + server := args[0] + + tenant, vspc, err := backupservices.ResolveBoth() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + agent, err := oneAgentProtecting(tenant, vspc, server) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + // Destructive: the restore points held under this agent go with it, and no + // other command brings them back. Same guard as terminating a Backup FTP + // space — the server's name, typed. + if !common.ConfirmAction(common.Destructive, server, fmt.Sprintf( + "This removes the backup agent of %s (%s, policy %s). Its restore points go with it.", + server, agent.ID, policyOrNone(agent.policy()))) { + display.OutputError(&flags.OutputFormatConfig, "removal of the backup agent of %s cancelled", server) + return + } + + endpoint := fmt.Sprintf("%s/backupAgent/%s", backupservices.VspcPath(tenant, vspc), url.PathEscape(agent.ID)) + if common.ReportDryRun(common.Call{Method: "DELETE", Endpoint: endpoint}) { + return + } + + if err := httpLib.Client.Delete(endpoint, nil); err != nil { + display.OutputError(&flags.OutputFormatConfig, + "failed to remove the backup agent of %s: %s", server, err) + return + } + + if !BackupAgentWait { + display.OutputInfo(&flags.OutputFormatConfig, map[string]any{"id": agent.ID, "serviceName": server}, + "⚡️ The backup agent of %s is being removed.", server) + return + } + + if _, err := waitForBackupAgent(tenant, vspc, server, false); err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + display.OutputInfo(&flags.OutputFormatConfig, map[string]any{"id": agent.ID, "serviceName": server}, + "✅ The backup agent of %s is gone.", server) +} + +// agentsProtecting lists the agents that back up a given server. +func agentsProtecting(tenant, vspc, server string) ([]backupAgent, error) { + var agents []backupAgent + + path := backupservices.VspcPath(tenant, vspc) + "/backupAgent" + if err := httpLib.Client.Get(path, &agents); err != nil { + return nil, fmt.Errorf("failed to list the backup agents: %w", err) + } + + var matching []backupAgent + for _, agent := range agents { + if agent.protects() == server { + matching = append(matching, agent) + } + } + + return matching, nil +} + +// oneAgentProtecting is agentsProtecting for the commands that change one. +func oneAgentProtecting(tenant, vspc, server string) (backupAgent, error) { + agents, err := agentsProtecting(tenant, vspc, server) + if err != nil { + return backupAgent{}, err + } + + switch len(agents) { + case 1: + return agents[0], nil + + case 0: + return backupAgent{}, fmt.Errorf( + "%s has no backup agent.\n Create one with: ovhcloud baremetal backup-agent create %s", server, server) + + default: + var lines []string + for _, agent := range agents { + lines = append(lines, fmt.Sprintf(" %s (%s)", agent.ID, agent.Status)) + } + + return backupAgent{}, fmt.Errorf( + "%d backup agents protect %s, so this command cannot tell which one you mean:\n%s", + len(agents), server, strings.Join(lines, "\n")) + } +} + +// agentSpecFor builds the creation body from the server itself. +// +// Nothing here has to be typed: nine agents out of nine on this account are +// named agent-, carry the server's own address in a /32, and sit in the +// server's region. The flags override each part for the cases that are not +// those nine. +func agentSpecFor(server string) (map[string]any, error) { + var machine struct { + IP string `json:"ip"` + Region string `json:"region"` + } + + path := fmt.Sprintf("/v1/dedicated/server/%s", url.PathEscape(server)) + if err := httpLib.Client.Get(path, &machine); err != nil { + return nil, fmt.Errorf("failed to read %s, so its region and address cannot be derived: %w", server, err) + } + + ips := BackupAgentIPs + if len(ips) == 0 { + if machine.IP == "" { + return nil, fmt.Errorf("%s reports no address, so the agent has none to be reached at; give --ip", server) + } + ips = []string{machine.IP + "/32"} + } + + region := BackupAgentRegion + if region == "" { + region = machine.Region + } + if region == "" { + return nil, fmt.Errorf("%s reports no region, so the agent has nowhere to sit; give --region", server) + } + + regions, err := backupRegions() + if err != nil { + return nil, fmt.Errorf("failed to read the regions from the embedded schema: %w", err) + } + if !slicesContain(regions, region) { + return nil, fmt.Errorf("region %q is not one the backup service accepts; use one of %s", + region, strings.Join(regions, ", ")) + } + + name := BackupAgentName + if name == "" { + name = "agent-" + server + } + + return map[string]any{ + "displayName": name, + "ips": ips, + "productResourceName": server, + "region": region, + }, nil +} + +// checkBackupPolicy refuses a policy the tenant does not define. +func checkBackupPolicy(tenant, vspc, policy string) error { + policies, err := backupservices.PoliciesOf(tenant, vspc) + if err != nil { + return err + } + + // An empty policy is how an agent is taken off retention, and the API + // accepts it: it is the state all nine agents of this account are in. + if policy == "" || slicesContain(policies, policy) { + return nil + } + + if len(policies) == 0 { + return fmt.Errorf("this VSPC tenant defines no retention policy, so %q cannot be one of them", policy) + } + + return fmt.Errorf("unknown retention policy %q; this tenant defines %s", + policy, strings.Join(policies, ", ")) +} + +// waitForBackupAgent follows a creation or a removal by reading the agent. +// +// This generation of the API answers a write with 200 and no body, so there is +// no identifier and no operation to follow — the state of the resource is the +// only thing there is to read. CREATING and UPDATING are transitions; the other +// four statuses are places an agent stops. +func waitForBackupAgent(tenant, vspc, server string, want bool) (backupAgent, error) { + var last string + + for attempt := 0; attempt < backupAgentPollAttempts; attempt++ { + agents, err := agentsProtecting(tenant, vspc, server) + if err != nil { + return backupAgent{}, err + } + + switch { + case !want && len(agents) == 0: + return backupAgent{}, nil + + case want && len(agents) > 0: + last = agents[0].Status + if !backupAgentTransient[last] { + return agents[0], nil + } + } + + time.Sleep(backupAgentPollInterval) + } + + state := "created" + if !want { + state = "removed" + } + + return backupAgent{}, fmt.Errorf( + "stopped waiting after %s; the backup agent of %s is not %s yet (last status %q), read it with: ovhcloud baremetal backup-agent show %s", + time.Duration(backupAgentPollAttempts)*backupAgentPollInterval, server, state, last, server) +} + +// reportBackupAgentDryRun previews a call with the body it would carry. +// +// The path of an agent is three UUIDs and says nothing; the body is where the +// region, the addresses and the policy are, and those are what somebody checks +// before agreeing. One document rather than two messages, so that -o json stays +// one JSON document. +func reportBackupAgentDryRun(method, endpoint string, body map[string]any) bool { + if !flags.DryRun { + return false + } + + rendered, err := marshalIndent(body) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to render the request body: %s", err) + return true + } + + display.OutputInfo(&flags.OutputFormatConfig, map[string]any{ + "calls": []map[string]any{{"method": method, "endpoint": endpoint}}, + "payload": body, + }, "🔍 Dry run: nothing was sent. This would have been called:\n %s %s\n\nwith:\n %s", + method, endpoint, rendered) + + return true +} + +// policyOrNone says out loud that an agent retains nothing. +// +// An empty policy field printed as an empty cell reads as "not shown". All nine +// agents of this account are in exactly that state, which is worth reading as +// a fact rather than as a gap in the table. +func policyOrNone(policy string) string { + if policy == "" { + return "none" + } + + return policy +} + +// marshalIndent is json.MarshalIndent with the indentation these previews use. +func marshalIndent(value any) ([]byte, error) { + return json.MarshalIndent(value, " ", " ") +} + +// joinAny renders the addresses of a spec for a prompt. +// +// It reads the spec rather than the flag, because the flag is empty in the case +// that matters: the addresses were derived from the server, and a prompt that +// showed the flag would show nothing exactly when it has something to say. +func joinAny(value any) string { + list, ok := value.([]string) + if !ok { + return fmt.Sprint(value) + } + + return strings.Join(list, ", ") +} diff --git a/internal/services/baremetal/backupagent_test.go b/internal/services/baremetal/backupagent_test.go new file mode 100644 index 000000000..42e2cc13e --- /dev/null +++ b/internal/services/baremetal/backupagent_test.go @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2026 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package baremetal + +import "testing" + +// All nine agents of this account carry an empty policy while two retention +// policies exist beside them. An empty cell reads as "not shown"; the fact is +// that these agents retain nothing. +func TestAnAgentWithoutAPolicySaysSo(t *testing.T) { + if policyOrNone("") != "none" { + t.Fatal("no policy has to be readable as no policy") + } + if policyOrNone("14d_retention") != "14d_retention" { + t.Fatal("a policy is its own name") + } +} + +// The agent names the machine it protects in its current state, which is the +// only thing joining a UUID to a server somebody can type. +func TestAnAgentNamesTheMachineItProtects(t *testing.T) { + agent := backupAgent{CurrentState: map[string]any{"productResourceName": "ns1.example"}} + if agent.protects() != "ns1.example" { + t.Fatalf("got %q", agent.protects()) + } + + if (backupAgent{}).protects() != "" { + t.Fatal("an agent that says nothing protects nothing this command can name") + } +} + +// What is true now wins over what was asked for, so an edit that has not been +// applied yet does not read as applied. +func TestAnAgentPolicyIsReadFromWhatIsTrueNow(t *testing.T) { + agent := backupAgent{ + TargetSpec: map[string]any{"policy": "30d_retention"}, + CurrentState: map[string]any{"policy": "14d_retention"}, + } + if agent.policy() != "14d_retention" { + t.Fatalf("got %q", agent.policy()) + } + + pending := backupAgent{TargetSpec: map[string]any{"policy": "30d_retention"}} + if pending.policy() != "30d_retention" { + t.Fatalf("got %q", pending.policy()) + } +} + +// CREATING and UPDATING are transitions; the other four statuses are places an +// agent stops. NOT_INSTALLED is where a freshly created one stops, and treating +// it as a transition would make --wait time out on a success. +func TestOnlyTwoAgentStatusesAreTransitions(t *testing.T) { + for _, transient := range []string{"CREATING", "UPDATING"} { + if !backupAgentTransient[transient] { + t.Fatalf("%s is a transition", transient) + } + } + for _, settled := range []string{"NOT_INSTALLED", "NOT_CONFIGURED", "ENABLED", "DISABLED"} { + if backupAgentTransient[settled] { + t.Fatalf("%s is where an agent stops, not a transition", settled) + } + } +} From 6ac1f389b93139513a890e42753c4a78951f734d Mon Sep 17 00:00:00 2001 From: Denis Hamon Date: Thu, 20 Aug 2026 09:46:24 +0200 Subject: [PATCH 04/13] feat(backup-services): list every agent, and say what the product costs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checked against the acceptance criteria of LVL2-19664 (Backup Agent - CLI Integration). Four of the six MUSTs were already covered by the previous commit; these are the three that were not. MUST "List Agent" was answered per server by `baremetal backup-agent show`, which is the question an operator asks about a machine. `backup-services agents` is the other question — what the backup posture of the estate is — and it is the one that makes this account's state readable in one screen: nine agents provisioned, none deployed, none on a retention policy. MUST "Download Agent" is `deploy-script`, which prints the install command and the per-platform links. It gains `download-agent` as an alias so the name in the product brief reaches the command; one command under two names rather than two commands. MUST "List billing / invoices pricing / consumption" was not covered at all, and the reason is that the backup API carries no price: a v2 resource has no plan, no billing date and no renewal mode. Those live in the account's service router, keyed by the very same identifiers — measured, /v1/services?resourceName= answers with exactly one service for the backup tenant, for the VSPC tenant and for each vault. So the join is one lookup per resource rather than a sweep of the 826 services on this account, and `backup-services billing` reads: kind name plan price period consumption renew state tenant 15d3f0dd-… backup-tenant 0.00 € P1M none yet automatic active vspc vspc-tenant-610301 vspc-tenant 0.00 € P1M none yet automatic active vault backup-vault-paygo-…-sbg-… backup-vault-paygo 0.00 € P1M none yet automatic active Consumption is joined by serviceId from the account's current usage. Nothing has been stored on this account — every agent is NOT_INSTALLED — so every line reads "none yet", which is a fact and not an empty cell. Consumption that could not be read reads "unknown" instead: a failed read is not a reading of zero. A backup resource with no billable service behind it does not stop the command: that is what an included component looks like, and the price column says so. Account-level invoices (/v1/me/bill) are deliberately not here. They are an account-wide surface no command exposes yet, and scoping them to one product belongs to an `account` lot rather than to this one. 3 unit tests, 4 cobra tests, 7 sabotages, 7 red. Signed-off-by: Denis Hamon --- doc/ovhcloud_backup-services.md | 2 + doc/ovhcloud_backup-services_agents.md | 46 ++++ doc/ovhcloud_backup-services_billing.md | 52 ++++ internal/cmd/backupservices.go | 24 +- internal/cmd/backupservices_test.go | 82 ++++++ .../services/backupservices/backupservices.go | 62 +++++ internal/services/backupservices/billing.go | 253 ++++++++++++++++++ .../services/backupservices/billing_test.go | 62 +++++ 8 files changed, 581 insertions(+), 2 deletions(-) create mode 100644 doc/ovhcloud_backup-services_agents.md create mode 100644 doc/ovhcloud_backup-services_billing.md create mode 100644 internal/services/backupservices/billing.go create mode 100644 internal/services/backupservices/billing_test.go diff --git a/doc/ovhcloud_backup-services.md b/doc/ovhcloud_backup-services.md index 2177b8e78..7241ea726 100644 --- a/doc/ovhcloud_backup-services.md +++ b/doc/ovhcloud_backup-services.md @@ -40,6 +40,8 @@ The agents themselves are managed from the machine they protect: see `ovhcloud b ### SEE ALSO * [ovhcloud](ovhcloud.md) - CLI to manage your OVHcloud services +* [ovhcloud backup-services agents](ovhcloud_backup-services_agents.md) - List every backup agent, and what each one protects +* [ovhcloud backup-services billing](ovhcloud_backup-services_billing.md) - Show what each part of the backup service costs, and what it has consumed * [ovhcloud backup-services deploy-script](ovhcloud_backup-services_deploy-script.md) - Show the command that installs the backup agent on a machine * [ovhcloud backup-services licenses](ovhcloud_backup-services_licenses.md) - Show the Veeam licences held by a VSPC tenant * [ovhcloud backup-services policies](ovhcloud_backup-services_policies.md) - List the retention policies an agent can be put on diff --git a/doc/ovhcloud_backup-services_agents.md b/doc/ovhcloud_backup-services_agents.md new file mode 100644 index 000000000..b5d7cd1b9 --- /dev/null +++ b/doc/ovhcloud_backup-services_agents.md @@ -0,0 +1,46 @@ +## ovhcloud backup-services agents + +List every backup agent, and what each one protects + +``` +ovhcloud backup-services agents [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 agents +``` + +### 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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) +``` + +### SEE ALSO + +* [ovhcloud backup-services](ovhcloud_backup-services.md) - Retrieve information and manage your Veeam Backup services + diff --git a/doc/ovhcloud_backup-services_billing.md b/doc/ovhcloud_backup-services_billing.md new file mode 100644 index 000000000..cb076dfdb --- /dev/null +++ b/doc/ovhcloud_backup-services_billing.md @@ -0,0 +1,52 @@ +## ovhcloud backup-services billing + +Show what each part of the backup service costs, and what it has consumed + +### Synopsis + +Show what each part of the backup service costs, and what it has consumed. + +The backup API carries no price. The plan, the price, the period, the renewal mode and the next billing date come from the account's service router, matched to each backup resource by its identifier; the consumption comes from the account's current usage. + +``` +ovhcloud backup-services billing [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 billing +``` + +### 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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) +``` + +### SEE ALSO + +* [ovhcloud backup-services](ovhcloud_backup-services.md) - Retrieve information and manage your Veeam Backup services + diff --git a/internal/cmd/backupservices.go b/internal/cmd/backupservices.go index 3a1149267..de97761b5 100644 --- a/internal/cmd/backupservices.go +++ b/internal/cmd/backupservices.go @@ -126,9 +126,29 @@ func init() { Run: backupservices.ListPolicies, })) + backupCmd.AddCommand(withFilterFlag(&cobra.Command{ + Use: "agents", + Short: "List every backup agent, and what each one protects", + Aliases: []string{"agent-list"}, + Run: backupservices.ListAgents, + })) + + backupCmd.AddCommand(withFilterFlag(&cobra.Command{ + Use: "billing", + Short: "Show what each part of the backup service costs, and what it has consumed", + Long: "Show what each part of the backup service costs, and what it has consumed.\n\n" + + "The backup API carries no price. The plan, the price, the period, the renewal mode and " + + "the next billing date come from the account's service router, matched to each backup " + + "resource by its identifier; the consumption comes from the account's current usage.", + Run: backupservices.ShowBilling, + })) + backupCmd.AddCommand(&cobra.Command{ - Use: "deploy-script", - Short: "Show the command that installs the backup agent on a machine", + Use: "deploy-script", + // download-agent is the name the product brief uses for this; the + // command is one thing under two names rather than two commands. + Aliases: []string{"download-agent"}, + Short: "Show the command that installs the backup agent on a machine", Long: "Show the command that installs the backup agent on a machine.\n\n" + "An agent created through the API exists as an object and protects nothing until this " + "script has run on the machine. The links carry their own authorisation.", diff --git a/internal/cmd/backupservices_test.go b/internal/cmd/backupservices_test.go index 985f1c4b6..0a705c391 100644 --- a/internal/cmd/backupservices_test.go +++ b/internal/cmd/backupservices_test.go @@ -7,6 +7,7 @@ package cmd_test import ( "encoding/json" "net/http" + "strings" "github.com/jarcoal/httpmock" "github.com/maxatome/go-testdeep/td" @@ -281,3 +282,84 @@ func (ms *MockSuite) TestBaremetalBackupAgentDeleteIsPreviewedWithoutSending(ass assert.Cmp(out, td.Contains("a-1")) assert.Cmp(httpmock.GetCallCountInfo()["DELETE "+backupAgents+"/a-1"], 0) } + +// The estate-wide view is the other question, and it is the one that made the +// posture of this account visible: nine agents provisioned, none deployed. +func (ms *MockSuite) TestBackupAgentsListsWhatEachOneProtects(assert, require *td.T) { + registerOneTenant() + httpmock.RegisterResponder(http.MethodGet, backupAgents, + httpmock.NewStringResponder(200, `[ + {"id":"a-2","status":"NOT_INSTALLED","targetSpec":{"displayName":"agent-zeta"}, + "currentState":{"productResourceName":"zeta.example","type":"OVHCLOUD_BAREMETAL","policy":""}}, + {"id":"a-1","status":"ENABLED","targetSpec":{"displayName":"agent-alpha"}, + "currentState":{"productResourceName":"alpha.example","type":"OVHCLOUD_BAREMETAL","policy":"14d_retention"}}]`)) + + out, err := cmd.Execute("backup-services", "agents") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("alpha.example")) + assert.Cmp(out, td.Contains("zeta.example")) + assert.Cmp(strings.Index(out, "alpha.example") < strings.Index(out, "zeta.example"), true, + "the estate reads in the order of the machines it protects") + assert.Cmp(out, td.Contains("none"), "an agent retaining nothing says so") +} + +// No agent at all is an answer with the command that makes one. +func (ms *MockSuite) TestBackupAgentsSaysWhenThereAreNone(assert, require *td.T) { + registerOneTenant() + httpmock.RegisterResponder(http.MethodGet, backupAgents, httpmock.NewStringResponder(200, `[]`)) + + out, err := cmd.Execute("backup-services", "agents") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("backup-agent create")) +} + +// The backup API carries no price. What each part costs is joined from the +// account's service router by the resource's own identifier. +func (ms *MockSuite) TestBackupBillingJoinsThePriceToEachResource(assert, require *td.T) { + registerOneTenant() + httpmock.RegisterResponder(http.MethodGet, backupTenant+"/vault", + httpmock.NewStringResponder(200, `[{"id":"v-1","resourceStatus":"READY","targetSpec":{"name":"vault-sbg"}, + "currentState":{"name":"vault-sbg","buckets":[]},"currentTasks":[]}]`)) + httpmock.RegisterResponder(http.MethodGet, "https://eu.api.ovh.com/v1/me/consumption/usage/current", + httpmock.NewStringResponder(200, `[]`)) + + for name, id := range map[string]string{"t-1": "111", "s-1": "222", "v-1": "333"} { + httpmock.RegisterResponder(http.MethodGet, "https://eu.api.ovh.com/v1/services?resourceName="+name, + httpmock.NewStringResponder(200, "["+id+"]")) + } + httpmock.RegisterResponder(http.MethodGet, `=~^https://eu\.api\.ovh\.com/v1/services/\d+$`, + httpmock.NewStringResponder(200, `{"serviceId":333,"billing":{"nextBillingDate":"2026-09-01T00:00:00Z", + "plan":{"code":"backup-vault-paygo","invoiceName":"Backup vault"}, + "pricing":{"duration":"P1M","price":{"currencyCode":"EUR","text":"0.00 €","value":0}}, + "renew":{"current":{"mode":"automatic"}},"lifecycle":{"current":{"state":"active"}}}}`)) + + out, err := cmd.Execute("backup-services", "billing") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("backup-vault-paygo")) + assert.Cmp(out, td.Contains("P1M")) + assert.Cmp(out, td.Contains("none yet"), "pay-as-you-go with nothing stored reports no line") + assert.Cmp(out, td.Contains("vault"), "and each kind of resource is named") +} + +// A backup resource with no billable service behind it is what an included +// component looks like, not a failure of the command. +func (ms *MockSuite) TestBackupBillingKeepsGoingWhenAResourceHasNoService(assert, require *td.T) { + registerOneTenant() + httpmock.RegisterResponder(http.MethodGet, backupTenant+"/vault", + httpmock.NewStringResponder(200, `[]`)) + httpmock.RegisterResponder(http.MethodGet, "https://eu.api.ovh.com/v1/me/consumption/usage/current", + httpmock.NewStringResponder(200, `[]`)) + httpmock.RegisterResponder(http.MethodGet, "https://eu.api.ovh.com/v1/services?resourceName=t-1", + httpmock.NewStringResponder(200, `[]`)) + httpmock.RegisterResponder(http.MethodGet, "https://eu.api.ovh.com/v1/services?resourceName=s-1", + httpmock.NewStringResponder(200, `[]`)) + + out, err := cmd.Execute("backup-services", "billing") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("tenant"), "the table is still printed") + assert.Cmp(out, td.Contains("—"), "and the missing price reads as missing") +} diff --git a/internal/services/backupservices/backupservices.go b/internal/services/backupservices/backupservices.go index 5131c1dc9..e75bce8e5 100644 --- a/internal/services/backupservices/backupservices.go +++ b/internal/services/backupservices/backupservices.go @@ -478,3 +478,65 @@ func tenantFromArgs(args []string) (string, error) { return ResolveTenant() } + +// ListAgents shows every backup agent of the VSPC tenant, whatever it protects. +// +// `baremetal backup-agent show` answers for one machine, which is the question +// an operator asks about a server. This is the other question — what is the +// backup posture of the estate — and it is the one that made the state of this +// account visible: nine agents provisioned, none deployed, none on a policy. +func ListAgents(_ *cobra.Command, _ []string) { + tenant, vspc, err := ResolveBoth() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + var agents []struct { + ID string `json:"id"` + Status string `json:"status"` + TargetSpec map[string]any `json:"targetSpec"` + CurrentState map[string]any `json:"currentState"` + CurrentTasks []currentTask `json:"currentTasks"` + CreatedAt string `json:"createdAt"` + } + + path := VspcPath(tenant, vspc) + "/backupAgent" + if err := httpLib.Client.Get(path, &agents); err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to list the backup agents: %s", err) + return + } + + if len(agents) == 0 { + display.OutputInfo(&flags.OutputFormatConfig, map[string]any{"agents": []any{}}, + "This VSPC tenant has no backup agent.\n"+ + " Create one for a dedicated server with: ovhcloud baremetal backup-agent create ") + return + } + + rows := make([]map[string]any, 0, len(agents)) + for _, agent := range agents { + policy, _ := agent.CurrentState["policy"].(string) + if policy == "" { + policy = "none" + } + + rows = append(rows, map[string]any{ + "id": agent.ID, + "name": agent.TargetSpec["displayName"], + "protects": agent.CurrentState["productResourceName"], + "type": agent.CurrentState["type"], + "status": agent.Status, + "policy": policy, + "ips": agent.CurrentState["ips"], + "tasks": taskSummary(agent.CurrentTasks), + }) + } + + sort.Slice(rows, func(i, j int) bool { + return fmt.Sprint(rows[i]["protects"]) < fmt.Sprint(rows[j]["protects"]) + }) + + display.RenderTable(rows, + []string{"protects", "status", "policy", "type", "ips", "id", "tasks"}, &flags.OutputFormatConfig) +} diff --git a/internal/services/backupservices/billing.go b/internal/services/backupservices/billing.go new file mode 100644 index 000000000..a0fb69c8e --- /dev/null +++ b/internal/services/backupservices/billing.go @@ -0,0 +1,253 @@ +// SPDX-FileCopyrightText: 2026 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package backupservices + +import ( + "fmt" + "net/url" + "sort" + "strings" + "sync" + + "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" + "golang.org/x/sync/errgroup" +) + +// What the backup product costs is not in the backup API. The v2 resources +// carry no price, no billing date and no renewal mode; those live in the +// account's service router, keyed by the very same identifiers. +// +// Measured on 20 August 2026: /v1/services?resourceName= answers with +// exactly one service for the backup tenant, for the VSPC tenant and for each +// vault, and each of those carries its plan code, its price, its period, its +// next billing date and its renewal mode. So the join is one lookup per +// resource rather than a sweep of the 826 services on this account. + +// billedResource is one line of the billing view. +type billedResource struct { + kind string + id string + name string +} + +// ShowBilling shows what every piece of the backup product costs. +func ShowBilling(_ *cobra.Command, _ []string) { + tenant, err := ResolveTenant() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + resources, err := billableResources(tenant) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + usage, err := currentUsage() + if err != nil { + // Consumption is one half of the answer and the prices are the other. + // Losing the half that failed is better than answering nothing, so it + // is reported and the table is still printed. + display.OutputInfo(&flags.OutputFormatConfig, nil, + "🟠 Current consumption could not be read (%s); the prices below are still what is billed.", err) + usage = nil + } + + rows := make([]map[string]any, len(resources)) + group := new(errgroup.Group) + group.SetLimit(10) + + for index, item := range resources { + group.Go(func() error { + row := map[string]any{ + "kind": item.kind, + "id": item.id, + "name": item.name, + } + + service, err := serviceOf(item.id) + switch { + case err != nil: + // A resource with no billable service behind it is a fact, not + // a failure of the command: it is what an included component + // looks like. + row["plan"] = "—" + row["note"] = err.Error() + + default: + billing := service.Billing + row["serviceId"] = service.ServiceID + row["plan"] = billing.Plan.Code + row["invoiceName"] = billing.Plan.InvoiceName + row["price"] = billing.Pricing.Price.Text + row["priceValue"] = billing.Pricing.Price.Value + row["currency"] = billing.Pricing.Price.CurrencyCode + row["period"] = billing.Pricing.Duration + row["nextBillingDate"] = billing.NextBillingDate + row["renew"] = billing.Renew.Current.Mode + row["state"] = billing.Lifecycle.Current.State + row["consumption"] = usageOf(usage, service.ServiceID) + } + + rows[index] = row + + return nil + }) + } + if err := group.Wait(); err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + display.RenderTable(rows, + []string{"kind", "name", "plan", "price", "period", "consumption", "renew", "nextBillingDate", "state"}, + &flags.OutputFormatConfig) +} + +// billableResources lists everything of the backup product that has a price. +func billableResources(tenant string) ([]billedResource, error) { + resources := []billedResource{{kind: "tenant", id: tenant, name: tenant}} + + vspcs, err := listVspc(tenant) + if err != nil { + return nil, err + } + for _, vspc := range vspcs { + resources = append(resources, billedResource{kind: "vspc", id: vspc.ID, name: vspc.name()}) + } + + var vaults []resource + path := fmt.Sprintf("%s/%s/vault", TenantsPath, url.PathEscape(tenant)) + if err := httpLib.Client.Get(path, &vaults); err != nil { + return nil, fmt.Errorf("failed to list the vaults of %s: %w", tenant, err) + } + for _, vault := range vaults { + resources = append(resources, billedResource{kind: "vault", id: vault.ID, name: vault.name()}) + } + + return resources, nil +} + +// service is the part of a service router entry this command reads. +type service struct { + ServiceID int `json:"serviceId"` + Billing struct { + NextBillingDate string `json:"nextBillingDate"` + Plan struct { + Code string `json:"code"` + InvoiceName string `json:"invoiceName"` + } `json:"plan"` + Pricing struct { + 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"` + } `json:"current"` + } `json:"renew"` + Lifecycle struct { + Current struct { + State string `json:"state"` + } `json:"current"` + } `json:"lifecycle"` + } `json:"billing"` +} + +// serviceOf finds the billable service behind one backup identifier. +// +// The service router is queried by resource name rather than swept: this +// account carries 826 services and reading them all to find five would be a +// minute of requests for an answer one lookup gives. +func serviceOf(resourceName string) (service, error) { + var ids []int + + path := "/v1/services?resourceName=" + url.QueryEscape(resourceName) + if err := httpLib.Client.Get(path, &ids); err != nil { + return service{}, fmt.Errorf("no billable service found for %s: %w", resourceName, err) + } + + if len(ids) == 0 { + return service{}, fmt.Errorf("no billable service is registered under %s", resourceName) + } + + var found service + if err := httpLib.Client.Get(fmt.Sprintf("/v1/services/%d", ids[0]), &found); err != nil { + return service{}, fmt.Errorf("failed to read service %d: %w", ids[0], err) + } + + return found, nil +} + +// usageEntry is one line of the account's current consumption. +type usageEntry struct { + ServiceID int `json:"serviceId"` + Elements []struct { + PlanCode string `json:"planCode"` + Details []struct { + Quantity float64 `json:"quantity"` + UniqueID string `json:"uniqueId"` + } `json:"details"` + } `json:"elements"` + Price *struct { + Text string `json:"text"` + } `json:"price"` +} + +var currentUsage = sync.OnceValues(func() ([]usageEntry, error) { + var entries []usageEntry + if err := httpLib.Client.Get("/v1/me/consumption/usage/current", &entries); err != nil { + return nil, err + } + + return entries, nil +}) + +// usageOf says what a service has consumed so far this month. +// +// Pay-as-you-go storage that nothing has written to yet reports no line at all, +// which is the state of every backup service on this account — all nine agents +// are NOT_INSTALLED, so nothing has been stored. "none yet" says that; an empty +// cell would read as a column that failed to fill. +func usageOf(entries []usageEntry, serviceID int) string { + if entries == nil { + return "unknown" + } + + var parts []string + for _, entry := range entries { + if entry.ServiceID != serviceID { + continue + } + + for _, element := range entry.Elements { + quantity := 0.0 + for _, detail := range element.Details { + quantity += detail.Quantity + } + parts = append(parts, fmt.Sprintf("%s×%g", element.PlanCode, quantity)) + } + + if entry.Price != nil && entry.Price.Text != "" { + parts = append(parts, entry.Price.Text) + } + } + + if len(parts) == 0 { + return "none yet" + } + + sort.Strings(parts) + + return strings.Join(parts, ", ") +} diff --git a/internal/services/backupservices/billing_test.go b/internal/services/backupservices/billing_test.go new file mode 100644 index 000000000..e7ed4f979 --- /dev/null +++ b/internal/services/backupservices/billing_test.go @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2026 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package backupservices + +import ( + "strings" + "testing" +) + +// Pay-as-you-go storage nothing has written to yet reports no line at all, and +// that is the state of every backup service on this account. An empty cell +// would read as a column that failed to fill; "none yet" is the fact. +func TestNoConsumptionYetIsSaidAndNotLeftBlank(t *testing.T) { + if got := usageOf([]usageEntry{}, 69737222); got != "none yet" { + t.Fatalf("got %q", got) + } +} + +// Consumption that could not be read is not consumption of zero. Printing +// "none yet" there would answer a question the command failed to ask. +func TestUnreadableConsumptionIsNotZeroConsumption(t *testing.T) { + if got := usageOf(nil, 69737222); got != "unknown" { + t.Fatalf("got %q", got) + } +} + +// A service that has consumed something says what, and how much. +func TestConsumptionIsReportedPerPlan(t *testing.T) { + entries := []usageEntry{ + {ServiceID: 42, Elements: []struct { + PlanCode string `json:"planCode"` + Details []struct { + Quantity float64 `json:"quantity"` + UniqueID string `json:"uniqueId"` + } `json:"details"` + }{{ + PlanCode: "backup-vault-paygo", + Details: []struct { + Quantity float64 `json:"quantity"` + UniqueID string `json:"uniqueId"` + }{{Quantity: 1.5}, {Quantity: 2.5}}, + }}}, + // Another service's usage must not leak into this line. + {ServiceID: 99, Elements: []struct { + PlanCode string `json:"planCode"` + Details []struct { + Quantity float64 `json:"quantity"` + UniqueID string `json:"uniqueId"` + } `json:"details"` + }{{PlanCode: "somebody-else"}}}, + } + + got := usageOf(entries, 42) + if !strings.Contains(got, "backup-vault-paygo×4") { + t.Fatalf("the quantities of one plan are summed, got %q", got) + } + if strings.Contains(got, "somebody-else") { + t.Fatalf("another service's usage leaked in: %q", got) + } +} From 29b3345131900ccf7f722be89f409243dc8c2db2 Mon Sep 17 00:00:00 2001 From: Denis Date: Thu, 20 Aug 2026 13:12:11 +0200 Subject: [PATCH 05/13] test(backup-services): stop using a real server address as a fixture The IP of the machine these tests were written against ended up in the fixtures, five times. It is not a secret -- it is in the DNS name of the server -- but it ties this repository to one account for no benefit, and the same habit put a live migration token in a test once already (fixed in #257). Replaced with 203.0.113.7, which RFC 5737 reserves for documentation. Signed-off-by: Denis --- internal/cmd/backupservices_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 a91829e8552d3927620e69a2b6e63465278d4454 Mon Sep 17 00:00:00 2001 From: Denis Hamon Date: Thu, 20 Aug 2026 16:15:00 +0200 Subject: [PATCH 06/13] 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/baremetal_tag_test.go | 4 ++-- internal/services/baremetal/tags_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cmd/baremetal_tag_test.go b/internal/cmd/baremetal_tag_test.go index 93f1e8edb..c166763df 100644 --- a/internal/cmd/baremetal_tag_test.go +++ b/internal/cmd/baremetal_tag_test.go @@ -37,7 +37,7 @@ func captureTagQuery(seen *map[string][]map[string]any) { return httpmock.NewStringResponse(200, `[{"id":"ns1.example"}]`), nil }) httpmock.RegisterResponder(http.MethodGet, serversV1+"/ns1.example", - httpmock.NewStringResponder(200, `{"name":"ns1.example","datacenter":"rbx8","region":"eu-west-rbx","os":"debian12","state":"ok","iam":{"displayName":"Paperclip"}}`)) + httpmock.NewStringResponder(200, `{"name":"ns1.example","datacenter":"rbx8","region":"eu-west-rbx","os":"debian12","state":"ok","iam":{"displayName":"Build runner"}}`)) } // The narrowing is asked of the API, in the shape the API takes. @@ -76,7 +76,7 @@ func (ms *MockSuite) TestBaremetalListWithoutTagDoesNotTouchV2(assert, require * httpmock.RegisterResponder(http.MethodGet, serversV1, httpmock.NewStringResponder(200, `["ns1.example"]`)) httpmock.RegisterResponder(http.MethodGet, serversV1+"/ns1.example", - httpmock.NewStringResponder(200, `{"name":"ns1.example","datacenter":"rbx8","region":"eu-west-rbx","os":"debian12","state":"ok","iam":{"displayName":"Paperclip"}}`)) + httpmock.NewStringResponder(200, `{"name":"ns1.example","datacenter":"rbx8","region":"eu-west-rbx","os":"debian12","state":"ok","iam":{"displayName":"Build runner"}}`)) out, err := cmd.Execute("baremetal", "list") diff --git a/internal/services/baremetal/tags_test.go b/internal/services/baremetal/tags_test.go index f091cf2ac..39f0e75a2 100644 --- a/internal/services/baremetal/tags_test.go +++ b/internal/services/baremetal/tags_test.go @@ -106,7 +106,7 @@ func TestAValueIsTakenWhole(t *testing.T) { // Several comparisons on one key are what the API's own shape allows: the // parameter maps a key to a list of filters, not to one. func TestSeveralComparisonsOnOneKeyAccumulate(t *testing.T) { - filters, err := parseTagFilters([]string{"owner:NEQ=Denis", "owner:NEQ=Yaniv"}) + filters, err := parseTagFilters([]string{"owner:NEQ=Denis", "owner:NEQ=bob"}) if err != nil { t.Fatalf("unexpected refusal: %s", err) } From fc83be31143a00b681767f9d4ec6a83fa8f2af3e Mon Sep 17 00:00:00 2001 From: Denis Hamon Date: Thu, 20 Aug 2026 16:30:02 +0200 Subject: [PATCH 07/13] chore: no account data as test fixtures (case variants) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first sweep replaced only the exact spelling. A test that checks a name can be copied back without reproducing its case carried the uppercase form of a real hostname, which therefore survived — and, once its neighbour was replaced, made that test fail. A real value does not stop being one because one of its spellings was removed. Signed-off-by: Denis Hamon Co-Authored-By: Claude Opus 5 (1M context) --- internal/services/baremetal/logs_test.go | 2 +- internal/services/ip/move_test.go | 2 +- internal/services/vrack/attach_test.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 06e7d796121adb8b22602ef9b4d2df51b9b690b7 Mon Sep 17 00:00:00 2001 From: Denis Hamon Date: Thu, 20 Aug 2026 22:49:46 +0200 Subject: [PATCH 08/13] fix(backup-services): --filter was accepted, documented, and ignored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten commands of this group registered the flag and rendered their rows directly: tenant, vault, bucket, vspc, policies, agents, billing, licenses, licence servers, and the per-server agent view. withFilterFlag only binds --filter to flags.GenericFilters; display.RenderTable does not read it, and ManageListRequest — which normally passes it to filters.FilterLines — is not on this path because the rows are assembled here. So the flag was accepted by cobra, written into doc/ by docgen, and inert. An operator reading a list they believe was narrowed is worse served than one who was never offered the flag. These go through common.RenderFilteredTable, added for the same defect earlier in the stack. The test asserts the row the filter EXCLUDES is absent, not merely that the kept one is present: the second passes just as well with the filtering removed. Found by a review pass over this branch, then confirmed by an audit of every withFilterFlag call site in the tree — twenty-one commands this series added carried the same defect. Signed-off-by: Denis Hamon Co-Authored-By: Claude Opus 5 (1M context) --- internal/cmd/backupservices_test.go | 21 +++++++++++++ .../services/backupservices/backupservices.go | 31 +++++++++---------- internal/services/backupservices/billing.go | 6 ++-- internal/services/baremetal/backupagent.go | 3 +- 4 files changed, 39 insertions(+), 22 deletions(-) diff --git a/internal/cmd/backupservices_test.go b/internal/cmd/backupservices_test.go index 4915aca66..72347fa56 100644 --- a/internal/cmd/backupservices_test.go +++ b/internal/cmd/backupservices_test.go @@ -363,3 +363,24 @@ func (ms *MockSuite) TestBackupBillingKeepsGoingWhenAResourceHasNoService(assert assert.Cmp(out, td.Contains("tenant"), "the table is still printed") assert.Cmp(out, td.Contains("—"), "and the missing price reads as missing") } + +// --filter is registered on ten commands of this group and reached none of +// them. The assertion that carries this test is the absence of the excluded +// vault: asserting only the kept one would pass with no filtering at all. +func (ms *MockSuite) TestBackupVaultListIsFiltered(assert, require *td.T) { + registerOneTenant() + httpmock.RegisterResponder(http.MethodGet, backupTenant+"/vault", + httpmock.NewStringResponder(200, `[ + {"id":"v-1","resourceStatus":"READY","targetSpec":{"name":"vault-sbg"}, + "currentState":{"name":"vault-sbg","type":"PAYGO","vspcTenants":["s-1"],"buckets":[]}, + "currentTasks":[]}, + {"id":"v-2","resourceStatus":"ERROR","targetSpec":{"name":"vault-rbx"}, + "currentState":{"name":"vault-rbx","type":"PAYGO","vspcTenants":["s-1"],"buckets":[]}, + "currentTasks":[]}]`)) + + out, err := cmd.Execute("backup-services", "vault", "list", "--filter", `id=="v-2"`) + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("vault-rbx")) + assert.Cmp(out, td.Not(td.Contains("vault-sbg")), "the vault the filter excludes must not be printed") +} diff --git a/internal/services/backupservices/backupservices.go b/internal/services/backupservices/backupservices.go index e75bce8e5..ac9fea87f 100644 --- a/internal/services/backupservices/backupservices.go +++ b/internal/services/backupservices/backupservices.go @@ -39,13 +39,13 @@ func ListTenants(_ *cobra.Command, _ []string) { return } - display.RenderTable(rowsOf(tenants, func(r resource) map[string]any { + common.RenderFilteredTable(rowsOf(tenants, func(r resource) map[string]any { state := r.CurrentState return map[string]any{ "vaults": countOf(state["vaults"]), "vspcTenants": countOf(state["vspcTenants"]), } - }), []string{"id", "name", "resourceStatus status", "vaults", "vspcTenants", "tasks"}, &flags.OutputFormatConfig) + }), []string{"id", "name", "resourceStatus status", "vaults", "vspcTenants", "tasks"}) } // ShowTenant reads one backup tenant, resolved when the account has only one. @@ -79,15 +79,14 @@ func ListVaults(_ *cobra.Command, _ []string) { // tenant's own view of its vaults, and reading them off the vault resource // answers 0 on an account where the real number is 9 — a column that is // wrong reads worse than a column that is missing. - display.RenderTable(rowsOf(vaults, func(r resource) map[string]any { + common.RenderFilteredTable(rowsOf(vaults, func(r resource) map[string]any { return map[string]any{ "buckets": countOf(r.CurrentState["buckets"]), "vspcTenants": countOf(r.CurrentState["vspcTenants"]), "type": r.CurrentState["type"], "regions": strings.Join(bucketRegions(r), ", "), } - }), []string{"id", "name", "resourceStatus status", "regions", "type", "buckets", "vspcTenants", "tasks"}, - &flags.OutputFormatConfig) + }), []string{"id", "name", "resourceStatus status", "regions", "type", "buckets", "vspcTenants", "tasks"}) } // ShowVault reads one vault. @@ -120,8 +119,7 @@ func ListBuckets(_ *cobra.Command, args []string) { return } - display.RenderTable(buckets, []string{"id", "name", "region", "performance", "role", "status"}, - &flags.OutputFormatConfig) + common.RenderFilteredTable(buckets, []string{"id", "name", "region", "performance", "role", "status"}) } // EditVault renames a vault. @@ -149,7 +147,7 @@ func ListVspc(_ *cobra.Command, _ []string) { return } - display.RenderTable(rowsOf(vspcs, func(r resource) map[string]any { + common.RenderFilteredTable(rowsOf(vspcs, func(r resource) map[string]any { state := r.CurrentState addons, _ := state["enabledAddons"].([]any) names := make([]string, 0, len(addons)) @@ -164,8 +162,7 @@ func ListVspc(_ *cobra.Command, _ []string) { "addons": strings.Join(names, ", "), "agents": countOf(state["backupAgents"]), } - }), []string{"id", "name", "resourceStatus status", "vspcType type", "region", "agents", "addons", "tasks"}, - &flags.OutputFormatConfig) + }), []string{"id", "name", "resourceStatus status", "vspcType type", "region", "agents", "addons", "tasks"}) } // ShowVspc reads one VSPC tenant. @@ -230,7 +227,7 @@ func ListPolicies(_ *cobra.Command, _ []string) { rows = append(rows, map[string]any{"policy": policy}) } - display.RenderTable(rows, []string{"policy"}, &flags.OutputFormatConfig) + common.RenderFilteredTable(rows, []string{"policy"}) } // PoliciesOf lists the retention policies of a VSPC tenant. @@ -310,8 +307,8 @@ func ListLicenses(_ *cobra.Command, _ []string) { return } - display.RenderTable(rowsOf(licenses, nil), - []string{"id", "name", "resourceStatus status", "tasks"}, &flags.OutputFormatConfig) + common.RenderFilteredTable(rowsOf(licenses, nil), + []string{"id", "name", "resourceStatus status", "tasks"}) } // ListLicenseServers shows the backup servers driven by one licence. @@ -330,12 +327,12 @@ func ListLicenseServers(_ *cobra.Command, args []string) { return } - display.RenderTable(rowsOf(servers, func(r resource) map[string]any { + common.RenderFilteredTable(rowsOf(servers, func(r resource) map[string]any { return map[string]any{ "licenseType": r.TargetSpec["licenseType"], "displayName": r.TargetSpec["displayName"], } - }), []string{"id", "displayName", "licenseType", "status", "tasks"}, &flags.OutputFormatConfig) + }), []string{"id", "displayName", "licenseType", "status", "tasks"}) } // editNamedResource sends the one field these two resources accept. @@ -537,6 +534,6 @@ func ListAgents(_ *cobra.Command, _ []string) { return fmt.Sprint(rows[i]["protects"]) < fmt.Sprint(rows[j]["protects"]) }) - display.RenderTable(rows, - []string{"protects", "status", "policy", "type", "ips", "id", "tasks"}, &flags.OutputFormatConfig) + common.RenderFilteredTable(rows, + []string{"protects", "status", "policy", "type", "ips", "id", "tasks"}) } diff --git a/internal/services/backupservices/billing.go b/internal/services/backupservices/billing.go index a0fb69c8e..cdda3ca03 100644 --- a/internal/services/backupservices/billing.go +++ b/internal/services/backupservices/billing.go @@ -14,6 +14,7 @@ import ( "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/services/common" "github.com/spf13/cobra" "golang.org/x/sync/errgroup" ) @@ -105,9 +106,8 @@ func ShowBilling(_ *cobra.Command, _ []string) { return } - display.RenderTable(rows, - []string{"kind", "name", "plan", "price", "period", "consumption", "renew", "nextBillingDate", "state"}, - &flags.OutputFormatConfig) + common.RenderFilteredTable(rows, + []string{"kind", "name", "plan", "price", "period", "consumption", "renew", "nextBillingDate", "state"}) } // billableResources lists everything of the backup product that has a price. diff --git a/internal/services/baremetal/backupagent.go b/internal/services/baremetal/backupagent.go index dcc3956a9..d520e113e 100644 --- a/internal/services/baremetal/backupagent.go +++ b/internal/services/baremetal/backupagent.go @@ -130,8 +130,7 @@ func ShowBackupAgent(_ *cobra.Command, args []string) { }) } - display.RenderTable(rows, []string{"id", "displayName", "status", "policy", "ips", "type"}, - &flags.OutputFormatConfig) + common.RenderFilteredTable(rows, []string{"id", "displayName", "status", "policy", "ips", "type"}) } // CreateBackupAgent provisions a backup agent for a server. From b7ec146e994d552e5fcd9c5cede7a9c1bdeba40f Mon Sep 17 00:00:00 2001 From: Denis Hamon Date: Fri, 21 Aug 2026 09:32:18 +0200 Subject: [PATCH 09/13] fix(baremetal): backup-agent refused with a flag it did not accept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tenant and the VSPC tenant are both resolved when the account holds only one, and named with --tenant / --vspc when it holds several. That resolution is shared: `backup-services` uses it, and so does `baremetal backup-agent`, which reaches the same resources starting from the machine they protect. The two flags were only registered on the `backup-services` tree. On an account with more than one backup tenant — or more than one VSPC tenant inside it — every `baremetal backup-agent` command therefore failed with "name one with --tenant", and cobra then answered "unknown flag: --tenant". The whole subtree was unreachable, and its refusal told the operator to do something the command could not do. Verified against the built binary before and after. Registered through one helper so the two trees cannot drift apart again, and the comment says why it is a function rather than two lines. Two tests. The first runs the command rather than inspecting a FlagSet, because what matters is what cobra accepts on the command line; the existing test asserted only that the refusal *named* the flag, which is what let this through. The second pins both trees to the same pair. A compiling sabotage that drops the registration turns both red. Signed-off-by: Denis Hamon Co-Authored-By: Claude Opus 5 (1M context) --- doc/ovhcloud_baremetal_backup-agent.md | 4 +- doc/ovhcloud_baremetal_backup-agent_create.md | 2 + doc/ovhcloud_baremetal_backup-agent_delete.md | 2 + doc/ovhcloud_baremetal_backup-agent_edit.md | 2 + doc/ovhcloud_baremetal_backup-agent_show.md | 2 + internal/cmd/backupservices.go | 23 +++++-- internal/cmd/backupservices_test.go | 60 +++++++++++++++++++ internal/cmd/baremetal.go | 6 ++ 8 files changed, 94 insertions(+), 7 deletions(-) diff --git a/doc/ovhcloud_baremetal_backup-agent.md b/doc/ovhcloud_baremetal_backup-agent.md index ad8a90522..a91792466 100644 --- a/doc/ovhcloud_baremetal_backup-agent.md +++ b/doc/ovhcloud_baremetal_backup-agent.md @@ -5,7 +5,9 @@ Manage the Veeam backup agent protecting this server ### Options ``` - -h, --help help for backup-agent + -h, --help help for backup-agent + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) ``` ### Options inherited from parent commands diff --git a/doc/ovhcloud_baremetal_backup-agent_create.md b/doc/ovhcloud_baremetal_backup-agent_create.md index f449f5428..668bbfd77 100644 --- a/doc/ovhcloud_baremetal_backup-agent_create.md +++ b/doc/ovhcloud_baremetal_backup-agent_create.md @@ -41,6 +41,8 @@ ovhcloud baremetal backup-agent create [flags] --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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) ``` ### SEE ALSO diff --git a/doc/ovhcloud_baremetal_backup-agent_delete.md b/doc/ovhcloud_baremetal_backup-agent_delete.md index 500ba1d25..ab689148e 100644 --- a/doc/ovhcloud_baremetal_backup-agent_delete.md +++ b/doc/ovhcloud_baremetal_backup-agent_delete.md @@ -32,6 +32,8 @@ ovhcloud baremetal backup-agent delete [flags] --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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) ``` ### SEE ALSO diff --git a/doc/ovhcloud_baremetal_backup-agent_edit.md b/doc/ovhcloud_baremetal_backup-agent_edit.md index a9d922555..2de129933 100644 --- a/doc/ovhcloud_baremetal_backup-agent_edit.md +++ b/doc/ovhcloud_baremetal_backup-agent_edit.md @@ -34,6 +34,8 @@ ovhcloud baremetal backup-agent edit [flags] --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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) ``` ### SEE ALSO diff --git a/doc/ovhcloud_baremetal_backup-agent_show.md b/doc/ovhcloud_baremetal_backup-agent_show.md index 513b021df..a0f3d67d6 100644 --- a/doc/ovhcloud_baremetal_backup-agent_show.md +++ b/doc/ovhcloud_baremetal_backup-agent_show.md @@ -36,6 +36,8 @@ ovhcloud baremetal backup-agent show [flags] --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 + --tenant string Backup tenant to work on (default: the only one on the account) + --vspc string VSPC tenant to work on (default: the only one in the backup tenant) ``` ### SEE ALSO diff --git a/internal/cmd/backupservices.go b/internal/cmd/backupservices.go index de97761b5..2f632744f 100644 --- a/internal/cmd/backupservices.go +++ b/internal/cmd/backupservices.go @@ -22,12 +22,7 @@ func init() { "`ovhcloud baremetal backup-agent`.", } - // Both levels of the hierarchy are UUIDs, and both are resolved when there - // is only one. These flags exist for the accounts where there is not. - backupCmd.PersistentFlags().StringVar(&backupservices.Tenant, "tenant", "", - "Backup tenant to work on (default: the only one on the account)") - backupCmd.PersistentFlags().StringVar(&backupservices.Vspc, "vspc", "", - "VSPC tenant to work on (default: the only one in the backup tenant)") + addBackupTenantFlags(backupCmd) // Tenants backupTenantCmd := &cobra.Command{ @@ -177,3 +172,19 @@ func init() { rootCmd.AddCommand(backupCmd) } + +// addBackupTenantFlags registers the two levels of the hierarchy on a command +// tree. +// +// Both levels are UUIDs and both are resolved when there is only one, so these +// flags exist for the accounts where there is not. It is a function and not two +// lines repeated because it is needed on two trees: `backup-services`, and +// `baremetal backup-agent`, which reaches the same resources from the machine +// they protect. Registering them on only one of the two made the resolution +// refuse on a multi-tenant account and name a flag the command did not have. +func addBackupTenantFlags(cmd *cobra.Command) { + cmd.PersistentFlags().StringVar(&backupservices.Tenant, "tenant", "", + "Backup tenant to work on (default: the only one on the account)") + cmd.PersistentFlags().StringVar(&backupservices.Vspc, "vspc", "", + "VSPC tenant to work on (default: the only one in the backup tenant)") +} diff --git a/internal/cmd/backupservices_test.go b/internal/cmd/backupservices_test.go index 72347fa56..874c04e58 100644 --- a/internal/cmd/backupservices_test.go +++ b/internal/cmd/backupservices_test.go @@ -89,6 +89,66 @@ func (ms *MockSuite) TestBackupRefusesToChooseBetweenTenants(assert, require *td assert.Cmp(err.Error(), td.Contains("second")) } +// The refusal above names --tenant, and that is only useful on a command that +// takes --tenant. The two flags were registered on the `backup-services` tree +// alone, while the agents are reached from `baremetal backup-agent` — which +// resolves the same two levels and produces the same refusal. On an account +// with more than one tenant, every command of that subtree therefore failed, +// told the operator to name one, and then rejected the flag it had just named: +// the whole subtree was unreachable. +// +// Asserted by running the command rather than by inspecting a FlagSet, because +// what matters is what cobra accepts on the command line. +func (ms *MockSuite) TestBackupAgentTakesTheFlagItsRefusalNames(assert, require *td.T) { + httpmock.RegisterResponder(http.MethodGet, backupTenants, + httpmock.NewStringResponder(200, `[{"id":"t-1","targetSpec":{"name":"first"}},{"id":"t-2","targetSpec":{"name":"second"}}]`)) + + // Without it: the refusal, naming the flag. + _, err := cmd.Execute("baremetal", "backup-agent", "show", "ns1.example") + + require.CmpError(err) + assert.Cmp(err.Error(), td.Contains("--tenant")) + + // With it: the flag is accepted, and the command gets past that level. + cmd.PostExecute() + httpmock.RegisterResponder(http.MethodGet, backupTenants+"/t-2/vspc", + httpmock.NewStringResponder(200, `[{"id":"s-9","targetSpec":{"name":"vspc-second"}, + "currentState":{"name":"vspc-second","backupAgents":[]},"currentTasks":[]}]`)) + registerServerForAgent() + httpmock.RegisterResponder(http.MethodGet, backupTenants+"/t-2/vspc/s-9/backupAgent", + httpmock.NewStringResponder(200, `[]`)) + + _, err = cmd.Execute("baremetal", "backup-agent", "show", "ns1.example", "--tenant", "t-2") + + if err != nil { + assert.Cmp(err.Error(), td.Not(td.Contains("unknown flag")), + "the flag the refusal names must exist on the command that refused") + assert.Cmp(err.Error(), td.Not(td.Contains("--tenant")), + "and naming it must settle the question, not repeat it") + } +} + +// Both trees resolve the same two levels, so both must offer the same two +// flags. Registered through one helper for that reason; this pins it. +func (ms *MockSuite) TestBothBackupTreesOfferTheSameTwoFlags(assert, require *td.T) { + for _, path := range [][]string{ + {"backup-services", "policies"}, + {"baremetal", "backup-agent", "show"}, + {"baremetal", "backup-agent", "create"}, + {"baremetal", "backup-agent", "edit"}, + {"baremetal", "backup-agent", "delete"}, + } { + found, _, err := cmd.GetRootCommand().Find(path) + require.CmpNoError(err) + for _, flag := range []string{"tenant", "vspc"} { + local := found.Flags().Lookup(flag) + inherited := found.InheritedFlags().Lookup(flag) + assert.True(local != nil || inherited != nil, + "%v must accept --%s", path, flag) + } + } +} + // An account with no backup tenant is a state, and saying so beats a stack of // failed lookups underneath. func (ms *MockSuite) TestBackupSaysWhenThereIsNoTenant(assert, require *td.T) { diff --git a/internal/cmd/baremetal.go b/internal/cmd/baremetal.go index 469f07479..a4d1ecf13 100644 --- a/internal/cmd/baremetal.go +++ b/internal/cmd/baremetal.go @@ -927,6 +927,12 @@ sending. --dry-run prints the whole message instead of sending it.`, Use: "backup-agent", Short: "Manage the Veeam backup agent protecting this server", } + // The resolution of the tenant and the VSPC tenant lives in the same place + // for both trees, so its refusal — "name one with --tenant" — has to be + // actionable from both. Without these, every command here was unusable on an + // account holding more than one tenant, and said so by naming a flag cobra + // then rejected as unknown. + addBackupTenantFlags(baremetalBackupAgentCmd) baremetalCmd.AddCommand(baremetalBackupAgentCmd) baremetalBackupAgentCmd.AddCommand(withFilterFlag(&cobra.Command{ From db0d674398bd3b5b2a7c434152998c8659d4ea20 Mon Sep 17 00:00:00 2001 From: Denis Hamon Date: Fri, 21 Aug 2026 14:01:32 +0200 Subject: [PATCH 10/13] fix(backup-services): three ways the backup surface reported success it had not seen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The status column was always empty.** `licenses servers` asked for the column "status", while rowsOf writes the value under "resourceStatus" — the two-word form in every other list here is key first, header second. So the column was blank on every line, and a blank status column reads as "nothing to report" on exactly the resources somebody is checking on. **`backup-agent --wait` could not see a failure.** This generation of the API has no task route and no failed value in its status enumeration: currentTasks is the only place a failure appears. The sibling package reads it; this one did not have the field at all, so a creation whose task ended in ERROR came back as a created agent. Measured on this account: three BACKUP_VAULT_CREATE and one VSPC_AGENT_UPDATE sat in ERROR for five minutes on resources every status field called READY. The tasks are read now, in both directions — a failed removal leaves the agent in place, so the loop would otherwise poll a corpse for its whole timeout and then blame its own patience. WAITING_USER_INPUT is treated as terminal: it is not an error, but only a person can clear it, and holding the terminal open for that tells nobody anything. An empty status is no longer taken for a finished one either: backupAgentTransient has no entry for "", so an answer carrying no status at all counted as settled. **Billing emitted two JSON documents.** When the consumption read failed, ShowBilling printed an OutputInfo document and then the table, so `-o json` put two documents on stdout back to back — which no parser accepts. A script asking for the prices got a decode error precisely on the runs where half the data was missing, defeating the whole point of still answering. The sentence goes to the log, which is stderr, and the fact goes into the consumption cell of every row, where a script will find it. "unreadable" and "none yet" are kept apart: an empty usage list is the normal answer for a freshly ordered vault. Found while testing that: currentUsage was a sync.OnceValues over a live API read — a process-wide cache over data that changes. The first call decided the answer for the life of the process, so a transient 500 on that first read made every later billing command in the same process report the consumption as unreadable. Every other once-value in this repository wraps the embedded schema, which cannot change. It is a plain function now. This is also why one test passed alone and failed in its suite, which is how it was found. Nine tests, seven sabotages, seven reds. Two of the seven first came back green for the wrong reason: one broke the build, and the two-documents one could not be seen at all through the return value of Execute, because every writer overwrites display.ResultString — the two documents only exist on real stdout. That test now captures os.Stdout, which is the only place this defect was ever visible. Signed-off-by: Denis Hamon Co-Authored-By: Claude Opus 5 (1M context) --- internal/cmd/backupservices_test.go | 126 ++++++++++++++++++ .../services/backupservices/backupservices.go | 7 +- internal/services/backupservices/billing.go | 48 +++++-- .../services/backupservices/billing_test.go | 13 +- internal/services/baremetal/backupagent.go | 81 ++++++++++- .../services/baremetal/backupagent_test.go | 109 ++++++++++++++- 6 files changed, 365 insertions(+), 19 deletions(-) diff --git a/internal/cmd/backupservices_test.go b/internal/cmd/backupservices_test.go index 874c04e58..83a1624b6 100644 --- a/internal/cmd/backupservices_test.go +++ b/internal/cmd/backupservices_test.go @@ -6,7 +6,9 @@ package cmd_test import ( "encoding/json" + "io" "net/http" + "os" "strings" "github.com/jarcoal/httpmock" @@ -444,3 +446,127 @@ func (ms *MockSuite) TestBackupVaultListIsFiltered(assert, require *td.T) { assert.Cmp(out, td.Contains("vault-rbx")) assert.Cmp(out, td.Not(td.Contains("vault-sbg")), "the vault the filter excludes must not be printed") } + +// A failed consumption read used to print an OutputInfo document and then the +// table, so `-o json` put two JSON documents on stdout back to back — which no +// parser accepts. A script asking for the prices therefore got a decode error +// precisely on the runs where half the data was missing. +// +// The whole point of losing only the missing half was to still answer, so the +// answer has to be parseable. This decodes the output rather than pattern- +// matching it, which is the only assertion that catches a second document. +func (ms *MockSuite) TestBackupBillingStaysOneJsonDocumentWhenUsageFails(assert, require *td.T) { + registerOneTenant() + httpmock.RegisterResponder(http.MethodGet, backupTenant+"/vault", + httpmock.NewStringResponder(200, `[]`)) + httpmock.RegisterResponder(http.MethodGet, "https://eu.api.ovh.com/v1/me/consumption/usage/current", + httpmock.NewStringResponder(500, `{"message":"gateway is having a day"}`)) + for name, id := range map[string]string{"t-1": "111", "s-1": "222"} { + httpmock.RegisterResponder(http.MethodGet, "https://eu.api.ovh.com/v1/services?resourceName="+name, + httpmock.NewStringResponder(200, "["+id+"]")) + } + httpmock.RegisterResponder(http.MethodGet, `=~^https://eu\.api\.ovh\.com/v1/services/\d+$`, + httpmock.NewStringResponder(200, `{"serviceId":333,"billing":{"nextBillingDate":"2026-09-01T00:00:00Z", + "plan":{"code":"backup-vault-paygo","invoiceName":"Backup vault"}, + "pricing":{"duration":"P1M","price":{"currencyCode":"EUR","text":"0.00 €","value":0}}, + "renew":{"current":{"mode":"automatic"}},"lifecycle":{"current":{"state":"active"}}}}`)) + + // Captured from os.Stdout and not from what Execute returns: every writer + // sets display.ResultString, so the second document overwrites the first + // there and the return value shows only the last one. The two documents only + // exist where they matter, on the actual output, which is why this defect + // survived a suite full of tests reading the return value. + printed := captureStdout(require, func() { + _, err := cmd.Execute("backup-services", "billing", "-o", "json") + require.CmpNoError(err) + }) + + var decoded []map[string]any + require.CmpNoError(json.Unmarshal([]byte(printed), &decoded), + "stdout has to be one JSON document: %s", printed) + require.Cmp(len(decoded) > 0, true, "positive control: rows were produced") + + // And the fact travels where a script will find it, not only in a sentence + // on stderr. + assert.Cmp(decoded[0]["consumption"], "unreadable") +} + +// captureStdout returns everything the given function prints to os.Stdout. +// +// Needed because display writes to stdout directly and only remembers the last +// thing it wrote, so a command that emits two documents looks like one from the +// return value of Execute. +func captureStdout(require *td.T, run func()) string { + reader, writer, err := os.Pipe() + require.CmpNoError(err) + + saved := os.Stdout + os.Stdout = writer + + done := make(chan string, 1) + go func() { + var buffer strings.Builder + _, _ = io.Copy(&buffer, reader) + done <- buffer.String() + }() + + run() + + os.Stdout = saved + _ = writer.Close() + printed := <-done + _ = reader.Close() + + return printed +} + +// The status enumeration of this API generation has no failed value, and there is +// no task route: currentTasks is the only place a failure appears. The sibling +// package reads it; this one did not, so `create --wait` returned an agent whose +// provisioning had failed and called it created. +// +// Measured on this account: three BACKUP_VAULT_CREATE and one VSPC_AGENT_UPDATE +// sat in ERROR for five minutes on resources every status field called READY. +func (ms *MockSuite) TestBaremetalBackupAgentCreateReportsAFailedTask(assert, require *td.T) { + registerOneTenant() + registerServerForAgent() + httpmock.RegisterResponder(http.MethodPost, backupAgents, + httpmock.NewStringResponder(200, ``)) + httpmock.RegisterResponder(http.MethodGet, backupAgents, + httpmock.ResponderFromMultipleResponses([]*http.Response{ + httpmock.NewStringResponse(200, `[]`), + httpmock.NewStringResponse(200, `[{"id":"a-1","status":"NOT_INSTALLED", + "targetSpec":{"name":"agent-ns1.example"}, + "currentState":{"productResourceName":"ns1.example"}, + "currentTasks":[{"id":"tk-1","type":"VSPC_AGENT_CREATE","status":"ERROR","errors":[]}]}]`), + })) + + _, err := cmd.Execute("baremetal", "backup-agent", "create", "ns1.example", "--wait", "--yes") + + require.CmpError(err, "a failed task is not a created agent") + assert.Cmp(err.Error(), td.Contains("VSPC_AGENT_CREATE ERROR"), + "the operation is named, because ERROR alone answers 'error doing what' with nothing") + assert.Cmp(err.Error(), td.Not(td.Contains("stopped waiting")), + "it stopped because the task failed, not because it ran out of patience") +} + +// rowsOf writes the value under the key "resourceStatus"; the column list asked +// for "status", which is a key no row has, so the column was empty on every +// line. An empty status column reads as "nothing to report" on exactly the +// resources somebody is checking on. +// +// The two-word form is the repository's own syntax: key first, header second. +func (ms *MockSuite) TestBackupLicenseServersFillTheStatusColumn(assert, require *td.T) { + registerOneTenant() + httpmock.RegisterResponder(http.MethodGet, backupVspc+"/backupLicenses/l-1/backupServer", + httpmock.NewStringResponder(200, `[{"id":"bs-1","resourceStatus":"OUT_OF_SYNC", + "targetSpec":{"displayName":"veeam-rbx-1","licenseType":"RENTAL"}, + "currentState":{"displayName":"veeam-rbx-1"},"currentTasks":[]}]`)) + + out, err := cmd.Execute("backup-services", "licenses", "servers", "l-1") + + require.CmpNoError(err) + assert.Cmp(out, td.Contains("veeam-rbx-1"), "positive control: the row is rendered") + assert.Cmp(out, td.Contains("OUT_OF_SYNC"), "and its status is in it") + assert.Cmp(out, td.Contains("status"), "under a header still called status") +} diff --git a/internal/services/backupservices/backupservices.go b/internal/services/backupservices/backupservices.go index ac9fea87f..bdff764fa 100644 --- a/internal/services/backupservices/backupservices.go +++ b/internal/services/backupservices/backupservices.go @@ -332,7 +332,12 @@ func ListLicenseServers(_ *cobra.Command, args []string) { "licenseType": r.TargetSpec["licenseType"], "displayName": r.TargetSpec["displayName"], } - }), []string{"id", "displayName", "licenseType", "status", "tasks"}) + // "resourceStatus status" and not "status": rowsOf writes the value under + // resourceStatus, and the second word is only the header. Asking for + // "status" asked for a key no row has, so the column was empty on every + // line — and an empty status column reads as "nothing to report" on + // exactly the resources somebody is checking on. + }), []string{"id", "displayName", "licenseType", "resourceStatus status", "tasks"}) } // editNamedResource sends the one field these two resources accept. diff --git a/internal/services/backupservices/billing.go b/internal/services/backupservices/billing.go index cdda3ca03..ed213bf31 100644 --- a/internal/services/backupservices/billing.go +++ b/internal/services/backupservices/billing.go @@ -6,10 +6,10 @@ package backupservices import ( "fmt" + "log" "net/url" "sort" "strings" - "sync" "github.com/ovh/ovhcloud-cli/internal/display" "github.com/ovh/ovhcloud-cli/internal/flags" @@ -50,15 +50,22 @@ func ShowBilling(_ *cobra.Command, _ []string) { return } + // Consumption is one half of the answer and the prices are the other. Losing + // the half that failed is better than answering nothing, so the table is + // still printed. + // + // But it is printed as ONE document. This used to emit an OutputInfo and then + // the table, which under -o json put two JSON documents on stdout back to + // back — something no parser accepts, so a script asking for the prices got a + // decode error precisely on the runs where half the data was missing. The + // sentence goes to the log, which is stderr, and the fact goes where a script + // will find it: in the consumption cell of every row. usage, err := currentUsage() if err != nil { - // Consumption is one half of the answer and the prices are the other. - // Losing the half that failed is better than answering nothing, so it - // is reported and the table is still printed. - display.OutputInfo(&flags.OutputFormatConfig, nil, - "🟠 Current consumption could not be read (%s); the prices below are still what is billed.", err) + log.Printf("🟠 Current consumption could not be read (%s); the prices below are still what is billed.", err) usage = nil } + usageReadable := err == nil rows := make([]map[string]any, len(resources)) group := new(errgroup.Group) @@ -93,7 +100,7 @@ func ShowBilling(_ *cobra.Command, _ []string) { row["nextBillingDate"] = billing.NextBillingDate row["renew"] = billing.Renew.Current.Mode row["state"] = billing.Lifecycle.Current.State - row["consumption"] = usageOf(usage, service.ServiceID) + row["consumption"] = usageOf(usage, service.ServiceID, usageReadable) } rows[index] = row @@ -204,14 +211,26 @@ type usageEntry struct { } `json:"price"` } -var currentUsage = sync.OnceValues(func() ([]usageEntry, error) { +// currentUsage reads this month's consumption. +// +// A plain function, not a sync.OnceValues. It was one, and a once-value over a +// live API read is a process-wide cache over data that changes: the first call +// decided the answer for the life of the process, so a transient 500 on that +// first read made every later `backup-services billing` in the same process +// report the consumption as unreadable — for as long as the process lived. The +// once-values elsewhere in this repository all wrap the embedded schema, which +// cannot change; this one wraps an account. +// +// It is also the only reason a test could pass alone and fail in its suite, which +// is how this was found. +func currentUsage() ([]usageEntry, error) { var entries []usageEntry if err := httpLib.Client.Get("/v1/me/consumption/usage/current", &entries); err != nil { return nil, err } return entries, nil -}) +} // usageOf says what a service has consumed so far this month. // @@ -219,9 +238,16 @@ var currentUsage = sync.OnceValues(func() ([]usageEntry, error) { // which is the state of every backup service on this account — all nine agents // are NOT_INSTALLED, so nothing has been stored. "none yet" says that; an empty // cell would read as a column that failed to fill. -func usageOf(entries []usageEntry, serviceID int) string { +// The last argument tells the two empty answers apart: consumption that could +// not be read, and consumption that is legitimately absent. "unknown" for both +// would have made a failed read indistinguishable from a service nobody has used +// yet — and the second is the normal state of a freshly ordered vault. +func usageOf(entries []usageEntry, serviceID int, readable bool) string { + if !readable { + return "unreadable" + } if entries == nil { - return "unknown" + return "none yet" } var parts []string diff --git a/internal/services/backupservices/billing_test.go b/internal/services/backupservices/billing_test.go index e7ed4f979..ecf506e26 100644 --- a/internal/services/backupservices/billing_test.go +++ b/internal/services/backupservices/billing_test.go @@ -13,17 +13,24 @@ import ( // that is the state of every backup service on this account. An empty cell // would read as a column that failed to fill; "none yet" is the fact. func TestNoConsumptionYetIsSaidAndNotLeftBlank(t *testing.T) { - if got := usageOf([]usageEntry{}, 69737222); got != "none yet" { + if got := usageOf([]usageEntry{}, 69737222, true); got != "none yet" { t.Fatalf("got %q", got) } } // Consumption that could not be read is not consumption of zero. Printing // "none yet" there would answer a question the command failed to ask. +// +// The two are told apart by whether the read succeeded, not by whether the list +// came back empty — an empty list is the normal answer for a freshly ordered +// vault, and on this account it is the answer for every service. func TestUnreadableConsumptionIsNotZeroConsumption(t *testing.T) { - if got := usageOf(nil, 69737222); got != "unknown" { + if got := usageOf(nil, 69737222, false); got != "unreadable" { t.Fatalf("got %q", got) } + if got := usageOf(nil, 69737222, true); got != "none yet" { + t.Fatalf("an absent list is not a failed read: got %q", got) + } } // A service that has consumed something says what, and how much. @@ -52,7 +59,7 @@ func TestConsumptionIsReportedPerPlan(t *testing.T) { }{{PlanCode: "somebody-else"}}}, } - got := usageOf(entries, 42) + got := usageOf(entries, 42, true) if !strings.Contains(got, "backup-vault-paygo×4") { t.Fatalf("the quantities of one plan are summed, got %q", got) } diff --git a/internal/services/baremetal/backupagent.go b/internal/services/baremetal/backupagent.go index d520e113e..19a0c2431 100644 --- a/internal/services/baremetal/backupagent.go +++ b/internal/services/baremetal/backupagent.go @@ -52,7 +52,10 @@ var ( BackupAgentWait bool ) -const ( +// var and not const so a test can shorten the poll, which is what the two other +// wait loops of this package already do. A wait that can only be exercised in +// five minutes is a wait nobody exercises. +var ( backupAgentPollInterval = 5 * time.Second backupAgentPollAttempts = 60 ) @@ -72,15 +75,69 @@ var backupRegions = sync.OnceValues(func() ([]string, error) { }) // backupAgent is one agent, flattened out of the resource shape. +// +// CurrentTasks is read because it is the only place this generation of the API +// reports a failure. There is no task route and no operation identifier; the +// status enumeration has no failed value at all, so an agent whose provisioning +// broke keeps a status that looks like any other. Measured on this account: +// three BACKUP_VAULT_CREATE and one VSPC_AGENT_UPDATE sat in ERROR for five +// minutes on resources every status field called READY. +// +// The sibling package reads it. This one did not, and its --wait therefore +// reported success over a failed creation. type backupAgent struct { ID string `json:"id"` Status string `json:"status"` TargetSpec map[string]any `json:"targetSpec"` CurrentState map[string]any `json:"currentState"` + CurrentTasks []agentTask `json:"currentTasks"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } +// agentTask is one operation in flight on an agent. +type agentTask struct { + ID string `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +// backupAgentTaskFailed are the two task statuses an agent does not come back +// from on its own. ERROR is over; WAITING_USER_INPUT is not an error but is just +// as terminal for a command that can only wait — polling it forever would hold +// the terminal open for something only a person can unblock. +var backupAgentTaskFailed = map[string]bool{"ERROR": true, "WAITING_USER_INPUT": true} + +// blockingTask returns the first task that will not finish by waiting. +func (a backupAgent) blockingTask() (agentTask, bool) { + for _, task := range a.CurrentTasks { + if backupAgentTaskFailed[task.Status] { + return task, true + } + } + return agentTask{}, false +} + +// describe names the operation and, when the API bothers to say, the reason. +// +// The type is what makes the status useful: "ERROR" answers "error doing what" +// with nothing. The schema declares errors on a task and the API leaves the list +// empty even on a failed one, so the reason is printed when it is there and +// nothing pretends it will be. +func (t agentTask) describe() string { + described := t.Status + if t.Type != "" { + described = t.Type + " " + t.Status + } + if len(t.Errors) > 0 && t.Errors[0].Message != "" { + described += ": " + t.Errors[0].Message + } + return described +} + func (a backupAgent) protects() string { name, _ := a.CurrentState["productResourceName"].(string) return name @@ -466,9 +523,27 @@ func waitForBackupAgent(tenant, vspc, server string, want bool) (backupAgent, er case !want && len(agents) == 0: return backupAgent{}, nil - case want && len(agents) > 0: + case len(agents) > 0: + // Asked before the status, and asked in both directions: a removal + // that failed leaves the agent in place, so the loop would otherwise + // poll a corpse for its full timeout. + if task, blocked := agents[0].blockingTask(); blocked { + return backupAgent{}, fmt.Errorf( + "the backup agent of %s did not finish: %s\n Read it with: ovhcloud baremetal backup-agent show %s", + server, task.describe(), server) + } + + if !want { + break + } + last = agents[0].Status - if !backupAgentTransient[last] { + + // An empty status is not a finished one. It used to count as settled + // — backupAgentTransient has no entry for "" — so an answer that + // carried no status at all was returned as a successfully created + // agent. + if last != "" && !backupAgentTransient[last] { return agents[0], nil } } diff --git a/internal/services/baremetal/backupagent_test.go b/internal/services/baremetal/backupagent_test.go index 42e2cc13e..d5e2e3929 100644 --- a/internal/services/baremetal/backupagent_test.go +++ b/internal/services/baremetal/backupagent_test.go @@ -4,7 +4,15 @@ package baremetal -import "testing" +import ( + "strings" + "testing" + "time" + + "github.com/jarcoal/httpmock" + "github.com/ovh/go-ovh/ovh" + httpLib "github.com/ovh/ovhcloud-cli/internal/http" +) // All nine agents of this account carry an empty policy while two retention // policies exist beside them. An empty cell reads as "not shown"; the fact is @@ -63,3 +71,102 @@ func TestOnlyTwoAgentStatusesAreTransitions(t *testing.T) { } } } + +// withAgentAPI points the shared client at httpmock and shortens the poll, so +// the timeout path is reachable in a test rather than in five minutes. +func withAgentAPI(t *testing.T, agents string) { + t.Helper() + httpmock.Activate(t) + + origClient := httpLib.Client + origInterval, origAttempts := backupAgentPollInterval, backupAgentPollAttempts + client, err := ovh.NewClient("ovh-eu", "app_key", "app_secret", "consumer_key") + if err != nil { + t.Fatalf("could not build a client: %s", err) + } + httpLib.Client = client + backupAgentPollInterval, backupAgentPollAttempts = time.Millisecond, 2 + + t.Cleanup(func() { + httpLib.Client = origClient + backupAgentPollInterval, backupAgentPollAttempts = origInterval, origAttempts + }) + + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/auth/time", + httpmock.NewStringResponder(200, "0")) + httpmock.RegisterResponder("GET", + "https://eu.api.ovh.com/v2/backupServices/tenant/t-1/vspc/s-1/backupAgent", + httpmock.NewStringResponder(200, agents)) +} + +// An empty status is not a finished one. backupAgentTransient has no entry for +// "", so an answer carrying no status at all counted as settled and was returned +// as a successfully created agent — the wait ending on the absence of an answer. +func TestWaitForAgentDoesNotTakeABlankStatusForDone(t *testing.T) { + withAgentAPI(t, `[{"id":"a-1","status":"", + "currentState":{"productResourceName":"ns1.example"},"currentTasks":[]}]`) + + _, err := waitForBackupAgent("t-1", "s-1", "ns1.example", true) + + if err == nil { + t.Fatal("a blank status must be waited on, never taken for an outcome") + } + if !strings.Contains(err.Error(), "stopped waiting") { + t.Fatalf("got %q", err) + } +} + +// The status enumeration has no failed value and there is no task route, so +// currentTasks is the only place a failure appears. Measured on this account: +// three BACKUP_VAULT_CREATE and one VSPC_AGENT_UPDATE sat in ERROR for five +// minutes on resources every status field called READY. +func TestWaitForAgentStopsOnAFailedTask(t *testing.T) { + withAgentAPI(t, `[{"id":"a-1","status":"NOT_INSTALLED", + "currentState":{"productResourceName":"ns1.example"}, + "currentTasks":[{"id":"tk-1","type":"VSPC_AGENT_CREATE","status":"ERROR","errors":[]}]}]`) + + _, err := waitForBackupAgent("t-1", "s-1", "ns1.example", true) + + if err == nil { + t.Fatal("a failed task is not a created agent") + } + if !strings.Contains(err.Error(), "VSPC_AGENT_CREATE ERROR") { + t.Fatalf("the operation has to be named, ERROR alone says nothing: %q", err) + } + if strings.Contains(err.Error(), "stopped waiting") { + t.Fatalf("it stopped because the task failed, not out of patience: %q", err) + } +} + +// WAITING_USER_INPUT is not an error, and it is just as terminal for a command +// whose only move is to wait: only a person can unblock it, so holding the +// terminal open until the timeout tells nobody anything. +func TestWaitForAgentStopsWhenOnlyAPersonCanUnblockIt(t *testing.T) { + withAgentAPI(t, `[{"id":"a-1","status":"CREATING", + "currentState":{"productResourceName":"ns1.example"}, + "currentTasks":[{"id":"tk-1","type":"VSPC_AGENT_CREATE","status":"WAITING_USER_INPUT","errors":[]}]}]`) + + _, err := waitForBackupAgent("t-1", "s-1", "ns1.example", true) + + if err == nil || !strings.Contains(err.Error(), "WAITING_USER_INPUT") { + t.Fatalf("got %v", err) + } +} + +// A removal that failed leaves the agent in place, so the loop would otherwise +// poll a corpse for its whole timeout and then blame its own patience. +func TestWaitForAgentRemovalStopsOnAFailedTask(t *testing.T) { + withAgentAPI(t, `[{"id":"a-1","status":"DELETING", + "currentState":{"productResourceName":"ns1.example"}, + "currentTasks":[{"id":"tk-1","type":"VSPC_AGENT_DELETE","status":"ERROR", + "errors":[{"message":"the vault still holds restore points"}]}]}]`) + + _, err := waitForBackupAgent("t-1", "s-1", "ns1.example", false) + + if err == nil { + t.Fatal("a failed removal is not a removal") + } + if !strings.Contains(err.Error(), "the vault still holds restore points") { + t.Fatalf("the reason is printed when the API gives one: %q", err) + } +} From 1a1bda3155664c0a6546aaeb3b39b38dc63d2b5d Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 24 Aug 2026 12:03:02 +0200 Subject: [PATCH 11/13] 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 12/13] 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 9d575b99bf162260d20c59748e675b01066fbeb7 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 24 Aug 2026 12:06:54 +0200 Subject: [PATCH 13/13] 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 +