diff --git a/README.md b/README.md index d42d3e2..2535c86 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ If not, a convenience wrapper for the storage is returned. | Backend | StreamReader | StreamWriter | | --- | --- | --- | | S3 | ✔ | ✔ | +| Azure | ✔ | ✔ | | Filesystem | ✔ | ✔ | | Memory | ✖ | ✖ | diff --git a/backends/azure/azure.go b/backends/azure/azure.go new file mode 100644 index 0000000..72bfd0f --- /dev/null +++ b/backends/azure/azure.go @@ -0,0 +1,469 @@ +package azure + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "sort" + "strings" + "sync" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/bloberror" + "github.com/PowerDNS/go-tlsconfig" + "github.com/PowerDNS/simpleblob" + "github.com/go-logr/logr" + "github.com/sirupsen/logrus" +) + +// Azure blob implementation examples can be found here: +//https://github.com/Azure/azure-sdk-for-go/blob/main/sdk/storage/azblob/examples_test.go + +const ( + // DefaultInitTimeout is the time we allow for initialisation, like credential + // checking and bucket creation. We define this here, because we do not + // pass a context when initialising a plugin. + DefaultInitTimeout = 20 * time.Second + // UpdateMarkerFilename is the filename used for the update marker functionality + UpdateMarkerFilename = "update-marker" + // DefaultUpdateMarkerForceListInterval is the default value for + // UpdateMarkerForceListInterval. + DefaultUpdateMarkerForceListInterval = 5 * time.Minute + // DefaultDisableContentMd5 : disable sending the Content-MD5 header + DefaultDisableContentMd5 = false + // Max number of concurrent uploads to be performed to upload the file + DefaultConcurrency = 1 +) + +type Options struct { + + // AccountName and AccountKey are statically defined here. + AccountName string `yaml:"account_name"` + AccountKey string `yaml:"account_key"` + + UseSharedKey bool `yaml:"use_shared_key"` + + // Azure blob container name. If it doesn't exist it will be automatically created if `CreateContainer` is true. + Container string `yaml:"container"` + + // CreateBucket tells us to try to create the bucket + CreateContainer bool `yaml:"create_container"` + + // GlobalPrefix is a prefix applied to all operations, allowing work within a prefix + // seamlessly + GlobalPrefix string `yaml:"global_prefix"` + + // EndpointURL can be set to something like "http://localhost:9000" for local testing + EndpointURL string `yaml:"endpoint_url"` + + // DisableContentMd5 defines whether to disable sending the Content-MD5 header + DisableContentMd5 bool `yaml:"disable_send_content_md5"` + + // TLS allows customising the TLS configuration + // See https://github.com/PowerDNS/go-tlsconfig for the available options + TLS tlsconfig.Config `yaml:"tls"` + + // InitTimeout is the time we allow for initialisation, like credential + // checking and bucket creation. It defaults to DefaultInitTimeout, which + // is currently 20s. + InitTimeout time.Duration `yaml:"init_timeout"` + + // UseUpdateMarker makes the backend write and read a file to determine if + // it can cache the last List command. The file contains the name of the + // last file stored or deleted. + // This can reduce the number of LIST commands sent to Azure, replacing them + // with GET commands that are about 12x cheaper. + // If enabled, it MUST be enabled on all instances! + // CAVEAT: This will NOT work correctly if the bucket itself is replicated + // in an active-active fashion between data centers! In that case + // do not enable this option. + UseUpdateMarker bool `yaml:"use_update_marker"` + // UpdateMarkerForceListInterval is used when UseUpdateMarker is enabled. + // A LIST command will be sent when this interval has passed without a + // change in marker, to ensure a full sync even if the marker would for + // some reason get out of sync. + UpdateMarkerForceListInterval time.Duration `yaml:"update_marker_force_list_interval"` + + // Concurrency defines the max number of concurrent uploads to be performed to upload the file. + // Each concurrent upload will create a buffer of size BlockSize. The default value is one. + // https://github.com/Azure/azure-sdk-for-go/blob/e5c902ce7aca5aa0f4c7bb7e46c18c8fc91ad458/sdk/storage/azblob/blockblob/models.go#L264 + Concurrency int `yaml:"concurrency"` + + // Not loaded from YAML + Logger logr.Logger `yaml:"-"` +} + +type Backend struct { + opt Options + client *azblob.Client + log logr.Logger + markerName string + + mu sync.Mutex + lastMarker string + lastList simpleblob.BlobList + lastTime time.Time +} + +func (o Options) Check() error { + if o.Container == "" { + return fmt.Errorf("azure storage.options: container is required") + } + + if o.UseSharedKey { + hasSecretsCreds := o.AccountName != "" && o.AccountKey != "" + if !hasSecretsCreds { + return fmt.Errorf("azure storage.options: account_name and account_key are required when use_shared_key is true") + } + } + + return nil +} + +// New creates a new backend instance. +// The lifetime of the context passed in must span the lifetime of the whole +// backend instance, not just the init time, so do not set any timeout on it! +func New(ctx context.Context, opt Options) (*Backend, error) { + if opt.InitTimeout == 0 { + opt.InitTimeout = DefaultInitTimeout + } + + if opt.UpdateMarkerForceListInterval == 0 { + opt.UpdateMarkerForceListInterval = DefaultUpdateMarkerForceListInterval + } + + if opt.Concurrency == 0 { + opt.Concurrency = DefaultConcurrency + } + + if err := opt.Check(); err != nil { + return nil, err + } + + log := opt.Logger + if log.GetSink() == nil { + log = logr.Discard() + } + log = log.WithName("azure") + + var endpoint string + + accountName := opt.AccountName + + if opt.EndpointURL != "" { + endpoint = opt.EndpointURL + } else { + endpoint = fmt.Sprintf("https://%s.blob.core.windows.net", accountName) + } + + var client *azblob.Client + + // Default path: let the Azure SDK decide how to authenticate + cred, err := azidentity.NewDefaultAzureCredential(nil) + if err != nil { + return nil, err + } + + client, err = azblob.NewClient(endpoint, cred, nil) + if err != nil { + return nil, err + } + + // If UseSharedKey is true, authenticate using shared key credentials with AccountName and AccountKey. + // Otherwise, DefaultAzureCredential is used (service principal via environment variables). + // https://github.com/Azure/azure-sdk-for-go/blob/main/sdk/azidentity/README.md#service-principal-with-secret + if opt.UseSharedKey { + if opt.AccountName == "" || opt.AccountKey == "" { + return nil, errors.New("AccountName and AccountKey are required when UseSharedKey is true") + } + + cred, err := azblob.NewSharedKeyCredential(opt.AccountName, opt.AccountKey) + if err != nil { + return nil, err + } + + client, err = azblob.NewClientWithSharedKeyCredential(endpoint, cred, nil) + if err != nil { + return nil, err + } + } + + if opt.CreateContainer { + // Create bucket if it does not exist + metricCalls.WithLabelValues("create-container").Inc() + metricLastCallTimestamp.WithLabelValues("create-container").SetToCurrentTime() + + _, err := client.CreateContainer(ctx, opt.Container, &azblob.CreateContainerOptions{}) + + if err != nil { + if bloberror.HasCode(err, bloberror.ContainerAlreadyExists) { + logrus.WithField("storage_type", "Azure").Infof("Container already exists: %s", opt.Container) + } else { + return nil, err + } + } + } + + b := &Backend{ + opt: opt, + client: client, + log: log, + } + + b.setGlobalPrefix(opt.GlobalPrefix) + + return b, nil +} + +func (b *Backend) List(ctx context.Context, prefix string) (blobList simpleblob.BlobList, err error) { + // Handle global prefix + combinedPrefix := b.prependGlobalPrefix(prefix) + + if !b.opt.UseUpdateMarker { + return b.doList(ctx, combinedPrefix) + } + + // Using Load, that will itself prepend the global prefix to the marker name. + // So we're using the raw marker name here. + m, err := b.Load(ctx, UpdateMarkerFilename) + + notFound := errors.Is(err, os.ErrNotExist) + + if err != nil && !notFound { + return nil, err + } + + upstreamMarker := string(m) + + b.mu.Lock() + mustUpdate := b.lastList == nil || + upstreamMarker != b.lastMarker || + time.Since(b.lastTime) >= b.opt.UpdateMarkerForceListInterval || + notFound + blobs := b.lastList + b.mu.Unlock() + + if !mustUpdate { + return blobs.WithPrefix(prefix), nil + } + + blobs, err = b.doList(ctx, b.opt.GlobalPrefix) // We want to cache all, so no prefix + if err != nil { + return nil, err + } + + b.mu.Lock() + b.lastMarker = upstreamMarker + b.lastList = blobs + b.lastTime = time.Now() + b.mu.Unlock() + + return blobs.WithPrefix(prefix), nil +} + +// convertAzureError takes an error from Azure SDK and converts it to +// os.ErrNotExist when appropriate (BlobNotFound, ContainerNotFound, ResourceNotFound). +// If the error is not a "not found" error, it is returned as is. +func convertAzureError(err error) error { + if err == nil { + return nil + } + if bloberror.HasCode(err, + bloberror.BlobNotFound, + bloberror.ContainerNotFound, + bloberror.ResourceNotFound, + ) { + return fmt.Errorf("%w: %s", os.ErrNotExist, err.Error()) + } + return err +} + +func (b *Backend) doList(ctx context.Context, prefix string) (simpleblob.BlobList, error) { + var blobs simpleblob.BlobList + + // Runes to strip from blob names for GlobalPrefix + // This is fine, because we can trust the API to only return with the prefix. + gpEndIndex := len(b.opt.GlobalPrefix) + + // Use Azure SDK to get blobs from container + blobPager := b.client.NewListBlobsFlatPager(b.opt.Container, nil) + + for blobPager.More() { + resp, err := blobPager.NextPage(ctx) + + if err != nil { + return nil, err + } + + // if empty... + if resp.Segment == nil { + // Container is empty + return blobs, nil + } + + for _, v := range resp.Segment.BlobItems { + blobName := *v.Name + + // We have to manually check for prefix since Azure doesn't support querying by prefix + if !strings.HasPrefix(blobName, prefix) { + continue + } + + if blobName == b.markerName { + continue + } + + size := *v.Properties.ContentLength + + if gpEndIndex > 0 { + blobName = blobName[gpEndIndex:] + } + + blobs = append(blobs, simpleblob.Blob{Name: blobName, Size: size}) + } + } + + // Sort explicitly. + sort.Sort(blobs) + + return blobs, nil +} + +// Load retrieves the content of the object identified by name from the Azure container +// configured in b. +func (b *Backend) Load(ctx context.Context, name string) ([]byte, error) { + name = b.prependGlobalPrefix(name) + + r, err := b.doLoadReader(ctx, name) + if err != nil { + return nil, err + } + defer r.Close() + + p, err := io.ReadAll(r) + if err != nil { + return nil, err + } + + return p, nil +} + +func (b *Backend) doLoadReader(ctx context.Context, name string) (io.ReadCloser, error) { + metricCalls.WithLabelValues("load").Inc() + metricLastCallTimestamp.WithLabelValues("load").SetToCurrentTime() + + // Download the blob's contents and ensure that the download worked properly + blobDownloadResponse, err := b.client.DownloadStream(ctx, b.opt.Container, name, nil) + if err = convertAzureError(err); err != nil { + metricCallErrors.WithLabelValues("load").Inc() + return nil, err + } + + // Use the bytes.Buffer object to read the downloaded data. + // RetryReaderOptions has a lot of in-depth tuning abilities, but for the sake of simplicity, we'll omit those here. + // Convert the response body to a Reader + reader := io.Reader(blobDownloadResponse.Body) + + return io.NopCloser(reader), nil +} + +// Store sets the content of the object identified by name to the content +// of data, in the Azure container configured in b. +func (b *Backend) Store(ctx context.Context, name string, data []byte) error { + // Prepend global prefix + name = b.prependGlobalPrefix(name) + + info, err := b.doStore(ctx, name, data) + + if err != nil { + return err + } + + return b.setMarker(ctx, name, string(*info.ETag), false) +} + +// doStore is a convenience wrapper around doStoreReader. +func (b *Backend) doStore(ctx context.Context, name string, data []byte) (azblob.UploadStreamResponse, error) { + return b.doStoreReader(ctx, name, bytes.NewReader(data), int64(len(data))) +} + +// doStoreReader stores data with key name in Azure blob, using r as a source for data. +// The value of size may be -1, in case the size is not known. +func (b *Backend) doStoreReader(ctx context.Context, name string, r io.Reader, size int64) (azblob.UploadStreamResponse, error) { + metricCalls.WithLabelValues("store").Inc() + metricLastCallTimestamp.WithLabelValues("store").SetToCurrentTime() + + uploadStreamOptions := &azblob.UploadStreamOptions{ + Concurrency: b.opt.Concurrency, + } + + // Perform UploadStream + resp, err := b.client.UploadStream(ctx, b.opt.Container, name, r, uploadStreamOptions) + + if err != nil { + metricCallErrors.WithLabelValues("store").Inc() + return azblob.UploadStreamResponse{}, err + } + + return resp, err +} + +// Delete removes the object identified by name from the Azure Container +// configured in b. +func (b *Backend) Delete(ctx context.Context, name string) error { + // Prepend global prefix + name = b.prependGlobalPrefix(name) + + if err := b.doDelete(ctx, name); err != nil { + return err + } + return b.setMarker(ctx, name, "", true) +} + +func (b *Backend) doDelete(ctx context.Context, name string) error { + metricCalls.WithLabelValues("delete").Inc() + metricLastCallTimestamp.WithLabelValues("delete").SetToCurrentTime() + + _, err := b.client.DeleteBlob(ctx, b.opt.Container, name, nil) + + if err = convertAzureError(err); err != nil { + // Delete is idempotent - if blob doesn't exist, that's fine + if errors.Is(err, os.ErrNotExist) { + return nil + } + metricCallErrors.WithLabelValues("delete").Inc() + return err + } + + return nil +} + +// setGlobalPrefix updates the global prefix in b and the cached marker name, +// so it can be dynamically changed in tests. +func (b *Backend) setGlobalPrefix(prefix string) { + b.opt.GlobalPrefix = prefix + b.markerName = b.prependGlobalPrefix(UpdateMarkerFilename) +} + +// prependGlobalPrefix prepends the GlobalPrefix to the name/prefix +// passed as input +func (b *Backend) prependGlobalPrefix(name string) string { + return b.opt.GlobalPrefix + name +} + +func init() { + simpleblob.RegisterBackend("azure", func(ctx context.Context, p simpleblob.InitParams) (simpleblob.Interface, error) { + var opt Options + if err := p.OptionsThroughYAML(&opt); err != nil { + return nil, err + } + opt.Logger = p.Logger + + return New(ctx, opt) + }) +} diff --git a/backends/azure/azure_test.go b/backends/azure/azure_test.go new file mode 100644 index 0000000..bf75d36 --- /dev/null +++ b/backends/azure/azure_test.go @@ -0,0 +1,151 @@ +package azure + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/azurite" + + "github.com/PowerDNS/simpleblob/tester" +) + +var azuriteContainer *azurite.AzuriteContainer + +func getBackend(ctx context.Context, t *testing.T) (b *Backend) { + testcontainers.SkipIfProviderIsNotHealthy(t) + + azuriteContainer, err := azurite.Run(ctx, "mcr.microsoft.com/azure-storage/azurite") + t.Cleanup(func() { + if err := testcontainers.TerminateContainer(azuriteContainer); err != nil { + log.Printf("failed to terminate container: %s", err) + } + }) + + if err != nil { + t.Fatalf("failed to start container: %v", err) + } + + state, err := azuriteContainer.State(ctx) + if err != nil { + t.Fatalf("failed to get container state: %v", err) + } + + t.Log(state.Running) + + // using the built-in shared key credential type + cred, err := azblob.NewSharedKeyCredential(azurite.AccountName, azurite.AccountKey) + if err != nil { + t.Fatalf("failed to create shared key credential: %v", err) + } + + // create an azblob.Client for the specified storage account that uses the above credentials + blobServiceURL := fmt.Sprintf("%s/%s", azuriteContainer.MustServiceURL(ctx, azurite.BlobService), azurite.AccountName) + + client, err := azblob.NewClientWithSharedKeyCredential(blobServiceURL, cred, nil) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + b, err = New(ctx, Options{ + EndpointURL: blobServiceURL, + AccountName: azurite.AccountName, + AccountKey: azurite.AccountKey, + Container: "test-container", + CreateContainer: true, + }) + + b.client = client + require.NoError(t, err) + + cleanStorage := func(ctx context.Context) { + blobs, err := b.List(ctx, "") + if err != nil { + t.Fatalf("Blobs list error: %v", err) + } + + for _, blob := range blobs { + err := b.Delete(ctx, blob.Name) + if err != nil { + t.Fatalf("Object delete error: %v", err) + } + } + + require.NoError(t, err) + } + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cleanStorage(ctx) + }) + cleanStorage(ctx) + + return b +} + +func tearDown(t *testing.T) { + if err := testcontainers.TerminateContainer(azuriteContainer); err != nil { + t.Fatalf("failed to terminate container: %v", err) + } +} + +func TestBackend(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + defer tearDown(t) + + b := getBackend(ctx, t) + tester.DoBackendTests(t, b) + assert.Len(t, b.lastMarker, 0) +} + +func TestBackend_marker(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + defer tearDown(t) + + b := getBackend(ctx, t) + b.opt.UseUpdateMarker = true + + tester.DoBackendTests(t, b) + assert.Regexp(t, "^foo-1:[A-Za-z0-9]*:[0-9]+:true$", b.lastMarker) + // ^ reflects last write operation of tester.DoBackendTestsAzure + // i.e. deleting "foo-1" + + // Marker file should have been written accordingly + markerFileContent, err := b.Load(ctx, UpdateMarkerFilename) + assert.NoError(t, err) + assert.EqualValues(t, b.lastMarker, markerFileContent) +} + +func TestBackend_globalprefix(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + defer tearDown(t) + + b := getBackend(ctx, t) + b.setGlobalPrefix("v5/") + + tester.DoBackendTests(t, b) + assert.Empty(t, b.lastMarker) +} + +func TestBackend_globalPrefixAndMarker(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + defer tearDown(t) + + // Start the backend over + b := getBackend(ctx, t) + b.setGlobalPrefix("v6/") + b.opt.UseUpdateMarker = true + + tester.DoBackendTests(t, b) + assert.NotEmpty(t, b.lastMarker) +} diff --git a/backends/azure/marker.go b/backends/azure/marker.go new file mode 100644 index 0000000..befbd25 --- /dev/null +++ b/backends/azure/marker.go @@ -0,0 +1,31 @@ +package azure + +import ( + "context" + "fmt" + "time" +) + +// setMarker puts name and etag into the object identified by +// UpdateMarkerFilename. +// An empty etag string means that the object identified by name was deleted. +// +// In case the UseUpdateMarker option is false, this function doesn't do +// anything and returns no error. +func (b *Backend) setMarker(ctx context.Context, name, etag string, isDel bool) error { + if !b.opt.UseUpdateMarker { + return nil + } + nanos := time.Now().UnixNano() + s := fmt.Sprintf("%s:%s:%d:%v", name, etag, nanos, isDel) + // Here, we're not using Store because markerName already has the global prefix. + _, err := b.doStore(ctx, b.markerName, []byte(s)) + if err != nil { + return err + } + b.mu.Lock() + defer b.mu.Unlock() + b.lastList = nil + b.lastMarker = s + return nil +} diff --git a/backends/azure/metrics.go b/backends/azure/metrics.go new file mode 100644 index 0000000..e38ca3c --- /dev/null +++ b/backends/azure/metrics.go @@ -0,0 +1,35 @@ +package azure + +import ( + "github.com/prometheus/client_golang/prometheus" +) + +var ( + metricLastCallTimestamp = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "storage_azure_call_timestamp_seconds", + Help: "UNIX timestamp of last Azure API call by method", + }, + []string{"method"}, + ) + metricCalls = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "storage_azure_call_total", + Help: "Azure API calls by method", + }, + []string{"method"}, + ) + metricCallErrors = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "storage_azure_call_error_total", + Help: "Azure API call errors by method", + }, + []string{"method"}, + ) +) + +func init() { + prometheus.MustRegister(metricLastCallTimestamp) + prometheus.MustRegister(metricCalls) + prometheus.MustRegister(metricCallErrors) +} diff --git a/backends/azure/stream.go b/backends/azure/stream.go new file mode 100644 index 0000000..2308d29 --- /dev/null +++ b/backends/azure/stream.go @@ -0,0 +1,81 @@ +package azure + +import ( + "context" + "io" + + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" + "github.com/PowerDNS/simpleblob" +) + +// NewReader satisfies StreamReader and provides a read streaming interface to +// a blob located on an Azure Storage container. +func (b *Backend) NewReader(ctx context.Context, name string) (io.ReadCloser, error) { + name = b.prependGlobalPrefix(name) + r, err := b.doLoadReader(ctx, name) + if err != nil { + return nil, err + } + return r, nil +} + +// NewWriter satisfies StreamWriter and provides a write streaming interface to +// a blob located on an Azure Storage container. +func (b *Backend) NewWriter(ctx context.Context, name string) (io.WriteCloser, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + name = b.prependGlobalPrefix(name) + pr, pw := io.Pipe() + w := &writerWrapper{ + ctx: ctx, + backend: b, + name: name, + pw: pw, + donePipe: make(chan struct{}), + } + go func() { + var err error + // The following call will return only on error or + // if the writing end of the pipe is closed. + // It is okay to write to w.info from this goroutine + // because it will only be used after w.donePipe is closed. + w.info, err = w.backend.doStoreReader(w.ctx, w.name, pr, -1) + _ = pr.CloseWithError(err) // Always returns nil. + close(w.donePipe) + }() + return w, nil +} + +// A writerWrapper implements io.WriteCloser and is returned by (*Backend).NewWriter. +type writerWrapper struct { + backend *Backend + + // We need to keep these around + // to write the marker in Close. + ctx context.Context + info azblob.UploadStreamResponse + name string + + // Writes are sent to this pipe + // and then written to Azure Blob Storage in a background goroutine. + pw *io.PipeWriter + donePipe chan struct{} +} + +func (w *writerWrapper) Write(p []byte) (int, error) { + // Not checking the status of ctx explicitly because it will be propagated + // from the reader goroutine. + return w.pw.Write(p) +} + +func (w *writerWrapper) Close() error { + select { + case <-w.donePipe: + return simpleblob.ErrClosed + default: + } + _ = w.pw.Close() // Always returns nil. + <-w.donePipe // Wait for doStoreReader to return and w.info to be set. + return w.backend.setMarker(w.ctx, w.name, string(*w.info.ETag), false) +} diff --git a/backends/azure/stream_test.go b/backends/azure/stream_test.go new file mode 100644 index 0000000..9799760 --- /dev/null +++ b/backends/azure/stream_test.go @@ -0,0 +1,201 @@ +package azure + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestStreamReader(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + defer tearDown(t) + + b := getBackend(ctx, t) + + // Test reading from non-existent blob + reader, err := b.NewReader(ctx, "does-not-exist") + assert.Error(t, err) + assert.Nil(t, reader) + assert.True(t, errors.Is(err, os.ErrNotExist), "expected os.ErrNotExist") + + // Store test data + testData := []byte("test data for streaming") + err = b.Store(ctx, "test-stream", testData) + assert.NoError(t, err) + + // Test successful reading + reader, err = b.NewReader(ctx, "test-stream") + assert.NoError(t, err) + defer reader.Close() + + data, err := io.ReadAll(reader) + assert.NoError(t, err) + assert.Equal(t, testData, data) + + // Test with global prefix + b.setGlobalPrefix("prefix/") + + // Store test data with prefix + err = b.Store(ctx, "test-stream-prefix", testData) + assert.NoError(t, err) + + // Read with prefix + reader, err = b.NewReader(ctx, "test-stream-prefix") + assert.NoError(t, err) + defer reader.Close() + + data, err = io.ReadAll(reader) + assert.NoError(t, err) + assert.Equal(t, testData, data) +} + +func TestStreamWriter(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + defer tearDown(t) + + b := getBackend(ctx, t) + + // Test streaming writer + writer, err := b.NewWriter(ctx, "test-stream-write") + assert.NoError(t, err) + + testData := []byte("test data for streaming writer") + n, err := writer.Write(testData) + assert.NoError(t, err) + assert.Equal(t, len(testData), n) + + // File should not exist before closing + _, err = b.Load(ctx, "test-stream-write") + assert.Error(t, err) + assert.True(t, errors.Is(err, os.ErrNotExist), "expected os.ErrNotExist") + + // Close to finalize the upload + err = writer.Close() + assert.NoError(t, err) + + // Verify data was written correctly + data, err := b.Load(ctx, "test-stream-write") + assert.NoError(t, err) + assert.Equal(t, testData, data) + + // Cannot write after close + _, err = writer.Write([]byte("more data")) + assert.Error(t, err) + + // Test with update marker + b.opt.UseUpdateMarker = true + + writer, err = b.NewWriter(ctx, "test-stream-marker") + assert.NoError(t, err) + + testData = []byte("test data with marker") + _, err = writer.Write(testData) + assert.NoError(t, err) + + err = writer.Close() + assert.NoError(t, err) + + // Verify marker is set + assert.NotEmpty(t, b.lastMarker) + + // Verify marker file is created + markerData, err := b.Load(ctx, UpdateMarkerFilename) + assert.NoError(t, err) + assert.Equal(t, b.lastMarker, string(markerData)) +} + +func TestStreamLargeData(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + defer tearDown(t) + + b := getBackend(ctx, t) + + // Create large test data (1MB) + size := 1024 * 1024 + largeData := make([]byte, size) + for i := range size { + largeData[i] = byte(i % 256) + } + + // Test writing large data + writer, err := b.NewWriter(ctx, "large-test") + assert.NoError(t, err) + + n, err := writer.Write(largeData) + assert.NoError(t, err) + assert.Equal(t, size, n) + + err = writer.Close() + assert.NoError(t, err) + + // Test reading large data + reader, err := b.NewReader(ctx, "large-test") + assert.NoError(t, err) + defer reader.Close() + + readData, err := io.ReadAll(reader) + assert.NoError(t, err) + assert.Equal(t, largeData, readData) + + // Test writing in chunks + writer, err = b.NewWriter(ctx, "chunk-test") + assert.NoError(t, err) + + chunkSize := 64 * 1024 + for i := 0; i < size; i += chunkSize { + end := i + chunkSize + if end > size { + end = size + } + _, err := writer.Write(largeData[i:end]) + assert.NoError(t, err) + } + + err = writer.Close() + assert.NoError(t, err) + + // Verify chunked write + chunkData, err := b.Load(ctx, "chunk-test") + assert.NoError(t, err) + assert.Equal(t, largeData, chunkData) +} + +func TestStreamMultipleWrites(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + defer tearDown(t) + + b := getBackend(ctx, t) + + // Test multiple writes + writer, err := b.NewWriter(ctx, "multi-write") + assert.NoError(t, err) + + buffer := bytes.NewBuffer(nil) + + for i := range 10 { + data := []byte("part " + string(rune('0'+i))) + buffer.Write(data) // Keep track of expected data + + n, err := writer.Write(data) + assert.NoError(t, err) + assert.Equal(t, len(data), n) + } + + err = writer.Close() + assert.NoError(t, err) + + // Verify concatenated writes + data, err := b.Load(ctx, "multi-write") + assert.NoError(t, err) + assert.Equal(t, buffer.Bytes(), data) +} diff --git a/go.mod b/go.mod index a93684d..98cfc6d 100644 --- a/go.mod +++ b/go.mod @@ -5,19 +5,26 @@ go 1.24.0 toolchain go1.25.1 require ( + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1 github.com/PowerDNS/go-tlsconfig v0.0.0-20221101135152-0956853b28df github.com/go-logr/logr v1.4.3 github.com/minio/minio-go/v7 v7.0.95 github.com/prometheus/client_golang v1.23.0 + github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.38.0 + github.com/testcontainers/testcontainers-go/modules/azurite v0.35.0 github.com/testcontainers/testcontainers-go/modules/minio v0.38.0 gopkg.in/yaml.v2 v2.4.0 ) require ( dario.cat/mergo v1.0.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect @@ -40,6 +47,7 @@ require ( github.com/go-ole/go-ole v1.2.6 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect github.com/klauspost/compress v1.18.0 // indirect @@ -61,6 +69,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/philhofer/fwd v1.2.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect @@ -69,7 +78,6 @@ require ( github.com/prometheus/procfs v0.16.1 // indirect github.com/rs/xid v1.6.0 // indirect github.com/shirou/gopsutil/v4 v4.25.5 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect github.com/tinylib/msgp v1.3.0 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect @@ -79,10 +87,10 @@ require ( go.opentelemetry.io/otel v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect - golang.org/x/crypto v0.39.0 // indirect - golang.org/x/net v0.41.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.26.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c0b4e3d..1c1809b 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,28 @@ dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 h1:5YTBM8QDVIBN3sxBil89WfdAAqDZbyJTgh688DSxX5w= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 h1:KpMC6LFL7mqpExyMC9jVOYRiVhLmamjeZfRsUpB7l4s= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0/go.mod h1:J7MUC/wtRpfGVbQ5sIItY5/FuVWmvzlY21WAOfQnq/I= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= +github.com/Azure/azure-sdk-for-go/sdk/data/aztables v1.2.0 h1:aJG+Jxd9/rrLwf8R1Ko0RlOBTJASs/lGQJ8b9AdlKTc= +github.com/Azure/azure-sdk-for-go/sdk/data/aztables v1.2.0/go.mod h1:41ONblJrPxDcnVr+voS+3xXWy/KnZLh+7zY5s6woAlQ= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.0 h1:LR0kAX9ykz8G4YgLCaRDVJ3+n43R8MneB5dTy2konZo= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.0/go.mod h1:DWAciXemNf++PQJLeXUB4HHH5OpsAh12HZnu2wXE1jA= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1 h1:lhZdRq7TIx0GJQvSyX2Si406vrYsov2FXGp/RnSEtcs= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1/go.mod h1:8cl44BDmi+effbARHMQjgOKA2AYvcohNm7KEt42mSV8= +github.com/Azure/azure-sdk-for-go/sdk/storage/azqueue v1.0.0 h1:lJwNFV+xYjHREUTHJKx/ZF6CJSt9znxmLw9DqSTvyRU= +github.com/Azure/azure-sdk-for-go/sdk/storage/azqueue v1.0.0/go.mod h1:GfT0aGew8Qj5yiQVqOO5v7N8fanbJGyUoHqXg56qcVY= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 h1:XkkQbfMyuH2jTSjQjSoihryI8GINRcs4xp8lNawg0FI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/PowerDNS/go-tlsconfig v0.0.0-20221101135152-0956853b28df h1:WMUClevRPgmFNmyurAcMj1BRbcMRMi4Zv5H30U6CNMk= @@ -56,6 +76,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -63,6 +85,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -112,6 +136,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -142,6 +168,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/testcontainers/testcontainers-go v0.38.0 h1:d7uEapLcv2P8AvH8ahLqDMMxda2W9gQN1nRbHS28HBw= github.com/testcontainers/testcontainers-go v0.38.0/go.mod h1:C52c9MoHpWO+C4aqmgSU+hxlR5jlEayWtgYrb8Pzz1w= +github.com/testcontainers/testcontainers-go/modules/azurite v0.35.0 h1:gUZ25e1DVE/0+ZZ0nupsIo+C1j7UNloN7Pkg3w6tceI= +github.com/testcontainers/testcontainers-go/modules/azurite v0.35.0/go.mod h1:2Fc67EpyOEexLAF99zhSuzu9H22zd83pkjxEHHTtHf4= github.com/testcontainers/testcontainers-go/modules/minio v0.38.0 h1:iBxk0f9YEVZkC0CoiI8UsHg+zC9eWQudng7nBFkVkzU= github.com/testcontainers/testcontainers-go/modules/minio v0.38.0/go.mod h1:LAxD0g8YUvs08zyLlEzpD81lTJSyADAYsEGPlEI6diY= github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= @@ -177,16 +205,16 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -197,16 +225,17 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=