diff --git a/pkg/blueprint/blueprint_handler.go b/pkg/blueprint/blueprint_handler.go index 21daff2cc..a6af7195a 100644 --- a/pkg/blueprint/blueprint_handler.go +++ b/pkg/blueprint/blueprint_handler.go @@ -44,6 +44,7 @@ import ( type BlueprintHandler interface { Initialize() error + LoadBlueprint() error LoadConfig() error LoadData(data map[string]any, ociInfo ...*artifact.OCIArtifactInfo) error Write(overwrite ...bool) error @@ -131,6 +132,81 @@ func (b *BaseBlueprintHandler) Initialize() error { return nil } +// LoadBlueprint loads all blueprint data into memory, establishing defaults from either templates +// or OCI artifacts, then applies any local blueprint.yaml overrides to ensure the correct precedence. +// All sources are processed and merged into the in-memory runtime state. +// Returns an error if any required paths are inaccessible or any loading operation fails. +func (b *BaseBlueprintHandler) LoadBlueprint() error { + if _, err := b.shims.Stat(b.templateRoot); err == nil { + if _, err := b.GetLocalTemplateData(); err != nil { + return fmt.Errorf("failed to get local template data: %w", err) + } + } else { + effectiveBlueprintURL := constants.GetEffectiveBlueprintURL() + ociInfo, err := artifact.ParseOCIReference(effectiveBlueprintURL) + if err != nil { + return fmt.Errorf("failed to parse default blueprint reference: %w", err) + } + if ociInfo == nil { + return fmt.Errorf("invalid default blueprint reference: %s", effectiveBlueprintURL) + } + artifactBuilder := b.injector.Resolve("artifactBuilder") + if artifactBuilder == nil { + return fmt.Errorf("artifact builder not available") + } + ab, ok := artifactBuilder.(artifact.Artifact) + if !ok { + return fmt.Errorf("artifact builder has wrong type") + } + templateData, err := ab.GetTemplateData(ociInfo.URL) + if err != nil { + return fmt.Errorf("failed to get template data from default blueprint: %w", err) + } + blueprintData := make(map[string]any) + for key, value := range templateData { + blueprintData[key] = string(value) + } + if err := b.LoadData(blueprintData, ociInfo); err != nil { + return fmt.Errorf("failed to load default blueprint data: %w", err) + } + } + + sources := b.GetSources() + if len(sources) > 0 { + artifactBuilder := b.injector.Resolve("artifactBuilder") + if artifactBuilder != nil { + if ab, ok := artifactBuilder.(artifact.Artifact); ok { + var ociURLs []string + for _, source := range sources { + if strings.HasPrefix(source.Url, "oci://") { + ociURLs = append(ociURLs, source.Url) + } + } + if len(ociURLs) > 0 { + _, err := ab.Pull(ociURLs) + if err != nil { + return fmt.Errorf("failed to load OCI sources: %w", err) + } + } + } + } + } + + configRoot, err := b.configHandler.GetConfigRoot() + if err != nil { + return fmt.Errorf("error getting config root: %w", err) + } + + blueprintPath := filepath.Join(configRoot, "blueprint.yaml") + if _, err := b.shims.Stat(blueprintPath); err == nil { + if err := b.LoadConfig(); err != nil { + return fmt.Errorf("failed to load blueprint config overrides: %w", err) + } + } + + return nil +} + // LoadConfig reads blueprint configuration from blueprint.yaml file. // Returns an error if blueprint.yaml does not exist. // Template processing is now handled by the pkg/template package. diff --git a/pkg/blueprint/blueprint_handler_public_test.go b/pkg/blueprint/blueprint_handler_public_test.go index 9b7dca5c0..4ce7e0460 100644 --- a/pkg/blueprint/blueprint_handler_public_test.go +++ b/pkg/blueprint/blueprint_handler_public_test.go @@ -3792,6 +3792,66 @@ func TestBaseBlueprintHandler_SetRenderedKustomizeData(t *testing.T) { }) } +func TestBlueprintHandler_LoadBlueprint(t *testing.T) { + t.Run("LoadsBlueprintSuccessfullyWithLocalTemplates", func(t *testing.T) { + // Given a blueprint handler with local templates + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + if err := handler.Initialize(); err != nil { + t.Fatalf("Initialize() failed: %v", err) + } + // Set up shims after initialization + handler.shims = mocks.Shims + + // Set up project root and create template root directory + tmpDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil + } + templateRoot := filepath.Join(tmpDir, "contexts", "_template") + if err := os.MkdirAll(templateRoot, 0755); err != nil { + t.Fatalf("Failed to create template root: %v", err) + } + + // Create a basic blueprint.yaml in templates + blueprintContent := `apiVersion: v1alpha1 +kind: Blueprint +metadata: + name: test-blueprint + description: Test blueprint +sources: [] +terraformComponents: [] +kustomizations: []` + + blueprintPath := filepath.Join(templateRoot, "blueprint.yaml") + if err := os.WriteFile(blueprintPath, []byte(blueprintContent), 0644); err != nil { + t.Fatalf("Failed to create blueprint.yaml: %v", err) + } + + // Mock config handler to return empty context values + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{}, nil + } + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextFunc = func() string { + return "test-context" + } + + // When loading blueprint + err := handler.LoadBlueprint() + + // Then should succeed + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And blueprint should be loaded + metadata := handler.GetMetadata() + if metadata.Name != "test-blueprint" { + t.Errorf("Expected blueprint name 'test-blueprint', got %s", metadata.Name) + } + }) +} + func TestBaseBlueprintHandler_GetLocalTemplateData(t *testing.T) { t.Run("CollectsBlueprintAndFeatureFiles", func(t *testing.T) { projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") diff --git a/pkg/blueprint/mock_blueprint_handler.go b/pkg/blueprint/mock_blueprint_handler.go index 3ec7d0e5e..73124cf6e 100644 --- a/pkg/blueprint/mock_blueprint_handler.go +++ b/pkg/blueprint/mock_blueprint_handler.go @@ -9,6 +9,7 @@ import ( // MockBlueprintHandler is a mock implementation of BlueprintHandler interface for testing type MockBlueprintHandler struct { InitializeFunc func() error + LoadBlueprintFunc func() error LoadConfigFunc func() error LoadDataFunc func(data map[string]any, ociInfo ...*artifact.OCIArtifactInfo) error WriteFunc func(overwrite ...bool) error @@ -48,6 +49,14 @@ func (m *MockBlueprintHandler) Initialize() error { return nil } +// LoadBlueprint calls the mock LoadBlueprintFunc if set, otherwise returns nil +func (m *MockBlueprintHandler) LoadBlueprint() error { + if m.LoadBlueprintFunc != nil { + return m.LoadBlueprintFunc() + } + return nil +} + // LoadConfig calls the mock LoadConfigFunc if set, otherwise returns nil func (m *MockBlueprintHandler) LoadConfig() error { if m.LoadConfigFunc != nil { diff --git a/pkg/runtime/runtime_loaders.go b/pkg/runtime/runtime_loaders.go index f4ea76a00..3cf1bca12 100644 --- a/pkg/runtime/runtime_loaders.go +++ b/pkg/runtime/runtime_loaders.go @@ -5,6 +5,8 @@ import ( "path/filepath" secretsConfigType "github.com/windsorcli/cli/api/v1alpha1/secrets" + "github.com/windsorcli/cli/pkg/artifact" + "github.com/windsorcli/cli/pkg/blueprint" "github.com/windsorcli/cli/pkg/cluster" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/env" @@ -25,8 +27,8 @@ func (r *Runtime) LoadShell() *Runtime { if r.Shell == nil { r.Shell = shell.NewDefaultShell(r.Injector) - r.Injector.Register("shell", r.Shell) } + r.Injector.Register("shell", r.Shell) return r } @@ -42,10 +44,11 @@ func (r *Runtime) LoadConfigHandler() *Runtime { if r.ConfigHandler == nil { r.ConfigHandler = config.NewConfigHandler(r.Injector) - if err := r.ConfigHandler.Initialize(); err != nil { - r.err = fmt.Errorf("failed to initialize config handler: %w", err) - return r - } + } + r.Injector.Register("configHandler", r.ConfigHandler) + if err := r.ConfigHandler.Initialize(); err != nil { + r.err = fmt.Errorf("failed to initialize config handler: %w", err) + return r } return r } @@ -143,11 +146,7 @@ func (r *Runtime) LoadSecretsProviders() *Runtime { return r } -// LoadKubernetes loads and initializes Kubernetes and cluster client dependencies for the Runtime. -// It creates and registers the Kubernetes client, cluster client, and Kubernetes manager in the Injector, -// then initializes the Kubernetes manager to establish connections to Kubernetes API and provider APIs. -// If any dependency is missing, it is constructed and registered. If initialization fails, it sets r.err and returns. -// Returns the Runtime instance with updated dependencies and error state. +// LoadKubernetes loads and initializes Kubernetes and cluster client dependencies. func (r *Runtime) LoadKubernetes() *Runtime { if r.err != nil { return r @@ -161,23 +160,64 @@ func (r *Runtime) LoadKubernetes() *Runtime { r.Injector.Register("kubernetesClient", kubernetesClient) } - if r.ConfigHandler.GetString("cluster.driver") == "talos" { + driver := r.ConfigHandler.GetString("cluster.driver") + if driver == "" { + return r + } + if driver == "talos" { if r.ClusterClient == nil { r.ClusterClient = cluster.NewTalosClusterClient(r.Injector) r.Injector.Register("clusterClient", r.ClusterClient) } } else { - r.err = fmt.Errorf("unsupported cluster driver: %s", r.ConfigHandler.GetString("cluster.driver")) + r.err = fmt.Errorf("unsupported cluster driver: %s", driver) return r } if r.K8sManager == nil { r.K8sManager = kubernetes.NewKubernetesManager(r.Injector) - r.Injector.Register("kubernetesManager", r.K8sManager) } + r.Injector.Register("kubernetesManager", r.K8sManager) if err := r.K8sManager.Initialize(); err != nil { r.err = fmt.Errorf("failed to initialize kubernetes manager: %w", err) return r } return r } + +// LoadBlueprint initializes and configures all runtime dependencies necessary for blueprint processing. +// It creates and registers the blueprint handler and artifact builder if they do not already exist, +// then initializes each component to provide template processing, OCI artifact loading, and blueprint +// data management. All dependencies are injected and registered as needed. If any error occurs during +// initialization, the error is set in the runtime and the method returns. Returns the Runtime instance +// with updated dependencies and error state. +func (r *Runtime) LoadBlueprint() *Runtime { + if r.err != nil { + return r + } + if r.ConfigHandler == nil { + r.err = fmt.Errorf("config handler not loaded - call LoadConfigHandler() first") + return r + } + if r.BlueprintHandler == nil { + r.BlueprintHandler = blueprint.NewBlueprintHandler(r.Injector) + r.Injector.Register("blueprintHandler", r.BlueprintHandler) + } + if r.ArtifactBuilder == nil { + r.ArtifactBuilder = artifact.NewArtifactBuilder() + r.Injector.Register("artifactBuilder", r.ArtifactBuilder) + } + if err := r.BlueprintHandler.Initialize(); err != nil { + r.err = fmt.Errorf("failed to initialize blueprint handler: %w", err) + return r + } + if err := r.ArtifactBuilder.Initialize(r.Injector); err != nil { + r.err = fmt.Errorf("failed to initialize artifact builder: %w", err) + return r + } + if err := r.BlueprintHandler.LoadBlueprint(); err != nil { + r.err = fmt.Errorf("failed to load blueprint data: %w", err) + return r + } + return r +} diff --git a/pkg/runtime/runtime_test.go b/pkg/runtime/runtime_test.go index e29e62e57..de4aa1af0 100644 --- a/pkg/runtime/runtime_test.go +++ b/pkg/runtime/runtime_test.go @@ -234,9 +234,6 @@ func TestRuntime_PrintContext(t *testing.T) { if output != "test-context" { t.Errorf("Expected output 'test-context', got %q", output) } - - // And GetContext should have been called on the config handler - // (We can't easily track this without modifying the mock, so we just verify the output is correct) }) t.Run("ReturnsErrorWhenConfigHandlerNotLoaded", func(t *testing.T) { @@ -398,6 +395,112 @@ func TestRuntime_WriteResetToken(t *testing.T) { }) } +func TestRuntime_LoadBlueprint(t *testing.T) { + t.Run("LoadsBlueprintSuccessfully", func(t *testing.T) { + // Given a runtime with loaded dependencies + mocks := setupMocks(t) + mocks.ConfigHandler.(*config.MockConfigHandler).GetStringFunc = func(key string, defaultValue ...string) string { + if key == "cluster.driver" { + return "talos" + } + return "mock-string" + } + runtime := NewRuntime(mocks).LoadShell().LoadConfigHandler().LoadKubernetes() + + // When loading blueprint + result := runtime.LoadBlueprint() + + // Then should return the same runtime instance + if result != runtime { + t.Error("Expected LoadBlueprint to return the same runtime instance") + } + + // And no error should be set + if runtime.err != nil { + t.Errorf("Expected no error, got %v", runtime.err) + } + + // And blueprint handler should be created and registered + if runtime.BlueprintHandler == nil { + t.Error("Expected blueprint handler to be created") + } + + // And artifact builder should be created and registered + if runtime.ArtifactBuilder == nil { + t.Error("Expected artifact builder to be created") + } + + // And components should be registered in injector + if runtime.Injector.Resolve("blueprintHandler") == nil { + t.Error("Expected blueprint handler to be registered in injector") + } + + if runtime.Injector.Resolve("artifactBuilder") == nil { + t.Error("Expected artifact builder to be registered in injector") + } + }) + + t.Run("ReturnsErrorWhenConfigHandlerNotLoaded", func(t *testing.T) { + // Given a runtime without loaded config handler + runtime := NewRuntime() + + // When loading blueprint + result := runtime.LoadBlueprint() + + // Then should return the same runtime instance + if result != runtime { + t.Error("Expected LoadBlueprint to return the same runtime instance") + } + + // And error should be set + if runtime.err == nil { + t.Error("Expected error when config handler not loaded") + } + + expectedError := "config handler not loaded - call LoadConfigHandler() first" + if runtime.err.Error() != expectedError { + t.Errorf("Expected error %q, got %q", expectedError, runtime.err.Error()) + } + + // And components should not be created + if runtime.BlueprintHandler != nil { + t.Error("Expected blueprint handler to not be created") + } + + if runtime.ArtifactBuilder != nil { + t.Error("Expected artifact builder to not be created") + } + }) + + t.Run("ReturnsEarlyOnExistingError", func(t *testing.T) { + // Given a runtime with an existing error + runtime := NewRuntime() + runtime.err = errors.New("existing error") + + // When loading blueprint + result := runtime.LoadBlueprint() + + // Then should return the same runtime instance + if result != runtime { + t.Error("Expected LoadBlueprint to return the same runtime instance") + } + + // And original error should be preserved + if runtime.err.Error() != "existing error" { + t.Errorf("Expected original error to be preserved, got %v", runtime.err) + } + + // And components should not be created + if runtime.BlueprintHandler != nil { + t.Error("Expected blueprint handler to not be created") + } + + if runtime.ArtifactBuilder != nil { + t.Error("Expected artifact builder to not be created") + } + }) +} + func TestRuntime_Do(t *testing.T) { t.Run("ReturnsNilWhenNoError", func(t *testing.T) { // Given a runtime with no error