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 }