Skip to content

Commit e8c7ab8

Browse files
authored
STAC-24174: add stackpack validate command (#137)
* STAC-24174: add stackpack validate command
1 parent 5ee5d98 commit e8c7ab8

23 files changed

Lines changed: 944 additions & 248 deletions

cmd/stackpack.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func StackPackCommand(cli *di.Deps) *cobra.Command {
3333
cmd.AddCommand(stackpack.StackpackScaffoldCommand(cli))
3434
cmd.AddCommand(stackpack.StackpackPackageCommand(cli))
3535
cmd.AddCommand(stackpack.StackpackTestDeployCommand(cli))
36+
cmd.AddCommand(stackpack.StackpackValidateCommand(cli))
3637
}
3738

3839
return cmd
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package stackpack
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
8+
"github.com/spf13/cobra"
9+
"github.com/stackvista/stackstate-cli/generated/stackstate_api"
10+
"github.com/stackvista/stackstate-cli/internal/common"
11+
"github.com/stackvista/stackstate-cli/internal/di"
12+
)
13+
14+
// ValidateArgs contains arguments for stackpack validate command
15+
type ValidateArgs struct {
16+
StackpackDir string
17+
StackpackFile string
18+
}
19+
20+
// StackpackValidateCommand creates the validate subcommand
21+
func StackpackValidateCommand(cli *di.Deps) *cobra.Command {
22+
return stackpackValidateCommandWithArgs(cli, &ValidateArgs{})
23+
}
24+
25+
// stackpackValidateCommandWithArgs creates the validate command with injected args (for testing)
26+
func stackpackValidateCommandWithArgs(cli *di.Deps, args *ValidateArgs) *cobra.Command {
27+
cmd := &cobra.Command{
28+
Use: "validate",
29+
Short: "Validate a stackpack",
30+
Long: `Validate a stackpack against a SUSE Observability server.
31+
32+
This command validates a stackpack by uploading it to the server.
33+
- If a directory is provided, it is automatically packaged into a .sts file before uploading
34+
- If a .sts file is provided, it is uploaded directly
35+
36+
Exactly one of --stackpack-directory or --stackpack-file must be specified.
37+
38+
This command is experimental and requires STS_EXPERIMENTAL_STACKPACK environment variable to be set.`,
39+
Example: `# Validate a stackpack directory (automatically packaged)
40+
sts stackpack validate --stackpack-directory ./my-stackpack
41+
42+
# Validate a pre-packaged .sts file
43+
sts stackpack validate --stackpack-file ./my-stackpack.sts`,
44+
RunE: cli.CmdRunEWithApi(RunStackpackValidateCommand(args)),
45+
}
46+
47+
cmd.Flags().StringVarP(&args.StackpackDir, "stackpack-directory", "d", "", "Path to stackpack directory")
48+
cmd.Flags().StringVarP(&args.StackpackFile, "stackpack-file", "f", "", "Path to .sts file")
49+
50+
return cmd
51+
}
52+
53+
// RunStackpackValidateCommand executes the validate command
54+
func RunStackpackValidateCommand(args *ValidateArgs) di.CmdWithApiFn {
55+
return func(
56+
cmd *cobra.Command,
57+
cli *di.Deps,
58+
api *stackstate_api.APIClient,
59+
serverInfo *stackstate_api.ServerInfo,
60+
) common.CLIError {
61+
// Validate exactly one of directory or file is set
62+
if (args.StackpackDir == "" && args.StackpackFile == "") ||
63+
(args.StackpackDir != "" && args.StackpackFile != "") {
64+
return common.NewCLIArgParseError(fmt.Errorf("exactly one of --stackpack-directory or --stackpack-file must be specified"))
65+
}
66+
67+
// Prepare file to validate - if directory is provided, package it first
68+
fileToValidate, cleanup, err := prepareStackpackFile(args)
69+
if err != nil {
70+
return err
71+
}
72+
defer cleanup()
73+
74+
// Open the file
75+
file, openErr := os.Open(fileToValidate)
76+
if openErr != nil {
77+
return common.NewRuntimeError(fmt.Errorf("failed to open stackpack file: %w", openErr))
78+
}
79+
defer file.Close()
80+
81+
// Call validate endpoint
82+
result, resp, validateErr := api.StackpackApi.StackPackValidate(cli.Context).StackPack(file).Execute()
83+
if validateErr != nil {
84+
return common.NewResponseError(validateErr, resp)
85+
}
86+
87+
if cli.IsJson() {
88+
cli.Printer.PrintJson(map[string]interface{}{
89+
"success": true,
90+
"result": result,
91+
})
92+
} else {
93+
cli.Printer.Success("Stackpack validation successful!")
94+
if result != "" {
95+
fmt.Println(result)
96+
}
97+
}
98+
99+
return nil
100+
}
101+
}
102+
103+
// prepareStackpackFile returns the path to the stackpack file to validate.
104+
// If a directory is provided, it packages it into a temporary .sts file.
105+
// Returns the file path and a cleanup function that should be deferred.
106+
func prepareStackpackFile(args *ValidateArgs) (string, func(), common.CLIError) {
107+
if args.StackpackFile != "" {
108+
// Use provided .sts file directly
109+
if _, err := os.Stat(args.StackpackFile); err != nil {
110+
return "", func() {}, common.NewRuntimeError(fmt.Errorf("failed to access stackpack file: %w", err))
111+
}
112+
return args.StackpackFile, func() {}, nil
113+
}
114+
115+
// Package the directory
116+
absDir, err := filepath.Abs(args.StackpackDir)
117+
if err != nil {
118+
return "", func() {}, common.NewRuntimeError(fmt.Errorf("failed to resolve stackpack directory: %w", err))
119+
}
120+
121+
// Validate stackpack directory
122+
if err := validateStackpackDirectory(absDir); err != nil {
123+
return "", func() {}, common.NewCLIArgParseError(err)
124+
}
125+
126+
// Parse stackpack info
127+
parser := &YamlParser{}
128+
stackpackInfo, err := parser.Parse(filepath.Join(absDir, "stackpack.yaml"))
129+
if err != nil {
130+
return "", func() {}, common.NewRuntimeError(fmt.Errorf("failed to parse stackpack.yaml: %w", err))
131+
}
132+
133+
// Create temporary .sts file
134+
tmpFile, err := os.CreateTemp("", fmt.Sprintf("%s-*.sts", stackpackInfo.Name))
135+
if err != nil {
136+
return "", func() {}, common.NewRuntimeError(fmt.Errorf("failed to create temporary file: %w", err))
137+
}
138+
tmpFile.Close()
139+
tmpPath := tmpFile.Name()
140+
141+
// Package stackpack into temporary file
142+
if err := createStackpackZip(absDir, tmpPath); err != nil {
143+
os.Remove(tmpPath) // Clean up on error
144+
return "", func() {}, common.NewRuntimeError(fmt.Errorf("failed to package stackpack: %w", err))
145+
}
146+
147+
// Return cleanup function that removes the temporary file
148+
cleanup := func() {
149+
os.Remove(tmpPath)
150+
}
151+
152+
return tmpPath, cleanup, nil
153+
}
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
package stackpack
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/spf13/cobra"
10+
"github.com/stackvista/stackstate-cli/internal/config"
11+
"github.com/stackvista/stackstate-cli/internal/di"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
// setupValidateCmd creates a test command with API context
17+
func setupValidateCmd(t *testing.T) (*di.MockDeps, *cobra.Command) {
18+
cli := di.NewMockDeps(t)
19+
cfg := &config.Config{
20+
CurrentContext: "test-context",
21+
Contexts: []*config.NamedContext{
22+
{
23+
Name: "test-context",
24+
Context: &config.StsContext{
25+
URL: "https://test-server.example.com",
26+
APIPath: "/api",
27+
},
28+
},
29+
},
30+
}
31+
cli.ConfigPath = filepath.Join(t.TempDir(), "config.yaml")
32+
err := config.WriteConfig(cli.ConfigPath, cfg)
33+
require.NoError(t, err)
34+
35+
cmd := StackpackValidateCommand(&cli.Deps)
36+
return &cli, cmd
37+
}
38+
39+
// createTestStackpackDir creates a minimal stackpack directory with required items
40+
func createTestStackpackDir(t *testing.T, dir string, name string, version string) {
41+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "settings"), 0755))
42+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "resources"), 0755))
43+
44+
stackpackConf := fmt.Sprintf(`name: "%s"
45+
version: "%s"
46+
`, name, version)
47+
require.NoError(t, os.WriteFile(filepath.Join(dir, "stackpack.yaml"), []byte(stackpackConf), 0644))
48+
require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Test Stackpack"), 0644))
49+
}
50+
51+
// ===== Tests =====
52+
53+
func TestValidate_WithDirectory_AutoPackages(t *testing.T) {
54+
cli, cmd := setupValidateCmd(t)
55+
56+
tempDir, err := os.MkdirTemp("", "stackpack-validate-test-*")
57+
require.NoError(t, err)
58+
defer os.RemoveAll(tempDir)
59+
60+
stackpackDir := filepath.Join(tempDir, "test-stackpack")
61+
require.NoError(t, os.MkdirAll(stackpackDir, 0755))
62+
createTestStackpackDir(t, stackpackDir, "test-stackpack", "1.0.0")
63+
64+
_, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "--stackpack-directory", stackpackDir)
65+
require.NoError(t, err)
66+
67+
// Verify success message
68+
require.NotEmpty(t, *cli.MockPrinter.SuccessCalls)
69+
successCall := (*cli.MockPrinter.SuccessCalls)[0]
70+
assert.Contains(t, successCall, "validation successful")
71+
}
72+
73+
func TestValidate_WithDirectory_InvalidStackpack(t *testing.T) {
74+
cli, cmd := setupValidateCmd(t)
75+
76+
tempDir, err := os.MkdirTemp("", "stackpack-validate-test-*")
77+
require.NoError(t, err)
78+
defer os.RemoveAll(tempDir)
79+
80+
// Create directory with missing required items
81+
stackpackDir := filepath.Join(tempDir, "invalid-stackpack")
82+
require.NoError(t, os.MkdirAll(stackpackDir, 0755))
83+
84+
_, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "--stackpack-directory", stackpackDir)
85+
require.Error(t, err)
86+
assert.Contains(t, err.Error(), "required stackpack item not found")
87+
}
88+
89+
func TestValidate_WithDirectory_MissingStackpackYaml(t *testing.T) {
90+
cli, cmd := setupValidateCmd(t)
91+
92+
tempDir, err := os.MkdirTemp("", "stackpack-validate-test-*")
93+
require.NoError(t, err)
94+
defer os.RemoveAll(tempDir)
95+
96+
stackpackDir := filepath.Join(tempDir, "test-stackpack")
97+
require.NoError(t, os.MkdirAll(filepath.Join(stackpackDir, "settings"), 0755))
98+
require.NoError(t, os.MkdirAll(filepath.Join(stackpackDir, "resources"), 0755))
99+
require.NoError(t, os.WriteFile(filepath.Join(stackpackDir, "README.md"), []byte("test"), 0644))
100+
101+
_, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "--stackpack-directory", stackpackDir)
102+
require.Error(t, err)
103+
assert.Contains(t, err.Error(), "required stackpack item not found")
104+
}
105+
106+
func TestValidate_WithPrePackagedFile(t *testing.T) {
107+
cli, cmd := setupValidateCmd(t)
108+
109+
tempDir, err := os.MkdirTemp("", "stackpack-validate-test-*")
110+
require.NoError(t, err)
111+
defer os.RemoveAll(tempDir)
112+
113+
// Create a pre-packaged .sts file
114+
stackpackFile := filepath.Join(tempDir, "test.sts")
115+
require.NoError(t, os.WriteFile(stackpackFile, []byte("test stackpack content"), 0644))
116+
117+
_, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "--stackpack-file", stackpackFile)
118+
require.NoError(t, err)
119+
120+
// Verify success message
121+
require.NotEmpty(t, *cli.MockPrinter.SuccessCalls)
122+
successCall := (*cli.MockPrinter.SuccessCalls)[0]
123+
assert.Contains(t, successCall, "validation successful")
124+
}
125+
126+
func TestValidate_JSONOutput(t *testing.T) {
127+
cli, cmd := setupValidateCmd(t)
128+
129+
tempDir, err := os.MkdirTemp("", "stackpack-validate-test-*")
130+
require.NoError(t, err)
131+
defer os.RemoveAll(tempDir)
132+
133+
stackpackFile := filepath.Join(tempDir, "test.sts")
134+
require.NoError(t, os.WriteFile(stackpackFile, []byte("test content"), 0644))
135+
136+
_, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "--stackpack-file", stackpackFile, "-o", "json")
137+
require.NoError(t, err)
138+
139+
// Verify JSON was called
140+
require.Len(t, *cli.MockPrinter.PrintJsonCalls, 1)
141+
jsonOutput := (*cli.MockPrinter.PrintJsonCalls)[0]
142+
143+
assert.Equal(t, true, jsonOutput["success"])
144+
}
145+
146+
func TestValidate_MissingPath(t *testing.T) {
147+
cli, cmd := setupValidateCmd(t)
148+
149+
_, err := di.ExecuteCommandWithContext(&cli.Deps, cmd)
150+
require.Error(t, err)
151+
assert.Contains(t, err.Error(), "exactly one of")
152+
}
153+
154+
func TestValidate_MutuallyExclusive(t *testing.T) {
155+
cli, cmd := setupValidateCmd(t)
156+
157+
tempDir, err := os.MkdirTemp("", "stackpack-validate-test-*")
158+
require.NoError(t, err)
159+
defer os.RemoveAll(tempDir)
160+
161+
stackpackDir := filepath.Join(tempDir, "stackpack")
162+
require.NoError(t, os.MkdirAll(stackpackDir, 0755))
163+
164+
stackpackFile := filepath.Join(tempDir, "test.sts")
165+
require.NoError(t, os.WriteFile(stackpackFile, []byte("test"), 0644))
166+
167+
_, err = di.ExecuteCommandWithContext(&cli.Deps, cmd,
168+
"-d", stackpackDir,
169+
"-f", stackpackFile)
170+
require.Error(t, err)
171+
assert.Contains(t, err.Error(), "exactly one of")
172+
}
173+
174+
func TestValidate_NonexistentFile(t *testing.T) {
175+
cli, cmd := setupValidateCmd(t)
176+
177+
_, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "--stackpack-file", "/nonexistent/path/file.sts")
178+
require.Error(t, err)
179+
assert.Contains(t, err.Error(), "failed to access stackpack file")
180+
}
181+
182+
func TestValidate_WithDirectory_IncludingOptionalItems(t *testing.T) {
183+
cli, cmd := setupValidateCmd(t)
184+
185+
tempDir, err := os.MkdirTemp("", "stackpack-validate-test-*")
186+
require.NoError(t, err)
187+
defer os.RemoveAll(tempDir)
188+
189+
stackpackDir := filepath.Join(tempDir, "test-stackpack")
190+
require.NoError(t, os.MkdirAll(stackpackDir, 0755))
191+
createTestStackpackDir(t, stackpackDir, "test-stackpack", "1.0.0")
192+
193+
// Add optional items
194+
require.NoError(t, os.MkdirAll(filepath.Join(stackpackDir, "icons"), 0755))
195+
require.NoError(t, os.WriteFile(filepath.Join(stackpackDir, "icons", "icon.png"), []byte("fake png"), 0644))
196+
require.NoError(t, os.MkdirAll(filepath.Join(stackpackDir, "includes"), 0755))
197+
require.NoError(t, os.WriteFile(filepath.Join(stackpackDir, "includes", "include.txt"), []byte("include data"), 0644))
198+
199+
_, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "--stackpack-directory", stackpackDir)
200+
require.NoError(t, err)
201+
202+
// Verify success message
203+
require.NotEmpty(t, *cli.MockPrinter.SuccessCalls)
204+
successCall := (*cli.MockPrinter.SuccessCalls)[0]
205+
assert.Contains(t, successCall, "validation successful")
206+
}
207+
208+
func TestValidate_NonexistentDirectory(t *testing.T) {
209+
cli, cmd := setupValidateCmd(t)
210+
211+
_, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "--stackpack-directory", "/nonexistent/stackpack/dir")
212+
require.Error(t, err)
213+
assert.Contains(t, err.Error(), "required stackpack item not found")
214+
}

generated/stackstate_api/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ Class | Method | HTTP request | Description
227227
*StackpackApi* | [**ProvisionUninstall**](docs/StackpackApi.md#provisionuninstall) | **Post** /stackpack/{stackPackName}/deprovision/{stackPackInstanceId} | Provision API
228228
*StackpackApi* | [**StackPackList**](docs/StackpackApi.md#stackpacklist) | **Get** /stackpack | StackPack API
229229
*StackpackApi* | [**StackPackUpload**](docs/StackpackApi.md#stackpackupload) | **Post** /stackpack | StackPack API
230+
*StackpackApi* | [**StackPackValidate**](docs/StackpackApi.md#stackpackvalidate) | **Post** /stackpack/validate | Validate API
230231
*StackpackApi* | [**UpgradeStackPack**](docs/StackpackApi.md#upgradestackpack) | **Post** /stackpack/{stackPackName}/upgrade | Upgrade API
231232
*SubjectApi* | [**CreateSubject**](docs/SubjectApi.md#createsubject) | **Put** /security/subjects/{subject} | Create a subject
232233
*SubjectApi* | [**DeleteSubject**](docs/SubjectApi.md#deletesubject) | **Delete** /security/subjects/{subject} | Delete a subject

0 commit comments

Comments
 (0)