1package config 2 3import ( 4 "bytes" 5 "context" 6 "fmt" 7 "io" 8 "net/http" 9 "net/url" 10 "os" 11 "path" 12 "path/filepath" 13 "strconv" 14 "strings" 15 "time" 16 17 "github.com/hairyhenderson/gomplate/v3/internal/iohelpers" 18 "github.com/pkg/errors" 19 "gopkg.in/yaml.v3" 20) 21 22// Parse a config file 23func Parse(in io.Reader) (*Config, error) { 24 out := &Config{} 25 dec := yaml.NewDecoder(in) 26 err := dec.Decode(out) 27 if err != nil && err != io.EOF { 28 return out, err 29 } 30 return out, nil 31} 32 33// Config - configures the gomplate execution 34type Config struct { 35 Input string `yaml:"in,omitempty"` 36 InputFiles []string `yaml:"inputFiles,omitempty,flow"` 37 InputDir string `yaml:"inputDir,omitempty"` 38 ExcludeGlob []string `yaml:"excludes,omitempty"` 39 OutputFiles []string `yaml:"outputFiles,omitempty,flow"` 40 OutputDir string `yaml:"outputDir,omitempty"` 41 OutputMap string `yaml:"outputMap,omitempty"` 42 43 Experimental bool `yaml:"experimental,omitempty"` 44 45 SuppressEmpty bool `yaml:"suppressEmpty,omitempty"` 46 ExecPipe bool `yaml:"execPipe,omitempty"` 47 PostExec []string `yaml:"postExec,omitempty,flow"` 48 49 OutMode string `yaml:"chmod,omitempty"` 50 LDelim string `yaml:"leftDelim,omitempty"` 51 RDelim string `yaml:"rightDelim,omitempty"` 52 DataSources map[string]DataSource `yaml:"datasources,omitempty"` 53 Context map[string]DataSource `yaml:"context,omitempty"` 54 Plugins map[string]string `yaml:"plugins,omitempty"` 55 PluginTimeout time.Duration `yaml:"pluginTimeout,omitempty"` 56 Templates []string `yaml:"templates,omitempty"` 57 58 // Extra HTTP headers not attached to pre-defined datsources. Potentially 59 // used by datasources defined in the template. 60 ExtraHeaders map[string]http.Header `yaml:"-"` 61 62 // internal use only, can't be injected in YAML 63 PostExecInput io.Reader `yaml:"-"` 64 65 Stdin io.Reader `yaml:"-"` 66 Stdout io.Writer `yaml:"-"` 67 Stderr io.Writer `yaml:"-"` 68} 69 70var cfgContextKey = struct{}{} 71 72// ContextWithConfig returns a new context with a reference to the config. 73func ContextWithConfig(ctx context.Context, cfg *Config) context.Context { 74 return context.WithValue(ctx, cfgContextKey, cfg) 75} 76 77// FromContext returns a config from the given context, if any. If no 78// config is present a new default configuration will be returned. 79func FromContext(ctx context.Context) (cfg *Config) { 80 ok := ctx != nil 81 if ok { 82 cfg, ok = ctx.Value(cfgContextKey).(*Config) 83 } 84 if !ok { 85 cfg = &Config{} 86 cfg.ApplyDefaults() 87 } 88 return cfg 89} 90 91// mergeDataSources - use d as defaults, and override with values from o 92func mergeDataSources(d, o map[string]DataSource) map[string]DataSource { 93 for k, v := range o { 94 c, ok := d[k] 95 if ok { 96 d[k] = c.mergeFrom(v) 97 } else { 98 d[k] = v 99 } 100 } 101 return d 102} 103 104// DataSource - datasource configuration 105type DataSource struct { 106 URL *url.URL `yaml:"-"` 107 Header http.Header `yaml:"header,omitempty,flow"` 108} 109 110// UnmarshalYAML - satisfy the yaml.Umarshaler interface - URLs aren't 111// well supported, and anyway we need to do some extra parsing 112func (d *DataSource) UnmarshalYAML(value *yaml.Node) error { 113 type raw struct { 114 URL string 115 Header http.Header 116 } 117 r := raw{} 118 err := value.Decode(&r) 119 if err != nil { 120 return err 121 } 122 u, err := ParseSourceURL(r.URL) 123 if err != nil { 124 return fmt.Errorf("could not parse datasource URL %q: %w", r.URL, err) 125 } 126 *d = DataSource{ 127 URL: u, 128 Header: r.Header, 129 } 130 return nil 131} 132 133// MarshalYAML - satisfy the yaml.Marshaler interface - URLs aren't 134// well supported, and anyway we need to do some extra parsing 135func (d DataSource) MarshalYAML() (interface{}, error) { 136 type raw struct { 137 URL string 138 Header http.Header 139 } 140 r := raw{ 141 URL: d.URL.String(), 142 Header: d.Header, 143 } 144 return r, nil 145} 146 147// mergeFrom - use this as default, and override with values from o 148func (d DataSource) mergeFrom(o DataSource) DataSource { 149 if o.URL != nil { 150 d.URL = o.URL 151 } 152 if d.Header == nil { 153 d.Header = o.Header 154 } else { 155 for k, v := range o.Header { 156 d.Header[k] = v 157 } 158 } 159 return d 160} 161 162// MergeFrom - use this Config as the defaults, and override it with any 163// non-zero values from the other Config 164// 165// Note that Input/InputDir/InputFiles will override each other, as well as 166// OutputDir/OutputFiles. 167func (c *Config) MergeFrom(o *Config) *Config { 168 switch { 169 case !isZero(o.Input): 170 c.Input = o.Input 171 c.InputDir = "" 172 c.InputFiles = nil 173 c.OutputDir = "" 174 case !isZero(o.InputDir): 175 c.Input = "" 176 c.InputDir = o.InputDir 177 c.InputFiles = nil 178 case !isZero(o.InputFiles): 179 if !(len(o.InputFiles) == 1 && o.InputFiles[0] == "-") { 180 c.Input = "" 181 c.InputFiles = o.InputFiles 182 c.InputDir = "" 183 c.OutputDir = "" 184 } 185 } 186 187 if !isZero(o.OutputMap) { 188 c.OutputDir = "" 189 c.OutputFiles = nil 190 c.OutputMap = o.OutputMap 191 } 192 if !isZero(o.OutputDir) { 193 c.OutputDir = o.OutputDir 194 c.OutputFiles = nil 195 c.OutputMap = "" 196 } 197 if !isZero(o.OutputFiles) { 198 c.OutputDir = "" 199 c.OutputFiles = o.OutputFiles 200 c.OutputMap = "" 201 } 202 if !isZero(o.ExecPipe) { 203 c.ExecPipe = o.ExecPipe 204 c.PostExec = o.PostExec 205 c.OutputFiles = o.OutputFiles 206 } 207 if !isZero(o.ExcludeGlob) { 208 c.ExcludeGlob = o.ExcludeGlob 209 } 210 if !isZero(o.OutMode) { 211 c.OutMode = o.OutMode 212 } 213 if !isZero(o.LDelim) { 214 c.LDelim = o.LDelim 215 } 216 if !isZero(o.RDelim) { 217 c.RDelim = o.RDelim 218 } 219 if !isZero(o.Templates) { 220 c.Templates = o.Templates 221 } 222 mergeDataSources(c.DataSources, o.DataSources) 223 mergeDataSources(c.Context, o.Context) 224 if len(o.Plugins) > 0 { 225 for k, v := range o.Plugins { 226 c.Plugins[k] = v 227 } 228 } 229 230 return c 231} 232 233// ParseDataSourceFlags - sets the DataSources and Context fields from the 234// key=value format flags as provided at the command-line 235func (c *Config) ParseDataSourceFlags(datasources, contexts, headers []string) error { 236 for _, d := range datasources { 237 k, ds, err := parseDatasourceArg(d) 238 if err != nil { 239 return err 240 } 241 if c.DataSources == nil { 242 c.DataSources = map[string]DataSource{} 243 } 244 c.DataSources[k] = ds 245 } 246 for _, d := range contexts { 247 k, ds, err := parseDatasourceArg(d) 248 if err != nil { 249 return err 250 } 251 if c.Context == nil { 252 c.Context = map[string]DataSource{} 253 } 254 c.Context[k] = ds 255 } 256 257 hdrs, err := parseHeaderArgs(headers) 258 if err != nil { 259 return err 260 } 261 262 for k, v := range hdrs { 263 if d, ok := c.Context[k]; ok { 264 d.Header = v 265 c.Context[k] = d 266 delete(hdrs, k) 267 } 268 if d, ok := c.DataSources[k]; ok { 269 d.Header = v 270 c.DataSources[k] = d 271 delete(hdrs, k) 272 } 273 } 274 if len(hdrs) > 0 { 275 c.ExtraHeaders = hdrs 276 } 277 return nil 278} 279 280// ParsePluginFlags - sets the Plugins field from the 281// key=value format flags as provided at the command-line 282func (c *Config) ParsePluginFlags(plugins []string) error { 283 for _, plugin := range plugins { 284 parts := strings.SplitN(plugin, "=", 2) 285 if len(parts) < 2 { 286 return fmt.Errorf("plugin requires both name and path") 287 } 288 if c.Plugins == nil { 289 c.Plugins = map[string]string{} 290 } 291 c.Plugins[parts[0]] = parts[1] 292 } 293 return nil 294} 295 296func parseDatasourceArg(value string) (key string, ds DataSource, err error) { 297 parts := strings.SplitN(value, "=", 2) 298 if len(parts) == 1 { 299 f := parts[0] 300 key = strings.SplitN(value, ".", 2)[0] 301 if path.Base(f) != f { 302 err = fmt.Errorf("invalid datasource (%s): must provide an alias with files not in working directory", value) 303 return key, ds, err 304 } 305 ds.URL, err = ParseSourceURL(f) 306 } else if len(parts) == 2 { 307 key = parts[0] 308 ds.URL, err = ParseSourceURL(parts[1]) 309 } 310 return key, ds, err 311} 312 313func parseHeaderArgs(headerArgs []string) (map[string]http.Header, error) { 314 headers := make(map[string]http.Header) 315 for _, v := range headerArgs { 316 ds, name, value, err := splitHeaderArg(v) 317 if err != nil { 318 return nil, err 319 } 320 if _, ok := headers[ds]; !ok { 321 headers[ds] = make(http.Header) 322 } 323 headers[ds][name] = append(headers[ds][name], strings.TrimSpace(value)) 324 } 325 return headers, nil 326} 327 328func splitHeaderArg(arg string) (datasourceAlias, name, value string, err error) { 329 parts := strings.SplitN(arg, "=", 2) 330 if len(parts) != 2 { 331 err = fmt.Errorf("invalid datasource-header option '%s'", arg) 332 return "", "", "", err 333 } 334 datasourceAlias = parts[0] 335 name, value, err = splitHeader(parts[1]) 336 return datasourceAlias, name, value, err 337} 338 339func splitHeader(header string) (name, value string, err error) { 340 parts := strings.SplitN(header, ":", 2) 341 if len(parts) != 2 { 342 err = fmt.Errorf("invalid HTTP Header format '%s'", header) 343 return "", "", err 344 } 345 name = http.CanonicalHeaderKey(parts[0]) 346 value = parts[1] 347 return name, value, nil 348} 349 350// Validate the Config 351func (c Config) Validate() (err error) { 352 err = notTogether( 353 []string{"in", "inputFiles", "inputDir"}, 354 c.Input, c.InputFiles, c.InputDir) 355 if err == nil { 356 err = notTogether( 357 []string{"outputFiles", "outputDir", "outputMap"}, 358 c.OutputFiles, c.OutputDir, c.OutputMap) 359 } 360 if err == nil { 361 err = notTogether( 362 []string{"outputDir", "outputMap", "execPipe"}, 363 c.OutputDir, c.OutputMap, c.ExecPipe) 364 } 365 366 if err == nil { 367 err = mustTogether("outputDir", "inputDir", 368 c.OutputDir, c.InputDir) 369 } 370 371 if err == nil { 372 err = mustTogether("outputMap", "inputDir", 373 c.OutputMap, c.InputDir) 374 } 375 376 if err == nil { 377 f := len(c.InputFiles) 378 if f == 0 && c.Input != "" { 379 f = 1 380 } 381 o := len(c.OutputFiles) 382 if f != o && !c.ExecPipe { 383 err = fmt.Errorf("must provide same number of 'outputFiles' (%d) as 'in' or 'inputFiles' (%d) options", o, f) 384 } 385 } 386 387 if err == nil { 388 if c.ExecPipe && len(c.PostExec) == 0 { 389 err = fmt.Errorf("execPipe may only be used with a postExec command") 390 } 391 } 392 393 if err == nil { 394 if c.ExecPipe && (len(c.OutputFiles) > 0 && c.OutputFiles[0] != "-") { 395 err = fmt.Errorf("must not set 'outputFiles' when using 'execPipe'") 396 } 397 } 398 399 return err 400} 401 402func notTogether(names []string, values ...interface{}) error { 403 found := "" 404 for i, value := range values { 405 if isZero(value) { 406 continue 407 } 408 if found != "" { 409 return fmt.Errorf("only one of these options is supported at a time: '%s', '%s'", 410 found, names[i]) 411 } 412 found = names[i] 413 } 414 return nil 415} 416 417func mustTogether(left, right string, lValue, rValue interface{}) error { 418 if !isZero(lValue) && isZero(rValue) { 419 return fmt.Errorf("these options must be set together: '%s', '%s'", 420 left, right) 421 } 422 423 return nil 424} 425 426func isZero(value interface{}) bool { 427 switch v := value.(type) { 428 case string: 429 return v == "" 430 case []string: 431 return len(v) == 0 432 case bool: 433 return !v 434 default: 435 return false 436 } 437} 438 439// ApplyDefaults - any defaults changed here should be added to cmd.InitFlags as 440// well for proper help/usage display. 441func (c *Config) ApplyDefaults() { 442 if c.InputDir != "" && c.OutputDir == "" && c.OutputMap == "" { 443 c.OutputDir = "." 444 } 445 if c.Input == "" && c.InputDir == "" && len(c.InputFiles) == 0 { 446 c.InputFiles = []string{"-"} 447 } 448 if c.OutputDir == "" && c.OutputMap == "" && len(c.OutputFiles) == 0 && !c.ExecPipe { 449 c.OutputFiles = []string{"-"} 450 } 451 if c.LDelim == "" { 452 c.LDelim = "{{" 453 } 454 if c.RDelim == "" { 455 c.RDelim = "}}" 456 } 457 458 if c.ExecPipe { 459 pipe := &bytes.Buffer{} 460 c.PostExecInput = pipe 461 c.OutputFiles = []string{"-"} 462 463 // --exec-pipe redirects standard out to the out pipe 464 c.Stdout = &iohelpers.NopCloser{Writer: pipe} 465 } else { 466 c.PostExecInput = c.Stdin 467 } 468 469 if c.PluginTimeout == 0 { 470 c.PluginTimeout = 5 * time.Second 471 } 472} 473 474// GetMode - parse an os.FileMode out of the string, and let us know if it's an override or not... 475func (c *Config) GetMode() (os.FileMode, bool, error) { 476 modeOverride := c.OutMode != "" 477 m, err := strconv.ParseUint("0"+c.OutMode, 8, 32) 478 if err != nil { 479 return 0, false, err 480 } 481 mode := NormalizeFileMode(os.FileMode(m)) 482 if mode == 0 && c.Input != "" { 483 mode = NormalizeFileMode(0644) 484 } 485 return mode, modeOverride, nil 486} 487 488// String - 489func (c *Config) String() string { 490 out := &strings.Builder{} 491 out.WriteString("---\n") 492 enc := yaml.NewEncoder(out) 493 enc.SetIndent(2) 494 495 // dereferenced copy so we can truncate input for display 496 c2 := *c 497 if len(c2.Input) >= 11 { 498 c2.Input = c2.Input[0:8] + "..." 499 } 500 501 err := enc.Encode(c2) 502 if err != nil { 503 return err.Error() 504 } 505 return out.String() 506} 507 508// ParseSourceURL parses a datasource URL value, which may be '-' (for stdin://), 509// or it may be a Windows path (with driver letter and back-slack separators) or 510// UNC, or it may be relative. It also might just be a regular absolute URL... 511// In all cases it returns a correct URL for the value. 512func ParseSourceURL(value string) (*url.URL, error) { 513 if value == "-" { 514 value = "stdin://" 515 } 516 value = filepath.ToSlash(value) 517 // handle absolute Windows paths 518 volName := "" 519 if volName = filepath.VolumeName(value); volName != "" { 520 // handle UNCs 521 if len(volName) > 2 { 522 value = "file:" + value 523 } else { 524 value = "file:///" + value 525 } 526 } 527 srcURL, err := url.Parse(value) 528 if err != nil { 529 return nil, err 530 } 531 532 if volName != "" && len(srcURL.Path) >= 3 { 533 if srcURL.Path[0] == '/' && srcURL.Path[2] == ':' { 534 srcURL.Path = srcURL.Path[1:] 535 } 536 } 537 538 if !srcURL.IsAbs() { 539 srcURL, err = absFileURL(value) 540 if err != nil { 541 return nil, err 542 } 543 } 544 return srcURL, nil 545} 546 547func absFileURL(value string) (*url.URL, error) { 548 wd, err := os.Getwd() 549 if err != nil { 550 return nil, errors.Wrapf(err, "can't get working directory") 551 } 552 wd = filepath.ToSlash(wd) 553 baseURL := &url.URL{ 554 Scheme: "file", 555 Path: wd + "/", 556 } 557 relURL, err := url.Parse(value) 558 if err != nil { 559 return nil, fmt.Errorf("can't parse value %s as URL: %w", value, err) 560 } 561 resolved := baseURL.ResolveReference(relURL) 562 // deal with Windows drive letters 563 if !strings.HasPrefix(wd, "/") && resolved.Path[2] == ':' { 564 resolved.Path = resolved.Path[1:] 565 } 566 return resolved, nil 567} 568