From 40090cfd8662e66558d26f6732e96e85b398c71b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Bustarret?= Date: Fri, 10 Jul 2026 08:46:14 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(storage):=20presigned=20PutObj?= =?UTF-8?q?ect/DeleteObject=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/storage.go | 31 ++++++++-- cmd/storage_test.go | 57 +++++++++++++++---- docs/reference/octl_storage_object_presign.md | 1 + 3 files changed, 73 insertions(+), 16 deletions(-) diff --git a/cmd/storage.go b/cmd/storage.go index c487cb8c..60dce8b1 100644 --- a/cmd/storage.go +++ b/cmd/storage.go @@ -8,9 +8,11 @@ package cmd import ( "fmt" "io" + "net/http" "os" "reflect" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/gabriel-vasile/mimetype" "github.com/outscale/octl/pkg/builder" @@ -51,8 +53,12 @@ func init() { objectCmd, _ := lo.Find(storageCmd.Commands(), func(c *cobra.Command) bool { return c.Name() == "object" }) objectCmd.AddCommand(presignCmd) presignCmd.Flags().String("bucket", "", "bucket") - presignCmd.Flags().Duration("expires", 0, "URL expiration (e.g. 30s, 1h)") _ = presignCmd.MarkFlagRequired("bucket") + presignCmd.Flags().Duration("expires", 0, "URL expiration (e.g. 30s, 1h)") + presignCmd.Flags().String("method", http.MethodGet, "Method used to access the presigned URL (GET, PUT, DELETE)") + _ = presignCmd.RegisterFlagCompletionFunc("method", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) { + return []cobra.Completion{http.MethodGet, http.MethodPut, http.MethodDelete}, cobra.ShellCompDirectiveDefault + }) runner.RegisterHook("auto-content-type", guessContentType) } @@ -92,10 +98,25 @@ func presign(cmd *cobra.Command, args []string) { } key := args[0] bucket, _ := cmd.Flags().GetString("bucket") - req, err := cl.PresignGetObject(cmd.Context(), &s3.GetObjectInput{ - Key: new(key), - Bucket: new(bucket), - }, opts...) + method, _ := cmd.Flags().GetString("method") + var req *v4.PresignedHTTPRequest + switch method { + case http.MethodGet: + req, err = cl.PresignGetObject(cmd.Context(), &s3.GetObjectInput{ + Key: new(key), + Bucket: new(bucket), + }, opts...) + case http.MethodPut: + req, err = cl.PresignPutObject(cmd.Context(), &s3.PutObjectInput{ + Key: new(key), + Bucket: new(bucket), + }, opts...) + case http.MethodDelete: + req, err = cl.PresignDeleteObject(cmd.Context(), &s3.DeleteObjectInput{ + Key: new(key), + Bucket: new(bucket), + }, opts...) + } if err != nil { messages.ExitErr(err) } diff --git a/cmd/storage_test.go b/cmd/storage_test.go index 91c8fc8b..4d5c8647 100644 --- a/cmd/storage_test.go +++ b/cmd/storage_test.go @@ -511,21 +511,19 @@ func TestObjectTagging(t *testing.T) { }) } -func TestPResign(t *testing.T) { +func TestPresign(t *testing.T) { sum := sha1.Sum([]byte(t.TempDir())) bucket := hex.EncodeToString(sum[:]) - object := "object.txt" - path := file(t, object, hello) - - t.Run("Presigning URL works", func(t *testing.T) { - _ = run(t, []string{"storage", "bucket", "create", "--bucket", bucket}, nil) - defer func() { - _ = run(t, []string{"storage", "bucket", "del", bucket, "-y"}, nil) - }() + _ = run(t, []string{"storage", "bucket", "create", "--bucket", bucket}, nil) + defer func() { + _ = run(t, []string{"storage", "bucket", "del", bucket, "-y"}, nil) + }() - var res s3.PutObjectOutput - runJSON(t, []string{"storage", "object", "put", object, "--bucket", bucket, "--body", path, "--output", "json"}, nil, &res) + t.Run("Presigning GetObject works", func(t *testing.T) { + object := "GetObject.txt" + path := file(t, object, hello) + _ = run(t, []string{"storage", "object", "put", object, "--bucket", bucket, "--body", path, "--output", "json"}, nil) defer func() { _ = run(t, []string{"storage", "object", "del", object, "--bucket", bucket, "-y"}, nil) }() @@ -545,4 +543,41 @@ func TestPResign(t *testing.T) { assert.NotEqual(t, http.StatusOK, resp.StatusCode) resp.Body.Close() //nolint }) + + t.Run("Presigning PutObject works", func(t *testing.T) { + object := "PutObject.txt" + out := run(t, []string{"storage", "object", "presign", object, "--bucket", bucket, "--expires", "2s", "--method", "PUT"}, nil) + require.True(t, strings.HasPrefix(string(out), "http")) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPut, string(out), strings.NewReader(hello)) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + resp.Body.Close() //nolint + defer func() { + _ = run(t, []string{"storage", "object", "del", object, "--bucket", bucket, "-y"}, nil) + }() + + res := run(t, []string{"storage", "object", "download", object, "--bucket", bucket}, nil) + assert.Equal(t, hello, string(res)) + }) + + t.Run("Presigning DeleteObject works", func(t *testing.T) { + object := "DeleteObject.txt" + path := file(t, object, hello) + _ = run(t, []string{"storage", "object", "put", object, "--bucket", bucket, "--body", path, "--output", "json"}, nil) + + out := run(t, []string{"storage", "object", "presign", object, "--bucket", bucket, "--expires", "2s", "--method", "DELETE"}, nil) + require.True(t, strings.HasPrefix(string(out), "http")) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodDelete, string(out), nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + resp.Body.Close() //nolint + + runWithError(t, []string{"storage", "object", "download", object, "--bucket", bucket}, nil) + }) } diff --git a/docs/reference/octl_storage_object_presign.md b/docs/reference/octl_storage_object_presign.md index 1b93c0fb..72d57272 100644 --- a/docs/reference/octl_storage_object_presign.md +++ b/docs/reference/octl_storage_object_presign.md @@ -12,6 +12,7 @@ octl storage object presign key [flags] --bucket string bucket --expires duration URL expiration (e.g. 30s, 1h) -h, --help help for presign + --method string Method used to access the presigned URL (GET, PUT, DELETE) (default "GET") ``` ### Options inherited from parent commands