diff --git a/pkg/bundler/artifact.go b/pkg/bundler/artifact.go new file mode 100644 index 000000000..18b12b76d --- /dev/null +++ b/pkg/bundler/artifact.go @@ -0,0 +1,304 @@ +package bundler + +import ( + "archive/tar" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/windsorcli/cli/pkg/di" + "github.com/windsorcli/cli/pkg/shell" +) + +// The ArtifactBuilder creates tar.gz artifacts from prepared build directories. +// It provides a unified interface for packaging prepared files into distributable artifacts +// without requiring git synchronization or validation. The ArtifactBuilder serves as the final +// step in the bundling pipeline, creating self-contained artifacts that include all bundled +// dependencies and metadata for distribution. + +// ============================================================================= +// Types +// ============================================================================= + +// BlueprintMetadata represents the complete metadata embedded in artifacts +type BlueprintMetadata struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Version string `json:"version,omitempty"` + Author string `json:"author,omitempty"` + Tags []string `json:"tags,omitempty"` + Homepage string `json:"homepage,omitempty"` + License string `json:"license,omitempty"` + Timestamp string `json:"timestamp"` + Git GitProvenance `json:"git"` + Builder BuilderInfo `json:"builder"` +} + +// GitProvenance contains git repository information for traceability +type GitProvenance struct { + CommitSHA string `json:"commitSHA"` + Tag string `json:"tag,omitempty"` + RemoteURL string `json:"remoteURL"` +} + +// BuilderInfo contains information about who/what built the artifact +type BuilderInfo struct { + User string `json:"user"` + Email string `json:"email"` +} + +// BlueprintMetadataInput represents the input metadata from contexts/_template/metadata.yaml +type BlueprintMetadataInput struct { + Name string `yaml:"name"` + Description string `yaml:"description,omitempty"` + Version string `yaml:"version,omitempty"` + Author string `yaml:"author,omitempty"` + Tags []string `yaml:"tags,omitempty"` + Homepage string `yaml:"homepage,omitempty"` + License string `yaml:"license,omitempty"` +} + +// ============================================================================= +// Interfaces +// ============================================================================= + +// Artifact defines the interface for artifact creation operations +type Artifact interface { + Initialize(injector di.Injector) error + AddFile(path string, content []byte) error + Create(outputPath string, tag string) (string, error) +} + +// ============================================================================= +// ArtifactBuilder Implementation +// ============================================================================= + +// ArtifactBuilder implements the Artifact interface for blueprint artifacts +type ArtifactBuilder struct { + shims *Shims + shell shell.Shell + files map[string][]byte +} + +// ============================================================================= +// Constructor +// ============================================================================= + +// NewArtifactBuilder creates a new ArtifactBuilder instance +func NewArtifactBuilder() *ArtifactBuilder { + return &ArtifactBuilder{ + shims: NewShims(), + files: make(map[string][]byte), + } +} + +// ============================================================================= +// Public Methods +// ============================================================================= + +// Initialize initializes the ArtifactBuilder with dependency injection +func (a *ArtifactBuilder) Initialize(injector di.Injector) error { + if injector != nil { + shell, ok := injector.Resolve("shell").(shell.Shell) + if !ok { + return fmt.Errorf("failed to resolve shell from injector") + } + a.shell = shell + } + return nil +} + +// AddFile adds a file with the given path and content to the artifact +func (a *ArtifactBuilder) AddFile(path string, content []byte) error { + a.files[path] = content + return nil +} + +// Create generates a tar.gz artifact from stored files and metadata. +// Accepts optional tag in "name:version" format to override metadata.yaml values. +// Tag takes precedence over existing metadata. If no metadata.yaml exists, tag is required. +// OutputPath can be file or directory - generates filename from metadata if directory. +// Creates compressed tar.gz with all files plus generated metadata.yaml at root. +func (a *ArtifactBuilder) Create(outputPath string, tag string) (string, error) { + var tagName, tagVersion string + if tag != "" { + parts := strings.Split(tag, ":") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", fmt.Errorf("tag must be in format 'name:version', got: %s", tag) + } + tagName = parts[0] + tagVersion = parts[1] + } + + metadataData, hasMetadata := a.files["_templates/metadata.yaml"] + var input BlueprintMetadataInput + + if hasMetadata { + if err := a.shims.YamlUnmarshal(metadataData, &input); err != nil { + return "", fmt.Errorf("failed to parse metadata.yaml: %w", err) + } + } + + finalName := input.Name + finalVersion := input.Version + + if tagName != "" { + finalName = tagName + } + if tagVersion != "" { + finalVersion = tagVersion + } + + if finalName == "" { + return "", fmt.Errorf("name is required: provide via tag parameter or metadata.yaml") + } + if finalVersion == "" { + return "", fmt.Errorf("version is required: provide via tag parameter or metadata.yaml") + } + + finalOutputPath := a.resolveOutputPath(outputPath, finalName, finalVersion) + + metadata, err := a.generateMetadataWithNameVersion(input, finalName, finalVersion) + if err != nil { + return "", fmt.Errorf("failed to generate metadata: %w", err) + } + + outputFile, err := a.shims.Create(finalOutputPath) + if err != nil { + return "", fmt.Errorf("failed to create output file: %w", err) + } + defer outputFile.Close() + + gzipWriter := a.shims.NewGzipWriter(outputFile) + defer gzipWriter.Close() + + tarWriter := a.shims.NewTarWriter(gzipWriter) + defer tarWriter.Close() + + metadataHeader := &tar.Header{ + Name: "metadata.yaml", + Mode: 0644, + Size: int64(len(metadata)), + } + + if err := tarWriter.WriteHeader(metadataHeader); err != nil { + return "", fmt.Errorf("failed to write metadata header: %w", err) + } + + if _, err := tarWriter.Write(metadata); err != nil { + return "", fmt.Errorf("failed to write metadata: %w", err) + } + + for path, content := range a.files { + if path == "_templates/metadata.yaml" { + continue + } + + header := &tar.Header{ + Name: path, + Mode: 0644, + Size: int64(len(content)), + } + + if err := tarWriter.WriteHeader(header); err != nil { + return "", fmt.Errorf("failed to write header for %s: %w", path, err) + } + + if _, err := tarWriter.Write(content); err != nil { + return "", fmt.Errorf("failed to write content for %s: %w", path, err) + } + } + + return finalOutputPath, nil +} + +// ============================================================================= +// Private Methods +// ============================================================================= + +// resolveOutputPath determines the final output path for the artifact. +// If outputPath is a directory or ends with slash, generates filename from name and version in that directory. +// If outputPath appears to be a directory path (no extension), generates filename from name and version. +// Otherwise uses outputPath as-is for the filename (user provided explicit filename). +func (a *ArtifactBuilder) resolveOutputPath(outputPath, name, version string) string { + filename := fmt.Sprintf("%s-%s.tar.gz", name, version) + + if stat, err := a.shims.Stat(outputPath); err == nil && stat.IsDir() { + return filepath.Join(outputPath, filename) + } + + if strings.HasSuffix(outputPath, "/") { + return filepath.Join(outputPath, filename) + } + + if filepath.Ext(outputPath) == "" { + return filepath.Join(outputPath, filename) + } + + return outputPath +} + +// generateMetadataWithNameVersion creates metadata for bundled artifacts with final name and version. +// It combines input metadata with git provenance and builder information, then marshals +// the complete metadata structure to YAML for embedding in the artifact. +func (a *ArtifactBuilder) generateMetadataWithNameVersion(input BlueprintMetadataInput, name, version string) ([]byte, error) { + gitInfo, _ := a.getGitProvenance() + builderInfo, _ := a.getBuilderInfo() + + metadata := BlueprintMetadata{ + Name: name, + Version: version, + Description: input.Description, + Author: input.Author, + Tags: input.Tags, + Homepage: input.Homepage, + License: input.License, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Git: gitInfo, + Builder: builderInfo, + } + + return a.shims.YamlMarshal(metadata) +} + +// getGitProvenance extracts git repository information for provenance tracking. +// All git operations are best-effort and failures are ignored since git provenance is optional. +// Returns empty values for any git operations that fail. +func (a *ArtifactBuilder) getGitProvenance() (GitProvenance, error) { + var gitInfo GitProvenance + + if commitSHA, err := a.shell.ExecSilent("git", "rev-parse", "HEAD"); err == nil { + gitInfo.CommitSHA = strings.TrimSpace(commitSHA) + } + + if tag, err := a.shell.ExecSilent("git", "tag", "--points-at", "HEAD"); err == nil { + gitInfo.Tag = strings.TrimSpace(tag) + } + + if remoteURL, err := a.shell.ExecSilent("git", "config", "--get", "remote.origin.url"); err == nil { + gitInfo.RemoteURL = strings.TrimSpace(remoteURL) + } + + return gitInfo, nil +} + +// getBuilderInfo extracts information about who/what built the artifact. +// It retrieves git user name and email configuration. All git operations are +// best-effort and failures are ignored since builder info is optional. +func (a *ArtifactBuilder) getBuilderInfo() (BuilderInfo, error) { + var builderInfo BuilderInfo + + if user, err := a.shell.ExecSilent("git", "config", "user.name"); err == nil { + builderInfo.User = strings.TrimSpace(user) + } + + if email, err := a.shell.ExecSilent("git", "config", "user.email"); err == nil { + builderInfo.Email = strings.TrimSpace(email) + } + + return builderInfo, nil +} + +// Ensure ArtifactBuilder implements Artifact interface +var _ Artifact = (*ArtifactBuilder)(nil) diff --git a/pkg/bundler/artifact_test.go b/pkg/bundler/artifact_test.go new file mode 100644 index 000000000..0041f3c42 --- /dev/null +++ b/pkg/bundler/artifact_test.go @@ -0,0 +1,833 @@ +package bundler + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/windsorcli/cli/pkg/di" + "github.com/windsorcli/cli/pkg/shell" +) + +// ============================================================================= +// Test Setup +// ============================================================================= + +type mockFileInfo struct { + name string + isDir bool +} + +func (m *mockFileInfo) Name() string { return m.name } +func (m *mockFileInfo) Size() int64 { return 0 } +func (m *mockFileInfo) Mode() os.FileMode { return 0 } +func (m *mockFileInfo) ModTime() time.Time { return time.Time{} } +func (m *mockFileInfo) IsDir() bool { return m.isDir } +func (m *mockFileInfo) Sys() interface{} { return nil } + +type ArtifactMocks struct { + Injector di.Injector + Shell *shell.MockShell + Shims *Shims +} + +// mockTarWriter provides a mock implementation of TarWriter for testing +type mockTarWriter struct { + writeHeaderFunc func(*tar.Header) error + writeFunc func([]byte) (int, error) + closeFunc func() error +} + +func (m *mockTarWriter) WriteHeader(hdr *tar.Header) error { + if m.writeHeaderFunc != nil { + return m.writeHeaderFunc(hdr) + } + return nil +} + +func (m *mockTarWriter) Write(b []byte) (int, error) { + if m.writeFunc != nil { + return m.writeFunc(b) + } + return len(b), nil +} + +func (m *mockTarWriter) Close() error { + if m.closeFunc != nil { + return m.closeFunc() + } + return nil +} + +type ArtifactSetupOptions struct { + Injector di.Injector + Shell shell.Shell +} + +func setupArtifactMocks(t *testing.T, opts ...*ArtifactSetupOptions) *ArtifactMocks { + t.Helper() + + // Create temporary directory for test + tmpDir, err := os.MkdirTemp("", "artifact-test-*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + + // Change to temporary directory + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + // Create injector + injector := di.NewInjector() + + // Set up shell - default to MockShell for easier testing + var mockShell *shell.MockShell + if len(opts) > 0 && opts[0].Shell != nil { + if ms, ok := opts[0].Shell.(*shell.MockShell); ok { + mockShell = ms + } else { + mockShell = shell.NewMockShell() + } + } else { + mockShell = shell.NewMockShell() + } + + // Set up default shell behaviors + mockShell.ExecSilentFunc = func(command string, args ...string) (string, error) { + cmd := strings.Join(append([]string{command}, args...), " ") + switch { + case strings.Contains(cmd, "git rev-parse HEAD"): + return "abc123def456", nil + case strings.Contains(cmd, "git tag --points-at HEAD"): + return "v1.0.0", nil + case strings.Contains(cmd, "git config --get remote.origin.url"): + return "https://github.com/example/repo.git", nil + case strings.Contains(cmd, "git config user.name"): + return "Test User", nil + case strings.Contains(cmd, "git config user.email"): + return "test@example.com", nil + default: + return "", nil + } + } + + // Register shell with injector + injector.Register("shell", mockShell) + + // Create shims with test-friendly defaults + shims := NewShims() + shims.Stat = func(name string) (os.FileInfo, error) { + if name == "." { + // Mock "." as an existing directory + return &mockFileInfo{name: ".", isDir: true}, nil + } + return nil, os.ErrNotExist + } + shims.Create = func(name string) (io.WriteCloser, error) { + // Create the full path, handling directories properly + fullPath := name + if !filepath.IsAbs(name) { + fullPath = filepath.Join(tmpDir, name) + } + + // Create directory if needed + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, err + } + + return os.Create(fullPath) + } + shims.YamlUnmarshal = func(data []byte, v any) error { + return nil + } + shims.YamlMarshal = func(data any) ([]byte, error) { + return []byte("test: yaml"), nil + } + + // Cleanup function + t.Cleanup(func() { + os.Chdir(tmpDir) + }) + + return &ArtifactMocks{ + Injector: injector, + Shell: mockShell, + Shims: shims, + } +} + +// ============================================================================= +// Test Constructor +// ============================================================================= + +func TestArtifactBuilder_NewArtifactBuilder(t *testing.T) { + t.Run("CreatesBuilderWithDefaults", func(t *testing.T) { + // Given no preconditions + + // When creating a new artifact builder + builder := NewArtifactBuilder() + + // Then the builder should be properly initialized + if builder == nil { + t.Fatal("Expected non-nil builder") + } + + // And basic fields should be set + if builder.shims == nil { + t.Error("Expected shims to be set") + } + if builder.files == nil { + t.Error("Expected files map to be initialized") + } + + // And dependency fields should be nil until Initialize() is called + if builder.shell != nil { + t.Error("Expected shell to be nil before Initialize()") + } + }) +} + +// ============================================================================= +// Test Public Methods +// ============================================================================= + +func TestArtifactBuilder_Initialize(t *testing.T) { + setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { + t.Helper() + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder() + builder.shims = mocks.Shims + return builder, mocks + } + + t.Run("Success", func(t *testing.T) { + // Given a builder and mocks + builder, mocks := setup(t) + + // When calling Initialize + err := builder.Initialize(mocks.Injector) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + + // And shell should be injected + if builder.shell == nil { + t.Error("Expected shell to be set after Initialize()") + } + }) + + t.Run("SuccessWithNilInjector", func(t *testing.T) { + // Given a builder + builder, _ := setup(t) + + // When calling Initialize with nil injector + err := builder.Initialize(nil) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + + // And shell should remain nil + if builder.shell != nil { + t.Error("Expected shell to remain nil with nil injector") + } + }) + + t.Run("ErrorWhenShellNotFound", func(t *testing.T) { + // Given a builder and injector without shell + builder, mocks := setup(t) + mocks.Injector.Register("shell", "not-a-shell") + + // When calling Initialize + err := builder.Initialize(mocks.Injector) + + // Then an error should be returned + if err == nil { + t.Error("Expected error when shell not found") + } + if !strings.Contains(err.Error(), "failed to resolve shell") { + t.Errorf("Expected shell resolution error, got: %v", err) + } + }) +} + +func TestArtifactBuilder_AddFile(t *testing.T) { + setup := func(t *testing.T) *ArtifactBuilder { + t.Helper() + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder() + builder.shims = mocks.Shims + return builder + } + + t.Run("AddsFileSuccessfully", func(t *testing.T) { + // Given a builder + builder := setup(t) + + // When adding a file + testPath := "test/file.txt" + testContent := []byte("test content") + err := builder.AddFile(testPath, testContent) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + + // And file should be stored in builder + if len(builder.files) != 1 { + t.Errorf("Expected 1 file, got %d", len(builder.files)) + } + if content, exists := builder.files[testPath]; !exists { + t.Error("Expected file to be stored") + } else if string(content) != string(testContent) { + t.Errorf("Expected content %s, got %s", testContent, content) + } + }) + + t.Run("AddsMultipleFiles", func(t *testing.T) { + // Given a builder + builder := setup(t) + + // When adding multiple files + files := map[string][]byte{ + "file1.txt": []byte("content 1"), + "file2.txt": []byte("content 2"), + "dir/file3.txt": []byte("content 3"), + } + + for path, content := range files { + err := builder.AddFile(path, content) + if err != nil { + t.Errorf("Unexpected error adding file %s: %v", path, err) + } + } + + // Then all files should be stored + if len(builder.files) != len(files) { + t.Errorf("Expected %d files, got %d", len(files), len(builder.files)) + } + + for path, expectedContent := range files { + if actualContent, exists := builder.files[path]; !exists { + t.Errorf("Expected file %s to be stored", path) + } else if string(actualContent) != string(expectedContent) { + t.Errorf("Expected content %s for file %s, got %s", expectedContent, path, actualContent) + } + } + }) +} + +func TestArtifactBuilder_Create(t *testing.T) { + setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { + t.Helper() + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder() + builder.shims = mocks.Shims + builder.Initialize(mocks.Injector) + return builder, mocks + } + + t.Run("CreateWithValidTag", func(t *testing.T) { + // Given a builder with shell initialized + builder, _ := setup(t) + + // When creating artifact with valid tag + outputPath := "." + tag := "testproject:v1.0.0" + actualPath, err := builder.Create(outputPath, tag) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + + // And output path should be generated correctly + expectedPath := "testproject-v1.0.0.tar.gz" + if actualPath != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, actualPath) + } + }) + + t.Run("CreateWithMetadataFile", func(t *testing.T) { + // Given a builder with metadata file + builder, _ := setup(t) + + // Add metadata file to builder + metadataContent := []byte(` +name: myproject +version: v2.0.0 +description: A test project +`) + builder.AddFile("_templates/metadata.yaml", metadataContent) + + // Override YamlUnmarshal to parse the metadata + builder.shims.YamlUnmarshal = func(data []byte, v any) error { + if metadata, ok := v.(*BlueprintMetadataInput); ok { + metadata.Name = "myproject" + metadata.Version = "v2.0.0" + metadata.Description = "A test project" + } + return nil + } + + // When creating artifact without tag + outputPath := "." + actualPath, err := builder.Create(outputPath, "") + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + + // And output path should use metadata values + expectedPath := "myproject-v2.0.0.tar.gz" + if actualPath != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, actualPath) + } + }) + + t.Run("TagOverridesMetadata", func(t *testing.T) { + // Given a builder with metadata file + builder, _ := setup(t) + + // Add metadata file with different values + builder.AddFile("_templates/metadata.yaml", []byte("metadata")) + builder.shims.YamlUnmarshal = func(data []byte, v any) error { + if metadata, ok := v.(*BlueprintMetadataInput); ok { + metadata.Name = "frommetadata" + metadata.Version = "v1.0.0" + } + return nil + } + + // When creating artifact with tag that overrides metadata + tag := "fromtag:v2.0.0" + actualPath, err := builder.Create(".", tag) + + // Then tag values should take precedence + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + + expectedPath := "fromtag-v2.0.0.tar.gz" + if actualPath != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, actualPath) + } + }) + + t.Run("ErrorWithInvalidTagFormat", func(t *testing.T) { + // Given a builder + builder, _ := setup(t) + + // When creating artifact with invalid tag format + invalidTags := []string{ + "notag", + "only:colon:", + ":missingname", + "missingversion:", + "", + } + + for _, tag := range invalidTags { + if tag == "" { + continue // Skip empty tag as it's handled differently + } + + _, err := builder.Create(".", tag) + + // Then an error should be returned + if err == nil { + t.Errorf("Expected error for invalid tag %s", tag) + } + if !strings.Contains(err.Error(), "tag must be in format") { + t.Errorf("Expected tag format error for %s, got: %v", tag, err) + } + } + }) + + t.Run("ErrorWhenNameMissing", func(t *testing.T) { + // Given a builder with no metadata and no tag + builder, _ := setup(t) + + // When creating artifact without name + _, err := builder.Create(".", "") + + // Then an error should be returned + if err == nil { + t.Error("Expected error when name is missing") + } + if !strings.Contains(err.Error(), "name is required") { + t.Errorf("Expected name required error, got: %v", err) + } + }) + + t.Run("ErrorWhenVersionMissing", func(t *testing.T) { + // Given a builder with metadata containing only name + builder, _ := setup(t) + + builder.AddFile("_templates/metadata.yaml", []byte("metadata")) + builder.shims.YamlUnmarshal = func(data []byte, v any) error { + if metadata, ok := v.(*BlueprintMetadataInput); ok { + metadata.Name = "testproject" + // Version intentionally left empty + } + return nil + } + + // When creating artifact without version + _, err := builder.Create(".", "") + + // Then an error should be returned + if err == nil { + t.Error("Expected error when version is missing") + } + if !strings.Contains(err.Error(), "version is required") { + t.Errorf("Expected version required error, got: %v", err) + } + }) + + t.Run("ErrorWhenMetadataParsingFails", func(t *testing.T) { + // Given a builder with invalid metadata + builder, _ := setup(t) + + builder.AddFile("_templates/metadata.yaml", []byte("invalid yaml")) + builder.shims.YamlUnmarshal = func(data []byte, v any) error { + return fmt.Errorf("yaml parse error") + } + + // When creating artifact + _, err := builder.Create(".", "") + + // Then an error should be returned + if err == nil { + t.Error("Expected error when metadata parsing fails") + } + if !strings.Contains(err.Error(), "failed to parse metadata.yaml") { + t.Errorf("Expected metadata parse error, got: %v", err) + } + }) + + t.Run("ErrorWhenMetadataGenerationFails", func(t *testing.T) { + // Given a builder with failing metadata generation + builder, _ := setup(t) + + builder.shims.YamlMarshal = func(data any) ([]byte, error) { + return nil, fmt.Errorf("marshal error") + } + + // When creating artifact with valid tag + _, err := builder.Create(".", "testproject:v1.0.0") + + // Then an error should be returned + if err == nil { + t.Error("Expected error when metadata generation fails") + } + if !strings.Contains(err.Error(), "failed to generate metadata") { + t.Errorf("Expected metadata generation error, got: %v", err) + } + }) + + t.Run("ErrorWhenOutputFileCreationFails", func(t *testing.T) { + // Given a builder with failing file creation + builder, _ := setup(t) + + builder.shims.Create = func(name string) (io.WriteCloser, error) { + return nil, fmt.Errorf("file creation error") + } + + // When creating artifact with valid tag + _, err := builder.Create(".", "testproject:v1.0.0") + + // Then an error should be returned + if err == nil { + t.Error("Expected error when output file creation fails") + } + if !strings.Contains(err.Error(), "failed to create output file") { + t.Errorf("Expected output file creation error, got: %v", err) + } + }) + + t.Run("ErrorWhenGzipWriterFails", func(t *testing.T) { + // Given a builder with failing gzip writer + builder, _ := setup(t) + + builder.shims.NewGzipWriter = func(w io.Writer) *gzip.Writer { + // Return a gzip writer that will fail on close + gzw := gzip.NewWriter(w) + return gzw + } + + // When creating artifact + _, err := builder.Create(".", "testproject:v1.0.0") + + // Then it should succeed (gzip writer errors are deferred) + if err != nil { + t.Errorf("Expected success, got error: %v", err) + } + }) + + t.Run("ErrorWhenTarWriterHeaderFails", func(t *testing.T) { + // Given a builder with failing tar writer + builder, _ := setup(t) + + mockTarWriter := &mockTarWriter{ + writeHeaderFunc: func(hdr *tar.Header) error { + if hdr.Name == "metadata.yaml" { + return fmt.Errorf("tar header error") + } + return nil + }, + } + + builder.shims.NewTarWriter = func(w io.Writer) TarWriter { + return mockTarWriter + } + + // When creating artifact + _, err := builder.Create(".", "testproject:v1.0.0") + + // Then an error should be returned + if err == nil { + t.Error("Expected error when tar writer header fails") + } + if !strings.Contains(err.Error(), "failed to write metadata header") { + t.Errorf("Expected tar header error, got: %v", err) + } + }) + + t.Run("ErrorWhenTarWriterContentFails", func(t *testing.T) { + // Given a builder with failing tar writer + builder, _ := setup(t) + + mockTarWriter := &mockTarWriter{ + writeHeaderFunc: func(hdr *tar.Header) error { + return nil + }, + writeFunc: func(b []byte) (int, error) { + return 0, fmt.Errorf("tar write error") + }, + } + + builder.shims.NewTarWriter = func(w io.Writer) TarWriter { + return mockTarWriter + } + + // When creating artifact + _, err := builder.Create(".", "testproject:v1.0.0") + + // Then an error should be returned + if err == nil { + t.Error("Expected error when tar writer content fails") + } + if !strings.Contains(err.Error(), "failed to write metadata") { + t.Errorf("Expected tar write error, got: %v", err) + } + }) + + t.Run("ErrorWhenFileHeaderWriteFails", func(t *testing.T) { + // Given a builder with files and failing file header write + builder, _ := setup(t) + builder.AddFile("test.txt", []byte("content")) + + mockTarWriter := &mockTarWriter{ + writeHeaderFunc: func(hdr *tar.Header) error { + if hdr.Name == "test.txt" { + return fmt.Errorf("file header error") + } + return nil + }, + writeFunc: func(b []byte) (int, error) { + return len(b), nil + }, + } + + builder.shims.NewTarWriter = func(w io.Writer) TarWriter { + return mockTarWriter + } + + // When creating artifact + _, err := builder.Create(".", "testproject:v1.0.0") + + // Then an error should be returned + if err == nil { + t.Error("Expected error when file header write fails") + } + if !strings.Contains(err.Error(), "failed to write header for test.txt") { + t.Errorf("Expected file header error, got: %v", err) + } + }) + + t.Run("ErrorWhenFileContentWriteFails", func(t *testing.T) { + // Given a builder with files and failing file content write + builder, _ := setup(t) + builder.AddFile("test.txt", []byte("content")) + + writeCount := 0 + mockTarWriter := &mockTarWriter{ + writeHeaderFunc: func(hdr *tar.Header) error { + return nil + }, + writeFunc: func(b []byte) (int, error) { + writeCount++ + // Let metadata write succeed (first write), fail on file content (second write) + if writeCount == 1 { + return len(b), nil // metadata succeeds + } + return 0, fmt.Errorf("file content error") // file content fails + }, + } + + builder.shims.NewTarWriter = func(w io.Writer) TarWriter { + return mockTarWriter + } + + // When creating artifact + _, err := builder.Create(".", "testproject:v1.0.0") + + // Then an error should be returned + if err == nil { + t.Error("Expected error when file content write fails") + } + if !strings.Contains(err.Error(), "failed to write content for test.txt") { + t.Errorf("Expected file content error, got: %v", err) + } + }) + + t.Run("SkipsMetadataFileInFileLoop", func(t *testing.T) { + // Given a builder with metadata file and other files + builder, _ := setup(t) + builder.AddFile("_templates/metadata.yaml", []byte("metadata content")) + builder.AddFile("other.txt", []byte("other content")) + + filesWritten := make(map[string]bool) + mockTarWriter := &mockTarWriter{ + writeHeaderFunc: func(hdr *tar.Header) error { + filesWritten[hdr.Name] = true + return nil + }, + writeFunc: func(b []byte) (int, error) { + return len(b), nil + }, + } + + builder.shims.NewTarWriter = func(w io.Writer) TarWriter { + return mockTarWriter + } + + // When creating artifact + _, err := builder.Create(".", "testproject:v1.0.0") + + // Then no error should be returned + if err != nil { + t.Errorf("Expected success, got error: %v", err) + } + + // And metadata.yaml should be written once (from the metadata generation) + // And _templates/metadata.yaml should be skipped in the file loop + if !filesWritten["metadata.yaml"] { + t.Error("Expected metadata.yaml to be written") + } + if filesWritten["_templates/metadata.yaml"] { + t.Error("Expected _templates/metadata.yaml to be skipped in file loop") + } + if !filesWritten["other.txt"] { + t.Error("Expected other.txt to be written") + } + }) +} + +// ============================================================================= +// Test Private Methods +// ============================================================================= + +func TestArtifactBuilder_resolveOutputPath(t *testing.T) { + setup := func(t *testing.T) (*ArtifactBuilder, *ArtifactMocks) { + t.Helper() + mocks := setupArtifactMocks(t) + builder := NewArtifactBuilder() + builder.shims = mocks.Shims + return builder, mocks + } + + t.Run("GeneratesFilenameInCurrentDirectory", func(t *testing.T) { + // Given a builder + builder, _ := setup(t) + + // When resolving path for current directory + actualPath := builder.resolveOutputPath(".", "testproject", "v1.0.0") + + // Then filename should be generated in current directory + expectedPath := "testproject-v1.0.0.tar.gz" + if actualPath != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, actualPath) + } + }) + + t.Run("GeneratesFilenameInSpecifiedDirectory", func(t *testing.T) { + // Given a builder + builder, _ := setup(t) + + // When resolving path for directory without extension + actualPath := builder.resolveOutputPath("output", "testproject", "v1.0.0") + + // Then filename should be generated in that directory + expectedPath := filepath.Join("output", "testproject-v1.0.0.tar.gz") + if actualPath != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, actualPath) + } + }) + + t.Run("GeneratesFilenameWithTrailingSlash", func(t *testing.T) { + // Given a builder + builder, _ := setup(t) + + // When resolving path with trailing slash + actualPath := builder.resolveOutputPath("output/", "testproject", "v1.0.0") + + // Then filename should be generated in that directory + expectedPath := filepath.Join("output", "testproject-v1.0.0.tar.gz") + if actualPath != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, actualPath) + } + }) + + t.Run("UsesExplicitFilename", func(t *testing.T) { + // Given a builder + builder, _ := setup(t) + + // When resolving path with explicit filename + explicitPath := "custom-name.tar.gz" + actualPath := builder.resolveOutputPath(explicitPath, "testproject", "v1.0.0") + + // Then explicit filename should be used + if actualPath != explicitPath { + t.Errorf("Expected path %s, got %s", explicitPath, actualPath) + } + }) + + t.Run("UsesExplicitPathWithFilename", func(t *testing.T) { + // Given a builder + builder, _ := setup(t) + + // When resolving path with directory and filename + explicitPath := filepath.Join("output", "custom-name.tar.gz") + actualPath := builder.resolveOutputPath(explicitPath, "testproject", "v1.0.0") + + // Then explicit path should be used + if actualPath != explicitPath { + t.Errorf("Expected path %s, got %s", explicitPath, actualPath) + } + }) +} diff --git a/pkg/bundler/mock_artifact.go b/pkg/bundler/mock_artifact.go new file mode 100644 index 000000000..71761246f --- /dev/null +++ b/pkg/bundler/mock_artifact.go @@ -0,0 +1,61 @@ +package bundler + +import ( + "github.com/windsorcli/cli/pkg/di" +) + +// The MockArtifact is a mock implementation of the Artifact interface for testing. +// It provides function fields that can be overridden to control behavior during tests. +// It serves as a test double for the Artifact interface in unit tests. +// It enables isolation and verification of component interactions with the artifact system. + +// ============================================================================= +// Types +// ============================================================================= + +// MockArtifact is a mock implementation of the Artifact interface +type MockArtifact struct { + InitializeFunc func(injector di.Injector) error + AddFileFunc func(path string, content []byte) error + CreateFunc func(outputPath string, tag string) (string, error) +} + +// ============================================================================= +// Constructor +// ============================================================================= + +// NewMockArtifact creates a new MockArtifact instance +func NewMockArtifact() *MockArtifact { + return &MockArtifact{} +} + +// ============================================================================= +// Public Methods +// ============================================================================= + +// Initialize calls the mock InitializeFunc if set, otherwise returns nil +func (m *MockArtifact) Initialize(injector di.Injector) error { + if m.InitializeFunc != nil { + return m.InitializeFunc(injector) + } + return nil +} + +// AddFile calls the mock AddFileFunc if set, otherwise returns nil +func (m *MockArtifact) AddFile(path string, content []byte) error { + if m.AddFileFunc != nil { + return m.AddFileFunc(path, content) + } + return nil +} + +// Create calls the mock CreateFunc if set, otherwise returns empty string and nil error +func (m *MockArtifact) Create(outputPath string, tag string) (string, error) { + if m.CreateFunc != nil { + return m.CreateFunc(outputPath, tag) + } + return "", nil +} + +// Ensure MockArtifact implements Artifact interface +var _ Artifact = (*MockArtifact)(nil) diff --git a/pkg/bundler/mock_artifact_test.go b/pkg/bundler/mock_artifact_test.go new file mode 100644 index 000000000..1e922049f --- /dev/null +++ b/pkg/bundler/mock_artifact_test.go @@ -0,0 +1,139 @@ +package bundler + +import ( + "testing" + + "github.com/windsorcli/cli/pkg/di" +) + +// ============================================================================= +// Test Public Methods +// ============================================================================= + +func TestMockArtifact_NewMockArtifact(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // Given no preconditions + // When creating a new mock artifact + mock := NewMockArtifact() + + // Then it should not be nil + if mock == nil { + t.Fatal("Expected non-nil mock artifact") + } + }) +} + +func TestMockArtifact_Initialize(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // Given a mock with a custom initialize function + mock := NewMockArtifact() + called := false + mock.InitializeFunc = func(injector di.Injector) error { + called = true + return nil + } + + // When calling Initialize + err := mock.Initialize(di.NewInjector()) + + // Then the mock function should be called + if !called { + t.Error("Expected InitializeFunc to be called") + } + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + }) + + t.Run("NotImplemented", func(t *testing.T) { + // Given a mock with no custom initialize function + mock := NewMockArtifact() + + // When calling Initialize + err := mock.Initialize(di.NewInjector()) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + }) +} + +func TestMockArtifact_AddFile(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // Given a mock with a custom add file function + mock := NewMockArtifact() + called := false + mock.AddFileFunc = func(path string, content []byte) error { + called = true + return nil + } + + // When calling AddFile + err := mock.AddFile("test/path", []byte("content")) + + // Then the mock function should be called + if !called { + t.Error("Expected AddFileFunc to be called") + } + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + }) + + t.Run("NotImplemented", func(t *testing.T) { + // Given a mock with no custom add file function + mock := NewMockArtifact() + + // When calling AddFile + err := mock.AddFile("test/path", []byte("content")) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + }) +} + +func TestMockArtifact_Create(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // Given a mock with a custom create function + mock := NewMockArtifact() + called := false + expectedPath := "expected/path.tar.gz" + mock.CreateFunc = func(outputPath string, tag string) (string, error) { + called = true + return expectedPath, nil + } + + // When calling Create + actualPath, err := mock.Create("test/output", "test:v1.0.0") + + // Then the mock function should be called + if !called { + t.Error("Expected CreateFunc to be called") + } + if actualPath != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, actualPath) + } + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + }) + + t.Run("NotImplemented", func(t *testing.T) { + // Given a mock with no custom create function + mock := NewMockArtifact() + + // When calling Create + actualPath, err := mock.Create("test/output", "test:v1.0.0") + + // Then empty string and no error should be returned + if actualPath != "" { + t.Errorf("Expected empty string, got %s", actualPath) + } + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + }) +} diff --git a/pkg/bundler/shims.go b/pkg/bundler/shims.go new file mode 100644 index 000000000..f78568d8f --- /dev/null +++ b/pkg/bundler/shims.go @@ -0,0 +1,53 @@ +package bundler + +import ( + "archive/tar" + "compress/gzip" + "io" + "os" + + "github.com/goccy/go-yaml" +) + +// The Shims provides mockable wrappers around system and file operations for testing. +// It provides a unified interface for all external dependencies including file system, +// compression, and data marshaling operations. The Shims serves as a dependency injection +// layer that enables comprehensive unit testing by allowing all system calls to be mocked. + +// ============================================================================= +// Types +// ============================================================================= + +// TarWriter provides an interface for tar writing operations +type TarWriter interface { + WriteHeader(hdr *tar.Header) error + Write(b []byte) (int, error) + Close() error +} + +// Shims provides mockable wrappers around system and file operations +type Shims struct { + Stat func(name string) (os.FileInfo, error) + Create func(name string) (io.WriteCloser, error) + NewGzipWriter func(w io.Writer) *gzip.Writer + NewTarWriter func(w io.Writer) TarWriter + YamlUnmarshal func(data []byte, v any) error + YamlMarshal func(data any) ([]byte, error) +} + +// ============================================================================= +// Helpers +// ============================================================================= + +// NewShims creates a new Shims instance with default implementations +func NewShims() *Shims { + return &Shims{ + Stat: os.Stat, + // #nosec G304 - User-controlled output path is intentional for build artifact creation + Create: func(name string) (io.WriteCloser, error) { return os.Create(name) }, + NewGzipWriter: gzip.NewWriter, + NewTarWriter: func(w io.Writer) TarWriter { return tar.NewWriter(w) }, + YamlUnmarshal: yaml.Unmarshal, + YamlMarshal: yaml.Marshal, + } +}