From f8e95c0cff185edb723381f288c68c2a1bbbe252 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 14 May 2026 14:52:33 -0700 Subject: [PATCH] feat(cli): resolve source metadata in plans --- internal/cli/plan.go | 44 ++++++++- internal/cli/root_test.go | 101 ++++++++++++++++++-- internal/providers/incusos/provider.go | 72 +++++++++++--- internal/providers/incusos/provider_test.go | 74 +++++++++++++- internal/providers/provider.go | 18 ++++ schemas/core/cue_types_gen.go | 12 +++ schemas/core/schema.cue | 9 ++ 7 files changed, 302 insertions(+), 28 deletions(-) diff --git a/internal/cli/plan.go b/internal/cli/plan.go index 39b9fa1..2472ddd 100644 --- a/internal/cli/plan.go +++ b/internal/cli/plan.go @@ -5,11 +5,13 @@ import ( "encoding/json" "fmt" "io" + "strings" "github.com/spf13/cobra" "github.com/meigma/imgcli/internal/providers" incusosprovider "github.com/meigma/imgcli/internal/providers/incusos" + "github.com/meigma/imgcli/internal/providers/incusos/cdn" imgschemas "github.com/meigma/imgcli/schemas" "github.com/meigma/imgcli/schemas/core" ) @@ -25,7 +27,7 @@ func newPlanCommand(rt *runtime) *cobra.Command { return err } - plan, err := runIncusOSPlan(cmd.Context(), config) + plan, err := rt.runIncusOSPlan(cmd.Context(), config) if err != nil { return err } @@ -35,11 +37,13 @@ func newPlanCommand(rt *runtime) *cobra.Command { } } -func runIncusOSPlan( +func (rt *runtime) runIncusOSPlan( ctx context.Context, config imgschemas.Config, ) (providers.Plan, error) { - provider := incusosprovider.New(*config.Incusos, incusosprovider.Options{}) + provider := incusosprovider.New(*config.Incusos, incusosprovider.Options{ + Catalog: rt.incusOSPlanCatalog(), + }) return provider.Plan(ctx, providers.PlanRequest{ Image: config.Image, @@ -47,6 +51,19 @@ func runIncusOSPlan( }) } +func (rt *runtime) incusOSPlanCatalog() incusosprovider.Catalog { + if rt.opts.IncusOSCatalog != nil { + return rt.opts.IncusOSCatalog + } + + options := []cdn.Option{} + if strings.TrimSpace(rt.opts.IncusOSCDNBaseURL) != "" { + options = append(options, cdn.WithBaseURL(rt.opts.IncusOSCDNBaseURL)) + } + + return cdn.NewClient(options...) +} + func printResolvedPlan(output io.Writer, plan providers.Plan) error { encoder := json.NewEncoder(output) encoder.SetIndent("", " ") @@ -86,5 +103,26 @@ func resolvedArtifactForOutput(plan providers.Plan, artifact providers.ArtifactP Path: artifact.OutputPath, Labels: artifact.Labels, Annotations: artifact.Annotations, + Source: resolvedArtifactSourceForOutput(artifact), + } +} + +func resolvedArtifactSourceForOutput(artifact providers.ArtifactPlan) *core.ResolvedArtifactSource { + if artifact.Source == nil { + return nil + } + + return &core.ResolvedArtifactSource{ + Version: artifact.Source.Version, + URL: artifact.Source.URL, + Digest: qualifiedSHA256(artifact.Source.SHA256), + Size: artifact.Source.Size, + } +} + +func qualifiedSHA256(sha256Digest string) string { + if strings.HasPrefix(sha256Digest, "sha256:") { + return sha256Digest } + return "sha256:" + sha256Digest } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 525ac72..766244e 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "os" "path/filepath" "strings" @@ -178,13 +179,42 @@ incusos: { } `) - result := executeCommand(t, Options{}, "--cache-dir", cacheDir, "plan", configPath) + catalog := &testCatalog{ + asset: incusos.ImageAsset{ + Version: incusos.Version("202604261712"), + Architecture: core.Architecture("amd64"), + Type: incusos.ImageTypeRaw, + URL: "https://example.invalid/os/202604261712/x86_64/IncusOS_202604261712.img.gz", + SHA256: "source-sha", + Size: 42, + }, + } + + result := executeCommand(t, Options{IncusOSCatalog: catalog}, "--cache-dir", cacheDir, "plan", configPath) require.NoError(t, result.err) assert.Empty(t, result.stderr) assert.NoDirExists(t, cacheDir) + assert.Equal(t, []incusos.ImageQuery{ + { + Channel: incusos.ChannelTesting, + Architecture: core.Architecture("amd64"), + Type: incusos.ImageTypeRaw, + }, + { + Channel: incusos.ChannelTesting, + Architecture: core.Architecture("amd64"), + Type: incusos.ImageTypeRaw, + }, + }, catalog.queries) var plan core.ResolvedPlan require.NoError(t, json.Unmarshal([]byte(result.stdout), &plan)) + source := &core.ResolvedArtifactSource{ + Version: "202604261712", + URL: "https://example.invalid/os/202604261712/x86_64/IncusOS_202604261712.img.gz", + Digest: "sha256:source-sha", + Size: 42, + } assert.Equal(t, core.ResolvedPlan{ Image: core.Image{ Name: core.Name("test-image"), @@ -204,6 +234,7 @@ incusos: { Path: filepath.Join(outputDir, "test-image-default-amd64.raw.gz"), Labels: map[string]string{"tier": "smoke"}, Annotations: map[string]string{"note": "planned"}, + Source: source, }, "secureboot": { ArtifactKey: core.ArtifactKey("secureboot"), @@ -215,6 +246,7 @@ incusos: { Format: core.ArtifactFormat("raw.gz"), MediaType: "application/gzip", Path: filepath.Join(outputDir, "custom", "secureboot.img.gz"), + Source: source, }, }, }, plan) @@ -233,13 +265,44 @@ incusos: variants: default: artifact: { } `) - result := executeCommand(t, Options{}, "plan", configPath) + result := executeCommand(t, Options{ + IncusOSCatalog: &testCatalog{asset: incusos.ImageAsset{ + Version: incusos.Version("202604261712"), + Architecture: core.Architecture("amd64"), + Type: incusos.ImageTypeRaw, + URL: "https://example.invalid/incusos.img.gz", + SHA256: "source-sha", + Size: 42, + }}, + }, "plan", configPath) require.NoError(t, result.err) assert.Empty(t, result.stderr) assert.NotEmpty(t, result.stdout) }) + t.Run("catalog errors fail without stdout", func(t *testing.T) { + clearIMGCLIEnv(t) + catalogErr := errors.New("catalog failed") + configPath := writeImageConfig(t, ` +apiVersion: "imgcli.meigma.io/v0alpha1" +kind: "ImagePlan" +image: name: "test-image" +incusos: variants: default: artifact: { + architecture: "amd64" + format: "raw.gz" +} +`) + + result := executeCommand(t, Options{ + IncusOSCatalog: &testCatalog{err: catalogErr}, + }, "plan", configPath) + + require.ErrorIs(t, result.err, catalogErr) + assert.Empty(t, result.stdout) + assert.Empty(t, result.stderr) + }) + t.Run("missing provider fails explicitly", func(t *testing.T) { clearIMGCLIEnv(t) configPath := writeImageConfig(t, ` @@ -380,6 +443,7 @@ talos: {} Path: run.defaultPath, Labels: map[string]string{"tier": "smoke"}, Annotations: map[string]string{"note": "built"}, + Source: testResolvedSource(), Digest: "sha256:" + run.sha256, Size: int64(len(run.artifactBody)), }) @@ -393,6 +457,7 @@ talos: {} Format: core.ArtifactFormat("raw.gz"), MediaType: "application/gzip", Path: run.secureBootPath, + Source: testResolvedSource(), Digest: "sha256:" + run.sha256, Size: int64(len(run.artifactBody)), }) @@ -561,9 +626,12 @@ incusos: { wantOutputPath := filepath.Join(outputDir, "test-image-default-amd64.raw.gz") catalog := &testCatalog{ asset: incusos.ImageAsset{ - URL: "https://example.invalid/os/202604261712/x86_64/IncusOS_202604261712.img.gz", - SHA256: "source-sha", - Size: 42, + Version: incusos.Version("202604261712"), + Architecture: core.Architecture("amd64"), + Type: incusos.ImageTypeRaw, + URL: "https://example.invalid/os/202604261712/x86_64/IncusOS_202604261712.img.gz", + SHA256: "source-sha", + Size: 42, }, } downloader := &testDownloader{ @@ -843,9 +911,12 @@ incusos: { secureBootPath := filepath.Join(outputDir, "test-image-secureboot-amd64.raw.gz") catalog := &testCatalog{ asset: incusos.ImageAsset{ - URL: "https://example.invalid/os/202604261712/x86_64/IncusOS_202604261712.img.gz", - SHA256: "source-sha", - Size: 42, + Version: incusos.Version("202604261712"), + Architecture: core.Architecture("amd64"), + Type: incusos.ImageTypeRaw, + URL: "https://example.invalid/os/202604261712/x86_64/IncusOS_202604261712.img.gz", + SHA256: "source-sha", + Size: 42, }, } downloader := &testDownloader{ @@ -907,6 +978,15 @@ func assertBuildMetadata(t *testing.T, path string, want core.ResolvedArtifact) assert.Equal(t, os.FileMode(artifactMetadataFileMode), info.Mode().Perm()) } +func testResolvedSource() *core.ResolvedArtifactSource { + return &core.ResolvedArtifactSource{ + Version: "202604261712", + URL: "https://example.invalid/os/202604261712/x86_64/IncusOS_202604261712.img.gz", + Digest: "sha256:source-sha", + Size: 42, + } +} + func assertSuccessfulBuildAdapters(t *testing.T, run buildCommandRun) { t.Helper() @@ -989,11 +1069,16 @@ func writeImageConfig(t *testing.T, content string) string { type testCatalog struct { asset incusos.ImageAsset + err error queries []incusos.ImageQuery } func (c *testCatalog) ResolveImage(_ context.Context, query incusos.ImageQuery) (incusos.ImageAsset, error) { c.queries = append(c.queries, query) + if c.err != nil { + return incusos.ImageAsset{}, c.err + } + return c.asset, nil } diff --git a/internal/providers/incusos/provider.go b/internal/providers/incusos/provider.go index 09fc9d8..bcffe59 100644 --- a/internal/providers/incusos/provider.go +++ b/internal/providers/incusos/provider.go @@ -60,11 +60,17 @@ func (p *Provider) Name() core.ProviderName { } // Plan resolves IncusOS configuration into concrete artifact work. -func (p *Provider) Plan(_ context.Context, req providers.PlanRequest) (providers.Plan, error) { +func (p *Provider) Plan(ctx context.Context, req providers.PlanRequest) (providers.Plan, error) { artifacts, err := planArtifacts(req, p.config) if err != nil { return providers.Plan{}, err } + if p.options.Catalog == nil { + return providers.Plan{}, errors.New("incusos catalog is required") + } + if err := p.resolveArtifactSources(ctx, artifacts); err != nil { + return providers.Plan{}, err + } return providerPlan(req, artifacts), nil } @@ -105,18 +111,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{ - Channel: source.Channel, - Version: source.Version, - Architecture: artifact.plan.Architecture, - Type: artifact.imageType, - }) - if err != nil { - return providers.BuildResult{}, cleanupBuiltOutputs(err, cleanupOutputs) - } - - downloaded, err := p.options.Downloader.DownloadImage(ctx, asset) + downloaded, err := p.options.Downloader.DownloadImage(ctx, artifact.source) if err != nil { return providers.BuildResult{}, cleanupBuiltOutputs(err, cleanupOutputs) } @@ -144,9 +139,31 @@ func (p *Provider) Build(ctx context.Context, req providers.BuildRequest) (provi type plannedArtifact struct { variant incusosschema.Variant imageType ImageType + source ImageAsset plan providers.ArtifactPlan } +func (p *Provider) resolveArtifactSources(ctx context.Context, artifacts []plannedArtifact) error { + for index := range artifacts { + artifact := &artifacts[index] + source := resolveSource(p.config.Defaults, artifact.variant.Source) + asset, err := p.options.Catalog.ResolveImage(ctx, ImageQuery{ + Channel: source.Channel, + Version: source.Version, + Architecture: artifact.plan.Architecture, + Type: artifact.imageType, + }) + if err != nil { + return err + } + + artifact.source = asset + artifact.plan.Source = sourceMetadataForAsset(asset) + } + + return nil +} + func planArtifacts(req providers.PlanRequest, config Config) ([]plannedArtifact, error) { if len(config.Variants) == 0 { return nil, errors.New("incusos build requires at least one variant") @@ -237,10 +254,15 @@ func plannedArtifactsForExecution(plan providers.Plan, config Config) ([]planned if err != nil { return nil, err } + sourceAsset, err := sourceAssetFromPlan(artifactPlan, imageType) + if err != nil { + return nil, err + } artifacts = append(artifacts, plannedArtifact{ variant: variant, imageType: imageType, + source: sourceAsset, plan: artifactPlan, }) } @@ -248,6 +270,30 @@ func plannedArtifactsForExecution(plan providers.Plan, config Config) ([]planned return artifacts, nil } +func sourceMetadataForAsset(asset ImageAsset) *providers.SourceMetadata { + return &providers.SourceMetadata{ + Version: string(asset.Version), + URL: asset.URL, + SHA256: asset.SHA256, + Size: asset.Size, + } +} + +func sourceAssetFromPlan(artifact providers.ArtifactPlan, imageType ImageType) (ImageAsset, error) { + if artifact.Source == nil { + return ImageAsset{}, fmt.Errorf("incusos planned artifact %q is missing source metadata", artifact.Key) + } + + return ImageAsset{ + Version: Version(artifact.Source.Version), + Architecture: artifact.Architecture, + Type: imageType, + URL: artifact.Source.URL, + SHA256: artifact.Source.SHA256, + Size: artifact.Source.Size, + }, nil +} + func rejectExistingOutputPaths(artifacts []plannedArtifact) error { for _, artifact := range artifacts { path := artifact.plan.OutputPath diff --git a/internal/providers/incusos/provider_test.go b/internal/providers/incusos/provider_test.go index e6d12e1..8e34dd4 100644 --- a/internal/providers/incusos/provider_test.go +++ b/internal/providers/incusos/provider_test.go @@ -56,7 +56,11 @@ func TestProviderPlanResolvesArtifactFilenames(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { outputDir := t.TempDir() - provider := New(configWithArtifact(tt.artifact), Options{}) + asset := testImageAsset() + catalog := &recordingCatalog{asset: asset} + provider := New(configWithArtifact(tt.artifact), Options{ + Catalog: catalog, + }) plan, err := provider.Plan(context.Background(), providers.PlanRequest{ Image: core.Image{Name: core.Name("test-image")}, @@ -78,6 +82,7 @@ func TestProviderPlanResolvesArtifactFilenames(t *testing.T) { OutputPath: tt.wantOutputPath(outputDir), Labels: tt.artifact.Labels, Annotations: tt.artifact.Annotations, + Source: sourceMetadataForAsset(asset), }, plan.Artifacts[0]) }) } @@ -85,7 +90,11 @@ func TestProviderPlanResolvesArtifactFilenames(t *testing.T) { func TestProviderPlanCreatesMultipleVariantsInStableOrder(t *testing.T) { outputDir := t.TempDir() - provider := New(multiVariantConfig(), Options{}) + asset := testImageAsset() + catalog := &recordingCatalog{asset: asset} + provider := New(multiVariantConfig(), Options{ + Catalog: catalog, + }) plan, err := provider.Plan(context.Background(), providers.PlanRequest{ Image: core.Image{Name: core.Name("test-image")}, @@ -93,6 +102,18 @@ func TestProviderPlanCreatesMultipleVariantsInStableOrder(t *testing.T) { }) require.NoError(t, err) + assert.Equal(t, []ImageQuery{ + { + Channel: ChannelStable, + Architecture: core.Architecture("amd64"), + Type: ImageTypeRaw, + }, + { + Channel: ChannelStable, + Architecture: core.Architecture("amd64"), + Type: ImageTypeRaw, + }, + }, catalog.queries) assert.Equal(t, []providers.ArtifactPlan{ { Key: core.ArtifactKey("default"), @@ -102,6 +123,7 @@ func TestProviderPlanCreatesMultipleVariantsInStableOrder(t *testing.T) { Format: core.ArtifactFormat("raw.gz"), MediaType: "application/gzip", OutputPath: filepath.Join(outputDir, "test-image-default-amd64.raw.gz"), + Source: sourceMetadataForAsset(asset), }, { Key: core.ArtifactKey("secureboot"), @@ -111,6 +133,7 @@ func TestProviderPlanCreatesMultipleVariantsInStableOrder(t *testing.T) { Format: core.ArtifactFormat("raw.gz"), MediaType: "application/gzip", OutputPath: filepath.Join(outputDir, "test-image-secureboot-amd64.raw.gz"), + Source: sourceMetadataForAsset(asset), }, }, plan.Artifacts) } @@ -119,6 +142,7 @@ func TestProviderPlanErrors(t *testing.T) { tests := []struct { name string config Config + options Options wantErr string }{ { @@ -133,6 +157,19 @@ func TestProviderPlanErrors(t *testing.T) { config: configWithVariant(core.ArtifactFormat("iso")), wantErr: `unsupported incusos artifact format "iso"`, }, + { + name: "missing catalog", + config: configWithVariant(core.ArtifactFormat("raw")), + wantErr: "incusos catalog is required", + }, + { + name: "catalog error", + config: configWithVariant(core.ArtifactFormat("raw")), + options: Options{ + Catalog: &recordingCatalog{err: errors.New("catalog failed")}, + }, + wantErr: "catalog failed", + }, { name: "duplicate output paths", config: Config{ @@ -178,7 +215,11 @@ func TestProviderPlanErrors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - provider := New(tt.config, Options{}) + options := tt.options + if options == (Options{}) && tt.wantErr != "incusos catalog is required" { + options.Catalog = &recordingCatalog{asset: testImageAsset()} + } + provider := New(tt.config, options) plan, err := provider.Plan(context.Background(), providers.PlanRequest{ Image: core.Image{Name: core.Name("test-image")}, @@ -320,6 +361,7 @@ func TestProviderBuildCreatesCustomizedImage(t *testing.T) { OutputPath: wantOutputPath, Labels: tt.artifact.Labels, Annotations: tt.artifact.Annotations, + Source: sourceMetadataForAsset(asset), }, result.Plan.Artifacts[0]) }) } @@ -419,6 +461,7 @@ func TestProviderBuildCreatesMultipleVariantsInStableOrder(t *testing.T) { Format: core.ArtifactFormat("raw.gz"), MediaType: "application/gzip", OutputPath: filepath.Join(outputDir, "test-image-default-amd64.raw.gz"), + Source: sourceMetadataForAsset(asset), }, { Key: core.ArtifactKey("secureboot"), @@ -428,6 +471,7 @@ func TestProviderBuildCreatesMultipleVariantsInStableOrder(t *testing.T) { Format: core.ArtifactFormat("raw.gz"), MediaType: "application/gzip", OutputPath: filepath.Join(outputDir, "test-image-secureboot-amd64.raw.gz"), + Source: sourceMetadataForAsset(asset), }, }, result.Plan.Artifacts) assert.Equal(t, result.Plan.Artifacts[0], result.Artifacts[0].Plan) @@ -537,7 +581,18 @@ func TestProviderBuildRejectsPreExistingOutputBeforeBuild(t *testing.T) { require.ErrorContains(t, err, "incusos artifact output path already exists") assert.Empty(t, result) assert.FileExists(t, defaultOutputPath) - assert.Empty(t, catalog.queries) + assert.Equal(t, []ImageQuery{ + { + Channel: ChannelStable, + Architecture: core.Architecture("amd64"), + Type: ImageTypeRaw, + }, + { + Channel: ChannelStable, + Architecture: core.Architecture("amd64"), + Type: ImageTypeRaw, + }, + }, catalog.queries) assert.Empty(t, downloader.assets) assert.Empty(t, seedBuilder.configs) assert.Empty(t, injector.calls) @@ -719,6 +774,17 @@ func configWithArtifact(artifact core.ArtifactIntent) Config { } } +func testImageAsset() ImageAsset { + return ImageAsset{ + Version: Version("202604261712"), + Architecture: core.Architecture("amd64"), + Type: ImageTypeRaw, + URL: "https://example.invalid/os/202604261712/x86_64/IncusOS_202604261712.img.gz", + SHA256: "source-sha", + Size: 42, + } +} + func optionsWithout(mutator func(*Options)) Options { options := Options{ Catalog: &recordingCatalog{asset: ImageAsset{}}, diff --git a/internal/providers/provider.go b/internal/providers/provider.go index 24f15c9..6c2e33f 100644 --- a/internal/providers/provider.go +++ b/internal/providers/provider.go @@ -88,6 +88,24 @@ type ArtifactPlan struct { // Annotations are provider or user annotations copied to artifact metadata. Annotations map[string]string + + // Source is the upstream artifact selected by provider planning. + Source *SourceMetadata +} + +// SourceMetadata describes the upstream source asset selected for an artifact. +type SourceMetadata struct { + // Version is the upstream release version that contains this source asset. + Version string + + // URL is the upstream source asset URL. + URL string + + // SHA256 is the expected upstream source SHA-256 digest in lowercase hex. + SHA256 string + + // Size is the upstream source asset size in bytes. + Size int64 } // BuildResult describes artifacts produced by a provider build. diff --git a/schemas/core/cue_types_gen.go b/schemas/core/cue_types_gen.go index c55f3e3..51c1bee 100644 --- a/schemas/core/cue_types_gen.go +++ b/schemas/core/cue_types_gen.go @@ -83,12 +83,24 @@ type ResolvedArtifact struct { Annotations map[string]string `json:"annotations,omitempty"` + Source *ResolvedArtifactSource `json:"source,omitempty"` + // Populated after build. Digest string `json:"digest,omitempty"` Size int64 `json:"size,omitempty"` } +type ResolvedArtifactSource struct { + Version string `json:"version"` + + URL string `json:"url"` + + Digest string `json:"digest"` + + Size int64 `json:"size"` +} + type ResolvedPlan struct { Image Image `json:"image"` diff --git a/schemas/core/schema.cue b/schemas/core/schema.cue index 0eb1a87..48f936b 100644 --- a/schemas/core/schema.cue +++ b/schemas/core/schema.cue @@ -68,11 +68,20 @@ package core labels?: [string]: string annotations?: [string]: string + source?: #ResolvedArtifactSource @go(,optional=nillable) + // Populated after build. digest?: string size?: int } +#ResolvedArtifactSource: { + version: string + url: string @go(URL) + digest: string + size: int +} + #ResolvedPlan: { image: #Image