diff --git a/doc/ovhcloud.md b/doc/ovhcloud.md index 7d79d615..7ca1e698 100644 --- a/doc/ovhcloud.md +++ b/doc/ovhcloud.md @@ -1,90 +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 ``` - -Add the appropriate line to your shell’s startup file (`~/.bashrc`, `~/.zshrc`, etc.) to enable persistent autocompletion. - ---- - -## Global Usage - -```text -ovhcloud [command] [flags] + -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 ``` -### 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 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 @@ -102,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 @@ -113,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 @@ -124,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_backup-services.md b/doc/ovhcloud_backup-services.md new file mode 100644 index 00000000..7241ea72 --- /dev/null +++ b/doc/ovhcloud_backup-services.md @@ -0,0 +1,51 @@ +## 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 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 +* [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_agents.md b/doc/ovhcloud_backup-services_agents.md new file mode 100644 index 00000000..b5d7cd1b --- /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 00000000..cb076dfd --- /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/doc/ovhcloud_backup-services_deploy-script.md b/doc/ovhcloud_backup-services_deploy-script.md new file mode 100644 index 00000000..93d5ac6c --- /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 00000000..c05a2f4e --- /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 00000000..d7f509a0 --- /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 00000000..305ca48b --- /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 00000000..522b63de --- /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 00000000..83a42994 --- /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 00000000..9af8b54b --- /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 00000000..9f65e0fd --- /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 00000000..637bad0a --- /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 00000000..6680844b --- /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 00000000..f2dba3cd --- /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 00000000..25e88765 --- /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 00000000..53bc2f94 --- /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 00000000..2fabca57 --- /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 00000000..91fdb014 --- /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 00000000..6a43a9bf --- /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 00000000..d2a492b7 --- /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 42710243..af6a25e4 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 00000000..a9179246 --- /dev/null +++ b/doc/ovhcloud_baremetal_backup-agent.md @@ -0,0 +1,39 @@ +## ovhcloud baremetal backup-agent + +Manage the Veeam backup agent protecting this server + +### Options + +``` + -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 + +``` + -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 00000000..668bbfd7 --- /dev/null +++ b/doc/ovhcloud_baremetal_backup-agent_create.md @@ -0,0 +1,51 @@ +## 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 + --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 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 00000000..ab689148 --- /dev/null +++ b/doc/ovhcloud_baremetal_backup-agent_delete.md @@ -0,0 +1,42 @@ +## 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 + --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 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 00000000..2de12993 --- /dev/null +++ b/doc/ovhcloud_baremetal_backup-agent_edit.md @@ -0,0 +1,44 @@ +## 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 + --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 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 00000000..a0f3d67d --- /dev/null +++ b/doc/ovhcloud_baremetal_backup-agent_show.md @@ -0,0 +1,46 @@ +## 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 + --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 baremetal backup-agent](ovhcloud_baremetal_backup-agent.md) - Manage the Veeam backup agent protecting this server + diff --git a/doc/ovhcloud_logout.md b/doc/ovhcloud_logout.md new file mode 100644 index 00000000..c78e5c51 --- /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 + diff --git a/internal/assets/api-schemas/backupservices_v2.json b/internal/assets/api-schemas/backupservices_v2.json new file mode 100644 index 00000000..88c29978 --- /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 e226b0ec..67e53488 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 39343b85..15ce5a67 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 00000000..2f632744 --- /dev/null +++ b/internal/cmd/backupservices.go @@ -0,0 +1,190 @@ +// 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`.", + } + + addBackupTenantFlags(backupCmd) + + // 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(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", + // 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.", + 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) +} + +// 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 new file mode 100644 index 00000000..83a1624b --- /dev/null +++ b/internal/cmd/backupservices_test.go @@ -0,0 +1,572 @@ +// SPDX-FileCopyrightText: 2026 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + "encoding/json" + "io" + "net/http" + "os" + "strings" + + "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":"203.0.113.7","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")) +} + +// 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) { + 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":["203.0.113.7/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{"203.0.113.7/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":["203.0.113.7/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{"203.0.113.7/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) +} + +// 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") +} + +// --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") +} + +// 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/cmd/baremetal.go b/internal/cmd/baremetal.go index 77a5292c..bd26c981 100644 --- a/internal/cmd/baremetal.go +++ b/internal/cmd/baremetal.go @@ -11,7 +11,6 @@ import ( "github.com/ovh/ovhcloud-cli/internal/completion" "github.com/ovh/ovhcloud-cli/internal/flags" "github.com/ovh/ovhcloud-cli/internal/services/baremetal" - "github.com/ovh/ovhcloud-cli/internal/services/common" "github.com/ovh/ovhcloud-cli/internal/services/vrack" "github.com/spf13/cobra" ) @@ -134,7 +133,7 @@ they are not sold from the public price list, and show as "on quotation".`, ValidArgsFunction: completion.ServiceList("/v1/dedicated/server"), Run: baremetal.EditBaremetalServiceInfo, } - common.AddServiceInfoRenewFlags(baremetalServiceInfoEditCmd) + addServiceInfoRenewFlags(baremetalServiceInfoEditCmd) addInteractiveEditorFlag(baremetalServiceInfoEditCmd) baremetalServiceInfoCmd.AddCommand(baremetalServiceInfoEditCmd) @@ -924,6 +923,80 @@ 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", + } + // 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{ + 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/cmd/dry_run_log_test.go b/internal/cmd/dry_run_log_test.go new file mode 100644 index 00000000..fcf9f15f --- /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"`)) +} diff --git a/internal/cmd/ip.go b/internal/cmd/ip.go index 93485087..737f34e1 100644 --- a/internal/cmd/ip.go +++ b/internal/cmd/ip.go @@ -7,7 +7,6 @@ package cmd import ( "github.com/ovh/ovhcloud-cli/internal/assets" "github.com/ovh/ovhcloud-cli/internal/completion" - "github.com/ovh/ovhcloud-cli/internal/services/common" "github.com/ovh/ovhcloud-cli/internal/services/ip" "github.com/spf13/cobra" ) @@ -192,7 +191,7 @@ func init() { ValidArgsFunction: completion.ServiceList("/v1/ip/service"), Run: ip.EditIpServiceInfo, } - common.AddServiceInfoRenewFlags(ipServiceInfoEditCmd) + addServiceInfoRenewFlags(ipServiceInfoEditCmd) addInteractiveEditorFlag(ipServiceInfoEditCmd) ipServiceInfoCmd.AddCommand(ipServiceInfoEditCmd) diff --git a/internal/cmd/service_info.go b/internal/cmd/service_info.go new file mode 100644 index 00000000..e08a19d1 --- /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 f452ef0c..f112381a 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 7e5096d0..e98a7e7f 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/backupservices/backupservices.go b/internal/services/backupservices/backupservices.go new file mode 100644 index 00000000..bdff764f --- /dev/null +++ b/internal/services/backupservices/backupservices.go @@ -0,0 +1,544 @@ +// 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 + } + + 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"}) +} + +// 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. + 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"}) +} + +// 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 + } + + common.RenderFilteredTable(buckets, []string{"id", "name", "region", "performance", "role", "status"}) +} + +// 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 + } + + common.RenderFilteredTable(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"}) +} + +// 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}) + } + + common.RenderFilteredTable(rows, []string{"policy"}) +} + +// 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 + } + + common.RenderFilteredTable(rowsOf(licenses, nil), + []string{"id", "name", "resourceStatus status", "tasks"}) +} + +// 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 + } + + common.RenderFilteredTable(rowsOf(servers, func(r resource) map[string]any { + return map[string]any{ + "licenseType": r.TargetSpec["licenseType"], + "displayName": r.TargetSpec["displayName"], + } + // "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. +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() +} + +// 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"]) + }) + + common.RenderFilteredTable(rows, + []string{"protects", "status", "policy", "type", "ips", "id", "tasks"}) +} diff --git a/internal/services/backupservices/backupservices_test.go b/internal/services/backupservices/backupservices_test.go new file mode 100644 index 00000000..0d697b3a --- /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/billing.go b/internal/services/backupservices/billing.go new file mode 100644 index 00000000..ed213bf3 --- /dev/null +++ b/internal/services/backupservices/billing.go @@ -0,0 +1,279 @@ +// SPDX-FileCopyrightText: 2026 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package backupservices + +import ( + "fmt" + "log" + "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" + "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 + } + + // 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 { + 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) + 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, usageReadable) + } + + rows[index] = row + + return nil + }) + } + if err := group.Wait(); err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + common.RenderFilteredTable(rows, + []string{"kind", "name", "plan", "price", "period", "consumption", "renew", "nextBillingDate", "state"}) +} + +// 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"` +} + +// 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. +// +// 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. +// 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 "none yet" + } + + 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 00000000..ecf506e2 --- /dev/null +++ b/internal/services/backupservices/billing_test.go @@ -0,0 +1,69 @@ +// 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, 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, 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. +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, true) + 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) + } +} diff --git a/internal/services/backupservices/resolve.go b/internal/services/backupservices/resolve.go new file mode 100644 index 00000000..ce42630a --- /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 00000000..19a0c243 --- /dev/null +++ b/internal/services/baremetal/backupagent.go @@ -0,0 +1,620 @@ +// 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 +) + +// 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 +) + +// 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. +// +// 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 +} + +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, + }) + } + + common.RenderFilteredTable(rows, []string{"id", "displayName", "status", "policy", "ips", "type"}) +} + +// 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 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 + + // 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 + } + } + + 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 00000000..d5e2e392 --- /dev/null +++ b/internal/services/baremetal/backupagent_test.go @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: 2026 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package baremetal + +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 +// 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) + } + } +} + +// 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) + } +} diff --git a/internal/services/common/common.go b/internal/services/common/common.go index 7bf12c98..86b8db26 100644 --- a/internal/services/common/common.go +++ b/internal/services/common/common.go @@ -201,8 +201,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 { @@ -221,6 +219,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) diff --git a/internal/services/common/service_info.go b/internal/services/common/service_info.go index 9ce0b4c5..b678696f 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 {