From efbddec082eeb7fc1302f3e65cdafd0fb6bcb775 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 14 May 2026 14:08:44 -0700 Subject: [PATCH] feat(cli): add build artifact metadata output --- internal/cli/build.go | 259 +++++++++++++++++++++++++++++- internal/cli/plan.go | 32 ++-- internal/cli/root_test.go | 325 +++++++++++++++++++++++++++++++------- schemas/cue_types_gen.go | 8 + schemas/schema.cue | 7 + 5 files changed, 554 insertions(+), 77 deletions(-) diff --git a/internal/cli/build.go b/internal/cli/build.go index 9a4cc95..67e7bea 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -2,10 +2,14 @@ package cli import ( "context" + "encoding/json" "errors" "fmt" "io" + "os" + "path/filepath" "strings" + "text/tabwriter" "github.com/spf13/cobra" @@ -18,14 +22,38 @@ import ( "github.com/meigma/imgcli/schemas/core" ) -const defaultBuildOutputDir = "dist" +const ( + defaultBuildOutputDir = "dist" + + artifactMetadataAPIVersion = "imgcli.meigma.io/v0alpha1" + artifactMetadataKind = "ArtifactMetadata" + artifactMetadataFileMode = 0o600 + artifactMetadataSuffix = ".artifact.json" + + buildSHA256PrefixLength = 12 + flagBuildFormat = "format" + tablePaddingWidth = 2 +) + +type buildOutputFormat string + +const ( + buildOutputFormatTable buildOutputFormat = "table" + buildOutputFormatJSON buildOutputFormat = "json" + buildOutputFormatPaths buildOutputFormat = "paths" +) func newBuildCommand(rt *runtime) *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "build CONFIG", Short: "Build disk image artifacts from configuration", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + format, err := parseBuildOutputFormat(cmd) + if err != nil { + return err + } + config, err := loadImageConfig(args[0]) if err != nil { return err @@ -36,9 +64,22 @@ func newBuildCommand(rt *runtime) *cobra.Command { return err } - return printBuildArtifacts(rt.opts.stdout(), result) + artifacts, err := writeBuildArtifactMetadata(result) + if err != nil { + return err + } + + return printBuildArtifacts(rt.opts.stdout(), result, artifacts, format) }, } + + cmd.Flags().String( + flagBuildFormat, + string(buildOutputFormatTable), + "Output format: table, json, or paths", + ) + + return cmd } type incusOSBuildPorts struct { @@ -151,9 +192,178 @@ func runIncusOSBuild( return result, nil } -func printBuildArtifacts(output io.Writer, result providers.BuildResult) error { +type builtArtifactOutput struct { + artifact providers.BuiltArtifact + metadataPath string +} + +type buildOutputSummary struct { + Image core.Image `json:"image"` + Artifacts []buildArtifactOutputSummary `json:"artifacts"` +} + +type buildArtifactOutputSummary struct { + ArtifactKey core.ArtifactKey `json:"artifactKey"` + Variant core.VariantName `json:"variant"` + Provider core.ProviderName `json:"provider"` + Os string `json:"os"` + Architecture core.Architecture `json:"architecture"` + Format core.ArtifactFormat `json:"format"` + Path string `json:"path"` + MetadataPath string `json:"metadataPath"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +func parseBuildOutputFormat(cmd *cobra.Command) (buildOutputFormat, error) { + value, err := cmd.Flags().GetString(flagBuildFormat) + if err != nil { + return "", fmt.Errorf("read build output format: %w", err) + } + + switch buildOutputFormat(strings.ToLower(strings.TrimSpace(value))) { + case buildOutputFormatTable: + return buildOutputFormatTable, nil + case buildOutputFormatJSON: + return buildOutputFormatJSON, nil + case buildOutputFormatPaths: + return buildOutputFormatPaths, nil + default: + return "", fmt.Errorf("invalid build output format %q: expected table, json, or paths", value) + } +} + +func writeBuildArtifactMetadata(result providers.BuildResult) ([]builtArtifactOutput, error) { + artifacts := make([]builtArtifactOutput, 0, len(result.Artifacts)) for _, artifact := range result.Artifacts { - if _, err := fmt.Fprintln(output, artifact.Path); err != nil { + metadataPath := buildArtifactMetadataPath(artifact.Path) + metadata := buildArtifactMetadata(result.Plan, artifact) + if err := writeJSONFile(metadataPath, metadata); err != nil { + return nil, fmt.Errorf("write artifact metadata %q: %w", metadataPath, err) + } + + artifacts = append(artifacts, builtArtifactOutput{ + artifact: artifact, + metadataPath: metadataPath, + }) + } + + return artifacts, nil +} + +func buildArtifactMetadata(plan providers.Plan, artifact providers.BuiltArtifact) imgschemas.ArtifactMetadata { + resolved := resolvedArtifactForOutput(plan, artifact.Plan) + resolved.Path = artifact.Path + resolved.Digest = "sha256:" + artifact.SHA256 + resolved.Size = artifact.Size + + return imgschemas.ArtifactMetadata{ + ApiVersion: artifactMetadataAPIVersion, + Kind: artifactMetadataKind, + Artifact: resolved, + } +} + +func writeJSONFile(path string, value any) error { + temp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + + tempPath := temp.Name() + committed := false + defer func() { + if !committed { + _ = os.Remove(tempPath) + } + }() + + encoder := json.NewEncoder(temp) + encoder.SetIndent("", " ") + if err := encoder.Encode(value); err != nil { + _ = temp.Close() + return fmt.Errorf("encode JSON: %w", err) + } + if err := temp.Close(); err != nil { + return fmt.Errorf("close temp file: %w", err) + } + if err := os.Chmod(tempPath, artifactMetadataFileMode); err != nil { + return fmt.Errorf("set temp file permissions: %w", err) + } + if err := os.Rename(tempPath, path); err != nil { + return fmt.Errorf("publish temp file: %w", err) + } + committed = true + + return nil +} + +func printBuildArtifacts( + output io.Writer, + result providers.BuildResult, + artifacts []builtArtifactOutput, + format buildOutputFormat, +) error { + switch format { + case buildOutputFormatTable: + return printBuildArtifactsTable(output, artifacts) + case buildOutputFormatJSON: + return printBuildArtifactsJSON(output, result, artifacts) + case buildOutputFormatPaths: + return printBuildArtifactPaths(output, artifacts) + default: + return fmt.Errorf("unsupported build output format %q", format) + } +} + +func printBuildArtifactsTable(output io.Writer, artifacts []builtArtifactOutput) error { + table := tabwriter.NewWriter(output, 0, 0, tablePaddingWidth, ' ', 0) + if _, err := fmt.Fprintln( + table, + "VARIANT\tOS\tARCH\tFORMAT\tSIZE_BYTES\tSHA256_PREFIX\tARTIFACT\tMETADATA", + ); err != nil { + return fmt.Errorf("write build artifact table header: %w", err) + } + + for _, artifact := range artifacts { + plan := artifact.artifact.Plan + if _, err := fmt.Fprintf( + table, + "%s\t%s\t%s\t%s\t%d\t%s\t%s\t%s\n", + plan.Variant, + plan.OperatingSystem, + plan.Architecture, + plan.Format, + artifact.artifact.Size, + shortSHA256(artifact.artifact.SHA256), + artifact.artifact.Path, + artifact.metadataPath, + ); err != nil { + return fmt.Errorf("write build artifact table row: %w", err) + } + } + + if err := table.Flush(); err != nil { + return fmt.Errorf("flush build artifact table: %w", err) + } + return nil +} + +func printBuildArtifactsJSON( + output io.Writer, + result providers.BuildResult, + artifacts []builtArtifactOutput, +) error { + encoder := json.NewEncoder(output) + if err := encoder.Encode(buildOutputForJSON(result, artifacts)); err != nil { + return fmt.Errorf("write build artifact summary: %w", err) + } + return nil +} + +func printBuildArtifactPaths(output io.Writer, artifacts []builtArtifactOutput) error { + for _, artifact := range artifacts { + if _, err := fmt.Fprintln(output, artifact.artifact.Path); err != nil { return fmt.Errorf("write build artifact path: %w", err) } } @@ -161,6 +371,45 @@ func printBuildArtifacts(output io.Writer, result providers.BuildResult) error { return nil } +func buildOutputForJSON( + result providers.BuildResult, + artifacts []builtArtifactOutput, +) buildOutputSummary { + summary := buildOutputSummary{ + Image: result.Plan.Image, + Artifacts: make([]buildArtifactOutputSummary, 0, len(artifacts)), + } + + for _, artifact := range artifacts { + plan := artifact.artifact.Plan + summary.Artifacts = append(summary.Artifacts, buildArtifactOutputSummary{ + ArtifactKey: plan.Key, + Variant: plan.Variant, + Provider: result.Plan.Provider, + Os: plan.OperatingSystem, + Architecture: plan.Architecture, + Format: plan.Format, + Path: artifact.artifact.Path, + MetadataPath: artifact.metadataPath, + Size: artifact.artifact.Size, + SHA256: artifact.artifact.SHA256, + }) + } + + return summary +} + +func buildArtifactMetadataPath(path string) string { + return path + artifactMetadataSuffix +} + +func shortSHA256(sha256Digest string) string { + if len(sha256Digest) <= buildSHA256PrefixLength { + return sha256Digest + } + return sha256Digest[:buildSHA256PrefixLength] +} + func newCacheStore(cfg Config) (*cache.DiskStore, error) { options := []cache.Option{ cache.WithMaxSizeBytes(cfg.CacheMaxSizeBytes), diff --git a/internal/cli/plan.go b/internal/cli/plan.go index b6eb306..39b9fa1 100644 --- a/internal/cli/plan.go +++ b/internal/cli/plan.go @@ -66,21 +66,25 @@ func resolvedPlanForOutput(plan providers.Plan) core.ResolvedPlan { } for _, artifact := range plan.Artifacts { - resolved.Artifacts[artifact.Key] = core.ResolvedArtifact{ - ArtifactKey: artifact.Key, - ImageName: string(plan.Image.Name), - Version: plan.Version, - Variant: artifact.Variant, - Provider: plan.Provider, - Os: artifact.OperatingSystem, - Architecture: artifact.Architecture, - Format: artifact.Format, - MediaType: artifact.MediaType, - Path: artifact.OutputPath, - Labels: artifact.Labels, - Annotations: artifact.Annotations, - } + resolved.Artifacts[artifact.Key] = resolvedArtifactForOutput(plan, artifact) } return resolved } + +func resolvedArtifactForOutput(plan providers.Plan, artifact providers.ArtifactPlan) core.ResolvedArtifact { + return core.ResolvedArtifact{ + ArtifactKey: artifact.Key, + ImageName: string(plan.Image.Name), + Version: plan.Version, + Variant: artifact.Variant, + Provider: plan.Provider, + Os: artifact.OperatingSystem, + Architecture: artifact.Architecture, + Format: artifact.Format, + MediaType: artifact.MediaType, + Path: artifact.OutputPath, + Labels: artifact.Labels, + Annotations: artifact.Annotations, + } +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index ff91ec2..525ac72 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -17,6 +17,7 @@ import ( "github.com/meigma/imgcli/internal/providers/incusos" "github.com/meigma/imgcli/internal/publish" publishmocks "github.com/meigma/imgcli/internal/publish/mocks" + imgschemas "github.com/meigma/imgcli/schemas" "github.com/meigma/imgcli/schemas/core" ) @@ -327,69 +328,136 @@ talos: {} assert.Empty(t, result.stderr) }) - 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.gz" - } - } -} -`) - catalog := &testCatalog{ - asset: incusos.ImageAsset{ - URL: "https://example.invalid/os/202604261712/x86_64/IncusOS_202604261712.img.gz", - SHA256: "source-sha", - Size: 42, + t.Run("prints table and writes artifact sidecars by default", func(t *testing.T) { + run := runSuccessfulBuildCommand(t) + + require.NoError(t, run.result.err) + assert.Empty(t, run.result.stderr) + lines := strings.Split(strings.TrimSuffix(run.result.stdout, "\n"), "\n") + require.Len(t, lines, 3) + assert.Equal( + t, + []string{"VARIANT", "OS", "ARCH", "FORMAT", "SIZE_BYTES", "SHA256_PREFIX", "ARTIFACT", "METADATA"}, + strings.Fields(lines[0]), + ) + assert.Equal( + t, + []string{ + "default", + "incusos", + "amd64", + "raw.gz", + "8", + run.sha256[:buildSHA256PrefixLength], + run.defaultPath, + run.defaultMetadataPath, }, - } - downloader := &testDownloader{ - image: incusos.DownloadedImage{ - Path: "/cache/source.img.gz", - SHA256: "source-sha", - Size: 42, + strings.Fields(lines[1]), + ) + assert.Equal( + t, + []string{ + "secureboot", + "incusos", + "amd64", + "raw.gz", + "8", + run.sha256[:buildSHA256PrefixLength], + run.secureBootPath, + run.secureBootMetadataPath, }, - } - seedBuilder := &testSeedBuilder{ - seed: incusos.SeedArchive{Data: []byte("seed")}, - } - injector := &testImageInjector{} - cacheDir := filepath.Join(t.TempDir(), "cache") + strings.Fields(lines[2]), + ) + assertBuildMetadata(t, run.defaultMetadataPath, core.ResolvedArtifact{ + ArtifactKey: core.ArtifactKey("default"), + ImageName: "test-image", + Variant: core.VariantName("default"), + Provider: core.ProviderName("incusos"), + Os: "incusos", + Architecture: core.Architecture("amd64"), + Format: core.ArtifactFormat("raw.gz"), + MediaType: "application/gzip", + Path: run.defaultPath, + Labels: map[string]string{"tier": "smoke"}, + Annotations: map[string]string{"note": "built"}, + Digest: "sha256:" + run.sha256, + Size: int64(len(run.artifactBody)), + }) + assertBuildMetadata(t, run.secureBootMetadataPath, core.ResolvedArtifact{ + ArtifactKey: core.ArtifactKey("secureboot"), + ImageName: "test-image", + Variant: core.VariantName("secureboot"), + Provider: core.ProviderName("incusos"), + Os: "incusos", + Architecture: core.Architecture("amd64"), + Format: core.ArtifactFormat("raw.gz"), + MediaType: "application/gzip", + Path: run.secureBootPath, + Digest: "sha256:" + run.sha256, + Size: int64(len(run.artifactBody)), + }) + assertSuccessfulBuildAdapters(t, run) + }) - result := executeCommand(t, Options{ - IncusOSCatalog: catalog, - IncusOSDownloader: downloader, - IncusOSSeedBuilder: seedBuilder, - IncusOSImageInjector: injector, - }, "--cache-dir", cacheDir, "build", configPath) + t.Run("prints high-level JSON summary", func(t *testing.T) { + run := runSuccessfulBuildCommand(t, "--format", "json") - require.NoError(t, result.err) - wantOutputPath := filepath.Join(outputDir, "test-image-default-amd64.raw.gz") - assert.Equal(t, wantOutputPath+"\n", result.stdout) + require.NoError(t, run.result.err) + assert.Empty(t, run.result.stderr) + var summary buildOutputSummary + require.NoError(t, json.Unmarshal([]byte(run.result.stdout), &summary)) + assert.Equal(t, buildOutputSummary{ + Image: core.Image{Name: core.Name("test-image")}, + Artifacts: []buildArtifactOutputSummary{ + { + ArtifactKey: core.ArtifactKey("default"), + Variant: core.VariantName("default"), + Provider: core.ProviderName("incusos"), + Os: "incusos", + Architecture: core.Architecture("amd64"), + Format: core.ArtifactFormat("raw.gz"), + Path: run.defaultPath, + MetadataPath: run.defaultMetadataPath, + Size: int64(len(run.artifactBody)), + SHA256: run.sha256, + }, + { + ArtifactKey: core.ArtifactKey("secureboot"), + Variant: core.VariantName("secureboot"), + Provider: core.ProviderName("incusos"), + Os: "incusos", + Architecture: core.Architecture("amd64"), + Format: core.ArtifactFormat("raw.gz"), + Path: run.secureBootPath, + MetadataPath: run.secureBootMetadataPath, + Size: int64(len(run.artifactBody)), + SHA256: run.sha256, + }, + }, + }, summary) + assert.FileExists(t, run.defaultMetadataPath) + assert.FileExists(t, run.secureBootMetadataPath) + }) + + t.Run("paths format preserves path-only stdout", func(t *testing.T) { + run := runSuccessfulBuildCommand(t, "--format", "paths") + + require.NoError(t, run.result.err) + assert.Equal(t, run.defaultPath+"\n"+run.secureBootPath+"\n", run.result.stdout) + assert.Empty(t, run.result.stderr) + assert.FileExists(t, run.defaultMetadataPath) + assert.FileExists(t, run.secureBootMetadataPath) + }) + + t.Run("invalid format fails before reading config or building", func(t *testing.T) { + clearIMGCLIEnv(t) + + result := executeCommand(t, Options{}, "build", "--format", "yaml", "missing.cue") + + require.Error(t, result.err) + require.ErrorContains(t, result.err, `invalid build output format "yaml"`) + assert.Empty(t, result.stdout) assert.Empty(t, result.stderr) - assert.NoDirExists(t, cacheDir) - require.Len(t, catalog.queries, 1) - assert.Equal(t, incusos.ImageQuery{ - Channel: incusos.ChannelTesting, - Version: incusos.Version("202604261712"), - 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) }) } @@ -724,6 +792,147 @@ func TestPublishConfigIsPublishOnly(t *testing.T) { }) } +type buildCommandRun struct { + result commandResult + artifactBody []byte + sha256 string + cacheDir string + defaultPath string + defaultMetadataPath string + secureBootPath string + secureBootMetadataPath string + catalog *testCatalog + downloader *testDownloader + seedBuilder *testSeedBuilder + injector *testImageInjector +} + +func runSuccessfulBuildCommand(t *testing.T, buildArgs ...string) buildCommandRun { + t.Helper() + + 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" + version: "202604261712" + } + seed: install: {} + variants: { + secureboot: artifact: { + architecture: "amd64" + format: "raw.gz" + } + default: artifact: { + architecture: "amd64" + format: "raw.gz" + labels: tier: "smoke" + annotations: note: "built" + } + } +} +`) + artifactBody := []byte("artifact") + artifactSHA256 := strings.Repeat("abcdef0123456789", 4) + defaultPath := filepath.Join(outputDir, "test-image-default-amd64.raw.gz") + 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, + }, + } + 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{ + body: artifactBody, + sha256: artifactSHA256, + } + cacheDir := filepath.Join(t.TempDir(), "cache") + + args := []string{"--cache-dir", cacheDir, "build"} + args = append(args, buildArgs...) + args = append(args, configPath) + + result := executeCommand(t, Options{ + IncusOSCatalog: catalog, + IncusOSDownloader: downloader, + IncusOSSeedBuilder: seedBuilder, + IncusOSImageInjector: injector, + }, args...) + + return buildCommandRun{ + result: result, + artifactBody: artifactBody, + sha256: artifactSHA256, + cacheDir: cacheDir, + defaultPath: defaultPath, + defaultMetadataPath: buildArtifactMetadataPath(defaultPath), + secureBootPath: secureBootPath, + secureBootMetadataPath: buildArtifactMetadataPath(secureBootPath), + catalog: catalog, + downloader: downloader, + seedBuilder: seedBuilder, + injector: injector, + } +} + +func assertBuildMetadata(t *testing.T, path string, want core.ResolvedArtifact) { + t.Helper() + + data, err := os.ReadFile(path) + require.NoError(t, err) + var metadata imgschemas.ArtifactMetadata + require.NoError(t, json.Unmarshal(data, &metadata)) + assert.Equal(t, artifactMetadataAPIVersion, metadata.ApiVersion) + assert.Equal(t, artifactMetadataKind, metadata.Kind) + assert.Equal(t, want, metadata.Artifact) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(artifactMetadataFileMode), info.Mode().Perm()) +} + +func assertSuccessfulBuildAdapters(t *testing.T, run buildCommandRun) { + t.Helper() + + assert.NoDirExists(t, run.cacheDir) + require.Len(t, run.catalog.queries, 2) + assert.Equal(t, []incusos.ImageQuery{ + { + Channel: incusos.ChannelTesting, + Version: incusos.Version("202604261712"), + Architecture: core.Architecture("amd64"), + Type: incusos.ImageTypeRaw, + }, + { + Channel: incusos.ChannelTesting, + Version: incusos.Version("202604261712"), + Architecture: core.Architecture("amd64"), + Type: incusos.ImageTypeRaw, + }, + }, run.catalog.queries) + assert.Equal(t, []incusos.ImageAsset{run.catalog.asset, run.catalog.asset}, run.downloader.assets) + assert.Len(t, run.seedBuilder.configs, 1) + require.Len(t, run.injector.calls, 2) + assert.Equal(t, run.defaultPath, run.injector.calls[0].outputPath) + assert.Equal(t, run.secureBootPath, run.injector.calls[1].outputPath) +} + func executeCommand(t *testing.T, opts Options, args ...string) commandResult { t.Helper() diff --git a/schemas/cue_types_gen.go b/schemas/cue_types_gen.go index 7d057f2..addf52d 100644 --- a/schemas/cue_types_gen.go +++ b/schemas/cue_types_gen.go @@ -29,6 +29,14 @@ type PublishIntent core.PublishIntent type ArtifactIntent core.ArtifactIntent +type ArtifactMetadata struct { + ApiVersion string `json:"apiVersion"` + + Kind string `json:"kind"` + + Artifact core.ResolvedArtifact `json:"artifact"` +} + type ResolvedArtifact core.ResolvedArtifact type ResolvedPlan core.ResolvedPlan diff --git a/schemas/schema.cue b/schemas/schema.cue index 1861d6f..bb64cdc 100644 --- a/schemas/schema.cue +++ b/schemas/schema.cue @@ -25,6 +25,13 @@ import ( #ArtifactIntent: core.#ArtifactIntent +#ArtifactMetadata: { + apiVersion: "imgcli.meigma.io/v0alpha1" + kind: "ArtifactMetadata" + + artifact: core.#ResolvedArtifact +} + #ResolvedArtifact: core.#ResolvedArtifact #ResolvedPlan: core.#ResolvedPlan