Skip to content
Open
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
135 changes: 135 additions & 0 deletions cmd/dryrun.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package cmd

import (
"fmt"
"strings"

"github.com/fatih/color"
"github.com/krkn-chaos/krknctl/pkg/provider/models"
"github.com/krkn-chaos/krknctl/pkg/typing"
)

const dryRunClientValue = "client"

// DryRunResult holds the outcome of a local validation pass.
type DryRunResult struct {
Valid bool
Messages []dryRunMessage
}

type dryRunMessage struct {
ok bool
text string
}

func (r *DryRunResult) addOK(msg string) {
r.Messages = append(r.Messages, dryRunMessage{ok: true, text: msg})
}

func (r *DryRunResult) addFail(msg string) {
r.Valid = false
r.Messages = append(r.Messages, dryRunMessage{ok: false, text: msg})
}

// Print writes the validation results to stdout using the project colour scheme.
func (r *DryRunResult) Print() {
green := color.New(color.FgGreen).SprintFunc()
red := color.New(color.FgHiRed).SprintFunc()
for _, m := range r.Messages {
if m.ok {
fmt.Printf("%s %s\n", green("✔"), m.text)
} else {
fmt.Printf("%s %s\n", red("✖"), m.text)
}
}
}

// parseDryRunFlag extracts the --dry-run flag value from raw args.
// Returns ("", false, nil) when the flag is absent.
// Returns an error when the flag is present but has an unsupported value.
func parseDryRunFlag(args []string) (string, bool, error) {
value, found, err := ParseArgValue(args, "--dry-run")
if err != nil {
return "", false, fmt.Errorf("--dry-run: %w", err)
}
if !found {
return "", false, nil
}
if strings.ToLower(value) != dryRunClientValue {
return "", false, fmt.Errorf("--dry-run only accepts %q, got %q", dryRunClientValue, value)
}
return value, true, nil
}

// validateScenarioLocally performs client-side validation of a scenario's
// input fields against the values supplied in args.
// It never contacts the cluster, the container runtime, or the image registry.
//
// scenarioDetail and globalDetail may be nil (e.g. when the registry is
// unreachable in dry-run mode); in that case only the args themselves are
// checked for well-formedness.
func validateScenarioLocally(
scenarioDetail *models.ScenarioDetail,
globalDetail *models.ScenarioDetail,
args []string,
) *DryRunResult {
result := &DryRunResult{Valid: true}

// ── 1. schema present ────────────────────────────────────────────────────
if scenarioDetail == nil {
result.addFail("scenario schema not found")
return result
}
result.addOK("Scenario schema valid")

// Build a single flat slice of all fields without mutating the originals.
// Using append on a nil/empty base avoids the capacity-aliasing bug where
// append(scenarioDetail.Fields, ...) could overwrite scenarioDetail.Fields.
var allFields []typing.InputField
allFields = append(allFields, scenarioDetail.Fields...)
if globalDetail != nil {
allFields = append(allFields, globalDetail.Fields...)
}

// ── 2. required fields present ───────────────────────────────────────────
missingRequired := false
for _, field := range allFields {
if !field.Required {
continue
}
flagName := fmt.Sprintf("--%s", *field.Name)
_, found, _ := ParseArgValue(args, flagName)
hasDefault := field.Default != nil && *field.Default != ""
if !found && !hasDefault {
result.addFail(fmt.Sprintf("Missing required field: %s", *field.Name))
missingRequired = true
}
}
if !missingRequired {
result.addOK("All required fields present")
}

// ── 3. validate each supplied value ──────────────────────────────────────
validationFailed := false
for _, field := range allFields {
flagName := fmt.Sprintf("--%s", *field.Name)
rawValue, found, err := ParseArgValue(args, flagName)
if err != nil {
result.addFail(fmt.Sprintf("Flag parse error for %s: %v", *field.Name, err))
validationFailed = true
continue
}
if !found {
continue // not supplied — required check already handled above
}
if _, err := field.Validate(&rawValue); err != nil {
result.addFail(fmt.Sprintf("Invalid value for %s (%s): %v", *field.Name, field.Type.String(), err))
validationFailed = true
}
}
if !validationFailed {
result.addOK("Values validated")
}

return result
}
217 changes: 217 additions & 0 deletions cmd/dryrun_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
package cmd

import (
"strings"
"testing"

"github.com/krkn-chaos/krknctl/pkg/provider/models"
"github.com/krkn-chaos/krknctl/pkg/typing"
"github.com/stretchr/testify/assert"
)

// ── helpers ──────────────────────────────────────────────────────────────────

func strPtr(s string) *string { return &s }

// makeField builds a typing.InputField directly without JSON round-tripping,
// so Required and Default are never silently reset to zero values.
func makeField(name, variable string, typ typing.Type, required bool, defaultVal *string) typing.InputField {
return typing.InputField{
Name: strPtr(name),
Variable: strPtr(variable),
Description: strPtr(name + " description"),
Type: typ,
Required: required,
Default: defaultVal,
}
}

func emptyGlobal() *models.ScenarioDetail {
return &models.ScenarioDetail{}
}

// ── parseDryRunFlag ───────────────────────────────────────────────────────────

func TestParseDryRunFlag_NotPresent(t *testing.T) {
_, found, err := parseDryRunFlag([]string{"my-scenario", "--kubeconfig", "/tmp/kube"})
assert.Nil(t, err)
assert.False(t, found)
}

func TestParseDryRunFlag_ValidClient(t *testing.T) {
_, found, err := parseDryRunFlag([]string{"my-scenario", "--dry-run=client"})
assert.Nil(t, err)
assert.True(t, found)
}

func TestParseDryRunFlag_ValidClientSpaceSeparated(t *testing.T) {
_, found, err := parseDryRunFlag([]string{"my-scenario", "--dry-run", "client"})
assert.Nil(t, err)
assert.True(t, found)
}

func TestParseDryRunFlag_InvalidValue(t *testing.T) {
_, found, err := parseDryRunFlag([]string{"my-scenario", "--dry-run=server"})
assert.NotNil(t, err)
assert.False(t, found)
assert.Contains(t, err.Error(), "client")
}

func TestParseDryRunFlag_MissingValue(t *testing.T) {
_, found, err := parseDryRunFlag([]string{"my-scenario", "--dry-run"})
assert.NotNil(t, err)
assert.False(t, found)
}

// ── validateScenarioLocally ───────────────────────────────────────────────────

func TestValidateScenarioLocally_NilScenario(t *testing.T) {
result := validateScenarioLocally(nil, emptyGlobal(), []string{})
assert.False(t, result.Valid)
assertHasFail(t, result, "schema not found")
}

func TestValidateScenarioLocally_ValidConfig(t *testing.T) {
dur := "10"
scenario := &models.ScenarioDetail{
Fields: []typing.InputField{
makeField("duration", "DURATION", typing.Number, true, &dur),
},
}
args := []string{"my-scenario", "--duration=30"}
result := validateScenarioLocally(scenario, emptyGlobal(), args)
assert.True(t, result.Valid)
assertHasOK(t, result, "Scenario schema valid")
assertHasOK(t, result, "All required fields present")
assertHasOK(t, result, "Values validated")
}

func TestValidateScenarioLocally_MissingRequiredField(t *testing.T) {
scenario := &models.ScenarioDetail{
Fields: []typing.InputField{
makeField("namespace", "NAMESPACE", typing.String, true, nil),
},
}
result := validateScenarioLocally(scenario, emptyGlobal(), []string{"my-scenario"})
assert.False(t, result.Valid)
assertHasFail(t, result, "namespace")
}

func TestValidateScenarioLocally_RequiredFieldWithDefault_Valid(t *testing.T) {
def := "default-ns"
scenario := &models.ScenarioDetail{
Fields: []typing.InputField{
makeField("namespace", "NAMESPACE", typing.String, true, &def),
},
}
// no --namespace supplied; default covers the required constraint
result := validateScenarioLocally(scenario, emptyGlobal(), []string{"my-scenario"})
assert.True(t, result.Valid)
}

func TestValidateScenarioLocally_InvalidType_Number(t *testing.T) {
scenario := &models.ScenarioDetail{
Fields: []typing.InputField{
makeField("duration", "DURATION", typing.Number, false, nil),
},
}
args := []string{"my-scenario", "--duration=notanumber"}
result := validateScenarioLocally(scenario, emptyGlobal(), args)
assert.False(t, result.Valid)
assertHasFail(t, result, "duration")
}

func TestValidateScenarioLocally_InvalidType_Boolean(t *testing.T) {
scenario := &models.ScenarioDetail{
Fields: []typing.InputField{
makeField("verbose", "VERBOSE", typing.Boolean, false, nil),
},
}
args := []string{"my-scenario", "--verbose=maybe"}
result := validateScenarioLocally(scenario, emptyGlobal(), args)
assert.False(t, result.Valid)
assertHasFail(t, result, "verbose")
}

func TestValidateScenarioLocally_GlobalFieldsValidated(t *testing.T) {
scenario := &models.ScenarioDetail{}
global := &models.ScenarioDetail{
Fields: []typing.InputField{
makeField("log-level", "LOG_LEVEL", typing.Number, false, nil),
},
}
args := []string{"my-scenario", "--log-level=bad"}
result := validateScenarioLocally(scenario, global, args)
assert.False(t, result.Valid)
assertHasFail(t, result, "log-level")
}

func TestValidateScenarioLocally_NilGlobalDetail(t *testing.T) {
scenario := &models.ScenarioDetail{
Fields: []typing.InputField{
makeField("duration", "DURATION", typing.Number, false, nil),
},
}
// nil globalDetail must not panic
result := validateScenarioLocally(scenario, nil, []string{"my-scenario", "--duration=5"})
assert.True(t, result.Valid)
}

func TestValidateScenarioLocally_MultipleErrors(t *testing.T) {
scenario := &models.ScenarioDetail{
Fields: []typing.InputField{
makeField("namespace", "NAMESPACE", typing.String, true, nil),
makeField("duration", "DURATION", typing.Number, true, nil),
},
}
// both required, neither supplied
result := validateScenarioLocally(scenario, emptyGlobal(), []string{"my-scenario"})
assert.False(t, result.Valid)
failCount := 0
for _, m := range result.Messages {
if !m.ok {
failCount++
}
}
assert.Equal(t, 2, failCount)
}

// ── DryRunResult.Print (smoke test — just ensure no panic) ───────────────────

func TestDryRunResult_Print_NoFields(t *testing.T) {
r := &DryRunResult{Valid: true}
assert.NotPanics(t, func() { r.Print() })
}

func TestDryRunResult_Print_Mixed(t *testing.T) {
r := &DryRunResult{Valid: false}
r.addOK("Scenario schema valid")
r.addFail("Missing required field: namespace")
assert.NotPanics(t, func() { r.Print() })
}

// ── assertion helpers ─────────────────────────────────────────────────────────

func assertHasOK(t *testing.T, r *DryRunResult, substr string) {
t.Helper()
for _, m := range r.Messages {
if m.ok && containsCI(m.text, substr) {
return
}
}
t.Errorf("expected an OK message containing %q, got: %+v", substr, r.Messages)
}

func assertHasFail(t *testing.T, r *DryRunResult, substr string) {
t.Helper()
for _, m := range r.Messages {
if !m.ok && containsCI(m.text, substr) {
return
}
}
t.Errorf("expected a FAIL message containing %q, got: %+v", substr, r.Messages)
}

func containsCI(s, sub string) bool {
return strings.Contains(strings.ToLower(s), strings.ToLower(sub))
}
2 changes: 1 addition & 1 deletion cmd/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ func NewGraphRunCommand(factory *providerfactory.ProviderFactory, scenarioOrches
commChannel := make(chan *models.GraphCommChannel)

go func() {
(*scenarioOrchestrator).RunGraph(nodes, executionPlan, environment, volumes, false, commChannel, registrySettings, nil)
(*scenarioOrchestrator).RunGraph(nodes, executionPlan, environment, volumes, false, commChannel, registrySettings, nil, "")
}()

for {
Expand Down
Loading