diff --git a/cmd/init.go b/cmd/init.go index e944c496f..ee17ac701 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -35,38 +35,33 @@ var initCmd = &cobra.Command{ Long: "Initialize the application environment with the specified context configuration", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // Get shared dependency injector from context injector := cmd.Context().Value(injectorKey).(di.Injector) + ctx := cmd.Context() + if len(args) > 0 { + ctx = context.WithValue(ctx, "contextName", args[0]) + } + ctx = context.WithValue(ctx, "reset", initReset) + if initBlueprint != "" { + ctx = context.WithValue(ctx, "blueprint", initBlueprint) + } - // First, run the env pipeline in quiet mode to set up environment variables - envPipeline, err := pipelines.WithPipeline(injector, cmd.Context(), "envPipeline") + ctx = context.WithValue(ctx, "quiet", true) + ctx = context.WithValue(ctx, "decrypt", true) + envPipeline, err := pipelines.WithPipeline(injector, ctx, "envPipeline") if err != nil { return fmt.Errorf("failed to set up env pipeline: %w", err) } - envCtx := context.WithValue(cmd.Context(), "quiet", true) - envCtx = context.WithValue(envCtx, "decrypt", true) - if err := envPipeline.Execute(envCtx); err != nil { + if err := envPipeline.Execute(ctx); err != nil { return fmt.Errorf("failed to set up environment: %w", err) } - // Set up the init pipeline - initPipeline, err := pipelines.WithPipeline(injector, cmd.Context(), "initPipeline") + ctx = context.WithValue(ctx, "quiet", false) + ctx = context.WithValue(ctx, "decrypt", false) + initPipeline, err := pipelines.WithPipeline(injector, ctx, "initPipeline") if err != nil { return fmt.Errorf("failed to set up init pipeline: %w", err) } - ctx := cmd.Context() - - // Add context name and reset flag to context (these are needed during Initialize) - if len(args) > 0 { - ctx = context.WithValue(ctx, "contextName", args[0]) - } - ctx = context.WithValue(ctx, "reset", initReset) - if initBlueprint != "" { - ctx = context.WithValue(ctx, "blueprint", initBlueprint) - } - - // Set flag values in config handler before execution configHandler := injector.Resolve("configHandler").(config.ConfigHandler) if initBackend != "" { @@ -125,7 +120,6 @@ var initCmd = &cobra.Command{ } } - // Handle set flags for _, setFlag := range initSetFlags { parts := strings.SplitN(setFlag, "=", 2) if len(parts) == 2 { @@ -140,7 +134,6 @@ var initCmd = &cobra.Command{ } func init() { - initCmd.Flags().BoolVar(&initReset, "reset", false, "Reset/overwrite existing files and clean .terraform directory") initCmd.Flags().StringVar(&initBackend, "backend", "", "Specify the backend to use") initCmd.Flags().StringVar(&initAwsProfile, "aws-profile", "", "Specify the AWS profile to use") diff --git a/pkg/artifact/artifact.go b/pkg/artifact/artifact.go index bfc07e4d6..f7d811ba1 100644 --- a/pkg/artifact/artifact.go +++ b/pkg/artifact/artifact.go @@ -3,7 +3,6 @@ package artifact import ( "archive/tar" "bytes" - "compress/gzip" "fmt" "io" "maps" @@ -60,6 +59,16 @@ type BuilderInfo struct { Email string `json:"email"` } +// OCIArtifactInfo contains information about the OCI artifact source for blueprint data +type OCIArtifactInfo struct { + // Name is the name of the OCI artifact + Name string + // URL is the full OCI URL of the artifact + URL string + // Tag is the tag/version of the OCI artifact + Tag string +} + // BlueprintMetadataInput represents the input metadata from contexts/_template/metadata.yaml type BlueprintMetadataInput struct { Name string `yaml:"name"` @@ -360,12 +369,7 @@ func (a *ArtifactBuilder) GetTemplateData(ociRef string) (map[string][]byte, err templateData := make(map[string][]byte) templateData["ociUrl"] = []byte(ociRef) - gzipReader, err := gzip.NewReader(bytes.NewReader(artifactData)) - if err != nil { - return nil, fmt.Errorf("failed to create gzip reader: %w", err) - } - defer gzipReader.Close() - tarReader := tar.NewReader(gzipReader) + tarReader := tar.NewReader(bytes.NewReader(artifactData)) var metadataName string jsonnetFiles := make(map[string][]byte) @@ -421,6 +425,64 @@ func (a *ArtifactBuilder) GetTemplateData(ociRef string) (map[string][]byte, err return templateData, nil } +// ============================================================================= +// Package Functions +// ============================================================================= + +// ParseOCIReference parses a blueprint reference string in OCI URL or org/repo:tag format and returns an OCIArtifactInfo struct. +// Accepts full OCI URLs (e.g., oci://ghcr.io/org/repo:v1.0.0) and org/repo:v1.0.0 formats only. +// Returns nil if the reference is empty, missing a version, or not in a supported format. +func ParseOCIReference(ociRef string) (*OCIArtifactInfo, error) { + if ociRef == "" { + return nil, nil + } + + var name, version, fullURL string + + if strings.HasPrefix(ociRef, "oci://") { + fullURL = ociRef + remaining := strings.TrimPrefix(ociRef, "oci://") + if lastColon := strings.LastIndex(remaining, ":"); lastColon > 0 { + version = remaining[lastColon+1:] + pathPart := remaining[:lastColon] + if lastSlash := strings.LastIndex(pathPart, "/"); lastSlash >= 0 { + name = pathPart[lastSlash+1:] + } else { + return nil, fmt.Errorf("blueprint reference '%s' is missing a version (e.g., core:v1.0.0)", ociRef) + } + } else { + return nil, fmt.Errorf("blueprint reference '%s' is missing a version (e.g., core:v1.0.0)", ociRef) + } + } else { + if colonIdx := strings.LastIndex(ociRef, ":"); colonIdx > 0 { + pathPart := ociRef[:colonIdx] + version = ociRef[colonIdx+1:] + if strings.Count(pathPart, "/") >= 1 { + if lastSlash := strings.LastIndex(pathPart, "/"); lastSlash >= 0 { + name = pathPart[lastSlash+1:] + } else { + return nil, fmt.Errorf("blueprint reference '%s' is missing a version (e.g., core:v1.0.0)", ociRef) + } + fullURL = "oci://ghcr.io/" + ociRef + } else { + return nil, fmt.Errorf("blueprint reference '%s' is missing a version (e.g., core:v1.0.0)", ociRef) + } + } else { + return nil, fmt.Errorf("blueprint reference '%s' is missing a version (e.g., core:v1.0.0)", ociRef) + } + } + + if version == "" || name == "" { + return nil, fmt.Errorf("blueprint reference '%s' is missing a version (e.g., core:v1.0.0)", ociRef) + } + + return &OCIArtifactInfo{ + Name: name, + URL: fullURL, + Tag: version, + }, nil +} + // ============================================================================= // Private Methods // ============================================================================= diff --git a/pkg/artifact/artifact_test.go b/pkg/artifact/artifact_test.go index 50c349d5e..b0c392b07 100644 --- a/pkg/artifact/artifact_test.go +++ b/pkg/artifact/artifact_test.go @@ -3305,13 +3305,12 @@ func TestArtifactBuilder_GetTemplateData(t *testing.T) { }) } -// createTestTarGz creates a test tar.gz archive with the given files +// createTestTarGz creates a test tar archive with the given files func createTestTarGz(t *testing.T, files map[string][]byte) []byte { t.Helper() var buf bytes.Buffer - gzipWriter := gzip.NewWriter(&buf) - tarWriter := tar.NewWriter(gzipWriter) + tarWriter := tar.NewWriter(&buf) for path, content := range files { header := &tar.Header{ @@ -3330,7 +3329,106 @@ func createTestTarGz(t *testing.T, files map[string][]byte) []byte { } tarWriter.Close() - gzipWriter.Close() return buf.Bytes() } + +// ============================================================================= +// Test Package Functions +// ============================================================================= + +func TestParseOCIReference(t *testing.T) { + testCases := []struct { + name string + input string + expected *OCIArtifactInfo + expectError bool + }{ + { + name: "EmptyString", + input: "", + expected: nil, + expectError: false, + }, + { + name: "FullOCIURL", + input: "oci://ghcr.io/windsorcli/core:v1.0.0", + expected: &OCIArtifactInfo{ + Name: "core", + URL: "oci://ghcr.io/windsorcli/core:v1.0.0", + Tag: "v1.0.0", + }, + expectError: false, + }, + { + name: "ShortFormat", + input: "windsorcli/core:v1.0.0", + expected: &OCIArtifactInfo{ + Name: "core", + URL: "oci://ghcr.io/windsorcli/core:v1.0.0", + Tag: "v1.0.0", + }, + expectError: false, + }, + { + name: "MissingVersion", + input: "windsorcli/core", + expected: nil, + expectError: true, + }, + { + name: "InvalidFormat", + input: "core:v1.0.0", + expected: nil, + expectError: true, + }, + { + name: "EmptyVersion", + input: "windsorcli/core:", + expected: nil, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := ParseOCIReference(tc.input) + + if tc.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if tc.expected == nil { + if result != nil { + t.Errorf("Expected nil result but got: %+v", result) + } + return + } + + if result == nil { + t.Errorf("Expected result but got nil") + return + } + + if result.Name != tc.expected.Name { + t.Errorf("Expected name %s but got %s", tc.expected.Name, result.Name) + } + + if result.URL != tc.expected.URL { + t.Errorf("Expected URL %s but got %s", tc.expected.URL, result.URL) + } + + if result.Tag != tc.expected.Tag { + t.Errorf("Expected tag %s but got %s", tc.expected.Tag, result.Tag) + } + }) + } +} diff --git a/pkg/blueprint/blueprint_handler.go b/pkg/blueprint/blueprint_handler.go index e4e62a9db..cb3acbf24 100644 --- a/pkg/blueprint/blueprint_handler.go +++ b/pkg/blueprint/blueprint_handler.go @@ -10,6 +10,7 @@ import ( _ "embed" + "github.com/windsorcli/cli/pkg/artifact" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/constants" "github.com/windsorcli/cli/pkg/di" @@ -25,14 +26,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// OCIArtifactInfo contains information about the OCI artifact source for blueprint data -type OCIArtifactInfo struct { - // Name is the name of the OCI artifact - Name string - // URL is the full OCI URL of the artifact - URL string -} - // The BlueprintHandler is a core component that manages infrastructure and application configurations // through a declarative, GitOps-based approach. It handles the lifecycle of infrastructure blueprints, // which are composed of Terraform components, Kubernetes Kustomizations, and associated metadata. @@ -44,7 +37,7 @@ type OCIArtifactInfo struct { type BlueprintHandler interface { Initialize() error LoadConfig() error - LoadData(data map[string]any, ociInfo ...*OCIArtifactInfo) error + LoadData(data map[string]any, ociInfo ...*artifact.OCIArtifactInfo) error Write(overwrite ...bool) error Install() error GetMetadata() blueprintv1alpha1.Metadata @@ -157,7 +150,7 @@ func (b *BaseBlueprintHandler) LoadConfig() error { // LoadData loads blueprint configuration from a map containing blueprint data. // It marshals the input map to YAML, processes it as a Blueprint object, and updates the handler's blueprint state. // The ociInfo parameter optionally provides OCI artifact source information for source resolution and tracking. -func (b *BaseBlueprintHandler) LoadData(data map[string]any, ociInfo ...*OCIArtifactInfo) error { +func (b *BaseBlueprintHandler) LoadData(data map[string]any, ociInfo ...*artifact.OCIArtifactInfo) error { yamlData, err := b.shims.YamlMarshal(data) if err != nil { return fmt.Errorf("error marshalling blueprint data to yaml: %w", err) @@ -423,39 +416,6 @@ func (b *BaseBlueprintHandler) GetLocalTemplateData() (map[string][]byte, error) return templateData, nil } -// walkAndCollectTemplates recursively walks through the template directory and collects only .jsonnet files. -// It maintains the relative path structure from the template directory root. -func (b *BaseBlueprintHandler) walkAndCollectTemplates(templateDir, templateRoot string, templateData map[string][]byte) error { - entries, err := b.shims.ReadDir(templateDir) - if err != nil { - return fmt.Errorf("failed to read template directory: %w", err) - } - - for _, entry := range entries { - entryPath := filepath.Join(templateDir, entry.Name()) - - if entry.IsDir() { - if err := b.walkAndCollectTemplates(entryPath, templateRoot, templateData); err != nil { - return err - } - } else if strings.HasSuffix(entry.Name(), ".jsonnet") { - content, err := b.shims.ReadFile(filepath.Clean(entryPath)) - if err != nil { - return fmt.Errorf("failed to read template file %s: %w", entryPath, err) - } - - relPath, err := filepath.Rel(templateRoot, entryPath) - if err != nil { - return fmt.Errorf("failed to get relative path: %w", err) - } - - templateData[filepath.ToSlash(relPath)] = content - } - } - - return nil -} - // Down orchestrates the teardown of all kustomizations and associated resources, skipping "not found" errors. // Sequence: // 1. Suspend all kustomizations and associated helmreleases to prevent reconciliation (ignoring not found errors) @@ -626,6 +586,39 @@ func (b *BaseBlueprintHandler) Down() error { // Private Methods // ============================================================================= +// walkAndCollectTemplates recursively walks through the template directory and collects only .jsonnet files. +// It maintains the relative path structure from the template directory root. +func (b *BaseBlueprintHandler) walkAndCollectTemplates(templateDir, templateRoot string, templateData map[string][]byte) error { + entries, err := b.shims.ReadDir(templateDir) + if err != nil { + return fmt.Errorf("failed to read template directory: %w", err) + } + + for _, entry := range entries { + entryPath := filepath.Join(templateDir, entry.Name()) + + if entry.IsDir() { + if err := b.walkAndCollectTemplates(entryPath, templateRoot, templateData); err != nil { + return err + } + } else if strings.HasSuffix(entry.Name(), ".jsonnet") { + content, err := b.shims.ReadFile(filepath.Clean(entryPath)) + if err != nil { + return fmt.Errorf("failed to read template file %s: %w", entryPath, err) + } + + relPath, err := filepath.Rel(templateRoot, entryPath) + if err != nil { + return fmt.Errorf("failed to get relative path: %w", err) + } + + templateData[filepath.ToSlash(relPath)] = content + } + } + + return nil +} + // resolveComponentSources transforms component source names into fully qualified URLs // with path prefix and reference information based on the associated source configuration. // It processes both OCI and Git sources, constructing appropriate URL formats for each type. @@ -703,7 +696,7 @@ func (b *BaseBlueprintHandler) resolveComponentPaths(blueprint *blueprintv1alpha // the target blueprint. If ociInfo is provided, injects the OCI source into the sources list, updates Terraform // components and kustomizations lacking a source to use the OCI source, and ensures the OCI source is present // or updated in the sources slice. -func (b *BaseBlueprintHandler) processBlueprintData(data []byte, blueprint *blueprintv1alpha1.Blueprint, ociInfo ...*OCIArtifactInfo) error { +func (b *BaseBlueprintHandler) processBlueprintData(data []byte, blueprint *blueprintv1alpha1.Blueprint, ociInfo ...*artifact.OCIArtifactInfo) error { newBlueprint := &blueprintv1alpha1.PartialBlueprint{} if err := b.shims.YamlUnmarshal(data, newBlueprint); err != nil { return fmt.Errorf("error unmarshalling blueprint data: %w", err) diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_test.go index b83c0a4ba..31df7ad24 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_test.go @@ -13,6 +13,7 @@ import ( kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" sourcev1 "github.com/fluxcd/source-controller/api/v1" blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" + "github.com/windsorcli/cli/pkg/artifact" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/constants" "github.com/windsorcli/cli/pkg/di" @@ -3009,7 +3010,7 @@ func TestBlueprintHandler_LoadData(t *testing.T) { } // And OCI artifact info - ociInfo := &OCIArtifactInfo{ + ociInfo := &artifact.OCIArtifactInfo{ Name: "my-blueprint", URL: "oci://registry.example.com/my-blueprint:v1.0.0", } diff --git a/pkg/blueprint/mock_blueprint_handler.go b/pkg/blueprint/mock_blueprint_handler.go index 2611f8ff4..7fa63e3f9 100644 --- a/pkg/blueprint/mock_blueprint_handler.go +++ b/pkg/blueprint/mock_blueprint_handler.go @@ -2,6 +2,7 @@ package blueprint import ( blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" + "github.com/windsorcli/cli/pkg/artifact" "github.com/windsorcli/cli/pkg/di" ) @@ -9,7 +10,7 @@ import ( type MockBlueprintHandler struct { InitializeFunc func() error LoadConfigFunc func() error - LoadDataFunc func(data map[string]any, ociInfo ...*OCIArtifactInfo) error + LoadDataFunc func(data map[string]any, ociInfo ...*artifact.OCIArtifactInfo) error WriteFunc func(overwrite ...bool) error GetMetadataFunc func() blueprintv1alpha1.Metadata GetSourcesFunc func() []blueprintv1alpha1.Source @@ -55,7 +56,7 @@ func (m *MockBlueprintHandler) LoadConfig() error { } // LoadData calls the mock LoadDataFunc if set, otherwise returns nil -func (m *MockBlueprintHandler) LoadData(data map[string]any, ociInfo ...*OCIArtifactInfo) error { +func (m *MockBlueprintHandler) LoadData(data map[string]any, ociInfo ...*artifact.OCIArtifactInfo) error { if m.LoadDataFunc != nil { return m.LoadDataFunc(data, ociInfo...) } diff --git a/pkg/pipelines/init.go b/pkg/pipelines/init.go index b60e33fcc..01b3debc4 100644 --- a/pkg/pipelines/init.go +++ b/pkg/pipelines/init.go @@ -8,7 +8,7 @@ import ( "runtime" "strings" - bundler "github.com/windsorcli/cli/pkg/artifact" + "github.com/windsorcli/cli/pkg/artifact" "github.com/windsorcli/cli/pkg/blueprint" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/di" @@ -33,21 +33,21 @@ import ( // Types // ============================================================================= -// InitPipeline provides application initialization functionality +// InitPipeline handles the initialization of a Windsor project type InitPipeline struct { BasePipeline + templateRenderer template.Template blueprintHandler blueprint.BlueprintHandler toolsManager tools.ToolsManager stack stack.Stack generators []generators.Generator - bundlers []bundler.Bundler - artifactBuilder bundler.Artifact + bundlers []artifact.Bundler + artifactBuilder artifact.Artifact services []services.Service virtualMachine virt.VirtualMachine containerRuntime virt.ContainerRuntime networkManager network.NetworkManager terraformResolvers []terraform.ModuleResolver - templateRenderer template.Template } // ============================================================================= @@ -262,26 +262,25 @@ func (p *InitPipeline) Execute(ctx context.Context) error { } // Phase 2: template processing and blueprint generation - var renderedData map[string]any - if p.templateRenderer != nil && len(templateData) > 0 { - renderedData = make(map[string]any) - if err := p.templateRenderer.Process(templateData, renderedData); err != nil { - return fmt.Errorf("failed to process template data: %w", err) - } - if blueprintData, exists := renderedData["blueprint"]; exists { - if blueprintGenerator := p.injector.Resolve("blueprintGenerator"); blueprintGenerator != nil { - if generator, ok := blueprintGenerator.(generators.Generator); ok { - if err := generator.Generate(map[string]any{"blueprint": blueprintData}, reset); err != nil { - return fmt.Errorf("failed to generate blueprint from template data: %w", err) - } - } - } + renderedData, err := p.processTemplateData(templateData) + if err != nil { + return err + } + + if err := p.loadBlueprintFromTemplate(ctx, renderedData); err != nil { + return err + } + + // Phase 3: blueprint loading (fallback if no template data) + if len(renderedData) == 0 || renderedData["blueprint"] == nil { + if err := p.blueprintHandler.LoadConfig(); err != nil { + return fmt.Errorf("Error loading blueprint config: %w", err) } } - // Phase 3: blueprint loading - if err := p.blueprintHandler.LoadConfig(); err != nil { - return fmt.Errorf("Error reloading blueprint config after generation: %w", err) + // Write blueprint file + if err := p.blueprintHandler.Write(reset); err != nil { + return fmt.Errorf("failed to write blueprint file: %w", err) } // Phase 4: terraform module resolution @@ -428,12 +427,15 @@ func (p *InitPipeline) saveConfiguration(overwrite bool) error { return nil } -// prepareTemplateData selects template input sources by priority. +// prepareTemplateData selects template input sources for template rendering in InitPipeline. +// +// Selection priority is as follows: +// 1. If the context "blueprint" value is an OCI reference and artifactBuilder is set, extract template data from the OCI artifact. +// 2. If blueprintHandler is set, attempt to load template data from the local _template directory. +// 3. If local template data is unavailable, generate default template data for the current context using blueprintHandler. +// 4. If none of the above yield data, return an empty map. // -// 1: If --blueprint is an OCI ref, try extracting template data from OCI artifact. -// 2: If local _template dir exists, try loading template data from it. -// 3: If blueprint handler exists, generate default template data for current context. -// 4: If all fail, return empty map. +// Returns a map of template file names to contents, or an error if extraction fails at any step. func (p *InitPipeline) prepareTemplateData(ctx context.Context) (map[string][]byte, error) { var blueprintValue string if blueprintCtx := ctx.Value("blueprint"); blueprintCtx != nil { @@ -442,15 +444,20 @@ func (p *InitPipeline) prepareTemplateData(ctx context.Context) (map[string][]by } } - if blueprintValue != "" && strings.HasPrefix(blueprintValue, "oci://") { - if p.artifactBuilder == nil { - return nil, fmt.Errorf("artifact builder not available for OCI blueprint") - } - templateData, err := p.artifactBuilder.GetTemplateData(blueprintValue) - if err != nil { - return nil, fmt.Errorf("failed to get OCI template data: %w", err) - } - if len(templateData) > 0 { + if blueprintValue != "" { + if p.artifactBuilder != nil { + ociInfo, err := artifact.ParseOCIReference(blueprintValue) + if err != nil { + return nil, fmt.Errorf("failed to parse blueprint reference: %w", err) + } + if ociInfo == nil { + return nil, fmt.Errorf("invalid blueprint reference: %s", blueprintValue) + } + + templateData, err := p.artifactBuilder.GetTemplateData(ociInfo.URL) + if err != nil { + return nil, fmt.Errorf("failed to get template data from blueprint: %w", err) + } return templateData, nil } } @@ -463,16 +470,55 @@ func (p *InitPipeline) prepareTemplateData(ctx context.Context) (map[string][]by if len(localTemplateData) > 0 { return localTemplateData, nil } - } - if p.blueprintHandler != nil { - contextName := p.determineContextName(context.Background()) - return p.blueprintHandler.GetDefaultTemplateData(contextName) + contextName := p.determineContextName(ctx) + defaultTemplateData, err := p.blueprintHandler.GetDefaultTemplateData(contextName) + if err != nil { + return nil, fmt.Errorf("failed to get default template data: %w", err) + } + return defaultTemplateData, nil } return make(map[string][]byte), nil } +// processTemplateData processes the template data to load blueprint data and render it. +func (p *InitPipeline) processTemplateData(templateData map[string][]byte) (map[string]any, error) { + var renderedData map[string]any + if p.templateRenderer != nil && len(templateData) > 0 { + renderedData = make(map[string]any) + if err := p.templateRenderer.Process(templateData, renderedData); err != nil { + return nil, fmt.Errorf("failed to process template data: %w", err) + } + } + return renderedData, nil +} + +// loadBlueprintFromTemplate loads blueprint data from rendered template data. If the "blueprint" key exists +// in renderedData and is a map, attempts to parse OCI artifact info from the context's "blueprint" value. +// Delegates loading to blueprintHandler.LoadData with the parsed blueprint map and optional OCI info. +func (p *InitPipeline) loadBlueprintFromTemplate(ctx context.Context, renderedData map[string]any) error { + if blueprintData, exists := renderedData["blueprint"]; exists { + if blueprintMap, ok := blueprintData.(map[string]any); ok { + var ociInfo *artifact.OCIArtifactInfo + if blueprintCtx := ctx.Value("blueprint"); blueprintCtx != nil { + if blueprintValue, ok := blueprintCtx.(string); ok { + var err error + ociInfo, err = artifact.ParseOCIReference(blueprintValue) + if err != nil { + return err + } + } + } + + if err := p.blueprintHandler.LoadData(blueprintMap, ociInfo); err != nil { + return fmt.Errorf("failed to load blueprint data: %w", err) + } + } + } + return nil +} + // writeConfigurationFiles writes configuration files for all managed components in the InitPipeline. // It sequentially invokes WriteManifest or WriteConfig on the tools manager, each registered service, // the virtual machine, and the container runtime if present. Returns an error if any write operation fails. diff --git a/pkg/pipelines/init_test.go b/pkg/pipelines/init_test.go index 230bfc08c..26adaf387 100644 --- a/pkg/pipelines/init_test.go +++ b/pkg/pipelines/init_test.go @@ -379,7 +379,7 @@ func TestInitPipeline_Execute(t *testing.T) { return fmt.Errorf("blueprint load config failed") } }, - expectedErr: "Error reloading blueprint config after generation: blueprint load config failed", + expectedErr: "Error loading blueprint config: blueprint load config failed", }, { name: "ReturnsErrorWhenSaveConfigFails", @@ -870,15 +870,15 @@ func TestInitPipeline_prepareTemplateData(t *testing.T) { if err == nil { t.Fatal("Expected error, got nil") } - if !strings.Contains(err.Error(), "failed to get OCI template data") { - t.Errorf("Expected error to contain 'failed to get OCI template data', got %v", err) + if !strings.Contains(err.Error(), "failed to get template data from blueprint") { + t.Errorf("Expected error to contain 'failed to get template data from blueprint', got %v", err) } if templateData != nil { t.Error("Expected nil template data on error") } }) - t.Run("ReturnsErrorWhenArtifactBuilderMissing", func(t *testing.T) { + t.Run("ReturnsEmptyWhenArtifactBuilderMissing", func(t *testing.T) { // Given a pipeline with OCI blueprint but no artifact builder pipeline := &InitPipeline{} pipeline.artifactBuilder = nil @@ -889,15 +889,15 @@ func TestInitPipeline_prepareTemplateData(t *testing.T) { // When prepareTemplateData is called templateData, err := pipeline.prepareTemplateData(ctx) - // Then should return error - if err == nil { - t.Fatal("Expected error, got nil") + // Then should return empty template data + if err != nil { + t.Errorf("Expected no error, got %v", err) } - if !strings.Contains(err.Error(), "artifact builder not available") { - t.Errorf("Expected error to contain 'artifact builder not available', got %v", err) + if templateData == nil { + t.Error("Expected non-nil template data") } - if templateData != nil { - t.Error("Expected nil template data on error") + if len(templateData) != 0 { + t.Error("Expected empty template data") } }) @@ -932,6 +932,44 @@ func TestInitPipeline_prepareTemplateData(t *testing.T) { } }) + t.Run("UsesArtifactBuilderForShortFormatBlueprint", func(t *testing.T) { + // Given a pipeline with short format blueprint and artifact builder + pipeline := &InitPipeline{} + + mockArtifactBuilder := artifact.NewMockArtifact() + expectedTemplateData := map[string][]byte{ + "blueprint.jsonnet": []byte("{ test: 'short-format' }"), + } + var receivedOCIRef string + mockArtifactBuilder.GetTemplateDataFunc = func(ociRef string) (map[string][]byte, error) { + receivedOCIRef = ociRef + return expectedTemplateData, nil + } + pipeline.artifactBuilder = mockArtifactBuilder + + // Create context with short format blueprint value + ctx := context.WithValue(context.Background(), "blueprint", "windsorcli/core:v0.0.1") + + // When prepareTemplateData is called + templateData, err := pipeline.prepareTemplateData(ctx) + + // Then should use artifact builder with converted full URL + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if len(templateData) != 1 { + t.Errorf("Expected 1 template file, got %d", len(templateData)) + } + if string(templateData["blueprint.jsonnet"]) != "{ test: 'short-format' }" { + t.Error("Expected correct template data from artifact builder") + } + // Verify that the short format was converted to full OCI URL + expectedURL := "oci://ghcr.io/windsorcli/core:v0.0.1" + if receivedOCIRef != expectedURL { + t.Errorf("Expected GetTemplateData to be called with %s, got %s", expectedURL, receivedOCIRef) + } + }) + t.Run("UsesLocalTemplateDataWhenAvailable", func(t *testing.T) { // Given a pipeline with local template data pipeline := &InitPipeline{} diff --git a/pkg/pipelines/pipeline.go b/pkg/pipelines/pipeline.go index 5a7056dea..b47b93145 100644 --- a/pkg/pipelines/pipeline.go +++ b/pkg/pipelines/pipeline.go @@ -260,7 +260,7 @@ func (p *BasePipeline) withStack() stack.Stack { return stack } -// withGenerators creates and registers generators including git, terraform, and blueprint generators. +// withGenerators creates and registers generators including git and terraform generators. // Returns a slice of initialized generators or an error if creation fails. func (p *BasePipeline) withGenerators() ([]generators.Generator, error) { var generatorList []generators.Generator @@ -273,10 +273,6 @@ func (p *BasePipeline) withGenerators() ([]generators.Generator, error) { p.injector.Register("terraformGenerator", terraformGenerator) generatorList = append(generatorList, terraformGenerator) - blueprintGenerator := generators.NewBlueprintGenerator(p.injector) - p.injector.Register("blueprintGenerator", blueprintGenerator) - generatorList = append(generatorList, blueprintGenerator) - return generatorList, nil }