Skip to content
Draft
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
caa3ed2
feat: Add jq filtering support for enhanced data querying
nieomylnieja Jul 30, 2025
2aae692
feat: add alias for jq flag and update tests
nieomylnieja Jul 30, 2025
2d6119b
refactor: simplify SLO filtering and counting commands in README.md
nieomylnieja Jul 30, 2025
842df81
feat: improve error handling and JSON scalar formatting
nieomylnieja Jul 30, 2025
303e4fe
feat: add support for integer conversion in JSON scalar printer
nieomylnieja Jul 30, 2025
3a451c8
refactor: remove jq dependency from GetCmd and integrate it into Printer
nieomylnieja Jul 31, 2025
446d3ad
refactor: update error handling and type usage across multiple modules
nieomylnieja Jul 31, 2025
44fd250
feat: update flag description for jq expression filtering
nieomylnieja Jul 31, 2025
ab5dabd
feat: Add support for custom recipes in sloctl CLI
nieomylnieja Jul 31, 2025
9d1e342
feat: add support for managing and executing built-in recipes
nieomylnieja Aug 1, 2025
97d738b
feat: replace YAML encoding with custom encoder for better control
nieomylnieja Aug 5, 2025
f7f1ad0
Merge remote-tracking branch 'origin/main' into add-recipes
nieomylnieja Aug 5, 2025
e66484a
feat: refactor recipes command structure for better encapsulation
nieomylnieja Aug 6, 2025
d6392e3
feat: enhance JSON handling and streamline recipe commands
nieomylnieja Aug 6, 2025
1a8d6a8
Merge branch 'main' into add-recipes
nieomylnieja Aug 6, 2025
cd20900
Merge branch 'main' into add-recipes
nieomylnieja Aug 8, 2025
79e8e09
feat: allow additional arguments in recipes command
nieomylnieja Aug 8, 2025
652ee4a
refactor(printer): simplify YAML printer by removing unused dependenc…
nieomylnieja Aug 12, 2025
e9130e2
Merge remote-tracking branch 'origin/main' into add-recipes
nieomylnieja Jan 8, 2026
2d0d71b
post merge fixes
nieomylnieja Jan 8, 2026
647f63d
Merge branch 'main' into add-recipes
nieomylnieja Jan 8, 2026
387e3d3
Merge remote-tracking branch 'origin/main' into add-recipes
nieomylnieja Jan 28, 2026
fe1dd5d
remove unneeded omitempty
nieomylnieja Jan 29, 2026
424208e
Merge remote-tracking branch 'origin/main' into add-recipes
nieomylnieja Feb 18, 2026
873b323
build(deps): make govy a direct dependency
nieomylnieja Feb 18, 2026
4200df0
feat(recipes): handle unknown recipe commands
nieomylnieja Feb 19, 2026
e1d79cf
refactor(recipes): improve config path handling and tests
nieomylnieja Feb 23, 2026
5df8624
Merge branch 'main' into add-recipes
nieomylnieja Feb 23, 2026
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
10 changes: 8 additions & 2 deletions cmd/sloctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
4 changes: 3 additions & 1 deletion cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"golangci",
"gosec",
"govulncheck",
"govy",
"ldflags",
"nobl",
"openslo",
Expand All @@ -58,6 +59,7 @@
"unmarshalling",
"vuln",
"vulns",
"wrapf"
"wrapf",
"yamlenc"
]
}
32 changes: 32 additions & 0 deletions internal/builtin_recipes.yaml
Original file line number Diff line number Diff line change
@@ -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[]
12 changes: 2 additions & 10 deletions internal/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions internal/jq/jq.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions internal/jsonbuffer/json.go
Original file line number Diff line number Diff line change
@@ -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)
}
136 changes: 136 additions & 0 deletions internal/jsonbuffer/json_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
5 changes: 4 additions & 1 deletion internal/printer/flags.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package printer

import (
"fmt"
"strings"

"github.com/spf13/cobra"

"github.com/nobl9/sloctl/internal/csv"
Expand All @@ -11,7 +14,7 @@ func (o *Printer) MustRegisterFlags(cmd *cobra.Command) {
&o.config.OutputFormat,
"output",
"o",
`Output format: one of yaml|json|csv.`,
fmt.Sprintf("Output format: one of %s.", strings.Join(validFormatStrings, "|")),
)

cmd.PersistentFlags().StringVarP(
Expand Down
27 changes: 19 additions & 8 deletions internal/printer/printer.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io"
"os"
"slices"

"github.com/nobl9/sloctl/internal/csv"
"github.com/nobl9/sloctl/internal/jq"
Expand Down Expand Up @@ -37,7 +38,7 @@ func NewPrinter(config Config) *Printer {

type Printer struct {
config Config
jq *jq.ExpressionRunner
jq jq.ExpressionRunner
}

func (o *Printer) Print(v any) error {
Expand Down Expand Up @@ -69,6 +70,18 @@ func (o *Printer) Print(v any) error {
return nil
}

func (o *Printer) WithOutput(out io.Writer) *Printer {
cp := *o
cp.config.Output = out
return &cp
}

var validFormatStrings = []string{
YAMLFormat.String(),
JSONFormat.String(),
CSVFormat.String(),
}

// All supported output formats by [Printer].
const (
YAMLFormat Format = "yaml"
Expand All @@ -79,18 +92,16 @@ const (
// Format represents supported printing outputs.
type Format string

func (f *Format) String() string {
return string(*f)
func (f Format) String() string {
return string(f)
}

func (f *Format) Set(value string) error {
switch value {
case "yaml", "json", "csv":
*f = Format(value)
return nil
default:
if !slices.Contains(validFormatStrings, value) {
return fmt.Errorf("invalid value for Format: %s", value)
}
*f = Format(value)
return nil
}

func (f *Format) Type() string {
Expand Down
11 changes: 4 additions & 7 deletions internal/printer/yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -17,11 +18,7 @@ func (p *yamlPrinter) Print(content any) error {
case []manifest.Object:
return sdk.PrintObjects(v, p.out, manifest.ObjectFormatYAML)
default:
b, err := yaml.Marshal(content)
if err != nil {
return err
}
_, err = p.out.Write(b)
return err
enc := yamlenc.NewEncoder(p.out)
return enc.Encode(content)
}
}
Loading
Loading