Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions internal/providers/incusos/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
Expand Down Expand Up @@ -82,13 +83,17 @@ 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 {
return providers.BuildResult{}, err
}

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{
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
118 changes: 118 additions & 0 deletions internal/providers/incusos/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package incusos
import (
"context"
"errors"
"os"
"path/filepath"
"testing"

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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
}
69 changes: 69 additions & 0 deletions internal/publish/mocks/catalog_client.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 44 additions & 1 deletion internal/publish/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ import (
const (
defaultPublisherTimeout = time.Minute
defaultPublisherPollInterval = time.Second
catalogCleanupTimeout = 30 * time.Second
)

// CatalogClient is the imgsrv catalog operation seam used by the release publisher.
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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading