go.mondoo.com/cnquery@v0.0.0-20231005093811-59568235f6ea/providers-sdk/v1/lr/cli/cmd/markdown.go (about)

     1  // Copyright (c) Mondoo, Inc.
     2  // SPDX-License-Identifier: BUSL-1.1
     3  
     4  package cmd
     5  
     6  import (
     7  	"bufio"
     8  	"bytes"
     9  	"fmt"
    10  	"os"
    11  	"path/filepath"
    12  	"regexp"
    13  	"sort"
    14  	"strings"
    15  	"text/template"
    16  
    17  	"github.com/olekukonko/tablewriter"
    18  	"github.com/rs/zerolog/log"
    19  	"github.com/spf13/cobra"
    20  	"go.mondoo.com/cnquery/providers-sdk/v1/lr"
    21  	"go.mondoo.com/cnquery/providers-sdk/v1/lr/docs"
    22  	"go.mondoo.com/cnquery/providers-sdk/v1/resources"
    23  	"sigs.k8s.io/yaml"
    24  )
    25  
    26  func init() {
    27  	markdownCmd.Flags().String("pack-name", "", "name of the resource pack")
    28  	markdownCmd.Flags().String("description", "", "description of the resource pack")
    29  	markdownCmd.Flags().String("docs-file", "", "optional docs yaml to enrich the resource information")
    30  	markdownCmd.Flags().StringP("output", "o", ".build", "optional docs yaml to enrich the resource information")
    31  	rootCmd.AddCommand(markdownCmd)
    32  }
    33  
    34  const frontMatterTemplate = `---
    35  title: {{ .PackName }} Resource Pack - MQL Resources
    36  id: {{ .ID }}.pack
    37  sidebar_label: {{ .PackName }} Resource Pack
    38  displayed_sidebar: MQL
    39  description: {{ .Description }}
    40  ---
    41  `
    42  
    43  var markdownCmd = &cobra.Command{
    44  	Use:   "markdown",
    45  	Short: "generates markdown files",
    46  	Long:  `parse an LR file and generates a markdown file`,
    47  	Args:  cobra.MinimumNArgs(1),
    48  	Run: func(cmd *cobra.Command, args []string) {
    49  		raw, err := os.ReadFile(args[0])
    50  		if err != nil {
    51  			log.Error().Msg(err.Error())
    52  			return
    53  		}
    54  
    55  		outputDir, err := cmd.Flags().GetString("output")
    56  		if err != nil {
    57  			log.Fatal().Err(err).Msg("no output directory provided")
    58  		}
    59  
    60  		res, err := lr.Parse(string(raw))
    61  		if err != nil {
    62  			log.Error().Msg(err.Error())
    63  			return
    64  		}
    65  
    66  		schema, err := lr.Schema(res)
    67  		if err != nil {
    68  			log.Error().Err(err).Msg("failed to generate schema")
    69  		}
    70  
    71  		var lrDocsData docs.LrDocs
    72  		docsFilepath, _ := cmd.Flags().GetString("docs-file")
    73  		_, err = os.Stat(docsFilepath)
    74  		if err == nil {
    75  			content, err := os.ReadFile(docsFilepath)
    76  			if err != nil {
    77  				log.Fatal().Err(err).Msg("could not read file " + docsFilepath)
    78  			}
    79  			err = yaml.Unmarshal(content, &lrDocsData)
    80  			if err != nil {
    81  				log.Fatal().Err(err).Msg("could not load yaml data")
    82  			}
    83  
    84  			log.Info().Int("resources", len(lrDocsData.Resources)).Msg("loaded docs from " + docsFilepath)
    85  		} else {
    86  			log.Info().Msg("no docs file provided")
    87  		}
    88  
    89  		// to ensure we generate the same markdown, we sort the resources first
    90  		sort.SliceStable(res.Resources, func(i, j int) bool {
    91  			return res.Resources[i].ID < res.Resources[j].ID
    92  		})
    93  
    94  		// generate resource map for hyperlink generation and table of content
    95  		resourceHrefMap := map[string]bool{}
    96  		for i := range res.Resources {
    97  			resource := res.Resources[i]
    98  			resourceHrefMap[resource.ID] = true
    99  		}
   100  
   101  		// render all resources incl. fields and examples
   102  		r := &lrSchemaRenderer{
   103  			resourceHrefMap: resourceHrefMap,
   104  		}
   105  
   106  		// render readme
   107  		packName, _ := cmd.Flags().GetString("pack-name")
   108  		err = os.MkdirAll(outputDir, 0o755)
   109  		if err != nil {
   110  			log.Fatal().Err(err).Msg("could not create directory: " + outputDir)
   111  		}
   112  		description, _ := cmd.Flags().GetString("description")
   113  		err = os.MkdirAll(outputDir, 0o755)
   114  		if err != nil {
   115  			log.Fatal().Err(err).Msg("could not create directory: " + outputDir)
   116  		}
   117  		err = os.WriteFile(filepath.Join(outputDir, "README.md"), []byte(r.renderToc(packName, description, res.Resources, schema)), 0o600)
   118  		if err != nil {
   119  			log.Fatal().Err(err).Msg("could not write file")
   120  		}
   121  
   122  		for i := range res.Resources {
   123  			resource := res.Resources[i]
   124  			var docs *docs.LrDocsEntry
   125  			var ok bool
   126  			if lrDocsData.Resources != nil {
   127  				docs, ok = lrDocsData.Resources[resource.ID]
   128  				if !ok {
   129  					log.Warn().Msg("no docs found for resource " + resource.ID)
   130  				}
   131  			}
   132  
   133  			err = os.WriteFile(filepath.Join(outputDir, strings.ToLower(resource.ID+".md")), []byte(r.renderResourcePage(resource, schema, docs)), 0o600)
   134  			if err != nil {
   135  				log.Fatal().Err(err).Msg("could not write file")
   136  			}
   137  		}
   138  	},
   139  }
   140  
   141  var reNonID = regexp.MustCompile(`[^A-Za-z0-9-]+`)
   142  
   143  type lrSchemaRenderer struct {
   144  	resourceHrefMap map[string]bool
   145  }
   146  
   147  func toID(s string) string {
   148  	s = reNonID.ReplaceAllString(s, ".")
   149  	s = strings.ToLower(s)
   150  	return strings.Trim(s, ".")
   151  }
   152  
   153  func (l *lrSchemaRenderer) renderToc(packName string, description string, resources []*lr.Resource, schema *resources.Schema) string {
   154  	builder := &strings.Builder{}
   155  
   156  	// render front matter
   157  	tpl, _ := template.New("frontmatter").Parse(frontMatterTemplate)
   158  	var buf bytes.Buffer
   159  	writer := bufio.NewWriter(&buf)
   160  	err := tpl.Execute(writer, struct {
   161  		PackName    string
   162  		Description string
   163  		ID          string
   164  	}{
   165  		PackName:    packName,
   166  		Description: description,
   167  		ID:          toID(packName),
   168  	})
   169  	if err != nil {
   170  		panic(err)
   171  	}
   172  	writer.Flush()
   173  	builder.WriteString(buf.String())
   174  	builder.WriteString("\n")
   175  
   176  	// render content
   177  	builder.WriteString("# Mondoo " + packName + " Resource Pack Reference\n\n")
   178  	builder.WriteString("In this pack:\n\n")
   179  	rows := [][]string{}
   180  
   181  	for i := range resources {
   182  		resource := resources[i]
   183  		rows = append(rows, []string{"[" + resource.ID + "](" + mdRef(resource.ID) + ")", strings.Join(sanitizeComments([]string{schema.Resources[resource.ID].Title}), " ")})
   184  	}
   185  
   186  	table := tablewriter.NewWriter(builder)
   187  	table.SetHeader([]string{"ID", "Description"})
   188  	table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
   189  	table.SetAlignment(tablewriter.ALIGN_LEFT)
   190  	table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false})
   191  	table.SetCenterSeparator("|")
   192  	table.SetAutoWrapText(false)
   193  	table.AppendBulk(rows)
   194  	table.Render()
   195  	builder.WriteString("\n")
   196  
   197  	return builder.String()
   198  }
   199  
   200  // trimColon removes any : from the string since colons are not allowed in markdown front matter
   201  func trimColon(s string) string {
   202  	return strings.ReplaceAll(s, ":", "")
   203  }
   204  
   205  func (l *lrSchemaRenderer) renderResourcePage(resource *lr.Resource, schema *resources.Schema, docs *docs.LrDocsEntry) string {
   206  	builder := &strings.Builder{}
   207  
   208  	builder.WriteString("---\n")
   209  	builder.WriteString("title: " + resource.ID + "\n")
   210  	builder.WriteString("id: " + resource.ID + "\n")
   211  	builder.WriteString("sidebar_label: " + resource.ID + "\n")
   212  	builder.WriteString("displayed_sidebar: MQL\n")
   213  
   214  	headerDesc := strings.Join(sanitizeComments([]string{schema.Resources[resource.ID].Title}), " ")
   215  	if headerDesc != "" {
   216  		builder.WriteString("description: " + trimColon(headerDesc) + "\n")
   217  	}
   218  	builder.WriteString("---\n")
   219  	builder.WriteString("\n")
   220  
   221  	builder.WriteString("# ")
   222  	builder.WriteString(resource.ID)
   223  	builder.WriteString("\n\n")
   224  
   225  	if docs != nil && docs.Platform != nil && (len(docs.Platform.Name) > 0 || len(docs.Platform.Family) > 0) {
   226  		builder.WriteString("**Supported Platform**\n\n")
   227  		for r := range docs.Platform.Name {
   228  			builder.WriteString(fmt.Sprintf("- %s", docs.Platform.Name[r]))
   229  			builder.WriteString("\n")
   230  		}
   231  		for r := range docs.Platform.Family {
   232  			builder.WriteString(fmt.Sprintf("- %s", docs.Platform.Name[r]))
   233  			builder.WriteString("\n")
   234  		}
   235  		builder.WriteString("\n")
   236  	}
   237  
   238  	if docs != nil && len(docs.Maturity) > 0 {
   239  		builder.WriteString("**Maturity**\n\n")
   240  		builder.WriteString(docs.Maturity)
   241  		builder.WriteString("\n\n")
   242  	}
   243  
   244  	if schema.Resources[resource.ID].Title != "" {
   245  		builder.WriteString("**Description**\n\n")
   246  		builder.WriteString(strings.Join(sanitizeComments([]string{schema.Resources[resource.ID].Title}), "\n"))
   247  		builder.WriteString("\n\n")
   248  	}
   249  
   250  	if docs != nil && docs.Docs != nil && docs.Docs.Description != "" {
   251  		builder.WriteString(docs.Docs.Description)
   252  		builder.WriteString("\n\n")
   253  	}
   254  
   255  	inits := resource.GetInitFields()
   256  	// generate the constructor
   257  	if len(inits) > 0 {
   258  		builder.WriteString("**Init**\n\n")
   259  		for j := range inits {
   260  			init := inits[j]
   261  
   262  			for a := range init.Args {
   263  				arg := init.Args[a]
   264  				builder.WriteString(resource.ID + "(" + arg.ID + " " + renderLrType(arg.Type, l.resourceHrefMap) + ")")
   265  				builder.WriteString("\n")
   266  			}
   267  		}
   268  		builder.WriteString("\n")
   269  	}
   270  
   271  	if resource.ListType != nil {
   272  		builder.WriteString("**List**\n\n")
   273  		builder.WriteString("[]" + resource.ListType.Type.Type)
   274  		builder.WriteString("\n\n")
   275  	}
   276  
   277  	basicFields := []*lr.BasicField{}
   278  	comments := [][]string{}
   279  	for _, f := range resource.Body.Fields {
   280  		if f.BasicField != nil {
   281  			basicFields = append(basicFields, f.BasicField)
   282  			comments = append(comments, f.Comments)
   283  		}
   284  	}
   285  	// generate the fields markdown table
   286  	// NOTE: list types may not have any fields
   287  	if len(basicFields) > 0 {
   288  		builder.WriteString("**Fields**\n\n")
   289  		rows := [][]string{}
   290  
   291  		for k := range basicFields {
   292  			field := basicFields[k]
   293  			rows = append(rows, []string{
   294  				field.ID, renderLrType(field.Type, l.resourceHrefMap),
   295  				strings.Join(sanitizeComments(comments[k]), ", "),
   296  			})
   297  		}
   298  
   299  		table := tablewriter.NewWriter(builder)
   300  		table.SetHeader([]string{"ID", "Type", "Description"})
   301  		table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
   302  		table.SetAlignment(tablewriter.ALIGN_LEFT)
   303  		table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false})
   304  		table.SetCenterSeparator("|")
   305  		table.SetAutoWrapText(false)
   306  		table.AppendBulk(rows)
   307  		table.Render()
   308  		builder.WriteString("\n")
   309  	}
   310  
   311  	if docs != nil && len(docs.Snippets) > 0 {
   312  		builder.WriteString("**Examples**\n\n")
   313  		for si := range docs.Snippets {
   314  			snippet := docs.Snippets[si]
   315  			builder.WriteString(snippet.Title)
   316  			builder.WriteString("\n\n")
   317  			builder.WriteString("```coffee\n")
   318  			builder.WriteString(strings.TrimSpace(snippet.Query))
   319  			builder.WriteString("\n```\n\n")
   320  		}
   321  		builder.WriteString("\n")
   322  	}
   323  
   324  	if docs != nil && len(docs.Resources) > 0 {
   325  		builder.WriteString("**Resources**\n\n")
   326  		for r := range docs.Resources {
   327  			builder.WriteString(fmt.Sprintf("- [%s](%s)", docs.Resources[r].Title, docs.Resources[r].Url))
   328  			builder.WriteString("\n")
   329  		}
   330  		builder.WriteString("\n")
   331  	}
   332  
   333  	if docs != nil && len(docs.Refs) > 0 {
   334  		builder.WriteString("**References**\n\n")
   335  		for r := range docs.Refs {
   336  			builder.WriteString(fmt.Sprintf("- [%s](%s)", docs.Refs[r].Title, docs.Refs[r].Url))
   337  			builder.WriteString("\n")
   338  		}
   339  		builder.WriteString("\n")
   340  	}
   341  
   342  	return builder.String()
   343  }
   344  
   345  func anchore(name string) string {
   346  	name = strings.Replace(name, ".", "", -1)
   347  	return strings.ToLower(name)
   348  }
   349  
   350  func mdRef(name string) string {
   351  	return strings.ToLower(name) + ".md"
   352  }
   353  
   354  func renderLrType(t lr.Type, resourceHrefMap map[string]bool) string {
   355  	switch {
   356  	case t.SimpleType != nil:
   357  		_, ok := resourceHrefMap[t.SimpleType.Type]
   358  		if ok {
   359  			return "[" + t.SimpleType.Type + "](" + mdRef(t.SimpleType.Type) + ")"
   360  		}
   361  		return t.SimpleType.Type
   362  	case t.ListType != nil:
   363  		// we need a space between [] and the link, otherwise some markdown link parsers do not render the links properly
   364  		// related to https://github.com/facebook/docusaurus/issues/4801
   365  		return "&#91;&#93;" + renderLrType(t.ListType.Type, resourceHrefMap)
   366  	case t.MapType != nil:
   367  		return "map[" + t.MapType.Key.Type + "]" + renderLrType(t.MapType.Value, resourceHrefMap)
   368  	default:
   369  		return "?"
   370  	}
   371  }
   372  
   373  func sanitizeComments(c []string) []string {
   374  	for i := range c {
   375  		c[i] = strings.TrimPrefix(c[i], "// ")
   376  	}
   377  
   378  	return c
   379  }