diff --git a/cmd/config.go b/cmd/config.go index cac41fc48..e286de581 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -77,6 +77,16 @@ Tests handler configs present in ~/.kubewatch.yaml by sending test messages`, }, } +var configSampleCmd = &cobra.Command{ + Use: "sample", + Short: "Show a sample config file", + Long: ` +Print a sample config file which can be put in ~/.kubewatch.yaml`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Print(config.ConfigSample) + }, +} + var configViewCmd = &cobra.Command{ Use: "view", Short: "view ~/.kubewatch.yaml", @@ -98,6 +108,7 @@ func init() { configCmd.AddCommand( configAddCmd, configTestCmd, + configSampleCmd, configViewCmd, ) diff --git a/config/config.go b/config/config.go index 37ea06902..f0ab26e1a 100644 --- a/config/config.go +++ b/config/config.go @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +//go:generate bash -c "go install ../tools/yannotated && yannotated -o sample.go -format go -package config -type Config" + package config import ( @@ -25,8 +27,13 @@ import ( "gopkg.in/yaml.v3" ) -// ConfigFileName stores file of config -var ConfigFileName = ".kubewatch.yaml" +var ( + // ConfigFileName stores file of config + ConfigFileName = ".kubewatch.yaml" + + // ConfigSample is a sample configuration file. + ConfigSample = yannotated +) // Handler contains handler configuration type Handler struct { @@ -60,26 +67,37 @@ type Resource struct { // Config struct contains kubewatch configuration type Config struct { + // Handlers know how to send notifications to specific services. Handler Handler `json:"handler"` + //Reason []string `json:"reason"` + + // Resources to watch. Resource Resource `json:"resource"` - // for watching specific namespace, leave it empty for watching all. + + // For watching specific namespace, leave it empty for watching all. // this config is ignored when watching namespaces Namespace string `json:"namespace,omitempty"` } // Slack contains slack configuration type Slack struct { - Token string `json:"token"` + // Slack "legacy" API token. + Token string `json:"token"` + // Slack channel. Channel string `json:"channel"` - Title string `json:"title"` + // Title of the message. + Title string `json:"title"` } // Hipchat contains hipchat configuration type Hipchat struct { + // Hipchat token. Token string `json:"token"` - Room string `json:"room"` - Url string `json:"url"` + // Room name. + Room string `json:"room"` + // URL of the hipchat server. + Url string `json:"url"` } // Mattermost contains mattermost configuration @@ -91,36 +109,51 @@ type Mattermost struct { // Flock contains flock configuration type Flock struct { + // URL of the flock API. Url string `json:"url"` } // Webhook contains webhook configuration type Webhook struct { + // Webhook URL. Url string `json:"url"` } // MSTeams contains MSTeams configuration type MSTeams struct { + // MSTeams API Webhook URL. WebhookURL string `json:"webhookurl"` } // SMTP contains SMTP configuration. type SMTP struct { - To string `json:"to" yaml:"to,omitempty"` - From string `json:"from" yaml:"from,omitempty"` - Hello string `json:"hello" yaml:"hello,omitempty"` - Smarthost string `json:"smarthost" yaml:"smarthost,omitempty"` - Subject string `json:"subject" yaml:"subject,omitempty"` - Headers map[string]string `json:"headers" yaml:"headers,omitempty"` - Auth SMTPAuth `json:"auth" yaml:"auth,omitempty"` - RequireTLS bool `json:"requireTLS" yaml:"requireTLS"` + // Destination e-mail address. + To string `json:"to" yaml:"to,omitempty"` + // Sender e-mail address . + From string `json:"from" yaml:"from,omitempty"` + // Smarthost, aka "SMTP server"; address of server used to send email. + Smarthost string `json:"smarthost" yaml:"smarthost,omitempty"` + // Subject of the outgoing emails. + Subject string `json:"subject" yaml:"subject,omitempty"` + // Extra e-mail headers to be added to all outgoing messages. + Headers map[string]string `json:"headers" yaml:"headers,omitempty"` + // Authentication parameters. + Auth SMTPAuth `json:"auth" yaml:"auth,omitempty"` + // If "true" forces secure SMTP protocol (AKA StartTLS). + RequireTLS bool `json:"requireTLS" yaml:"requireTLS"` + // SMTP hello field (optional) + Hello string `json:"hello" yaml:"hello,omitempty"` } type SMTPAuth struct { + // Username for PLAN and LOGIN auth mechanisms. Username string `json:"username" yaml:"username,omitempty"` + // Password for PLAIN and LOGIN auth mechanisms. Password string `json:"password" yaml:"password,omitempty"` - Secret string `json:"secret" yaml:"secret,omitempty"` + // Identity for PLAIN auth mechanism Identity string `json:"identity" yaml:"identity,omitempty"` + // Secret for CRAM-MD5 auth mechanism + Secret string `json:"secret" yaml:"secret,omitempty"` } // New creates new config object diff --git a/config/sample.go b/config/sample.go new file mode 100644 index 000000000..d42876d5b --- /dev/null +++ b/config/sample.go @@ -0,0 +1,77 @@ +package config + +var yannotated = `# Handlers know how to send notifications to specific services. +handler: + slack: + # Slack "legacy" API token. + token: "" + # Slack channel. + channel: "" + # Title of the message. + title: "" + hipchat: + # Hipchat token. + token: "" + # Room name. + room: "" + # URL of the hipchat server. + url: "" + mattermost: + room: "" + url: "" + username: "" + flock: + # URL of the flock API. + url: "" + webhook: + # Webhook URL. + url: "" + msteams: + # MSTeams API Webhook URL. + webhookurl: "" + smtp: + # Destination e-mail address. + to: "" + # Sender e-mail address . + from: "" + # Smarthost, aka "SMTP server"; address of server used to send email. + smarthost: "" + # Subject of the outgoing emails. + subject: "" + # Extra e-mail headers to be added to all outgoing messages. + headers: {} + # Authentication parameters. + auth: + # Username for PLAN and LOGIN auth mechanisms. + username: "" + # Password for PLAIN and LOGIN auth mechanisms. + password: "" + # Identity for PLAIN auth mechanism + identity: "" + # Secret for CRAM-MD5 auth mechanism + secret: "" + # If "true" forces secure SMTP protocol (AKA StartTLS). + requireTLS: false + # SMTP hello field (optional) + hello: "" +# Resources to watch. +resource: + deployment: false + rc: false + rs: false + ds: false + svc: false + po: false + job: false + node: false + clusterrole: false + sa: false + pv: false + ns: false + secret: false + configmap: false + ing: false +# For watching specific namespace, leave it empty for watching all. +# this config is ignored when watching namespaces +namespace: "" +` diff --git a/go.mod b/go.mod index b49ed2fcc..f45c2c877 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/bitnami-labs/kubewatch go 1.14 require ( + github.com/fatih/structtag v1.2.0 github.com/fsnotify/fsnotify v1.4.9 // indirect github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect github.com/golang/protobuf v1.4.2 // indirect @@ -15,6 +16,7 @@ require ( github.com/mkmik/multierror v0.3.0 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect github.com/pelletier/go-toml v1.0.1 // indirect + github.com/segmentio/textio v1.2.0 github.com/sirupsen/logrus v1.6.0 github.com/slack-go/slack v0.6.5 github.com/spf13/cast v1.1.0 // indirect diff --git a/go.sum b/go.sum index 16f48ae48..05106ad24 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZ github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= @@ -114,6 +116,8 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/segmentio/textio v1.2.0 h1:Ug4IkV3kh72juJbG8azoSBlgebIbUUxVNrfFcKHfTSQ= +github.com/segmentio/textio v1.2.0/go.mod h1:+Rb7v0YVODP+tK5F7FD9TCkV7gOYx9IgLHWiqtvY8ag= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/slack-go/slack v0.6.5 h1:IkDKtJ2IROJNoe3d6mW870/NRKvq2fhLB/Q5XmzWk00= github.com/slack-go/slack v0.6.5/go.mod h1:FGqNzJBmxIsZURAxh2a8D21AnOVvvXZvGligs4npPUM= diff --git a/tools/yannotated/yannotated.go b/tools/yannotated/yannotated.go new file mode 100644 index 000000000..3734aa2b7 --- /dev/null +++ b/tools/yannotated/yannotated.go @@ -0,0 +1,196 @@ +// Yannotated generates an annotated yaml config boilerplate from a Go structure tree. +package main + +import ( + "flag" + "fmt" + "go/ast" + "go/parser" + "go/token" + "io" + "log" + "os" + "strings" + + "github.com/fatih/structtag" + "github.com/segmentio/textio" +) + +const ( + YAMLFormat = "yaml" + GoFormat = "go" +) + +type Flags struct { + // Dir is the directory containing the source code. + Dir string + // Package of the root type name. + Package string + // Type is the name of the root type of the config tree. + Type string + // Output is the file name of the generated output. + Output string + // Format is the format of the output. + Format string +} + +func (f *Flags) Bind(fs *flag.FlagSet) { + if fs == nil { + fs = flag.CommandLine + } + fs.StringVar(&f.Dir, "dir", ".", "Directory of the Go source code") + fs.StringVar(&f.Package, "package", "", "Name of the root struct type") + fs.StringVar(&f.Type, "type", "", "Name of the root struct type") + fs.StringVar(&f.Output, "o", "", "Filename of the generated output") + fs.StringVar(&f.Format, "format", "", "Output format: Yaml, Go") +} + +func mainE(flags Flags) error { + var fset token.FileSet + pkgs, err := parser.ParseDir(&fset, flags.Dir, nil, parser.ParseComments) + if err != nil { + return err + } + pkg, found := pkgs[flags.Package] + if !found { + return fmt.Errorf("cannot find package %q in %v", flags.Package, pkgs) + } + + // trim all unexported symbols + for _, f := range pkg.Files { + ast.FileExports(f) + } + + types := collectTypes(pkg) + + root, found := types[flags.Type] + if !found { + return fmt.Errorf("cannot find root type %q in %v", flags.Type, types) + } + w, err := os.Create(flags.Output) + if err != nil { + return err + } + defer w.Close() + + if flags.Format == GoFormat { + fmt.Fprintf(w, "package %s\n\n", flags.Package) + fmt.Fprintf(w, "var yannotated = `") + defer fmt.Fprintf(w, "`\n") + } + + return emit(w, types, root) +} + +func emit(w io.Writer, types map[string]*ast.StructType, node *ast.StructType) error { + for _, field := range node.Fields.List { + name, err := fieldName(field) + if err != nil { + return err + } + if field.Doc != nil { + lines := strings.Split(strings.TrimSpace(field.Doc.Text()), "\n") + for _, l := range lines { + fmt.Fprintf(w, "# %s\n", l) + } + } + + fmt.Fprintf(w, "%s:", name) + + switch typ := field.Type.(type) { + case *ast.Ident: + + switch name := typ.Name; name { + case "string": + fmt.Fprintln(w, ` ""`) + case "int": + fmt.Fprintln(w, " 0") + case "bool": + fmt.Fprintln(w, " false") + default: + t, found := types[name] + if !found { + return fmt.Errorf("cannot find type %q", name) + } + fmt.Fprintf(w, "\n") + iw := textio.NewPrefixWriter(w, " ") + if err := emit(iw, types, t); err != nil { + return err + } + } + case *ast.MapType: + fmt.Fprintf(w, " {}\n") + default: + return fmt.Errorf("unsupported field type: %T (%s)", field.Type, field.Type) + } + } + return nil +} + +func fieldName(field *ast.Field) (string, error) { + if field.Tag != nil { + // remove backticks + clean := field.Tag.Value[1 : len(field.Tag.Value)-1] + tags, err := structtag.Parse(clean) + if err != nil { + return "", fmt.Errorf("while parsing %q: %w", clean, err) + } + + var yamlName, jsonName string + for _, tag := range tags.Tags() { + switch tag.Key { + case "json": + jsonName = tag.Name + case "yaml": + yamlName = tag.Name + } + } + if yamlName != "" { + return yamlName, nil + } + if jsonName != "" { + return jsonName, nil + } + } + if got, want := len(field.Names), 1; got != want { + return "", fmt.Errorf("unsupported number of struct field names, got: %d, want: %d", got, want) + } + + return strings.ToLower(field.Names[0].Name), nil +} + +func collectTypes(n ast.Node) map[string]*ast.StructType { + v := typeCollectingVisitor(map[string]*ast.StructType{}) + ast.Walk(v, n) + return v +} + +type typeCollectingVisitor map[string]*ast.StructType + +func (v typeCollectingVisitor) Visit(node ast.Node) (w ast.Visitor) { + switch n := node.(type) { + case *ast.TypeSpec: + if t, ok := n.Type.(*ast.StructType); ok { + v[n.Name.Name] = t + } + case *ast.Package: + return v + case *ast.File: + return v + case *ast.GenDecl: + if n.Tok == token.TYPE { + return v + } + } + return nil +} + +func main() { + var flags Flags + flags.Bind(nil) + flag.Parse() + + if err := mainE(flags); err != nil { + log.Fatal(err) + } +} diff --git a/tools/yannotated/yannotated_test.go b/tools/yannotated/yannotated_test.go new file mode 100644 index 000000000..bcda3b53c --- /dev/null +++ b/tools/yannotated/yannotated_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "io/ioutil" + "os" + "testing" +) + +// Config is a config. +type Config struct { + // Foo is foo. + Foo string `yaml:"foo"` + // Bar is bar. + // So useful. + Bar Bar `yaml:"bar"` + // Rebar is another bar. + Rebar Bar `yaml:"rebar"` + Quz map[string]string +} + +// Bar is a struct. +type Bar struct { + // Baz is baz. + Baz int `yaml:"baz"` +} + +func TestMain(t *testing.T) { + tmp, err := ioutil.TempFile("", "") + if err != nil { + t.Fatal(err) + } + tmp.Close() + defer os.RemoveAll(tmp.Name()) + + err = mainE(Flags{ + Dir: ".", + Package: "main", + Type: "Config", + Output: tmp.Name(), + }) + if err != nil { + t.Fatal(err) + } + + want := `# Foo is foo. +foo: "" +# Bar is bar. +# So useful. +bar: + # Baz is baz. + baz: 0 +# Rebar is another bar. +rebar: + # Baz is baz. + baz: 0 +quz: {} +` + b, err := ioutil.ReadFile(tmp.Name()) + if err != nil { + t.Fatal(err) + } + + if got := string(b); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestGo(t *testing.T) { + tmp, err := ioutil.TempFile("", "") + if err != nil { + t.Fatal(err) + } + tmp.Close() + defer os.RemoveAll(tmp.Name()) + + err = mainE(Flags{ + Dir: ".", + Package: "main", + Type: "Bar", + Output: tmp.Name(), + Format: GoFormat, + }) + if err != nil { + t.Fatal(err) + } + + want := `package main + +var yannotated = ` + "`" + `# Baz is baz. +baz: 0 +` + "`\n" + + b, err := ioutil.ReadFile(tmp.Name()) + if err != nil { + t.Fatal(err) + } + + if got := string(b); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +}