Explorar el Código

create basic version of exporting data to a CSV file in cloud storage

Signed-off-by: r2k1 <yokree@gmail.com>
r2k1 hace 3 años
padre
commit
dbcd593029

+ 2 - 1
go.mod

@@ -45,6 +45,7 @@ require (
 	github.com/scaleway/scaleway-sdk-go v1.0.0-beta.9
 	github.com/spf13/cobra v1.2.1
 	github.com/spf13/viper v1.8.1
+	github.com/stretchr/testify v1.8.1
 	go.etcd.io/bbolt v1.3.5
 	golang.org/x/exp v0.0.0-20221031165847-c99f073a8326
 	golang.org/x/oauth2 v0.1.0
@@ -123,6 +124,7 @@ require (
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
 	github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
 	github.com/pelletier/go-toml v1.9.3 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/prometheus/procfs v0.8.0 // indirect
 	github.com/rs/xid v1.3.0 // indirect
 	github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24 // indirect
@@ -131,7 +133,6 @@ require (
 	github.com/spf13/cast v1.3.1 // indirect
 	github.com/spf13/jwalterweatherman v1.1.0 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
-	github.com/stretchr/testify v1.8.1 // indirect
 	github.com/subosito/gotenv v1.2.0 // indirect
 	go.opencensus.io v0.23.0 // indirect
 	go.uber.org/atomic v1.10.0 // indirect

+ 297 - 0
pkg/costmodel/allocation_csv.go

@@ -0,0 +1,297 @@
+package costmodel
+
+import (
+	"context"
+	"encoding/csv"
+	"io"
+	"os"
+	"strconv"
+	"time"
+
+	"github.com/pkg/errors"
+
+	"github.com/opencost/opencost/pkg/kubecost"
+	"github.com/opencost/opencost/pkg/log"
+)
+
+type CloudStorage interface {
+	FileReplace(ctx context.Context, f *os.File, path string) error
+	FileDownload(ctx context.Context, path string) (*os.File, error)
+	FileExists(ctx context.Context, path string) (bool, error)
+}
+
+type AllocationModel interface {
+	ComputeAllocation(start, end time.Time, resolution time.Duration) (*kubecost.AllocationSet, error)
+	DateRange(ctx context.Context) (time.Time, time.Time, error)
+}
+
+// UpdateCSVWorker launches a worker that updates CSV file in cloud storage with allocation data
+// It updates data immediately on launch and then runs every day at 00:10 UTC
+// It expected to run a goroutine
+func UpdateCSVWorker(ctx context.Context, storage CloudStorage, model AllocationModel, path string) error {
+	// perform first update immediately
+	nextRunAt := time.Now()
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case <-time.After(nextRunAt.Sub(time.Now())):
+			dayBefore := time.Date(nextRunAt.Year(), nextRunAt.Month(), nextRunAt.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, -1)
+			err := UpdateCSV(ctx, storage, model, path, dayBefore)
+			if err != nil {
+				// it's background worker, log error and carry on, maybe next time it will work
+				log.Errorf("Error updating CSV: %s", err)
+			}
+			now := time.Now()
+			// next launch is at 00:10 UTC tomorrow
+			// extra 10 minutes is to let prometheus to collect all the data for the previous day
+			nextRunAt = time.Date(now.Year(), now.Month(), now.Day(), 0, 10, 0, 0, time.UTC).AddDate(0, 0, 1)
+		}
+	}
+}
+
+func UpdateCSV(ctx context.Context, storage CloudStorage, model AllocationModel, path string, date time.Time) error {
+	exporter := &csvExporter{
+		Storage:  storage,
+		Model:    model,
+		FilePath: path,
+	}
+	return exporter.Update(ctx, date)
+}
+
+type csvExporter struct {
+	Storage  CloudStorage
+	Model    AllocationModel
+	FilePath string
+}
+
+// TODO: logging
+func (e *csvExporter) Update(ctx context.Context, date time.Time) error {
+	exist, err := e.Storage.FileExists(ctx, e.FilePath)
+	if err != nil {
+		return err
+	}
+
+	dateExport, err := e.writeCSVToFile(ctx, date)
+	if err != nil {
+		return err
+	}
+	defer dateExport.Close()
+
+	var result *os.File
+	if exist {
+		// merge existing file with new data
+		previousExport, err := e.Storage.FileDownload(ctx, e.FilePath)
+		if err != nil {
+			return err
+		}
+		defer previousExport.Close()
+
+		result, err = os.CreateTemp("", "cost-model-*.csv")
+		if err != nil {
+			return errors.Wrap(err, "creating temp file")
+		}
+		err = mergeCSV([]*os.File{previousExport, dateExport}, result)
+		if err != nil {
+			return err
+		}
+	} else {
+		// no existing file, create a new one
+		result = dateExport
+	}
+
+	// we just finished writing to the file, so we need to seek to the beginning, so we can read from it
+	_, err = result.Seek(0, io.SeekStart)
+	if err != nil {
+		return errors.Wrap(err, "seeking to the beginning of the csv file")
+	}
+
+	err = e.Storage.FileReplace(ctx, result, e.FilePath)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (e *csvExporter) writeCSVToFile(ctx context.Context, date time.Time) (*os.File, error) {
+	f, err := os.CreateTemp("", "cost-model-*.csv")
+	if err != nil {
+		return nil, errors.Wrap(err, "creating temp file")
+	}
+
+	err = e.writeCSVToWriter(ctx, f, date)
+	if err != nil {
+		return nil, err
+	}
+	return f, nil
+}
+
+func (e *csvExporter) writeCSVToWriter(ctx context.Context, w io.Writer, date time.Time) error {
+	start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC)
+	end := start.AddDate(0, 1, 0)
+	data, err := e.Model.ComputeAllocation(start, end, 5*time.Minute)
+	if err != nil {
+		return err
+	}
+	log.Infof("data: %d", len(data.Allocations))
+	csvWriter := csv.NewWriter(w)
+	// TODO: confirm columns we want to export
+	err = csvWriter.Write([]string{
+		"Date",
+		"Name",
+		"CPUCoreUsageAverage",
+		"CPUCoreRequestAverage",
+		"CPUCost",
+		"RAMBytesUsageAverage",
+		"RAMBytesRequestAverage",
+		"RAMCost",
+	})
+	if err != nil {
+		return err
+	}
+	for _, alloc := range data.Allocations {
+		if err := ctx.Err(); err != nil {
+			return err
+		}
+
+		err := csvWriter.Write([]string{
+			date.Format("2006-01-02"),
+			alloc.Name,
+			fmtFloat(alloc.CPUCoreUsageAverage),
+			fmtFloat(alloc.CPUCoreRequestAverage),
+			fmtFloat(alloc.CPUCost),
+			fmtFloat(alloc.RAMBytesUsageAverage),
+			fmtFloat(alloc.RAMBytesRequestAverage),
+			fmtFloat(alloc.RAMCost),
+		})
+		if err != nil {
+			return err
+		}
+	}
+	csvWriter.Flush()
+	if err := csvWriter.Error(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func fmtFloat(f float64) string {
+	return strconv.FormatFloat(f, 'f', -1, 64)
+}
+
+// mergeCSV merges multiple csv files into one.
+// Files may have different headers, but the result will have a header that is a union of all headers.
+// The main goal here is to allow changing CSV format without breaking or loosing existing data.
+func mergeCSV(files []*os.File, output *os.File) error {
+	var err error
+	headers := make([][]string, 0, len(files))
+	csvReaders := make([]*csv.Reader, 0, len(files))
+
+	// first, get information about the result header
+	for _, file := range files {
+		if _, err := file.Seek(0, io.SeekStart); err != nil {
+			return errors.Wrapf(err, "seeking to start of %s", file.Name())
+		}
+		csvReader := csv.NewReader(file)
+		header, err := csvReader.Read()
+		if errors.Is(err, io.EOF) {
+			// ignore empty files
+			continue
+		}
+		if err != nil {
+			return errors.Wrapf(err, "reading header of %s", file.Name())
+		}
+		headers = append(headers, header)
+		csvReaders = append(csvReaders, csvReader)
+	}
+
+	mapping, header := combineHeaders(headers)
+
+	csvWriter := csv.NewWriter(output)
+	err = csvWriter.Write(mergeHeaders(headers))
+	if err != nil {
+		return errors.Wrapf(err, "writing header to %s", output.Name())
+	}
+
+	for csvIndex, csvReader := range csvReaders {
+		for {
+			inputLine, err := csvReader.Read()
+			if errors.Is(err, io.EOF) {
+				break
+			}
+			if err != nil {
+				return errors.Wrap(err, "reading csv file line")
+			}
+
+			outputLine := make([]string, len(header))
+			for colIndex := range header {
+				destColIndex, ok := mapping[csvIndex][colIndex]
+				if !ok {
+					continue
+				}
+				outputLine[destColIndex] = inputLine[colIndex]
+			}
+			err = csvWriter.Write(outputLine)
+			if err != nil {
+				return errors.Wrapf(err, "writing line to %s", output.Name())
+			}
+		}
+
+	}
+	csvWriter.Flush()
+	_, err = output.Seek(0, io.SeekStart)
+	if err != nil {
+		return errors.Wrapf(err, "seeking to start of %s", output.Name())
+	}
+
+	return nil
+}
+
+func combineHeaders(headers [][]string) ([]map[int]int, []string) {
+	result := make([]string, 0)
+	indices := make([]map[int]int, len(headers))
+	for i, header := range headers {
+		indices[i] = make(map[int]int)
+		for j, column := range header {
+			if !contains(result, column) {
+				result = append(result, column)
+				indices[i][j] = len(result) - 1
+			} else {
+				indices[i][j] = indexOf(result, column)
+			}
+		}
+	}
+	return indices, result
+}
+
+func mergeHeaders(headers [][]string) []string {
+	result := make([]string, 0)
+	for _, header := range headers {
+		for _, column := range header {
+			if !contains(result, column) {
+				result = append(result, column)
+			}
+		}
+	}
+	return result
+}
+
+func contains(slice []string, item string) bool {
+	for _, element := range slice {
+		if element == item {
+			return true
+		}
+	}
+	return false
+}
+
+func indexOf(slice []string, element string) int {
+	for i, e := range slice {
+		if e == element {
+			return i
+		}
+	}
+	return -1
+}

+ 107 - 0
pkg/costmodel/allocation_csv_test.go

@@ -0,0 +1,107 @@
+package costmodel
+
+import (
+	"context"
+	"io"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/opencost/opencost/pkg/kubecost"
+)
+
+//go:generate moq -out moq_cloud_storage_test.go . CloudStorage:CloudStorageMock
+//go:generate moq -out moq_allocation_model_test.go . AllocationModel:AllocationModelMock
+
+func Test_UpdateCSV(t *testing.T) {
+	t.Run("previous data doesn't exist, upload new data", func(t *testing.T) {
+		var csv string
+		storage := &CloudStorageMock{
+			FileExistsFunc: func(ctx context.Context, path string) (bool, error) {
+				return false, nil
+			},
+			FileReplaceFunc: func(ctx context.Context, f *os.File, path string) error {
+				data, err := io.ReadAll(f)
+				csv = string(data)
+				require.NoError(t, err)
+				return nil
+			},
+		}
+		model := &AllocationModelMock{
+			ComputeAllocationFunc: func(start time.Time, end time.Time, resolution time.Duration) (*kubecost.AllocationSet, error) {
+				return &kubecost.AllocationSet{
+					Allocations: map[string]*kubecost.Allocation{
+						"test": {
+							Name:                   "test",
+							CPUCoreUsageAverage:    0.1,
+							CPUCoreRequestAverage:  0.2,
+							CPUCost:                0.3,
+							RAMBytesUsageAverage:   0.4,
+							RAMBytesRequestAverage: 0.5,
+							RAMCost:                0.6,
+						},
+					},
+				}, nil
+			},
+		}
+		err := UpdateCSV(context.TODO(), storage, model, "/test.csv", time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC))
+		require.NoError(t, err)
+		// uploaded a single file with the data
+		require.Len(t, storage.FileReplaceCalls(), 1)
+		require.Equal(t, `Date,Name,CPUCoreUsageAverage,CPUCoreRequestAverage,CPUCost,RAMBytesUsageAverage,RAMBytesRequestAverage,RAMCost
+2021-01-01,test,0.1,0.2,0.3,0.4,0.5,0.6
+`, csv)
+	})
+
+	t.Run("merge new data with previous data", func(t *testing.T) {
+		var csv string
+		storage := &CloudStorageMock{
+			FileExistsFunc: func(ctx context.Context, path string) (bool, error) {
+				return true, nil
+			},
+			FileDownloadFunc: func(ctx context.Context, path string) (*os.File, error) {
+				f, err := os.CreateTemp("", "")
+				require.NoError(t, err)
+				_, err = f.WriteString(`Date,Name,CPUCoreUsageAverage,CPUCoreRequestAverage,CPUCost,RAMBytesUsageAverage,RAMBytesRequestAverage,RAMCost
+2021-01-01,test,0.1,0.2,0.3,0.4,0.5,0.6
+`)
+				_, err = f.Seek(0, io.SeekStart)
+				require.NoError(t, err)
+				return f, err
+			},
+			FileReplaceFunc: func(ctx context.Context, f *os.File, path string) error {
+				data, err := io.ReadAll(f)
+				csv = string(data)
+				require.NoError(t, err)
+				return nil
+			},
+		}
+		model := &AllocationModelMock{
+			ComputeAllocationFunc: func(start time.Time, end time.Time, resolution time.Duration) (*kubecost.AllocationSet, error) {
+				return &kubecost.AllocationSet{
+					Allocations: map[string]*kubecost.Allocation{
+						"test": {
+							Name:                   "test",
+							CPUCoreUsageAverage:    1,
+							CPUCoreRequestAverage:  2,
+							CPUCost:                3,
+							RAMBytesUsageAverage:   4,
+							RAMBytesRequestAverage: 5,
+							RAMCost:                6,
+						},
+					},
+				}, nil
+			},
+		}
+		err := UpdateCSV(context.TODO(), storage, model, "/test.csv", time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC))
+		require.NoError(t, err)
+		// uploaded a single file with the data
+		require.Len(t, storage.FileReplaceCalls(), 1)
+		require.Equal(t, `Date,Name,CPUCoreUsageAverage,CPUCoreRequestAverage,CPUCost,RAMBytesUsageAverage,RAMBytesRequestAverage,RAMCost
+2021-01-01,test,0.1,0.2,0.3,0.4,0.5,0.6
+2021-01-02,test,1,2,3,4,5,6
+`, csv)
+	})
+}

+ 133 - 0
pkg/costmodel/moq_allocation_model_test.go

@@ -0,0 +1,133 @@
+// Code generated by moq; DO NOT EDIT.
+// github.com/matryer/moq
+
+package costmodel
+
+import (
+	"context"
+	"github.com/opencost/opencost/pkg/kubecost"
+	"sync"
+	"time"
+)
+
+// Ensure, that AllocationModelMock does implement AllocationModel.
+// If this is not the case, regenerate this file with moq.
+var _ AllocationModel = &AllocationModelMock{}
+
+// AllocationModelMock is a mock implementation of AllocationModel.
+//
+//	func TestSomethingThatUsesAllocationModel(t *testing.T) {
+//
+//		// make and configure a mocked AllocationModel
+//		mockedAllocationModel := &AllocationModelMock{
+//			ComputeAllocationFunc: func(start time.Time, end time.Time, resolution time.Duration) (*kubecost.AllocationSet, error) {
+//				panic("mock out the ComputeAllocation method")
+//			},
+//			DateRangeFunc: func(ctx context.Context) (time.Time, time.Time, error) {
+//				panic("mock out the DateRange method")
+//			},
+//		}
+//
+//		// use mockedAllocationModel in code that requires AllocationModel
+//		// and then make assertions.
+//
+//	}
+type AllocationModelMock struct {
+	// ComputeAllocationFunc mocks the ComputeAllocation method.
+	ComputeAllocationFunc func(start time.Time, end time.Time, resolution time.Duration) (*kubecost.AllocationSet, error)
+
+	// DateRangeFunc mocks the DateRange method.
+	DateRangeFunc func(ctx context.Context) (time.Time, time.Time, error)
+
+	// calls tracks calls to the methods.
+	calls struct {
+		// ComputeAllocation holds details about calls to the ComputeAllocation method.
+		ComputeAllocation []struct {
+			// Start is the start argument value.
+			Start time.Time
+			// End is the end argument value.
+			End time.Time
+			// Resolution is the resolution argument value.
+			Resolution time.Duration
+		}
+		// DateRange holds details about calls to the DateRange method.
+		DateRange []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+		}
+	}
+	lockComputeAllocation sync.RWMutex
+	lockDateRange         sync.RWMutex
+}
+
+// ComputeAllocation calls ComputeAllocationFunc.
+func (mock *AllocationModelMock) ComputeAllocation(start time.Time, end time.Time, resolution time.Duration) (*kubecost.AllocationSet, error) {
+	if mock.ComputeAllocationFunc == nil {
+		panic("AllocationModelMock.ComputeAllocationFunc: method is nil but AllocationModel.ComputeAllocation was just called")
+	}
+	callInfo := struct {
+		Start      time.Time
+		End        time.Time
+		Resolution time.Duration
+	}{
+		Start:      start,
+		End:        end,
+		Resolution: resolution,
+	}
+	mock.lockComputeAllocation.Lock()
+	mock.calls.ComputeAllocation = append(mock.calls.ComputeAllocation, callInfo)
+	mock.lockComputeAllocation.Unlock()
+	return mock.ComputeAllocationFunc(start, end, resolution)
+}
+
+// ComputeAllocationCalls gets all the calls that were made to ComputeAllocation.
+// Check the length with:
+//
+//	len(mockedAllocationModel.ComputeAllocationCalls())
+func (mock *AllocationModelMock) ComputeAllocationCalls() []struct {
+	Start      time.Time
+	End        time.Time
+	Resolution time.Duration
+} {
+	var calls []struct {
+		Start      time.Time
+		End        time.Time
+		Resolution time.Duration
+	}
+	mock.lockComputeAllocation.RLock()
+	calls = mock.calls.ComputeAllocation
+	mock.lockComputeAllocation.RUnlock()
+	return calls
+}
+
+// DateRange calls DateRangeFunc.
+func (mock *AllocationModelMock) DateRange(ctx context.Context) (time.Time, time.Time, error) {
+	if mock.DateRangeFunc == nil {
+		panic("AllocationModelMock.DateRangeFunc: method is nil but AllocationModel.DateRange was just called")
+	}
+	callInfo := struct {
+		Ctx context.Context
+	}{
+		Ctx: ctx,
+	}
+	mock.lockDateRange.Lock()
+	mock.calls.DateRange = append(mock.calls.DateRange, callInfo)
+	mock.lockDateRange.Unlock()
+	return mock.DateRangeFunc(ctx)
+}
+
+// DateRangeCalls gets all the calls that were made to DateRange.
+// Check the length with:
+//
+//	len(mockedAllocationModel.DateRangeCalls())
+func (mock *AllocationModelMock) DateRangeCalls() []struct {
+	Ctx context.Context
+} {
+	var calls []struct {
+		Ctx context.Context
+	}
+	mock.lockDateRange.RLock()
+	calls = mock.calls.DateRange
+	mock.lockDateRange.RUnlock()
+	return calls
+}

+ 188 - 0
pkg/costmodel/moq_cloud_storage_test.go

@@ -0,0 +1,188 @@
+// Code generated by moq; DO NOT EDIT.
+// github.com/matryer/moq
+
+package costmodel
+
+import (
+	"context"
+	"os"
+	"sync"
+)
+
+// Ensure, that CloudStorageMock does implement CloudStorage.
+// If this is not the case, regenerate this file with moq.
+var _ CloudStorage = &CloudStorageMock{}
+
+// CloudStorageMock is a mock implementation of CloudStorage.
+//
+//	func TestSomethingThatUsesCloudStorage(t *testing.T) {
+//
+//		// make and configure a mocked CloudStorage
+//		mockedCloudStorage := &CloudStorageMock{
+//			FileDownloadFunc: func(ctx context.Context, path string) (*os.File, error) {
+//				panic("mock out the FileDownload method")
+//			},
+//			FileExistsFunc: func(ctx context.Context, path string) (bool, error) {
+//				panic("mock out the FileExists method")
+//			},
+//			FileReplaceFunc: func(ctx context.Context, f *os.File, path string) error {
+//				panic("mock out the FileReplace method")
+//			},
+//		}
+//
+//		// use mockedCloudStorage in code that requires CloudStorage
+//		// and then make assertions.
+//
+//	}
+type CloudStorageMock struct {
+	// FileDownloadFunc mocks the FileDownload method.
+	FileDownloadFunc func(ctx context.Context, path string) (*os.File, error)
+
+	// FileExistsFunc mocks the FileExists method.
+	FileExistsFunc func(ctx context.Context, path string) (bool, error)
+
+	// FileReplaceFunc mocks the FileReplace method.
+	FileReplaceFunc func(ctx context.Context, f *os.File, path string) error
+
+	// calls tracks calls to the methods.
+	calls struct {
+		// FileDownload holds details about calls to the FileDownload method.
+		FileDownload []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+			// Path is the path argument value.
+			Path string
+		}
+		// FileExists holds details about calls to the FileExists method.
+		FileExists []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+			// Path is the path argument value.
+			Path string
+		}
+		// FileReplace holds details about calls to the FileReplace method.
+		FileReplace []struct {
+			// Ctx is the ctx argument value.
+			Ctx context.Context
+			// F is the f argument value.
+			F *os.File
+			// Path is the path argument value.
+			Path string
+		}
+	}
+	lockFileDownload sync.RWMutex
+	lockFileExists   sync.RWMutex
+	lockFileReplace  sync.RWMutex
+}
+
+// FileDownload calls FileDownloadFunc.
+func (mock *CloudStorageMock) FileDownload(ctx context.Context, path string) (*os.File, error) {
+	if mock.FileDownloadFunc == nil {
+		panic("CloudStorageMock.FileDownloadFunc: method is nil but CloudStorage.FileDownload was just called")
+	}
+	callInfo := struct {
+		Ctx  context.Context
+		Path string
+	}{
+		Ctx:  ctx,
+		Path: path,
+	}
+	mock.lockFileDownload.Lock()
+	mock.calls.FileDownload = append(mock.calls.FileDownload, callInfo)
+	mock.lockFileDownload.Unlock()
+	return mock.FileDownloadFunc(ctx, path)
+}
+
+// FileDownloadCalls gets all the calls that were made to FileDownload.
+// Check the length with:
+//
+//	len(mockedCloudStorage.FileDownloadCalls())
+func (mock *CloudStorageMock) FileDownloadCalls() []struct {
+	Ctx  context.Context
+	Path string
+} {
+	var calls []struct {
+		Ctx  context.Context
+		Path string
+	}
+	mock.lockFileDownload.RLock()
+	calls = mock.calls.FileDownload
+	mock.lockFileDownload.RUnlock()
+	return calls
+}
+
+// FileExists calls FileExistsFunc.
+func (mock *CloudStorageMock) FileExists(ctx context.Context, path string) (bool, error) {
+	if mock.FileExistsFunc == nil {
+		panic("CloudStorageMock.FileExistsFunc: method is nil but CloudStorage.FileExists was just called")
+	}
+	callInfo := struct {
+		Ctx  context.Context
+		Path string
+	}{
+		Ctx:  ctx,
+		Path: path,
+	}
+	mock.lockFileExists.Lock()
+	mock.calls.FileExists = append(mock.calls.FileExists, callInfo)
+	mock.lockFileExists.Unlock()
+	return mock.FileExistsFunc(ctx, path)
+}
+
+// FileExistsCalls gets all the calls that were made to FileExists.
+// Check the length with:
+//
+//	len(mockedCloudStorage.FileExistsCalls())
+func (mock *CloudStorageMock) FileExistsCalls() []struct {
+	Ctx  context.Context
+	Path string
+} {
+	var calls []struct {
+		Ctx  context.Context
+		Path string
+	}
+	mock.lockFileExists.RLock()
+	calls = mock.calls.FileExists
+	mock.lockFileExists.RUnlock()
+	return calls
+}
+
+// FileReplace calls FileReplaceFunc.
+func (mock *CloudStorageMock) FileReplace(ctx context.Context, f *os.File, path string) error {
+	if mock.FileReplaceFunc == nil {
+		panic("CloudStorageMock.FileReplaceFunc: method is nil but CloudStorage.FileReplace was just called")
+	}
+	callInfo := struct {
+		Ctx  context.Context
+		F    *os.File
+		Path string
+	}{
+		Ctx:  ctx,
+		F:    f,
+		Path: path,
+	}
+	mock.lockFileReplace.Lock()
+	mock.calls.FileReplace = append(mock.calls.FileReplace, callInfo)
+	mock.lockFileReplace.Unlock()
+	return mock.FileReplaceFunc(ctx, f, path)
+}
+
+// FileReplaceCalls gets all the calls that were made to FileReplace.
+// Check the length with:
+//
+//	len(mockedCloudStorage.FileReplaceCalls())
+func (mock *CloudStorageMock) FileReplaceCalls() []struct {
+	Ctx  context.Context
+	F    *os.File
+	Path string
+} {
+	var calls []struct {
+		Ctx  context.Context
+		F    *os.File
+		Path string
+	}
+	mock.lockFileReplace.RLock()
+	calls = mock.calls.FileReplace
+	mock.lockFileReplace.RUnlock()
+	return calls
+}