From 6dda9f7d0d73e9ccd6e24d975ce4e2509c73050c Mon Sep 17 00:00:00 2001 From: Nofre Date: Sun, 18 May 2025 07:33:05 +0200 Subject: [PATCH 1/9] feat(config): added viper for configuration management --- cmd/cli/root.go | 14 +++++++- config/.qube.yaml | 9 +++++ go.mod | 11 ++++++ go.sum | 23 ++++++++++++ internal/config/cli.go | 82 ++++++++++++++++++++++++++++++++++++++++++ ui/console.go | 8 +++++ ui/styles.go | 11 +++--- ui/ui.go | 1 + 8 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 config/.qube.yaml create mode 100644 internal/config/cli.go diff --git a/cmd/cli/root.go b/cmd/cli/root.go index 31852d8..349e3bc 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -1,12 +1,24 @@ package cli import ( + "context" + "fmt" + "github.com/apiqube/cli/internal/config" "github.com/spf13/cobra" ) var rootCmd = &cobra.Command{ Use: "qube", - Short: "ApiQube is a powerful test manager for apps and APIs", + Short: "ApiQube is a powerful test manager for APIs", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.InitConfig() + if err != nil { + return fmt.Errorf("config init failed: %w", err) + } + + cmd.SetContext(context.WithValue(cmd.Context(), "config", cfg)) + return nil + }, } func Execute() { diff --git a/config/.qube.yaml b/config/.qube.yaml new file mode 100644 index 0000000..4d5bb21 --- /dev/null +++ b/config/.qube.yaml @@ -0,0 +1,9 @@ +# Example of CLI config +ui: # Set of fields for cli output + timestampColor: "#949494" + logColor: "#e4e4e4" + successColor: "#5fd700" + errorColor: "#FF0000" + warningColor: "#ff8700" + infoColor: "#00afff" + debugColor: "#005fff" diff --git a/go.mod b/go.mod index cb3e4e8..5f2c11c 100644 --- a/go.mod +++ b/go.mod @@ -41,8 +41,10 @@ require ( github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/golang/protobuf v1.5.0 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/flatbuffers v25.2.10+incompatible // indirect @@ -57,14 +59,23 @@ require ( 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/pelletier/go-toml/v2 v2.2.3 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/spf13/viper v1.20.1 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.etcd.io/bbolt v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/sync v0.13.0 // indirect golang.org/x/sys v0.33.0 // indirect diff --git a/go.sum b/go.sum index 1bd3d90..74b9bfc 100644 --- a/go.sum +++ b/go.sum @@ -71,11 +71,15 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= @@ -115,6 +119,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -123,14 +129,27 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= @@ -143,6 +162,10 @@ go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/ go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= diff --git a/internal/config/cli.go b/internal/config/cli.go new file mode 100644 index 0000000..990dcb2 --- /dev/null +++ b/internal/config/cli.go @@ -0,0 +1,82 @@ +package config + +import ( + "errors" + "fmt" + "github.com/adrg/xdg" + "github.com/spf13/viper" + "os" + "path/filepath" + "strings" +) + +const ( + appName = "ApiQube" +) + +type CLIConfig struct { + UI struct { + TimestampColor string `mapstructure:"timestamp_color" yaml:"timestamp_color" json:"timestampColor"` + LogColor string `mapstructure:"log_color" yaml:"log_color" json:"logColor"` + SuccessColor string `mapstructure:"success_color" yaml:"success_color" json:"successColor"` + WarnColor string `mapstructure:"warn_color" yaml:"warn_color" json:"warnColor"` + ErrorColor string `mapstructure:"error_color" yaml:"error_color" json:"errorColor"` + DebugColor string `mapstructure:"debug_color" yaml:"debug_color" json:"debugColor"` + InfoColor string `mapstructure:"info_color" yaml:"info_color" json:"infoColor"` + } `mapstructure:"ui" yaml:"ui" json:"ui"` +} + +func InitConfig() (*CLIConfig, error) { + configPath := filepath.Join(xdg.ConfigHome, appName) + + viper.AddConfigPath(configPath) + viper.AddConfigPath(".") + viper.SetConfigName("config") + viper.SetConfigType("yaml") + + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) + viper.SetEnvPrefix("QUBE") + viper.AutomaticEnv() + + if err := viper.ReadInConfig(); err != nil { + var configFileNotFoundError viper.ConfigFileNotFoundError + if errors.As(err, &configFileNotFoundError) { + if err = createDefaultConfig(configPath); err != nil { + return nil, fmt.Errorf("failed to create default config: %w", err) + } + } + } + + var cfg CLIConfig + if err := viper.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("config unmarshal error: %w", err) + } + + return &cfg, nil +} + +func createDefaultConfig(configPath string) error { + if err := os.MkdirAll(configPath, 0755); err != nil { + return fmt.Errorf("failed to create config dir: %w", err) + } + + cfgFile := filepath.Join(configPath, "config.yaml") + + defaultCfg := CLIConfig{} + _ = defaultCfg + + file, err := os.Create(cfgFile) + if err != nil { + return fmt.Errorf("failed to create config file: %w", err) + } + defer func() { + _ = file.Close() + }() + + if err = viper.WriteConfigAs(cfgFile); err != nil { + return fmt.Errorf("failed to write default config: %w", err) + } + + fmt.Printf("Created default config at: %s\n", cfgFile) + return nil +} diff --git a/ui/console.go b/ui/console.go index 940c594..507d39c 100644 --- a/ui/console.go +++ b/ui/console.go @@ -43,3 +43,11 @@ func Info(a ...interface{}) { func Infof(format string, a ...interface{}) { printStyledf(TypeInfo, format, a...) } + +func Debug(a ...interface{}) { + printStyled(TypeDebug, a...) +} + +func Debugf(format string, a ...interface{}) { + printStyledf(TypeDebug, format, a...) +} diff --git a/ui/styles.go b/ui/styles.go index 4893a94..847a78b 100644 --- a/ui/styles.go +++ b/ui/styles.go @@ -18,16 +18,17 @@ var ( Foreground(lipgloss.Color("#5fd700")) errorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF0000")). - Bold(false) + Foreground(lipgloss.Color("#FF0000")) warningStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#ff8700")). - Bold(true) + Foreground(lipgloss.Color("#ff8700")) infoStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#00afff")) + debugStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#005fff")) + snippetStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("230")). Background(lipgloss.Color("236")). @@ -59,6 +60,8 @@ func getStyle(t MessageType) lipgloss.Style { return warningStyle case TypeInfo: return infoStyle + case TypeDebug: + return debugStyle case TypeSnippet: return snippetStyle default: diff --git a/ui/ui.go b/ui/ui.go index 52c32a4..b273c72 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -19,6 +19,7 @@ const ( TypeError TypeWarning TypeInfo + TypeDebug TypeSnippet TypePackage TypeRealtime From 7cdb877592df65af30373d06b3d2e5f986e8e6e3 Mon Sep 17 00:00:00 2001 From: Nofre Date: Sun, 18 May 2025 17:22:58 +0200 Subject: [PATCH 2/9] feat(manifests): added ability to work load kind Plan manifest --- examples/plan/plan.yaml | 26 +++++++++++++++++ examples/simple/http_test_second.yaml | 1 - internal/core/manifests/interface.go | 2 ++ .../manifests/kinds/{system => }/plan/plan.go | 29 ++++++++++++------- internal/core/manifests/parsing/parse.go | 3 ++ 5 files changed, 49 insertions(+), 12 deletions(-) create mode 100644 examples/plan/plan.yaml rename internal/core/manifests/kinds/{system => }/plan/plan.go (61%) diff --git a/examples/plan/plan.yaml b/examples/plan/plan.yaml new file mode 100644 index 0000000..e8ff9e2 --- /dev/null +++ b/examples/plan/plan.yaml @@ -0,0 +1,26 @@ +version: 1 +kind: Plan +metadata: + name: plan-example + +spec: + stages: + + - name: "Preparation..." + description: "First stage with loading values to context" + manifests: + - Values.values-example + + - name: "Starting and checking server" + manifests: + - Server.simple-server + - Service.simple-service + + - name: "Testing APIs" + manifests: + - HttpTest.simple-http-test + - HttpTest.not-simple-http-test + + - name: "APIs loading testing" + manifests: + - HttpLoadTest.simple-http-load-test \ No newline at end of file diff --git a/examples/simple/http_test_second.yaml b/examples/simple/http_test_second.yaml index 3aaf6cc..117135e 100644 --- a/examples/simple/http_test_second.yaml +++ b/examples/simple/http_test_second.yaml @@ -2,7 +2,6 @@ version: 1 kind: HttpTest metadata: name: not-simple-http-test - namespace: not-simple spec: server: simple-server cases: diff --git a/internal/core/manifests/interface.go b/internal/core/manifests/interface.go index 7e72f8f..e201a91 100644 --- a/internal/core/manifests/interface.go +++ b/internal/core/manifests/interface.go @@ -6,7 +6,9 @@ import ( const ( DefaultNamespace = "default" +) +const ( PlanManifestKind = "Plan" ValuesManifestLind = "Values" ServerManifestKind = "Server" diff --git a/internal/core/manifests/kinds/system/plan/plan.go b/internal/core/manifests/kinds/plan/plan.go similarity index 61% rename from internal/core/manifests/kinds/system/plan/plan.go rename to internal/core/manifests/kinds/plan/plan.go index b62b38c..479abeb 100644 --- a/internal/core/manifests/kinds/system/plan/plan.go +++ b/internal/core/manifests/kinds/plan/plan.go @@ -18,26 +18,33 @@ type Plan struct { kinds.BaseManifest `yaml:",inline" json:",inline"` Spec struct { - Stages Stages `yaml:"stages" json:"stages"` - Hooks Hooks `yaml:"hooks,omitempty" json:"hooks"` + Stages []Stage `yaml:"stages" json:"stages"` + Hooks Hooks `yaml:"hooks,omitempty" json:"hooks,omitempty"` } `yaml:"spec" json:"spec"` Meta *kinds.Meta `yaml:",inline" json:"meta"` } -type Stages struct { - Stages []Stage `yaml:",inline" json:",inline"` -} - type Stage struct { - Name string `yaml:"name" json:"name"` - Manifests []string `yaml:"manifests" json:"manifests" ` - Parallel bool `yaml:"parallel" json:"parallel"` + Name string `yaml:"name" json:"name"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Manifests []string `yaml:"manifests" json:"manifests"` + Parallel bool `yaml:"parallel,omitempty" json:"parallel,omitempty"` + Params map[string]any `yaml:"params,omitempty" json:"params,omitempty"` + Mode string `yaml:"mode,omitempty" json:"mode,omitempty"` // eg parallel mode, (strict|lite) + Hooks Hooks `yaml:"hooks,omitempty" json:"hooks,omitempty"` } type Hooks struct { - OnSuccess []string `yaml:"onSuccess" json:"onSuccess"` - OnFailure []string `yaml:"onFailure" json:"onFailure"` + BeforeStart []Action `yaml:"beforeStart,omitempty" json:"beforeStart,omitempty"` + AfterFinish []Action `yaml:"afterFinish,omitempty" json:"afterFinish,omitempty"` + OnSuccess []Action `yaml:"onSuccess,omitempty" json:"onSuccess,omitempty"` + OnFailure []Action `yaml:"onFailure,omitempty" json:"onFailure,omitempty"` +} + +type Action struct { + Type string `yaml:"type" json:"type"` // eg log/save/skip/fail/exec/notify + Params map[string]any `yaml:"params" json:"params"` } func (p *Plan) GetID() string { diff --git a/internal/core/manifests/parsing/parse.go b/internal/core/manifests/parsing/parse.go index 0780b00..f815390 100644 --- a/internal/core/manifests/parsing/parse.go +++ b/internal/core/manifests/parsing/parse.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/apiqube/cli/internal/core/manifests/kinds/plan" "github.com/apiqube/cli/internal/core/manifests/kinds/values" @@ -113,6 +114,8 @@ func ParseManifest(parseMethod ParseMethod, data []byte) (manifests.Manifest, er var manifest manifests.Manifest switch raw.Kind { + case manifests.PlanManifestKind: + manifest = &plan.Plan{} case manifests.ValuesManifestLind: manifest = &values.Values{} case manifests.ServerManifestKind: From f1c2b93c787ad21f32bb1cb1ec1f3b8945aaba53 Mon Sep 17 00:00:00 2001 From: Nofre Date: Sun, 18 May 2025 17:53:05 +0200 Subject: [PATCH 3/9] feat(manifests): added some preparing for manifests --- .../{simple => services}/http_load_test.yaml | 0 examples/{simple => services}/service.yaml | 0 examples/simple/http_test_second.yaml | 21 ------------- examples/{values => simple}/values.yaml | 0 internal/core/manifests/kinds/plan/plan.go | 24 +++++++++++++++ .../core/manifests/kinds/servers/server.go | 4 +++ .../core/manifests/kinds/tests/api/http.go | 4 +++ internal/core/manifests/parsing/parse.go | 4 +++ internal/core/manifests/utils/id_helpers.go | 30 +++++++++++++++++++ 9 files changed, 66 insertions(+), 21 deletions(-) rename examples/{simple => services}/http_load_test.yaml (100%) rename examples/{simple => services}/service.yaml (100%) delete mode 100644 examples/simple/http_test_second.yaml rename examples/{values => simple}/values.yaml (100%) create mode 100644 internal/core/manifests/utils/id_helpers.go diff --git a/examples/simple/http_load_test.yaml b/examples/services/http_load_test.yaml similarity index 100% rename from examples/simple/http_load_test.yaml rename to examples/services/http_load_test.yaml diff --git a/examples/simple/service.yaml b/examples/services/service.yaml similarity index 100% rename from examples/simple/service.yaml rename to examples/services/service.yaml diff --git a/examples/simple/http_test_second.yaml b/examples/simple/http_test_second.yaml deleted file mode 100644 index 117135e..0000000 --- a/examples/simple/http_test_second.yaml +++ /dev/null @@ -1,21 +0,0 @@ -version: 1 -kind: HttpTest -metadata: - name: not-simple-http-test -spec: - server: simple-server - cases: - - name: user-login - method: GET - endpoint: /login - body: - email: "example_email" - password: "example_password" - expected: - code: 200 - - name: user-fetch - method: GET - endpoint: /users/{id} - expected: - code: 404 - message: "User not found" diff --git a/examples/values/values.yaml b/examples/simple/values.yaml similarity index 100% rename from examples/values/values.yaml rename to examples/simple/values.yaml diff --git a/internal/core/manifests/kinds/plan/plan.go b/internal/core/manifests/kinds/plan/plan.go index 479abeb..be5aadb 100644 --- a/internal/core/manifests/kinds/plan/plan.go +++ b/internal/core/manifests/kinds/plan/plan.go @@ -1,6 +1,8 @@ package plan import ( + "github.com/apiqube/cli/internal/core/manifests/utils" + "strings" "time" "github.com/apiqube/cli/internal/core/manifests" @@ -105,4 +107,26 @@ func (p *Plan) Prepare() { if p.Namespace == "" { p.Namespace = manifests.DefaultNamespace } + + if p.Kind == "" { + p.Kind = manifests.PlanManifestKind + } + + if p.Meta == nil { + p.Meta = kinds.DefaultMeta() + } + + for i, stage := range p.Spec.Stages { + if stage.Mode == "" { + stage.Mode = "lite" + } + + for j, m := range stage.Manifests { + namespace, kind, name := utils.ParseManifestID(m) + m = strings.Join([]string{namespace, kind, name}, ".") + stage.Manifests[j] = m + } + + p.Spec.Stages[i] = stage + } } diff --git a/internal/core/manifests/kinds/servers/server.go b/internal/core/manifests/kinds/servers/server.go index 9981620..050f47f 100644 --- a/internal/core/manifests/kinds/servers/server.go +++ b/internal/core/manifests/kinds/servers/server.go @@ -79,4 +79,8 @@ func (s *Server) Prepare() { if s.Namespace == "" { s.Namespace = manifests.DefaultNamespace } + + if s.Meta == nil { + s.Meta = kinds.DefaultMeta() + } } diff --git a/internal/core/manifests/kinds/tests/api/http.go b/internal/core/manifests/kinds/tests/api/http.go index 0018e20..0d6d3c9 100644 --- a/internal/core/manifests/kinds/tests/api/http.go +++ b/internal/core/manifests/kinds/tests/api/http.go @@ -93,4 +93,8 @@ func (h *Http) Prepare() { if h.Namespace == "" { h.Namespace = manifests.DefaultNamespace } + + if h.Meta == nil { + h.Meta = kinds.DefaultMeta() + } } diff --git a/internal/core/manifests/parsing/parse.go b/internal/core/manifests/parsing/parse.go index f815390..ea2e96d 100644 --- a/internal/core/manifests/parsing/parse.go +++ b/internal/core/manifests/parsing/parse.go @@ -134,6 +134,10 @@ func ParseManifest(parseMethod ParseMethod, data []byte) (manifests.Manifest, er def.Default() } + if prep, ok := manifest.(manifests.Prepare); ok { + prep.Prepare() + } + var err error switch parseMethod { case JSONMethod: diff --git a/internal/core/manifests/utils/id_helpers.go b/internal/core/manifests/utils/id_helpers.go new file mode 100644 index 0000000..cdd1ea6 --- /dev/null +++ b/internal/core/manifests/utils/id_helpers.go @@ -0,0 +1,30 @@ +package utils + +import ( + "fmt" + "github.com/apiqube/cli/internal/core/manifests" + "strings" +) + +func ParseManifestID(id string) (string, string, string) { + parts := strings.Split(id, ".") + if len(parts) == 3 { + return parts[0], parts[1], parts[2] + } else if len(parts) == 2 { + return manifests.DefaultNamespace, parts[0], parts[1] + } else { + return "", "", "" + } +} + +func ParseManifestIDWithError(id string) (string, string, string, error) { + namespace, kind, name := ParseManifestID(id) + if namespace == "" { + namespace = manifests.DefaultNamespace + } else if kind == "" { + return namespace, name, name, fmt.Errorf("manifest kind not specified") + } else if name == "" { + return namespace, kind, name, fmt.Errorf("manifest name not specified") + } + return namespace, kind, name, nil +} From 38c3039729bab14d391b7da4da4922f728cf1985 Mon Sep 17 00:00:00 2001 From: Nofre Date: Sun, 18 May 2025 20:21:33 +0200 Subject: [PATCH 4/9] feat(runner): added abstractions for runner module, added execution context base implementation, added cli output implementation --- internal/core/runner/cli/output.go | 87 +++++ internal/core/runner/context/builder.go | 96 ++++++ internal/core/runner/context/context.go | 379 +++++++++++++++++++++ internal/core/runner/interfaces/context.go | 27 ++ internal/core/runner/interfaces/events.go | 17 + internal/core/runner/interfaces/hooks.go | 19 ++ internal/core/runner/interfaces/log.go | 12 + internal/core/runner/interfaces/metrics.go | 26 ++ internal/core/runner/interfaces/output.go | 35 ++ internal/core/runner/interfaces/result.go | 15 + internal/core/runner/interfaces/store.go | 39 +++ 11 files changed, 752 insertions(+) create mode 100644 internal/core/runner/cli/output.go create mode 100644 internal/core/runner/context/builder.go create mode 100644 internal/core/runner/context/context.go create mode 100644 internal/core/runner/interfaces/context.go create mode 100644 internal/core/runner/interfaces/events.go create mode 100644 internal/core/runner/interfaces/hooks.go create mode 100644 internal/core/runner/interfaces/log.go create mode 100644 internal/core/runner/interfaces/metrics.go create mode 100644 internal/core/runner/interfaces/output.go create mode 100644 internal/core/runner/interfaces/result.go create mode 100644 internal/core/runner/interfaces/store.go diff --git a/internal/core/runner/cli/output.go b/internal/core/runner/cli/output.go new file mode 100644 index 0000000..7c394fd --- /dev/null +++ b/internal/core/runner/cli/output.go @@ -0,0 +1,87 @@ +package cli + +import ( + "fmt" + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/runner/interfaces" + "github.com/apiqube/cli/ui" + "strings" +) + +var _ interfaces.Output = (*Output)(nil) + +type Output struct{} + +func (o *Output) StartCase(manifest manifests.Manifest, caseName string) { + ui.Infof("Start %s case from %s manifest", caseName, manifest.GetName()) +} + +func (o *Output) EndCase(manifest manifests.Manifest, caseName string, result *interfaces.CaseResult) { + if result != nil { + ui.Println(fmt.Sprintf( + `Finish %s case from %s manifest with next reults + Result: %s + Success: %v + Status Code: %d + Duration: %s`, + caseName, + manifest.GetName(), + result.Name, + result.Success, + result.StatusCode, + result.Duration.String(), + ), + ) + } else { + ui.Infof("Finish %s case from %s manifest", caseName, manifest.GetName()) + } +} + +func (o *Output) ReceiveMsg(msg any) { + ui.Infof("Receiving message %s", msg) +} + +func (o *Output) Log(level interfaces.LogLevel, msg string) { + switch level { + case interfaces.DebugLevel: + ui.Debug(msg) + case interfaces.InfoLevel: + ui.Info(msg) + case interfaces.WarnLevel: + ui.Warning(msg) + case interfaces.ErrorLevel: + ui.Error(msg) + default: + ui.Info(msg) + } +} + +func (o *Output) Logf(level interfaces.LogLevel, format string, args ...any) { + switch level { + case interfaces.DebugLevel: + ui.Debugf(format, args...) + case interfaces.InfoLevel: + ui.Infof(format, args...) + case interfaces.WarnLevel: + ui.Warningf(format, args...) + case interfaces.ErrorLevel: + ui.Errorf(format, args...) + default: + ui.Infof(format, args...) + } +} + +func (o *Output) DumpValues(values map[string]any) { + if values != nil { + var rows []string + for k, v := range values { + rows = append(rows, fmt.Sprintf("%v: %v", k, v)) + } + + ui.Printf("Damping values: \n%s", strings.Join(rows, "\n")) + } +} + +func (o *Output) Error(err error) { + ui.Error(err.Error()) +} diff --git a/internal/core/runner/context/builder.go b/internal/core/runner/context/builder.go new file mode 100644 index 0000000..8999c4a --- /dev/null +++ b/internal/core/runner/context/builder.go @@ -0,0 +1,96 @@ +package context + +import ( + "context" + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/runner/interfaces" + "reflect" + "sync" +) + +type ValuePair struct { + Key string + Value any + Kind reflect.Kind +} + +type CtxBuilder struct { + ctx context.Context + manifests map[string]manifests.Manifest + values map[string]any + kinds map[string]reflect.Kind + passChans map[string]chan any + passKinds map[string]reflect.Kind + passDone map[string]bool + output interfaces.Output +} + +func NewCtxBuilder() *CtxBuilder { + return &CtxBuilder{ + ctx: context.Background(), + manifests: make(map[string]manifests.Manifest), + values: make(map[string]any), + kinds: make(map[string]reflect.Kind), + passChans: make(map[string]chan any), + passKinds: make(map[string]reflect.Kind), + passDone: make(map[string]bool), + } +} + +func (b *CtxBuilder) WithContext(ctx context.Context) *CtxBuilder { + b.ctx = ctx + return b +} + +func (b *CtxBuilder) WithManifests(manifests ...manifests.Manifest) *CtxBuilder { + for _, m := range manifests { + b.manifests[m.GetID()] = m + } + return b +} + +func (b *CtxBuilder) WithValue(key string, value any, kind reflect.Kind) *CtxBuilder { + b.values[key] = value + b.kinds[key] = kind + return b +} + +func (b *CtxBuilder) WithValues(valuesPairs ...ValuePair) *CtxBuilder { + for _, pair := range valuesPairs { + b.values[pair.Key] = pair.Value + b.kinds[pair.Key] = pair.Kind + } + return b +} + +func (b *CtxBuilder) WithPassChan(key string, ch chan any, kind reflect.Kind) *CtxBuilder { + b.passChans[key] = ch + b.passKinds[key] = kind + return b +} + +func (b *CtxBuilder) WithOutput(output interfaces.Output) *CtxBuilder { + b.output = output + return b +} + +func (b *CtxBuilder) Build() interfaces.ExecutionContext { + return &ctxBaseImpl{ + Context: b.ctx, + manifests: b.manifests, + values: b.values, + kinds: b.kinds, + passChans: b.passChans, + passKinds: b.passKinds, + passDone: b.passDone, + output: b.output, + manifestsMutex: sync.RWMutex{}, + storeMutex: sync.RWMutex{}, + chansMutex: sync.RWMutex{}, + outputMutex: sync.RWMutex{}, + } +} + +func (b *CtxBuilder) Reset() { + *b = CtxBuilder{ctx: b.ctx} +} diff --git a/internal/core/runner/context/context.go b/internal/core/runner/context/context.go new file mode 100644 index 0000000..3f9d650 --- /dev/null +++ b/internal/core/runner/context/context.go @@ -0,0 +1,379 @@ +package context + +import ( + "context" + "fmt" + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/runner/interfaces" + "reflect" + "sync" + "time" +) + +var _ interfaces.ExecutionContext = (*ctxBaseImpl)(nil) + +type ctxBaseImpl struct { + context.Context + + manifestsMutex sync.RWMutex + manifests map[string]manifests.Manifest + + storeMutex sync.RWMutex + values map[string]any + kinds map[string]reflect.Kind + + chansMutex sync.RWMutex + passChans map[string]chan any + passKinds map[string]reflect.Kind + passDone map[string]bool + + outputMutex sync.RWMutex + output interfaces.Output +} + +func (c *ctxBaseImpl) Deadline() (deadline time.Time, ok bool) { + return c.Context.Deadline() +} + +func (c *ctxBaseImpl) Done() <-chan struct{} { + return c.Context.Done() +} + +func (c *ctxBaseImpl) Err() error { + return c.Context.Err() +} + +func (c *ctxBaseImpl) Value(key any) any { + return c.Context.Value(key) +} + +func (c *ctxBaseImpl) GetManifest(id string) (manifests.Manifest, error) { + c.manifestsMutex.RLock() + defer c.manifestsMutex.RUnlock() + + return c.manifests[id], nil +} + +func (c *ctxBaseImpl) Set(key string, value any) { + c.storeMutex.Lock() + defer c.storeMutex.Unlock() + c.values[key] = value +} + +func (c *ctxBaseImpl) Get(key string) (any, bool) { + c.storeMutex.RLock() + defer c.storeMutex.RUnlock() + v, ok := c.values[key] + return v, ok +} + +func (c *ctxBaseImpl) Delete(key string) { + c.storeMutex.Lock() + defer c.storeMutex.Unlock() + delete(c.values, key) +} + +func (c *ctxBaseImpl) All() map[string]any { + c.storeMutex.Lock() + defer c.storeMutex.Unlock() + return deepCopyMap(c.values, c.kinds) +} + +func (c *ctxBaseImpl) SetTyped(key string, value any, kind reflect.Kind) { + c.storeMutex.Lock() + defer c.storeMutex.Unlock() + c.values[key] = value + c.kinds[key] = kind +} + +func (c *ctxBaseImpl) GetTyped(key string) (any, reflect.Kind, bool) { + c.storeMutex.RLock() + defer c.storeMutex.RUnlock() + v, ok := c.values[key] + if !ok { + return nil, reflect.Invalid, false + } + + return v, c.kinds[key], true +} + +func (c *ctxBaseImpl) AsString(key string) (string, error) { + c.storeMutex.RLock() + defer c.storeMutex.RUnlock() + v, ok := c.values[key] + if !ok { + return "", fmt.Errorf("value by %s not found", key) + } + + val, is := v.(string) + if !is { + return "", fmt.Errorf("value by %s is not a string", key) + } + + return val, nil +} + +func (c *ctxBaseImpl) AsInt(key string) (int64, error) { + c.storeMutex.RLock() + defer c.storeMutex.RUnlock() + v, ok := c.values[key] + if !ok { + return 0, fmt.Errorf("value by %s not found", key) + } + + val, is := v.(int64) + if !is { + return 0, fmt.Errorf("value by %s is not a int", key) + } + + return val, nil +} + +func (c *ctxBaseImpl) AsFloat(key string) (float64, error) { + c.storeMutex.RLock() + defer c.storeMutex.RUnlock() + v, ok := c.values[key] + if !ok { + return 0, fmt.Errorf("value by %s not found", key) + } + + val, is := v.(float64) + if !is { + return 0, fmt.Errorf("value by %s is not a float", key) + } + + return val, nil +} + +func (c *ctxBaseImpl) AsBool(key string) (bool, error) { + c.storeMutex.RLock() + defer c.storeMutex.RUnlock() + v, ok := c.values[key] + if !ok { + return false, fmt.Errorf("value by %s not found", key) + } + + val, is := v.(bool) + if !is { + return false, fmt.Errorf("value by %s is not a bool", key) + } + + return val, nil +} + +func (c *ctxBaseImpl) AsStringSlice(key string) ([]string, error) { + c.storeMutex.RLock() + defer c.storeMutex.RUnlock() + v, ok := c.values[key] + if !ok { + return nil, fmt.Errorf("value by %s not found", key) + } + + val, is := v.([]string) + if !is { + return nil, fmt.Errorf("value by %s is not a string slice", key) + } + + return val, nil +} + +func (c *ctxBaseImpl) AsIntSlice(key string) ([]int, error) { + c.storeMutex.RLock() + defer c.storeMutex.RUnlock() + v, ok := c.values[key] + if !ok { + return nil, fmt.Errorf("value by %s not found", key) + } + + val, is := v.([]int) + if !is { + return nil, fmt.Errorf("value by %s is not a int slice", key) + } + + return val, nil +} + +func (c *ctxBaseImpl) AsMap(key string) (map[string]any, error) { + c.storeMutex.RLock() + defer c.storeMutex.RUnlock() + v, ok := c.values[key] + if !ok { + return nil, fmt.Errorf("value by %s not found", key) + } + + val, is := v.(map[string]any) + if !is { + return nil, fmt.Errorf("value by %s is not a map", key) + } + + return val, nil +} + +func (c *ctxBaseImpl) Channel(key string) chan any { + c.chansMutex.Lock() + defer c.chansMutex.Unlock() + + ch, ok := c.passChans[key] + if !ok { + ch = make(chan any, 1) + c.passChans[key] = ch + } + return ch +} + +func (c *ctxBaseImpl) ChannelT(key string, kind reflect.Kind) chan any { + c.chansMutex.Lock() + defer c.chansMutex.Unlock() + + ch, ok := c.passChans[key] + if !ok { + ch = make(chan any, 1) + c.passChans[key] = ch + c.passKinds[key] = kind + } + return ch +} + +func (c *ctxBaseImpl) SafeSend(key string, val any) { + c.chansMutex.RLock() + ch, ok := c.passChans[key] + c.chansMutex.RUnlock() + + if !ok { + return + } + + select { + case ch <- val: + default: + } +} + +func (c *ctxBaseImpl) SendOutput(msg any) { + c.outputMutex.RLock() + defer c.outputMutex.RUnlock() + if c.output != nil { + go c.output.ReceiveMsg(msg) + } +} + +func (c *ctxBaseImpl) GetOutput() interfaces.Output { + c.outputMutex.Lock() + defer c.outputMutex.Unlock() + return c.output +} + +func (c *ctxBaseImpl) SetOutput(out interfaces.Output) { + c.outputMutex.Lock() + defer c.outputMutex.Unlock() + c.output = out +} + +func deepCopyMap(m map[string]any, kinds map[string]reflect.Kind) map[string]any { + if m == nil { + return nil + } + + cache := make(map[uintptr]any) + newMap := make(map[string]any, len(m)) + + for key, val := range m { + if expectedKind, exists := kinds[key]; exists { + actualKind := reflect.TypeOf(val).Kind() + if actualKind != expectedKind { + continue + } + } + newMap[key] = deepCopyValue(val, cache) + } + return newMap +} + +func deepCopyValue(value any, cache map[uintptr]any) any { + if value == nil { + return nil + } + + val := reflect.ValueOf(value) + + if val.CanAddr() { + ptr := val.UnsafeAddr() + if cached, exists := cache[ptr]; exists { + return cached + } + } + + switch val.Kind() { + case reflect.Ptr: + if val.IsNil() { + return nil + } + + elem := val.Elem() + newPtr := reflect.New(elem.Type()) + + if val.CanAddr() { + cache[val.UnsafeAddr()] = newPtr.Interface() + } + + newPtr.Elem().Set(reflect.ValueOf(deepCopyValue(elem.Interface(), cache))) + return newPtr.Interface() + + case reflect.Map: + newMap := reflect.MakeMap(val.Type()) + + if val.CanAddr() { + cache[val.UnsafeAddr()] = newMap.Interface() + } + + iter := val.MapRange() + for iter.Next() { + newKey := deepCopyValue(iter.Key().Interface(), cache) + newValue := deepCopyValue(iter.Value().Interface(), cache) + newMap.SetMapIndex( + reflect.ValueOf(newKey), + reflect.ValueOf(newValue), + ) + } + return newMap.Interface() + + case reflect.Slice: + newSlice := reflect.MakeSlice(val.Type(), val.Len(), val.Cap()) + + if val.CanAddr() { + cache[val.UnsafeAddr()] = newSlice.Interface() + } + + for i := 0; i < val.Len(); i++ { + newSlice.Index(i).Set( + reflect.ValueOf(deepCopyValue(val.Index(i).Interface(), cache)), + ) + } + return newSlice.Interface() + + case reflect.Struct: + newStruct := reflect.New(val.Type()).Elem() + + if val.CanAddr() { + cache[val.UnsafeAddr()] = newStruct.Interface() + } + + for i := 0; i < val.NumField(); i++ { + if newStruct.Field(i).CanSet() { + newStruct.Field(i).Set( + reflect.ValueOf(deepCopyValue(val.Field(i).Interface(), cache)), + ) + } + } + return newStruct.Interface() + + case reflect.Interface: + if val.IsNil() { + return nil + } + return deepCopyValue(val.Elem().Interface(), cache) + + default: + return value + } +} diff --git a/internal/core/runner/interfaces/context.go b/internal/core/runner/interfaces/context.go new file mode 100644 index 0000000..7a52143 --- /dev/null +++ b/internal/core/runner/interfaces/context.go @@ -0,0 +1,27 @@ +package interfaces + +import ( + "context" + "github.com/apiqube/cli/internal/core/manifests/kinds/plan" +) + +type ExecutorRegistry interface { + Register(kind string, exec Executor) + Find(kind string) (Executor, bool) +} + +type Executor interface { + Run(ctx ExecutionContext) error +} + +type PlanRunner interface { + RunPlan(ctx ExecutionContext, plan *plan.Plan) error +} + +type ExecutionContext interface { + context.Context + ManifestStore + DataStore + PassStore + OutputStore +} diff --git a/internal/core/runner/interfaces/events.go b/internal/core/runner/interfaces/events.go new file mode 100644 index 0000000..845204c --- /dev/null +++ b/internal/core/runner/interfaces/events.go @@ -0,0 +1,17 @@ +package interfaces + +import "time" + +type EventEmitter interface { + Emit(event Event) + Subscribe(handler EventHandler) + Unsubscribe(handler EventHandler) +} + +type Event struct { + Type string // e.g. "case.started", "case.finished", "plan.error" + Timestamp time.Time + Payload map[string]any +} + +type EventHandler func(event Event) diff --git a/internal/core/runner/interfaces/hooks.go b/internal/core/runner/interfaces/hooks.go new file mode 100644 index 0000000..7e9ed03 --- /dev/null +++ b/internal/core/runner/interfaces/hooks.go @@ -0,0 +1,19 @@ +package interfaces + +type HookRunner interface { + RunHook(event HookEvent, ctx ExecutionContext, metadata map[string]any) error + RegisterHookHandler(event HookEvent, handler HookHandler) +} + +type HookEvent string + +const ( + beforeRun HookEvent = "beforeRun" + afterRun HookEvent = "afterRun" + BeforeStage HookEvent = "beforeStage" + AfterStage HookEvent = "afterStage" + OnSuccess HookEvent = "onSuccess" + OnFailure HookEvent = "onFailure" +) + +type HookHandler func(ctx ExecutionContext, metadata map[string]any) error diff --git a/internal/core/runner/interfaces/log.go b/internal/core/runner/interfaces/log.go new file mode 100644 index 0000000..2d6e205 --- /dev/null +++ b/internal/core/runner/interfaces/log.go @@ -0,0 +1,12 @@ +package interfaces + +type LogLevel uint8 + +const ( + DebugLevel LogLevel = iota + 1 + InfoLevel + WarnLevel + ErrorLevel + FatalLevel + PanicLevel +) diff --git a/internal/core/runner/interfaces/metrics.go b/internal/core/runner/interfaces/metrics.go new file mode 100644 index 0000000..2057de2 --- /dev/null +++ b/internal/core/runner/interfaces/metrics.go @@ -0,0 +1,26 @@ +package interfaces + +type MetricStore interface { + RegisterMetric(name string, metricType MetricType) + SetMetric(name string, value float64) + GetMetric(name string) (float64, bool) + Aggregate(name string, method AggregationMethod) (float64, error) + AllMetrics() map[string]float64 +} + +type MetricType string + +const ( + GaugeMetric MetricType = "gauge" + CounterMetric MetricType = "counter" + HistogramMetric MetricType = "histogram" +) + +type AggregationMethod string + +const ( + SumAggregation AggregationMethod = "sum" + AvgAggregation AggregationMethod = "avg" + MinAggregation AggregationMethod = "min" + MaxAggregation AggregationMethod = "max" +) diff --git a/internal/core/runner/interfaces/output.go b/internal/core/runner/interfaces/output.go new file mode 100644 index 0000000..15d3668 --- /dev/null +++ b/internal/core/runner/interfaces/output.go @@ -0,0 +1,35 @@ +package interfaces + +import ( + "github.com/apiqube/cli/internal/core/manifests" +) + +type Output interface { + StartCase(manifest manifests.Manifest, caseName string) + EndCase(manifest manifests.Manifest, caseName string, result *CaseResult) + ReceiveMsg(msg any) + Log(level LogLevel, msg string) + Logf(level LogLevel, format string, args ...any) + DumpValues(values map[string]any) + Error(err error) +} + +type Message struct { + Format string + Values []any +} + +type Progress struct { + Stage string + Step string + Total int + Done int +} + +type MetricResult struct { + Name string + Value float64 + Unit string + Warn float64 + Error float64 +} diff --git a/internal/core/runner/interfaces/result.go b/internal/core/runner/interfaces/result.go new file mode 100644 index 0000000..ecd375d --- /dev/null +++ b/internal/core/runner/interfaces/result.go @@ -0,0 +1,15 @@ +package interfaces + +import ( + "time" +) + +type CaseResult struct { + Name string + Success bool + StatusCode int + Duration time.Duration + Errors []string + Values map[string]any + Details map[string]any +} diff --git a/internal/core/runner/interfaces/store.go b/internal/core/runner/interfaces/store.go new file mode 100644 index 0000000..8c9205d --- /dev/null +++ b/internal/core/runner/interfaces/store.go @@ -0,0 +1,39 @@ +package interfaces + +import ( + "github.com/apiqube/cli/internal/core/manifests" + "reflect" +) + +type ManifestStore interface { + GetManifest(id string) (manifests.Manifest, error) +} + +type DataStore interface { + Set(key string, value any) + Get(key string) (any, bool) + Delete(key string) + All() map[string]any + + SetTyped(key string, value any, kind reflect.Kind) + GetTyped(key string) (any, reflect.Kind, bool) + AsString(key string) (string, error) + AsInt(key string) (int64, error) + AsFloat(key string) (float64, error) + AsBool(key string) (bool, error) + AsStringSlice(key string) ([]string, error) + AsIntSlice(key string) ([]int, error) + AsMap(key string) (map[string]any, error) +} + +type PassStore interface { + Channel(key string) chan any + ChannelT(key string, kind reflect.Kind) chan any + SafeSend(key string, val any) +} + +type OutputStore interface { + SendOutput(msg any) + GetOutput() Output + SetOutput(out Output) +} From 50b466772a3ab9337c4694e38ba1750c09df014e Mon Sep 17 00:00:00 2001 From: Nofre Date: Sun, 18 May 2025 20:23:32 +0200 Subject: [PATCH 5/9] chore(output): added forgotten creation cli output method --- internal/core/runner/cli/output.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/core/runner/cli/output.go b/internal/core/runner/cli/output.go index 7c394fd..3e421dd 100644 --- a/internal/core/runner/cli/output.go +++ b/internal/core/runner/cli/output.go @@ -12,6 +12,10 @@ var _ interfaces.Output = (*Output)(nil) type Output struct{} +func NewOutput() *Output { + return &Output{} +} + func (o *Output) StartCase(manifest manifests.Manifest, caseName string) { ui.Infof("Start %s case from %s manifest", caseName, manifest.GetName()) } From 8470ad460bbfaba37b97e6183e932b2304e5adc6 Mon Sep 17 00:00:00 2001 From: Nofre Date: Mon, 19 May 2025 03:22:16 +0200 Subject: [PATCH 6/9] feat(plan): realized plan generator and plan validation, added generator and builder, some fixies and refactors --- cmd/cli/apply.go | 2 +- cmd/cli/check.go | 204 ++++++++++++++++++ cmd/cli/generate.go | 17 ++ cmd/cli/root.go | 7 +- cmd/cli/search.go | 184 ++++++++-------- examples/plan/plan.yaml | 13 +- examples/simple/http_test.yaml | 3 - examples/simple/values.yaml | 2 +- go.mod | 3 +- go.sum | 2 + internal/config/cli.go | 7 +- internal/core/manifests/kinds/plan/plan.go | 25 ++- internal/core/manifests/kinds/tests/base.go | 5 +- internal/core/manifests/loader/loader.go | 28 +-- internal/core/manifests/parsing/parse.go | 1 + internal/core/manifests/utils/id_helpers.go | 52 ++++- internal/core/runner/cli/output.go | 3 +- internal/core/runner/context/builder.go | 5 +- internal/core/runner/context/context.go | 5 +- internal/core/runner/interfaces/context.go | 1 + internal/core/runner/interfaces/store.go | 3 +- internal/core/runner/plan/builder.go | 74 +++++++ internal/core/runner/plan/graph.go | 72 +++++++ internal/core/runner/plan/manager.go | 227 ++++++++++++++++++++ internal/core/store/db.go | 10 + 25 files changed, 796 insertions(+), 159 deletions(-) create mode 100644 cmd/cli/check.go create mode 100644 cmd/cli/generate.go create mode 100644 internal/core/runner/plan/builder.go create mode 100644 internal/core/runner/plan/graph.go create mode 100644 internal/core/runner/plan/manager.go diff --git a/cmd/cli/apply.go b/cmd/cli/apply.go index 84de9d8..7cc5bbc 100644 --- a/cmd/cli/apply.go +++ b/cmd/cli/apply.go @@ -8,7 +8,7 @@ import ( ) func init() { - applyCmd.Flags().StringP("file", "f", ".", "Path to manifests file") + applyCmd.Flags().StringP("file", "f", ".", "Path to manifests file, by default is current") rootCmd.AddCommand(applyCmd) } diff --git a/cmd/cli/check.go b/cmd/cli/check.go new file mode 100644 index 0000000..5a7b187 --- /dev/null +++ b/cmd/cli/check.go @@ -0,0 +1,204 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/manifests/kinds/plan" + "github.com/apiqube/cli/internal/core/manifests/loader" + runner "github.com/apiqube/cli/internal/core/runner/plan" + "github.com/apiqube/cli/internal/core/store" + "github.com/apiqube/cli/ui" + "github.com/spf13/cobra" +) + +var checkCmd = &cobra.Command{ + Use: "check", + Short: "Check validity of manifests, plans or full configurations", +} + +var checkManifestCmd = &cobra.Command{ + Use: "manifest", + Short: "Validate individual manifests", + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} + +var checkPlanCmd = &cobra.Command{ + Use: "plan", + Short: "Validate a plan manifest", + RunE: func(cmd *cobra.Command, args []string) error { + opts, err := parseCheckPlanFlags(cmd, args) + if err != nil { + ui.Errorf("Failed to parse provided values: %v", err) + return err + } + + if !opts.flagsSet["id"] && + !opts.flagsSet["name"] && + !opts.flagsSet["namespace"] && + !opts.flagsSet["file"] { + ui.Errorf("At least one check plan filter must be specified") + return nil + } + + ui.Spinner(true, "Checking manifests...") + defer ui.Spinner(false) + + var loadedMans []manifests.Manifest + var man manifests.Manifest + query := store.NewQuery() + withQuery := false + + if opts.flagsSet["id"] { + if loadedMans, err = store.Load(store.LoadOptions{ + IDs: []string{opts.id}, + }); err != nil { + ui.Errorf("Failed to load manifest: %v", err) + return nil + } + } else if opts.flagsSet["name"] { + query.WithExactName(opts.name) + withQuery = true + } else if opts.flagsSet["namespace"] { + query.WithNamespace(opts.namespace) + withQuery = true + } else if opts.flagsSet["file"] { + if loadedMans, _, err = loader.LoadManifests(opts.file); err != nil { + ui.Errorf("Failed to load manifest: %v", err) + return nil + } + + ui.Infof("Manifests from provieded path %s loaded", opts.file) + } + + if withQuery { + loadedMans, err = store.Search(query) + if err != nil { + ui.Errorf("Failed to search plan manifests: %v", err) + return nil + } + } + + if man, err = findManifestWithKind(manifests.PlanManifestKind, loadedMans); err != nil { + ui.Errorf("Failed to check plan manifest: %v", err) + return nil + } + + if planToCheck, ok := man.(*plan.Plan); ok { + manifestIds := planToCheck.GetAllManifests() + + if loadedMans, err = store.Load(store.LoadOptions{ + IDs: manifestIds, + }); err != nil { + ui.Errorf("Failed to load plan manifests: %v", err) + } + + builder := runner.NewPlanManagerBuilder().WithManifests(loadedMans...) + generator := builder.Build() + + if generator.CheckPlan(planToCheck) != nil { + ui.Errorf("Failed to check plan: %s", err) + return nil + } + } else { + ui.Errorf("Failed to check plan, manifest found but not plan manifest") + return nil + } + + ui.Successf("Successfully checked plan manifest") + return nil + }, +} + +var checkAllCmd = &cobra.Command{ + Use: "all", + Short: "Validate full manifest set (plan + dependencies + tests)", + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} + +func init() { + checkManifestCmd.Flags().String("id", "", "Full manifest ID to check (namespace.kind.name)") + checkManifestCmd.Flags().String("kind", "", "kind of manifest (e.g., HttpTest, Server, Values)") + checkManifestCmd.Flags().String("name", "", "name of manifest") + checkManifestCmd.Flags().String("namespace", "", "namespace of manifest") + checkManifestCmd.Flags().String("file", "", "Path to manifest file to check") + + checkPlanCmd.Flags().String("id", "", "Full plan ID to check (namespace.Plan.name)") + checkPlanCmd.Flags().StringP("name", "n", "", "name of plan") + checkPlanCmd.Flags().StringP("namespace", "s", "", "namespace of manifest") + checkPlanCmd.Flags().StringP("file", "f", "", "Path to plan.yaml") + + checkAllCmd.Flags().String("path", ".", "Path to directory with manifests to check") + + checkCmd.AddCommand(checkManifestCmd) + checkCmd.AddCommand(checkPlanCmd) + checkCmd.AddCommand(checkAllCmd) + + rootCmd.AddCommand(checkCmd) +} + +type ( + checkPlanOptions struct { + id string + name string + namespace string + file string + + flagsSet map[string]bool + } +) + +func parseCheckPlanFlags(cmd *cobra.Command, _ []string) (*checkPlanOptions, error) { + opts := &checkPlanOptions{ + flagsSet: make(map[string]bool), + } + + markFlag := func(name string) bool { + if cmd.Flags().Changed(name) { + opts.flagsSet[name] = true + return true + } + return false + } + + if markFlag("id") { + opts.id, _ = cmd.Flags().GetString("id") + } + if markFlag("name") { + opts.name, _ = cmd.Flags().GetString("name") + } + if markFlag("namespace") { + opts.namespace, _ = cmd.Flags().GetString("namespace") + } + + if markFlag("file") { + var file string + file, _ = cmd.Flags().GetString("file") + if strings.HasSuffix(file, ".yml") || strings.HasSuffix(file, ".yaml") { + opts.file = file + } else { + return nil, fmt.Errorf("--file flag must end with .yml or .yaml") + } + } + + if opts.flagsSet["id"] || (opts.flagsSet["name"] || (opts.flagsSet["namespace"] && opts.flagsSet["file"])) { + return nil, fmt.Errorf("cannot use all filters at the same time") + } + + return opts, nil +} + +func findManifestWithKind(kind string, mans []manifests.Manifest) (manifests.Manifest, error) { + for i, man := range mans { + if man.GetKind() == kind { + return mans[i], nil + } + } + + return nil, fmt.Errorf("expected manifest with %s kind not found", kind) +} diff --git a/cmd/cli/generate.go b/cmd/cli/generate.go new file mode 100644 index 0000000..f257178 --- /dev/null +++ b/cmd/cli/generate.go @@ -0,0 +1,17 @@ +package cli + +import "github.com/spf13/cobra" + +var generateCmd = &cobra.Command{ + Use: "generate", + Short: "Generate manifests with provided flags", + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} + +func init() { + rootCmd.AddCommand(generateCmd) +} diff --git a/cmd/cli/root.go b/cmd/cli/root.go index 349e3bc..bcc8820 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -3,10 +3,15 @@ package cli import ( "context" "fmt" + "github.com/apiqube/cli/internal/config" "github.com/spf13/cobra" ) +type contextKey string + +var configKey contextKey = "config" + var rootCmd = &cobra.Command{ Use: "qube", Short: "ApiQube is a powerful test manager for APIs", @@ -16,7 +21,7 @@ var rootCmd = &cobra.Command{ return fmt.Errorf("config init failed: %w", err) } - cmd.SetContext(context.WithValue(cmd.Context(), "config", cfg)) + cmd.SetContext(context.WithValue(cmd.Context(), configKey, cfg)) return nil }, } diff --git a/cmd/cli/search.go b/cmd/cli/search.go index 8ef36ae..227405c 100644 --- a/cmd/cli/search.go +++ b/cmd/cli/search.go @@ -32,7 +32,7 @@ var searchCmd = &cobra.Command{ var manifests []manifests.Manifest - if !opts.All && + if !opts.all && !opts.flagsSet["name"] && !opts.flagsSet["name-wildcard"] && !opts.flagsSet["name-regex"] && @@ -62,61 +62,61 @@ var searchCmd = &cobra.Command{ query := store.NewQuery() if opts.flagsSet["name"] { - query.WithExactName(opts.Name) + query.WithExactName(opts.name) } else if opts.flagsSet["name-wildcard"] { - query.WithWildcardName(opts.NameWildcard) + query.WithWildcardName(opts.nameWildcard) } else if opts.flagsSet["name-regex"] { - query.WithRegexName(opts.NameRegex) + query.WithRegexName(opts.nameRegex) } if opts.flagsSet["namespace"] { - query.WithNamespace(opts.Namespace) + query.WithNamespace(opts.namespace) } if opts.flagsSet["kind"] { - query.WithKind(opts.Kind) + query.WithKind(opts.kind) } if opts.flagsSet["version"] { - query.WithVersion(opts.Version) + query.WithVersion(opts.version) } if opts.flagsSet["created-by"] { - query.WithCreatedBy(opts.CreatedBy) + query.WithCreatedBy(opts.createdBy) } if opts.flagsSet["user-by"] { - query.WithUsedBy(opts.UsedBy) + query.WithUsedBy(opts.usedBy) } if opts.flagsSet["hash"] { - query.WithHashPrefix(opts.HashPrefix) + query.WithHashPrefix(opts.hashPrefix) } if opts.flagsSet["depends"] { - query.WithDependencies(opts.DependsOn) + query.WithDependencies(opts.dependsOn) } else if opts.flagsSet["depends-all"] { - query.WithAllDependencies(opts.DependsOnAll) + query.WithAllDependencies(opts.dependsOnAll) } if opts.flagsSet["created-after"] { - query.WithCreatedAfter(opts.CreatedAfter) + query.WithCreatedAfter(opts.createdAfter) } if opts.flagsSet["created-before"] { - query.WithCreatedBefore(opts.CreatedBefore) + query.WithCreatedBefore(opts.createdBefore) } if opts.flagsSet["updated-after"] { - query.WithUpdatedAfter(opts.UpdatedAfter) + query.WithUpdatedAfter(opts.updatedAfter) } if opts.flagsSet["updated-before"] { - query.WithUpdatedBefore(opts.UpdatedBefore) + query.WithUpdatedBefore(opts.updatedBefore) } if opts.flagsSet["last-applied"] { - query.WithLastApplied(opts.LastApplied) + query.WithLastApplied(opts.lastApplied) } manifests, err = store.Search(query) @@ -133,13 +133,13 @@ var searchCmd = &cobra.Command{ ui.Infof("Found %d manifests", len(manifests)) - if len(opts.SortBy) > 0 { - sortManifests(manifests, opts.SortBy) + if len(opts.sortBy) > 0 { + sortManifests(manifests, opts.sortBy) } ui.Spinner(true, "Prepare answer...") - if opts.Output { + if opts.output { if err := outputManifests(manifests, opts); err != nil { ui.Spinner(false) ui.Errorf("Failed to output manifests: %v", err) @@ -179,8 +179,8 @@ func init() { searchCmd.Flags().String("last-applied", "", "Search manifests by last applied date/duration") searchCmd.Flags().BoolP("output", "o", false, "Make output after searching") - searchCmd.Flags().String("output-path", "", "Output path for results (default: current directory)") - searchCmd.Flags().String("output-mode", "separate", "Output mode (combined|separate)") + searchCmd.Flags().String("output-path", "", "output path for results (default: current directory)") + searchCmd.Flags().String("output-mode", "separate", "output mode (combined|separate)") searchCmd.Flags().String("output-format", "yaml", "File format for output (yaml|json)") searchCmd.Flags().StringSlice("sort", []string{}, "Sort by fields (e.g. --sort=kind,-name)") @@ -188,42 +188,42 @@ func init() { rootCmd.AddCommand(searchCmd) } -type SearchOptions struct { - All bool +type searchOptions struct { + all bool - Name string - NameWildcard string - NameRegex string + name string + nameWildcard string + nameRegex string - Namespace string - Kind string - Version int - CreatedBy string - UsedBy string + namespace string + kind string + version int + createdBy string + usedBy string - HashPrefix string - DependsOn []string - DependsOnAll []string + hashPrefix string + dependsOn []string + dependsOnAll []string - CreatedAfter time.Time - CreatedBefore time.Time - UpdatedAfter time.Time - UpdatedBefore time.Time - LastApplied time.Time - IsRelativeTime bool + createdAfter time.Time + createdBefore time.Time + updatedAfter time.Time + updatedBefore time.Time + lastApplied time.Time + isRelativeTime bool - Output bool - OutputPath string - OutputMode string // combined | separate - OutputFormat string // yaml | json + output bool + outputPath string + outputMode string // combined | separate + outputFormat string // yaml | json - SortBy []string + sortBy []string flagsSet map[string]bool } -func parseSearchFlags(cmd *cobra.Command, _ []string) (*SearchOptions, error) { - opts := &SearchOptions{ +func parseSearchFlags(cmd *cobra.Command, _ []string) (*searchOptions, error) { + opts := &searchOptions{ flagsSet: make(map[string]bool), } @@ -236,17 +236,17 @@ func parseSearchFlags(cmd *cobra.Command, _ []string) (*SearchOptions, error) { } if markFlag("all") { - opts.All, _ = cmd.Flags().GetBool("all") + opts.all, _ = cmd.Flags().GetBool("all") } if markFlag("name") { - opts.Name, _ = cmd.Flags().GetString("name") + opts.name, _ = cmd.Flags().GetString("name") } if markFlag("name-wildcard") { - opts.NameWildcard, _ = cmd.Flags().GetString("name-wildcard") + opts.nameWildcard, _ = cmd.Flags().GetString("name-wildcard") } if markFlag("name-regex") { - opts.NameRegex, _ = cmd.Flags().GetString("name-regex") + opts.nameRegex, _ = cmd.Flags().GetString("name-regex") } if opts.flagsSet["name"] && (opts.flagsSet["name-wildcard"] || opts.flagsSet["name-regex"]) { @@ -254,39 +254,39 @@ func parseSearchFlags(cmd *cobra.Command, _ []string) (*SearchOptions, error) { } if markFlag("namespace") { - opts.Namespace, _ = cmd.Flags().GetString("namespace") + opts.namespace, _ = cmd.Flags().GetString("namespace") } if markFlag("kind") { - opts.Kind, _ = cmd.Flags().GetString("kind") + opts.kind, _ = cmd.Flags().GetString("kind") } if markFlag("version") { - opts.Version, _ = cmd.Flags().GetInt("version") + opts.version, _ = cmd.Flags().GetInt("version") } if markFlag("created-by") { - opts.CreatedBy, _ = cmd.Flags().GetString("created-by") + opts.createdBy, _ = cmd.Flags().GetString("created-by") } if markFlag("used-by") { - opts.UsedBy, _ = cmd.Flags().GetString("used-by") + opts.usedBy, _ = cmd.Flags().GetString("used-by") } if markFlag("hash") { - opts.HashPrefix, _ = cmd.Flags().GetString("hash") - if len(opts.HashPrefix) < 5 { + opts.hashPrefix, _ = cmd.Flags().GetString("hash") + if len(opts.hashPrefix) < 5 { return nil, fmt.Errorf("hash prefix must be at least 5 characters") } } if markFlag("depends") { - opts.DependsOn, _ = cmd.Flags().GetStringSlice("depends") + opts.dependsOn, _ = cmd.Flags().GetStringSlice("depends") } else if markFlag("depends-all") { - opts.DependsOnAll, _ = cmd.Flags().GetStringSlice("depends-all") + opts.dependsOnAll, _ = cmd.Flags().GetStringSlice("depends-all") } timeFilters := map[string]*time.Time{ - "created-after": &opts.CreatedAfter, - "created-before": &opts.CreatedBefore, - "updated-after": &opts.UpdatedAfter, - "updated-before": &opts.UpdatedBefore, - "last-applied": &opts.LastApplied, + "created-after": &opts.createdAfter, + "created-before": &opts.createdBefore, + "updated-after": &opts.updatedAfter, + "updated-before": &opts.updatedBefore, + "last-applied": &opts.lastApplied, } for flag, target := range timeFilters { @@ -294,7 +294,7 @@ func parseSearchFlags(cmd *cobra.Command, _ []string) (*SearchOptions, error) { val, _ := cmd.Flags().GetString(flag) if t, err := parseTimeOrDuration(val); err == nil { *target = t - opts.IsRelativeTime = isDuration(val) + opts.isRelativeTime = isDuration(val) } else { return nil, fmt.Errorf("invalid %s value: %w", flag, err) } @@ -302,37 +302,37 @@ func parseSearchFlags(cmd *cobra.Command, _ []string) (*SearchOptions, error) { } if markFlag("output") { - opts.Output, _ = cmd.Flags().GetBool("output") - if opts.Output { + opts.output, _ = cmd.Flags().GetBool("output") + if opts.output { if markFlag("output-path") { - opts.OutputPath, _ = cmd.Flags().GetString("output-path") + opts.outputPath, _ = cmd.Flags().GetString("output-path") } - if opts.OutputPath == "" { - opts.OutputPath = "." + if opts.outputPath == "" { + opts.outputPath = "." } if markFlag("output-mode") { - opts.OutputMode, _ = cmd.Flags().GetString("output-mode") - if opts.OutputMode != "combined" && opts.OutputMode != "separate" { + opts.outputMode, _ = cmd.Flags().GetString("output-mode") + if opts.outputMode != "combined" && opts.outputMode != "separate" { return nil, fmt.Errorf("invalid output mode, must be 'combined' or 'separate'") } } - if opts.OutputMode == "" { - opts.OutputMode = "separate" + if opts.outputMode == "" { + opts.outputMode = "separate" } if markFlag("output-format") { - opts.OutputFormat, _ = cmd.Flags().GetString("output-format") - if opts.OutputFormat != "yaml" && opts.OutputFormat != "json" { + opts.outputFormat, _ = cmd.Flags().GetString("output-format") + if opts.outputFormat != "yaml" && opts.outputFormat != "json" { return nil, fmt.Errorf("invalid output format, must be 'yaml' or 'json'") } } - if opts.OutputFormat == "" { - opts.OutputFormat = "yaml" + if opts.outputFormat == "" { + opts.outputFormat = "yaml" } } } if markFlag("sort") { - opts.SortBy, _ = cmd.Flags().GetStringSlice("sort") + opts.sortBy, _ = cmd.Flags().GetStringSlice("sort") } return opts, nil @@ -359,18 +359,18 @@ func isDuration(val string) bool { return err == nil } -func outputManifests(manifests []manifests.Manifest, opts *SearchOptions) error { - if err := os.MkdirAll(opts.OutputPath, 0o755); err != nil { +func outputManifests(manifests []manifests.Manifest, opts *searchOptions) error { + if err := os.MkdirAll(opts.outputPath, 0o755); err != nil { return fmt.Errorf("failed to create output directory: %w", err) } - if opts.OutputMode == "combined" { - filename := filepath.Join(opts.OutputPath, fmt.Sprintf("manifests.%s", opts.OutputFormat)) - return writeCombinedFile(filename, manifests, opts.OutputFormat) + if opts.outputMode == "combined" { + filename := filepath.Join(opts.outputPath, fmt.Sprintf("manifests.%s", opts.outputFormat)) + return writeCombinedFile(filename, manifests, opts.outputFormat) } else { for _, m := range manifests { - filename := filepath.Join(opts.OutputPath, fmt.Sprintf("%s.%s", m.GetID(), opts.OutputFormat)) - if err := writeSingleFile(filename, m, opts.OutputFormat); err != nil { + filename := filepath.Join(opts.outputPath, fmt.Sprintf("%s.%s", m.GetID(), opts.outputFormat)) + if err := writeSingleFile(filename, m, opts.outputFormat); err != nil { return err } } @@ -531,10 +531,10 @@ func displayResults(manifests []manifests.Manifest) { headers := []string{ "#", "Hash", - "Kind", - "Name", - "Namespace", - "Version", + "kind", + "name", + "namespace", + "version", "Created", "Updated", "Last Updated", diff --git a/examples/plan/plan.yaml b/examples/plan/plan.yaml index e8ff9e2..67b3cc5 100644 --- a/examples/plan/plan.yaml +++ b/examples/plan/plan.yaml @@ -9,18 +9,13 @@ spec: - name: "Preparation..." description: "First stage with loading values to context" manifests: - - Values.values-example + - Values.simple-value - name: "Starting and checking server" manifests: - - Server.simple-server - - Service.simple-service + - default.Server.simple-server + - default.Server.simple-server - name: "Testing APIs" manifests: - - HttpTest.simple-http-test - - HttpTest.not-simple-http-test - - - name: "APIs loading testing" - manifests: - - HttpLoadTest.simple-http-load-test \ No newline at end of file + - default.HttpTest.simple-http-test \ No newline at end of file diff --git a/examples/simple/http_test.yaml b/examples/simple/http_test.yaml index d0d78dc..d8b60d4 100644 --- a/examples/simple/http_test.yaml +++ b/examples/simple/http_test.yaml @@ -36,6 +36,3 @@ spec: expected: code: 201 message: "User not found" - -dependsOn: - - default.Service.simple-service diff --git a/examples/simple/values.yaml b/examples/simple/values.yaml index b8172c5..6a594c3 100644 --- a/examples/simple/values.yaml +++ b/examples/simple/values.yaml @@ -2,7 +2,7 @@ version: 1 kind: Values metadata: - name: values-example + name: simple-value spec: users: diff --git a/go.mod b/go.mod index 5f2c11c..acc1771 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,9 @@ require ( github.com/charmbracelet/bubbletea v1.3.5 github.com/charmbracelet/lipgloss v1.1.0 github.com/dgraph-io/badger/v4 v4.7.0 + github.com/google/uuid v1.6.0 github.com/spf13/cobra v1.9.1 + github.com/spf13/viper v1.20.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -66,7 +68,6 @@ require ( github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect - github.com/spf13/viper v1.20.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.etcd.io/bbolt v1.4.0 // indirect diff --git a/go.sum b/go.sum index 74b9bfc..6e4dfd4 100644 --- a/go.sum +++ b/go.sum @@ -71,6 +71,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= diff --git a/internal/config/cli.go b/internal/config/cli.go index 990dcb2..6fa8391 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -3,11 +3,12 @@ package config import ( "errors" "fmt" - "github.com/adrg/xdg" - "github.com/spf13/viper" "os" "path/filepath" "strings" + + "github.com/adrg/xdg" + "github.com/spf13/viper" ) const ( @@ -56,7 +57,7 @@ func InitConfig() (*CLIConfig, error) { } func createDefaultConfig(configPath string) error { - if err := os.MkdirAll(configPath, 0755); err != nil { + if err := os.MkdirAll(configPath, 0o755); err != nil { return fmt.Errorf("failed to create config dir: %w", err) } diff --git a/internal/core/manifests/kinds/plan/plan.go b/internal/core/manifests/kinds/plan/plan.go index be5aadb..75da5bb 100644 --- a/internal/core/manifests/kinds/plan/plan.go +++ b/internal/core/manifests/kinds/plan/plan.go @@ -1,10 +1,13 @@ package plan import ( - "github.com/apiqube/cli/internal/core/manifests/utils" + "fmt" "strings" "time" + "github.com/apiqube/cli/internal/core/manifests/utils" + "github.com/google/uuid" + "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/manifests/index" "github.com/apiqube/cli/internal/core/manifests/kinds" @@ -33,7 +36,7 @@ type Stage struct { Manifests []string `yaml:"manifests" json:"manifests"` Parallel bool `yaml:"parallel,omitempty" json:"parallel,omitempty"` Params map[string]any `yaml:"params,omitempty" json:"params,omitempty"` - Mode string `yaml:"mode,omitempty" json:"mode,omitempty"` // eg parallel mode, (strict|lite) + Mode string `yaml:"mode,omitempty" json:"mode,omitempty"` // (strict|parallel) Hooks Hooks `yaml:"hooks,omitempty" json:"hooks,omitempty"` } @@ -90,6 +93,14 @@ func (p *Plan) GetMeta() manifests.Meta { } func (p *Plan) Default() { + if p.Version <= 0 { + p.Version = 1 + } + + if p.Name == "" { + p.Name = fmt.Sprintf("%s-%s", "generated", uuid.NewString()[:8]) + } + if p.Kind == "" { p.Kind = manifests.PlanManifestKind } @@ -130,3 +141,13 @@ func (p *Plan) Prepare() { p.Spec.Stages[i] = stage } } + +func (p *Plan) GetAllManifests() []string { + var results []string + + for _, stage := range p.Spec.Stages { + results = append(results, stage.Manifests...) + } + + return results +} diff --git a/internal/core/manifests/kinds/tests/base.go b/internal/core/manifests/kinds/tests/base.go index a054958..8381581 100644 --- a/internal/core/manifests/kinds/tests/base.go +++ b/internal/core/manifests/kinds/tests/base.go @@ -40,7 +40,6 @@ type Save struct { } type Pass struct { - From string `yaml:"from" json:"from"` - Map map[string]string `yaml:"map,omitempty" json:"map,omitempty"` - Inline bool `yaml:"inline,omitempty" json:"inline,omitempty"` + From string `yaml:"from" json:"from"` + Map map[string]string `yaml:"map,omitempty" json:"map,omitempty"` } diff --git a/internal/core/manifests/loader/loader.go b/internal/core/manifests/loader/loader.go index 69c078f..6c5baae 100644 --- a/internal/core/manifests/loader/loader.go +++ b/internal/core/manifests/loader/loader.go @@ -50,7 +50,6 @@ func LoadManifests(path string) (new []manifests.Manifest, cached []manifests.Ma cachedCount += len(fileCached) } - logResults(newCount, cachedCount) return new, cached, nil } @@ -80,7 +79,7 @@ func processFile(filePath string, manifestsSet map[string]struct{}) (new []manif manifestID := m.GetID() var normalized []byte - normalized, err = normalizeYAML(m) + normalized, err = NormalizeYAML(m) if err != nil { return nil, nil, fmt.Errorf("failed to normalize manifest %s: %w", manifestID, err) } @@ -105,9 +104,6 @@ func processFile(filePath string, manifestsSet map[string]struct{}) (new []manif if existingManifest != nil { if _, exists := manifestsSet[existingManifest.GetID()]; !exists { manifestsSet[existingManifest.GetID()] = struct{}{} - ui.Infof("Manifest %s unchanged (%s) - using cached version", - existingManifest.GetID(), shortHash(manifestHash)) - cachedManifests = append(cachedManifests, existingManifest) } continue } @@ -125,32 +121,12 @@ func processFile(filePath string, manifestsSet map[string]struct{}) (new []manif manifestsSet[manifestID] = struct{}{} newManifests = append(newManifests, m) - ui.Successf("New manifest added: %s (h: %s)", manifestID, shortHash(manifestHash)) } return newManifests, cachedManifests, nil } -func logResults(cachedCount, newCount int) { - if cachedCount > 0 { - ui.Infof("Loaded %d cached manifests", cachedCount) - } - if newCount > 0 { - ui.Infof("Loaded %d new manifests", newCount) - } - if cachedCount == 0 && newCount == 0 { - ui.Info("No manifests found") - } -} - -func shortHash(fullHash string) string { - if len(fullHash) > 12 { - return fullHash[:12] + "..." - } - return fullHash -} - -func normalizeYAML(m manifests.Manifest) ([]byte, error) { +func NormalizeYAML(m manifests.Manifest) ([]byte, error) { data, err := yaml.Marshal(m) if err != nil { return nil, err diff --git a/internal/core/manifests/parsing/parse.go b/internal/core/manifests/parsing/parse.go index ea2e96d..7744221 100644 --- a/internal/core/manifests/parsing/parse.go +++ b/internal/core/manifests/parsing/parse.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/apiqube/cli/internal/core/manifests/kinds/plan" "github.com/apiqube/cli/internal/core/manifests/kinds/values" diff --git a/internal/core/manifests/utils/id_helpers.go b/internal/core/manifests/utils/id_helpers.go index cdd1ea6..78e4be4 100644 --- a/internal/core/manifests/utils/id_helpers.go +++ b/internal/core/manifests/utils/id_helpers.go @@ -2,29 +2,59 @@ package utils import ( "fmt" - "github.com/apiqube/cli/internal/core/manifests" "strings" + + "github.com/google/uuid" + + "github.com/apiqube/cli/internal/core/manifests" ) -func ParseManifestID(id string) (string, string, string) { +func ParseManifestID(id string) (namespace, kind, name string) { parts := strings.Split(id, ".") - if len(parts) == 3 { - return parts[0], parts[1], parts[2] - } else if len(parts) == 2 { - return manifests.DefaultNamespace, parts[0], parts[1] - } else { + + switch len(parts) { + case 3: + namespace = parts[0] + kind = parts[1] + name = parts[2] + case 2: + namespace = manifests.DefaultNamespace + kind = parts[0] + name = parts[1] + case 1: + namespace = manifests.DefaultNamespace + kind = parts[0] + name = generateDefaultName() + default: + return "", "", "" + } + + if kind == "" { return "", "", "" } + + return namespace, kind, name } func ParseManifestIDWithError(id string) (string, string, string, error) { namespace, kind, name := ParseManifestID(id) + + if kind == "" { + return "", "", "", fmt.Errorf("manifest kind not specified: %s", id) + } + + if name == "" { + name = generateDefaultName() + } + if namespace == "" { namespace = manifests.DefaultNamespace - } else if kind == "" { - return namespace, name, name, fmt.Errorf("manifest kind not specified") - } else if name == "" { - return namespace, kind, name, fmt.Errorf("manifest name not specified") } + return namespace, kind, name, nil } + +func generateDefaultName() string { + uuidStr := uuid.NewString() + return "m-" + strings.Split(uuidStr, "-")[0] +} diff --git a/internal/core/runner/cli/output.go b/internal/core/runner/cli/output.go index 3e421dd..c9808ea 100644 --- a/internal/core/runner/cli/output.go +++ b/internal/core/runner/cli/output.go @@ -2,10 +2,11 @@ package cli import ( "fmt" + "strings" + "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/runner/interfaces" "github.com/apiqube/cli/ui" - "strings" ) var _ interfaces.Output = (*Output)(nil) diff --git a/internal/core/runner/context/builder.go b/internal/core/runner/context/builder.go index 8999c4a..b3dc18e 100644 --- a/internal/core/runner/context/builder.go +++ b/internal/core/runner/context/builder.go @@ -2,10 +2,11 @@ package context import ( "context" - "github.com/apiqube/cli/internal/core/manifests" - "github.com/apiqube/cli/internal/core/runner/interfaces" "reflect" "sync" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/runner/interfaces" ) type ValuePair struct { diff --git a/internal/core/runner/context/context.go b/internal/core/runner/context/context.go index 3f9d650..fbf7bac 100644 --- a/internal/core/runner/context/context.go +++ b/internal/core/runner/context/context.go @@ -3,11 +3,12 @@ package context import ( "context" "fmt" - "github.com/apiqube/cli/internal/core/manifests" - "github.com/apiqube/cli/internal/core/runner/interfaces" "reflect" "sync" "time" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/runner/interfaces" ) var _ interfaces.ExecutionContext = (*ctxBaseImpl)(nil) diff --git a/internal/core/runner/interfaces/context.go b/internal/core/runner/interfaces/context.go index 7a52143..663caaa 100644 --- a/internal/core/runner/interfaces/context.go +++ b/internal/core/runner/interfaces/context.go @@ -2,6 +2,7 @@ package interfaces import ( "context" + "github.com/apiqube/cli/internal/core/manifests/kinds/plan" ) diff --git a/internal/core/runner/interfaces/store.go b/internal/core/runner/interfaces/store.go index 8c9205d..5939617 100644 --- a/internal/core/runner/interfaces/store.go +++ b/internal/core/runner/interfaces/store.go @@ -1,8 +1,9 @@ package interfaces import ( - "github.com/apiqube/cli/internal/core/manifests" "reflect" + + "github.com/apiqube/cli/internal/core/manifests" ) type ManifestStore interface { diff --git a/internal/core/runner/plan/builder.go b/internal/core/runner/plan/builder.go new file mode 100644 index 0000000..5d88500 --- /dev/null +++ b/internal/core/runner/plan/builder.go @@ -0,0 +1,74 @@ +package plan + +import "github.com/apiqube/cli/internal/core/manifests" + +type RunMode string + +const ( + StrictMode RunMode = "strict" + Parallel RunMode = "parallel" +) + +func (m RunMode) String() string { + return string(m) +} + +type Builder interface { + WithManifests(manifests ...manifests.Manifest) Builder + WithManifestsMap(manifests map[string]manifests.Manifest) Builder + WithRunMode(mode string) Builder + WithStableSort(enable bool) Builder + WithParallel(parallel bool) Builder + Build() Manager +} + +type managerBuilder struct { + manifests map[string]manifests.Manifest + mode string + stableSort bool + parallel bool +} + +func NewPlanManagerBuilder() Builder { + return &managerBuilder{ + manifests: make(map[string]manifests.Manifest), + mode: StrictMode.String(), + stableSort: true, + parallel: false, + } +} + +func (b *managerBuilder) WithManifests(manifests ...manifests.Manifest) Builder { + for _, manifest := range manifests { + b.manifests[manifest.GetID()] = manifest + } + return b +} + +func (b *managerBuilder) WithManifestsMap(set map[string]manifests.Manifest) Builder { + b.manifests = set + return b +} + +func (b *managerBuilder) WithRunMode(mode string) Builder { + b.mode = mode + return b +} + +func (b *managerBuilder) WithStableSort(enable bool) Builder { + b.stableSort = enable + return b +} + +func (b *managerBuilder) WithParallel(parallel bool) Builder { + b.parallel = parallel + return b +} + +func (b *managerBuilder) Build() Manager { + return &basicManager{ + manifests: b.manifests, + mode: b.mode, + stableSort: b.stableSort, + } +} diff --git a/internal/core/runner/plan/graph.go b/internal/core/runner/plan/graph.go new file mode 100644 index 0000000..7bf741b --- /dev/null +++ b/internal/core/runner/plan/graph.go @@ -0,0 +1,72 @@ +package plan + +import ( + "errors" + "sync" +) + +type depGraph struct { + edges map[string][]string + nodes map[string]bool + lock sync.Mutex +} + +func newDepGraph() *depGraph { + return &depGraph{ + edges: map[string][]string{}, + nodes: map[string]bool{}, + } +} + +func (g *depGraph) addNode(id string) { + g.lock.Lock() + defer g.lock.Unlock() + g.nodes[id] = true +} + +func (g *depGraph) addEdge(from, to string) { + g.lock.Lock() + defer g.lock.Unlock() + g.edges[from] = append(g.edges[from], to) + g.nodes[from] = true + g.nodes[to] = true +} + +func (g *depGraph) topoSort() ([]string, error) { + inDegree := map[string]int{} + for node := range g.nodes { + inDegree[node] = 0 + } + + for _, toList := range g.edges { + for _, to := range toList { + inDegree[to]++ + } + } + + var queue []string + for node, deg := range inDegree { + if deg == 0 { + queue = append(queue, node) + } + } + + var result []string + for len(queue) > 0 { + n := queue[0] + queue = queue[1:] + result = append(result, n) + + for _, neighbor := range g.edges[n] { + inDegree[neighbor]-- + if inDegree[neighbor] == 0 { + queue = append(queue, neighbor) + } + } + } + + if len(result) != len(g.nodes) { + return nil, errors.New("cycle detected in dependency graph") + } + return result, nil +} diff --git a/internal/core/runner/plan/manager.go b/internal/core/runner/plan/manager.go new file mode 100644 index 0000000..de751c8 --- /dev/null +++ b/internal/core/runner/plan/manager.go @@ -0,0 +1,227 @@ +package plan + +import ( + "fmt" + "sort" + "strings" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/manifests/hash" + "github.com/apiqube/cli/internal/core/manifests/kinds" + "github.com/apiqube/cli/internal/core/manifests/kinds/plan" + "github.com/apiqube/cli/internal/core/manifests/loader" + "github.com/apiqube/cli/internal/core/manifests/utils" +) + +var kindPriority = map[string]int{ + "Values": 0, + "Server": 10, + "Service": 20, + "HttpTest": 30, + "HttpLoadTest": 40, +} + +const defaultPriority = 10_000 + +type Manager interface { + Generate() (*plan.Plan, error) + CheckPlan(*plan.Plan) error +} + +type basicManager struct { + manifests map[string]manifests.Manifest + mode string + stableSort bool + parallel bool +} + +func (g *basicManager) CheckPlan(pln *plan.Plan) error { + stageOrder := make(map[string]int) + seen := make(map[string]bool) + + for i, stage := range pln.Spec.Stages { + for j, id := range stage.Manifests { + namespace, kind, name, err := utils.ParseManifestIDWithError(id) + if err != nil { + return err + } + + id = kinds.FormManifestID(namespace, kind, name) + stage.Manifests[j] = id + + if seen[id] { + return fmt.Errorf("manifest %s occurs more than once in plans", id) + } + + if _, ok := g.manifests[id]; !ok { + return fmt.Errorf("in the privided plan lacks the stated manifest: %s", id) + } + + stageOrder[id] = i + seen[id] = true + } + } + + for id, m := range g.manifests { + dep, ok := m.(manifests.Dependencies) + if !ok { + continue + } + + deps := dep.GetDependsOn() + for _, depID := range deps { + i, okI := stageOrder[id] + j, okJ := stageOrder[depID] + + if !okI || !okJ { + return fmt.Errorf("manifest '%s' or its dependency '%s' not found in the plan", id, depID) + } + + if j >= i { + return fmt.Errorf( + "dependency order is broken: '%s' (stage %d) depends on '%s' (stage %d), but is located earlier or in the same stage", + id, i, depID, j, + ) + } + } + } + + return nil +} + +func (g *basicManager) Generate() (*plan.Plan, error) { + if len(g.manifests) == 0 { + return nil, fmt.Errorf("manifests not provided for generating the plan") + } + + graph := newDepGraph() + + for id, m := range g.manifests { + graph.addNode(id) + + if depend, ok := m.(manifests.Dependencies); ok { + deps := depend.GetDependsOn() + for _, depId := range deps { + if _, found := g.manifests[depId]; !found { + return nil, fmt.Errorf("manifest '%s' depends on '%s', but it was not found in the manifest set", id, depId) + } + + graph.addEdge(depId, id) + } + } + } + + sorted, err := graph.topoSort() + if err != nil { + return nil, err + } + + stages := groupByLayers(sorted, g.manifests, g.mode, g.stableSort, g.parallel) + + var newPlan plan.Plan + newPlan.Default() + + newPlan.Spec.Stages = stages + + planData, err := loader.NormalizeYAML(&newPlan) + if err != nil { + return nil, fmt.Errorf("fail while generating plan hash: %v", err) + } + + planHash, err := hash.CalculateHashWithContent(planData) + if err != nil { + return nil, fmt.Errorf("fail while calculation plan hash: %v", err) + } + + meta := newPlan.GetMeta() + meta.SetHash(planHash) + meta.SetCreatedBy("plan-generator") + + return &newPlan, nil +} + +func groupByLayers(sorted []string, mans map[string]manifests.Manifest, mode string, stable, parallel bool) []plan.Stage { + sort.SliceStable(sorted, func(i, j int) bool { + ki := mans[sorted[i]].GetKind() + kj := mans[sorted[j]].GetKind() + + pi := kindPriorityOrDefault(ki) + pj := kindPriorityOrDefault(kj) + + if pi == pj && stable { + return sorted[i] < sorted[j] + } + return pi < pj + }) + + var stages []plan.Stage + var current []string + var currentKind string + prevDeps := map[string]bool{} + + for _, id := range sorted { + m := mans[id] + kind := m.GetKind() + ready := true + + if depend, ok := m.(manifests.Dependencies); ok { + for _, dep := range depend.GetDependsOn() { + if !prevDeps[dep] { + ready = false + break + } + } + } + + if (!ready || currentKind != "" && kind != currentKind) && len(current) > 0 { + stages = append(stages, makeStage(current, mans, mode, stable, parallel)) + for _, cid := range current { + prevDeps[cid] = true + } + current = []string{} + } + + current = append(current, id) + currentKind = kind + } + + if len(current) > 0 { + stages = append(stages, makeStage(current, mans, mode, stable, parallel)) + } + + return stages +} + +func kindPriorityOrDefault(kind string) int { + if p, ok := kindPriority[kind]; ok { + return p + } + return defaultPriority +} + +func makeStage(ids []string, mans map[string]manifests.Manifest, mode string, stable, parallel bool) plan.Stage { + if stable { + sort.Strings(ids) + } + + var nameParts []string + seenKinds := make(map[string]bool) + + for _, id := range ids { + m := mans[id] + k := m.GetKind() + if !seenKinds[k] { + nameParts = append(nameParts, k) + seenKinds[k] = true + } + } + + stageName := fmt.Sprintf("stage-%s", strings.Join(nameParts, "_")) + + return plan.Stage{ + Name: stageName, + Manifests: ids, + Parallel: parallel, + Mode: mode, + } +} diff --git a/internal/core/store/db.go b/internal/core/store/db.go index e36316d..d9ed059 100644 --- a/internal/core/store/db.go +++ b/internal/core/store/db.go @@ -12,6 +12,9 @@ import ( "strings" "time" + "github.com/apiqube/cli/internal/core/manifests/kinds" + "github.com/apiqube/cli/internal/core/manifests/utils" + "github.com/apiqube/cli/internal/core/manifests/index" "github.com/adrg/xdg" @@ -352,6 +355,13 @@ func (s *Storage) loadBulk(opts LoadOptions) ([]manifests.Manifest, error) { err := instance.db.View(func(txn *badger.Txn) error { for _, id := range opts.IDs { + namespace, kind, name, err := utils.ParseManifestIDWithError(id) + if err != nil { + return err + } + + id = kinds.FormManifestID(namespace, kind, name) + item, err := txn.Get(genLatestKey(id)) if err != nil { if errors.Is(err, badger.ErrKeyNotFound) { From cca41de9041c9dcaf595e20a1b230015896bb4a3 Mon Sep 17 00:00:00 2001 From: Nofre Date: Mon, 19 May 2025 03:58:50 +0200 Subject: [PATCH 7/9] refactor(cmd): cmd refactored, big functions splitted, cmds splitted on different packages --- cmd/cli/apply.go | 49 --- cmd/cli/apply/apply.go | 58 +++ cmd/cli/check.go | 204 ---------- cmd/cli/check/check.go | 218 +++++++++++ cmd/cli/{ => cleanup}/cleanup.go | 32 +- cmd/cli/{ => generator}/generate.go | 12 +- cmd/cli/{ => rollback}/rollback.go | 33 +- cmd/cli/root.go | 15 + cmd/cli/search.go | 568 ---------------------------- cmd/cli/search/execution.go | 79 ++++ cmd/cli/search/options.go | 239 ++++++++++++ cmd/cli/search/results.go | 252 ++++++++++++ cmd/cli/search/search.go | 74 ++++ ui/helpers.go | 9 + 14 files changed, 979 insertions(+), 863 deletions(-) delete mode 100644 cmd/cli/apply.go create mode 100644 cmd/cli/apply/apply.go delete mode 100644 cmd/cli/check.go create mode 100644 cmd/cli/check/check.go rename cmd/cli/{ => cleanup}/cleanup.go (68%) rename cmd/cli/{ => generator}/generate.go (61%) rename cmd/cli/{ => rollback}/rollback.go (64%) delete mode 100644 cmd/cli/search.go create mode 100644 cmd/cli/search/execution.go create mode 100644 cmd/cli/search/options.go create mode 100644 cmd/cli/search/results.go create mode 100644 cmd/cli/search/search.go create mode 100644 ui/helpers.go diff --git a/cmd/cli/apply.go b/cmd/cli/apply.go deleted file mode 100644 index 7cc5bbc..0000000 --- a/cmd/cli/apply.go +++ /dev/null @@ -1,49 +0,0 @@ -package cli - -import ( - "github.com/apiqube/cli/internal/core/manifests/loader" - "github.com/apiqube/cli/internal/core/store" - "github.com/apiqube/cli/ui" - "github.com/spf13/cobra" -) - -func init() { - applyCmd.Flags().StringP("file", "f", ".", "Path to manifests file, by default is current") - rootCmd.AddCommand(applyCmd) -} - -var applyCmd = &cobra.Command{ - Use: "apply", - Short: "Apply resources from manifest file", - SilenceErrors: true, - SilenceUsage: true, - Run: func(cmd *cobra.Command, args []string) { - file, err := cmd.Flags().GetString("file") - if err != nil { - ui.Errorf("Failed to parse --file: %s", err.Error()) - return - } - - ui.Printf("Applying manifests from: %s", file) - ui.Spinner(true, "Loading manifests") - - loadedMans, _, err := loader.LoadManifests(file) - if err != nil { - ui.Spinner(false) - ui.Errorf("Failed to load manifests: %s", err.Error()) - return - } - - ui.Spinner(false) - ui.Spinner(true, "Saving manifests...") - - if err := store.Save(loadedMans...); err != nil { - ui.Error("Failed to save manifests: " + err.Error()) - ui.Spinner(false) - return - } - - ui.Spinner(false) - ui.Println("Manifests applied successfully") - }, -} diff --git a/cmd/cli/apply/apply.go b/cmd/cli/apply/apply.go new file mode 100644 index 0000000..e007195 --- /dev/null +++ b/cmd/cli/apply/apply.go @@ -0,0 +1,58 @@ +package apply + +import ( + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/manifests/loader" + "github.com/apiqube/cli/internal/core/store" + "github.com/apiqube/cli/ui" + "github.com/spf13/cobra" +) + +func init() { + Cmd.Flags().StringP("file", "f", ".", "Path to manifests file, by default is current") +} + +var Cmd = &cobra.Command{ + Use: "apply", + Short: "Apply resources from manifest file", + SilenceErrors: true, + SilenceUsage: true, + Run: func(cmd *cobra.Command, args []string) { + file, err := cmd.Flags().GetString("file") + if err != nil { + ui.Errorf("Failed to parse --file: %s", err.Error()) + return + } + + ui.Printf("Loading manifests from: %s", file) + ui.Spinner(true, "Applying manifests...") + defer ui.Spinner(false) + + loadedMans, cachedMans, err := loader.LoadManifests(file) + if err != nil { + ui.Errorf("Failed to load manifests: %s", err.Error()) + return + } + + printManifestsLoadResult(loadedMans, cachedMans) + + if err := store.Save(loadedMans...); err != nil { + ui.Error("Failed to save manifests: " + err.Error()) + return + } + + ui.Println("Manifests applied successfully") + }, +} + +func printManifestsLoadResult(newMans, cachedMans []manifests.Manifest) { + ui.Infof("Loaded %d manifests", len(newMans)) + + for _, m := range newMans { + ui.Infof("New manifest added: %s (h: %s...)", m.GetID(), ui.ShortHash(m.GetMeta().GetHash())) + } + + for _, m := range cachedMans { + ui.Infof("Manifest %s unchanged (h: %s...) - using cached version", m.GetID(), ui.ShortHash(m.GetMeta().GetHash())) + } +} diff --git a/cmd/cli/check.go b/cmd/cli/check.go deleted file mode 100644 index 5a7b187..0000000 --- a/cmd/cli/check.go +++ /dev/null @@ -1,204 +0,0 @@ -package cli - -import ( - "fmt" - "strings" - - "github.com/apiqube/cli/internal/core/manifests" - "github.com/apiqube/cli/internal/core/manifests/kinds/plan" - "github.com/apiqube/cli/internal/core/manifests/loader" - runner "github.com/apiqube/cli/internal/core/runner/plan" - "github.com/apiqube/cli/internal/core/store" - "github.com/apiqube/cli/ui" - "github.com/spf13/cobra" -) - -var checkCmd = &cobra.Command{ - Use: "check", - Short: "Check validity of manifests, plans or full configurations", -} - -var checkManifestCmd = &cobra.Command{ - Use: "manifest", - Short: "Validate individual manifests", - RunE: func(cmd *cobra.Command, args []string) error { - return nil - }, -} - -var checkPlanCmd = &cobra.Command{ - Use: "plan", - Short: "Validate a plan manifest", - RunE: func(cmd *cobra.Command, args []string) error { - opts, err := parseCheckPlanFlags(cmd, args) - if err != nil { - ui.Errorf("Failed to parse provided values: %v", err) - return err - } - - if !opts.flagsSet["id"] && - !opts.flagsSet["name"] && - !opts.flagsSet["namespace"] && - !opts.flagsSet["file"] { - ui.Errorf("At least one check plan filter must be specified") - return nil - } - - ui.Spinner(true, "Checking manifests...") - defer ui.Spinner(false) - - var loadedMans []manifests.Manifest - var man manifests.Manifest - query := store.NewQuery() - withQuery := false - - if opts.flagsSet["id"] { - if loadedMans, err = store.Load(store.LoadOptions{ - IDs: []string{opts.id}, - }); err != nil { - ui.Errorf("Failed to load manifest: %v", err) - return nil - } - } else if opts.flagsSet["name"] { - query.WithExactName(opts.name) - withQuery = true - } else if opts.flagsSet["namespace"] { - query.WithNamespace(opts.namespace) - withQuery = true - } else if opts.flagsSet["file"] { - if loadedMans, _, err = loader.LoadManifests(opts.file); err != nil { - ui.Errorf("Failed to load manifest: %v", err) - return nil - } - - ui.Infof("Manifests from provieded path %s loaded", opts.file) - } - - if withQuery { - loadedMans, err = store.Search(query) - if err != nil { - ui.Errorf("Failed to search plan manifests: %v", err) - return nil - } - } - - if man, err = findManifestWithKind(manifests.PlanManifestKind, loadedMans); err != nil { - ui.Errorf("Failed to check plan manifest: %v", err) - return nil - } - - if planToCheck, ok := man.(*plan.Plan); ok { - manifestIds := planToCheck.GetAllManifests() - - if loadedMans, err = store.Load(store.LoadOptions{ - IDs: manifestIds, - }); err != nil { - ui.Errorf("Failed to load plan manifests: %v", err) - } - - builder := runner.NewPlanManagerBuilder().WithManifests(loadedMans...) - generator := builder.Build() - - if generator.CheckPlan(planToCheck) != nil { - ui.Errorf("Failed to check plan: %s", err) - return nil - } - } else { - ui.Errorf("Failed to check plan, manifest found but not plan manifest") - return nil - } - - ui.Successf("Successfully checked plan manifest") - return nil - }, -} - -var checkAllCmd = &cobra.Command{ - Use: "all", - Short: "Validate full manifest set (plan + dependencies + tests)", - RunE: func(cmd *cobra.Command, args []string) error { - return nil - }, -} - -func init() { - checkManifestCmd.Flags().String("id", "", "Full manifest ID to check (namespace.kind.name)") - checkManifestCmd.Flags().String("kind", "", "kind of manifest (e.g., HttpTest, Server, Values)") - checkManifestCmd.Flags().String("name", "", "name of manifest") - checkManifestCmd.Flags().String("namespace", "", "namespace of manifest") - checkManifestCmd.Flags().String("file", "", "Path to manifest file to check") - - checkPlanCmd.Flags().String("id", "", "Full plan ID to check (namespace.Plan.name)") - checkPlanCmd.Flags().StringP("name", "n", "", "name of plan") - checkPlanCmd.Flags().StringP("namespace", "s", "", "namespace of manifest") - checkPlanCmd.Flags().StringP("file", "f", "", "Path to plan.yaml") - - checkAllCmd.Flags().String("path", ".", "Path to directory with manifests to check") - - checkCmd.AddCommand(checkManifestCmd) - checkCmd.AddCommand(checkPlanCmd) - checkCmd.AddCommand(checkAllCmd) - - rootCmd.AddCommand(checkCmd) -} - -type ( - checkPlanOptions struct { - id string - name string - namespace string - file string - - flagsSet map[string]bool - } -) - -func parseCheckPlanFlags(cmd *cobra.Command, _ []string) (*checkPlanOptions, error) { - opts := &checkPlanOptions{ - flagsSet: make(map[string]bool), - } - - markFlag := func(name string) bool { - if cmd.Flags().Changed(name) { - opts.flagsSet[name] = true - return true - } - return false - } - - if markFlag("id") { - opts.id, _ = cmd.Flags().GetString("id") - } - if markFlag("name") { - opts.name, _ = cmd.Flags().GetString("name") - } - if markFlag("namespace") { - opts.namespace, _ = cmd.Flags().GetString("namespace") - } - - if markFlag("file") { - var file string - file, _ = cmd.Flags().GetString("file") - if strings.HasSuffix(file, ".yml") || strings.HasSuffix(file, ".yaml") { - opts.file = file - } else { - return nil, fmt.Errorf("--file flag must end with .yml or .yaml") - } - } - - if opts.flagsSet["id"] || (opts.flagsSet["name"] || (opts.flagsSet["namespace"] && opts.flagsSet["file"])) { - return nil, fmt.Errorf("cannot use all filters at the same time") - } - - return opts, nil -} - -func findManifestWithKind(kind string, mans []manifests.Manifest) (manifests.Manifest, error) { - for i, man := range mans { - if man.GetKind() == kind { - return mans[i], nil - } - } - - return nil, fmt.Errorf("expected manifest with %s kind not found", kind) -} diff --git a/cmd/cli/check/check.go b/cmd/cli/check/check.go new file mode 100644 index 0000000..8084de0 --- /dev/null +++ b/cmd/cli/check/check.go @@ -0,0 +1,218 @@ +package check + +import ( + "fmt" + "strings" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/manifests/kinds/plan" + "github.com/apiqube/cli/internal/core/manifests/loader" + runner "github.com/apiqube/cli/internal/core/runner/plan" + "github.com/apiqube/cli/internal/core/store" + "github.com/apiqube/cli/ui" + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "check", + Short: "Check validity of manifests, plans or full configurations", +} + +var cmdManifestCheck = &cobra.Command{ + Use: "manifest", + Short: "Validate individual manifests", + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} + +var cmdPlanCheck = &cobra.Command{ + Use: "plan", + Short: "Validate a plan manifest", + RunE: func(cmd *cobra.Command, args []string) error { + opts, err := parseCheckPlanFlags(cmd, args) + if err != nil { + return uiErrorf("Failed to parse provided values: %v", err) + } + + if err := validateCheckPlanOptions(opts); err != nil { + return uiErrorf(err.Error()) + } + + ui.Spinner(true, "Checking manifests...") + defer ui.Spinner(false) + + loadedManifests, err := loadManifests(opts) + if err != nil { + return uiErrorf("Failed to load manifests: %v", err) + } + + planManifest, err := extractPlanManifest(loadedManifests) + if err != nil { + return uiErrorf("Failed to check plan manifest: %v", err) + } + + if err := validatePlan(planManifest); err != nil { + return uiErrorf("Failed to check plan: %v", err) + } + + ui.Successf("Successfully checked plan manifest") + return nil + }, +} + +var cmdAllCheck = &cobra.Command{ + Use: "all", + Short: "Validate full manifest set (plan + dependencies + tests)", + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} + +func init() { + cmdManifestCheck.Flags().String("id", "", "Full manifest ID to check (namespace.kind.name)") + cmdManifestCheck.Flags().String("kind", "", "kind of manifest (e.g., HttpTest, Server, Values)") + cmdManifestCheck.Flags().String("name", "", "name of manifest") + cmdManifestCheck.Flags().String("namespace", "", "namespace of manifest") + cmdManifestCheck.Flags().String("file", "", "Path to manifest file to check") + + cmdPlanCheck.Flags().String("id", "", "Full plan ID to check (namespace.Plan.name)") + cmdPlanCheck.Flags().StringP("name", "n", "", "name of plan") + cmdPlanCheck.Flags().StringP("namespace", "s", "", "namespace of manifest") + cmdPlanCheck.Flags().StringP("file", "f", "", "Path to plan.yaml") + + cmdAllCheck.Flags().String("path", ".", "Path to directory with manifests to check") + + Cmd.AddCommand(cmdManifestCheck) + Cmd.AddCommand(cmdPlanCheck) + Cmd.AddCommand(cmdAllCheck) +} + +type ( + checkPlanOptions struct { + id string + name string + namespace string + file string + + flagsSet map[string]bool + } +) + +func uiErrorf(format string, args ...interface{}) error { + ui.Errorf(format, args...) + return nil +} + +func validateCheckPlanOptions(opts *checkPlanOptions) error { + if !opts.flagsSet["id"] && + !opts.flagsSet["name"] && + !opts.flagsSet["namespace"] && + !opts.flagsSet["file"] { + return fmt.Errorf("at least one check plan filter must be specified") + } + return nil +} + +func loadManifests(opts *checkPlanOptions) ([]manifests.Manifest, error) { + switch { + case opts.flagsSet["id"]: + return store.Load(store.LoadOptions{ + IDs: []string{opts.id}, + }) + + case opts.flagsSet["file"]: + loadedMans, _, err := loader.LoadManifests(opts.file) + if err == nil { + ui.Infof("Manifests from provided path %s loaded", opts.file) + } + return loadedMans, err + + default: + query := store.NewQuery() + if opts.flagsSet["name"] { + query.WithExactName(opts.name) + } + if opts.flagsSet["namespace"] { + query.WithNamespace(opts.namespace) + } + return store.Search(query) + } +} + +func extractPlanManifest(mans []manifests.Manifest) (*plan.Plan, error) { + man, err := findManifestWithKind(manifests.PlanManifestKind, mans) + if err != nil { + return nil, err + } + + planManifest, ok := man.(*plan.Plan) + if !ok { + return nil, fmt.Errorf("manifest found but not a plan manifest") + } + return planManifest, nil +} + +func validatePlan(planToCheck *plan.Plan) error { + manifestIds := planToCheck.GetAllManifests() + loadedMans, err := store.Load(store.LoadOptions{ + IDs: manifestIds, + }) + if err != nil { + return err + } + + builder := runner.NewPlanManagerBuilder().WithManifests(loadedMans...) + generator := builder.Build() + return generator.CheckPlan(planToCheck) +} + +func parseCheckPlanFlags(cmd *cobra.Command, _ []string) (*checkPlanOptions, error) { + opts := &checkPlanOptions{ + flagsSet: make(map[string]bool), + } + + markFlag := func(name string) bool { + if cmd.Flags().Changed(name) { + opts.flagsSet[name] = true + return true + } + return false + } + + if markFlag("id") { + opts.id, _ = cmd.Flags().GetString("id") + } + if markFlag("name") { + opts.name, _ = cmd.Flags().GetString("name") + } + if markFlag("namespace") { + opts.namespace, _ = cmd.Flags().GetString("namespace") + } + + if markFlag("file") { + var file string + file, _ = cmd.Flags().GetString("file") + if strings.HasSuffix(file, ".yml") || strings.HasSuffix(file, ".yaml") { + opts.file = file + } else { + return nil, fmt.Errorf("--file flag must end with .yml or .yaml") + } + } + + if opts.flagsSet["id"] || (opts.flagsSet["name"] || (opts.flagsSet["namespace"] && opts.flagsSet["file"])) { + return nil, fmt.Errorf("cannot use all filters at the same time") + } + + return opts, nil +} + +func findManifestWithKind(kind string, mans []manifests.Manifest) (manifests.Manifest, error) { + for i, man := range mans { + if man.GetKind() == kind { + return mans[i], nil + } + } + + return nil, fmt.Errorf("expected manifest with %s kind not found", kind) +} diff --git a/cmd/cli/cleanup.go b/cmd/cli/cleanup/cleanup.go similarity index 68% rename from cmd/cli/cleanup.go rename to cmd/cli/cleanup/cleanup.go index 83050f5..30cfbc9 100644 --- a/cmd/cli/cleanup.go +++ b/cmd/cli/cleanup/cleanup.go @@ -1,8 +1,7 @@ -package cli +package cleanup import ( "fmt" - "github.com/apiqube/cli/internal/core/store" "github.com/apiqube/cli/ui" "github.com/spf13/cobra" @@ -10,7 +9,7 @@ import ( const keepVersionDefault = 5 -var cleanupCmd = &cobra.Command{ +var Cmd = &cobra.Command{ Use: "cleanup [ID]", Short: "Cleanup old manifest versions by its id", Long: fmt.Sprintf("Delete all versions of the manifest,"+ @@ -24,41 +23,40 @@ var cleanupCmd = &cobra.Command{ return err } - keep := opts.Keep + keep := opts.keep if keep <= 0 { keep = keepVersionDefault } ui.Spinner(true, "Cleaning up...") - if err = store.CleanupOldVersions(opts.ManifestID, keep); err != nil { - ui.Spinner(false) + defer ui.Spinner(false) + + if err = store.CleanupOldVersions(opts.manifestID, keep); err != nil { ui.Errorf("Failed to cleanup old versions: %v", err) } - ui.Spinner(false) - ui.Successf("Successfully cleaned up %v to last %d versions", opts.ManifestID, keep) + ui.Successf("Successfully cleaned up %v to last %d versions", opts.manifestID, keep) return nil }, } func init() { - rootCmd.AddCommand(cleanupCmd) - cleanupCmd.Flags().IntP("keep", "k", keepVersionDefault, "Number of last versions to keep") + Cmd.Flags().IntP("keep", "k", keepVersionDefault, "Number of last versions to keep") } -type CleanUpOptions struct { - ManifestID string - Keep int +type Options struct { + manifestID string + keep int } -func parseCleanUpFlags(cmd *cobra.Command, args []string) (*CleanUpOptions, error) { - opts := &CleanUpOptions{} +func parseCleanUpFlags(cmd *cobra.Command, args []string) (*Options, error) { + opts := &Options{} if len(args) == 0 { return nil, fmt.Errorf("manifest ID is required") } - opts.ManifestID = args[0] + opts.manifestID = args[0] var err error var keep int @@ -70,7 +68,7 @@ func parseCleanUpFlags(cmd *cobra.Command, args []string) (*CleanUpOptions, erro if keep < 1 { return nil, fmt.Errorf("keep value must be positive") } - opts.Keep = keep + opts.keep = keep } return opts, nil diff --git a/cmd/cli/generate.go b/cmd/cli/generator/generate.go similarity index 61% rename from cmd/cli/generate.go rename to cmd/cli/generator/generate.go index f257178..81a5d37 100644 --- a/cmd/cli/generate.go +++ b/cmd/cli/generator/generate.go @@ -1,8 +1,10 @@ -package cli +package generator -import "github.com/spf13/cobra" +import ( + "github.com/spf13/cobra" +) -var generateCmd = &cobra.Command{ +var Cmd = &cobra.Command{ Use: "generate", Short: "Generate manifests with provided flags", SilenceErrors: true, @@ -11,7 +13,3 @@ var generateCmd = &cobra.Command{ return nil }, } - -func init() { - rootCmd.AddCommand(generateCmd) -} diff --git a/cmd/cli/rollback.go b/cmd/cli/rollback/rollback.go similarity index 64% rename from cmd/cli/rollback.go rename to cmd/cli/rollback/rollback.go index 77deef2..f949ee3 100644 --- a/cmd/cli/rollback.go +++ b/cmd/cli/rollback/rollback.go @@ -1,14 +1,13 @@ -package cli +package rollback import ( "fmt" - "github.com/apiqube/cli/internal/core/store" "github.com/apiqube/cli/ui" "github.com/spf13/cobra" ) -var rollbackCmd = &cobra.Command{ +var Cmd = &cobra.Command{ Use: "rollback [ID]", Short: "Rollback to previous manifest version", Long: fmt.Sprint("Rollback to specific version of manifest." + @@ -20,42 +19,40 @@ var rollbackCmd = &cobra.Command{ return } - targetVersion := opts.Version + targetVersion := opts.version if targetVersion <= 0 { targetVersion = 1 } ui.Spinner(true, "Rolling back") - if err = store.Rollback(opts.ManifestID, targetVersion); err != nil { - ui.Spinner(false, "Failed to rollback") + defer ui.Spinner(false) + + if err = store.Rollback(opts.manifestID, targetVersion); err != nil { ui.Errorf("Error rolling back to previous version: %s", err) return } - ui.Spinner(false) - ui.Successf("Successfully rolled back %s to version %d\n", opts.ManifestID, targetVersion) + ui.Successf("Successfully rolled back %s to version %d\n", opts.manifestID, targetVersion) }, } func init() { - rollbackCmd.Flags().IntP("version", "v", 0, "Target version number (defaults to previous version)") - - rootCmd.AddCommand(rollbackCmd) + Cmd.Flags().IntP("version", "v", 0, "Target version number (defaults to previous version)") } -type RollbackOptions struct { - ManifestID string - Version int +type Options struct { + manifestID string + version int } -func parseRollbackFlags(cmd *cobra.Command, args []string) (*RollbackOptions, error) { - opts := &RollbackOptions{} +func parseRollbackFlags(cmd *cobra.Command, args []string) (*Options, error) { + opts := &Options{} if len(args) == 0 { return nil, fmt.Errorf("manifest ID is required") } - opts.ManifestID = args[0] + opts.manifestID = args[0] var err error var ver int @@ -67,7 +64,7 @@ func parseRollbackFlags(cmd *cobra.Command, args []string) (*RollbackOptions, er if ver < 1 { return nil, fmt.Errorf("version must be positive") } - opts.Version = ver + opts.version = ver } return opts, nil diff --git a/cmd/cli/root.go b/cmd/cli/root.go index bcc8820..e8c54bf 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -3,6 +3,12 @@ package cli import ( "context" "fmt" + "github.com/apiqube/cli/cmd/cli/apply" + "github.com/apiqube/cli/cmd/cli/check" + "github.com/apiqube/cli/cmd/cli/cleanup" + "github.com/apiqube/cli/cmd/cli/generator" + "github.com/apiqube/cli/cmd/cli/rollback" + "github.com/apiqube/cli/cmd/cli/search" "github.com/apiqube/cli/internal/config" "github.com/spf13/cobra" @@ -27,5 +33,14 @@ var rootCmd = &cobra.Command{ } func Execute() { + rootCmd.AddCommand( + apply.Cmd, + check.Cmd, + cleanup.Cmd, + generator.Cmd, + rollback.Cmd, + search.Cmd, + ) + cobra.CheckErr(rootCmd.Execute()) } diff --git a/cmd/cli/search.go b/cmd/cli/search.go deleted file mode 100644 index 227405c..0000000 --- a/cmd/cli/search.go +++ /dev/null @@ -1,568 +0,0 @@ -package cli - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - "time" - - "github.com/apiqube/cli/internal/core/manifests" - "github.com/apiqube/cli/internal/core/store" - "github.com/apiqube/cli/ui" - "github.com/spf13/cobra" - "gopkg.in/yaml.v3" -) - -var searchCmd = &cobra.Command{ - Use: "search", - Short: "Search for manifests using filters", - Long: fmt.Sprint("Search for manifests with powerful filtering options including exact/wildcard matching," + - "\ntime ranges, and output formatting"), - SilenceUsage: true, - SilenceErrors: true, - RunE: func(cmd *cobra.Command, args []string) error { - opts, err := parseSearchFlags(cmd, args) - if err != nil { - ui.Errorf("Failed to parse provided values: %v", err) - return err - } - - var manifests []manifests.Manifest - - if !opts.all && - !opts.flagsSet["name"] && - !opts.flagsSet["name-wildcard"] && - !opts.flagsSet["name-regex"] && - !opts.flagsSet["kind"] && - !opts.flagsSet["hash"] && - !opts.flagsSet["version"] && - !opts.flagsSet["namespace"] && - !opts.flagsSet["created-by"] && - !opts.flagsSet["used-by"] && - !opts.flagsSet["depends"] && - !opts.flagsSet["depends-all"] && - !opts.flagsSet["created-after"] && - !opts.flagsSet["created-before"] && - !opts.flagsSet["updated-after"] && - !opts.flagsSet["updated-before"] && - !opts.flagsSet["last-applied"] { - return fmt.Errorf("at least one search filter must be specified") - } - - if opts.flagsSet["all"] { - manifests, err = store.Load(store.LoadOptions{All: true}) - if err != nil { - ui.Errorf("Failed to loadmanifests: %v", err) - return nil - } - } else { - query := store.NewQuery() - - if opts.flagsSet["name"] { - query.WithExactName(opts.name) - } else if opts.flagsSet["name-wildcard"] { - query.WithWildcardName(opts.nameWildcard) - } else if opts.flagsSet["name-regex"] { - query.WithRegexName(opts.nameRegex) - } - - if opts.flagsSet["namespace"] { - query.WithNamespace(opts.namespace) - } - - if opts.flagsSet["kind"] { - query.WithKind(opts.kind) - } - - if opts.flagsSet["version"] { - query.WithVersion(opts.version) - } - - if opts.flagsSet["created-by"] { - query.WithCreatedBy(opts.createdBy) - } - - if opts.flagsSet["user-by"] { - query.WithUsedBy(opts.usedBy) - } - - if opts.flagsSet["hash"] { - query.WithHashPrefix(opts.hashPrefix) - } - - if opts.flagsSet["depends"] { - query.WithDependencies(opts.dependsOn) - } else if opts.flagsSet["depends-all"] { - query.WithAllDependencies(opts.dependsOnAll) - } - - if opts.flagsSet["created-after"] { - query.WithCreatedAfter(opts.createdAfter) - } - - if opts.flagsSet["created-before"] { - query.WithCreatedBefore(opts.createdBefore) - } - - if opts.flagsSet["updated-after"] { - query.WithUpdatedAfter(opts.updatedAfter) - } - - if opts.flagsSet["updated-before"] { - query.WithUpdatedBefore(opts.updatedBefore) - } - - if opts.flagsSet["last-applied"] { - query.WithLastApplied(opts.lastApplied) - } - - manifests, err = store.Search(query) - if err != nil { - ui.Errorf("Failed to search manifests: %v", err) - return nil - } - } - - if len(manifests) == 0 { - ui.Warning("No manifests found matching the criteria") - return nil - } - - ui.Infof("Found %d manifests", len(manifests)) - - if len(opts.sortBy) > 0 { - sortManifests(manifests, opts.sortBy) - } - - ui.Spinner(true, "Prepare answer...") - - if opts.output { - if err := outputManifests(manifests, opts); err != nil { - ui.Spinner(false) - ui.Errorf("Failed to output manifests: %v", err) - return nil - } - } else { - displayResults(manifests) - } - - ui.Spinner(false, "Complete") - - return nil - }, -} - -func init() { - searchCmd.Flags().BoolP("all", "a", false, "Get all manifests") - - searchCmd.Flags().StringP("name", "n", "", "Search manifest by name (exact match)") - searchCmd.Flags().StringP("name-wildcard", "W", "", "Search manifest by wildcard pattern (e.g. '*name*')") - searchCmd.Flags().StringP("name-regex", "R", "", "Search manifest by regex pattern") - - searchCmd.Flags().StringP("namespace", "s", "", "Search manifests by namespace") - searchCmd.Flags().StringP("kind", "k", "", "Search manifests by kind") - searchCmd.Flags().IntP("version", "v", 0, "Search manifests by version") - searchCmd.Flags().String("created-by", "", "Filter by exact creator username") - searchCmd.Flags().String("used-by", "", "Filter by exact user who applied") - - searchCmd.Flags().StringP("hash", "H", "", "Search manifests by hash prefix (min 5 chars)") - searchCmd.Flags().StringSliceP("depends", "d", []string{}, "Search manifests by dependencies (comma separated)") - searchCmd.Flags().StringSliceP("depends-all", "D", []string{}, "Search manifests by all dependencies (comma separated)") - - searchCmd.Flags().String("created-after", "", "Search manifests created after date (YYYY-MM-DD or duration like 1h30m)") - searchCmd.Flags().String("created-before", "", "Search manifests created before date/duration") - searchCmd.Flags().String("updated-after", "", "Search manifests updated after date/duration") - searchCmd.Flags().String("updated-before", "", "Search manifests updated before date/duration") - searchCmd.Flags().String("last-applied", "", "Search manifests by last applied date/duration") - - searchCmd.Flags().BoolP("output", "o", false, "Make output after searching") - searchCmd.Flags().String("output-path", "", "output path for results (default: current directory)") - searchCmd.Flags().String("output-mode", "separate", "output mode (combined|separate)") - searchCmd.Flags().String("output-format", "yaml", "File format for output (yaml|json)") - - searchCmd.Flags().StringSlice("sort", []string{}, "Sort by fields (e.g. --sort=kind,-name)") - - rootCmd.AddCommand(searchCmd) -} - -type searchOptions struct { - all bool - - name string - nameWildcard string - nameRegex string - - namespace string - kind string - version int - createdBy string - usedBy string - - hashPrefix string - dependsOn []string - dependsOnAll []string - - createdAfter time.Time - createdBefore time.Time - updatedAfter time.Time - updatedBefore time.Time - lastApplied time.Time - isRelativeTime bool - - output bool - outputPath string - outputMode string // combined | separate - outputFormat string // yaml | json - - sortBy []string - - flagsSet map[string]bool -} - -func parseSearchFlags(cmd *cobra.Command, _ []string) (*searchOptions, error) { - opts := &searchOptions{ - flagsSet: make(map[string]bool), - } - - markFlag := func(name string) bool { - if cmd.Flags().Changed(name) { - opts.flagsSet[name] = true - return true - } - return false - } - - if markFlag("all") { - opts.all, _ = cmd.Flags().GetBool("all") - } - - if markFlag("name") { - opts.name, _ = cmd.Flags().GetString("name") - } - if markFlag("name-wildcard") { - opts.nameWildcard, _ = cmd.Flags().GetString("name-wildcard") - } - if markFlag("name-regex") { - opts.nameRegex, _ = cmd.Flags().GetString("name-regex") - } - - if opts.flagsSet["name"] && (opts.flagsSet["name-wildcard"] || opts.flagsSet["name-regex"]) { - return nil, fmt.Errorf("cannot use exact name filter with wildcard/regex filters") - } - - if markFlag("namespace") { - opts.namespace, _ = cmd.Flags().GetString("namespace") - } - if markFlag("kind") { - opts.kind, _ = cmd.Flags().GetString("kind") - } - if markFlag("version") { - opts.version, _ = cmd.Flags().GetInt("version") - } - if markFlag("created-by") { - opts.createdBy, _ = cmd.Flags().GetString("created-by") - } - if markFlag("used-by") { - opts.usedBy, _ = cmd.Flags().GetString("used-by") - } - - if markFlag("hash") { - opts.hashPrefix, _ = cmd.Flags().GetString("hash") - if len(opts.hashPrefix) < 5 { - return nil, fmt.Errorf("hash prefix must be at least 5 characters") - } - } - if markFlag("depends") { - opts.dependsOn, _ = cmd.Flags().GetStringSlice("depends") - } else if markFlag("depends-all") { - opts.dependsOnAll, _ = cmd.Flags().GetStringSlice("depends-all") - } - - timeFilters := map[string]*time.Time{ - "created-after": &opts.createdAfter, - "created-before": &opts.createdBefore, - "updated-after": &opts.updatedAfter, - "updated-before": &opts.updatedBefore, - "last-applied": &opts.lastApplied, - } - - for flag, target := range timeFilters { - if markFlag(flag) { - val, _ := cmd.Flags().GetString(flag) - if t, err := parseTimeOrDuration(val); err == nil { - *target = t - opts.isRelativeTime = isDuration(val) - } else { - return nil, fmt.Errorf("invalid %s value: %w", flag, err) - } - } - } - - if markFlag("output") { - opts.output, _ = cmd.Flags().GetBool("output") - if opts.output { - if markFlag("output-path") { - opts.outputPath, _ = cmd.Flags().GetString("output-path") - } - if opts.outputPath == "" { - opts.outputPath = "." - } - if markFlag("output-mode") { - opts.outputMode, _ = cmd.Flags().GetString("output-mode") - if opts.outputMode != "combined" && opts.outputMode != "separate" { - return nil, fmt.Errorf("invalid output mode, must be 'combined' or 'separate'") - } - } - if opts.outputMode == "" { - opts.outputMode = "separate" - } - if markFlag("output-format") { - opts.outputFormat, _ = cmd.Flags().GetString("output-format") - if opts.outputFormat != "yaml" && opts.outputFormat != "json" { - return nil, fmt.Errorf("invalid output format, must be 'yaml' or 'json'") - } - } - if opts.outputFormat == "" { - opts.outputFormat = "yaml" - } - } - } - - if markFlag("sort") { - opts.sortBy, _ = cmd.Flags().GetStringSlice("sort") - } - - return opts, nil -} - -func parseTimeOrDuration(val string) (time.Time, error) { - if duration, err := time.ParseDuration(val); err == nil { - return time.Now().Add(-duration), nil - } - - if t, err := time.Parse("2006-01-02", val); err == nil { - return t, nil - } - - if t, err := time.Parse(time.RFC3339, val); err == nil { - return t, nil - } - - return time.Time{}, fmt.Errorf("invalid time format") -} - -func isDuration(val string) bool { - _, err := time.ParseDuration(val) - return err == nil -} - -func outputManifests(manifests []manifests.Manifest, opts *searchOptions) error { - if err := os.MkdirAll(opts.outputPath, 0o755); err != nil { - return fmt.Errorf("failed to create output directory: %w", err) - } - - if opts.outputMode == "combined" { - filename := filepath.Join(opts.outputPath, fmt.Sprintf("manifests.%s", opts.outputFormat)) - return writeCombinedFile(filename, manifests, opts.outputFormat) - } else { - for _, m := range manifests { - filename := filepath.Join(opts.outputPath, fmt.Sprintf("%s.%s", m.GetID(), opts.outputFormat)) - if err := writeSingleFile(filename, m, opts.outputFormat); err != nil { - return err - } - } - } - - return nil -} - -func writeCombinedFile(filename string, manifests []manifests.Manifest, format string) error { - if len(manifests) == 0 { - return fmt.Errorf("no manifests to write") - } - - file, err := os.Create(filename) - if err != nil { - return fmt.Errorf("failed to create file %s: %w", filename, err) - } - defer func() { - _ = file.Close() - }() - - switch strings.ToLower(format) { - case "yaml": - encoder := yaml.NewEncoder(file) - for i, m := range manifests { - if i > 0 { - if _, err = file.WriteString("---\n"); err != nil { - return fmt.Errorf("failed to write YAML manifest: %w", err) - } - } - if err = encoder.Encode(m); err != nil { - return fmt.Errorf("failed to encode manifest %d: %w", i+1, err) - } - } - case "json": - if _, err = file.WriteString("[\n"); err != nil { - return fmt.Errorf("failed to write JSON manifest: %w", err) - } - - encoder := json.NewEncoder(file) - encoder.SetIndent("", " ") - - for i, m := range manifests { - if i > 0 { - if _, err = file.WriteString(",\n"); err != nil { - return fmt.Errorf("failed to write YAML manifest: %w", err) - } - } - if err = encoder.Encode(m); err != nil { - return fmt.Errorf("failed to encode manifest %d: %w", i+1, err) - } - } - if _, err = file.WriteString("\n]"); err != nil { - return fmt.Errorf("failed to write JSON manifest: %w", err) - } - default: - return fmt.Errorf("unsupported format: %s", format) - } - - ui.Successf("Successfully wrote %d manifests to %s", len(manifests), filename) - return nil -} - -func writeSingleFile(filename string, manifest manifests.Manifest, format string) error { - file, err := os.Create(filename) - if err != nil { - return fmt.Errorf("failed to create file %s: %w", filename, err) - } - defer func() { - _ = file.Close() - }() - - switch strings.ToLower(format) { - case "yaml": - encoder := yaml.NewEncoder(file) - if err = encoder.Encode(manifest); err != nil { - return fmt.Errorf("failed to encode manifest: %w", err) - } - case "json": - encoder := json.NewEncoder(file) - encoder.SetIndent("", " ") - if err = encoder.Encode(manifest); err != nil { - return fmt.Errorf("failed to encode manifest: %w", err) - } - default: - return fmt.Errorf("unsupported format: %s", format) - } - - ui.Successf("Successfully wrote manifest %s to %s", manifest.GetID(), filename) - return nil -} - -func sortManifests(manifests []manifests.Manifest, fields []string) { - sort.Slice(manifests, func(i, j int) bool { - for _, field := range fields { - desc := false - if strings.HasPrefix(field, "-") { - desc = true - field = field[1:] - } - - switch field { - case "id": - if manifests[i].GetID() != manifests[j].GetID() { - if desc { - return manifests[i].GetID() > manifests[j].GetID() - } - return manifests[i].GetID() < manifests[j].GetID() - } - case "name": - if manifests[i].GetName() != manifests[j].GetName() { - if desc { - return manifests[i].GetName() > manifests[j].GetName() - } - return manifests[i].GetName() < manifests[j].GetName() - } - case "kind": - if manifests[i].GetKind() != manifests[j].GetKind() { - if desc { - return manifests[i].GetKind() > manifests[j].GetKind() - } - return manifests[i].GetKind() < manifests[j].GetKind() - } - case "namespace": - if manifests[i].GetNamespace() != manifests[j].GetNamespace() { - if desc { - return manifests[i].GetNamespace() > manifests[j].GetNamespace() - } - return manifests[i].GetNamespace() < manifests[j].GetNamespace() - } - case "version": - if desc { - return manifests[i].GetMeta().GetVersion() > manifests[j].GetMeta().GetVersion() - } - return manifests[i].GetMeta().GetVersion() < manifests[j].GetMeta().GetVersion() - case "created": - if desc { - return manifests[i].GetMeta().GetCreatedAt().After(manifests[j].GetMeta().GetCreatedAt()) - } - return manifests[i].GetMeta().GetCreatedAt().Before(manifests[j].GetMeta().GetCreatedAt()) - case "updated": - if desc { - return manifests[i].GetMeta().GetUpdatedAt().After(manifests[j].GetMeta().GetUpdatedAt()) - } - return manifests[i].GetMeta().GetUpdatedAt().Before(manifests[j].GetMeta().GetUpdatedAt()) - case "last": - if desc { - return manifests[i].GetMeta().GetLastApplied().After(manifests[j].GetMeta().GetLastApplied()) - } - return manifests[i].GetMeta().GetLastApplied().Before(manifests[j].GetMeta().GetLastApplied()) - } - } - return false - }) -} - -func displayResults(manifests []manifests.Manifest) { - headers := []string{ - "#", - "Hash", - "kind", - "name", - "namespace", - "version", - "Created", - "Updated", - "Last Updated", - } - - var rows [][]string - for i, m := range manifests { - meta := m.GetMeta() - row := []string{ - fmt.Sprint(i + 1), - shortHash(meta.GetHash()), - m.GetKind(), - m.GetName(), - m.GetNamespace(), - fmt.Sprint(meta.GetVersion()), - meta.GetCreatedAt().Format(time.RFC3339), - meta.GetUpdatedAt().Format(time.RFC3339), - meta.GetLastApplied().Format(time.RFC3339), - } - rows = append(rows, row) - } - - ui.Table(headers, rows) -} - -func shortHash(fullHash string) string { - if len(fullHash) > 8 { - return fullHash[:8] - } - return fullHash -} diff --git a/cmd/cli/search/execution.go b/cmd/cli/search/execution.go new file mode 100644 index 0000000..a89da56 --- /dev/null +++ b/cmd/cli/search/execution.go @@ -0,0 +1,79 @@ +package search + +import ( + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/store" +) + +func executeSearch(opts *Options) ([]manifests.Manifest, error) { + if opts.all { + return store.Load(store.LoadOptions{All: true}) + } + + query := buildSearchQuery(opts) + return store.Search(query) +} + +func buildSearchQuery(opts *Options) store.Query { + query := store.NewQuery() + + if opts.flagsSet["name"] { + query.WithExactName(opts.name) + } else if opts.flagsSet["name-wildcard"] { + query.WithWildcardName(opts.nameWildcard) + } else if opts.flagsSet["name-regex"] { + query.WithRegexName(opts.nameRegex) + } + + if opts.flagsSet["namespace"] { + query.WithNamespace(opts.namespace) + } + + if opts.flagsSet["kind"] { + query.WithKind(opts.kind) + } + + if opts.flagsSet["version"] { + query.WithVersion(opts.version) + } + + if opts.flagsSet["created-by"] { + query.WithCreatedBy(opts.createdBy) + } + + if opts.flagsSet["user-by"] { + query.WithUsedBy(opts.usedBy) + } + + if opts.flagsSet["hash"] { + query.WithHashPrefix(opts.hashPrefix) + } + + if opts.flagsSet["depends"] { + query.WithDependencies(opts.dependsOn) + } else if opts.flagsSet["depends-all"] { + query.WithAllDependencies(opts.dependsOnAll) + } + + if opts.flagsSet["created-after"] { + query.WithCreatedAfter(opts.createdAfter) + } + + if opts.flagsSet["created-before"] { + query.WithCreatedBefore(opts.createdBefore) + } + + if opts.flagsSet["updated-after"] { + query.WithUpdatedAfter(opts.updatedAfter) + } + + if opts.flagsSet["updated-before"] { + query.WithUpdatedBefore(opts.updatedBefore) + } + + if opts.flagsSet["last-applied"] { + query.WithLastApplied(opts.lastApplied) + } + + return query +} diff --git a/cmd/cli/search/options.go b/cmd/cli/search/options.go new file mode 100644 index 0000000..01ce210 --- /dev/null +++ b/cmd/cli/search/options.go @@ -0,0 +1,239 @@ +package search + +import ( + "fmt" + "github.com/spf13/cobra" + "time" +) + +type Options struct { + all bool + name string + nameWildcard string + nameRegex string + namespace string + kind string + version int + createdBy string + usedBy string + hashPrefix string + dependsOn []string + dependsOnAll []string + createdAfter time.Time + createdBefore time.Time + updatedAfter time.Time + updatedBefore time.Time + lastApplied time.Time + isRelativeTime bool + + // output options + output bool + outputPath string + outputMode string + outputFormat string + + // Sorting + sortBy []string + + // Internal state + flagsSet map[string]bool +} + +func parseSearchOptions(cmd *cobra.Command) (*Options, error) { + opts := &Options{ + flagsSet: make(map[string]bool), + } + + // Parse all flags + if err := parseBasicFilters(cmd, opts); err != nil { + return nil, err + } + + if err := parseMetadataFilters(cmd, opts); err != nil { + return nil, err + } + + if err := parseAdvancedFilters(cmd, opts); err != nil { + return nil, err + } + + if err := parseTimeFilters(cmd, opts); err != nil { + return nil, err + } + + if err := parseOutputOptions(cmd, opts); err != nil { + return nil, err + } + + if err := parseSortOptions(cmd, opts); err != nil { + return nil, err + } + + return opts, nil +} + +func validateSearchOptions(opts *Options) error { + if !opts.all && len(opts.flagsSet) == 0 { + return fmt.Errorf("at least one search filter must be specified") + } + + if opts.flagsSet["name"] && (opts.flagsSet["name-wildcard"] || opts.flagsSet["name-regex"]) { + return fmt.Errorf("cannot use exact name filter with wildcard/regex filters") + } + + if opts.flagsSet["hash"] && len(opts.hashPrefix) < 5 { + return fmt.Errorf("hash prefix must be at least 5 characters") + } + + return nil +} + +func parseBasicFilters(cmd *cobra.Command, opts *Options) error { + if cmd.Flags().Changed("all") { + opts.all, _ = cmd.Flags().GetBool("all") + opts.flagsSet["all"] = true + } + + if cmd.Flags().Changed("name") { + opts.name, _ = cmd.Flags().GetString("name") + opts.flagsSet["name"] = true + } + if cmd.Flags().Changed("name-wildcard") { + opts.nameWildcard, _ = cmd.Flags().GetString("name-wildcard") + opts.flagsSet["name-wildcard"] = true + } + if cmd.Flags().Changed("name-regex") { + opts.nameRegex, _ = cmd.Flags().GetString("name-regex") + opts.flagsSet["name-regex"] = true + } + + return nil +} + +func parseMetadataFilters(cmd *cobra.Command, opts *Options) error { + if cmd.Flags().Changed("namespace") { + opts.namespace, _ = cmd.Flags().GetString("namespace") + opts.flagsSet["namespace"] = true + } + if cmd.Flags().Changed("kind") { + opts.kind, _ = cmd.Flags().GetString("kind") + opts.flagsSet["kind"] = true + } + if cmd.Flags().Changed("version") { + opts.version, _ = cmd.Flags().GetInt("version") + opts.flagsSet["version"] = true + } + if cmd.Flags().Changed("created-by") { + opts.createdBy, _ = cmd.Flags().GetString("created-by") + opts.flagsSet["created-by"] = true + } + if cmd.Flags().Changed("used-by") { + opts.usedBy, _ = cmd.Flags().GetString("used-by") + opts.flagsSet["used-by"] = true + } + return nil +} + +func parseAdvancedFilters(cmd *cobra.Command, opts *Options) error { + if cmd.Flags().Changed("hash") { + opts.hashPrefix, _ = cmd.Flags().GetString("hash") + opts.flagsSet["hash"] = true + } + if cmd.Flags().Changed("depends") { + opts.dependsOn, _ = cmd.Flags().GetStringSlice("depends") + opts.flagsSet["depends"] = true + } + if cmd.Flags().Changed("depends-all") { + opts.dependsOnAll, _ = cmd.Flags().GetStringSlice("depends-all") + opts.flagsSet["depends-all"] = true + } + return nil +} + +func parseTimeFilters(cmd *cobra.Command, opts *Options) error { + timeFields := map[string]*time.Time{ + "created-after": &opts.createdAfter, + "created-before": &opts.createdBefore, + "updated-after": &opts.updatedAfter, + "updated-before": &opts.updatedBefore, + "last-applied": &opts.lastApplied, + } + + for flag, target := range timeFields { + if cmd.Flags().Changed(flag) { + val, _ := cmd.Flags().GetString(flag) + if t, err := parseTimeOrDuration(val); err == nil { + *target = t + opts.isRelativeTime = isDuration(val) + opts.flagsSet[flag] = true + } else { + return fmt.Errorf("invalid %s value: %w", flag, err) + } + } + } + return nil +} + +func parseOutputOptions(cmd *cobra.Command, opts *Options) error { + if cmd.Flags().Changed("output") { + opts.output, _ = cmd.Flags().GetBool("output") + opts.flagsSet["output"] = true + + if opts.output { + if cmd.Flags().Changed("output-path") { + opts.outputPath, _ = cmd.Flags().GetString("output-path") + } + if opts.outputPath == "" { + opts.outputPath = "." + } + + if cmd.Flags().Changed("output-mode") { + opts.outputMode, _ = cmd.Flags().GetString("output-mode") + if opts.outputMode != "combined" && opts.outputMode != "separate" { + return fmt.Errorf("invalid output mode: %s", opts.outputMode) + } + } else { + opts.outputMode = "separate" + } + + if cmd.Flags().Changed("output-format") { + opts.outputFormat, _ = cmd.Flags().GetString("output-format") + if opts.outputFormat != "yaml" && opts.outputFormat != "json" { + return fmt.Errorf("invalid output format: %s", opts.outputFormat) + } + } else { + opts.outputFormat = "yaml" + } + } + } + return nil +} + +func parseSortOptions(cmd *cobra.Command, opts *Options) error { + if cmd.Flags().Changed("sort") { + opts.sortBy, _ = cmd.Flags().GetStringSlice("sort") + opts.flagsSet["sort"] = true + } + return nil +} + +func isDuration(val string) bool { + _, err := time.ParseDuration(val) + return err == nil +} + +func parseTimeOrDuration(val string) (time.Time, error) { + if duration, err := time.ParseDuration(val); err == nil { + return time.Now().Add(-duration), nil + } + + if t, err := time.Parse("2006-01-02", val); err == nil { + return t, nil + } + + if t, err := time.Parse(time.RFC3339, val); err == nil { + return t, nil + } + + return time.Time{}, fmt.Errorf("invalid time format") +} diff --git a/cmd/cli/search/results.go b/cmd/cli/search/results.go new file mode 100644 index 0000000..dbfa369 --- /dev/null +++ b/cmd/cli/search/results.go @@ -0,0 +1,252 @@ +package search + +import ( + "encoding/json" + "fmt" + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/ui" + "gopkg.in/yaml.v3" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +func sortManifests(manifests []manifests.Manifest, fields []string) { + sort.Slice(manifests, func(i, j int) bool { + for _, field := range fields { + desc := false + if strings.HasPrefix(field, "-") { + desc = true + field = field[1:] + } + + switch field { + case "id": + if manifests[i].GetID() != manifests[j].GetID() { + if desc { + return manifests[i].GetID() > manifests[j].GetID() + } + return manifests[i].GetID() < manifests[j].GetID() + } + case "name": + if manifests[i].GetName() != manifests[j].GetName() { + if desc { + return manifests[i].GetName() > manifests[j].GetName() + } + return manifests[i].GetName() < manifests[j].GetName() + } + case "kind": + if manifests[i].GetKind() != manifests[j].GetKind() { + if desc { + return manifests[i].GetKind() > manifests[j].GetKind() + } + return manifests[i].GetKind() < manifests[j].GetKind() + } + case "namespace": + if manifests[i].GetNamespace() != manifests[j].GetNamespace() { + if desc { + return manifests[i].GetNamespace() > manifests[j].GetNamespace() + } + return manifests[i].GetNamespace() < manifests[j].GetNamespace() + } + case "version": + if desc { + return manifests[i].GetMeta().GetVersion() > manifests[j].GetMeta().GetVersion() + } + return manifests[i].GetMeta().GetVersion() < manifests[j].GetMeta().GetVersion() + case "created": + if desc { + return manifests[i].GetMeta().GetCreatedAt().After(manifests[j].GetMeta().GetCreatedAt()) + } + return manifests[i].GetMeta().GetCreatedAt().Before(manifests[j].GetMeta().GetCreatedAt()) + case "updated": + if desc { + return manifests[i].GetMeta().GetUpdatedAt().After(manifests[j].GetMeta().GetUpdatedAt()) + } + return manifests[i].GetMeta().GetUpdatedAt().Before(manifests[j].GetMeta().GetUpdatedAt()) + case "last": + if desc { + return manifests[i].GetMeta().GetLastApplied().After(manifests[j].GetMeta().GetLastApplied()) + } + return manifests[i].GetMeta().GetLastApplied().Before(manifests[j].GetMeta().GetLastApplied()) + } + } + return false + }) +} + +func displayResults(manifests []manifests.Manifest) { + headers := []string{ + "#", + "Hash", + "kind", + "name", + "namespace", + "version", + "Created", + "Updated", + "Last Updated", + } + + var rows [][]string + for i, m := range manifests { + meta := m.GetMeta() + row := []string{ + fmt.Sprint(i + 1), + ui.ShortHash(meta.GetHash()), + m.GetKind(), + m.GetName(), + m.GetNamespace(), + fmt.Sprint(meta.GetVersion()), + meta.GetCreatedAt().Format(time.RFC3339), + meta.GetUpdatedAt().Format(time.RFC3339), + meta.GetLastApplied().Format(time.RFC3339), + } + rows = append(rows, row) + } + + ui.Table(headers, rows) +} + +func handleSearchResults(manifests []manifests.Manifest, opts *Options) error { + ui.Infof("Found %d manifests", len(manifests)) + + if len(opts.sortBy) > 0 { + sortManifests(manifests, opts.sortBy) + } + + ui.Spinner(true, "Preparing results...") + defer ui.Spinner(false) + + if opts.output { + if err := outputResults(manifests, opts); err != nil { + return fmt.Errorf("output failed: %w", err) + } + } else { + displayResults(manifests) + } + + ui.Success("Search completed") + return nil +} + +func outputResults(manifests []manifests.Manifest, opts *Options) error { + if err := ensureOutputDirectory(opts.outputPath); err != nil { + return err + } + + if opts.outputMode == "combined" { + return writeCombinedOutput(manifests, opts) + } + return writeSeparateOutputs(manifests, opts) +} + +func writeCombinedOutput(manifests []manifests.Manifest, opts *Options) error { + filename := filepath.Join(opts.outputPath, fmt.Sprintf("manifests.%s", opts.outputFormat)) + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer func() { + _ = file.Close() + }() + + switch opts.outputFormat { + case "yaml": + return writeCombinedYAML(file, manifests) + case "json": + return writeCombinedJSON(file, manifests) + default: + return fmt.Errorf("unsupported format: %s", opts.outputFormat) + } +} + +func writeCombinedYAML(file *os.File, manifests []manifests.Manifest) error { + encoder := yaml.NewEncoder(file) + for _, m := range manifests { + if err := encoder.Encode(m); err != nil { + return fmt.Errorf("YAML encoding failed: %w", err) + } + if _, err := file.WriteString("---\n"); err != nil { + return fmt.Errorf("failed to write YAML separator: %w", err) + } + } + return nil +} + +func writeCombinedJSON(file *os.File, manifests []manifests.Manifest) error { + if _, err := file.WriteString("[\n"); err != nil { + return err + } + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + + for i, m := range manifests { + if i > 0 { + if _, err := file.WriteString(",\n"); err != nil { + return err + } + } + if err := encoder.Encode(m); err != nil { + return err + } + } + + _, err := file.WriteString("\n]") + return err +} + +func ensureOutputDirectory(path string) error { + if path == "" { + path = "." + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + ui.Infof("Creating output directory: %s", path) + if err = os.MkdirAll(path, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + } + return nil +} + +func writeSeparateOutputs(manifests []manifests.Manifest, opts *Options) error { + for _, m := range manifests { + filename := filepath.Join(opts.outputPath, fmt.Sprintf("%s.%s", m.GetID(), opts.outputFormat)) + if err := writeSingleManifest(filename, m, opts.outputFormat); err != nil { + return fmt.Errorf("failed to write manifest %s: %w", m.GetID(), err) + } + } + ui.Successf("Successfully wrote %d manifests to %s", len(manifests), opts.outputPath) + return nil +} + +func writeSingleManifest(filename string, manifest manifests.Manifest, format string) error { + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer func() { + _ = file.Close() + }() + + switch strings.ToLower(format) { + case "yaml": + encoder := yaml.NewEncoder(file) + if err := encoder.Encode(manifest); err != nil { + return fmt.Errorf("yaml encoding failed: %w", err) + } + case "json": + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + if err := encoder.Encode(manifest); err != nil { + return fmt.Errorf("json encoding failed: %w", err) + } + default: + return fmt.Errorf("unsupported format: %s", format) + } + return nil +} diff --git a/cmd/cli/search/search.go b/cmd/cli/search/search.go new file mode 100644 index 0000000..4a6ad74 --- /dev/null +++ b/cmd/cli/search/search.go @@ -0,0 +1,74 @@ +package search + +import ( + "fmt" + "github.com/apiqube/cli/ui" + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "search", + Short: "Search for manifests using filters", + Long: `Search for manifests with powerful filtering options including exact/wildcard matching, +time ranges, and output formatting`, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + opts, err := parseSearchOptions(cmd) + if err != nil { + return fmt.Errorf("failed to parse options: %w", err) + } + + if err := validateSearchOptions(opts); err != nil { + return err + } + + manifests, err := executeSearch(opts) + if err != nil { + return fmt.Errorf("search failed: %w", err) + } + + if len(manifests) == 0 { + ui.Warning("No manifests found matching the criteria") + return nil + } + + return handleSearchResults(manifests, opts) + }, +} + +func init() { + // Basic filters + Cmd.Flags().BoolP("all", "a", false, "Get all manifests") + Cmd.Flags().StringP("name", "n", "", "Search manifest by name (exact match)") + Cmd.Flags().StringP("name-wildcard", "W", "", "Search manifest by wildcard pattern (e.g. '*name*')") + Cmd.Flags().StringP("name-regex", "R", "", "Search manifest by regex pattern") + + // Metadata filters + Cmd.Flags().StringP("namespace", "s", "", "Search manifests by namespace") + Cmd.Flags().StringP("kind", "k", "", "Search manifests by kind") + Cmd.Flags().IntP("version", "v", 0, "Search manifests by version") + Cmd.Flags().String("created-by", "", "Filter by exact creator username") + Cmd.Flags().String("used-by", "", "Filter by exact user who applied") + + // Advanced filters + Cmd.Flags().StringP("hash", "H", "", "Search manifests by hash prefix (min 5 chars)") + Cmd.Flags().StringSliceP("depends", "d", []string{}, "Search manifests by dependencies (comma separated)") + Cmd.Flags().StringSliceP("depends-all", "D", []string{}, "Search manifests by all dependencies (comma separated)") + + // Time filters + Cmd.Flags().String("created-after", "", "Search manifests created after date (YYYY-MM-DD or duration like 1h30m)") + Cmd.Flags().String("created-before", "", "Search manifests created before date/duration") + Cmd.Flags().String("updated-after", "", "Search manifests updated after date/duration") + Cmd.Flags().String("updated-before", "", "Search manifests updated before date/duration") + Cmd.Flags().String("last-applied", "", "Search manifests by last applied date/duration") + + // output options + Cmd.Flags().BoolP("output", "o", false, "Make output after searching") + Cmd.Flags().String("output-path", "", "output path for results (default: current directory)") + Cmd.Flags().String("output-mode", "separate", "output mode (combined|separate)") + Cmd.Flags().String("output-format", "yaml", "File format for output (yaml|json)") + + // Sorting + Cmd.Flags().StringSlice("sort", []string{}, "Sort by fields (e.g. --sort=kind,-name)") +} diff --git a/ui/helpers.go b/ui/helpers.go new file mode 100644 index 0000000..dc9e124 --- /dev/null +++ b/ui/helpers.go @@ -0,0 +1,9 @@ +package ui + +func ShortHash(hash string) string { + return hash[:8] +} + +func ShortHashLen(hash string, l int) string { + return hash[:l] +} From 4eba94b73ed6a7f3972f4940ee6ef0ac91b767b7 Mon Sep 17 00:00:00 2001 From: Nofre Date: Mon, 19 May 2025 04:01:25 +0200 Subject: [PATCH 8/9] chore: linting, cleaning, mod tiding --- cmd/cli/check/check.go | 2 +- cmd/cli/cleanup/cleanup.go | 1 + cmd/cli/rollback/rollback.go | 1 + cmd/cli/root.go | 1 + cmd/cli/search/options.go | 3 ++- cmd/cli/search/results.go | 9 +++++---- cmd/cli/search/search.go | 1 + 7 files changed, 12 insertions(+), 6 deletions(-) diff --git a/cmd/cli/check/check.go b/cmd/cli/check/check.go index 8084de0..a896d84 100644 --- a/cmd/cli/check/check.go +++ b/cmd/cli/check/check.go @@ -36,7 +36,7 @@ var cmdPlanCheck = &cobra.Command{ } if err := validateCheckPlanOptions(opts); err != nil { - return uiErrorf(err.Error()) + return uiErrorf("%s", err.Error()) } ui.Spinner(true, "Checking manifests...") diff --git a/cmd/cli/cleanup/cleanup.go b/cmd/cli/cleanup/cleanup.go index 30cfbc9..6f7b985 100644 --- a/cmd/cli/cleanup/cleanup.go +++ b/cmd/cli/cleanup/cleanup.go @@ -2,6 +2,7 @@ package cleanup import ( "fmt" + "github.com/apiqube/cli/internal/core/store" "github.com/apiqube/cli/ui" "github.com/spf13/cobra" diff --git a/cmd/cli/rollback/rollback.go b/cmd/cli/rollback/rollback.go index f949ee3..16fee82 100644 --- a/cmd/cli/rollback/rollback.go +++ b/cmd/cli/rollback/rollback.go @@ -2,6 +2,7 @@ package rollback import ( "fmt" + "github.com/apiqube/cli/internal/core/store" "github.com/apiqube/cli/ui" "github.com/spf13/cobra" diff --git a/cmd/cli/root.go b/cmd/cli/root.go index e8c54bf..5a28358 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -3,6 +3,7 @@ package cli import ( "context" "fmt" + "github.com/apiqube/cli/cmd/cli/apply" "github.com/apiqube/cli/cmd/cli/check" "github.com/apiqube/cli/cmd/cli/cleanup" diff --git a/cmd/cli/search/options.go b/cmd/cli/search/options.go index 01ce210..898efb4 100644 --- a/cmd/cli/search/options.go +++ b/cmd/cli/search/options.go @@ -2,8 +2,9 @@ package search import ( "fmt" - "github.com/spf13/cobra" "time" + + "github.com/spf13/cobra" ) type Options struct { diff --git a/cmd/cli/search/results.go b/cmd/cli/search/results.go index dbfa369..a2da169 100644 --- a/cmd/cli/search/results.go +++ b/cmd/cli/search/results.go @@ -3,14 +3,15 @@ package search import ( "encoding/json" "fmt" - "github.com/apiqube/cli/internal/core/manifests" - "github.com/apiqube/cli/ui" - "gopkg.in/yaml.v3" "os" "path/filepath" "sort" "strings" "time" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/ui" + "gopkg.in/yaml.v3" ) func sortManifests(manifests []manifests.Manifest, fields []string) { @@ -206,7 +207,7 @@ func ensureOutputDirectory(path string) error { if _, err := os.Stat(path); os.IsNotExist(err) { ui.Infof("Creating output directory: %s", path) - if err = os.MkdirAll(path, 0755); err != nil { + if err = os.MkdirAll(path, 0o755); err != nil { return fmt.Errorf("failed to create output directory: %w", err) } } diff --git a/cmd/cli/search/search.go b/cmd/cli/search/search.go index 4a6ad74..08acac3 100644 --- a/cmd/cli/search/search.go +++ b/cmd/cli/search/search.go @@ -2,6 +2,7 @@ package search import ( "fmt" + "github.com/apiqube/cli/ui" "github.com/spf13/cobra" ) From dfbe78dc374b3f4eb61c5dcd21465b766973ab9e Mon Sep 17 00:00:00 2001 From: Nofre Date: Mon, 19 May 2025 04:14:35 +0200 Subject: [PATCH 9/9] chore: some fixing --- cmd/cli/apply/apply.go | 2 +- cmd/cli/check/check.go | 17 +++++++++++++++-- examples/plan/plan.yaml | 1 - 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/cmd/cli/apply/apply.go b/cmd/cli/apply/apply.go index e007195..94f45e9 100644 --- a/cmd/cli/apply/apply.go +++ b/cmd/cli/apply/apply.go @@ -46,7 +46,7 @@ var Cmd = &cobra.Command{ } func printManifestsLoadResult(newMans, cachedMans []manifests.Manifest) { - ui.Infof("Loaded %d manifests", len(newMans)) + ui.Infof("Loaded %d new manifests", len(newMans)) for _, m := range newMans { ui.Infof("New manifest added: %s (h: %s...)", m.GetID(), ui.ShortHash(m.GetMeta().GetHash())) diff --git a/cmd/cli/check/check.go b/cmd/cli/check/check.go index a896d84..3bdabd6 100644 --- a/cmd/cli/check/check.go +++ b/cmd/cli/check/check.go @@ -200,8 +200,21 @@ func parseCheckPlanFlags(cmd *cobra.Command, _ []string) (*checkPlanOptions, err } } - if opts.flagsSet["id"] || (opts.flagsSet["name"] || (opts.flagsSet["namespace"] && opts.flagsSet["file"])) { - return nil, fmt.Errorf("cannot use all filters at the same time") + exclusiveFlags := []string{"id", "name", "namespace", "file"} + + var usedFlags []string + for _, flag := range exclusiveFlags { + if opts.flagsSet[flag] { + usedFlags = append(usedFlags, "--"+flag) + } + } + + if len(usedFlags) > 1 { + return nil, fmt.Errorf( + "conflicting filters: %s\n"+ + "these filters cannot be used together, please use only one", + strings.Join(usedFlags, " and "), + ) } return opts, nil diff --git a/examples/plan/plan.yaml b/examples/plan/plan.yaml index 67b3cc5..f75d5e2 100644 --- a/examples/plan/plan.yaml +++ b/examples/plan/plan.yaml @@ -14,7 +14,6 @@ spec: - name: "Starting and checking server" manifests: - default.Server.simple-server - - default.Server.simple-server - name: "Testing APIs" manifests: