diff --git a/internal/cli/build.go b/internal/cli/build.go index bbbee84..2fb4203 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -1,13 +1,20 @@ package cli import ( + "fmt" + "github.com/spf13/cobra" + "github.com/meigma/imgcli/internal/cache" "github.com/meigma/imgcli/internal/providers" incusosprovider "github.com/meigma/imgcli/internal/providers/incusos" "github.com/meigma/imgcli/internal/providers/incusos/cdn" + "github.com/meigma/imgcli/internal/providers/incusos/imagefile" + "github.com/meigma/imgcli/schemas/core" ) +const defaultBuildOutputDir = "dist" + func newBuildCommand(rt *runtime) *cobra.Command { return &cobra.Command{ Use: "build CONFIG", @@ -19,18 +26,83 @@ func newBuildCommand(rt *runtime) *cobra.Command { return err } - catalog := rt.opts.IncusOSCatalog - if catalog == nil { - catalog = cdn.NewClient() + ports, err := rt.incusOSBuildPorts() + if err != nil { + return err } provider := incusosprovider.New(*config.Incusos, incusosprovider.Options{ - Catalog: catalog, - Output: rt.opts.stdout(), + Catalog: ports.catalog, + Downloader: ports.downloader, + SeedBuilder: ports.seedBuilder, + ImageInjector: ports.imageInjector, }) - _, err = provider.Build(cmd.Context(), providers.BuildRequest{}) - return err + result, err := provider.Build(cmd.Context(), providers.BuildRequest{ + Plan: providers.Plan{ + Image: config.Image, + }, + OutputDir: buildOutputDir(config.Output), + }) + if err != nil { + return err + } + + for _, artifact := range result.Artifacts { + if _, err := fmt.Fprintln(rt.opts.stdout(), artifact.Path); err != nil { + return fmt.Errorf("write build artifact path: %w", err) + } + } + + return nil }, } } + +type incusOSBuildPorts struct { + catalog incusosprovider.Catalog + downloader incusosprovider.Downloader + seedBuilder incusosprovider.SeedBuilder + imageInjector incusosprovider.ImageInjector +} + +func (rt *runtime) incusOSBuildPorts() (incusOSBuildPorts, error) { + ports := incusOSBuildPorts{ + catalog: rt.opts.IncusOSCatalog, + downloader: rt.opts.IncusOSDownloader, + seedBuilder: rt.opts.IncusOSSeedBuilder, + imageInjector: rt.opts.IncusOSImageInjector, + } + + if ports.catalog == nil || ports.downloader == nil { + cacheStore, err := cache.NewDiskStore() + if err != nil { + return incusOSBuildPorts{}, fmt.Errorf("configure incusos cache: %w", err) + } + + client := cdn.NewClient(cdn.WithCacheService(cacheStore)) + if ports.catalog == nil { + ports.catalog = client + } + if ports.downloader == nil { + ports.downloader = client + } + } + + if ports.seedBuilder == nil { + ports.seedBuilder = incusosprovider.SeedArchiveBuilder{} + } + if ports.imageInjector == nil { + ports.imageInjector = imagefile.Injector{} + } + + return ports, nil +} + +func buildOutputDir(output *core.OutputDefaults) string { + if output == nil || output.Dir == "" { + return defaultBuildOutputDir + } + + return output.Dir +} diff --git a/internal/cli/options.go b/internal/cli/options.go index 4ce689d..c66dc1d 100644 --- a/internal/cli/options.go +++ b/internal/cli/options.go @@ -26,6 +26,15 @@ type Options struct { // IncusOSCatalog resolves IncusOS source images. Nil selects the default CDN catalog. IncusOSCatalog incusos.Catalog + + // IncusOSDownloader retrieves IncusOS source images. Nil selects the default CDN downloader. + IncusOSDownloader incusos.Downloader + + // IncusOSSeedBuilder creates IncusOS seed archives. Nil selects the default seed builder. + IncusOSSeedBuilder incusos.SeedBuilder + + // IncusOSImageInjector writes IncusOS seed archives into source images. Nil selects the default image injector. + IncusOSImageInjector incusos.ImageInjector } func (o Options) version() string { diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 7395e57..4dd8af0 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -192,33 +192,55 @@ talos: {} assert.Empty(t, result.stderr) }) - t.Run("prints resolved IncusOS image URL", func(t *testing.T) { + t.Run("prints customized IncusOS artifact path", func(t *testing.T) { clearIMGCLIEnv(t) + outputDir := filepath.Join(t.TempDir(), "out") configPath := writeImageConfig(t, ` apiVersion: "imgcli.meigma.io/v0alpha1" kind: "ImagePlan" image: name: "test-image" +output: dir: "`+outputDir+`" incusos: { defaults: source: channel: "testing" + seed: install: {} variants: default: { source: version: "202604261712" artifact: { architecture: "amd64" - format: "raw" + format: "raw.gz" } } } `) catalog := &testCatalog{ asset: incusos.ImageAsset{ - URL: "https://example.invalid/os/202604261712/x86_64/IncusOS_202604261712.img.gz", + URL: "https://example.invalid/os/202604261712/x86_64/IncusOS_202604261712.img.gz", + SHA256: "source-sha", + Size: 42, }, } + downloader := &testDownloader{ + image: incusos.DownloadedImage{ + Path: "/cache/source.img.gz", + SHA256: "source-sha", + Size: 42, + }, + } + seedBuilder := &testSeedBuilder{ + seed: incusos.SeedArchive{Data: []byte("seed")}, + } + injector := &testImageInjector{} - result := executeCommand(t, Options{IncusOSCatalog: catalog}, "build", configPath) + result := executeCommand(t, Options{ + IncusOSCatalog: catalog, + IncusOSDownloader: downloader, + IncusOSSeedBuilder: seedBuilder, + IncusOSImageInjector: injector, + }, "build", configPath) require.NoError(t, result.err) - assert.Equal(t, "https://example.invalid/os/202604261712/x86_64/IncusOS_202604261712.img.gz\n", result.stdout) + wantOutputPath := filepath.Join(outputDir, "test-image-default-amd64.raw.gz") + assert.Equal(t, wantOutputPath+"\n", result.stdout) assert.Empty(t, result.stderr) require.Len(t, catalog.queries, 1) assert.Equal(t, incusos.ImageQuery{ @@ -227,6 +249,10 @@ incusos: { Architecture: core.Architecture("amd64"), Type: incusos.ImageTypeRaw, }, catalog.queries[0]) + assert.Equal(t, []incusos.ImageAsset{catalog.asset}, downloader.assets) + assert.Len(t, seedBuilder.configs, 1) + require.Len(t, injector.calls, 1) + assert.Equal(t, wantOutputPath, injector.calls[0].outputPath) }) } @@ -283,3 +309,50 @@ func (c *testCatalog) ResolveImage(_ context.Context, query incusos.ImageQuery) c.queries = append(c.queries, query) return c.asset, nil } + +type testDownloader struct { + image incusos.DownloadedImage + assets []incusos.ImageAsset +} + +func (d *testDownloader) DownloadImage(_ context.Context, asset incusos.ImageAsset) (incusos.DownloadedImage, error) { + d.assets = append(d.assets, asset) + image := d.image + image.Asset = asset + return image, nil +} + +type testSeedBuilder struct { + seed incusos.SeedArchive + configs []incusos.Config +} + +func (b *testSeedBuilder) BuildSeed(_ context.Context, config incusos.Config) (incusos.SeedArchive, error) { + b.configs = append(b.configs, config) + return b.seed, nil +} + +type testImageInjector struct { + calls []testInjectCall +} + +type testInjectCall struct { + image incusos.DownloadedImage + seed incusos.SeedArchive + outputPath string +} + +func (i *testImageInjector) InjectSeed( + _ context.Context, + image incusos.DownloadedImage, + seed incusos.SeedArchive, + outputPath string, +) (incusos.CustomizedImage, error) { + i.calls = append(i.calls, testInjectCall{image: image, seed: seed, outputPath: outputPath}) + return incusos.CustomizedImage{ + Source: image, + Path: outputPath, + Size: 99, + SHA256: "custom-sha", + }, nil +} diff --git a/internal/providers/incusos/provider.go b/internal/providers/incusos/provider.go index c7fd862..3c69a67 100644 --- a/internal/providers/incusos/provider.go +++ b/internal/providers/incusos/provider.go @@ -4,14 +4,19 @@ import ( "context" "errors" "fmt" - "io" + "path/filepath" + "strings" "github.com/meigma/imgcli/internal/providers" "github.com/meigma/imgcli/schemas/core" incusosschema "github.com/meigma/imgcli/schemas/providers/incusos" ) -const providerName core.ProviderName = "incusos" +const ( + defaultOutputDir = "dist" + defaultImageName = "image" + providerName = core.ProviderName("incusos") +) var _ providers.Provider = (*Provider)(nil) @@ -23,9 +28,6 @@ type Options struct { // Catalog resolves IncusOS release metadata into source image assets. Catalog Catalog - // Output receives temporary shallow build output. Nil discards output. - Output io.Writer - // Downloader retrieves and verifies IncusOS source image assets. Downloader Downloader @@ -61,12 +63,21 @@ func (p *Provider) Plan(_ context.Context, _ providers.PlanRequest) (providers.P } // Build creates IncusOS artifacts from an already resolved plan. -func (p *Provider) Build(ctx context.Context, _ providers.BuildRequest) (providers.BuildResult, error) { +func (p *Provider) Build(ctx context.Context, req providers.BuildRequest) (providers.BuildResult, error) { if p.options.Catalog == nil { return providers.BuildResult{}, errors.New("incusos catalog is required") } + if p.options.Downloader == nil { + return providers.BuildResult{}, errors.New("incusos downloader is required") + } + if p.options.SeedBuilder == nil { + return providers.BuildResult{}, errors.New("incusos seed builder is required") + } + if p.options.ImageInjector == nil { + return providers.BuildResult{}, errors.New("incusos image injector is required") + } - variant, err := singleVariant(p.config) + variantName, variant, err := singleVariant(p.config) if err != nil { return providers.BuildResult{}, err } @@ -76,6 +87,11 @@ func (p *Provider) Build(ctx context.Context, _ providers.BuildRequest) (provide return providers.BuildResult{}, err } + outputPath, err := artifactOutputPath(req, variantName, variant.Artifact) + if err != nil { + return providers.BuildResult{}, err + } + source := resolveSource(p.config.Defaults, variant.Source) asset, err := p.options.Catalog.ResolveImage(ctx, ImageQuery{ Channel: source.Channel, @@ -87,37 +103,108 @@ func (p *Provider) Build(ctx context.Context, _ providers.BuildRequest) (provide return providers.BuildResult{}, err } - if _, err := fmt.Fprintln(p.output(), asset.URL); err != nil { - return providers.BuildResult{}, fmt.Errorf("write incusos image URL: %w", err) + downloaded, err := p.options.Downloader.DownloadImage(ctx, asset) + if err != nil { + return providers.BuildResult{}, err } - return providers.BuildResult{}, nil -} + seed, err := p.options.SeedBuilder.BuildSeed(ctx, p.config) + if err != nil { + return providers.BuildResult{}, err + } -func (p *Provider) output() io.Writer { - if p.options.Output == nil { - return io.Discard + customized, err := p.options.ImageInjector.InjectSeed(ctx, downloaded, seed, outputPath) + if err != nil { + return providers.BuildResult{}, err } - return p.options.Output + artifactPlan := providers.ArtifactPlan{ + Key: core.ArtifactKey(variantName), + Variant: variantName, + Architecture: variant.Artifact.Architecture, + Format: variant.Artifact.Format, + MediaType: variant.Artifact.MediaType, + OutputPath: outputPath, + Labels: variant.Artifact.Labels, + Annotations: variant.Artifact.Annotations, + } + plan := req.Plan + plan.Provider = providerName + plan.OutputDir = outputDir(req) + plan.Artifacts = []providers.ArtifactPlan{artifactPlan} + + return providers.BuildResult{ + Plan: plan, + Artifacts: []providers.BuiltArtifact{ + { + Plan: artifactPlan, + Path: customized.Path, + Size: customized.Size, + SHA256: customized.SHA256, + }, + }, + }, nil } -func singleVariant(config Config) (incusosschema.Variant, error) { +func singleVariant(config Config) (core.VariantName, incusosschema.Variant, error) { switch len(config.Variants) { case 0: - return incusosschema.Variant{}, errors.New("incusos build requires exactly one variant, got 0") + return "", incusosschema.Variant{}, errors.New("incusos build requires exactly one variant, got 0") case 1: - for _, variant := range config.Variants { - return variant, nil + for name, variant := range config.Variants { + return name, variant, nil } } - return incusosschema.Variant{}, fmt.Errorf( + return "", incusosschema.Variant{}, fmt.Errorf( "incusos build requires exactly one variant, got %d", len(config.Variants), ) } +func artifactOutputPath( + req providers.BuildRequest, + variantName core.VariantName, + artifact core.ArtifactIntent, +) (string, error) { + filename := strings.TrimSpace(artifact.Filename) + if filename == "" { + filename = fallbackArtifactFilename(req.Plan.Image.Name, variantName, artifact) + } + if filepath.IsAbs(filename) { + return "", fmt.Errorf("incusos artifact filename must be relative: %q", filename) + } + + cleanFilename := filepath.Clean(filename) + if cleanFilename == "." || cleanFilename == ".." || + strings.HasPrefix(cleanFilename, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("incusos artifact filename must stay within output directory: %q", filename) + } + + return filepath.Join(outputDir(req), cleanFilename), nil +} + +func fallbackArtifactFilename(imageName core.Name, variantName core.VariantName, artifact core.ArtifactIntent) string { + name := strings.TrimSpace(string(imageName)) + if name == "" { + name = defaultImageName + } + + return fmt.Sprintf("%s-%s-%s.%s", name, variantName, artifact.Architecture, artifact.Format) +} + +func outputDir(req providers.BuildRequest) string { + outputDir := strings.TrimSpace(req.OutputDir) + if outputDir == "" { + outputDir = strings.TrimSpace(req.Plan.OutputDir) + } + if outputDir == "" { + return defaultOutputDir + } + + return outputDir +} + func resolveSource(defaults *incusosschema.Defaults, variantSource *incusosschema.Source) incusosschema.Source { var source incusosschema.Source if defaults != nil && defaults.Source != nil { @@ -142,8 +229,6 @@ func imageTypeForFormat(format core.ArtifactFormat) (ImageType, error) { switch format { case "raw", "raw.gz": return ImageTypeRaw, nil - case "iso": - return ImageTypeISO, nil default: return "", fmt.Errorf("unsupported incusos artifact format %q", format) } diff --git a/internal/providers/incusos/provider_test.go b/internal/providers/incusos/provider_test.go index 0554fb1..c2f51bd 100644 --- a/internal/providers/incusos/provider_test.go +++ b/internal/providers/incusos/provider_test.go @@ -1,9 +1,9 @@ package incusos import ( - "bytes" "context" "errors" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -28,125 +28,181 @@ func TestProviderPlanPlaceholderOperation(t *testing.T) { assert.Empty(t, plan) } -func TestProviderBuildResolvesImageURL(t *testing.T) { +func TestProviderBuildCreatesCustomizedImage(t *testing.T) { tests := []struct { - name string - config Config - wantQuery ImageQuery + name string + artifact core.ArtifactIntent + wantOutputPath func(outputDir string) string }{ { - name: "uses stable by default", - config: Config{ - Variants: map[core.VariantName]incusosschema.Variant{ - "default": { - Artifact: core.ArtifactIntent{ - Architecture: core.Architecture("amd64"), - Format: core.ArtifactFormat("raw"), - }, - }, - }, - }, - wantQuery: ImageQuery{ - Channel: ChannelStable, + name: "uses configured artifact filename", + artifact: core.ArtifactIntent{ Architecture: core.Architecture("amd64"), - Type: ImageTypeRaw, + Format: core.ArtifactFormat("raw.gz"), + Filename: "custom/incusos-smoke.img.gz", + MediaType: "application/gzip", + Labels: map[string]string{"tier": "smoke"}, + Annotations: map[string]string{"note": "e2e"}, + }, + wantOutputPath: func(outputDir string) string { + return filepath.Join(outputDir, "custom", "incusos-smoke.img.gz") }, }, { - name: "uses variant source over defaults", - config: Config{ + name: "derives artifact filename when omitted", + artifact: core.ArtifactIntent{ + Architecture: core.Architecture("amd64"), + Format: core.ArtifactFormat("raw.gz"), + }, + wantOutputPath: func(outputDir string) string { + return filepath.Join(outputDir, "test-image-default-amd64.raw.gz") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + outputDir := t.TempDir() + asset := ImageAsset{ + Version: Version("202604261712"), + Architecture: core.Architecture("amd64"), + Type: ImageTypeRaw, + URL: "https://example.invalid/incusos.img.gz", + SHA256: "source-sha", + Size: 42, + } + downloaded := DownloadedImage{ + Asset: asset, + Path: "/cache/source.img.gz", + SHA256: "source-sha", + Size: 42, + } + seed := SeedArchive{Data: []byte("seed")} + customized := CustomizedImage{ + Source: downloaded, + Size: 99, + SHA256: "custom-sha", + } + catalog := &recordingCatalog{asset: asset} + downloader := &recordingDownloader{image: downloaded} + seedBuilder := &recordingSeedBuilder{seed: seed} + injector := &recordingImageInjector{image: customized} + config := Config{ Defaults: &incusosschema.Defaults{ Source: &incusosschema.Source{ Channel: ChannelStable, Version: Version("202604202240"), }, }, + Seed: &incusosschema.Seed{}, Variants: map[core.VariantName]incusosschema.Variant{ "default": { Source: &incusosschema.Source{ Channel: ChannelTesting, - Version: Version("202604282312"), - }, - Artifact: core.ArtifactIntent{ - Architecture: core.Architecture("arm64"), - Format: core.ArtifactFormat("iso"), + Version: Version("202604261712"), }, + Artifact: tt.artifact, }, }, - }, - wantQuery: ImageQuery{ - Channel: ChannelTesting, - Version: Version("202604282312"), - Architecture: core.Architecture("arm64"), - Type: ImageTypeISO, - }, - }, - { - name: "maps raw gzip artifact to raw image", - config: Config{ - Variants: map[core.VariantName]incusosschema.Variant{ - "default": { - Artifact: core.ArtifactIntent{ - Architecture: core.Architecture("amd64"), - Format: core.ArtifactFormat("raw.gz"), - }, - }, - }, - }, - wantQuery: ImageQuery{ - Channel: ChannelStable, - Architecture: core.Architecture("amd64"), - Type: ImageTypeRaw, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - const assetURL = "https://example.invalid/incusos.img.gz" - - catalog := &recordingCatalog{ - asset: ImageAsset{URL: assetURL}, } - var output bytes.Buffer - provider := New(tt.config, Options{ - Catalog: catalog, - Output: &output, + provider := New(config, Options{ + Catalog: catalog, + Downloader: downloader, + SeedBuilder: seedBuilder, + ImageInjector: injector, }) - result, err := provider.Build(context.Background(), providers.BuildRequest{}) + result, err := provider.Build(ctx, providers.BuildRequest{ + Plan: providers.Plan{ + Image: core.Image{Name: core.Name("test-image")}, + }, + OutputDir: outputDir, + }) require.NoError(t, err) - assert.Empty(t, result) - require.Len(t, catalog.queries, 1) - assert.Equal(t, tt.wantQuery, catalog.queries[0]) - assert.Equal(t, assetURL+"\n", output.String()) + wantOutputPath := tt.wantOutputPath(outputDir) + assert.Equal(t, []ImageQuery{ + { + Channel: ChannelTesting, + Version: Version("202604261712"), + Architecture: core.Architecture("amd64"), + Type: ImageTypeRaw, + }, + }, catalog.queries) + assert.Equal(t, []ImageAsset{asset}, downloader.assets) + assert.Equal(t, []Config{config}, seedBuilder.configs) + require.Len(t, injector.calls, 1) + assert.Equal(t, downloaded, injector.calls[0].image) + assert.Equal(t, seed, injector.calls[0].seed) + assert.Equal(t, wantOutputPath, injector.calls[0].outputPath) + + require.Len(t, result.Artifacts, 1) + assert.Equal(t, wantOutputPath, result.Artifacts[0].Path) + assert.Equal(t, int64(99), result.Artifacts[0].Size) + assert.Equal(t, "custom-sha", result.Artifacts[0].SHA256) + assert.Equal(t, providerName, result.Plan.Provider) + assert.Equal(t, core.Image{Name: core.Name("test-image")}, result.Plan.Image) + assert.Equal(t, outputDir, result.Plan.OutputDir) + require.Len(t, result.Plan.Artifacts, 1) + assert.Equal(t, result.Plan.Artifacts[0], result.Artifacts[0].Plan) + assert.Equal(t, providers.ArtifactPlan{ + Key: core.ArtifactKey("default"), + Variant: core.VariantName("default"), + Architecture: tt.artifact.Architecture, + Format: tt.artifact.Format, + MediaType: tt.artifact.MediaType, + OutputPath: wantOutputPath, + Labels: tt.artifact.Labels, + Annotations: tt.artifact.Annotations, + }, result.Plan.Artifacts[0]) }) } } func TestProviderBuildErrors(t *testing.T) { catalogErr := errors.New("catalog failed") + downloadErr := errors.New("download failed") + seedErr := errors.New("seed failed") + injectErr := errors.New("inject failed") tests := []struct { - name string - config Config - catalog *recordingCatalog - wantErr string - wantErrIs error - wantOutput string + name string + config Config + options Options + wantErr string + wantErrIs error }{ { name: "missing catalog", config: configWithVariant(core.ArtifactFormat("raw")), + options: optionsWithout(func(options *Options) { options.Catalog = nil }), wantErr: "incusos catalog is required", }, + { + name: "missing downloader", + config: configWithVariant(core.ArtifactFormat("raw")), + options: optionsWithout(func(options *Options) { options.Downloader = nil }), + wantErr: "incusos downloader is required", + }, + { + name: "missing seed builder", + config: configWithVariant(core.ArtifactFormat("raw")), + options: optionsWithout(func(options *Options) { options.SeedBuilder = nil }), + wantErr: "incusos seed builder is required", + }, + { + name: "missing image injector", + config: configWithVariant(core.ArtifactFormat("raw")), + options: optionsWithout(func(options *Options) { options.ImageInjector = nil }), + wantErr: "incusos image injector is required", + }, { name: "zero variants", config: Config{ Variants: map[core.VariantName]incusosschema.Variant{}, }, - catalog: &recordingCatalog{}, + options: optionsWithout(func(_ *Options) {}), wantErr: "incusos build requires exactly one variant, got 0", }, { @@ -157,42 +213,75 @@ func TestProviderBuildErrors(t *testing.T) { "other": {}, }, }, - catalog: &recordingCatalog{}, + options: optionsWithout(func(_ *Options) {}), wantErr: "incusos build requires exactly one variant, got 2", }, { name: "unsupported format", - config: configWithVariant(core.ArtifactFormat("qcow2")), - catalog: &recordingCatalog{}, - wantErr: `unsupported incusos artifact format "qcow2"`, + config: configWithVariant(core.ArtifactFormat("iso")), + options: optionsWithout(func(_ *Options) {}), + wantErr: `unsupported incusos artifact format "iso"`, }, { - name: "catalog error", - config: Config{ - Variants: map[core.VariantName]incusosschema.Variant{ - "default": { - Artifact: core.ArtifactIntent{ - Architecture: core.Architecture("amd64"), - Format: core.ArtifactFormat("raw"), - }, - }, - }, - }, - catalog: &recordingCatalog{err: catalogErr}, + name: "catalog error", + config: configWithVariant(core.ArtifactFormat("raw")), + options: optionsWithout(func(options *Options) { options.Catalog = &recordingCatalog{err: catalogErr} }), wantErrIs: catalogErr, }, + { + name: "download error", + config: configWithVariant(core.ArtifactFormat("raw")), + options: optionsWithout( + func(options *Options) { options.Downloader = &recordingDownloader{err: downloadErr} }, + ), + wantErrIs: downloadErr, + }, + { + name: "seed error", + config: configWithVariant(core.ArtifactFormat("raw")), + options: optionsWithout( + func(options *Options) { options.SeedBuilder = &recordingSeedBuilder{err: seedErr} }, + ), + wantErrIs: seedErr, + }, + { + name: "inject error", + config: configWithVariant(core.ArtifactFormat("raw")), + options: optionsWithout( + func(options *Options) { options.ImageInjector = &recordingImageInjector{err: injectErr} }, + ), + wantErrIs: injectErr, + }, + { + name: "absolute artifact filename", + config: configWithArtifact(core.ArtifactIntent{ + Architecture: core.Architecture("amd64"), + Format: core.ArtifactFormat("raw"), + Filename: "/tmp/out.img", + }), + options: optionsWithout(func(_ *Options) {}), + wantErr: `incusos artifact filename must be relative`, + }, + { + name: "escaping artifact filename", + config: configWithArtifact(core.ArtifactIntent{ + Architecture: core.Architecture("amd64"), + Format: core.ArtifactFormat("raw"), + Filename: "../out.img", + }), + options: optionsWithout(func(_ *Options) {}), + wantErr: `incusos artifact filename must stay within output directory`, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var output bytes.Buffer - options := Options{Output: &output} - if tt.catalog != nil { - options.Catalog = tt.catalog - } - provider := New(tt.config, options) + provider := New(tt.config, tt.options) - result, err := provider.Build(context.Background(), providers.BuildRequest{}) + result, err := provider.Build(context.Background(), providers.BuildRequest{ + Plan: providers.Plan{Image: core.Image{Name: core.Name("test-image")}}, + OutputDir: t.TempDir(), + }) require.Error(t, err) if tt.wantErr != "" { @@ -202,24 +291,39 @@ func TestProviderBuildErrors(t *testing.T) { require.ErrorIs(t, err, tt.wantErrIs) } assert.Empty(t, result) - assert.Equal(t, tt.wantOutput, output.String()) }) } } func configWithVariant(format core.ArtifactFormat) Config { + return configWithArtifact(core.ArtifactIntent{ + Architecture: core.Architecture("amd64"), + Format: format, + }) +} + +func configWithArtifact(artifact core.ArtifactIntent) Config { return Config{ + Seed: &incusosschema.Seed{}, Variants: map[core.VariantName]incusosschema.Variant{ "default": { - Artifact: core.ArtifactIntent{ - Architecture: core.Architecture("amd64"), - Format: format, - }, + Artifact: artifact, }, }, } } +func optionsWithout(mutator func(*Options)) Options { + options := Options{ + Catalog: &recordingCatalog{asset: ImageAsset{}}, + Downloader: &recordingDownloader{}, + SeedBuilder: &recordingSeedBuilder{seed: SeedArchive{Data: []byte("seed")}}, + ImageInjector: &recordingImageInjector{}, + } + mutator(&options) + return options +} + type recordingCatalog struct { asset ImageAsset err error @@ -234,3 +338,66 @@ func (c *recordingCatalog) ResolveImage(_ context.Context, query ImageQuery) (Im return c.asset, nil } + +type recordingDownloader struct { + image DownloadedImage + err error + assets []ImageAsset +} + +func (d *recordingDownloader) DownloadImage(_ context.Context, asset ImageAsset) (DownloadedImage, error) { + d.assets = append(d.assets, asset) + if d.err != nil { + return DownloadedImage{}, d.err + } + + return d.image, nil +} + +type recordingSeedBuilder struct { + seed SeedArchive + err error + configs []Config +} + +func (b *recordingSeedBuilder) BuildSeed(_ context.Context, config Config) (SeedArchive, error) { + b.configs = append(b.configs, config) + if b.err != nil { + return SeedArchive{}, b.err + } + + return b.seed, nil +} + +type recordingImageInjector struct { + image CustomizedImage + err error + calls []injectCall +} + +type injectCall struct { + image DownloadedImage + seed SeedArchive + outputPath string +} + +func (i *recordingImageInjector) InjectSeed( + _ context.Context, + image DownloadedImage, + seed SeedArchive, + outputPath string, +) (CustomizedImage, error) { + i.calls = append(i.calls, injectCall{image: image, seed: seed, outputPath: outputPath}) + if i.err != nil { + return CustomizedImage{}, i.err + } + + customized := i.image + if customized.Source == (DownloadedImage{}) { + customized.Source = image + } + if customized.Path == "" { + customized.Path = outputPath + } + return customized, nil +}