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 "[]" + 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 }