From e3def9854cd8d5461f37ddb4f21f4babe22f509d Mon Sep 17 00:00:00 2001 From: Kristoffer Ahl Date: Fri, 24 Feb 2023 13:11:18 +0100 Subject: [PATCH] Basic schema validation and improved help output. --- cmd/get.go | 16 ++++------ cmd/query.go | 15 ++++----- cmd/root.go | 25 +++++++++++++-- cmd/usage.go | 56 +++++++++++++++++++++++++++++++++ internal/pkg/schema/config.go | 17 ++++++++-- internal/pkg/schema/manifest.go | 51 ++++++++++++++++++++++++++++-- 6 files changed, 154 insertions(+), 26 deletions(-) create mode 100644 cmd/usage.go diff --git a/cmd/get.go b/cmd/get.go index 3df217a..b73bddf 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -5,7 +5,6 @@ import ( "fmt" "os" - "github.com/dotnetmentor/rq/internal/pkg/schema" "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) @@ -19,21 +18,17 @@ var ( ) var getCmd = &cobra.Command{ - Use: "get", + Use: "get [resource]", Short: "Get resource by key", Long: `Get a single resource by key`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - log.Debug().Msg("reading manifest...") - m, err := schema.NewManifest(opt.FilePath) - if err != nil { - return err - } + m := manifest log.Debug().Msg("validating resource type...") - rt, ok := m.ResourceType(args[0]) - if !ok { - return fmt.Errorf("unknown resource type %s, valid resource types: %s", args[0], m.ResourceTypeNames()) + rt, err := m.ValidateResourceType(args[0]) + if err != nil { + return err } resourceKey := args[1] @@ -66,4 +61,5 @@ var getCmd = &cobra.Command{ func init() { RootCmd.AddCommand(getCmd) getCmd.Flags().StringVarP(&getOpt.Property, "select", "s", "", "selects value of a property (eg. build)") + getCmd.SetUsageFunc(customUsageFunc) } diff --git a/cmd/query.go b/cmd/query.go index 397af57..0f91278 100644 --- a/cmd/query.go +++ b/cmd/query.go @@ -23,7 +23,7 @@ var ( ) var queryCmd = &cobra.Command{ - Use: "query", + Use: "query [resource]", Short: "Query resources", Long: `Query resources`, Args: cobra.ExactArgs(1), @@ -31,16 +31,12 @@ var queryCmd = &cobra.Command{ resetQueryOpt() }, RunE: func(cmd *cobra.Command, args []string) error { - log.Debug().Msg("reading manifest...") - m, err := schema.NewManifest(opt.FilePath) - if err != nil { - return err - } + m := manifest log.Debug().Msg("validating resource type...") - rt, ok := m.ResourceType(args[0]) - if !ok { - return fmt.Errorf("unknown resource type %s, valid resource types: %s", args[0], m.ResourceTypeNames()) + rt, err := m.ValidateResourceType(args[0]) + if err != nil { + return err } conditions, err := query.ParseArgs(queryOpt.Parameters) @@ -93,6 +89,7 @@ func init() { queryCmd.Flags().BoolVarP(&queryOpt.DisableStrictMatching, "disable-strict-matching", "d", false, "disable strict matching (matches conditions where parameter is missing)") queryCmd.Flags().BoolVarP(&queryOpt.Sort, "sort", "s", false, "sort output (lexicographically)") queryCmd.Flags().StringVarP(&queryOpt.Output, "out", "o", output.Newline, "output type (eg. json/xargs)") + queryCmd.SetUsageFunc(customUsageFunc) } func resetQueryOpt() { diff --git a/cmd/root.go b/cmd/root.go index a21a948..4265b40 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,6 +5,7 @@ import ( "os" "time" + "github.com/dotnetmentor/rq/internal/pkg/schema" "github.com/dotnetmentor/rq/version" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -17,7 +18,8 @@ type GlobalOptions struct { } var ( - opt GlobalOptions + opt GlobalOptions + manifest schema.Manifest ) var RootCmd = &cobra.Command{ @@ -26,13 +28,21 @@ var RootCmd = &cobra.Command{ Long: `rq - for querying resources`, SilenceUsage: true, Version: fmt.Sprintf("%s (commit=%s)", version.Version, version.Commit), - PersistentPreRun: func(cmd *cobra.Command, args []string) { + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { level, err := zerolog.ParseLevel(opt.LogLevel) if err != nil { fmt.Printf("invalid log level %s, err: %s", opt.LogLevel, err) os.Exit(1) } log.Logger = log.Level(level).Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}) + + log.Debug().Msg("reading manifest...") + err = tryReadManifest() + if err != nil { + return err + } + + return nil }, } @@ -53,6 +63,17 @@ func environmentOrDefault(key string, defaultValue string) string { return value } +func tryReadManifest() error { + if !manifest.Parsed() { + m, err := schema.NewManifest(opt.FilePath) + if err != nil { + return err + } + manifest = m + } + return nil +} + func init() { RootCmd.PersistentFlags().StringVarP(&opt.FilePath, "file", "f", environmentOrDefault("RQ_DEFAULT_FILE", "rq.yaml"), "Manifest file (eg. rq.yaml)") RootCmd.PersistentFlags().StringVarP(&opt.LogLevel, "level", "l", "info", "Log level (eg. trace/debug/warn/error)") diff --git a/cmd/usage.go b/cmd/usage.go new file mode 100644 index 0000000..c00cbe6 --- /dev/null +++ b/cmd/usage.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "io" + "strings" + "text/template" + "unicode" + + "github.com/spf13/cobra" +) + +const customUsageTemplate = `Usage:{{if .Runnable}} +{{.UseLine}}{{end}} + +Resources:{{resourceTypes | trimTrailingWhitespaces}}{{if .HasAvailableLocalFlags}} + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}} +` + +var templateFuncs = template.FuncMap{ + "trimTrailingWhitespaces": trimTrailingWhitespaces, + "resourceTypes": resourceTypes, +} + +func customUsageFunc(cmd *cobra.Command) error { + tryReadManifest() + + err := tmpl(cmd.OutOrStderr(), customUsageTemplate, cmd) + if err != nil { + cmd.PrintErrln(err) + } + return err +} + +func resourceTypes() string { + if len(manifest.Config.Resources) > 0 { + return manifest.ResourceTypeNames() + } + return " unknown" +} + +func trimTrailingWhitespaces(s string) string { + return strings.TrimRightFunc(s, unicode.IsSpace) +} + +// tmpl executes the given template text on data, writing the result to w. +func tmpl(w io.Writer, text string, data interface{}) error { + t := template.New("top") + t.Funcs(templateFuncs) + template.Must(t.Parse(text)) + return t.Execute(w, data) +} diff --git a/internal/pkg/schema/config.go b/internal/pkg/schema/config.go index 507ce41..f697bd2 100644 --- a/internal/pkg/schema/config.go +++ b/internal/pkg/schema/config.go @@ -27,9 +27,20 @@ func (n CustomResourceNames) Match(t string) bool { } func (n CustomResourceNames) String() string { - if strings.HasPrefix(n.Plural, n.Singular) { + out := "" + if n.Plural != "" && n.Singular != "" && strings.HasPrefix(n.Plural, n.Singular) { pe := strings.ReplaceAll(n.Plural, n.Singular, "") - return fmt.Sprintf(" - %s(%s) (short: %s)", n.Singular, pe, n.Short) + out = fmt.Sprintf(" - %s(%s)", n.Singular, pe) + } else { + v := []string{n.Plural} + if n.Singular != "" { + v = append(v, n.Singular) + } + out = fmt.Sprintf(" - %s", strings.Join(v, "/")) } - return fmt.Sprintf(" - %s/%s (short: %s)", n.Plural, n.Singular, n.Short) + + if n.Short != "" { + out = fmt.Sprintf("%s (short: %s)", out, n.Short) + } + return out } diff --git a/internal/pkg/schema/manifest.go b/internal/pkg/schema/manifest.go index 3cdda4d..6c9105b 100644 --- a/internal/pkg/schema/manifest.go +++ b/internal/pkg/schema/manifest.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" yaml2 "gopkg.in/yaml.v2" ) @@ -34,18 +35,64 @@ func NewManifest(path string) (Manifest, error) { if err != nil { return Manifest{}, fmt.Errorf("failed to parse manifest yaml. %v", err) } + m.parsed = true - // TODO: validate manifest - // - Resource keys must be unique for each resource type + errors := make([]string, 0) + for i, cr := range m.Config.Resources { + if cr.Kind == "" { + errors = append(errors, fmt.Sprintf("config.resources[%d]: missing property \"kind\"", i)) + } + if cr.Names.Plural == "" { + errors = append(errors, fmt.Sprintf("config.resources[%d]: missing property \"plural\"", i)) + } + } + + for _, rt := range m.Config.Resources { + keys := make([]string, 0) + for _, key := range m.ResourcesOfType(rt).SelectMany(func(i Resource) string { + return i.Key + }) { + if contains(keys, key) { + errors = append(errors, fmt.Sprintf("resources[%s].key: key \"%s\" must be unique", rt.Kind, key)) + } + keys = append(keys, key) + } + } + + if len(errors) > 0 { + return m, fmt.Errorf("invalid resource manifest, error(s):\n\n%s", strings.Join(errors, "\n")) + } return m, nil } +func contains(s []string, e string) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} + type Manifest struct { + parsed bool Config Config `yaml:"config,omitempty"` Root map[string]ResourceList `yaml:"resources,omitempty"` } +func (m Manifest) Parsed() bool { + return m.parsed +} + +func (m Manifest) ValidateResourceType(t string) (CustomResource, error) { + rt, ok := m.ResourceType(t) + if !ok { + return CustomResource{}, fmt.Errorf("unknown resource type %s, valid resource types: \n%s", t, m.ResourceTypeNames()) + } + return rt, nil +} + func (m Manifest) ResourcesOfType(cr CustomResource) ResourceList { for rk, rl := range m.Root { if cr.Names.Match(rk) {