diff --git a/cmd/sloctl/main.go b/cmd/sloctl/main.go index ed0f24a..0d13a22 100644 --- a/cmd/sloctl/main.go +++ b/cmd/sloctl/main.go @@ -5,8 +5,14 @@ // example configuration file can be found in this repository samples/config.toml. package main -import "github.com/nobl9/sloctl/internal" +import ( + "os" + + "github.com/nobl9/sloctl/internal" +) func main() { - internal.Execute() + if err := internal.Execute(); err != nil { + os.Exit(1) + } } diff --git a/cspell.json b/cspell.json index 7df95c8..9871950 100644 --- a/cspell.json +++ b/cspell.json @@ -45,6 +45,7 @@ "golangci", "gosec", "govulncheck", + "govy", "ldflags", "nobl", "openslo", @@ -60,6 +61,7 @@ "unmarshalling", "vuln", "vulns", - "wrapf" + "wrapf", + "yamlenc" ] } \ No newline at end of file diff --git a/go.mod b/go.mod index bab52e0..a953284 100644 --- a/go.mod +++ b/go.mod @@ -3,19 +3,25 @@ module github.com/nobl9/sloctl go 1.25.7 require ( + github.com/BurntSushi/toml v1.6.0 + github.com/OpenSLO/go-sdk v0.9.1 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/exp/teatest v0.0.0-20250930200525-31788bbe6486 github.com/go-playground/validator/v10 v10.30.1 + github.com/goccy/go-yaml v1.19.2 github.com/itchyny/gojq v0.12.18 github.com/mark3labs/mcp-go v0.44.0 github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db github.com/nobl9/go-yaml v1.0.1 + github.com/nobl9/govy v0.23.0 github.com/nobl9/nobl9-go v0.122.2 + github.com/nobl9/nobl9-openslo v0.1.3 github.com/pkg/errors v0.9.1 github.com/schollz/progressbar/v3 v3.19.0 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 github.com/tidwall/sjson v1.2.5 golang.org/x/sync v0.19.0 @@ -23,9 +29,11 @@ require ( require ( github.com/atotto/clipboard v0.1.4 // indirect + github.com/aws/aws-sdk-go v1.55.8 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-udiff v0.3.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect @@ -37,20 +45,36 @@ require ( github.com/charmbracelet/x/term v0.2.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/itchyny/timefmt-go v0.1.7 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/cast v1.7.1 // indirect + github.com/teambition/rrule-go v1.8.2 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect @@ -58,34 +82,6 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect -) - -require ( - github.com/BurntSushi/toml v1.6.0 - github.com/OpenSLO/go-sdk v0.9.1 - github.com/aws/aws-sdk-go v1.55.8 // indirect - github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/fatih/color v1.18.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.12 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/goccy/go-yaml v1.19.2 - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect - github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-retryablehttp v0.7.8 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jmespath/go-jmespath v0.4.0 // indirect - github.com/leodido/go-urn v1.4.0 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/nobl9/govy v0.23.0 // indirect - github.com/nobl9/nobl9-openslo v0.1.3 - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/spf13/pflag v1.0.10 - github.com/teambition/rrule-go v1.8.2 // indirect golang.org/x/crypto v0.46.0 // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/sys v0.39.0 // indirect @@ -95,4 +91,5 @@ require ( golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/internal/builtin_recipes.yaml b/internal/builtin_recipes.yaml new file mode 100644 index 0000000..46419c4 --- /dev/null +++ b/internal/builtin_recipes.yaml @@ -0,0 +1,32 @@ +object-names: + description: Fetch object names + args: [get] + example: | + # Fetch SLO object names + sloctl recipe object-names slo + validators: + atLeastArgs: [kind] + jq: .[].metadata.name +prometheus-slos: + args: + - get + - slo + description: Fetch all prometheus SLOs + jq: | + [ .[] | select( + .spec.objectives[] | + (.rawMetric and .rawMetric.query["prometheus"]) + or + (.countMetrics and .countMetrics.total["prometheus"]) + )] +unique-integrations: + args: + - get + - slo + - -A + description: Display unique integration types for all SLOs + jq: | + [.[] | + first(.. | (.query? // .total?) | + select(type == "object") | + keys[0])] | unique[] diff --git a/internal/convert.go b/internal/convert.go index 2138d02..9b7c8bc 100644 --- a/internal/convert.go +++ b/internal/convert.go @@ -19,6 +19,7 @@ import ( "github.com/nobl9/nobl9-go/sdk" "github.com/nobl9/nobl9-openslo/pkg/openslotonobl9" + "github.com/nobl9/sloctl/internal/jsonbuffer" "github.com/nobl9/sloctl/internal/printer" ) @@ -123,7 +124,7 @@ func (c ConvertCmd) convertOpenSLODefinitions(cmd *cobra.Command) ([]manifest.Ob func (c ConvertCmd) readOpenSLODefinitionsFromSource(def *sdk.RawDefinition) ([]openslo.Object, error) { format := openslosdk.FormatYAML - if isJSONBuffer(def.Definition) { + if jsonbuffer.IsJSON(def.Definition) { format = openslosdk.FormatJSON } objects, err := openslosdk.Decode(bytes.NewReader(def.Definition), format) @@ -164,15 +165,6 @@ func (c ConvertCmd) setManifestSourceForOpenSLOObject(object []byte, basePath, s return object, nil } -var jsonBufferRegex = regexp.MustCompile(`^\s*\[?\s*{`) - -// isJSONBuffer scans the provided buffer, looking for an open brace indicating this is JSON. -// While a simple list like ["a", "b", "c"] is still a valid JSON, -// it does not really concern us when processing complex objects. -func isJSONBuffer(buf []byte) bool { - return jsonBufferRegex.Match(buf) -} - var opensloAPIVersionRegex = regexp.MustCompile(`"?apiVersion"?\s*:\s*"?openslo`) func (c ConvertCmd) filterOpenSLORawDefinitions(definitions []*sdk.RawDefinition) ([]*sdk.RawDefinition, error) { diff --git a/internal/jq/jq.go b/internal/jq/jq.go index 5170048..aac83d7 100644 --- a/internal/jq/jq.go +++ b/internal/jq/jq.go @@ -11,8 +11,8 @@ import ( "github.com/itchyny/gojq" ) -func NewExpressionRunner(config Config) *ExpressionRunner { - return &ExpressionRunner{config: config} +func NewExpressionRunner(config Config) ExpressionRunner { + return ExpressionRunner{config: config} } type ExpressionRunner struct { @@ -23,13 +23,13 @@ type Config struct { Expression string } -func (e *ExpressionRunner) ShouldRun() bool { +func (e ExpressionRunner) ShouldRun() bool { return e.config.Expression != "" } // Evaluate parses, compiles and runs jq expressions. // It returns an iterator which yields jq expression result and error (if any). -func (e *ExpressionRunner) Evaluate(v any) (iter.Seq2[any, error], error) { +func (e ExpressionRunner) Evaluate(v any) (iter.Seq2[any, error], error) { query, err := gojq.Parse(e.config.Expression) if err != nil { var parseErr *gojq.ParseError diff --git a/internal/jsonbuffer/json.go b/internal/jsonbuffer/json.go new file mode 100644 index 0000000..0b8c13b --- /dev/null +++ b/internal/jsonbuffer/json.go @@ -0,0 +1,14 @@ +// Package jsonbuffer is a small utility package which helps identify JSON buffers. +package jsonbuffer + +import "regexp" + +var jsonBufferRegex = regexp.MustCompile(`^\s*\[?\s*{`) + +// IsJSON scans the provided buffer, looking for an open brace indicating this is JSON. +// +// While a simple list like ["a", "b", "c"] is still a valid JSON, +// it does not really concern us when processing complex objects. +func IsJSON(buf []byte) bool { + return jsonBufferRegex.Match(buf) +} diff --git a/internal/jsonbuffer/json_test.go b/internal/jsonbuffer/json_test.go new file mode 100644 index 0000000..e640366 --- /dev/null +++ b/internal/jsonbuffer/json_test.go @@ -0,0 +1,136 @@ +package jsonbuffer + +import "testing" + +func TestIsJSON(t *testing.T) { + tests := []struct { + name string + input []byte + expected bool + }{ + { + name: "valid json object", + input: []byte(`{"key": "value"}`), + expected: true, + }, + { + name: "valid json object with leading whitespace", + input: []byte(` {"key": "value"}`), + expected: true, + }, + { + name: "valid json object with tabs", + input: []byte(` {"key": "value"}`), + expected: true, + }, + { + name: "valid json array of objects", + input: []byte(`[{"key": "value"}]`), + expected: true, + }, + { + name: "valid json array of objects with whitespace", + input: []byte(` [ {"key": "value"}]`), + expected: true, + }, + { + name: "valid json array of objects with mixed whitespace", + input: []byte(` [ {"key": "value"}]`), + expected: true, + }, + { + name: "simple json array", + input: []byte(`["a", "b", "c"]`), + expected: false, + }, + { + name: "simple json array with whitespace", + input: []byte(` ["a", "b", "c"]`), + expected: false, + }, + { + name: "empty json object", + input: []byte(`{}`), + expected: true, + }, + { + name: "empty json array", + input: []byte(`[]`), + expected: false, + }, + { + name: "plain text", + input: []byte(`this is not json`), + expected: false, + }, + { + name: "empty buffer", + input: []byte(``), + expected: false, + }, + { + name: "only whitespace", + input: []byte(` `), + expected: false, + }, + { + name: "json string", + input: []byte(`"hello world"`), + expected: false, + }, + { + name: "json number", + input: []byte(`42`), + expected: false, + }, + { + name: "json boolean", + input: []byte(`true`), + expected: false, + }, + { + name: "json null", + input: []byte(`null`), + expected: false, + }, + { + name: "malformed json starting with brace", + input: []byte(`{invalid json`), + expected: true, + }, + { + name: "nested json object", + input: []byte(`{"outer": {"inner": "value"}}`), + expected: true, + }, + { + name: "complex json object", + input: []byte(`{"name": "test", "items": [{"id": 1}, {"id": 2}]}`), + expected: true, + }, + { + name: "array starting with object", + input: []byte(`[{"first": true}, "second", 3]`), + expected: true, + }, + { + name: "newlines and spaces", + input: []byte(" \n \t {\n \"key\": \"value\"\n}"), + expected: true, + }, + { + name: "newlines before array with object", + input: []byte(" \n [\n {\"key\": \"value\"}\n]"), + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsJSON(tt.input) + if result != tt.expected { + t.Errorf("IsJSON(%q) = %v, expected %v", string(tt.input), result, tt.expected) + } + }) + } +} diff --git a/internal/printer/printer.go b/internal/printer/printer.go index 5e82f15..a3e7fce 100644 --- a/internal/printer/printer.go +++ b/internal/printer/printer.go @@ -42,7 +42,7 @@ func NewPrinter(config Config) *Printer { type Printer struct { config Config - jq *jq.ExpressionRunner + jq jq.ExpressionRunner } // Validate should be called before [Printer.Print], after the flag values are assigned. diff --git a/internal/printer/yaml.go b/internal/printer/yaml.go index f6db8c2..b040070 100644 --- a/internal/printer/yaml.go +++ b/internal/printer/yaml.go @@ -3,9 +3,10 @@ package printer import ( "io" - "github.com/goccy/go-yaml" "github.com/nobl9/nobl9-go/manifest" "github.com/nobl9/nobl9-go/sdk" + + "github.com/nobl9/sloctl/internal/yamlenc" ) type yamlPrinter struct { @@ -17,11 +18,6 @@ func (p yamlPrinter) Print(content any) error { case []manifest.Object: return sdk.EncodeObjects(v, p.out, manifest.ObjectFormatYAML) default: - b, err := yaml.Marshal(content) - if err != nil { - return err - } - _, err = p.out.Write(b) - return err + return yamlenc.NewEncoder(p.out).Encode(v) } } diff --git a/internal/recipes.go b/internal/recipes.go new file mode 100644 index 0000000..5c7be21 --- /dev/null +++ b/internal/recipes.go @@ -0,0 +1,270 @@ +package internal + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/goccy/go-yaml" + "github.com/nobl9/govy/pkg/govy" + "github.com/nobl9/govy/pkg/rules" + "github.com/nobl9/nobl9-go/sdk" + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/nobl9/sloctl/internal/printer" + "github.com/nobl9/sloctl/internal/yamlenc" +) + +const recipesPathEnv = "SLOCTL_RECIPES_PATH" + +type Recipes map[string]Recipe + +type Recipe struct { + Args []string `json:"args"` + Description string `json:"description"` + Example string `json:"example,omitempty"` + JQ string `json:"jq,omitempty"` + Validators RecipeValidators `json:"validators,omitzero"` + name string +} + +type RecipeValidators struct { + AtLeastArgs []string `json:"atLeastArgs,omitempty"` +} + +type RecipesCmd struct { + recipes Recipes + printer *printer.Printer +} + +func (r *RootCmd) NewRecipesCmd() *cobra.Command { + recipes, recipesErr := readRecipes() + var defaultRecipesPath string + if recipesErr == nil { + defaultRecipesPath, recipesErr = getRecipesDefaultConfigPath() + } + + cmd := &cobra.Command{ + Use: "recipes", + Short: "Run custom recipes", + Long: fmt.Sprintf("Recipes are configured in a YAML file, where each key is a recipe name.\n"+ + "You can change the default recipes path (%s) with %s environment variable.", + defaultRecipesPath, recipesPathEnv), + } + if recipesErr != nil { + cmd.RunE = func(*cobra.Command, []string) error { return recipesErr } + return cmd + } + + recipesCmd := &RecipesCmd{ + recipes: recipes, + printer: printer.NewPrinter(printer.Config{}), + } + + configGroup := &cobra.Group{ + ID: "config", + Title: "Config commands:", + } + + listCommand := &cobra.Command{ + GroupID: configGroup.ID, + Use: "list", + Short: "List existing recipes", + Args: cobra.NoArgs, + RunE: func(*cobra.Command, []string) error { + return recipesCmd.ListRecipes() + }, + } + recipesCmd.printer.MustRegisterFlags(listCommand) + + removeCommand := &cobra.Command{ + GroupID: configGroup.ID, + Use: "remove", + Short: "Remove recipe by name", + Args: recipesArgFunc([]string{"name"}), + RunE: func(_ *cobra.Command, args []string) error { + return recipesCmd.DeleteRecipes(args) + }, + } + cmd.AddCommand(listCommand, removeCommand) + + recipesGroup := &cobra.Group{ + ID: "recipes", + Title: "Recipes:", + } + for name, recipe := range recipes { + recipe.name = name + recipeCmd := &cobra.Command{ + GroupID: recipesGroup.ID, + Use: name, + Short: recipe.Description, + Example: recipe.Example, + Args: recipesArgFunc(recipe.Validators.AtLeastArgs), + RunE: func(*cobra.Command, []string) error { + return recipesCmd.RunRecipe(recipe) + }, + } + cmd.AddCommand(recipeCmd) + } + + cmd.AddGroup(configGroup, recipesGroup) + + // Set RunE after all subcommands are added - handles unknown recipes + cmd.RunE = func(cmd *cobra.Command, args []string) error { + // This RunE is called when no subcommand matches + if len(args) > 0 { + _ = cmd.Usage() + fmt.Fprintln(os.Stderr, "") + return fmt.Errorf("unknown recipe: %s", args[0]) + } + return cmd.Help() + } + + return cmd +} + +func (r RecipesCmd) ListRecipes() error { + return r.printer.Print(r.recipes) +} + +func (r RecipesCmd) DeleteRecipes(recipeNames []string) error { + for _, name := range recipeNames { + delete(r.recipes, name) + } + return saveRecipes(r.recipes) +} + +func (r RecipesCmd) RunRecipe(recipe Recipe) error { + args := recipe.Args + if len(args) == 0 { + return errors.New("empty arguments list for recipe") + } + // Program name is always the first argument when invoking sloctl cli. + if args[0] != "sloctl" { + args = slices.Insert(args, 0, "sloctl") + } + // 'sloctl', 'recipes', '' == 3 + if len(os.Args) > 3 { + args = append(args, os.Args[3:]...) + } + recipe.Args = args + if err := recipeValidation.Validate(recipe); err != nil { + return err + } + if len(recipe.JQ) > 0 { + args = append(args, "--jq", recipe.JQ) + } + os.Args = args + fmt.Fprintf(os.Stderr, "Running: %s\n", strings.Join(args, " ")) + return Execute() +} + +func readRecipes() (Recipes, error) { + configPath, err := getRecipesConfigPath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(configPath) // #nosec: G304 + if err != nil { + return nil, errors.Wrapf(err, "failed to read sloctl recipes located at: '%s'", configPath) + } + var recipes Recipes + if err = yaml.Unmarshal(data, &recipes); err != nil { + return nil, errors.Wrapf(err, "failed to decode sloctl recipes config located at: '%s'", configPath) + } + return recipes, nil +} + +func saveRecipes(recipes Recipes) error { + configPath, err := getRecipesConfigPath() + if err != nil { + return err + } + tmpFileName, err := writeRecipesToTempFile(configPath, recipes) + if err != nil { + return errors.Wrapf(err, "failed to create and write a temporary recipes file used for saving the changes") + } + if err = os.Rename(tmpFileName, configPath); err != nil { + return err + } + return nil +} + +type fileEncoder interface { + Encode(v any) error +} + +func writeRecipesToTempFile(path string, recipes Recipes) (tmpFileName string, err error) { + tmpFile, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)) + if err != nil { + return "", err + } + defer func() { _ = tmpFile.Close() }() + var enc fileEncoder + switch filepath.Ext(path) { + case ".json": + enc = json.NewEncoder(tmpFile) + default: + enc = yamlenc.NewEncoder(tmpFile) + } + if err = enc.Encode(recipes); err != nil { + return "", err + } + if err = tmpFile.Sync(); err != nil { + return "", err + } + return tmpFile.Name(), nil +} + +func getRecipesConfigPath() (string, error) { + configPath := os.Getenv(recipesPathEnv) + if configPath == "" { + return getRecipesDefaultConfigPath() + } + return configPath, nil +} + +func getRecipesDefaultConfigPath() (string, error) { + defaultFilename := "sloctl-recipes.yaml" + sdkConfigPath, err := sdk.GetDefaultConfigPath() + if err != nil { + return "", errors.Wrapf(err, "failed to read default Nobl9 SDK config path") + } + return filepath.Join(filepath.Dir(sdkConfigPath), defaultFilename), nil +} + +func recipesArgFunc(requiredArgs []string) cobra.PositionalArgs { + if len(requiredArgs) == 0 { + return nil + } + return func(_ *cobra.Command, args []string) error { + if len(args) < len(requiredArgs) { + return errors.Errorf("Expected at least %d arg(s), received %d, required arg(s): %v", + len(requiredArgs), len(args), requiredArgs) + } + return nil + } +} + +var recipeValidation = govy.New( + govy.For(govy.GetSelf[Recipe]()). + Rules(govy.NewRule(func(r Recipe) error { + if r.JQ != "" && (slices.Contains(r.Args, "--jq") || slices.Contains(r.Args, "-q")) { + return errors.Errorf("jq expression cannot be defined both in 'jq' property and provided with 'args'") + } + return nil + })), + govy.ForSlice(func(r Recipe) []string { return r.Args }). + WithName("cmd"). + Rules(rules.SliceMinLength[[]string](1)), + govy.For(func(r Recipe) string { return r.Description }). + WithName("description"). + Required(), + govy.For(func(r Recipe) string { return r.JQ }). + WithName("expr"), +). + WithNameFunc(func(s Recipe) string { return "'" + s.name + "' recipe" }) diff --git a/internal/root.go b/internal/root.go index 5c3473b..68ec9c5 100644 --- a/internal/root.go +++ b/internal/root.go @@ -18,10 +18,8 @@ const programName = "sloctl" // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. -func Execute() { - if err := NewRootCmd().Execute(); err != nil { - os.Exit(1) - } +func Execute() error { + return NewRootCmd().Execute() } type globalFlags struct { @@ -61,6 +59,7 @@ More detailed help is available for each command.`, rootCmd.AddCommand(root.NewMoveCmd()) rootCmd.AddCommand(root.NewMCPCmd()) rootCmd.AddCommand(root.NewReviewCmd()) + rootCmd.AddCommand(root.NewRecipesCmd()) return rootCmd } diff --git a/internal/yamlenc/encoder.go b/internal/yamlenc/encoder.go new file mode 100644 index 0000000..62cbe04 --- /dev/null +++ b/internal/yamlenc/encoder.go @@ -0,0 +1,22 @@ +package yamlenc + +import ( + "encoding/json" + "io" + + "github.com/goccy/go-yaml" +) + +func NewEncoder(writer io.Writer) *yaml.Encoder { + return yaml.NewEncoder(writer, + yaml.IndentSequence(true), + yaml.UseLiteralStyleIfMultiline(true), + yaml.CustomMarshaler(yamlNumberMarshaler)) +} + +// yamlNumberMarshaler is a custom marshaler for [json.Number]. +// It is used to avoid converting int to float64 when converting JSON to YAML for generic +// [manifest.Object] representations, like [v1alpha.GenericObject]. +func yamlNumberMarshaler(number json.Number) ([]byte, error) { + return []byte(number.String()), nil +} diff --git a/internal/yamlenc/encoder_test.go b/internal/yamlenc/encoder_test.go new file mode 100644 index 0000000..79c490c --- /dev/null +++ b/internal/yamlenc/encoder_test.go @@ -0,0 +1,302 @@ +package yamlenc + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/goccy/go-yaml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewEncoder(t *testing.T) { + var buf bytes.Buffer + encoder := NewEncoder(&buf) + assert.NotNil(t, encoder) +} + +func TestEncoder_BasicTypes(t *testing.T) { + tests := map[string]struct { + input any + expected string + }{ + "simple string": { + input: "hello", + expected: "hello\n", + }, + "integer": { + input: 42, + expected: "42\n", + }, + "float": { + input: 3.14, + expected: "3.14\n", + }, + "boolean true": { + input: true, + expected: "true\n", + }, + "boolean false": { + input: false, + expected: "false\n", + }, + "nil": { + input: nil, + expected: "null\n", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + encoder := NewEncoder(&buf) + + err := encoder.Encode(tc.input) + require.NoError(t, err) + assert.Equal(t, tc.expected, buf.String()) + }) + } +} + +func TestEncoder_StructEncoding(t *testing.T) { + type TestStruct struct { + Name string `yaml:"name"` + Age int `yaml:"age"` + } + + tests := map[string]struct { + input any + validate func(t *testing.T, output string) + }{ + "simple struct": { + input: TestStruct{Name: "Alice", Age: 30}, + validate: func(t *testing.T, output string) { + assert.Contains(t, output, "name: Alice") + assert.Contains(t, output, "age: 30") + }, + }, + "map": { + input: map[string]any{ + "key1": "value1", + "key2": 42, + }, + validate: func(t *testing.T, output string) { + assert.Contains(t, output, "key1: value1") + assert.Contains(t, output, "key2: 42") + }, + }, + "slice": { + input: []string{"a", "b", "c"}, + validate: func(t *testing.T, output string) { + assert.Contains(t, output, "- a") + assert.Contains(t, output, "- b") + assert.Contains(t, output, "- c") + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + encoder := NewEncoder(&buf) + + err := encoder.Encode(tc.input) + require.NoError(t, err) + tc.validate(t, buf.String()) + }) + } +} + +func TestEncoder_IndentSequence(t *testing.T) { + input := map[string]any{ + "items": []string{"one", "two", "three"}, + } + + var buf bytes.Buffer + encoder := NewEncoder(&buf) + + err := encoder.Encode(input) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "items:") + assert.Contains(t, output, " - one") + assert.Contains(t, output, " - two") + assert.Contains(t, output, " - three") +} + +func TestEncoder_MultilineString(t *testing.T) { + input := map[string]any{ + "description": "This is a\nmultiline\nstring", + } + + var buf bytes.Buffer + encoder := NewEncoder(&buf) + + err := encoder.Encode(input) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "description: |-") + assert.Contains(t, output, "This is a") + assert.Contains(t, output, "multiline") + assert.Contains(t, output, "string") +} + +func TestYamlNumberMarshaler(t *testing.T) { + tests := map[string]struct { + input json.Number + expected string + }{ + "integer": { + input: json.Number("42"), + expected: "42", + }, + "float": { + input: json.Number("3.14"), + expected: "3.14", + }, + "large number": { + input: json.Number("1234567890"), + expected: "1234567890", + }, + "negative number": { + input: json.Number("-42"), + expected: "-42", + }, + "zero": { + input: json.Number("0"), + expected: "0", + }, + "scientific notation": { + input: json.Number("1.23e10"), + expected: "1.23e10", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + result, err := yamlNumberMarshaler(tc.input) + require.NoError(t, err) + assert.Equal(t, tc.expected, string(result)) + }) + } +} + +func TestEncoder_WithJSONNumber(t *testing.T) { + tests := map[string]struct { + input map[string]any + validate func(t *testing.T, output string) + }{ + "integer as json.Number": { + input: map[string]any{ + "count": json.Number("42"), + }, + validate: func(t *testing.T, output string) { + assert.Contains(t, output, "count: 42") + assert.NotContains(t, output, "42.0") + }, + }, + "float as json.Number": { + input: map[string]any{ + "value": json.Number("3.14"), + }, + validate: func(t *testing.T, output string) { + assert.Contains(t, output, "value: 3.14") + }, + }, + "mixed types with json.Number": { + input: map[string]any{ + "name": "test", + "count": json.Number("100"), + "rate": json.Number("99.5"), + }, + validate: func(t *testing.T, output string) { + assert.Contains(t, output, "name: test") + assert.Contains(t, output, "count: 100") + assert.Contains(t, output, "rate: 99.5") + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + encoder := NewEncoder(&buf) + + err := encoder.Encode(tc.input) + require.NoError(t, err) + tc.validate(t, buf.String()) + }) + } +} + +func TestEncoder_NestedStructures(t *testing.T) { + input := map[string]any{ + "metadata": map[string]any{ + "name": "test-object", + "labels": map[string]string{ + "env": "prod", + "team": "platform", + }, + }, + "spec": map[string]any{ + "replicas": json.Number("3"), + "ports": []map[string]any{ + {"name": "http", "port": json.Number("80")}, + {"name": "https", "port": json.Number("443")}, + }, + }, + } + + var buf bytes.Buffer + encoder := NewEncoder(&buf) + + err := encoder.Encode(input) + require.NoError(t, err) + + output := buf.String() + + // Verify structure + var decoded map[string]any + err = yaml.Unmarshal([]byte(output), &decoded) + require.NoError(t, err) + + // Verify content + assert.Equal(t, "test-object", decoded["metadata"].(map[string]any)["name"]) + // When YAML is unmarshaled, numbers become native types (int, uint, float) + // so we verify the numeric value rather than the string representation + assert.EqualValues(t, 3, decoded["spec"].(map[string]any)["replicas"]) +} + +func TestEncoder_EmptyValues(t *testing.T) { + tests := map[string]struct { + input any + expected string + }{ + "empty map": { + input: map[string]any{}, + expected: "{}\n", + }, + "empty slice": { + input: []string{}, + expected: "[]\n", + }, + "empty string": { + input: "", + expected: "\"\"\n", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + encoder := NewEncoder(&buf) + + err := encoder.Encode(tc.input) + require.NoError(t, err) + assert.Equal(t, tc.expected, buf.String()) + }) + } +} diff --git a/test/inputs/recipes/sloctl-recipes.yaml b/test/inputs/recipes/sloctl-recipes.yaml new file mode 100644 index 0000000..267fb7c --- /dev/null +++ b/test/inputs/recipes/sloctl-recipes.yaml @@ -0,0 +1,22 @@ +list-all-slos: + description: List all SLOs + args: + - get + - slo + - -A +count-services: + description: Count number of services + args: + - get + - service + jq: length +find-by-label: + description: Find objects by label + args: + - get + example: | + sloctl recipes find-by-label slo env=prod + validators: + atLeastArgs: + - kind + - label diff --git a/test/outputs/recipes/list-after-remove-multiple.json b/test/outputs/recipes/list-after-remove-multiple.json new file mode 100644 index 0000000..3c3d9a2 --- /dev/null +++ b/test/outputs/recipes/list-after-remove-multiple.json @@ -0,0 +1,10 @@ +{ + "list-all-slos": { + "args": [ + "get", + "slo", + "-A" + ], + "description": "List all SLOs" + } +} diff --git a/test/outputs/recipes/list-after-remove.yaml b/test/outputs/recipes/list-after-remove.yaml new file mode 100644 index 0000000..a7e0f9a --- /dev/null +++ b/test/outputs/recipes/list-after-remove.yaml @@ -0,0 +1,12 @@ +count-services: + description: Count number of services + args: + - get + - service + jq: length +list-all-slos: + description: List all SLOs + args: + - get + - slo + - -A diff --git a/test/outputs/recipes/list.json b/test/outputs/recipes/list.json new file mode 100644 index 0000000..9a1f0c6 --- /dev/null +++ b/test/outputs/recipes/list.json @@ -0,0 +1,31 @@ +{ + "count-services": { + "args": [ + "get", + "service" + ], + "description": "Count number of services", + "jq": "length" + }, + "find-by-label": { + "args": [ + "get" + ], + "description": "Find objects by label", + "example": "sloctl recipes find-by-label slo env=prod\n", + "validators": { + "atLeastArgs": [ + "kind", + "label" + ] + } + }, + "list-all-slos": { + "args": [ + "get", + "slo", + "-A" + ], + "description": "List all SLOs" + } +} diff --git a/test/outputs/recipes/list.yaml b/test/outputs/recipes/list.yaml new file mode 100644 index 0000000..d347f77 --- /dev/null +++ b/test/outputs/recipes/list.yaml @@ -0,0 +1,22 @@ +count-services: + description: Count number of services + args: + - get + - service + jq: length +find-by-label: + description: Find objects by label + args: + - get + example: | + sloctl recipes find-by-label slo env=prod + validators: + atLeastArgs: + - kind + - label +list-all-slos: + description: List all SLOs + args: + - get + - slo + - -A diff --git a/test/recipes-unit.bats b/test/recipes-unit.bats new file mode 100644 index 0000000..581f228 --- /dev/null +++ b/test/recipes-unit.bats @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# bats file_tags=unit + +# setup_file is run only once for the whole file. +setup_file() { + export TEST_INPUTS="$TEST_SUITE_INPUTS/recipes" + export TEST_OUTPUTS="$TEST_SUITE_OUTPUTS/recipes" + export SLOCTL_RECIPES_PATH="$BATS_FILE_TMPDIR/sloctl-recipes.yaml" + cp "$TEST_INPUTS/sloctl-recipes.yaml" "$SLOCTL_RECIPES_PATH" +} + +# setup is run before each test. +setup() { + load "test_helper/load" + load_lib "bats-support" + load_lib "bats-assert" + + # Minimal TUI mode + export SLOCTL_ACCESSIBLE_MODE=1 + export NO_COLOR=1 +} + +@test "sloctl recipes list (yaml)" { + run_sloctl recipes list + assert_success_joined_output + assert_output <"$TEST_OUTPUTS/list.yaml" +} + +@test "sloctl recipes list (json)" { + run_sloctl recipes list -o json + assert_success_joined_output + assert_output <"$TEST_OUTPUTS/list.json" +} + +@test "sloctl recipes remove" { + export SLOCTL_RECIPES_PATH="$BATS_TEST_TMPDIR/sloctl-recipes.yaml" + cp "$TEST_INPUTS/sloctl-recipes.yaml" "$SLOCTL_RECIPES_PATH" + + run_sloctl recipes remove find-by-label + assert_success + + run_sloctl recipes list + assert_success_joined_output + assert_output <"$TEST_OUTPUTS/list-after-remove.yaml" +} + +@test "sloctl recipes remove (multiple)" { + export SLOCTL_RECIPES_PATH="$BATS_TEST_TMPDIR/sloctl-recipes.yaml" + cp "$TEST_INPUTS/sloctl-recipes.yaml" "$SLOCTL_RECIPES_PATH" + + run_sloctl recipes remove find-by-label count-services + assert_success + + run_sloctl recipes list -o json + assert_success_joined_output + assert_output <"$TEST_OUTPUTS/list-after-remove-multiple.json" +} + +@test "sloctl recipes remove (non-existent)" { + run_sloctl recipes remove non-existent-recipe + assert_success + + # Verify nothing was removed + run_sloctl recipes list -o json + assert_success_joined_output + assert_output <"$TEST_OUTPUTS/list.json" +} + +@test "sloctl recipes find-by-label (missing required args)" { + run_sloctl recipes find-by-label + assert_failure + assert_stderr --partial "Expected at least 2 arg(s), received 0" + assert_stderr --partial "required arg(s): [kind label]" +} + +@test "sloctl recipes find-by-label (partial args)" { + run_sloctl recipes find-by-label slo + assert_failure + assert_stderr --partial "Expected at least 2 arg(s), received 1" + assert_stderr --partial "required arg(s): [kind label]" +} + +@test "sloctl recipes with empty config" { + export SLOCTL_RECIPES_PATH="$BATS_TEST_TMPDIR/sloctl-recipes.yaml" + echo "{}" > "$SLOCTL_RECIPES_PATH" + + run_sloctl recipes list -o json + assert_success_joined_output + assert_output "{}" +} + +@test "sloctl recipes with invalid yaml" { + export SLOCTL_RECIPES_PATH="$BATS_TEST_TMPDIR/sloctl-recipes.yaml" + echo "invalid: yaml: content:" > "$SLOCTL_RECIPES_PATH" + + run_sloctl recipes list + assert_failure + assert_stderr --partial "failed to decode sloctl recipes config" +} + +@test "sloctl recipes with missing config file" { + export SLOCTL_RECIPES_PATH="$BATS_FILE_TMPDIR/nonexistent.yaml" + + run_sloctl recipes list + assert_failure + assert_stderr --partial "failed to read sloctl recipes" +} + +@test "sloctl recipes unknown-recipe" { + run_sloctl recipes unknown-recipe + assert_failure + assert_stderr --partial "unknown recipe: unknown-recipe" +}