Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions doc/ovhcloud_cloud_storage-s3_bulk-delete.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ ovhcloud cloud storage-s3 bulk-delete <container_name> [flags]
### Options

```
--all Delete all objects in the container
-h, --help help for bulk-delete
--objects strings List of objects to delete (format is '<object_name>' or '<object_name>:<version_id>'
--prefix string Prefix to filter objects to delete
```

### Options inherited from parent commands
Expand Down
5 changes: 5 additions & 0 deletions internal/cmd/cloud_storage_s3.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ func initCloudStorageS3Command(cloudCmd *cobra.Command) {
Args: cobra.ExactArgs(1),
}
bulkDeleteCmd.Flags().StringSliceVar(&cloud.StorageS3ObjectsToDelete, "objects", nil, "List of objects to delete (format is '<object_name>' or '<object_name>:<version_id>'")
bulkDeleteCmd.Flags().BoolVar(&cloud.StorageS3BulkDeleteAll, "all", false, "Delete all objects in the container")
bulkDeleteCmd.Flags().StringVar(&cloud.StorageS3BulkDeletePrefix, "prefix", "", "Prefix to filter objects to delete")
bulkDeleteCmd.MarkFlagsOneRequired("objects", "all", "prefix")
bulkDeleteCmd.MarkFlagsMutuallyExclusive("objects", "all", "prefix")

storageS3Cmd.AddCommand(bulkDeleteCmd)

// Object commands
Expand Down
163 changes: 163 additions & 0 deletions internal/cmd/cloud_storage_s3_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// SPDX-FileCopyrightText: 2025 OVH SAS <opensource@ovh.net>
//
// SPDX-License-Identifier: Apache-2.0

package cmd_test

import (
"encoding/json"
"net/http"

"github.com/jarcoal/httpmock"
"github.com/maxatome/go-testdeep/td"
"github.com/maxatome/tdhttpmock"
"github.com/ovh/ovhcloud-cli/internal/cmd"
)

func (ms *MockSuite) TestCloudStorageS3BulkDeletePrefixCmd(assert, require *td.T) {
httpmock.RegisterResponder(http.MethodGet, "https://eu.api.ovh.com/1.0/cloud/project/fakeProjectID/region",
httpmock.NewStringResponder(200, `["BHS"]`))

httpmock.RegisterResponder(http.MethodGet, "https://eu.api.ovh.com/1.0/cloud/project/fakeProjectID/region/BHS",
httpmock.NewStringResponder(200, `{
"name": "BHS",
"type": "region",
"status": "UP",
"services": [
{
"name": "storage",
"status": "UP"
},
{
"name": "storage-s3-high-perf",
"status": "UP"
},
{
"name": "storage-s3-standard",
"status": "UP"
}
],
"countryCode": "ca",
"ipCountries": [],
"continentCode": "NA",
"availabilityZones": [],
"datacenterLocation": "BHS"
}`))

httpmock.RegisterResponder(http.MethodGet,
"https://eu.api.ovh.com/1.0/cloud/project/fakeProjectID/region/BHS/storage/fakeContainer",
httpmock.NewStringResponder(200, `{
"name": "fakeContainer",
"virtualHost": "https://fakeContainer.test.ovh.net/",
"ownerId": 0,
"objectsCount": 15,
"objectsSize": 4147089,
"objects": [
{"key": "logs/log1.txt"},
{"key": "logs/log2.txt"},
{"key": "images/img1.png"}
],
"region": "BHS",
"createdAt": "2025-02-10T14:24:12Z"
}`))

httpmock.RegisterResponder(http.MethodGet,
"https://eu.api.ovh.com/1.0/cloud/project/fakeProjectID/region/BHS/storage/fakeContainer/object?prefix=logs%2F",
httpmock.NewStringResponder(200, `[
{"key": "logs/log1.txt"},
{"key": "logs/log2.txt"}
]`).Then(httpmock.NewStringResponder(200, `[]`)),
)

httpmock.RegisterMatcherResponder(http.MethodPost,
"https://eu.api.ovh.com/1.0/cloud/project/fakeProjectID/region/BHS/storage/fakeContainer/bulkDeleteObjects",
tdhttpmock.JSONBody(td.JSON(`
{
"objects": [
{"key": "logs/log1.txt"},
{"key": "logs/log2.txt"}
]
}`),
),
httpmock.NewStringResponder(200, ``),
)

out, err := cmd.Execute("cloud", "storage-s3", "bulk-delete", "fakeContainer", "--cloud-project", "fakeProjectID", "--prefix", "logs/", "--json")
require.CmpNoError(err)
assert.Cmp(json.RawMessage(out), td.JSON(`{"message": "✅ Objects deleted successfully"}`))
}

func (ms *MockSuite) TestCloudStorageS3BulkDeleteAllCmd(assert, require *td.T) {
httpmock.RegisterResponder(http.MethodGet, "https://eu.api.ovh.com/1.0/cloud/project/fakeProjectID/region",
httpmock.NewStringResponder(200, `["BHS"]`))

httpmock.RegisterResponder(http.MethodGet, "https://eu.api.ovh.com/1.0/cloud/project/fakeProjectID/region/BHS",
httpmock.NewStringResponder(200, `{
"name": "BHS",
"type": "region",
"status": "UP",
"services": [
{
"name": "storage",
"status": "UP"
},
{
"name": "storage-s3-high-perf",
"status": "UP"
},
{
"name": "storage-s3-standard",
"status": "UP"
}
],
"countryCode": "ca",
"ipCountries": [],
"continentCode": "NA",
"availabilityZones": [],
"datacenterLocation": "BHS"
}`))

httpmock.RegisterResponder(http.MethodGet,
"https://eu.api.ovh.com/1.0/cloud/project/fakeProjectID/region/BHS/storage/fakeContainer",
httpmock.NewStringResponder(200, `{
"name": "fakeContainer",
"virtualHost": "https://fakeContainer.test.ovh.net/",
"ownerId": 0,
"objectsCount": 15,
"objectsSize": 4147089,
"objects": [
{"key": "logs/log1.txt"},
{"key": "logs/log2.txt"},
{"key": "images/img1.png"}
],
"region": "BHS",
"createdAt": "2025-02-10T14:24:12Z"
}`))

httpmock.RegisterResponder(http.MethodGet,
"https://eu.api.ovh.com/1.0/cloud/project/fakeProjectID/region/BHS/storage/fakeContainer/object",
httpmock.NewStringResponder(200, `[
{"key": "logs/log1.txt"},
{"key": "logs/log2.txt"},
{"key": "images/img1.png"}
]`).Then(httpmock.NewStringResponder(200, `[]`)),
)

httpmock.RegisterMatcherResponder(http.MethodPost,
"https://eu.api.ovh.com/1.0/cloud/project/fakeProjectID/region/BHS/storage/fakeContainer/bulkDeleteObjects",
tdhttpmock.JSONBody(td.JSON(`
{
"objects": [
{"key": "logs/log1.txt"},
{"key": "logs/log2.txt"},
{"key": "images/img1.png"}
]
}`),
),
httpmock.NewStringResponder(200, ``),
)

out, err := cmd.Execute("cloud", "storage-s3", "bulk-delete", "fakeContainer", "--cloud-project", "fakeProjectID", "--all", "--json")
require.CmpNoError(err)
assert.Cmp(json.RawMessage(out), td.JSON(`{"message": "✅ Objects deleted successfully"}`))
}
101 changes: 79 additions & 22 deletions internal/services/cloud/cloud_storage_s3.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
_ "embed"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"strconv"
"strings"
Expand Down Expand Up @@ -72,7 +74,9 @@ var (
} `json:"versioning,omitzero"`
}

StorageS3ObjectsToDelete []string
StorageS3ObjectsToDelete []string
StorageS3BulkDeleteAll bool
StorageS3BulkDeletePrefix string

StorageS3ListParams struct {
KeyMarker string
Expand Down Expand Up @@ -264,41 +268,94 @@ func StorageS3BulkDeleteObjects(_ *cobra.Command, args []string) {
return
}

if len(StorageS3ObjectsToDelete) == 0 {
display.OutputWarning(&flags.OutputFormatConfig, "no objects to delete. Use --objects flag to specify objects to delete")
return
}

foundURL, _, err := locateStorageS3Container(projectID, args[0])
if err != nil {
display.OutputError(&flags.OutputFormatConfig, "%s", err)
return
}

var objectsToDelete []map[string]any
for _, object := range StorageS3ObjectsToDelete {
parts := strings.Split(object, ":")
// List of objects to delete given, process them
if len(StorageS3ObjectsToDelete) > 0 {
var objectsToDelete []map[string]any
for _, object := range StorageS3ObjectsToDelete {
parts := strings.Split(object, ":")

switch len(parts) {
case 1:
// Object name only
objectsToDelete = append(objectsToDelete, map[string]any{"key": parts[0]})
case 2:
// Object name with version ID
objectsToDelete = append(objectsToDelete, map[string]any{"key": parts[0], "versionId": parts[1]})
default:
display.OutputError(&flags.OutputFormatConfig, "invalid object format: %s. Use <object_name> or <object_name>:<version_id>", object)
switch len(parts) {
case 1:
// Object name only
objectsToDelete = append(objectsToDelete, map[string]any{"key": parts[0]})
case 2:
// Object name with version ID
objectsToDelete = append(objectsToDelete, map[string]any{"key": parts[0], "versionId": parts[1]})
default:
display.OutputError(&flags.OutputFormatConfig, "invalid object format: %s. Use <object_name> or <object_name>:<version_id>", object)
return
}
}

if err := httpLib.Client.Post(foundURL+"/bulkDeleteObjects", map[string]any{
"objects": objectsToDelete,
}, nil); err != nil {
display.OutputError(&flags.OutputFormatConfig, "failed to delete objects: %s", err)
return
}

display.OutputInfo(&flags.OutputFormatConfig, nil, "✅ Objects deleted successfully")
return
}

if err := httpLib.Client.Post(foundURL+"/bulkDeleteObjects", map[string]any{
"objects": objectsToDelete,
}, nil); err != nil {
display.OutputError(&flags.OutputFormatConfig, "failed to delete objects: %s", err)
var request *http.Request
switch {
case StorageS3BulkDeletePrefix != "":
endpoint := foundURL + "/object?prefix=" + url.QueryEscape(StorageS3BulkDeletePrefix)
request, err = httpLib.Client.NewRequest(http.MethodGet, endpoint, nil, true)
case StorageS3BulkDeleteAll:
request, err = httpLib.Client.NewRequest(http.MethodGet, foundURL+"/object", nil, true)
default:
display.OutputError(&flags.OutputFormatConfig, "Nothing to delete, either --objects, --prefix or --all must be specified")
return
}

if err != nil {
display.OutputError(&flags.OutputFormatConfig, "failed to create objects listing request: %s", err)
return
}

for {
// Fetch objects in the container (batches of 1000)
resp, err := httpLib.Client.Do(request)
if err != nil {
display.OutputError(&flags.OutputFormatConfig, "failed to fetch objects: %s", err)
return
}

var objects []map[string]any
if err := httpLib.Client.UnmarshalResponse(resp, &objects); err != nil {
display.OutputError(&flags.OutputFormatConfig, "failed to parse objects response: %s", err)
return
}

// No objects found, we are done
if len(objects) == 0 {
break
}

// Prepare objects to delete
var objectsToDelete []map[string]any
for _, object := range objects {
objectsToDelete = append(objectsToDelete, map[string]any{"key": object["key"]})
}

// Delete objects
log.Printf("Deleting %d objects...", len(objectsToDelete))
if err := httpLib.Client.Post(foundURL+"/bulkDeleteObjects", map[string]any{
"objects": objectsToDelete,
}, nil); err != nil {
display.OutputError(&flags.OutputFormatConfig, "failed to delete objects: %s", err)
return
}
}

display.OutputInfo(&flags.OutputFormatConfig, nil, "✅ Objects deleted successfully")
}

Expand Down