diff --git a/cmd/stackpack.go b/cmd/stackpack.go index 12010a40..4671a19c 100644 --- a/cmd/stackpack.go +++ b/cmd/stackpack.go @@ -8,6 +8,10 @@ import ( "github.com/stackvista/stackstate-cli/internal/di" ) +const ( + experimentalStackpackEnvVar = "STS_EXPERIMENTAL_STACKPACK" +) + func StackPackCommand(cli *di.Deps) *cobra.Command { cmd := &cobra.Command{ Use: "stackpack", @@ -24,9 +28,10 @@ func StackPackCommand(cli *di.Deps) *cobra.Command { cmd.AddCommand(stackpack.StackpackConfirmManualStepsCommand(cli)) cmd.AddCommand(stackpack.StackpackDescribeCommand(cli)) - // Only add scaffold command if experimental feature is enabled - if os.Getenv("STS_EXPERIMENTAL_STACKPACK_SCAFFOLD") != "" { + // The not-production-ready commands + if os.Getenv(experimentalStackpackEnvVar) != "" { cmd.AddCommand(stackpack.StackpackScaffoldCommand(cli)) + cmd.AddCommand(stackpack.StackpackPackageCommand(cli)) } return cmd diff --git a/cmd/stackpack/stackpack_package.go b/cmd/stackpack/stackpack_package.go new file mode 100644 index 00000000..966e160a --- /dev/null +++ b/cmd/stackpack/stackpack_package.go @@ -0,0 +1,300 @@ +package stackpack + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/gurkankaymak/hocon" + "github.com/spf13/cobra" + "github.com/stackvista/stackstate-cli/internal/common" + "github.com/stackvista/stackstate-cli/internal/di" +) + +const ( + defaultDirMode = 0755 // Default directory permissions +) + +// PackageArgs contains arguments for stackpack package command +type PackageArgs struct { + StackpackDir string + ArchiveFile string + Force bool +} + +// StackpackInfo contains parsed stackpack metadata +type StackpackInfo struct { + Name string + Version string +} + +// StackpackConfigParser interface for parsing stackpack configuration +type StackpackConfigParser interface { + Parse(filePath string) (*StackpackInfo, error) +} + +// HoconParser implements StackpackConfigParser for HOCON format +type HoconParser struct{} + +func (h *HoconParser) Parse(filePath string) (*StackpackInfo, error) { + // Read the file content + content, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + // Parse stackpack.conf content + conf, err := hocon.ParseString(string(content)) + if err != nil { + return nil, fmt.Errorf("failed to parse stackpack.conf file: %w", err) + } + + name := strings.Trim(conf.GetString("name"), `"`) + version := strings.Trim(conf.GetString("version"), `"`) + + if name == "" { + return nil, fmt.Errorf("name not found in stackpack.conf") + } + + if version == "" { + return nil, fmt.Errorf("version not found in stackpack.conf") + } + + return &StackpackInfo{ + Name: name, + Version: version, + }, nil +} + +// YamlParser implements StackpackConfigParser for YAML format (future) +type YamlParser struct{} + +func (y *YamlParser) Parse(filePath string) (*StackpackInfo, error) { + // TODO: Implement YAML parsing when format changes + return nil, fmt.Errorf("YAML format not yet implemented") +} + +// Required files and directories for a valid stackpack +var requiredStackpackItems = []string{ + "provisioning", + "README.md", + "resources", + "stackpack.conf", +} + +// StackpackPackageCommand creates the package subcommand +func StackpackPackageCommand(cli *di.Deps) *cobra.Command { + args := &PackageArgs{} + cmd := &cobra.Command{ + Use: "package", + Short: "Package a stackpack into a zip file", + Long: `Package a stackpack into a zip file. + +Creates a zip file containing all required stackpack files and directories: +- provisioning/ (directory) +- README.md (file) +- resources/ (directory) +- stackpack.conf (file) + +The zip file is named -.zip where the name and +version are extracted from stackpack.conf and created in the current directory.`, + Example: `# Package stackpack in current directory +sts stackpack package + +# Package specific stackpack directory +sts stackpack package -d ./my-stackpack + +# Package with custom archive filename +sts stackpack package -f my-custom-archive.zip + +# Force overwrite existing zip file +sts stackpack package --force`, + RunE: cli.CmdRunE(RunStackpackPackageCommand(args)), + } + + cmd.Flags().StringVarP(&args.StackpackDir, "stackpack-directory", "d", "", "Path to stackpack directory (defaults to current directory)") + cmd.Flags().StringVarP(&args.ArchiveFile, "archive-file", "f", "", "Path to the zip file to create (defaults to -.zip in current directory)") + cmd.Flags().BoolVar(&args.Force, "force", false, "Overwrite existing zip file without prompting") + + return cmd +} + +// RunStackpackPackageCommand executes the package command +func RunStackpackPackageCommand(args *PackageArgs) func(cli *di.Deps, cmd *cobra.Command) common.CLIError { + return func(cli *di.Deps, cmd *cobra.Command) common.CLIError { + // Set default stackpack directory + if args.StackpackDir == "" { + currentDir, err := os.Getwd() + if err != nil { + return common.NewRuntimeError(fmt.Errorf("failed to get current working directory: %w", err)) + } + args.StackpackDir = currentDir + } + + // Convert to absolute path + absStackpackDir, err := filepath.Abs(args.StackpackDir) + if err != nil { + return common.NewRuntimeError(fmt.Errorf("failed to get absolute path for stackpack directory: %w", err)) + } + args.StackpackDir = absStackpackDir + + // Parse stackpack.conf using HOCON parser to get name and version + parser := &HoconParser{} + stackpackInfo, err := parser.Parse(filepath.Join(args.StackpackDir, "stackpack.conf")) + if err != nil { + return common.NewRuntimeError(fmt.Errorf("failed to parse stackpack.conf: %w", err)) + } + + // Set default archive file path if not specified + if args.ArchiveFile == "" { + currentDir, err := os.Getwd() + if err != nil { + return common.NewRuntimeError(fmt.Errorf("failed to get current working directory: %w", err)) + } + zipFileName := fmt.Sprintf("%s-%s.zip", stackpackInfo.Name, stackpackInfo.Version) + args.ArchiveFile = filepath.Join(currentDir, zipFileName) + } else { + // Convert to absolute path + absArchiveFile, err := filepath.Abs(args.ArchiveFile) + if err != nil { + return common.NewRuntimeError(fmt.Errorf("failed to get absolute path for archive file: %w", err)) + } + args.ArchiveFile = absArchiveFile + } + + // Validate stackpack directory + if err := validateStackpackDirectory(args.StackpackDir); err != nil { + return common.NewCLIArgParseError(err) + } + + // Check if zip file exists and handle force flag + if _, err := os.Stat(args.ArchiveFile); err == nil && !args.Force { + return common.NewRuntimeError(fmt.Errorf("zip file already exists: %s (use --force to overwrite)", args.ArchiveFile)) + } + + // Create output directory if it doesn't exist + outputDir := filepath.Dir(args.ArchiveFile) + if err := os.MkdirAll(outputDir, os.FileMode(defaultDirMode)); err != nil { + return common.NewRuntimeError(fmt.Errorf("failed to create output directory: %w", err)) + } + + // Create zip file + if err := createStackpackZip(args.StackpackDir, args.ArchiveFile); err != nil { + return common.NewRuntimeError(fmt.Errorf("failed to create zip file: %w", err)) + } + + if cli.IsJson() { + cli.Printer.PrintJson(map[string]interface{}{ + "success": true, + "stackpack_name": stackpackInfo.Name, + "stackpack_version": stackpackInfo.Version, + "zip_file": args.ArchiveFile, + "source_dir": args.StackpackDir, + }) + } else { + cli.Printer.Successf("✓ Stackpack packaged successfully!") + cli.Printer.PrintLn("") + cli.Printer.PrintLn(fmt.Sprintf("Stackpack: %s (v%s)", stackpackInfo.Name, stackpackInfo.Version)) + cli.Printer.PrintLn(fmt.Sprintf("Zip file: %s", args.ArchiveFile)) + } + + return nil + } +} + +func validateStackpackDirectory(dir string) error { + for _, item := range requiredStackpackItems { + itemPath := filepath.Join(dir, item) + if _, err := os.Stat(itemPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("required stackpack item not found: %s", item) + } + return fmt.Errorf("failed to check stackpack item %s: %w", item, err) + } + } + return nil +} + +func createStackpackZip(sourceDir, zipPath string) error { + zipFile, err := os.Create(zipPath) + if err != nil { + return fmt.Errorf("failed to create zip file: %w", err) + } + defer zipFile.Close() + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + // Add each required item to the zip + for _, item := range requiredStackpackItems { + itemPath := filepath.Join(sourceDir, item) + if err := addToZip(zipWriter, itemPath, item); err != nil { + return fmt.Errorf("failed to add %s to zip: %w", item, err) + } + } + + return nil +} + +func addToZip(zipWriter *zip.Writer, sourcePath, zipPath string) error { + fileInfo, err := os.Stat(sourcePath) + if err != nil { + return err + } + + if fileInfo.IsDir() { + return addDirToZip(zipWriter, sourcePath, zipPath) + } + + return addFileToZip(zipWriter, sourcePath, zipPath) +} + +func addFileToZip(zipWriter *zip.Writer, sourcePath, zipPath string) error { + file, err := os.Open(sourcePath) + if err != nil { + return err + } + defer file.Close() + + zipFileWriter, err := zipWriter.Create(zipPath) + if err != nil { + return err + } + + _, err = io.Copy(zipFileWriter, file) + return err +} + +func addDirToZip(zipWriter *zip.Writer, sourceDir, zipDir string) error { + return filepath.Walk(sourceDir, func(filePath string, fileInfo os.FileInfo, err error) error { + if err != nil { + return err + } + + // Get relative path from source directory + relPath, err := filepath.Rel(sourceDir, filePath) + if err != nil { + return err + } + + // Skip the root directory itself + if relPath == "." { + return nil + } + + // Create zip path with forward slashes for cross-platform compatibility + zipPath := filepath.ToSlash(filepath.Join(zipDir, relPath)) + + if fileInfo.IsDir() { + // Create directory entry in zip + _, err := zipWriter.Create(zipPath + "/") + return err + } + + // Add file to zip + return addFileToZip(zipWriter, filePath, zipPath) + }) +} diff --git a/cmd/stackpack/stackpack_package_test.go b/cmd/stackpack/stackpack_package_test.go new file mode 100644 index 00000000..10997699 --- /dev/null +++ b/cmd/stackpack/stackpack_package_test.go @@ -0,0 +1,594 @@ +package stackpack + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + "github.com/stackvista/stackstate-cli/internal/di" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupStackPackPackageCmd creates a test command with mock dependencies +func setupStackPackPackageCmd(t *testing.T) (*di.MockDeps, *cobra.Command) { + cli := di.NewMockDeps(t) + cmd := StackpackPackageCommand(&cli.Deps) + return &cli, cmd +} + +// createTestStackpack creates a valid stackpack directory structure for testing +func createTestStackpack(t *testing.T, dir string, name string, version string) { + // Create required directories + require.NoError(t, os.MkdirAll(filepath.Join(dir, "provisioning"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "resources"), 0755)) + + // Create stackpack.conf + stackpackConf := fmt.Sprintf(`# schemaVersion -- Stackpack specification version. +schemaVersion = "2.0" +# name -- Name of the StackPack. +name = "%s" +# displayName -- Display name of the StackPack. +displayName = "Test %s" +# version -- Semantic version of the StackPack. +version = "%s" +`, name, name, version) + require.NoError(t, os.WriteFile(filepath.Join(dir, "stackpack.conf"), []byte(stackpackConf), 0644)) + + // Create README.md + readme := fmt.Sprintf("# %s\n\nThis is a test stackpack.", name) + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte(readme), 0644)) + + // Create test files in subdirectories + require.NoError(t, os.WriteFile(filepath.Join(dir, "provisioning", "test.sty"), []byte("test provisioning"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "resources", "overview.md"), []byte("test overview"), 0644)) +} + +func TestStackpackPackageCommand_DefaultBehavior(t *testing.T) { + // Create temporary directory structure + tempDir, err := os.MkdirTemp("", "stackpack-package-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + stackpackDir := filepath.Join(tempDir, "test-stackpack") + require.NoError(t, os.MkdirAll(stackpackDir, 0755)) + createTestStackpack(t, stackpackDir, "test-stackpack", "1.0.0") + + // Change to temp directory to test default current directory behavior + originalWd, err := os.Getwd() + require.NoError(t, err) + defer func() { + err := os.Chdir(originalWd) + require.NoError(t, err) + }() + err = os.Chdir(tempDir) + require.NoError(t, err) + + cli, cmd := setupStackPackPackageCmd(t) + + _, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "-d", stackpackDir) + require.NoError(t, err) + + // Verify zip file was created in current directory + expectedZipPath := filepath.Join(tempDir, "test-stackpack-1.0.0.zip") + _, err = os.Stat(expectedZipPath) + assert.NoError(t, err, "Zip file should be created") + + // Verify text output + require.NotEmpty(t, *cli.MockPrinter.SuccessCalls) + successCall := (*cli.MockPrinter.SuccessCalls)[0] + assert.Contains(t, successCall, "✓ Stackpack packaged successfully!") + + require.NotEmpty(t, *cli.MockPrinter.PrintLnCalls) + printLnCalls := *cli.MockPrinter.PrintLnCalls + allOutput := fmt.Sprintf("%v", printLnCalls) + assert.Contains(t, allOutput, "test-stackpack (v1.0.0)") + assert.Contains(t, allOutput, expectedZipPath) +} + +func TestStackpackPackageCommand_CustomArchiveFile(t *testing.T) { + tempDir, err := os.MkdirTemp("", "stackpack-package-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + stackpackDir := filepath.Join(tempDir, "my-stackpack") + require.NoError(t, os.MkdirAll(stackpackDir, 0755)) + createTestStackpack(t, stackpackDir, "my-stackpack", "2.1.0") + + customZipPath := filepath.Join(tempDir, "custom-name.zip") + cli, cmd := setupStackPackPackageCmd(t) + + _, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "-d", stackpackDir, "-f", customZipPath) + require.NoError(t, err) + + // Verify zip file was created with custom name + _, err = os.Stat(customZipPath) + assert.NoError(t, err, "Custom zip file should be created") + + // Verify output mentions custom path + printLnCalls := *cli.MockPrinter.PrintLnCalls + allOutput := fmt.Sprintf("%v", printLnCalls) + assert.Contains(t, allOutput, customZipPath) +} + +func TestStackpackPackageCommand_ForceFlag(t *testing.T) { + tempDir, err := os.MkdirTemp("", "stackpack-package-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + stackpackDir := filepath.Join(tempDir, "test-stackpack") + require.NoError(t, os.MkdirAll(stackpackDir, 0755)) + createTestStackpack(t, stackpackDir, "test-stackpack", "1.0.0") + + zipPath := filepath.Join(tempDir, "test-stackpack-1.0.0.zip") + + // Create existing zip file + require.NoError(t, os.WriteFile(zipPath, []byte("existing content"), 0644)) + + cli, cmd := setupStackPackPackageCmd(t) + + // Test without force flag - should fail + _, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "-d", stackpackDir, "-f", zipPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "zip file already exists") + assert.Contains(t, err.Error(), "--force") + + // Test with force flag - should succeed + cli2, cmd2 := setupStackPackPackageCmd(t) + _, err = di.ExecuteCommandWithContext(&cli2.Deps, cmd2, "-d", stackpackDir, "-f", zipPath, "--force") + require.NoError(t, err) + + // Verify success message + require.NotEmpty(t, *cli2.MockPrinter.SuccessCalls) + successCall := (*cli2.MockPrinter.SuccessCalls)[0] + assert.Contains(t, successCall, "✓ Stackpack packaged successfully!") +} + +func TestStackpackPackageCommand_JSONOutput(t *testing.T) { + tempDir, err := os.MkdirTemp("", "stackpack-package-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + stackpackDir := filepath.Join(tempDir, "json-test") + require.NoError(t, os.MkdirAll(stackpackDir, 0755)) + createTestStackpack(t, stackpackDir, "json-test", "3.0.0") + + // Change to temp directory to ensure zip is created there + originalWd, err := os.Getwd() + require.NoError(t, err) + defer func() { + err := os.Chdir(originalWd) + require.NoError(t, err) + }() + err = os.Chdir(tempDir) + require.NoError(t, err) + + cli, cmd := setupStackPackPackageCmd(t) + + _, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "-d", stackpackDir, "-o", "json") + require.NoError(t, err) + + // Verify JSON output + require.Len(t, *cli.MockPrinter.PrintJsonCalls, 1) + jsonOutput := (*cli.MockPrinter.PrintJsonCalls)[0] + + // Check JSON structure + assert.Equal(t, true, jsonOutput["success"]) + assert.Equal(t, "json-test", jsonOutput["stackpack_name"]) + assert.Equal(t, "3.0.0", jsonOutput["stackpack_version"]) + assert.Contains(t, jsonOutput["zip_file"], "json-test-3.0.0.zip") + assert.Contains(t, jsonOutput["source_dir"], stackpackDir) + + // Verify no text output for JSON mode + assert.False(t, cli.MockPrinter.HasNonJsonCalls) +} + +func TestStackpackPackageCommand_MissingRequiredFiles(t *testing.T) { + tests := []struct { + name string + setupFunc func(dir string) + expectedError string + }{ + { + name: "missing provisioning directory", + setupFunc: func(dir string) { + require.NoError(t, os.MkdirAll(filepath.Join(dir, "resources"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("readme"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "stackpack.conf"), []byte("name = \"test\"\nversion = \"1.0.0\""), 0644)) + }, + expectedError: "required stackpack item not found: provisioning", + }, + { + name: "missing README.md file", + setupFunc: func(dir string) { + require.NoError(t, os.MkdirAll(filepath.Join(dir, "provisioning"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "resources"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "stackpack.conf"), []byte("name = \"test\"\nversion = \"1.0.0\""), 0644)) + }, + expectedError: "required stackpack item not found: README.md", + }, + { + name: "missing resources directory", + setupFunc: func(dir string) { + require.NoError(t, os.MkdirAll(filepath.Join(dir, "provisioning"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("readme"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "stackpack.conf"), []byte("name = \"test\"\nversion = \"1.0.0\""), 0644)) + }, + expectedError: "required stackpack item not found: resources", + }, + { + name: "missing stackpack.conf file", + setupFunc: func(dir string) { + require.NoError(t, os.MkdirAll(filepath.Join(dir, "provisioning"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "resources"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("readme"), 0644)) + }, + expectedError: "failed to parse stackpack.conf", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "stackpack-package-validation-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + stackpackDir := filepath.Join(tempDir, "invalid-stackpack") + require.NoError(t, os.MkdirAll(stackpackDir, 0755)) + tt.setupFunc(stackpackDir) + + cli, cmd := setupStackPackPackageCmd(t) + + _, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "-d", stackpackDir) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + }) + } +} + +func TestStackpackPackageCommand_InvalidStackpackConf(t *testing.T) { + tests := []struct { + name string + confContent string + expectedError string + }{ + { + name: "invalid HOCON syntax", + confContent: `name = "test" invalid syntax {`, + expectedError: "failed to parse stackpack.conf file", + }, + { + name: "missing name field", + confContent: `version = "1.0.0"`, + expectedError: "name not found in stackpack.conf", + }, + { + name: "missing version field", + confContent: `name = "test"`, + expectedError: "version not found in stackpack.conf", + }, + { + name: "empty name field", + confContent: `name = "" +version = "1.0.0"`, + expectedError: "name not found in stackpack.conf", + }, + { + name: "empty version field", + confContent: `name = "test" +version = ""`, + expectedError: "version not found in stackpack.conf", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "stackpack-package-hocon-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + stackpackDir := filepath.Join(tempDir, "invalid-stackpack") + require.NoError(t, os.MkdirAll(stackpackDir, 0755)) + + // Create required directories and files except stackpack.conf + require.NoError(t, os.MkdirAll(filepath.Join(stackpackDir, "provisioning"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(stackpackDir, "resources"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(stackpackDir, "README.md"), []byte("readme"), 0644)) + + // Create invalid stackpack.conf + require.NoError(t, os.WriteFile(filepath.Join(stackpackDir, "stackpack.conf"), []byte(tt.confContent), 0644)) + + cli, cmd := setupStackPackPackageCmd(t) + + _, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "-d", stackpackDir) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + }) + } +} + +func TestStackpackPackageCommand_NonExistentDirectory(t *testing.T) { + cli, cmd := setupStackPackPackageCmd(t) + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "-d", "/non/existent/directory") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse stackpack.conf") + assert.Contains(t, err.Error(), "no such file or directory") +} + +func TestStackpackPackageCommand_CreateOutputDirectory(t *testing.T) { + tempDir, err := os.MkdirTemp("", "stackpack-package-output-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + stackpackDir := filepath.Join(tempDir, "test-stackpack") + require.NoError(t, os.MkdirAll(stackpackDir, 0755)) + createTestStackpack(t, stackpackDir, "test-stackpack", "1.0.0") + + // Create nested output directory path that doesn't exist + outputDir := filepath.Join(tempDir, "output", "nested", "path") + zipPath := filepath.Join(outputDir, "custom.zip") + + cli, cmd := setupStackPackPackageCmd(t) + + _, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "-d", stackpackDir, "-f", zipPath) + require.NoError(t, err) + + // Verify output directory was created + _, err = os.Stat(outputDir) + assert.NoError(t, err, "Output directory should be created") + + // Verify zip file was created + _, err = os.Stat(zipPath) + assert.NoError(t, err, "Zip file should be created in nested directory") +} + +func TestHoconParser_Parse(t *testing.T) { + tests := []struct { + name string + content string + expectedName string + expectedVer string + expectError bool + errorContains string + }{ + { + name: "valid HOCON with quotes", + content: `name = "my-stackpack" +version = "1.2.3"`, + expectedName: "my-stackpack", + expectedVer: "1.2.3", + expectError: false, + }, + { + name: "valid HOCON without quotes", + content: `name = my-stackpack +version = "1.2.3"`, + expectedName: "my-stackpack", + expectedVer: "1.2.3", + expectError: false, + }, + { + name: "HOCON with comments", + content: `# This is a comment +name = "test-app" +# Another comment +version = "2.0.0"`, + expectedName: "test-app", + expectedVer: "2.0.0", + expectError: false, + }, + { + name: "missing name", + content: `version = "1.0.0"`, + expectError: true, + errorContains: "name not found in stackpack.conf", + }, + { + name: "missing version", + content: `name = "test"`, + expectError: true, + errorContains: "version not found in stackpack.conf", + }, + { + name: "invalid HOCON syntax", + content: `name = "test" { invalid`, + expectError: true, + errorContains: "failed to parse stackpack.conf file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "hocon-parser-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + confPath := filepath.Join(tempDir, "test.conf") + require.NoError(t, os.WriteFile(confPath, []byte(tt.content), 0644)) + + parser := &HoconParser{} + result, err := parser.Parse(confPath) + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + assert.Nil(t, result) + } else { + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, tt.expectedName, result.Name) + assert.Equal(t, tt.expectedVer, result.Version) + } + }) + } +} + +func TestHoconParser_ParseNonExistentFile(t *testing.T) { + parser := &HoconParser{} + result, err := parser.Parse("/non/existent/file.conf") + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read file") + assert.Nil(t, result) +} + +func TestYamlParser_Parse(t *testing.T) { + parser := &YamlParser{} + result, err := parser.Parse("any-path") + + require.Error(t, err) + assert.Contains(t, err.Error(), "YAML format not yet implemented") + assert.Nil(t, result) +} + +func TestValidateStackpackDirectory(t *testing.T) { + tests := []struct { + name string + setupFunc func(dir string) + expectError bool + errorContains string + }{ + { + name: "valid stackpack directory", + setupFunc: func(dir string) { + require.NoError(t, os.MkdirAll(filepath.Join(dir, "provisioning"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "resources"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("readme"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "stackpack.conf"), []byte("conf"), 0644)) + }, + expectError: false, + }, + { + name: "missing provisioning directory", + setupFunc: func(dir string) { + require.NoError(t, os.MkdirAll(filepath.Join(dir, "resources"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("readme"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "stackpack.conf"), []byte("conf"), 0644)) + }, + expectError: true, + errorContains: "required stackpack item not found: provisioning", + }, + { + name: "missing all required items", + setupFunc: func(dir string) { + // Create empty directory + }, + expectError: true, + errorContains: "required stackpack item not found: provisioning", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "validate-stackpack-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + tt.setupFunc(tempDir) + + err = validateStackpackDirectory(tempDir) + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + } + }) + } +} + +//nolint:funlen +func TestStackpackPackageCommand_TextOutput(t *testing.T) { + tests := []struct { + name string + outputFlag string + expectText bool + expectJson bool + }{ + { + name: "default text output", + outputFlag: "", + expectText: true, + expectJson: false, + }, + { + name: "explicit text output", + outputFlag: "text", + expectText: true, + expectJson: false, + }, + { + name: "json output", + outputFlag: "json", + expectText: false, + expectJson: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create unique temp directory for each subtest + tempDir, err := os.MkdirTemp("", "stackpack-package-text-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + stackpackDir := filepath.Join(tempDir, "text-test") + require.NoError(t, os.MkdirAll(stackpackDir, 0755)) + createTestStackpack(t, stackpackDir, "text-test", "4.0.0") + + // Change to temp directory to ensure zip is created there + originalWd, err := os.Getwd() + require.NoError(t, err) + defer func() { + err := os.Chdir(originalWd) + require.NoError(t, err) + }() + err = os.Chdir(tempDir) + require.NoError(t, err) + + cli, cmd := setupStackPackPackageCmd(t) + + args := []string{"-d", stackpackDir} + if tt.outputFlag != "" { + args = append(args, "-o", tt.outputFlag) + } + + _, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, args...) + require.NoError(t, err) + + if tt.expectText { + // Verify success message + require.NotEmpty(t, *cli.MockPrinter.SuccessCalls) + successCall := (*cli.MockPrinter.SuccessCalls)[0] + assert.Contains(t, successCall, "✓ Stackpack packaged successfully!") + + // Verify stackpack info is printed + printLnCalls := *cli.MockPrinter.PrintLnCalls + allOutput := fmt.Sprintf("%v", printLnCalls) + assert.Contains(t, allOutput, "text-test (v4.0.0)") + + // Should not have JSON calls + assert.Empty(t, *cli.MockPrinter.PrintJsonCalls) + } + + if tt.expectJson { + // Verify JSON output + require.Len(t, *cli.MockPrinter.PrintJsonCalls, 1) + jsonOutput := (*cli.MockPrinter.PrintJsonCalls)[0] + assert.Equal(t, "text-test", jsonOutput["stackpack_name"]) + assert.Equal(t, "4.0.0", jsonOutput["stackpack_version"]) + + // Should not have text calls in JSON mode + assert.False(t, cli.MockPrinter.HasNonJsonCalls) + } + }) + } +} diff --git a/cmd/stackpack_test.go b/cmd/stackpack_test.go index 8029cdea..a47958b5 100644 --- a/cmd/stackpack_test.go +++ b/cmd/stackpack_test.go @@ -45,20 +45,20 @@ func TestStackPackCommand_FeatureGating(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Store original environment value to restore later - originalValue := os.Getenv("STS_EXPERIMENTAL_STACKPACK_SCAFFOLD") + originalValue := os.Getenv(experimentalStackpackEnvVar) defer func() { if originalValue == "" { - os.Unsetenv("STS_EXPERIMENTAL_STACKPACK_SCAFFOLD") + os.Unsetenv(experimentalStackpackEnvVar) } else { - os.Setenv("STS_EXPERIMENTAL_STACKPACK_SCAFFOLD", originalValue) + os.Setenv(experimentalStackpackEnvVar, originalValue) } }() // Set the environment variable for this test if tt.envVarValue == "" { - os.Unsetenv("STS_EXPERIMENTAL_STACKPACK_SCAFFOLD") + os.Unsetenv(experimentalStackpackEnvVar) } else { - err := os.Setenv("STS_EXPERIMENTAL_STACKPACK_SCAFFOLD", tt.envVarValue) + err := os.Setenv(experimentalStackpackEnvVar, tt.envVarValue) require.NoError(t, err) } diff --git a/go.mod b/go.mod index 63898e47..1c391f69 100644 --- a/go.mod +++ b/go.mod @@ -94,6 +94,7 @@ require ( github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/gorilla/websocket v1.5.1 // indirect + github.com/gurkankaymak/hocon v1.2.21 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect diff --git a/go.sum b/go.sum index 9fe3b88f..e50828bb 100644 --- a/go.sum +++ b/go.sum @@ -898,6 +898,8 @@ github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:Fecb github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks= github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w= +github.com/gurkankaymak/hocon v1.2.21 h1:ykr1ptXWc4UfPjY5hT0hbRocLWTRAlVvPt1mYbuU+y4= +github.com/gurkankaymak/hocon v1.2.21/go.mod h1:dQCfhnuDKlLqAZRGhFTd81HkAfMx7STHv0w2JkJ6iq4= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=