diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a1ec37e39..bf974d64a 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -22,9 +22,9 @@ builds: - arm64 - amd64 ldflags: - - "-X 'github.com/{{ .Env.GITHUB_REPOSITORY }}/cmd.version={{ .Version }}'" + - "-X 'github.com/{{ .Env.GITHUB_REPOSITORY }}/pkg/constants.Version={{ .Version }}'" + - "-X 'github.com/{{ .Env.GITHUB_REPOSITORY }}/pkg/constants.CommitSHA={{ .Env.GITHUB_SHA }}'" - "-X 'github.com/{{ .Env.GITHUB_REPOSITORY }}/pkg/secrets.version={{ .Version }}'" - - "-X 'github.com/{{ .Env.GITHUB_REPOSITORY }}/cmd.commitSHA={{ .Env.GITHUB_SHA }}'" - "-X 'github.com/{{ .Env.GITHUB_REPOSITORY }}/pkg/constants.PinnedBlueprintURL={{ .Env.PINNED_BLUEPRINT_URL }}'" # Archive configuration diff --git a/cmd/init.go b/cmd/init.go index 60e41728f..5e7d2cfa8 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -124,9 +124,6 @@ var initCmd = &cobra.Command{ } if err := proj.Initialize(initReset); err != nil { - if !verbose { - return nil - } return err } diff --git a/cmd/version.go b/cmd/version.go index bb29c7129..32761582e 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -5,12 +5,7 @@ import ( "runtime" "github.com/spf13/cobra" -) - -// These variables will be set at build time -var ( - version = "dev" - commitSHA = "none" + "github.com/windsorcli/cli/pkg/constants" ) // Goos returns the operating system, can be mocked for testing @@ -24,7 +19,7 @@ var versionCmd = &cobra.Command{ SilenceUsage: true, Run: func(cmd *cobra.Command, args []string) { platform := fmt.Sprintf("%s/%s", Goos, runtime.GOARCH) - cmd.Printf("Version: %s\nCommit SHA: %s\nPlatform: %s\n", version, commitSHA, platform) + cmd.Printf("Version: %s\nCommit SHA: %s\nPlatform: %s\n", constants.Version, constants.CommitSHA, platform) }, } diff --git a/go.mod b/go.mod index cf48ee961..b62cac06c 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.3 require ( github.com/1password/onepassword-sdk-go v0.3.1 + github.com/Masterminds/semver/v3 v3.4.0 github.com/abiosoft/colima v0.9.1 github.com/briandowns/spinner v1.23.2 github.com/compose-spec/compose-go/v2 v2.9.1 diff --git a/go.sum b/go.sum index 478580144..a89bb02af 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= diff --git a/pkg/composer/artifact/artifact.go b/pkg/composer/artifact/artifact.go index 68b3a1a77..1cb8ce03c 100644 --- a/pkg/composer/artifact/artifact.go +++ b/pkg/composer/artifact/artifact.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/Masterminds/semver/v3" "github.com/briandowns/spinner" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" @@ -18,6 +19,7 @@ import ( "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-containerregistry/pkg/v1/static" "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/windsorcli/cli/pkg/constants" "github.com/windsorcli/cli/pkg/runtime" "github.com/windsorcli/cli/pkg/runtime/shell" ) @@ -41,6 +43,7 @@ type BlueprintMetadata struct { Tags []string `json:"tags,omitempty"` Homepage string `json:"homepage,omitempty"` License string `json:"license,omitempty"` + CliVersion string `json:"cliVersion,omitempty"` Timestamp string `json:"timestamp"` Git GitProvenance `json:"git"` Builder BuilderInfo `json:"builder"` @@ -78,6 +81,7 @@ type BlueprintMetadataInput struct { Tags []string `yaml:"tags,omitempty"` Homepage string `yaml:"homepage,omitempty"` License string `yaml:"license,omitempty"` + CliVersion string `yaml:"cliVersion,omitempty"` } // ============================================================================= @@ -395,6 +399,9 @@ func (a *ArtifactBuilder) GetTemplateData(ociRef string) (map[string][]byte, err if err := a.shims.YamlUnmarshal(content, &metadata); err != nil { return nil, fmt.Errorf("failed to parse metadata.yaml: %w", err) } + if err := ValidateCliVersion(constants.Version, metadata.CliVersion); err != nil { + return nil, err + } metadataName = metadata.Name case name == "_template/schema.yaml": schemaContent, err = io.ReadAll(tarReader) @@ -666,6 +673,9 @@ func (a *ArtifactBuilder) parseTagAndResolveMetadata(repoName, tag string) (stri if err := a.shims.YamlUnmarshal(metadataFileInfo.Content, &input); err != nil { return "", "", nil, fmt.Errorf("failed to parse metadata.yaml: %w", err) } + if err := ValidateCliVersion(constants.Version, input.CliVersion); err != nil { + return "", "", nil, err + } } finalName := input.Name @@ -937,6 +947,10 @@ func (a *ArtifactBuilder) generateMetadataWithNameVersion(input BlueprintMetadat Builder: builderInfo, } + if input.CliVersion != "" { + metadata.CliVersion = input.CliVersion + } + return a.shims.YamlMarshal(metadata) } @@ -1121,5 +1135,41 @@ func IsAuthenticationError(err error) bool { return false } +// ValidateCliVersion validates that the provided CLI version satisfies the cliVersion constraint +// specified in the template metadata. If constraint is empty, validation is skipped. +// If cliVersion is empty, validation is skipped (caller cannot determine version). +// If the CLI version is "dev" or "main" or "latest", validation is skipped as these are development builds. +// Returns an error if the constraint is specified and the version does not satisfy it. +func ValidateCliVersion(cliVersion, constraint string) error { + if constraint == "" { + return nil + } + + if cliVersion == "" { + return nil + } + + if cliVersion == "dev" || cliVersion == "main" || cliVersion == "latest" { + return nil + } + + versionStr := strings.TrimPrefix(cliVersion, "v") + version, err := semver.NewVersion(versionStr) + if err != nil { + return fmt.Errorf("invalid CLI version format '%s': %w", cliVersion, err) + } + + c, err := semver.NewConstraint(constraint) + if err != nil { + return fmt.Errorf("invalid cliVersion constraint '%s': %w", constraint, err) + } + + if !c.Check(version) { + return fmt.Errorf("CLI version %s does not satisfy required constraint '%s'", cliVersion, constraint) + } + + return nil +} + // Ensure ArtifactBuilder implements Artifact interface var _ Artifact = (*ArtifactBuilder)(nil) diff --git a/pkg/composer/artifact/artifact_test.go b/pkg/composer/artifact/artifact_test.go index 71c6eefbb..c40beb0e1 100644 --- a/pkg/composer/artifact/artifact_test.go +++ b/pkg/composer/artifact/artifact_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/goccy/go-yaml" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" @@ -1668,12 +1669,18 @@ func TestArtifactBuilder_generateMetadataWithNameVersion(t *testing.T) { } } + // Override YamlMarshal to actually marshal the data + mocks.Shims.YamlMarshal = func(data any) ([]byte, error) { + return yaml.Marshal(data) + } + input := BlueprintMetadataInput{ Description: "Test description", Author: "Test Author", Tags: []string{"test", "example"}, Homepage: "https://example.com", License: "MIT", + CliVersion: ">=1.0.0", } // When generating metadata @@ -1686,6 +1693,15 @@ func TestArtifactBuilder_generateMetadataWithNameVersion(t *testing.T) { if metadata == nil { t.Error("Expected metadata to be generated") } + + // And cliVersion should be preserved in generated metadata + var generatedMetadata BlueprintMetadata + if err := yaml.Unmarshal(metadata, &generatedMetadata); err != nil { + t.Fatalf("Failed to unmarshal generated metadata: %v", err) + } + if generatedMetadata.CliVersion != ">=1.0.0" { + t.Errorf("Expected cliVersion to be '>=1.0.0', got '%s'", generatedMetadata.CliVersion) + } }) t.Run("SuccessWithGitProvenanceFailure", func(t *testing.T) { @@ -3338,6 +3354,43 @@ func TestArtifactBuilder_GetTemplateData(t *testing.T) { t.Error("Expected schema key to be included") } }) + + t.Run("ValidatesCliVersionFromMetadata", func(t *testing.T) { + // Given an artifact builder with cached data containing cliVersion + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder(mocks.Runtime) + + // Create test tar.gz data with cliVersion in metadata + testData := createTestTarGz(t, map[string][]byte{ + "metadata.yaml": []byte("name: test\nversion: v1.0.0\ncliVersion: '>=1.0.0'\n"), + "_template/blueprint.jsonnet": []byte("{ blueprint: 'content' }"), + }) + + // Pre-populate cache + builder.ociCache["registry.example.com/test:v1.0.0"] = testData + + builder.shims = &Shims{ + ParseReference: func(ref string, opts ...name.Option) (name.Reference, error) { + return &mockReference{}, nil + }, + YamlUnmarshal: func(data []byte, v any) error { + if metadata, ok := v.(*BlueprintMetadata); ok { + metadata.Name = "test" + metadata.Version = "v1.0.0" + metadata.CliVersion = ">=1.0.0" + } + return nil + }, + } + + // When calling GetTemplateData + _, err := builder.GetTemplateData("oci://registry.example.com/test:v1.0.0") + + // Then should succeed (validation skipped when cliVersion is empty) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + }) } // createTestTarGz creates a test tar archive with the given files @@ -4251,3 +4304,199 @@ func TestIsAuthenticationError(t *testing.T) { } }) } + +func TestValidateCliVersion(t *testing.T) { + t.Run("ReturnsNilWhenConstraintIsEmpty", func(t *testing.T) { + // Given an empty constraint + // When validating + err := ValidateCliVersion("1.0.0", "") + + // Then should return nil + if err != nil { + t.Errorf("Expected nil for empty constraint, got: %v", err) + } + }) + + t.Run("ReturnsNilWhenCliVersionIsEmpty", func(t *testing.T) { + // Given an empty CLI version + // When validating + err := ValidateCliVersion("", ">=1.0.0") + + // Then should return nil + if err != nil { + t.Errorf("Expected nil for empty CLI version, got: %v", err) + } + }) + + t.Run("ReturnsNilForDevVersion", func(t *testing.T) { + // Given dev version + // When validating + err := ValidateCliVersion("dev", ">=1.0.0") + + // Then should return nil + if err != nil { + t.Errorf("Expected nil for dev version, got: %v", err) + } + }) + + t.Run("ReturnsNilForMainVersion", func(t *testing.T) { + // Given main version + // When validating + err := ValidateCliVersion("main", ">=1.0.0") + + // Then should return nil + if err != nil { + t.Errorf("Expected nil for main version, got: %v", err) + } + }) + + t.Run("ReturnsNilForLatestVersion", func(t *testing.T) { + // Given latest version + // When validating + err := ValidateCliVersion("latest", ">=1.0.0") + + // Then should return nil + if err != nil { + t.Errorf("Expected nil for latest version, got: %v", err) + } + }) + + t.Run("ReturnsErrorForInvalidCliVersionFormat", func(t *testing.T) { + // Given an invalid CLI version format + // When validating + err := ValidateCliVersion("invalid-version", ">=1.0.0") + + // Then should return error + if err == nil { + t.Error("Expected error for invalid CLI version format") + } + if !strings.Contains(err.Error(), "invalid CLI version format") { + t.Errorf("Expected error to contain 'invalid CLI version format', got: %v", err) + } + }) + + t.Run("ReturnsErrorForInvalidConstraint", func(t *testing.T) { + // Given an invalid constraint + // When validating + err := ValidateCliVersion("1.0.0", "invalid-constraint") + + // Then should return error + if err == nil { + t.Error("Expected error for invalid constraint") + } + if !strings.Contains(err.Error(), "invalid cliVersion constraint") { + t.Errorf("Expected error to contain 'invalid cliVersion constraint', got: %v", err) + } + }) + + t.Run("ReturnsErrorWhenVersionDoesNotSatisfyConstraint", func(t *testing.T) { + // Given a version that doesn't satisfy constraint + // When validating + err := ValidateCliVersion("1.0.0", ">=2.0.0") + + // Then should return error + if err == nil { + t.Error("Expected error when version doesn't satisfy constraint") + } + if !strings.Contains(err.Error(), "does not satisfy required constraint") { + t.Errorf("Expected error to contain 'does not satisfy required constraint', got: %v", err) + } + }) + + t.Run("ReturnsNilWhenVersionSatisfiesGreaterThanConstraint", func(t *testing.T) { + // Given a version that satisfies >= constraint + // When validating + err := ValidateCliVersion("2.0.0", ">=1.0.0") + + // Then should return nil + if err != nil { + t.Errorf("Expected nil for satisfied constraint, got: %v", err) + } + }) + + t.Run("ReturnsNilWhenVersionSatisfiesLessThanConstraint", func(t *testing.T) { + // Given a version that satisfies < constraint + // When validating + err := ValidateCliVersion("1.0.0", "<2.0.0") + + // Then should return nil + if err != nil { + t.Errorf("Expected nil for satisfied constraint, got: %v", err) + } + }) + + t.Run("ReturnsNilWhenVersionSatisfiesRangeConstraint", func(t *testing.T) { + // Given a version that satisfies range constraint + // When validating + err := ValidateCliVersion("1.5.0", ">=1.0.0 <2.0.0") + + // Then should return nil + if err != nil { + t.Errorf("Expected nil for satisfied range constraint, got: %v", err) + } + }) + + t.Run("ReturnsErrorWhenVersionOutsideRange", func(t *testing.T) { + // Given a version outside range + // When validating + err := ValidateCliVersion("2.5.0", ">=1.0.0 <2.0.0") + + // Then should return error + if err == nil { + t.Error("Expected error when version outside range") + } + if !strings.Contains(err.Error(), "does not satisfy required constraint") { + t.Errorf("Expected error to contain 'does not satisfy required constraint', got: %v", err) + } + }) + + t.Run("ReturnsNilWhenVersionSatisfiesTildeConstraint", func(t *testing.T) { + // Given a version that satisfies ~ constraint + // When validating + err := ValidateCliVersion("1.2.3", "~1.2.0") + + // Then should return nil + if err != nil { + t.Errorf("Expected nil for satisfied tilde constraint, got: %v", err) + } + }) + + t.Run("ReturnsErrorWhenVersionDoesNotSatisfyTildeConstraint", func(t *testing.T) { + // Given a version that doesn't satisfy ~ constraint + // When validating + err := ValidateCliVersion("1.3.0", "~1.2.0") + + // Then should return error + if err == nil { + t.Error("Expected error when version doesn't satisfy tilde constraint") + } + if !strings.Contains(err.Error(), "does not satisfy required constraint") { + t.Errorf("Expected error to contain 'does not satisfy required constraint', got: %v", err) + } + }) + + t.Run("ReturnsNilWhenVersionWithVPrefixSatisfiesConstraint", func(t *testing.T) { + // Given a version with v prefix that satisfies constraint + // When validating + err := ValidateCliVersion("v1.0.0", ">=1.0.0") + + // Then should return nil + if err != nil { + t.Errorf("Expected nil for v-prefixed version satisfying constraint, got: %v", err) + } + }) + + t.Run("ReturnsErrorWhenVersionWithVPrefixDoesNotSatisfyConstraint", func(t *testing.T) { + // Given a version with v prefix that doesn't satisfy constraint + // When validating + err := ValidateCliVersion("v0.5.0", ">=1.0.0") + + // Then should return error + if err == nil { + t.Error("Expected error when v-prefixed version doesn't satisfy constraint") + } + if !strings.Contains(err.Error(), "does not satisfy required constraint") { + t.Errorf("Expected error to contain 'does not satisfy required constraint', got: %v", err) + } + }) +} diff --git a/pkg/composer/blueprint/blueprint_handler.go b/pkg/composer/blueprint/blueprint_handler.go index 8f2926a05..bfcdb5672 100644 --- a/pkg/composer/blueprint/blueprint_handler.go +++ b/pkg/composer/blueprint/blueprint_handler.go @@ -281,6 +281,21 @@ func (b *BaseBlueprintHandler) GetLocalTemplateData() (map[string][]byte, error) return nil, fmt.Errorf("failed to collect templates: %w", err) } + metadataPath := filepath.Join(b.runtime.TemplateRoot, "metadata.yaml") + if _, err := b.shims.Stat(metadataPath); err == nil { + metadataContent, err := b.shims.ReadFile(metadataPath) + if err != nil { + return nil, fmt.Errorf("failed to read metadata.yaml: %w", err) + } + var metadata artifact.BlueprintMetadataInput + if err := b.shims.YamlUnmarshal(metadataContent, &metadata); err != nil { + return nil, fmt.Errorf("failed to parse metadata.yaml: %w", err) + } + if err := artifact.ValidateCliVersion(constants.Version, metadata.CliVersion); err != nil { + return nil, err + } + } + if schemaData, exists := templateData["schema"]; exists { if err := b.runtime.ConfigHandler.LoadSchemaFromBytes(schemaData); err != nil { return nil, fmt.Errorf("failed to load schema: %w", err) @@ -808,6 +823,7 @@ func (b *BaseBlueprintHandler) processBlueprintData(data []byte, blueprint *blue if err := blueprint.StrategicMerge(completeBlueprint); err != nil { return fmt.Errorf("failed to strategic merge blueprint: %w", err) } + return nil } diff --git a/pkg/composer/blueprint/blueprint_handler_public_test.go b/pkg/composer/blueprint/blueprint_handler_public_test.go index b7aa4c2f6..d82e43308 100644 --- a/pkg/composer/blueprint/blueprint_handler_public_test.go +++ b/pkg/composer/blueprint/blueprint_handler_public_test.go @@ -259,6 +259,9 @@ func setupShims(t *testing.T) *Shims { if strings.Contains(name, "contexts") && strings.Contains(name, "values.yaml") { return &mockFileInfo{name: "values.yaml"}, nil } + if strings.Contains(name, "_template/metadata.yaml") { + return nil, os.ErrNotExist + } if strings.Contains(name, "_template") && !strings.Contains(name, "schema.yaml") { return &mockFileInfo{name: "_template", isDir: true}, nil } @@ -963,6 +966,12 @@ func TestBlueprintHandler_GetLocalTemplateData(t *testing.T) { if strings.Contains(normalizedPath, "test-context/values.yaml") { return &mockFileInfo{isDir: false}, nil } + if normalizedPath == filepath.ToSlash(filepath.Join(projectRoot, "contexts", "_template")) { + return &mockFileInfo{isDir: true}, nil + } + if strings.Contains(normalizedPath, "_template/metadata.yaml") { + return nil, os.ErrNotExist + } if strings.Contains(normalizedPath, "_template") && !strings.Contains(normalizedPath, "schema.yaml") { return &mockFileInfo{isDir: true}, nil } @@ -1149,6 +1158,13 @@ substitutions: strings.Contains(normalizedPath, "test-context/values.yaml") { return mockFileInfo{name: "template"}, nil } + if strings.Contains(normalizedPath, "_template/metadata.yaml") { + return nil, os.ErrNotExist + } + templateRoot := filepath.ToSlash(baseHandler.runtime.TemplateRoot) + if normalizedPath == templateRoot { + return mockFileInfo{name: "_template", isDir: true}, nil + } if strings.Contains(normalizedPath, "_template") && !strings.Contains(normalizedPath, "schema.yaml") { return mockFileInfo{name: "_template", isDir: true}, nil } @@ -2016,18 +2032,13 @@ func TestBlueprintHandler_LoadBlueprint(t *testing.T) { if err != nil { t.Fatalf("NewBlueprintHandler() failed: %v", err) } - // Set up shims after initialization handler.shims = mocks.Shims - // Set up project root and create template root directory + // Set up project root and template root tmpDir := t.TempDir() mocks.Runtime.ProjectRoot = tmpDir - 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 + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + templateRootNormalized := filepath.ToSlash(mocks.Runtime.TemplateRoot) blueprintContent := `apiVersion: v1alpha1 kind: Blueprint metadata: @@ -2037,9 +2048,38 @@ 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) + originalReadFile := handler.shims.ReadFile + handler.shims.Stat = func(path string) (os.FileInfo, error) { + normalizedPath := filepath.ToSlash(path) + if normalizedPath == templateRootNormalized { + return &mockFileInfo{name: "_template", isDir: true}, nil + } + if strings.Contains(normalizedPath, "_template/metadata.yaml") { + return nil, os.ErrNotExist + } + if strings.Contains(normalizedPath, "_template/blueprint.yaml") { + return &mockFileInfo{name: "blueprint.yaml", isDir: false}, nil + } + if strings.Contains(normalizedPath, "_template") { + return &mockFileInfo{name: "_template", isDir: true}, nil + } + return nil, os.ErrNotExist + } + handler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + normalizedPath := filepath.ToSlash(path) + if normalizedPath == templateRootNormalized { + return []os.DirEntry{ + &mockDirEntry{name: "blueprint.yaml", isDir: false}, + }, nil + } + return []os.DirEntry{}, nil + } + handler.shims.ReadFile = func(path string) ([]byte, error) { + normalizedPath := filepath.ToSlash(path) + if strings.Contains(normalizedPath, "_template/blueprint.yaml") { + return []byte(blueprintContent), nil + } + return originalReadFile(path) } // Mock config handler to return empty context values @@ -2780,6 +2820,207 @@ metadata: } } }) + + t.Run("ValidatesCliVersionFromMetadataYaml", func(t *testing.T) { + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") + + mocks := setupMocks(t) + mocks.Runtime.ProjectRoot = projectRoot + + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + + contextName := "test-context" + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.GetContextFunc = func() string { + return contextName + } + mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{}, nil + } + + templateDir := filepath.Join(projectRoot, "contexts", "_template") + + if err := os.MkdirAll(templateDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + metadataContent := []byte(`name: test-blueprint +version: 1.0.0 +cliVersion: ">=1.0.0" +`) + if err := os.WriteFile(filepath.Join(templateDir, "metadata.yaml"), metadataContent, 0644); err != nil { + t.Fatalf("Failed to write metadata.yaml: %v", err) + } + + blueprintContent := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base-blueprint +`) + if err := os.WriteFile(filepath.Join(templateDir, "blueprint.yaml"), blueprintContent, 0644); err != nil { + t.Fatalf("Failed to write blueprint.yaml: %v", err) + } + + _, err = handler.GetLocalTemplateData() + + if err != nil { + t.Fatalf("Expected no error when cliVersion is empty (validation skipped), got %v", err) + } + }) + + t.Run("SkipsValidationWhenMetadataYamlDoesNotExist", func(t *testing.T) { + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") + + mocks := setupMocks(t) + mocks.Runtime.ProjectRoot = projectRoot + + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + + contextName := "test-context" + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.GetContextFunc = func() string { + return contextName + } + mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{}, nil + } + + templateDir := filepath.Join(projectRoot, "contexts", "_template") + + if err := os.MkdirAll(templateDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + blueprintContent := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base-blueprint +`) + if err := os.WriteFile(filepath.Join(templateDir, "blueprint.yaml"), blueprintContent, 0644); err != nil { + t.Fatalf("Failed to write blueprint.yaml: %v", err) + } + + templateData, err := handler.GetLocalTemplateData() + + if err != nil { + t.Fatalf("Expected no error when metadata.yaml doesn't exist, got %v", err) + } + + if templateData == nil { + t.Fatal("Expected template data, got nil") + } + }) + + t.Run("ReturnsErrorWhenMetadataYamlCannotBeRead", func(t *testing.T) { + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") + + mocks := setupMocks(t) + mocks.Runtime.ProjectRoot = projectRoot + + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + + contextName := "test-context" + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.GetContextFunc = func() string { + return contextName + } + mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{}, nil + } + + templateDir := filepath.Join(projectRoot, "contexts", "_template") + + if err := os.MkdirAll(templateDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if strings.Contains(name, "metadata.yaml") { + return &mockFileInfo{name: "metadata.yaml", isDir: false}, nil + } + return os.Stat(name) + } + + handler.shims.ReadFile = func(path string) ([]byte, error) { + if strings.Contains(path, "metadata.yaml") { + return nil, fmt.Errorf("read error") + } + return os.ReadFile(path) + } + + _, err = handler.GetLocalTemplateData() + + if err == nil { + t.Fatal("Expected error when metadata.yaml cannot be read") + } + if !strings.Contains(err.Error(), "failed to read metadata.yaml") { + t.Errorf("Expected error to contain 'failed to read metadata.yaml', got: %v", err) + } + }) + + t.Run("ReturnsErrorWhenMetadataYamlCannotBeParsed", func(t *testing.T) { + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") + + mocks := setupMocks(t) + mocks.Runtime.ProjectRoot = projectRoot + + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + + contextName := "test-context" + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.GetContextFunc = func() string { + return contextName + } + mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{}, nil + } + + templateDir := filepath.Join(projectRoot, "contexts", "_template") + + if err := os.MkdirAll(templateDir, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + invalidMetadata := []byte(`name: test-blueprint +version: 1.0.0 +invalid: yaml: content +`) + if err := os.WriteFile(filepath.Join(templateDir, "metadata.yaml"), invalidMetadata, 0644); err != nil { + t.Fatalf("Failed to write metadata.yaml: %v", err) + } + + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + if strings.Contains(string(data), "invalid: yaml: content") { + return fmt.Errorf("yaml parse error") + } + return yaml.Unmarshal(data, v) + } + + _, err = handler.GetLocalTemplateData() + + if err == nil { + t.Fatal("Expected error when metadata.yaml cannot be parsed") + } + if !strings.Contains(err.Error(), "failed to parse metadata.yaml") { + t.Errorf("Expected error to contain 'failed to parse metadata.yaml', got: %v", err) + } + }) } func TestBaseBlueprintHandler_Generate(t *testing.T) { diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index dc191eb4b..91baa8aad 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -2,6 +2,12 @@ package constants import "time" +// Version is the CLI version, set at build time via ldflags +var Version = "dev" + +// CommitSHA is the git commit SHA, set at build time via ldflags +var CommitSHA = "none" + // The Constants package provides centralized default values and configuration constants // It provides shared constants for default settings, timeouts, versions, and resource configurations // The Constants package serves as a single source of truth for default values across the application diff --git a/pkg/project/project.go b/pkg/project/project.go index ddf74419a..a80b28e74 100644 --- a/pkg/project/project.go +++ b/pkg/project/project.go @@ -162,6 +162,11 @@ func (p *Project) Initialize(overwrite bool) error { if err := p.Runtime.ConfigHandler.GenerateContextID(); err != nil { return fmt.Errorf("failed to generate context ID: %w", err) } + + if err := p.Composer.BlueprintHandler.LoadBlueprint(); err != nil { + return fmt.Errorf("failed to load blueprint data: %w", err) + } + if err := p.Runtime.ConfigHandler.SaveConfig(overwrite); err != nil { return fmt.Errorf("failed to save config: %w", err) }