diff --git a/azurebs/README.md b/azurebs/README.md index 500662af..0fe24fdb 100644 --- a/azurebs/README.md +++ b/azurebs/README.md @@ -12,13 +12,22 @@ The Azure client requires a JSON configuration file with the following structure ``` json { - "account_name": " (required)", - "account_key": " (required)", - "container_name": " (required)", - "environment": " (optional, default: 'AzureCloud')" + "account_name": " (required)", + "account_key": " (required)", + "container_name": " (required)", + "environment": " (optional, default: 'AzureCloud')", + "put_timeout_in_seconds": " (optional; put/upload operation timeout in whole seconds)", + "http_request_timeout": " (optional; Go duration, e.g. '30s', '2m')", + "http_response_header_timeout": " (optional; Go duration, e.g. '10s', '1m')" } ``` +### Timeout Configuration + +- `put_timeout_in_seconds`: Applies to upload/put operations and is interpreted as seconds. +- `http_request_timeout`: Sets the underlying HTTP client timeout for Azure SDK requests. +- `http_response_header_timeout`: Sets how long to wait for response headers from the server. + **Usage examples:** ``` bash # Upload a blob diff --git a/azurebs/client/storage_client.go b/azurebs/client/storage_client.go index 0590df00..64759f0f 100644 --- a/azurebs/client/storage_client.go +++ b/azurebs/client/storage_client.go @@ -2,11 +2,14 @@ package client import ( "context" + "crypto/tls" "encoding/json" "errors" "fmt" "io" "log/slog" + "net" + "net/http" "os" "strconv" "strings" @@ -21,6 +24,8 @@ import ( azContainer "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container" "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/sas" + "golang.org/x/net/http2" + "github.com/cloudfoundry/storage-cli/azurebs/config" ) @@ -107,6 +112,7 @@ type DefaultStorageClient struct { credential *azblob.SharedKeyCredential serviceURL string storageConfig config.AZStorageConfig + clientOptions *azcore.ClientOptions } func NewStorageClient(storageConfig config.AZStorageConfig) (StorageClient, error) { @@ -115,9 +121,93 @@ func NewStorageClient(storageConfig config.AZStorageConfig) (StorageClient, erro return nil, err } + clientOptions, err := buildClientOptions(storageConfig) + if err != nil { + return nil, err + } + serviceURL := fmt.Sprintf("https://%s.%s/%s", storageConfig.AccountName, storageConfig.StorageEndpoint(), storageConfig.ContainerName) - return DefaultStorageClient{credential: credential, serviceURL: serviceURL, storageConfig: storageConfig}, nil + return DefaultStorageClient{credential: credential, serviceURL: serviceURL, storageConfig: storageConfig, clientOptions: clientOptions}, nil +} + +// buildClientOptions builds the shared azcore client options carrying a custom +// *http.Client whenever an HTTP request timeout and/or response header timeout is +// configured. It returns nil when neither is set, preserving the SDK defaults. +func buildClientOptions(storageConfig config.AZStorageConfig) (*azcore.ClientOptions, error) { + httpRequestTimeout, err := storageConfig.HTTPRequestTimeoutValue() + if err != nil { + return nil, err + } + + responseHeaderTimeout, err := storageConfig.HTTPResponseHeaderTimeoutValue() + if err != nil { + return nil, err + } + + if httpRequestTimeout == 0 && responseHeaderTimeout == 0 { + return nil, nil + } + + // Mirror the default transport built by azcore's runtime package + // (runtime/transport_default_http_client.go). Its defaultHTTPClient and the + // underlying transport are unexported and cannot be reused directly, so we + // replicate the settings here to preserve the SDK's tuned defaults while + // applying our custom timeouts. Keep this in sync with the pinned SDK + // version: github.com/Azure/azure-sdk-for-go/sdk/azcore@v1.23.1. + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + Renegotiation: tls.RenegotiateFreelyAsClient, + }, + } + // TODO: evaluate removing this once https://github.com/golang/go/issues/59690 has been fixed + if http2Transport, err := http2.ConfigureTransports(transport); err == nil { //nolint:staticcheck + // if the connection has been idle for 10 seconds, send a ping frame for a health check + http2Transport.ReadIdleTimeout = 10 * time.Second //nolint:staticcheck + // if there's no response to the ping within the timeout, the connection will be closed + http2Transport.PingTimeout = 5 * time.Second //nolint:staticcheck + } + + if responseHeaderTimeout > 0 { + transport.ResponseHeaderTimeout = responseHeaderTimeout + } + + httpClient := &http.Client{Timeout: httpRequestTimeout, Transport: transport} + + return &azcore.ClientOptions{Transport: httpClient}, nil +} + +func (dsc DefaultStorageClient) blockblobOptions() *blockblob.ClientOptions { + if dsc.clientOptions == nil { + return nil + } + return &blockblob.ClientOptions{ClientOptions: *dsc.clientOptions} +} + +func (dsc DefaultStorageClient) blobOptions() *azBlob.ClientOptions { + if dsc.clientOptions == nil { + return nil + } + return &azBlob.ClientOptions{ClientOptions: *dsc.clientOptions} +} + +func (dsc DefaultStorageClient) containerOptions() *azContainer.ClientOptions { + if dsc.clientOptions == nil { + return nil + } + return &azContainer.ClientOptions{ClientOptions: *dsc.clientOptions} } func (dsc DefaultStorageClient) Upload( @@ -138,7 +228,7 @@ func (dsc DefaultStorageClient) Upload( } defer cancel() - client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, nil) + client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, dsc.blockblobOptions()) if err != nil { return nil, err } @@ -173,7 +263,7 @@ func (dsc DefaultStorageClient) UploadStream( } defer cancel() - client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, nil) + client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, dsc.blockblobOptions()) if err != nil { return err } @@ -196,7 +286,7 @@ func (dsc DefaultStorageClient) Download( ) error { blobURL := fmt.Sprintf("%s/%s", dsc.serviceURL, source) slog.Info("Downloading blob from container", "container", dsc.storageConfig.ContainerName, "blob", source, "local_file", dest.Name()) - client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, nil) + client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, dsc.blockblobOptions()) if err != nil { return err } @@ -226,7 +316,7 @@ func (dsc DefaultStorageClient) Copy( srcURL := fmt.Sprintf("%s/%s", dsc.serviceURL, srcBlob) destURL := fmt.Sprintf("%s/%s", dsc.serviceURL, destBlob) - destClient, err := blockblob.NewClientWithSharedKeyCredential(destURL, dsc.credential, nil) + destClient, err := blockblob.NewClientWithSharedKeyCredential(destURL, dsc.credential, dsc.blockblobOptions()) if err != nil { return fmt.Errorf("failed to create destination client: %w", err) } @@ -268,7 +358,7 @@ func (dsc DefaultStorageClient) Delete( blobURL := fmt.Sprintf("%s/%s", dsc.serviceURL, dest) slog.Info("Deleting blob from container", "container", dsc.storageConfig.ContainerName, "blob", dest, "url", blobURL) - client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, nil) + client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, dsc.blockblobOptions()) if err != nil { return err } @@ -295,7 +385,7 @@ func (dsc DefaultStorageClient) DeleteRecursive( slog.Info("Deleting all blobs in container", "container", dsc.storageConfig.ContainerName) } - containerClient, err := azContainer.NewClientWithSharedKeyCredential(dsc.serviceURL, dsc.credential, nil) + containerClient, err := azContainer.NewClientWithSharedKeyCredential(dsc.serviceURL, dsc.credential, dsc.containerOptions()) if err != nil { return fmt.Errorf("failed to create container client: %w", err) } @@ -315,7 +405,7 @@ func (dsc DefaultStorageClient) DeleteRecursive( for _, blob := range resp.Segment.BlobItems { blobURL := fmt.Sprintf("%s/%s", dsc.serviceURL, *blob.Name) - blobClient, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, nil) + blobClient, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, dsc.blockblobOptions()) if err != nil { slog.Error("Failed to create blob client", "blob", *blob.Name, "error", err) continue @@ -338,7 +428,7 @@ func (dsc DefaultStorageClient) Exists( blobURL := fmt.Sprintf("%s/%s", dsc.serviceURL, dest) slog.Info("Checking if blob exists", "container", dsc.storageConfig.ContainerName, "blob", dest, "url", blobURL) - client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, nil) + client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, dsc.blockblobOptions()) if err != nil { return false, err } @@ -365,7 +455,7 @@ func (dsc DefaultStorageClient) SignedUrl( blobURL := fmt.Sprintf("%s/%s", dsc.serviceURL, dest) slog.Info("Generating SAS URL for blob", "container", dsc.storageConfig.ContainerName, "blob", dest, "request_type", requestType, "expiration", expiration) - client, err := azBlob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, nil) + client, err := azBlob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, dsc.blobOptions()) if err != nil { return "", err } @@ -398,7 +488,7 @@ func (dsc DefaultStorageClient) List( slog.Info("Listing blobs in container", "container", dsc.storageConfig.ContainerName) } - client, err := azContainer.NewClientWithSharedKeyCredential(dsc.serviceURL, dsc.credential, nil) + client, err := azContainer.NewClientWithSharedKeyCredential(dsc.serviceURL, dsc.credential, dsc.containerOptions()) if err != nil { return nil, fmt.Errorf("failed to create container client: %w", err) } @@ -437,7 +527,7 @@ func (dsc DefaultStorageClient) Properties( blobURL := fmt.Sprintf("%s/%s", dsc.serviceURL, dest) slog.Info("Getting properties for blob", "container", dsc.storageConfig.ContainerName, "blob", dest, "url", blobURL) - client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, nil) + client, err := blockblob.NewClientWithSharedKeyCredential(blobURL, dsc.credential, dsc.blockblobOptions()) if err != nil { return err } @@ -469,7 +559,7 @@ func (dsc DefaultStorageClient) Properties( func (dsc DefaultStorageClient) EnsureContainerExists() error { slog.Info("Ensuring container exists", "container", dsc.storageConfig.ContainerName) - containerClient, err := azContainer.NewClientWithSharedKeyCredential(dsc.serviceURL, dsc.credential, nil) + containerClient, err := azContainer.NewClientWithSharedKeyCredential(dsc.serviceURL, dsc.credential, dsc.containerOptions()) if err != nil { return fmt.Errorf("failed to create container client: %w", err) } diff --git a/azurebs/client/storage_client_test.go b/azurebs/client/storage_client_test.go new file mode 100644 index 00000000..f9a91bd3 --- /dev/null +++ b/azurebs/client/storage_client_test.go @@ -0,0 +1,92 @@ +package client_test + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/cloudfoundry/storage-cli/azurebs/client" + "github.com/cloudfoundry/storage-cli/azurebs/config" +) + +var _ = Describe("NewStorageClient", func() { + baseConfig := func() config.AZStorageConfig { + return config.AZStorageConfig{ + AccountName: "account", + AccountKey: "Zm9vYmFy", // base64("foobar") + ContainerName: "container", + } + } + + It("succeeds without any http timeout configured", func() { + c, err := client.NewStorageClient(baseConfig()) + Expect(err).ToNot(HaveOccurred()) + Expect(c).ToNot(BeNil()) + }) + + It("succeeds with http request and response header timeouts configured", func() { + cfg := baseConfig() + cfg.HTTPRequestTimeout = "30s" + cfg.HTTPResponseHeaderTimeout = "10s" + + c, err := client.NewStorageClient(cfg) + Expect(err).ToNot(HaveOccurred()) + Expect(c).ToNot(BeNil()) + }) + + It("returns an error for an invalid http_request_timeout", func() { + cfg := baseConfig() + cfg.HTTPRequestTimeout = "30" // missing unit + + _, err := client.NewStorageClient(cfg) + Expect(err).To(MatchError(ContainSubstring("missing duration unit"))) + }) + + It("returns an error for an invalid http_response_header_timeout", func() { + cfg := baseConfig() + cfg.HTTPResponseHeaderTimeout = "-5s" + + _, err := client.NewStorageClient(cfg) + Expect(err).To(MatchError(ContainSubstring("must be greater than 0"))) + }) +}) + +// Pinned against github.com/Azure/azure-sdk-for-go/sdk/azcore@v1.23.1. +// If this test fails: diff runtime/transport_default_http_client.go at the new +// version, update buildClientOptions in storage_client.go to reflect any +// changes, then update pinnedHash (and pinnedVersion for clarity) below. +var _ = Describe("azcore transport defaults drift detection", func() { + It("transport_default_http_client.go has not changed since it was last reviewed", func() { + const pinnedVersion = "v1.23.1" + const pinnedHash = "e60db6ff9e71a7f778503c3628c8cda73c5fd555436b470696a0c491fb6db20d" + + out, err := exec.Command("go", "list", "-m", "-json", "github.com/Azure/azure-sdk-for-go/sdk/azcore").Output() + Expect(err).NotTo(HaveOccurred(), "go list failed: %v", err) + + var modInfo struct { + Version string + Dir string + } + Expect(json.Unmarshal(out, &modInfo)).To(Succeed()) + Expect(modInfo.Dir).NotTo(BeEmpty(), "azcore module directory not found") + + transportFile := filepath.Join(modInfo.Dir, "runtime", "transport_default_http_client.go") + content, err := os.ReadFile(transportFile) + Expect(err).NotTo(HaveOccurred(), + "could not read azcore transport file at %s", transportFile) + + actualHash := fmt.Sprintf("%x", sha256.Sum256(content)) + Expect(actualHash).To(Equal(pinnedHash), + "azcore transport defaults changed in %s (last reviewed at %s).\n"+ + "Diff runtime/transport_default_http_client.go, update buildClientOptions "+ + "in storage_client.go if needed, then update pinnedVersion and pinnedHash in this test.", + modInfo.Version, pinnedVersion, + ) + }) +}) diff --git a/azurebs/config/config.go b/azurebs/config/config.go index 14070943..e7b8ed9b 100644 --- a/azurebs/config/config.go +++ b/azurebs/config/config.go @@ -3,11 +3,17 @@ package config import ( "encoding/json" "errors" + "fmt" "io" + "strconv" + "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" ) +var errorNonPositiveHTTPRequestTimeout = errors.New("http_request_timeout must be greater than 0") +var errorNonPositiveHTTPResponseHeaderTimeout = errors.New("http_response_header_timeout must be greater than 0") + const storage cloud.ServiceName = "storage" var cloudConfig cloud.Configuration @@ -27,11 +33,13 @@ func init() { } type AZStorageConfig struct { - AccountName string `json:"account_name"` - AccountKey string `json:"account_key"` - ContainerName string `json:"container_name"` - Environment string `json:"environment"` - Timeout string `json:"put_timeout_in_seconds"` + AccountName string `json:"account_name"` + AccountKey string `json:"account_key"` + ContainerName string `json:"container_name"` + Environment string `json:"environment"` + Timeout string `json:"put_timeout_in_seconds"` + HTTPRequestTimeout string `json:"http_request_timeout"` + HTTPResponseHeaderTimeout string `json:"http_response_header_timeout"` } // NewFromReader returns a new azure-storage-cli configuration struct from the contents of reader. @@ -53,6 +61,14 @@ func NewFromReader(reader io.Reader) (AZStorageConfig, error) { return AZStorageConfig{}, err } + if _, err := config.HTTPRequestTimeoutValue(); err != nil { + return AZStorageConfig{}, err + } + + if _, err := config.HTTPResponseHeaderTimeoutValue(); err != nil { + return AZStorageConfig{}, err + } + return config, nil } @@ -74,3 +90,35 @@ func (c *AZStorageConfig) configureCloud() error { } return nil } + +// parseOptionalPositiveDuration parses a Go duration string (e.g. "30s", "2m"). +// An empty value means "unset" and returns a zero duration with no error. +// A bare number without a unit is rejected, as is a non-positive duration. +func parseOptionalPositiveDuration(fieldName, value string, nonPositiveErr error) (time.Duration, error) { + if value == "" { + return 0, nil + } + + if _, err := strconv.ParseFloat(value, 64); err == nil { + return 0, fmt.Errorf("invalid %s: missing duration unit", fieldName) + } + + d, err := time.ParseDuration(value) + if err != nil { + return 0, fmt.Errorf("invalid %s: %w", fieldName, err) + } + + if d <= 0 { + return 0, nonPositiveErr + } + + return d, nil +} + +func (c AZStorageConfig) HTTPRequestTimeoutValue() (time.Duration, error) { + return parseOptionalPositiveDuration("http_request_timeout", c.HTTPRequestTimeout, errorNonPositiveHTTPRequestTimeout) +} + +func (c AZStorageConfig) HTTPResponseHeaderTimeoutValue() (time.Duration, error) { + return parseOptionalPositiveDuration("http_response_header_timeout", c.HTTPResponseHeaderTimeout, errorNonPositiveHTTPResponseHeaderTimeout) +} diff --git a/azurebs/config/config_test.go b/azurebs/config/config_test.go index 9f54c4f5..8944d87d 100644 --- a/azurebs/config/config_test.go +++ b/azurebs/config/config_test.go @@ -3,6 +3,7 @@ package config_test import ( "bytes" "errors" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -87,6 +88,69 @@ var _ = Describe("Config", func() { }) }) }) + + Context("http timeouts", func() { + DescribeTable("HTTPRequestTimeoutValue", + func(value string, expected time.Duration, errSubstring string) { + c := config.AZStorageConfig{HTTPRequestTimeout: value} + result, err := c.HTTPRequestTimeoutValue() + if errSubstring == "" { + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(expected)) + } else { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(errSubstring)) + } + }, + Entry("empty means unset", "", time.Duration(0), ""), + Entry("valid duration", "30s", 30*time.Second, ""), + Entry("valid minutes", "2m", 2*time.Minute, ""), + Entry("bare number is rejected", "30", time.Duration(0), "missing duration unit"), + Entry("garbage is rejected", "abc", time.Duration(0), "invalid http_request_timeout"), + Entry("zero is rejected", "0s", time.Duration(0), "must be greater than 0"), + Entry("negative is rejected", "-5s", time.Duration(0), "must be greater than 0"), + ) + + DescribeTable("HTTPResponseHeaderTimeoutValue", + func(value string, expected time.Duration, errSubstring string) { + c := config.AZStorageConfig{HTTPResponseHeaderTimeout: value} + result, err := c.HTTPResponseHeaderTimeoutValue() + if errSubstring == "" { + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(expected)) + } else { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(errSubstring)) + } + }, + Entry("empty means unset", "", time.Duration(0), ""), + Entry("valid duration", "10s", 10*time.Second, ""), + Entry("bare number is rejected", "10", time.Duration(0), "missing duration unit"), + Entry("garbage is rejected", "abc", time.Duration(0), "invalid http_response_header_timeout"), + Entry("zero is rejected", "0s", time.Duration(0), "must be greater than 0"), + Entry("negative is rejected", "-5s", time.Duration(0), "must be greater than 0"), + ) + + It("NewFromReader accepts valid timeout strings", func() { + configJson := []byte(`{"http_request_timeout": "30s", "http_response_header_timeout": "10s"}`) + c, err := config.NewFromReader(bytes.NewReader(configJson)) + Expect(err).ToNot(HaveOccurred()) + Expect(c.HTTPRequestTimeout).To(Equal("30s")) + Expect(c.HTTPResponseHeaderTimeout).To(Equal("10s")) + }) + + It("NewFromReader rejects an invalid http_request_timeout", func() { + configJson := []byte(`{"http_request_timeout": "30"}`) + _, err := config.NewFromReader(bytes.NewReader(configJson)) + Expect(err).To(MatchError(ContainSubstring("missing duration unit"))) + }) + + It("NewFromReader rejects an invalid http_response_header_timeout", func() { + configJson := []byte(`{"http_response_header_timeout": "-1s"}`) + _, err := config.NewFromReader(bytes.NewReader(configJson)) + Expect(err).To(MatchError(ContainSubstring("must be greater than 0"))) + }) + }) }) type explodingReader struct{} diff --git a/azurebs/integration/assertions.go b/azurebs/integration/assertions.go index a52f7859..9cf456d9 100644 --- a/azurebs/integration/assertions.go +++ b/azurebs/integration/assertions.go @@ -122,6 +122,32 @@ func AssertNegativeTimeoutIsError(cliPath string, cfg *config.AZStorageConfig) { Expect(sess.Err).Should(gbytes.Say(`"msg":"Invalid time, need at least 1 second"`)) } +func AssertPutHTTPRequestTimeoutFires(cliPath string, cfg *config.AZStorageConfig) { + cfg2 := *cfg + cfg2.HTTPRequestTimeout = "1ms" // far too short to complete a real upload + configPath := MakeConfigFile(&cfg2) + defer os.Remove(configPath) //nolint:errcheck + + const mb = 1024 * 1024 + big := bytes.Repeat([]byte("x"), 100*mb) + content := MakeContentFile(string(big)) + defer os.Remove(content) //nolint:errcheck + blob := GenerateRandomString() + + sess, err := RunCli(cliPath, configPath, storageType, "put", content, blob) + Expect(err).ToNot(HaveOccurred()) + Expect(sess.ExitCode()).ToNot(BeZero()) + // The http.Client.Timeout trips at the transport layer, so the failure is + // the net/http client-timeout error rather than the operation-level + // "timeout of X reached while uploading" message. + consoleOutput := string(sess.Err.Contents()) + Expect(consoleOutput).To(ContainSubstring("upload failure")) + Expect(consoleOutput).To(Or( + ContainSubstring("Client.Timeout"), + ContainSubstring("context deadline exceeded"), + )) +} + func AssertSignedURLTimeouts(cliPath string, cfg *config.AZStorageConfig) { configPath := MakeConfigFile(cfg) defer os.Remove(configPath) //nolint:errcheck diff --git a/azurebs/integration/general_azure_test.go b/azurebs/integration/general_azure_test.go index bedc8a5e..44f8ecbf 100644 --- a/azurebs/integration/general_azure_test.go +++ b/azurebs/integration/general_azure_test.go @@ -43,6 +43,10 @@ var _ = Describe("General testing for all Azure regions", func() { func(cfg *config.AZStorageConfig) { integration.AssertPutHonorsCustomTimeout(cliPath, cfg) }, configurations, ) + DescribeTable("Assert Put HTTP Request Timeout Fires", + func(cfg *config.AZStorageConfig) { integration.AssertPutHTTPRequestTimeoutFires(cliPath, cfg) }, + configurations, + ) DescribeTable("Assert Put Times Out", func(cfg *config.AZStorageConfig) { integration.AssertPutTimesOut(cliPath, cfg) }, configurations, diff --git a/go.mod b/go.mod index 7c20d661..f611dec2 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/maxbrunsfeld/counterfeiter/v6 v6.13.0 github.com/onsi/ginkgo/v2 v2.32.2 github.com/onsi/gomega v1.43.0 + golang.org/x/net v0.59.0 golang.org/x/oauth2 v0.37.0 google.golang.org/api v0.297.0 ) @@ -90,7 +91,6 @@ require ( golang.org/x/crypto v0.57.0 // indirect golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 // indirect golang.org/x/mod v0.41.0 // indirect - golang.org/x/net v0.59.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect