From c9b6f5e07ed7b9ab25fcd940be30fc4b80531674 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 14 May 2026 10:55:37 -0700 Subject: [PATCH] feat(cli): implement plan command --- internal/cli/plan.go | 76 ++++++++- internal/cli/root_test.go | 165 +++++++++++++++++-- internal/providers/incusos/errors.go | 3 - internal/providers/incusos/provider.go | 91 ++++++++-- internal/providers/incusos/provider_test.go | 173 +++++++++++++++++++- internal/providers/provider.go | 4 +- 6 files changed, 464 insertions(+), 48 deletions(-) diff --git a/internal/cli/plan.go b/internal/cli/plan.go index b0510f7..b6eb306 100644 --- a/internal/cli/plan.go +++ b/internal/cli/plan.go @@ -1,18 +1,86 @@ package cli import ( - "errors" + "context" + "encoding/json" + "fmt" + "io" "github.com/spf13/cobra" + + "github.com/meigma/imgcli/internal/providers" + incusosprovider "github.com/meigma/imgcli/internal/providers/incusos" + imgschemas "github.com/meigma/imgcli/schemas" + "github.com/meigma/imgcli/schemas/core" ) -func newPlanCommand(_ *runtime) *cobra.Command { +func newPlanCommand(rt *runtime) *cobra.Command { return &cobra.Command{ Use: "plan CONFIG", Short: "Print the resolved artifact plan", Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, _ []string) error { - return errors.New("plan command is not implemented yet") + RunE: func(cmd *cobra.Command, args []string) error { + config, err := loadImageConfig(args[0]) + if err != nil { + return err + } + + plan, err := runIncusOSPlan(cmd.Context(), config) + if err != nil { + return err + } + + return printResolvedPlan(rt.opts.stdout(), plan) }, } } + +func runIncusOSPlan( + ctx context.Context, + config imgschemas.Config, +) (providers.Plan, error) { + provider := incusosprovider.New(*config.Incusos, incusosprovider.Options{}) + + return provider.Plan(ctx, providers.PlanRequest{ + Image: config.Image, + OutputDir: buildOutputDir(config.Output), + }) +} + +func printResolvedPlan(output io.Writer, plan providers.Plan) error { + encoder := json.NewEncoder(output) + encoder.SetIndent("", " ") + if err := encoder.Encode(resolvedPlanForOutput(plan)); err != nil { + return fmt.Errorf("write resolved artifact plan: %w", err) + } + + return nil +} + +func resolvedPlanForOutput(plan providers.Plan) core.ResolvedPlan { + resolved := core.ResolvedPlan{ + Image: plan.Image, + Version: plan.Version, + OutputDir: plan.OutputDir, + Artifacts: make(map[core.ArtifactKey]core.ResolvedArtifact, len(plan.Artifacts)), + } + + 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, + } + } + + return resolved +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 03edfb6..ff91ec2 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -104,14 +104,12 @@ func TestInvalidLogSettings(t *testing.T) { func TestBaseCommands(t *testing.T) { tests := []struct { - name string - command string - placeholder bool + name string + command string }{ { - name: "plan", - command: "plan", - placeholder: true, + name: "plan", + command: "plan", }, { name: "build", @@ -144,22 +142,155 @@ func TestBaseCommands(t *testing.T) { assert.Empty(t, result.stdout) assert.Empty(t, result.stderr) }) + } +} - if !tt.placeholder { - continue +func TestPlanCommand(t *testing.T) { + t.Run("prints resolved IncusOS artifact plan", func(t *testing.T) { + clearIMGCLIEnv(t) + outputDir := filepath.Join(t.TempDir(), "out") + cacheDir := filepath.Join(t.TempDir(), "cache") + configPath := writeImageConfig(t, ` +apiVersion: "imgcli.meigma.io/v0alpha1" +kind: "ImagePlan" +image: { + name: "test-image" + description: "test image" +} +output: dir: "`+outputDir+`" +incusos: { + defaults: source: channel: "testing" + seed: install: {} + variants: { + secureboot: artifact: { + architecture: "amd64" + format: "raw.gz" + filename: "custom/secureboot.img.gz" + } + default: artifact: { + architecture: "amd64" + format: "raw.gz" + labels: tier: "smoke" + annotations: note: "planned" } + } +} +`) - t.Run(tt.name+" returns placeholder error", func(t *testing.T) { - clearIMGCLIEnv(t) + result := executeCommand(t, Options{}, "--cache-dir", cacheDir, "plan", configPath) - result := executeCommand(t, Options{}, tt.command, "image.cue") + require.NoError(t, result.err) + assert.Empty(t, result.stderr) + assert.NoDirExists(t, cacheDir) + var plan core.ResolvedPlan + require.NoError(t, json.Unmarshal([]byte(result.stdout), &plan)) + assert.Equal(t, core.ResolvedPlan{ + Image: core.Image{ + Name: core.Name("test-image"), + Description: "test image", + }, + OutputDir: outputDir, + Artifacts: map[core.ArtifactKey]core.ResolvedArtifact{ + "default": { + 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: filepath.Join(outputDir, "test-image-default-amd64.raw.gz"), + Labels: map[string]string{"tier": "smoke"}, + Annotations: map[string]string{"note": "planned"}, + }, + "secureboot": { + 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: filepath.Join(outputDir, "custom", "secureboot.img.gz"), + }, + }, + }, plan) + }) - require.Error(t, result.err) - require.ErrorContains(t, result.err, tt.command+" command is not implemented yet") - assert.Empty(t, result.stdout) - assert.Empty(t, result.stderr) - }) - } + t.Run("does not require publish configuration", func(t *testing.T) { + clearIMGCLIEnv(t) + t.Setenv("IMGCLI_PUBLISH_PART_SIZE", "1MB") + 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{}, "plan", configPath) + + require.NoError(t, result.err) + assert.Empty(t, result.stderr) + assert.NotEmpty(t, result.stdout) + }) + + t.Run("missing provider fails explicitly", func(t *testing.T) { + clearIMGCLIEnv(t) + configPath := writeImageConfig(t, ` +apiVersion: "imgcli.meigma.io/v0alpha1" +kind: "ImagePlan" +image: name: "test-image" +`) + + result := executeCommand(t, Options{}, "plan", configPath) + + require.Error(t, result.err) + require.ErrorContains(t, result.err, "must specify provider incusos") + assert.Empty(t, result.stdout) + assert.Empty(t, result.stderr) + }) + + t.Run("unsupported provider fails explicitly", func(t *testing.T) { + clearIMGCLIEnv(t) + configPath := writeImageConfig(t, ` +apiVersion: "imgcli.meigma.io/v0alpha1" +kind: "ImagePlan" +image: name: "test-image" +talos: {} +`) + + result := executeCommand(t, Options{}, "plan", configPath) + + require.Error(t, result.err) + require.ErrorContains(t, result.err, `unsupported provider "talos": only incusos is supported`) + assert.Empty(t, result.stdout) + assert.Empty(t, result.stderr) + }) + + t.Run("provider planning errors fail before build adapters", func(t *testing.T) { + clearIMGCLIEnv(t) + configPath := writeImageConfig(t, ` +apiVersion: "imgcli.meigma.io/v0alpha1" +kind: "ImagePlan" +image: name: "test-image" +incusos: variants: default: artifact: { + architecture: "amd64" + format: "iso" +} +`) + + result := executeCommand(t, Options{}, "plan", configPath) + + require.Error(t, result.err) + require.ErrorContains(t, result.err, `unsupported incusos artifact format "iso"`) + assert.Empty(t, result.stdout) + assert.Empty(t, result.stderr) + }) } func TestBuildCommand(t *testing.T) { diff --git a/internal/providers/incusos/errors.go b/internal/providers/incusos/errors.go index 94fc833..072a7d5 100644 --- a/internal/providers/incusos/errors.go +++ b/internal/providers/incusos/errors.go @@ -2,8 +2,5 @@ package incusos import "errors" -// ErrNotImplemented marks provider operations that are scaffolded but not implemented. -var ErrNotImplemented = errors.New("incusos provider operation is not implemented yet") - // ErrImageNotFound indicates that no IncusOS catalog entry matched a query. var ErrImageNotFound = errors.New("incusos image not found") diff --git a/internal/providers/incusos/provider.go b/internal/providers/incusos/provider.go index fa820ed..09fc9d8 100644 --- a/internal/providers/incusos/provider.go +++ b/internal/providers/incusos/provider.go @@ -60,11 +60,16 @@ func (p *Provider) Name() core.ProviderName { } // Plan resolves IncusOS configuration into concrete artifact work. -func (p *Provider) Plan(_ context.Context, _ providers.PlanRequest) (providers.Plan, error) { - return providers.Plan{}, ErrNotImplemented +func (p *Provider) Plan(_ context.Context, req providers.PlanRequest) (providers.Plan, error) { + artifacts, err := planArtifacts(req, p.config) + if err != nil { + return providers.Plan{}, err + } + + return providerPlan(req, artifacts), nil } -// Build creates IncusOS artifacts from an already resolved plan. +// Build creates IncusOS artifacts from a resolved provider plan. 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") @@ -79,7 +84,12 @@ func (p *Provider) Build(ctx context.Context, req providers.BuildRequest) (provi return providers.BuildResult{}, errors.New("incusos image injector is required") } - artifacts, err := planArtifacts(req, p.config) + plan, err := p.Plan(ctx, buildPlanRequest(req)) + if err != nil { + return providers.BuildResult{}, err + } + + artifacts, err := plannedArtifactsForExecution(plan, p.config) if err != nil { return providers.BuildResult{}, err } @@ -99,7 +109,7 @@ func (p *Provider) Build(ctx context.Context, req providers.BuildRequest) (provi asset, err := p.options.Catalog.ResolveImage(ctx, ImageQuery{ Channel: source.Channel, Version: source.Version, - Architecture: artifact.variant.Artifact.Architecture, + Architecture: artifact.plan.Architecture, Type: artifact.imageType, }) if err != nil { @@ -125,14 +135,6 @@ func (p *Provider) Build(ctx context.Context, req providers.BuildRequest) (provi }) } - plan := req.Plan - plan.Provider = providerName - plan.OutputDir = outputDir(req) - plan.Artifacts = make([]providers.ArtifactPlan, 0, len(artifacts)) - for _, artifact := range artifacts { - plan.Artifacts = append(plan.Artifacts, artifact.plan) - } - return providers.BuildResult{ Plan: plan, Artifacts: builtArtifacts, @@ -145,7 +147,7 @@ type plannedArtifact struct { plan providers.ArtifactPlan } -func planArtifacts(req providers.BuildRequest, config Config) ([]plannedArtifact, error) { +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") } @@ -200,6 +202,52 @@ func planArtifacts(req providers.BuildRequest, config Config) ([]plannedArtifact return artifacts, nil } +func providerPlan(req providers.PlanRequest, artifacts []plannedArtifact) providers.Plan { + plan := providers.Plan{ + Provider: providerName, + Image: req.Image, + Version: req.Version, + OutputDir: outputDir(req), + Artifacts: make([]providers.ArtifactPlan, 0, len(artifacts)), + } + for _, artifact := range artifacts { + plan.Artifacts = append(plan.Artifacts, artifact.plan) + } + + return plan +} + +func buildPlanRequest(req providers.BuildRequest) providers.PlanRequest { + return providers.PlanRequest{ + Image: req.Plan.Image, + Version: req.Plan.Version, + OutputDir: buildOutputDir(req), + } +} + +func plannedArtifactsForExecution(plan providers.Plan, config Config) ([]plannedArtifact, error) { + artifacts := make([]plannedArtifact, 0, len(plan.Artifacts)) + for _, artifactPlan := range plan.Artifacts { + variant, ok := config.Variants[artifactPlan.Variant] + if !ok { + return nil, fmt.Errorf("incusos planned artifact references unknown variant %q", artifactPlan.Variant) + } + + imageType, err := imageTypeForFormat(artifactPlan.Format) + if err != nil { + return nil, err + } + + artifacts = append(artifacts, plannedArtifact{ + variant: variant, + imageType: imageType, + plan: artifactPlan, + }) + } + + return artifacts, nil +} + func rejectExistingOutputPaths(artifacts []plannedArtifact) error { for _, artifact := range artifacts { path := artifact.plan.OutputPath @@ -237,13 +285,13 @@ func cleanupBuiltOutputs(cause error, paths []string) error { } func artifactOutputPath( - req providers.BuildRequest, + req providers.PlanRequest, variantName core.VariantName, artifact core.ArtifactIntent, ) (string, error) { filename := strings.TrimSpace(artifact.Filename) if filename == "" { - filename = fallbackArtifactFilename(req.Plan.Image.Name, variantName, artifact) + filename = fallbackArtifactFilename(req.Image.Name, variantName, artifact) } if filepath.IsAbs(filename) { return "", fmt.Errorf("incusos artifact filename must be relative: %q", filename) @@ -267,7 +315,7 @@ func fallbackArtifactFilename(imageName core.Name, variantName core.VariantName, return fmt.Sprintf("%s-%s-%s.%s", name, variantName, artifact.Architecture, artifact.Format) } -func outputDir(req providers.BuildRequest) string { +func buildOutputDir(req providers.BuildRequest) string { outputDir := strings.TrimSpace(req.OutputDir) if outputDir == "" { outputDir = strings.TrimSpace(req.Plan.OutputDir) @@ -279,6 +327,15 @@ func outputDir(req providers.BuildRequest) string { return outputDir } +func outputDir(req providers.PlanRequest) string { + outputDir := strings.TrimSpace(req.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 { diff --git a/internal/providers/incusos/provider_test.go b/internal/providers/incusos/provider_test.go index 12d57f5..e6d12e1 100644 --- a/internal/providers/incusos/provider_test.go +++ b/internal/providers/incusos/provider_test.go @@ -21,12 +21,175 @@ func TestProviderName(t *testing.T) { assert.Equal(t, core.ProviderName("incusos"), provider.Name()) } -func TestProviderPlanPlaceholderOperation(t *testing.T) { - provider := New(Config{}, Options{}) +func TestProviderPlanResolvesArtifactFilenames(t *testing.T) { + tests := []struct { + name string + artifact core.ArtifactIntent + wantOutputPath func(outputDir string) string + }{ + { + name: "uses configured artifact filename", + artifact: core.ArtifactIntent{ + Architecture: core.Architecture("amd64"), + 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: "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) { + outputDir := t.TempDir() + provider := New(configWithArtifact(tt.artifact), Options{}) + + plan, err := provider.Plan(context.Background(), providers.PlanRequest{ + Image: core.Image{Name: core.Name("test-image")}, + OutputDir: outputDir, + }) + + require.NoError(t, err) + assert.Equal(t, providerName, plan.Provider) + assert.Equal(t, core.Image{Name: core.Name("test-image")}, plan.Image) + assert.Equal(t, outputDir, plan.OutputDir) + require.Len(t, plan.Artifacts, 1) + assert.Equal(t, providers.ArtifactPlan{ + Key: core.ArtifactKey("default"), + Variant: core.VariantName("default"), + Architecture: tt.artifact.Architecture, + OperatingSystem: "incusos", + Format: tt.artifact.Format, + MediaType: artifactMediaType(tt.artifact), + OutputPath: tt.wantOutputPath(outputDir), + Labels: tt.artifact.Labels, + Annotations: tt.artifact.Annotations, + }, plan.Artifacts[0]) + }) + } +} + +func TestProviderPlanCreatesMultipleVariantsInStableOrder(t *testing.T) { + outputDir := t.TempDir() + provider := New(multiVariantConfig(), Options{}) + + plan, err := provider.Plan(context.Background(), providers.PlanRequest{ + Image: core.Image{Name: core.Name("test-image")}, + OutputDir: outputDir, + }) + + require.NoError(t, err) + assert.Equal(t, []providers.ArtifactPlan{ + { + Key: core.ArtifactKey("default"), + Variant: core.VariantName("default"), + Architecture: core.Architecture("amd64"), + OperatingSystem: "incusos", + Format: core.ArtifactFormat("raw.gz"), + MediaType: "application/gzip", + OutputPath: filepath.Join(outputDir, "test-image-default-amd64.raw.gz"), + }, + { + Key: core.ArtifactKey("secureboot"), + Variant: core.VariantName("secureboot"), + Architecture: core.Architecture("amd64"), + OperatingSystem: "incusos", + Format: core.ArtifactFormat("raw.gz"), + MediaType: "application/gzip", + OutputPath: filepath.Join(outputDir, "test-image-secureboot-amd64.raw.gz"), + }, + }, plan.Artifacts) +} - plan, err := provider.Plan(context.Background(), providers.PlanRequest{}) - require.ErrorIs(t, err, ErrNotImplemented) - assert.Empty(t, plan) +func TestProviderPlanErrors(t *testing.T) { + tests := []struct { + name string + config Config + wantErr string + }{ + { + name: "zero variants", + config: Config{ + Variants: map[core.VariantName]incusosschema.Variant{}, + }, + wantErr: "incusos build requires at least one variant", + }, + { + name: "unsupported format", + config: configWithVariant(core.ArtifactFormat("iso")), + wantErr: `unsupported incusos artifact format "iso"`, + }, + { + name: "duplicate output paths", + config: Config{ + Seed: &incusosschema.Seed{}, + Variants: map[core.VariantName]incusosschema.Variant{ + "default": { + Artifact: core.ArtifactIntent{ + Architecture: core.Architecture("amd64"), + Format: core.ArtifactFormat("raw.gz"), + Filename: "same.img.gz", + }, + }, + "secureboot": { + Artifact: core.ArtifactIntent{ + Architecture: core.Architecture("amd64"), + Format: core.ArtifactFormat("raw.gz"), + Filename: "same.img.gz", + }, + }, + }, + }, + wantErr: "incusos artifact output path", + }, + { + name: "absolute artifact filename", + config: configWithArtifact(core.ArtifactIntent{ + Architecture: core.Architecture("amd64"), + Format: core.ArtifactFormat("raw"), + Filename: "/tmp/out.img", + }), + 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", + }), + wantErr: `incusos artifact filename must stay within output directory`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := New(tt.config, Options{}) + + plan, err := provider.Plan(context.Background(), providers.PlanRequest{ + Image: core.Image{Name: core.Name("test-image")}, + OutputDir: t.TempDir(), + }) + + require.Error(t, err) + require.ErrorContains(t, err, tt.wantErr) + assert.Empty(t, plan) + }) + } } func TestProviderBuildCreatesCustomizedImage(t *testing.T) { diff --git a/internal/providers/provider.go b/internal/providers/provider.go index ce3f507..24f15c9 100644 --- a/internal/providers/provider.go +++ b/internal/providers/provider.go @@ -14,7 +14,7 @@ type Provider interface { // Plan resolves provider-specific configuration into concrete artifact work. Plan(ctx context.Context, req PlanRequest) (Plan, error) - // Build creates artifacts from an already resolved provider plan. + // Build creates artifacts for the requested provider configuration. Build(ctx context.Context, req BuildRequest) (BuildResult, error) } @@ -32,7 +32,7 @@ type PlanRequest struct { // BuildRequest carries the provider plan and build-time locations. type BuildRequest struct { - // Plan is the concrete artifact work to execute. + // Plan carries command-level plan inputs and any previously resolved plan data. Plan Plan // CacheDir is the directory providers may use for reusable downloads or intermediates.