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

     1  // Copyright (c) Mondoo, Inc.
     2  // SPDX-License-Identifier: BUSL-1.1
     3  
     4  package cmd
     5  
     6  import (
     7  	"encoding/json"
     8  	"fmt"
     9  	"os"
    10  	"path"
    11  	"path/filepath"
    12  	"sort"
    13  	"strings"
    14  
    15  	"github.com/rs/zerolog/log"
    16  	"github.com/spf13/cobra"
    17  	"go.mondoo.com/cnquery/providers-sdk/v1/lr"
    18  	"go.mondoo.com/cnquery/providers-sdk/v1/lr/docs"
    19  	"sigs.k8s.io/yaml"
    20  )
    21  
    22  func init() {
    23  	docsYamlCmd.Flags().String("docs-file", "", "optional file path to write content to a file")
    24  	docsYamlCmd.Flags().String("version", defaultVersion, "optional version to mark resource, default is latest")
    25  	docsCmd.AddCommand(docsYamlCmd)
    26  	docsJSONCmd.Flags().String("dist", "", "folder for output json generation")
    27  	docsCmd.AddCommand(docsJSONCmd)
    28  	rootCmd.AddCommand(docsCmd)
    29  }
    30  
    31  const defaultVersion = "latest"
    32  
    33  var docsCmd = &cobra.Command{
    34  	Use: "docs",
    35  }
    36  
    37  var docsYamlCmd = &cobra.Command{
    38  	Use:   "yaml",
    39  	Short: "generates yaml docs skeleton file and merges it into existing definition",
    40  	Long:  `parse an LR file and generates a yaml file structure for additional documentation.`,
    41  	Args:  cobra.MinimumNArgs(1),
    42  	Run: func(cmd *cobra.Command, args []string) {
    43  		raw, err := os.ReadFile(args[0])
    44  		if err != nil {
    45  			log.Error().Msg(err.Error())
    46  			return
    47  		}
    48  
    49  		res, err := lr.Parse(string(raw))
    50  		if err != nil {
    51  			log.Error().Msg(err.Error())
    52  			return
    53  		}
    54  
    55  		// to ensure we generate the same markdown, we sort the resources first
    56  		sort.SliceStable(res.Resources, func(i, j int) bool {
    57  			return res.Resources[i].ID < res.Resources[j].ID
    58  		})
    59  
    60  		filepath, err := cmd.Flags().GetString("docs-file")
    61  		if err != nil {
    62  			log.Fatal().Err(err).Msg("invalid argument for `file`")
    63  		}
    64  
    65  		version, err := cmd.Flags().GetString("version")
    66  		if err != nil {
    67  			log.Fatal().Err(err).Msg("invalid argument for `version`")
    68  		}
    69  
    70  		d := docs.LrDocs{
    71  			Resources: map[string]*docs.LrDocsEntry{},
    72  		}
    73  
    74  		fields := map[string][]*lr.BasicField{}
    75  		isPrivate := map[string]bool{}
    76  		for i := range res.Resources {
    77  			id := res.Resources[i].ID
    78  			isPrivate[id] = res.Resources[i].IsPrivate
    79  			d.Resources[id] = nil
    80  			if res.Resources[i].Body != nil {
    81  				basicFields := []*lr.BasicField{}
    82  				for _, f := range res.Resources[i].Body.Fields {
    83  					if f.BasicField != nil {
    84  						basicFields = append(basicFields, f.BasicField)
    85  					}
    86  				}
    87  				fields[id] = basicFields
    88  			}
    89  		}
    90  
    91  		// default behaviour is to output the result on cli
    92  		if filepath == "" {
    93  			data, err := yaml.Marshal(d)
    94  			if err != nil {
    95  				log.Fatal().Err(err).Msg("could not marshal docs")
    96  			}
    97  
    98  			fmt.Println(string(data))
    99  			return
   100  		}
   101  
   102  		// if an file was provided, we check if the file exist and merge existing content with the new resources
   103  		// to ensure that existing documentation stays available
   104  		var existingData docs.LrDocs
   105  		_, err = os.Stat(filepath)
   106  		if err == nil {
   107  			log.Info().Msg("load existing data")
   108  			content, err := os.ReadFile(filepath)
   109  			if err != nil {
   110  				log.Fatal().Err(err).Msg("could not read file " + filepath)
   111  			}
   112  			err = yaml.Unmarshal(content, &existingData)
   113  			if err != nil {
   114  				log.Fatal().Err(err).Msg("could not load yaml data")
   115  			}
   116  
   117  			log.Info().Msg("merge content")
   118  			for k := range existingData.Resources {
   119  				v := existingData.Resources[k]
   120  				d.Resources[k] = v
   121  			}
   122  		}
   123  
   124  		// ensure default values and fields are set
   125  		for k := range d.Resources {
   126  			d.Resources[k] = ensureDefaults(k, d.Resources[k], version)
   127  			mergeFields(version, d.Resources[k], fields[k])
   128  			// Merge in other doc fields from core.lr
   129  			d.Resources[k].IsPrivate = isPrivate[k]
   130  		}
   131  
   132  		// generate content
   133  		data, err := yaml.Marshal(d)
   134  		if err != nil {
   135  			log.Fatal().Err(err).Msg("could not marshal docs")
   136  		}
   137  
   138  		// add license header
   139  		data = append([]byte("# Copyright (c) Mondoo, Inc.\n# SPDX-License-Identifier: BUSL-1.1\n\n"), data...)
   140  
   141  		log.Info().Str("file", filepath).Msg("write file")
   142  		err = os.WriteFile(filepath, data, 0o700)
   143  		if err != nil {
   144  			log.Fatal().Err(err).Msg("could not write docs file")
   145  		}
   146  	},
   147  }
   148  
   149  var platformMapping = map[string][]string{
   150  	"aws":       {"aws"},
   151  	"gcp":       {"gcloud"},
   152  	"k8s":       {"kubernetes"},
   153  	"azure":     {"azure"},
   154  	"azurerm":   {"azure"},
   155  	"arista":    {"arista-eos"},
   156  	"equinix":   {"equinix"},
   157  	"ms365":     {"microsoft365"},
   158  	"msgraph":   {"microsoft365"},
   159  	"vsphere":   {"vmware-esxi", "vmware-vsphere"},
   160  	"esxi":      {"vmware-esxi", "vmware-vsphere"},
   161  	"terraform": {"terraform"},
   162  }
   163  
   164  func ensureDefaults(id string, entry *docs.LrDocsEntry, version string) *docs.LrDocsEntry {
   165  	for k := range platformMapping {
   166  		if entry == nil {
   167  			entry = &docs.LrDocsEntry{}
   168  		}
   169  		if entry.MinMondooVersion == "" {
   170  			entry.MinMondooVersion = version
   171  		} else if entry.MinMondooVersion == defaultVersion && version != defaultVersion {
   172  			// Update to specified version if previously set to default
   173  			entry.MinMondooVersion = version
   174  		}
   175  		if strings.HasPrefix(id, k) {
   176  			entry.Platform = &docs.LrDocsPlatform{
   177  				Name: platformMapping[k],
   178  			}
   179  		}
   180  	}
   181  	return entry
   182  }
   183  
   184  func mergeFields(version string, entry *docs.LrDocsEntry, fields []*lr.BasicField) {
   185  	if entry == nil && len(fields) > 0 {
   186  		entry = &docs.LrDocsEntry{}
   187  		entry.Fields = map[string]*docs.LrDocsField{}
   188  	} else if entry == nil {
   189  		return
   190  	} else if entry.Fields == nil {
   191  		entry.Fields = map[string]*docs.LrDocsField{}
   192  	}
   193  	docFields := entry.Fields
   194  	for _, f := range fields {
   195  		if docFields[f.ID] == nil {
   196  			fDoc := &docs.LrDocsField{
   197  				MinMondooVersion: version,
   198  			}
   199  			entry.Fields[f.ID] = fDoc
   200  		} else if entry.Fields[f.ID].MinMondooVersion == "latest" && version != "latest" {
   201  			entry.Fields[f.ID].MinMondooVersion = version
   202  		}
   203  		// Scrub field version if same as resource
   204  		if entry.Fields[f.ID].MinMondooVersion == entry.MinMondooVersion {
   205  			entry.Fields[f.ID].MinMondooVersion = ""
   206  		}
   207  	}
   208  }
   209  
   210  func extractComments(raw []string) (string, string) {
   211  	if len(raw) == 0 {
   212  		return "", ""
   213  	}
   214  
   215  	for i := range raw {
   216  		raw[i] = strings.Trim(raw[i][2:], " \t\n")
   217  	}
   218  
   219  	title, rest := raw[0], raw[1:]
   220  	desc := strings.Join(rest, " ")
   221  
   222  	return title, desc
   223  }
   224  
   225  var docsJSONCmd = &cobra.Command{
   226  	Use:   "json",
   227  	Short: "convert yaml docs manifest into json",
   228  	Long:  `convert a yaml-based docs manifest into its json description, ready for loading`,
   229  	Args:  cobra.MinimumNArgs(1),
   230  	Run: func(cmd *cobra.Command, args []string) {
   231  		file := args[0]
   232  
   233  		dist, err := cmd.Flags().GetString("dist")
   234  		if err != nil {
   235  			log.Fatal().Err(err).Msg("failed to get dist flag")
   236  		}
   237  
   238  		// without dist we want the file to be put alongside the original
   239  		if dist == "" {
   240  			src, err := filepath.Abs(file)
   241  			if err != nil {
   242  				log.Fatal().Err(err).Msg("cannot figure out the absolute path for the source file")
   243  			}
   244  			dist = filepath.Dir(src)
   245  		}
   246  
   247  		raw, err := os.ReadFile(file)
   248  		if err != nil {
   249  			log.Fatal().Err(err)
   250  		}
   251  
   252  		var lrDocsData docs.LrDocs
   253  		err = yaml.Unmarshal(raw, &lrDocsData)
   254  		if err != nil {
   255  			log.Fatal().Err(err).Msg("could not load yaml data")
   256  		}
   257  
   258  		out, err := json.Marshal(&lrDocsData)
   259  		if err != nil {
   260  			log.Fatal().Err(err).Msg("failed to convert yaml to json")
   261  		}
   262  
   263  		if err = os.MkdirAll(dist, 0o755); err != nil {
   264  			log.Fatal().Err(err).Msg("failed to create dist folder")
   265  		}
   266  		infoFile := path.Join(dist, strings.TrimSuffix(path.Base(args[0]), ".yaml")+".json")
   267  		err = os.WriteFile(infoFile, []byte(out), 0o644)
   268  		if err != nil {
   269  			log.Fatal().Err(err).Str("path", infoFile).Msg("failed to write to json file")
   270  		}
   271  	},
   272  }