Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/stackpack.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ func StackPackCommand(cli *di.Deps) *cobra.Command {
cmd.AddCommand(stackpack.StackpackScaffoldCommand(cli))
cmd.AddCommand(stackpack.StackpackPackageCommand(cli))
cmd.AddCommand(stackpack.StackpackTestDeployCommand(cli))
cmd.AddCommand(stackpack.StackpackValidateCommand(cli))
}

return cmd
Expand Down
153 changes: 153 additions & 0 deletions cmd/stackpack/stackpack_validate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package stackpack

import (
"fmt"
"os"
"path/filepath"

"github.com/spf13/cobra"
"github.com/stackvista/stackstate-cli/generated/stackstate_api"
"github.com/stackvista/stackstate-cli/internal/common"
"github.com/stackvista/stackstate-cli/internal/di"
)

// ValidateArgs contains arguments for stackpack validate command
type ValidateArgs struct {
StackpackDir string
StackpackFile string
}

// StackpackValidateCommand creates the validate subcommand
func StackpackValidateCommand(cli *di.Deps) *cobra.Command {
return stackpackValidateCommandWithArgs(cli, &ValidateArgs{})
}

// stackpackValidateCommandWithArgs creates the validate command with injected args (for testing)
func stackpackValidateCommandWithArgs(cli *di.Deps, args *ValidateArgs) *cobra.Command {
cmd := &cobra.Command{
Use: "validate",
Short: "Validate a stackpack",
Long: `Validate a stackpack against a SUSE Observability server.

This command validates a stackpack by uploading it to the server.
- If a directory is provided, it is automatically packaged into a .sts file before uploading
- If a .sts file is provided, it is uploaded directly

Exactly one of --stackpack-directory or --stackpack-file must be specified.

This command is experimental and requires STS_EXPERIMENTAL_STACKPACK environment variable to be set.`,
Example: `# Validate a stackpack directory (automatically packaged)
sts stackpack validate --stackpack-directory ./my-stackpack

# Validate a pre-packaged .sts file
sts stackpack validate --stackpack-file ./my-stackpack.sts`,
RunE: cli.CmdRunEWithApi(RunStackpackValidateCommand(args)),
}

cmd.Flags().StringVarP(&args.StackpackDir, "stackpack-directory", "d", "", "Path to stackpack directory")
cmd.Flags().StringVarP(&args.StackpackFile, "stackpack-file", "f", "", "Path to .sts file")

return cmd
}

// RunStackpackValidateCommand executes the validate command
func RunStackpackValidateCommand(args *ValidateArgs) di.CmdWithApiFn {
return func(
cmd *cobra.Command,
cli *di.Deps,
api *stackstate_api.APIClient,
serverInfo *stackstate_api.ServerInfo,
) common.CLIError {
// Validate exactly one of directory or file is set
if (args.StackpackDir == "" && args.StackpackFile == "") ||
(args.StackpackDir != "" && args.StackpackFile != "") {
return common.NewCLIArgParseError(fmt.Errorf("exactly one of --stackpack-directory or --stackpack-file must be specified"))
}

// Prepare file to validate - if directory is provided, package it first
fileToValidate, cleanup, err := prepareStackpackFile(args)
if err != nil {
return err
}
defer cleanup()

// Open the file
file, openErr := os.Open(fileToValidate)
if openErr != nil {
return common.NewRuntimeError(fmt.Errorf("failed to open stackpack file: %w", openErr))
}
defer file.Close()

// Call validate endpoint
result, resp, validateErr := api.StackpackApi.StackPackValidate(cli.Context).StackPack(file).Execute()
if validateErr != nil {
return common.NewResponseError(validateErr, resp)
}

if cli.IsJson() {
cli.Printer.PrintJson(map[string]interface{}{
"success": true,
"result": result,
})
} else {
cli.Printer.Success("Stackpack validation successful!")
if result != "" {
fmt.Println(result)
}
}

return nil
}
}

// prepareStackpackFile returns the path to the stackpack file to validate.
// If a directory is provided, it packages it into a temporary .sts file.
// Returns the file path and a cleanup function that should be deferred.
func prepareStackpackFile(args *ValidateArgs) (string, func(), common.CLIError) {
if args.StackpackFile != "" {
// Use provided .sts file directly
if _, err := os.Stat(args.StackpackFile); err != nil {
return "", func() {}, common.NewRuntimeError(fmt.Errorf("failed to access stackpack file: %w", err))
}
return args.StackpackFile, func() {}, nil
}

// Package the directory
absDir, err := filepath.Abs(args.StackpackDir)
if err != nil {
return "", func() {}, common.NewRuntimeError(fmt.Errorf("failed to resolve stackpack directory: %w", err))
}

// Validate stackpack directory
if err := validateStackpackDirectory(absDir); err != nil {
return "", func() {}, common.NewCLIArgParseError(err)
}

// Parse stackpack info
parser := &YamlParser{}
stackpackInfo, err := parser.Parse(filepath.Join(absDir, "stackpack.yaml"))
if err != nil {
return "", func() {}, common.NewRuntimeError(fmt.Errorf("failed to parse stackpack.yaml: %w", err))
}

// Create temporary .sts file
tmpFile, err := os.CreateTemp("", fmt.Sprintf("%s-*.sts", stackpackInfo.Name))
if err != nil {
return "", func() {}, common.NewRuntimeError(fmt.Errorf("failed to create temporary file: %w", err))
}
tmpFile.Close()
tmpPath := tmpFile.Name()

// Package stackpack into temporary file
if err := createStackpackZip(absDir, tmpPath); err != nil {
os.Remove(tmpPath) // Clean up on error
return "", func() {}, common.NewRuntimeError(fmt.Errorf("failed to package stackpack: %w", err))
}

// Return cleanup function that removes the temporary file
cleanup := func() {
os.Remove(tmpPath)
}

return tmpPath, cleanup, nil
}
214 changes: 214 additions & 0 deletions cmd/stackpack/stackpack_validate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package stackpack

import (
"fmt"
"os"
"path/filepath"
"testing"

"github.com/spf13/cobra"
"github.com/stackvista/stackstate-cli/internal/config"
"github.com/stackvista/stackstate-cli/internal/di"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// setupValidateCmd creates a test command with API context
func setupValidateCmd(t *testing.T) (*di.MockDeps, *cobra.Command) {
cli := di.NewMockDeps(t)
cfg := &config.Config{
CurrentContext: "test-context",
Contexts: []*config.NamedContext{
{
Name: "test-context",
Context: &config.StsContext{
URL: "https://test-server.example.com",
APIPath: "/api",
},
},
},
}
cli.ConfigPath = filepath.Join(t.TempDir(), "config.yaml")
err := config.WriteConfig(cli.ConfigPath, cfg)
require.NoError(t, err)

cmd := StackpackValidateCommand(&cli.Deps)
return &cli, cmd
}

// createTestStackpackDir creates a minimal stackpack directory with required items
func createTestStackpackDir(t *testing.T, dir string, name string, version string) {
require.NoError(t, os.MkdirAll(filepath.Join(dir, "settings"), 0755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "resources"), 0755))

stackpackConf := fmt.Sprintf(`name: "%s"
version: "%s"
`, name, version)
require.NoError(t, os.WriteFile(filepath.Join(dir, "stackpack.yaml"), []byte(stackpackConf), 0644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Test Stackpack"), 0644))
}

// ===== Tests =====

func TestValidate_WithDirectory_AutoPackages(t *testing.T) {
cli, cmd := setupValidateCmd(t)

tempDir, err := os.MkdirTemp("", "stackpack-validate-test-*")
require.NoError(t, err)
defer os.RemoveAll(tempDir)

stackpackDir := filepath.Join(tempDir, "test-stackpack")
require.NoError(t, os.MkdirAll(stackpackDir, 0755))
createTestStackpackDir(t, stackpackDir, "test-stackpack", "1.0.0")

_, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "--stackpack-directory", stackpackDir)
require.NoError(t, err)

// Verify success message
require.NotEmpty(t, *cli.MockPrinter.SuccessCalls)
successCall := (*cli.MockPrinter.SuccessCalls)[0]
assert.Contains(t, successCall, "validation successful")
}

func TestValidate_WithDirectory_InvalidStackpack(t *testing.T) {
cli, cmd := setupValidateCmd(t)

tempDir, err := os.MkdirTemp("", "stackpack-validate-test-*")
require.NoError(t, err)
defer os.RemoveAll(tempDir)

// Create directory with missing required items
stackpackDir := filepath.Join(tempDir, "invalid-stackpack")
require.NoError(t, os.MkdirAll(stackpackDir, 0755))

_, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "--stackpack-directory", stackpackDir)
require.Error(t, err)
assert.Contains(t, err.Error(), "required stackpack item not found")
}

func TestValidate_WithDirectory_MissingStackpackYaml(t *testing.T) {
cli, cmd := setupValidateCmd(t)

tempDir, err := os.MkdirTemp("", "stackpack-validate-test-*")
require.NoError(t, err)
defer os.RemoveAll(tempDir)

stackpackDir := filepath.Join(tempDir, "test-stackpack")
require.NoError(t, os.MkdirAll(filepath.Join(stackpackDir, "settings"), 0755))
require.NoError(t, os.MkdirAll(filepath.Join(stackpackDir, "resources"), 0755))
require.NoError(t, os.WriteFile(filepath.Join(stackpackDir, "README.md"), []byte("test"), 0644))

_, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "--stackpack-directory", stackpackDir)
require.Error(t, err)
assert.Contains(t, err.Error(), "required stackpack item not found")
}

func TestValidate_WithPrePackagedFile(t *testing.T) {
cli, cmd := setupValidateCmd(t)

tempDir, err := os.MkdirTemp("", "stackpack-validate-test-*")
require.NoError(t, err)
defer os.RemoveAll(tempDir)

// Create a pre-packaged .sts file
stackpackFile := filepath.Join(tempDir, "test.sts")
require.NoError(t, os.WriteFile(stackpackFile, []byte("test stackpack content"), 0644))

_, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "--stackpack-file", stackpackFile)
require.NoError(t, err)

// Verify success message
require.NotEmpty(t, *cli.MockPrinter.SuccessCalls)
successCall := (*cli.MockPrinter.SuccessCalls)[0]
assert.Contains(t, successCall, "validation successful")
}

func TestValidate_JSONOutput(t *testing.T) {
cli, cmd := setupValidateCmd(t)

tempDir, err := os.MkdirTemp("", "stackpack-validate-test-*")
require.NoError(t, err)
defer os.RemoveAll(tempDir)

stackpackFile := filepath.Join(tempDir, "test.sts")
require.NoError(t, os.WriteFile(stackpackFile, []byte("test content"), 0644))

_, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "--stackpack-file", stackpackFile, "-o", "json")
require.NoError(t, err)

// Verify JSON was called
require.Len(t, *cli.MockPrinter.PrintJsonCalls, 1)
jsonOutput := (*cli.MockPrinter.PrintJsonCalls)[0]

assert.Equal(t, true, jsonOutput["success"])
}

func TestValidate_MissingPath(t *testing.T) {
cli, cmd := setupValidateCmd(t)

_, err := di.ExecuteCommandWithContext(&cli.Deps, cmd)
require.Error(t, err)
assert.Contains(t, err.Error(), "exactly one of")
}

func TestValidate_MutuallyExclusive(t *testing.T) {
cli, cmd := setupValidateCmd(t)

tempDir, err := os.MkdirTemp("", "stackpack-validate-test-*")
require.NoError(t, err)
defer os.RemoveAll(tempDir)

stackpackDir := filepath.Join(tempDir, "stackpack")
require.NoError(t, os.MkdirAll(stackpackDir, 0755))

stackpackFile := filepath.Join(tempDir, "test.sts")
require.NoError(t, os.WriteFile(stackpackFile, []byte("test"), 0644))

_, err = di.ExecuteCommandWithContext(&cli.Deps, cmd,
"-d", stackpackDir,
"-f", stackpackFile)
require.Error(t, err)
assert.Contains(t, err.Error(), "exactly one of")
}

func TestValidate_NonexistentFile(t *testing.T) {
cli, cmd := setupValidateCmd(t)

_, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "--stackpack-file", "/nonexistent/path/file.sts")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to access stackpack file")
}

func TestValidate_WithDirectory_IncludingOptionalItems(t *testing.T) {
cli, cmd := setupValidateCmd(t)

tempDir, err := os.MkdirTemp("", "stackpack-validate-test-*")
require.NoError(t, err)
defer os.RemoveAll(tempDir)

stackpackDir := filepath.Join(tempDir, "test-stackpack")
require.NoError(t, os.MkdirAll(stackpackDir, 0755))
createTestStackpackDir(t, stackpackDir, "test-stackpack", "1.0.0")

// Add optional items
require.NoError(t, os.MkdirAll(filepath.Join(stackpackDir, "icons"), 0755))
require.NoError(t, os.WriteFile(filepath.Join(stackpackDir, "icons", "icon.png"), []byte("fake png"), 0644))
require.NoError(t, os.MkdirAll(filepath.Join(stackpackDir, "includes"), 0755))
require.NoError(t, os.WriteFile(filepath.Join(stackpackDir, "includes", "include.txt"), []byte("include data"), 0644))

_, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "--stackpack-directory", stackpackDir)
require.NoError(t, err)

// Verify success message
require.NotEmpty(t, *cli.MockPrinter.SuccessCalls)
successCall := (*cli.MockPrinter.SuccessCalls)[0]
assert.Contains(t, successCall, "validation successful")
}

func TestValidate_NonexistentDirectory(t *testing.T) {
cli, cmd := setupValidateCmd(t)

_, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "--stackpack-directory", "/nonexistent/stackpack/dir")
require.Error(t, err)
assert.Contains(t, err.Error(), "required stackpack item not found")
}
1 change: 1 addition & 0 deletions generated/stackstate_api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ Class | Method | HTTP request | Description
*StackpackApi* | [**ProvisionUninstall**](docs/StackpackApi.md#provisionuninstall) | **Post** /stackpack/{stackPackName}/deprovision/{stackPackInstanceId} | Provision API
*StackpackApi* | [**StackPackList**](docs/StackpackApi.md#stackpacklist) | **Get** /stackpack | StackPack API
*StackpackApi* | [**StackPackUpload**](docs/StackpackApi.md#stackpackupload) | **Post** /stackpack | StackPack API
*StackpackApi* | [**StackPackValidate**](docs/StackpackApi.md#stackpackvalidate) | **Post** /stackpack/validate | Validate API
*StackpackApi* | [**UpgradeStackPack**](docs/StackpackApi.md#upgradestackpack) | **Post** /stackpack/{stackPackName}/upgrade | Upgrade API
*SubjectApi* | [**CreateSubject**](docs/SubjectApi.md#createsubject) | **Put** /security/subjects/{subject} | Create a subject
*SubjectApi* | [**DeleteSubject**](docs/SubjectApi.md#deletesubject) | **Delete** /security/subjects/{subject} | Delete a subject
Expand Down
Loading