|
| 1 | +package stackpack |
| 2 | + |
| 3 | +import ( |
| 4 | + "archive/zip" |
| 5 | + "fmt" |
| 6 | + "io" |
| 7 | + "os" |
| 8 | + "path/filepath" |
| 9 | + "strings" |
| 10 | + |
| 11 | + "github.com/gurkankaymak/hocon" |
| 12 | + "github.com/spf13/cobra" |
| 13 | + "github.com/stackvista/stackstate-cli/internal/common" |
| 14 | + "github.com/stackvista/stackstate-cli/internal/di" |
| 15 | +) |
| 16 | + |
| 17 | +const ( |
| 18 | + defaultDirMode = 0755 // Default directory permissions |
| 19 | +) |
| 20 | + |
| 21 | +// PackageArgs contains arguments for stackpack package command |
| 22 | +type PackageArgs struct { |
| 23 | + StackpackDir string |
| 24 | + ArchiveFile string |
| 25 | + Force bool |
| 26 | +} |
| 27 | + |
| 28 | +// StackpackInfo contains parsed stackpack metadata |
| 29 | +type StackpackInfo struct { |
| 30 | + Name string |
| 31 | + Version string |
| 32 | +} |
| 33 | + |
| 34 | +// StackpackConfigParser interface for parsing stackpack configuration |
| 35 | +type StackpackConfigParser interface { |
| 36 | + Parse(filePath string) (*StackpackInfo, error) |
| 37 | +} |
| 38 | + |
| 39 | +// HoconParser implements StackpackConfigParser for HOCON format |
| 40 | +type HoconParser struct{} |
| 41 | + |
| 42 | +func (h *HoconParser) Parse(filePath string) (*StackpackInfo, error) { |
| 43 | + // Read the file content |
| 44 | + content, err := os.ReadFile(filePath) |
| 45 | + if err != nil { |
| 46 | + return nil, fmt.Errorf("failed to read file: %w", err) |
| 47 | + } |
| 48 | + |
| 49 | + // Parse stackpack.conf content |
| 50 | + conf, err := hocon.ParseString(string(content)) |
| 51 | + if err != nil { |
| 52 | + return nil, fmt.Errorf("failed to parse stackpack.conf file: %w", err) |
| 53 | + } |
| 54 | + |
| 55 | + name := strings.Trim(conf.GetString("name"), `"`) |
| 56 | + version := strings.Trim(conf.GetString("version"), `"`) |
| 57 | + |
| 58 | + if name == "" { |
| 59 | + return nil, fmt.Errorf("name not found in stackpack.conf") |
| 60 | + } |
| 61 | + |
| 62 | + if version == "" { |
| 63 | + return nil, fmt.Errorf("version not found in stackpack.conf") |
| 64 | + } |
| 65 | + |
| 66 | + return &StackpackInfo{ |
| 67 | + Name: name, |
| 68 | + Version: version, |
| 69 | + }, nil |
| 70 | +} |
| 71 | + |
| 72 | +// YamlParser implements StackpackConfigParser for YAML format (future) |
| 73 | +type YamlParser struct{} |
| 74 | + |
| 75 | +func (y *YamlParser) Parse(filePath string) (*StackpackInfo, error) { |
| 76 | + // TODO: Implement YAML parsing when format changes |
| 77 | + return nil, fmt.Errorf("YAML format not yet implemented") |
| 78 | +} |
| 79 | + |
| 80 | +// Required files and directories for a valid stackpack |
| 81 | +var requiredStackpackItems = []string{ |
| 82 | + "provisioning", |
| 83 | + "README.md", |
| 84 | + "resources", |
| 85 | + "stackpack.conf", |
| 86 | +} |
| 87 | + |
| 88 | +// StackpackPackageCommand creates the package subcommand |
| 89 | +func StackpackPackageCommand(cli *di.Deps) *cobra.Command { |
| 90 | + args := &PackageArgs{} |
| 91 | + cmd := &cobra.Command{ |
| 92 | + Use: "package", |
| 93 | + Short: "Package a stackpack into a zip file", |
| 94 | + Long: `Package a stackpack into a zip file. |
| 95 | +
|
| 96 | +Creates a zip file containing all required stackpack files and directories: |
| 97 | +- provisioning/ (directory) |
| 98 | +- README.md (file) |
| 99 | +- resources/ (directory) |
| 100 | +- stackpack.conf (file) |
| 101 | +
|
| 102 | +The zip file is named <stackpack_name>-<version>.zip where the name and |
| 103 | +version are extracted from stackpack.conf and created in the current directory.`, |
| 104 | + Example: `# Package stackpack in current directory |
| 105 | +sts stackpack package |
| 106 | +
|
| 107 | +# Package specific stackpack directory |
| 108 | +sts stackpack package -d ./my-stackpack |
| 109 | +
|
| 110 | +# Package with custom archive filename |
| 111 | +sts stackpack package -f my-custom-archive.zip |
| 112 | +
|
| 113 | +# Force overwrite existing zip file |
| 114 | +sts stackpack package --force`, |
| 115 | + RunE: cli.CmdRunE(RunStackpackPackageCommand(args)), |
| 116 | + } |
| 117 | + |
| 118 | + cmd.Flags().StringVarP(&args.StackpackDir, "stackpack-directory", "d", "", "Path to stackpack directory (defaults to current directory)") |
| 119 | + cmd.Flags().StringVarP(&args.ArchiveFile, "archive-file", "f", "", "Path to the zip file to create (defaults to <stackpack_name>-<version>.zip in current directory)") |
| 120 | + cmd.Flags().BoolVar(&args.Force, "force", false, "Overwrite existing zip file without prompting") |
| 121 | + |
| 122 | + return cmd |
| 123 | +} |
| 124 | + |
| 125 | +// RunStackpackPackageCommand executes the package command |
| 126 | +func RunStackpackPackageCommand(args *PackageArgs) func(cli *di.Deps, cmd *cobra.Command) common.CLIError { |
| 127 | + return func(cli *di.Deps, cmd *cobra.Command) common.CLIError { |
| 128 | + // Set default stackpack directory |
| 129 | + if args.StackpackDir == "" { |
| 130 | + currentDir, err := os.Getwd() |
| 131 | + if err != nil { |
| 132 | + return common.NewRuntimeError(fmt.Errorf("failed to get current working directory: %w", err)) |
| 133 | + } |
| 134 | + args.StackpackDir = currentDir |
| 135 | + } |
| 136 | + |
| 137 | + // Convert to absolute path |
| 138 | + absStackpackDir, err := filepath.Abs(args.StackpackDir) |
| 139 | + if err != nil { |
| 140 | + return common.NewRuntimeError(fmt.Errorf("failed to get absolute path for stackpack directory: %w", err)) |
| 141 | + } |
| 142 | + args.StackpackDir = absStackpackDir |
| 143 | + |
| 144 | + // Parse stackpack.conf using HOCON parser to get name and version |
| 145 | + parser := &HoconParser{} |
| 146 | + stackpackInfo, err := parser.Parse(filepath.Join(args.StackpackDir, "stackpack.conf")) |
| 147 | + if err != nil { |
| 148 | + return common.NewRuntimeError(fmt.Errorf("failed to parse stackpack.conf: %w", err)) |
| 149 | + } |
| 150 | + |
| 151 | + // Set default archive file path if not specified |
| 152 | + if args.ArchiveFile == "" { |
| 153 | + currentDir, err := os.Getwd() |
| 154 | + if err != nil { |
| 155 | + return common.NewRuntimeError(fmt.Errorf("failed to get current working directory: %w", err)) |
| 156 | + } |
| 157 | + zipFileName := fmt.Sprintf("%s-%s.zip", stackpackInfo.Name, stackpackInfo.Version) |
| 158 | + args.ArchiveFile = filepath.Join(currentDir, zipFileName) |
| 159 | + } else { |
| 160 | + // Convert to absolute path |
| 161 | + absArchiveFile, err := filepath.Abs(args.ArchiveFile) |
| 162 | + if err != nil { |
| 163 | + return common.NewRuntimeError(fmt.Errorf("failed to get absolute path for archive file: %w", err)) |
| 164 | + } |
| 165 | + args.ArchiveFile = absArchiveFile |
| 166 | + } |
| 167 | + |
| 168 | + // Validate stackpack directory |
| 169 | + if err := validateStackpackDirectory(args.StackpackDir); err != nil { |
| 170 | + return common.NewCLIArgParseError(err) |
| 171 | + } |
| 172 | + |
| 173 | + // Check if zip file exists and handle force flag |
| 174 | + if _, err := os.Stat(args.ArchiveFile); err == nil && !args.Force { |
| 175 | + return common.NewRuntimeError(fmt.Errorf("zip file already exists: %s (use --force to overwrite)", args.ArchiveFile)) |
| 176 | + } |
| 177 | + |
| 178 | + // Create output directory if it doesn't exist |
| 179 | + outputDir := filepath.Dir(args.ArchiveFile) |
| 180 | + if err := os.MkdirAll(outputDir, os.FileMode(defaultDirMode)); err != nil { |
| 181 | + return common.NewRuntimeError(fmt.Errorf("failed to create output directory: %w", err)) |
| 182 | + } |
| 183 | + |
| 184 | + // Create zip file |
| 185 | + if err := createStackpackZip(args.StackpackDir, args.ArchiveFile); err != nil { |
| 186 | + return common.NewRuntimeError(fmt.Errorf("failed to create zip file: %w", err)) |
| 187 | + } |
| 188 | + |
| 189 | + if cli.IsJson() { |
| 190 | + cli.Printer.PrintJson(map[string]interface{}{ |
| 191 | + "success": true, |
| 192 | + "stackpack_name": stackpackInfo.Name, |
| 193 | + "stackpack_version": stackpackInfo.Version, |
| 194 | + "zip_file": args.ArchiveFile, |
| 195 | + "source_dir": args.StackpackDir, |
| 196 | + }) |
| 197 | + } else { |
| 198 | + cli.Printer.Successf("✓ Stackpack packaged successfully!") |
| 199 | + cli.Printer.PrintLn("") |
| 200 | + cli.Printer.PrintLn(fmt.Sprintf("Stackpack: %s (v%s)", stackpackInfo.Name, stackpackInfo.Version)) |
| 201 | + cli.Printer.PrintLn(fmt.Sprintf("Zip file: %s", args.ArchiveFile)) |
| 202 | + } |
| 203 | + |
| 204 | + return nil |
| 205 | + } |
| 206 | +} |
| 207 | + |
| 208 | +func validateStackpackDirectory(dir string) error { |
| 209 | + for _, item := range requiredStackpackItems { |
| 210 | + itemPath := filepath.Join(dir, item) |
| 211 | + if _, err := os.Stat(itemPath); err != nil { |
| 212 | + if os.IsNotExist(err) { |
| 213 | + return fmt.Errorf("required stackpack item not found: %s", item) |
| 214 | + } |
| 215 | + return fmt.Errorf("failed to check stackpack item %s: %w", item, err) |
| 216 | + } |
| 217 | + } |
| 218 | + return nil |
| 219 | +} |
| 220 | + |
| 221 | +func createStackpackZip(sourceDir, zipPath string) error { |
| 222 | + zipFile, err := os.Create(zipPath) |
| 223 | + if err != nil { |
| 224 | + return fmt.Errorf("failed to create zip file: %w", err) |
| 225 | + } |
| 226 | + defer zipFile.Close() |
| 227 | + |
| 228 | + zipWriter := zip.NewWriter(zipFile) |
| 229 | + defer zipWriter.Close() |
| 230 | + |
| 231 | + // Add each required item to the zip |
| 232 | + for _, item := range requiredStackpackItems { |
| 233 | + itemPath := filepath.Join(sourceDir, item) |
| 234 | + if err := addToZip(zipWriter, itemPath, item); err != nil { |
| 235 | + return fmt.Errorf("failed to add %s to zip: %w", item, err) |
| 236 | + } |
| 237 | + } |
| 238 | + |
| 239 | + return nil |
| 240 | +} |
| 241 | + |
| 242 | +func addToZip(zipWriter *zip.Writer, sourcePath, zipPath string) error { |
| 243 | + fileInfo, err := os.Stat(sourcePath) |
| 244 | + if err != nil { |
| 245 | + return err |
| 246 | + } |
| 247 | + |
| 248 | + if fileInfo.IsDir() { |
| 249 | + return addDirToZip(zipWriter, sourcePath, zipPath) |
| 250 | + } |
| 251 | + |
| 252 | + return addFileToZip(zipWriter, sourcePath, zipPath) |
| 253 | +} |
| 254 | + |
| 255 | +func addFileToZip(zipWriter *zip.Writer, sourcePath, zipPath string) error { |
| 256 | + file, err := os.Open(sourcePath) |
| 257 | + if err != nil { |
| 258 | + return err |
| 259 | + } |
| 260 | + defer file.Close() |
| 261 | + |
| 262 | + zipFileWriter, err := zipWriter.Create(zipPath) |
| 263 | + if err != nil { |
| 264 | + return err |
| 265 | + } |
| 266 | + |
| 267 | + _, err = io.Copy(zipFileWriter, file) |
| 268 | + return err |
| 269 | +} |
| 270 | + |
| 271 | +func addDirToZip(zipWriter *zip.Writer, sourceDir, zipDir string) error { |
| 272 | + return filepath.Walk(sourceDir, func(filePath string, fileInfo os.FileInfo, err error) error { |
| 273 | + if err != nil { |
| 274 | + return err |
| 275 | + } |
| 276 | + |
| 277 | + // Get relative path from source directory |
| 278 | + relPath, err := filepath.Rel(sourceDir, filePath) |
| 279 | + if err != nil { |
| 280 | + return err |
| 281 | + } |
| 282 | + |
| 283 | + // Skip the root directory itself |
| 284 | + if relPath == "." { |
| 285 | + return nil |
| 286 | + } |
| 287 | + |
| 288 | + // Create zip path with forward slashes for cross-platform compatibility |
| 289 | + zipPath := filepath.ToSlash(filepath.Join(zipDir, relPath)) |
| 290 | + |
| 291 | + if fileInfo.IsDir() { |
| 292 | + // Create directory entry in zip |
| 293 | + _, err := zipWriter.Create(zipPath + "/") |
| 294 | + return err |
| 295 | + } |
| 296 | + |
| 297 | + // Add file to zip |
| 298 | + return addFileToZip(zipWriter, filePath, zipPath) |
| 299 | + }) |
| 300 | +} |
0 commit comments