@@ -4,6 +4,8 @@ import (
44"fmt"
55"log/syslog"
66"os"
7+ pathpkg "path"
8+"path/filepath"
79"time"
810911"github.com/pkg/errors"
@@ -22,8 +24,9 @@ const (
2224)
23252426type Config struct {
25-Jobs []JobEnum `yaml:"jobs,optional"`
26-Global *Global `yaml:"global,optional,fromdefaults"`
27+Jobs []JobEnum `yaml:"jobs,optional"`
28+Global *Global `yaml:"global,optional,fromdefaults"`
29+Include []string `yaml:"include,optional"`
2730}
28312932func (c *Config) Job(name string) (*JobEnum, error) {
@@ -655,8 +658,9 @@ var ConfigFileDefaultLocations = []string{
655658"/usr/local/etc/zrepl/zrepl.yml",
656659}
657660658-func ParseConfig(path string) (i *Config, err error) {
661+func ParseConfig(path string) (rootConfig *Config, err error) {
659662663+// Parse main configuration file
660664if path == "" {
661665// Try default locations
662666for _, l := range ConfigFileDefaultLocations {
@@ -679,7 +683,67 @@ func ParseConfig(path string) (i *Config, err error) {
679683return
680684 }
681685682-return ParseConfigBytes(bytes)
686+rootConfig, err = ParseConfigBytes(bytes)
687+if err != nil {
688+return nil, err
689+ }
690+691+err = expandConfigInclude(path, rootConfig)
692+if err != nil {
693+return nil, err
694+ }
695+696+return rootConfig, err
697+}
698+699+func expandConfigInclude(configPath string, config *Config) (err error) {
700+var includeConfigPaths []string
701+for _, path := range config.Include {
702+if !pathpkg.IsAbs(configPath) {
703+path = pathpkg.Join(pathpkg.Dir(configPath), path)
704+ }
705+706+stat, statErr := os.Stat(path)
707+if statErr != nil {
708+return errors.Wrapf(statErr, "stat path %q", path)
709+ }
710+711+if stat.Mode().IsDir() {
712+directoryPaths, err := filepath.Glob(path + "/*.yml")
713+if err != nil {
714+return err
715+ }
716+717+includeConfigPaths = append(includeConfigPaths, directoryPaths...)
718+ } else if stat.Mode().IsRegular() {
719+if extention := filepath.Ext(path); extention != ".yml" {
720+return fmt.Errorf("include config files must end with `.yml`: %s", path)
721+ }
722+includeConfigPaths = append(includeConfigPaths, path)
723+ } else {
724+return fmt.Errorf("not a file or directory: %s", path)
725+ }
726+ }
727+728+for _, path := range includeConfigPaths {
729+var bytes []byte
730+if bytes, err = os.ReadFile(path); err != nil {
731+return errors.Wrapf(err, "read file: %q", path)
732+ }
733+734+includedConfig, err := ParseConfigBytes(bytes)
735+if err != nil {
736+return err
737+ }
738+739+if len(includedConfig.Include) > 0 {
740+return errors.Errorf("included configuration files must not include other files: %s", path)
741+ }
742+743+config.Jobs = append(config.Jobs, includedConfig.Jobs...)
744+ }
745+746+return nil
683747}
684748685749func ParseConfigBytes(bytes []byte) (*Config, error) {