Skip to content

Commit 87afbb8

Browse files
authored
STAC-22609: Adding stackpack package command (#111)
1 parent 8b112e7 commit 87afbb8

File tree

6 files changed

+909
-7
lines changed

6 files changed

+909
-7
lines changed

cmd/stackpack.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import (
88
"github.com/stackvista/stackstate-cli/internal/di"
99
)
1010

11+
const (
12+
experimentalStackpackEnvVar = "STS_EXPERIMENTAL_STACKPACK"
13+
)
14+
1115
func StackPackCommand(cli *di.Deps) *cobra.Command {
1216
cmd := &cobra.Command{
1317
Use: "stackpack",
@@ -24,9 +28,10 @@ func StackPackCommand(cli *di.Deps) *cobra.Command {
2428
cmd.AddCommand(stackpack.StackpackConfirmManualStepsCommand(cli))
2529
cmd.AddCommand(stackpack.StackpackDescribeCommand(cli))
2630

27-
// Only add scaffold command if experimental feature is enabled
28-
if os.Getenv("STS_EXPERIMENTAL_STACKPACK_SCAFFOLD") != "" {
31+
// The not-production-ready commands
32+
if os.Getenv(experimentalStackpackEnvVar) != "" {
2933
cmd.AddCommand(stackpack.StackpackScaffoldCommand(cli))
34+
cmd.AddCommand(stackpack.StackpackPackageCommand(cli))
3035
}
3136

3237
return cmd

cmd/stackpack/stackpack_package.go

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
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

Comments
 (0)