diff --git a/.mockery.yml b/.mockery.yml new file mode 100644 index 0000000..0aed8e7 --- /dev/null +++ b/.mockery.yml @@ -0,0 +1,14 @@ +all: false +force-file-write: true +formatter: goimports +log-level: info +template: testify +packages: + github.com/meigma/imgcli/internal/cache: + config: + dir: internal/cache/mocks + filename: service.go + pkgname: mocks + structname: MockService + interfaces: + Service: diff --git a/go.mod b/go.mod index d40f6eb..1513855 100644 --- a/go.mod +++ b/go.mod @@ -46,6 +46,7 @@ require ( github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect diff --git a/go.sum b/go.sum index 102c9ba..824e342 100644 --- a/go.sum +++ b/go.sum @@ -93,6 +93,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= diff --git a/internal/cache/mocks/service.go b/internal/cache/mocks/service.go new file mode 100644 index 0000000..84a25d4 --- /dev/null +++ b/internal/cache/mocks/service.go @@ -0,0 +1,105 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/meigma/imgcli/internal/cache" + mock "github.com/stretchr/testify/mock" +) + +// NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockService(t interface { + mock.TestingT + Cleanup(func()) +}) *MockService { + mock := &MockService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockService is an autogenerated mock type for the Service type +type MockService struct { + mock.Mock +} + +type MockService_Expecter struct { + mock *mock.Mock +} + +func (_m *MockService) EXPECT() *MockService_Expecter { + return &MockService_Expecter{mock: &_m.Mock} +} + +// Fetch provides a mock function for the type MockService +func (_mock *MockService) Fetch(ctx context.Context, req cache.FetchRequest) (cache.Blob, error) { + ret := _mock.Called(ctx, req) + + if len(ret) == 0 { + panic("no return value specified for Fetch") + } + + var r0 cache.Blob + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, cache.FetchRequest) (cache.Blob, error)); ok { + return returnFunc(ctx, req) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, cache.FetchRequest) cache.Blob); ok { + r0 = returnFunc(ctx, req) + } else { + r0 = ret.Get(0).(cache.Blob) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, cache.FetchRequest) error); ok { + r1 = returnFunc(ctx, req) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockService_Fetch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Fetch' +type MockService_Fetch_Call struct { + *mock.Call +} + +// Fetch is a helper method to define mock.On call +// - ctx context.Context +// - req cache.FetchRequest +func (_e *MockService_Expecter) Fetch(ctx interface{}, req interface{}) *MockService_Fetch_Call { + return &MockService_Fetch_Call{Call: _e.mock.On("Fetch", ctx, req)} +} + +func (_c *MockService_Fetch_Call) Run(run func(ctx context.Context, req cache.FetchRequest)) *MockService_Fetch_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 cache.FetchRequest + if args[1] != nil { + arg1 = args[1].(cache.FetchRequest) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockService_Fetch_Call) Return(blob cache.Blob, err error) *MockService_Fetch_Call { + _c.Call.Return(blob, err) + return _c +} + +func (_c *MockService_Fetch_Call) RunAndReturn(run func(ctx context.Context, req cache.FetchRequest) (cache.Blob, error)) *MockService_Fetch_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/providers/incusos/cdn/client.go b/internal/providers/incusos/cdn/client.go index 5d95109..57588ca 100644 --- a/internal/providers/incusos/cdn/client.go +++ b/internal/providers/incusos/cdn/client.go @@ -3,6 +3,7 @@ package cdn import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -10,6 +11,7 @@ import ( "slices" "strings" + "github.com/meigma/imgcli/internal/cache" "github.com/meigma/imgcli/internal/providers/incusos" "github.com/meigma/imgcli/schemas/core" ) @@ -30,8 +32,9 @@ type Option func(*Client) // Client resolves and downloads IncusOS images from the Linux Containers CDN. type Client struct { - baseURL string - httpClient *http.Client + baseURL string + cacheService cache.Service + httpClient *http.Client } // WithBaseURL configures the CDN base URL containing index.json. @@ -50,6 +53,15 @@ func WithHTTPClient(httpClient *http.Client) Option { } } +// WithCacheService configures the cache service used for source image downloads. +func WithCacheService(cacheService cache.Service) Option { + return func(client *Client) { + if cacheService != nil { + client.cacheService = cacheService + } + } +} + // NewClient constructs a CDN client. func NewClient(options ...Option) *Client { client := &Client{ @@ -126,9 +138,30 @@ func (c *Client) ResolveImage(ctx context.Context, query incusos.ImageQuery) (in ) } -// DownloadImage downloads and verifies the provided image asset. -func (c *Client) DownloadImage(_ context.Context, _ incusos.ImageAsset, _ string) (incusos.DownloadedImage, error) { - return incusos.DownloadedImage{}, incusos.ErrNotImplemented +// DownloadImage downloads and verifies the provided image asset through the shared cache service. +func (c *Client) DownloadImage(ctx context.Context, asset incusos.ImageAsset) (incusos.DownloadedImage, error) { + if c.cacheService == nil { + return incusos.DownloadedImage{}, errors.New("incusos cache service is required") + } + if err := validateDownloadAsset(asset); err != nil { + return incusos.DownloadedImage{}, err + } + + blob, err := c.cacheService.Fetch(ctx, cache.FetchRequest{ + URL: asset.URL, + ExpectedSHA256: asset.SHA256, + ExpectedSize: asset.Size, + }) + if err != nil { + return incusos.DownloadedImage{}, fmt.Errorf("download incusos image through cache: %w", err) + } + + return incusos.DownloadedImage{ + Asset: asset, + Path: blob.Path, + SHA256: blob.SHA256, + Size: blob.Size, + }, nil } func (c *Client) fetchIndex(ctx context.Context) (catalogIndex, error) { @@ -192,6 +225,17 @@ func (c *Client) httpClientOrDefault() *http.Client { return c.httpClient } +func validateDownloadAsset(asset incusos.ImageAsset) error { + if strings.TrimSpace(asset.URL) == "" { + return errors.New("incusos image URL is required") + } + if strings.TrimSpace(asset.SHA256) == "" { + return errors.New("incusos image SHA-256 is required") + } + + return nil +} + func normalizeQuery(query incusos.ImageQuery) (incusos.ImageQuery, error) { if query.Channel == "" { query.Channel = incusos.ChannelStable diff --git a/internal/providers/incusos/cdn/download_test.go b/internal/providers/incusos/cdn/download_test.go new file mode 100644 index 0000000..88c73cc --- /dev/null +++ b/internal/providers/incusos/cdn/download_test.go @@ -0,0 +1,153 @@ +package cdn + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/imgcli/internal/cache" + cachemocks "github.com/meigma/imgcli/internal/cache/mocks" + "github.com/meigma/imgcli/internal/providers/incusos" + "github.com/meigma/imgcli/schemas/core" +) + +func TestDownloadImage(t *testing.T) { + tests := []struct { + name string + asset incusos.ImageAsset + blob cache.Blob + wantReq cache.FetchRequest + wantSize int64 + }{ + { + name: "downloads known-size asset through cache", + asset: downloadAsset(42), + blob: cache.Blob{ + Path: "/cache/blobs/sha256/aa/blob", + SHA256: strings.Repeat("a", 64), + Size: 42, + }, + wantReq: cache.FetchRequest{ + URL: "https://example.invalid/incusos.img.gz", + ExpectedSHA256: strings.Repeat("a", 64), + ExpectedSize: 42, + }, + wantSize: 42, + }, + { + name: "passes unknown size through to cache", + asset: downloadAsset(0), + blob: cache.Blob{ + Path: "/cache/blobs/sha256/aa/blob", + SHA256: strings.Repeat("a", 64), + Size: 99, + }, + wantReq: cache.FetchRequest{ + URL: "https://example.invalid/incusos.img.gz", + ExpectedSHA256: strings.Repeat("a", 64), + ExpectedSize: 0, + }, + wantSize: 99, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + cacheService := cachemocks.NewMockService(t) + cacheService.EXPECT().Fetch(ctx, tt.wantReq).Return(tt.blob, nil).Once() + client := NewClient(WithCacheService(cacheService)) + + got, err := client.DownloadImage(ctx, tt.asset) + + require.NoError(t, err) + assert.Equal(t, incusos.DownloadedImage{ + Asset: tt.asset, + Path: tt.blob.Path, + SHA256: tt.blob.SHA256, + Size: tt.wantSize, + }, got) + }) + } +} + +func TestDownloadImageValidatesInputs(t *testing.T) { + validAsset := downloadAsset(42) + tests := []struct { + name string + asset incusos.ImageAsset + wantErr string + }{ + { + name: "empty URL", + asset: incusos.ImageAsset{ + SHA256: validAsset.SHA256, + }, + wantErr: "incusos image URL is required", + }, + { + name: "empty SHA-256", + asset: incusos.ImageAsset{ + URL: validAsset.URL, + }, + wantErr: "incusos image SHA-256 is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cacheService := cachemocks.NewMockService(t) + client := NewClient(WithCacheService(cacheService)) + + got, err := client.DownloadImage(context.Background(), tt.asset) + + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + assert.Empty(t, got) + }) + } +} + +func TestDownloadImageRequiresCacheService(t *testing.T) { + client := NewClient() + + got, err := client.DownloadImage(context.Background(), downloadAsset(42)) + + require.Error(t, err) + assert.Contains(t, err.Error(), "incusos cache service is required") + assert.Empty(t, got) +} + +func TestDownloadImagePropagatesCacheErrors(t *testing.T) { + ctx := context.Background() + asset := downloadAsset(42) + cacheErr := errors.New("cache failed") + cacheService := cachemocks.NewMockService(t) + cacheService.EXPECT().Fetch(ctx, cache.FetchRequest{ + URL: asset.URL, + ExpectedSHA256: asset.SHA256, + ExpectedSize: asset.Size, + }).Return(cache.Blob{}, cacheErr).Once() + client := NewClient(WithCacheService(cacheService)) + + got, err := client.DownloadImage(ctx, asset) + + require.ErrorIs(t, err, cacheErr) + assert.Contains(t, err.Error(), "download incusos image through cache") + assert.Empty(t, got) +} + +func downloadAsset(size int64) incusos.ImageAsset { + return incusos.ImageAsset{ + Version: incusos.Version("202604261712"), + Architecture: core.Architecture("amd64"), + Type: incusos.ImageTypeRaw, + URL: "https://example.invalid/incusos.img.gz", + SHA256: strings.Repeat("a", 64), + Size: size, + } +} diff --git a/internal/providers/incusos/types.go b/internal/providers/incusos/types.go index c99e002..f93396e 100644 --- a/internal/providers/incusos/types.go +++ b/internal/providers/incusos/types.go @@ -16,7 +16,7 @@ type Catalog interface { // Downloader retrieves IncusOS source image assets. type Downloader interface { // DownloadImage downloads and verifies the provided image asset. - DownloadImage(ctx context.Context, asset ImageAsset, dst string) (DownloadedImage, error) + DownloadImage(ctx context.Context, asset ImageAsset) (DownloadedImage, error) } // SeedBuilder creates IncusOS seed archives.