github.com/snyk/vervet/v6@v6.2.4/internal/generator/generator.go (about)

     1  package generator
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"io"
     7  	"io/fs"
     8  	"log"
     9  	"os"
    10  	"path/filepath"
    11  	"strings"
    12  	"text/template"
    13  
    14  	"github.com/ghodss/yaml"
    15  
    16  	"github.com/snyk/vervet/v6"
    17  	"github.com/snyk/vervet/v6/config"
    18  )
    19  
    20  // Generator generates files for new resources from data models and templates.
    21  type Generator struct {
    22  	name      string
    23  	filename  *template.Template
    24  	contents  *template.Template
    25  	files     *template.Template
    26  	functions template.FuncMap
    27  	scope     config.GeneratorScope
    28  
    29  	debug  bool
    30  	dryRun bool
    31  	force  bool
    32  	here   string
    33  	fs     fs.FS
    34  }
    35  
    36  // NewMap instanstiates a map of Generators from configuration.
    37  func NewMap(generatorsConf config.Generators, options ...Option) (map[string]*Generator, error) {
    38  	result := map[string]*Generator{}
    39  	for name, genConf := range generatorsConf {
    40  		g, err := New(genConf, options...)
    41  		if err != nil {
    42  			return nil, err
    43  		}
    44  		result[name] = g
    45  	}
    46  	return result, nil
    47  }
    48  
    49  // New returns a new Generator from configuration.
    50  func New(conf *config.Generator, options ...Option) (*Generator, error) {
    51  	g := &Generator{
    52  		name:      conf.Name,
    53  		scope:     conf.Scope,
    54  		functions: template.FuncMap{},
    55  	}
    56  	for i := range options {
    57  		options[i](g)
    58  	}
    59  	if g.debug {
    60  		log.Printf("generator %s: debug logging enabled", g.name)
    61  	}
    62  
    63  	// If .Here isn't specified, we'll assume cwd.
    64  	var err error
    65  	if g.here == "" {
    66  		g.here, err = os.Getwd()
    67  		if err != nil {
    68  			return nil, err
    69  		}
    70  	}
    71  
    72  	// If no FS has been provided, use the DirFS for root.
    73  	if g.fs == nil {
    74  		fs := os.DirFS("/")
    75  		g.fs = fs
    76  	}
    77  
    78  	// Resolve the template 'functions'... with a template. Only .Here is
    79  	// supported, not full scope. Just enough to locate files relative to the
    80  	// config.
    81  	if conf.Functions != "" {
    82  		functionsFilename, err := g.resolveFilename(conf.Functions)
    83  		if err != nil {
    84  			return nil, fmt.Errorf("%w: (generators.%s.functions)", err, conf.Name)
    85  		}
    86  		err = g.loadFunctions(functionsFilename)
    87  		if err != nil {
    88  			return nil, fmt.Errorf("%w: (generators.%s.functions)", err, conf.Name)
    89  		}
    90  	}
    91  
    92  	// Resolve the template filename... with a template.  Only .Here and .Cwd
    93  	// are supported, not full scope. Just enough to locate files relative to
    94  	// the config.
    95  	templateFilename, err := g.resolveFilename(conf.Template)
    96  	if err != nil {
    97  		return nil, fmt.Errorf("%w: (generators.%s.template)", err, conf.Name)
    98  	}
    99  
   100  	// Parse & wire up other templates: contents, filename or files. These do
   101  	// support full scope.
   102  	templateFile, err := g.fs.Open(templateFilename)
   103  	if err != nil {
   104  		return nil, err
   105  	}
   106  	contentsTemplate, err := io.ReadAll(templateFile)
   107  	if err != nil {
   108  		return nil, fmt.Errorf("%w: (generators.%s.contents)", err, conf.Name)
   109  	}
   110  	g.contents, err = withIncludeFunc(template.New("contents").
   111  		Funcs(g.functions).
   112  		Funcs(builtinFuncs)).
   113  		Parse(string(contentsTemplate))
   114  	if err != nil {
   115  		return nil, fmt.Errorf("%w: (generators.%s.contents)", err, conf.Name)
   116  	}
   117  	if conf.Filename != "" {
   118  		g.filename, err = template.New("filename").
   119  			Funcs(g.functions).
   120  			Funcs(builtinFuncs).
   121  			Parse(conf.Filename)
   122  		if err != nil {
   123  			return nil, fmt.Errorf("%w: (generators.%s.filename)", err, conf.Name)
   124  		}
   125  	}
   126  	if conf.Files != "" {
   127  		g.files, err = withIncludeFunc(g.contents.New("files").Funcs(g.functions)).Parse(conf.Files)
   128  		if err != nil {
   129  			return nil, fmt.Errorf("%w: (generators.%s.files)", err, conf.Name)
   130  		}
   131  	}
   132  	return g, nil
   133  }
   134  
   135  func (g *Generator) resolveFilename(filenameTemplate string) (string, error) {
   136  	t, err := template.New("").Funcs(g.functions).Parse(filenameTemplate)
   137  	if err != nil {
   138  		return "", err
   139  	}
   140  	var buf bytes.Buffer
   141  	cwd, err := os.Getwd()
   142  	if err != nil {
   143  		return "", err
   144  	}
   145  	err = t.ExecuteTemplate(&buf, "", map[string]string{
   146  		"Here": g.here,
   147  		"Cwd":  cwd,
   148  	})
   149  	if err != nil {
   150  		return "", err
   151  	}
   152  	filename, err := filepath.Abs(buf.String())
   153  	if err != nil {
   154  		return "", err
   155  	}
   156  	// Remove the leading slash in the filepath -- fs.FS.Open does not accept rooted paths.
   157  	filename = strings.TrimPrefix(filename, "/")
   158  	return filename, nil
   159  }
   160  
   161  // Option configures a Generator.
   162  type Option func(g *Generator)
   163  
   164  // Force configures the Generator to overwrite generated artifacts.
   165  func Force(force bool) Option {
   166  	return func(g *Generator) {
   167  		g.force = true
   168  	}
   169  }
   170  
   171  // Debug turns on template debug logging.
   172  func Debug(debug bool) Option {
   173  	return func(g *Generator) {
   174  		g.debug = true
   175  	}
   176  }
   177  
   178  // DryRun executes templates and lists the files that would be generated
   179  // without actually generating them.
   180  func DryRun(dryRun bool) Option {
   181  	return func(g *Generator) {
   182  		g.dryRun = dryRun
   183  	}
   184  }
   185  
   186  // Here sets the .Here scope property. This is typically relative to the
   187  // location of the generators config file.
   188  func Here(here string) Option {
   189  	return func(g *Generator) {
   190  		g.here = here
   191  	}
   192  }
   193  
   194  // Filesystem sets the filesytem that the generator checks for templates.
   195  func Filesystem(fileSystem fs.FS) Option {
   196  	return func(g *Generator) {
   197  		g.fs = fileSystem
   198  	}
   199  }
   200  
   201  func Functions(funcs template.FuncMap) Option {
   202  	return func(g *Generator) {
   203  		for k := range funcs {
   204  			g.functions[k] = funcs[k]
   205  		}
   206  	}
   207  }
   208  
   209  // Execute runs the generator on the given resources.
   210  func (g *Generator) Execute(resources ResourceMap) ([]string, error) {
   211  	var allFiles []string
   212  	switch g.Scope() {
   213  	case config.GeneratorScopeDefault, config.GeneratorScopeVersion:
   214  		for rcKey, rcVersions := range resources {
   215  			for _, version := range rcVersions.Versions() {
   216  				rc, err := rcVersions.At(version.String())
   217  				if err != nil {
   218  					return nil, err
   219  				}
   220  				scope := &VersionScope{
   221  					API:             rcKey.API,
   222  					Path:            filepath.Join(rcKey.Path, version.DateString()),
   223  					ResourceVersion: rc,
   224  					Here:            g.here,
   225  					Env:             getEnvScope(),
   226  				}
   227  				generatedFiles, err := g.execute(scope)
   228  				if err != nil {
   229  					return nil, err
   230  				}
   231  				allFiles = append(allFiles, generatedFiles...)
   232  			}
   233  		}
   234  	case config.GeneratorScopeResource:
   235  		for rcKey, rcVersions := range resources {
   236  			scope := &ResourceScope{
   237  				API:              rcKey.API,
   238  				Path:             rcKey.Path,
   239  				ResourceVersions: rcVersions,
   240  				Here:             g.here,
   241  				Env:              getEnvScope(),
   242  			}
   243  			generatedFiles, err := g.execute(scope)
   244  			if err != nil {
   245  				return nil, err
   246  			}
   247  			allFiles = append(allFiles, generatedFiles...)
   248  		}
   249  	default:
   250  		return nil, fmt.Errorf("unsupported generator scope %q", g.Scope())
   251  	}
   252  	return allFiles, nil
   253  }
   254  
   255  func getEnvScope() map[string]string {
   256  	environPrefix := "VERVET_TEMPLATE_"
   257  	envScope := make(map[string]string)
   258  	environ := os.Environ()
   259  	for _, e := range environ {
   260  		if strings.HasPrefix(e, environPrefix) {
   261  			pair := strings.Split(e, "=")
   262  			key := strings.TrimPrefix(pair[0], environPrefix)
   263  			val := pair[1]
   264  			envScope[key] = val
   265  		}
   266  	}
   267  	return envScope
   268  }
   269  
   270  // ResourceScope identifies a resource that the generator is building for.
   271  type ResourceScope struct {
   272  	// ResourceVersions contains all the versions of this resource.
   273  	*vervet.ResourceVersions
   274  	// API is name of the API containing this resource.
   275  	API string
   276  	// Path is the path to the resource directory.
   277  	Path string
   278  	// Here is the directory containing the executing template.
   279  	Here string
   280  	// Env is a map of template values read from the os environment.
   281  	Env map[string]string
   282  }
   283  
   284  // Resource returns the name of the resource in scope.
   285  func (s *ResourceScope) Resource() string {
   286  	return s.ResourceVersions.Name()
   287  }
   288  
   289  // VersionScope identifies a distinct version of a resource that the generator
   290  // is building for.
   291  type VersionScope struct {
   292  	*vervet.ResourceVersion
   293  	// API is name of the API containing this resource.
   294  	API string
   295  	// Path is the path to the resource directory.
   296  	Path string
   297  	// Here is the directory containing the generator template.
   298  	Here string
   299  	// Env is a map of template values read from the os environment.
   300  	Env map[string]string
   301  }
   302  
   303  // Resource returns the name of the resource in scope.
   304  func (s *VersionScope) Resource() string {
   305  	return s.ResourceVersion.Name
   306  }
   307  
   308  // Version returns the version of the resource in scope.
   309  func (s *VersionScope) Version() *vervet.Version {
   310  	return &s.ResourceVersion.Version
   311  }
   312  
   313  // Scope returns the configured scope type of the generator.
   314  func (g *Generator) Scope() config.GeneratorScope {
   315  	return g.scope
   316  }
   317  
   318  // execute the Generator. If generated artifacts already exist, a warning
   319  // is logged but the file is not overwritten, unless force is true.
   320  func (g *Generator) execute(scope interface{}) ([]string, error) {
   321  	if g.files != nil {
   322  		return g.runFiles(scope)
   323  	}
   324  	return g.runFile(scope)
   325  }
   326  
   327  func (g *Generator) runFile(scope interface{}) ([]string, error) {
   328  	var filenameBuf bytes.Buffer
   329  	err := g.filename.ExecuteTemplate(&filenameBuf, "filename", scope)
   330  	if err != nil {
   331  		return nil, fmt.Errorf("failed to resolve filename: %w (generators.%s.filename)", err, g.name)
   332  	}
   333  	filename := filenameBuf.String()
   334  	if g.debug {
   335  		log.Printf("interpolated generators.%s.filename => %q", g.name, filename)
   336  	}
   337  	if _, err := os.Stat(filename); err == nil && !g.force {
   338  		log.Printf("not overwriting existing file %q", filename)
   339  		return nil, nil
   340  	}
   341  	parentDir := filepath.Dir(filename)
   342  	err = os.MkdirAll(parentDir, 0777)
   343  	if err != nil {
   344  		return nil, fmt.Errorf("failed to create %q: %w: (generators.%s.filename)", parentDir, err, g.name)
   345  	}
   346  	var out io.Writer
   347  	if g.dryRun {
   348  		out = io.Discard
   349  	} else {
   350  		f, err := os.Create(filename)
   351  		if err != nil {
   352  			return nil, fmt.Errorf("failed to create %q: %w: (generators.%s.filename)", filename, err, g.name)
   353  		}
   354  		defer f.Close()
   355  		out = f
   356  	}
   357  	err = g.contents.ExecuteTemplate(out, "contents", scope)
   358  	if err != nil {
   359  		return nil, fmt.Errorf("template failed: %w (generators.%s.filename)", err, g.name)
   360  	}
   361  	return []string{filename}, nil
   362  }
   363  
   364  func (g *Generator) runFiles(scope interface{}) ([]string, error) {
   365  	var filesBuf bytes.Buffer
   366  	err := g.files.ExecuteTemplate(&filesBuf, "files", scope)
   367  	if err != nil {
   368  		return nil, fmt.Errorf("%w: (generators.%s.files)", err, g.name)
   369  	}
   370  	if g.debug {
   371  		log.Printf("interpolated generators.%s.files => %q", g.name, filesBuf.String())
   372  	}
   373  	files := map[string]string{}
   374  	err = yaml.Unmarshal(filesBuf.Bytes(), &files)
   375  	if err != nil {
   376  		// TODO: dump output for debugging?
   377  		return nil, fmt.Errorf("failed to load output as yaml: %w: (generators.%s.files)", err, g.name)
   378  	}
   379  	generatedFiles := []string{}
   380  	for filename, contents := range files {
   381  		generatedFiles = append(generatedFiles, filename)
   382  		dir := filepath.Dir(filename)
   383  		err := os.MkdirAll(dir, 0777)
   384  		if err != nil {
   385  			return nil, fmt.Errorf("failed to create directory %q: %w (generators.%s.files)", dir, err, g.name)
   386  		}
   387  		if _, err := os.Stat(filename); err == nil && !g.force {
   388  			log.Printf("not overwriting existing file %q", filename)
   389  			continue
   390  		}
   391  		if g.dryRun {
   392  			continue
   393  		}
   394  		err = os.WriteFile(filename, []byte(contents), 0777)
   395  		if err != nil {
   396  			return nil, fmt.Errorf("failed to write file %q: %w (generators.%s.files)", filename, err, g.name)
   397  		}
   398  	}
   399  	return generatedFiles, nil
   400  }