diff --git a/internal/providers/incusos/provider.go b/internal/providers/incusos/provider.go index 87069fc..fa820ed 100644 --- a/internal/providers/incusos/provider.go +++ b/internal/providers/incusos/provider.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "os" "path/filepath" "sort" "strings" @@ -82,6 +83,9 @@ func (p *Provider) Build(ctx context.Context, req providers.BuildRequest) (provi if err != nil { return providers.BuildResult{}, err } + if outputErr := rejectExistingOutputPaths(artifacts); outputErr != nil { + return providers.BuildResult{}, outputErr + } seed, err := p.options.SeedBuilder.BuildSeed(ctx, p.config) if err != nil { @@ -89,6 +93,7 @@ func (p *Provider) Build(ctx context.Context, req providers.BuildRequest) (provi } builtArtifacts := make([]providers.BuiltArtifact, 0, len(artifacts)) + cleanupOutputs := make([]string, 0, len(artifacts)) for _, artifact := range artifacts { source := resolveSource(p.config.Defaults, artifact.variant.Source) asset, err := p.options.Catalog.ResolveImage(ctx, ImageQuery{ @@ -98,18 +103,19 @@ func (p *Provider) Build(ctx context.Context, req providers.BuildRequest) (provi Type: artifact.imageType, }) if err != nil { - return providers.BuildResult{}, err + return providers.BuildResult{}, cleanupBuiltOutputs(err, cleanupOutputs) } downloaded, err := p.options.Downloader.DownloadImage(ctx, asset) if err != nil { - return providers.BuildResult{}, err + return providers.BuildResult{}, cleanupBuiltOutputs(err, cleanupOutputs) } customized, err := p.options.ImageInjector.InjectSeed(ctx, downloaded, seed, artifact.plan.OutputPath) if err != nil { - return providers.BuildResult{}, err + return providers.BuildResult{}, cleanupBuiltOutputs(err, cleanupOutputs) } + cleanupOutputs = append(cleanupOutputs, artifact.plan.OutputPath) builtArtifacts = append(builtArtifacts, providers.BuiltArtifact{ Plan: artifact.plan, @@ -194,6 +200,42 @@ func planArtifacts(req providers.BuildRequest, config Config) ([]plannedArtifact return artifacts, nil } +func rejectExistingOutputPaths(artifacts []plannedArtifact) error { + for _, artifact := range artifacts { + path := artifact.plan.OutputPath + _, err := os.Stat(path) + switch { + case err == nil: + return fmt.Errorf("incusos artifact output path already exists: %q", path) + case errors.Is(err, os.ErrNotExist): + continue + default: + return fmt.Errorf("stat incusos artifact output path %q: %w", path, err) + } + } + + return nil +} + +func cleanupBuiltOutputs(cause error, paths []string) error { + if len(paths) == 0 { + return cause + } + + errs := []error{cause} + for index := len(paths) - 1; index >= 0; index-- { + path := paths[index] + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + errs = append(errs, fmt.Errorf("remove partial incusos artifact %q: %w", path, err)) + } + } + + if len(errs) == 1 { + return cause + } + return errors.Join(errs...) +} + func artifactOutputPath( req providers.BuildRequest, variantName core.VariantName, diff --git a/internal/providers/incusos/provider_test.go b/internal/providers/incusos/provider_test.go index df57278..12d57f5 100644 --- a/internal/providers/incusos/provider_test.go +++ b/internal/providers/incusos/provider_test.go @@ -3,6 +3,7 @@ package incusos import ( "context" "errors" + "os" "path/filepath" "testing" @@ -327,6 +328,72 @@ func TestProviderBuildRejectsDuplicateOutputPathsBeforeBuild(t *testing.T) { assert.Empty(t, injector.calls) } +func TestProviderBuildCleansUpNewOutputsAfterLaterVariantFailure(t *testing.T) { + outputDir := t.TempDir() + injectErr := errors.New("inject failed") + injector := &writingImageInjector{failOnCall: 2, err: injectErr} + provider := New(multiVariantConfig(), Options{ + Catalog: &recordingCatalog{asset: ImageAsset{}}, + Downloader: &recordingDownloader{}, + SeedBuilder: &recordingSeedBuilder{seed: SeedArchive{Data: []byte("seed")}}, + ImageInjector: injector, + }) + + result, err := provider.Build(context.Background(), providers.BuildRequest{ + Plan: providers.Plan{Image: core.Image{Name: core.Name("test-image")}}, + OutputDir: outputDir, + }) + + require.ErrorIs(t, err, injectErr) + assert.Empty(t, result) + assert.NoFileExists(t, filepath.Join(outputDir, "test-image-default-amd64.raw.gz")) + assert.NoFileExists(t, filepath.Join(outputDir, "test-image-secureboot-amd64.raw.gz")) + require.Len(t, injector.calls, 2) +} + +func TestProviderBuildRejectsPreExistingOutputBeforeBuild(t *testing.T) { + outputDir := t.TempDir() + defaultOutputPath := filepath.Join(outputDir, "test-image-default-amd64.raw.gz") + require.NoError(t, os.WriteFile(defaultOutputPath, []byte("pre-existing"), 0o600)) + catalog := &recordingCatalog{asset: ImageAsset{}} + downloader := &recordingDownloader{} + seedBuilder := &recordingSeedBuilder{seed: SeedArchive{Data: []byte("seed")}} + injector := &writingImageInjector{} + provider := New(multiVariantConfig(), Options{ + Catalog: catalog, + Downloader: downloader, + SeedBuilder: seedBuilder, + ImageInjector: injector, + }) + + result, err := provider.Build(context.Background(), providers.BuildRequest{ + Plan: providers.Plan{Image: core.Image{Name: core.Name("test-image")}}, + OutputDir: outputDir, + }) + + require.ErrorContains(t, err, "incusos artifact output path already exists") + assert.Empty(t, result) + assert.FileExists(t, defaultOutputPath) + assert.Empty(t, catalog.queries) + assert.Empty(t, downloader.assets) + assert.Empty(t, seedBuilder.configs) + assert.Empty(t, injector.calls) + assert.NoFileExists(t, filepath.Join(outputDir, "test-image-secureboot-amd64.raw.gz")) +} + +func TestCleanupBuiltOutputsJoinsCleanupErrors(t *testing.T) { + cause := errors.New("build failed") + outputPath := filepath.Join(t.TempDir(), "artifact.raw.gz") + require.NoError(t, os.Mkdir(outputPath, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(outputPath, "child"), []byte("data"), 0o600)) + + err := cleanupBuiltOutputs(cause, []string{outputPath}) + + require.ErrorIs(t, err, cause) + require.ErrorContains(t, err, "remove partial incusos artifact") + assert.DirExists(t, outputPath) +} + func TestProviderBuildErrors(t *testing.T) { catalogErr := errors.New("catalog failed") downloadErr := errors.New("download failed") @@ -451,6 +518,26 @@ func TestProviderBuildErrors(t *testing.T) { } } +func multiVariantConfig() Config { + return Config{ + Seed: &incusosschema.Seed{}, + Variants: map[core.VariantName]incusosschema.Variant{ + "default": { + Artifact: core.ArtifactIntent{ + Architecture: core.Architecture("amd64"), + Format: core.ArtifactFormat("raw.gz"), + }, + }, + "secureboot": { + Artifact: core.ArtifactIntent{ + Architecture: core.Architecture("amd64"), + Format: core.ArtifactFormat("raw.gz"), + }, + }, + }, + } +} + func configWithVariant(format core.ArtifactFormat) Config { return configWithArtifact(core.ArtifactIntent{ Architecture: core.Architecture("amd64"), @@ -557,3 +644,34 @@ func (i *recordingImageInjector) InjectSeed( } return customized, nil } + +type writingImageInjector struct { + failOnCall int + err error + calls []injectCall +} + +func (i *writingImageInjector) 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.failOnCall == len(i.calls) { + return CustomizedImage{}, i.err + } + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + return CustomizedImage{}, err + } + if err := os.WriteFile(outputPath, []byte(outputPath), 0o600); err != nil { + return CustomizedImage{}, err + } + + return CustomizedImage{ + Source: image, + Path: outputPath, + Size: int64(len(outputPath)), + SHA256: "custom-sha", + }, nil +} diff --git a/internal/publish/mocks/catalog_client.go b/internal/publish/mocks/catalog_client.go index 1dfad14..28d33e6 100644 --- a/internal/publish/mocks/catalog_client.go +++ b/internal/publish/mocks/catalog_client.go @@ -254,6 +254,75 @@ func (_c *MockCatalogClient_CreateImage_Call) RunAndReturn(run func(context1 con return _c } +// DeleteArtifact provides a mock function for the type MockCatalogClient +func (_mock *MockCatalogClient) DeleteArtifact(context1 context.Context, s string, s1 string, s2 string) error { + ret := _mock.Called(context1, s, s1, s2) + + if len(ret) == 0 { + panic("no return value specified for DeleteArtifact") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok { + r0 = returnFunc(context1, s, s1, s2) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockCatalogClient_DeleteArtifact_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteArtifact' +type MockCatalogClient_DeleteArtifact_Call struct { + *mock.Call +} + +// DeleteArtifact is a helper method to define mock.On call +// - context1 context.Context +// - s string +// - s1 string +// - s2 string +func (_e *MockCatalogClient_Expecter) DeleteArtifact(context1 interface{}, s interface{}, s1 interface{}, s2 interface{}) *MockCatalogClient_DeleteArtifact_Call { + return &MockCatalogClient_DeleteArtifact_Call{Call: _e.mock.On("DeleteArtifact", context1, s, s1, s2)} +} + +func (_c *MockCatalogClient_DeleteArtifact_Call) Run(run func(context1 context.Context, s string, s1 string, s2 string)) *MockCatalogClient_DeleteArtifact_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockCatalogClient_DeleteArtifact_Call) Return(err error) *MockCatalogClient_DeleteArtifact_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockCatalogClient_DeleteArtifact_Call) RunAndReturn(run func(context1 context.Context, s string, s1 string, s2 string) error) *MockCatalogClient_DeleteArtifact_Call { + _c.Call.Return(run) + return _c +} + // GetPublishJob provides a mock function for the type MockCatalogClient func (_mock *MockCatalogClient) GetPublishJob(context1 context.Context, s string) (client.PublishJob, error) { ret := _mock.Called(context1, s) diff --git a/internal/publish/release.go b/internal/publish/release.go index 7a5696d..32472ce 100644 --- a/internal/publish/release.go +++ b/internal/publish/release.go @@ -14,6 +14,7 @@ import ( const ( defaultPublisherTimeout = time.Minute defaultPublisherPollInterval = time.Second + catalogCleanupTimeout = 30 * time.Second ) // CatalogClient is the imgsrv catalog operation seam used by the release publisher. @@ -21,6 +22,7 @@ type CatalogClient interface { CreateImage(context.Context, imgsrv.CreateImageRequest) (imgsrv.Image, error) CreateDraftVersion(context.Context, string, imgsrv.CreateDraftVersionRequest) (imgsrv.ImageVersion, error) AddArtifact(context.Context, string, string, imgsrv.AddArtifactRequest) (imgsrv.Artifact, error) + DeleteArtifact(context.Context, string, string, string) error PublishVersion(context.Context, string, string) (imgsrv.PublishJob, error) GetPublishJob(context.Context, string) (imgsrv.PublishJob, error) PutAlias(context.Context, string, string, imgsrv.PutAliasRequest) (imgsrv.Alias, error) @@ -174,7 +176,7 @@ func (p *Publisher) PublishRelease(ctx context.Context, request ReleaseRequest) for _, artifact := range uploaded { published, addErr := p.addArtifact(ctx, request, artifact) if addErr != nil { - return ReleaseResult{}, addErr + return ReleaseResult{}, p.cleanupDraftArtifacts(ctx, request, result.Artifacts, addErr) } result.Artifacts = append(result.Artifacts, published) } @@ -286,6 +288,47 @@ func (p *Publisher) addArtifact( }, nil } +func (p *Publisher) cleanupDraftArtifacts( + ctx context.Context, + request ReleaseRequest, + artifacts []PublishedReleaseArtifact, + cause error, +) error { + if len(artifacts) == 0 { + return cause + } + + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), catalogCleanupTimeout) + defer cancel() + + errs := []error{cause} + for index := len(artifacts) - 1; index >= 0; index-- { + artifact := artifacts[index] + if artifact.ServerArtifactID == "" { + continue + } + if err := p.catalog.DeleteArtifact( + cleanupCtx, + request.ImageName, + request.Version, + artifact.ServerArtifactID, + ); err != nil { + errs = append(errs, fmt.Errorf( + "delete draft imgsrv artifact %s from %s %s: %w", + artifact.ServerArtifactID, + request.ImageName, + request.Version, + err, + )) + } + } + + if len(errs) == 1 { + return cause + } + return errors.Join(errs...) +} + func (p *Publisher) waitPublished(ctx context.Context, job imgsrv.PublishJob) (imgsrv.PublishJob, error) { finalJob, err := p.publishJobResult(job) if err != nil { diff --git a/internal/publish/release_test.go b/internal/publish/release_test.go index 1269bc2..e34fa3a 100644 --- a/internal/publish/release_test.go +++ b/internal/publish/release_test.go @@ -328,6 +328,258 @@ func TestPublisherFailsBeforeCatalogWhenUploadIsNotReady(t *testing.T) { assert.Empty(t, result) } +func TestPublisherDeletesAddedArtifactsWhenLaterAddArtifactFails(t *testing.T) { + uploads := mocks.NewMockUploadsClient(t) + catalog := mocks.NewMockCatalogClient(t) + defaultBody := bytes.Repeat([]byte("a"), int(publish.MinPartSizeBytes)) + secureBootBody := bytes.Repeat([]byte("b"), int(publish.MinPartSizeBytes)) + defaultPath := writePublishTestArtifact(t, "default.raw.gz", defaultBody) + secureBootPath := writePublishTestArtifact(t, "secureboot.raw.gz", secureBootBody) + secureBootArtifact := releaseTestArtifact(secureBootPath, int64(len(secureBootBody))) + secureBootArtifact.Key = "secureboot" + secureBootArtifact.Variant = "secureboot" + secureBootArtifact.Digest = "def456" + addErr := errors.New("artifact rejected") + + expectReadyUpload(t, uploads, defaultPath, int64(len(defaultBody)), "abc123") + expectReadyUpload(t, uploads, secureBootPath, int64(len(secureBootBody)), "def456") + catalog.EXPECT(). + CreateImage(mock.Anything, imgsrv.CreateImageRequest{Name: "incusos"}). + Return(imgsrv.Image{Name: "incusos"}, nil). + Once() + catalog.EXPECT(). + CreateDraftVersion(mock.Anything, "incusos", imgsrv.CreateDraftVersionRequest{Version: "v1.0.0"}). + Return(imgsrv.ImageVersion{Version: "v1.0.0", State: imgsrv.ImageVersionStateDraft}, nil). + Once() + catalog.EXPECT(). + AddArtifact(mock.Anything, "incusos", "v1.0.0", mock.Anything). + Return(imgsrv.Artifact{ + ID: "artifact-1", + Variant: "default", + OperatingSystem: "incusos", + Architecture: "x86_64", + Format: imgsrv.ArtifactFormatRawGZ, + PrimaryBlobDigest: "sha256:abc123", + PrimaryBlobSizeBytes: int64(len(defaultBody)), + PrimaryMediaType: "application/gzip", + }, nil). + Once() + catalog.EXPECT(). + AddArtifact(mock.Anything, "incusos", "v1.0.0", mock.Anything). + Return(imgsrv.Artifact{}, addErr). + Once() + catalog.EXPECT(). + DeleteArtifact(mock.Anything, "incusos", "v1.0.0", "artifact-1"). + Return(nil). + Once() + + publisher := newReleaseTestPublisher(t, catalog, uploads) + result, err := publisher.PublishRelease(context.Background(), publish.ReleaseRequest{ + ImageName: "incusos", + Version: "v1.0.0", + Artifacts: []publish.ReleaseArtifact{ + releaseTestArtifact(defaultPath, int64(len(defaultBody))), + secureBootArtifact, + }, + }) + + require.ErrorIs(t, err, addErr) + assert.Empty(t, result) +} + +func TestPublisherDoesNotDeleteAddedArtifactsWhenPublishVersionFails(t *testing.T) { + uploads := mocks.NewMockUploadsClient(t) + catalog := mocks.NewMockCatalogClient(t) + defaultBody := bytes.Repeat([]byte("a"), int(publish.MinPartSizeBytes)) + secureBootBody := bytes.Repeat([]byte("b"), int(publish.MinPartSizeBytes)) + defaultPath := writePublishTestArtifact(t, "default.raw.gz", defaultBody) + secureBootPath := writePublishTestArtifact(t, "secureboot.raw.gz", secureBootBody) + secureBootArtifact := releaseTestArtifact(secureBootPath, int64(len(secureBootBody))) + secureBootArtifact.Key = "secureboot" + secureBootArtifact.Variant = "secureboot" + secureBootArtifact.Digest = "def456" + publishErr := errors.New("publish denied") + + expectReadyUpload(t, uploads, defaultPath, int64(len(defaultBody)), "abc123") + expectReadyUpload(t, uploads, secureBootPath, int64(len(secureBootBody)), "def456") + catalog.EXPECT(). + CreateImage(mock.Anything, imgsrv.CreateImageRequest{Name: "incusos"}). + Return(imgsrv.Image{Name: "incusos"}, nil). + Once() + catalog.EXPECT(). + CreateDraftVersion(mock.Anything, "incusos", imgsrv.CreateDraftVersionRequest{Version: "v1.0.0"}). + Return(imgsrv.ImageVersion{Version: "v1.0.0", State: imgsrv.ImageVersionStateDraft}, nil). + Once() + catalog.EXPECT(). + AddArtifact(mock.Anything, "incusos", "v1.0.0", mock.Anything). + Return(imgsrv.Artifact{ + ID: "artifact-1", + Variant: "default", + OperatingSystem: "incusos", + Architecture: "x86_64", + Format: imgsrv.ArtifactFormatRawGZ, + PrimaryBlobDigest: "sha256:abc123", + PrimaryBlobSizeBytes: int64(len(defaultBody)), + PrimaryMediaType: "application/gzip", + }, nil). + Once() + catalog.EXPECT(). + AddArtifact(mock.Anything, "incusos", "v1.0.0", mock.Anything). + Return(imgsrv.Artifact{ + ID: "artifact-2", + Variant: "secureboot", + OperatingSystem: "incusos", + Architecture: "x86_64", + Format: imgsrv.ArtifactFormatRawGZ, + PrimaryBlobDigest: "sha256:def456", + PrimaryBlobSizeBytes: int64(len(secureBootBody)), + PrimaryMediaType: "application/gzip", + }, nil). + Once() + catalog.EXPECT(). + PublishVersion(mock.Anything, "incusos", "v1.0.0"). + Return(imgsrv.PublishJob{}, publishErr). + Once() + + publisher := newReleaseTestPublisher(t, catalog, uploads) + result, err := publisher.PublishRelease(context.Background(), publish.ReleaseRequest{ + ImageName: "incusos", + Version: "v1.0.0", + Artifacts: []publish.ReleaseArtifact{ + releaseTestArtifact(defaultPath, int64(len(defaultBody))), + secureBootArtifact, + }, + }) + + require.ErrorIs(t, err, publishErr) + require.ErrorContains(t, err, "publish imgsrv version incusos v1.0.0") + assert.Empty(t, result) +} + +func TestPublisherJoinsDraftArtifactCleanupFailure(t *testing.T) { + uploads := mocks.NewMockUploadsClient(t) + catalog := mocks.NewMockCatalogClient(t) + defaultBody := bytes.Repeat([]byte("a"), int(publish.MinPartSizeBytes)) + secureBootBody := bytes.Repeat([]byte("b"), int(publish.MinPartSizeBytes)) + defaultPath := writePublishTestArtifact(t, "default.raw.gz", defaultBody) + secureBootPath := writePublishTestArtifact(t, "secureboot.raw.gz", secureBootBody) + secureBootArtifact := releaseTestArtifact(secureBootPath, int64(len(secureBootBody))) + secureBootArtifact.Key = "secureboot" + secureBootArtifact.Variant = "secureboot" + secureBootArtifact.Digest = "def456" + addErr := errors.New("artifact rejected") + cleanupErr := errors.New("cleanup denied") + + expectReadyUpload(t, uploads, defaultPath, int64(len(defaultBody)), "abc123") + expectReadyUpload(t, uploads, secureBootPath, int64(len(secureBootBody)), "def456") + catalog.EXPECT(). + CreateImage(mock.Anything, imgsrv.CreateImageRequest{Name: "incusos"}). + Return(imgsrv.Image{Name: "incusos"}, nil). + Once() + catalog.EXPECT(). + CreateDraftVersion(mock.Anything, "incusos", imgsrv.CreateDraftVersionRequest{Version: "v1.0.0"}). + Return(imgsrv.ImageVersion{Version: "v1.0.0", State: imgsrv.ImageVersionStateDraft}, nil). + Once() + catalog.EXPECT(). + AddArtifact(mock.Anything, "incusos", "v1.0.0", mock.Anything). + Return(imgsrv.Artifact{ + ID: "artifact-1", + Variant: "default", + OperatingSystem: "incusos", + Architecture: "x86_64", + Format: imgsrv.ArtifactFormatRawGZ, + PrimaryBlobDigest: "sha256:abc123", + PrimaryBlobSizeBytes: int64(len(defaultBody)), + PrimaryMediaType: "application/gzip", + }, nil). + Once() + catalog.EXPECT(). + AddArtifact(mock.Anything, "incusos", "v1.0.0", mock.Anything). + Return(imgsrv.Artifact{}, addErr). + Once() + catalog.EXPECT(). + DeleteArtifact(mock.Anything, "incusos", "v1.0.0", "artifact-1"). + Return(cleanupErr). + Once() + + publisher := newReleaseTestPublisher(t, catalog, uploads) + result, err := publisher.PublishRelease(context.Background(), publish.ReleaseRequest{ + ImageName: "incusos", + Version: "v1.0.0", + Artifacts: []publish.ReleaseArtifact{ + releaseTestArtifact(defaultPath, int64(len(defaultBody))), + secureBootArtifact, + }, + }) + + require.ErrorIs(t, err, addErr) + require.ErrorIs(t, err, cleanupErr) + require.ErrorContains(t, err, "delete draft imgsrv artifact artifact-1") + assert.Empty(t, result) +} + +func TestPublisherDoesNotDeleteArtifactsWhenPublishJobFails(t *testing.T) { + uploads := mocks.NewMockUploadsClient(t) + catalog := mocks.NewMockCatalogClient(t) + artifactBody := bytes.Repeat([]byte("a"), int(publish.MinPartSizeBytes)) + artifactPath := writePublishTestArtifact(t, "artifact.raw.gz", artifactBody) + failureMessage := "manifest generation failed" + + expectReadyUpload(t, uploads, artifactPath, int64(len(artifactBody)), "abc123") + catalog.EXPECT(). + CreateImage(mock.Anything, imgsrv.CreateImageRequest{Name: "incusos"}). + Return(imgsrv.Image{Name: "incusos"}, nil). + Once() + catalog.EXPECT(). + CreateDraftVersion(mock.Anything, "incusos", imgsrv.CreateDraftVersionRequest{Version: "v1.0.0"}). + Return(imgsrv.ImageVersion{Version: "v1.0.0", State: imgsrv.ImageVersionStateDraft}, nil). + Once() + catalog.EXPECT(). + AddArtifact(mock.Anything, "incusos", "v1.0.0", mock.Anything). + Return(imgsrv.Artifact{ + ID: "artifact-1", + Variant: "default", + OperatingSystem: "incusos", + Architecture: "x86_64", + Format: imgsrv.ArtifactFormatRawGZ, + PrimaryBlobDigest: "sha256:abc123", + PrimaryBlobSizeBytes: int64(len(artifactBody)), + PrimaryMediaType: "application/gzip", + }, nil). + Once() + catalog.EXPECT(). + PublishVersion(mock.Anything, "incusos", "v1.0.0"). + Return(imgsrv.PublishJob{ + ID: "publish-job-1", + ImageName: "incusos", + Version: "v1.0.0", + State: imgsrv.PublishJobStateQueued, + }, nil). + Once() + catalog.EXPECT(). + GetPublishJob(mock.Anything, "publish-job-1"). + Return(imgsrv.PublishJob{ + ID: "publish-job-1", + ImageName: "incusos", + Version: "v1.0.0", + State: imgsrv.PublishJobStateFailed, + FailureMessage: &failureMessage, + }, nil). + Once() + + publisher := newReleaseTestPublisher(t, catalog, uploads) + result, err := publisher.PublishRelease(context.Background(), publish.ReleaseRequest{ + ImageName: "incusos", + Version: "v1.0.0", + Artifacts: []publish.ReleaseArtifact{ + releaseTestArtifact(artifactPath, int64(len(artifactBody))), + }, + }) + + require.ErrorContains(t, err, "publish imgsrv job publish-job-1 failed: manifest generation failed") + assert.Empty(t, result) +} + func TestPublisherSurfacesPartialAliasFailure(t *testing.T) { uploads := mocks.NewMockUploadsClient(t) catalog := mocks.NewMockCatalogClient(t)