pkg/config/config.go (249 lines of code) (raw):

package config import ( "bytes" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "reflect" "text/template" "github.com/santhosh-tekuri/jsonschema/v6" "gopkg.in/yaml.v3" ) // DefaultConfigReplacements has no replacements configured func DefaultConfigReplacements() *ConfigReplacements { return NewConfigReplacements("", "", "") } // NewConfigReplacements returns a new ConfigurationReplacements instance with given values func NewConfigReplacements(regionReplacement, regionShortReplacement, stampReplacement string) *ConfigReplacements { return &ConfigReplacements{ RegionReplacement: regionReplacement, RegionShortReplacement: regionShortReplacement, StampReplacement: stampReplacement, } } // ConfigReplacements holds replacement values type ConfigReplacements struct { RegionReplacement string RegionShortReplacement string StampReplacement string } // AsMap returns a map[string]interface{} representation of this ConfigReplacement instance func (c *ConfigReplacements) AsMap() map[string]interface{} { return map[string]interface{}{ "ctx": map[string]interface{}{ "region": c.RegionReplacement, "regionShort": c.RegionShortReplacement, "stamp": c.StampReplacement, }, } } // ConfigProvider resolves service configuration for specific environments and clouds using a base configuration file. type ConfigProvider interface { Validate(cloud, deployEnv string) error GetDeployEnvRegionConfiguration(cloud, deployEnv, region string, configReplacements *ConfigReplacements) (Configuration, error) GetDeployEnvConfiguration(cloud, deployEnv string, configReplacements *ConfigReplacements) (Configuration, error) GetRegions(cloud, deployEnv string) ([]string, error) GetRegionOverrides(cloud, deployEnv, region string, configReplacements *ConfigReplacements) (Configuration, error) } func NewConfigProvider(config string) ConfigProvider { return &configProviderImpl{ config: config, } } // InterfaceToConfiguration, pass in an interface of map[string]any and get (Configuration, true) back // This is also converting nested maps, making it easier to iterate over the configuration. // If type does not match, second return value will be false func InterfaceToConfiguration(i interface{}) (Configuration, bool) { // Helper, that reduces need for reflection calls, i.e. MapIndex // from: https://github.com/peterbourgon/mergemap/blob/master/mergemap.go value := reflect.ValueOf(i) if value.Kind() == reflect.Map { m := Configuration{} for _, k := range value.MapKeys() { v := value.MapIndex(k).Interface() if nestedMap, ok := InterfaceToConfiguration(v); ok { m[k.String()] = nestedMap } else { m[k.String()] = v } } return m, true } return Configuration{}, false } // Merges Configuration, returns merged Configuration // However the return value is only used for recursive updates on the map // The actual merged Configuration are updated in the base map func MergeConfiguration(base, override Configuration) Configuration { for k, newValue := range override { if baseValue, exists := base[k]; exists { srcMap, srcMapOk := InterfaceToConfiguration(newValue) dstMap, dstMapOk := InterfaceToConfiguration(baseValue) if srcMapOk && dstMapOk { newValue = MergeConfiguration(dstMap, srcMap) } } base[k] = newValue } return base } // Needed to convert Configuration to map[string]interface{} for jsonschema validation // see: https://github.com/santhosh-tekuri/jsonschema/blob/boon/schema.go#L124 func convertToInterface(config Configuration) map[string]any { m := map[string]any{} for k, v := range config { if subMap, ok := v.(Configuration); ok { m[k] = convertToInterface(subMap) } else { m[k] = v } } return m } func isUrl(str string) bool { u, err := url.Parse(str) return err == nil && u.Scheme != "" && u.Host != "" } func (cp *configProviderImpl) loadSchema() (any, error) { schemaPath := cp.schema var reader io.Reader var err error if isUrl(schemaPath) { resp, err := http.Get(schemaPath) if err != nil { return nil, fmt.Errorf("failed to get schema file: %v", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("faild to get schema file, statuscode %v", resp.StatusCode) } reader = resp.Body } else { if !filepath.IsAbs(schemaPath) { schemaPath = filepath.Join(filepath.Dir(cp.config), schemaPath) } reader, err = os.Open(schemaPath) if err != nil { return nil, fmt.Errorf("failed to open schema file: %v", err) } } schema, err := jsonschema.UnmarshalJSON(reader) if err != nil { return nil, fmt.Errorf("failed to unmarshal schema: %v", err) } return schema, nil } func (cp *configProviderImpl) validateSchema(config Configuration) error { c := jsonschema.NewCompiler() schema, err := cp.loadSchema() if err != nil { return fmt.Errorf("failed to load schema: %v", err) } err = c.AddResource(cp.schema, schema) if err != nil { return fmt.Errorf("failed to add schema resource: %v", err) } sch, err := c.Compile(cp.schema) if err != nil { return fmt.Errorf("failed to compile schema: %v", err) } err = sch.Validate(convertToInterface(config)) if err != nil { return fmt.Errorf("failed to validate schema: %v", err) } return nil } // GetDeployEnvRegionConfiguration reads, processes, validates and returns the configuration // It uses GetDeployEnvConfiguration and will in addition merge region overrides func (cp *configProviderImpl) GetDeployEnvRegionConfiguration(cloud, deployEnv, region string, configReplacements *ConfigReplacements) (Configuration, error) { config, err := cp.GetDeployEnvConfiguration(cloud, deployEnv, configReplacements) if err != nil { return nil, err } // region overrides regionOverrides, err := cp.GetRegionOverrides(cloud, deployEnv, region, configReplacements) if err != nil { return nil, err } MergeConfiguration(config, regionOverrides) // validate schema err = cp.validateSchema(config) if err != nil { return nil, err } return config, nil } // Validate basic validation func (cp *configProviderImpl) Validate(cloud, deployEnv string) error { config, err := cp.loadConfig(DefaultConfigReplacements()) if err != nil { return err } if ok := config.HasCloud(cloud); !ok { return fmt.Errorf("the cloud %s is not found in the config", cloud) } if ok := config.HasDeployEnv(cloud, deployEnv); !ok { return fmt.Errorf("the deployment env %s is not found under cloud %s", deployEnv, cloud) } if !config.HasSchema() { return fmt.Errorf("$schema not found in config") } return nil } // GetDeployEnvConfiguration load and merge the configuration // todo: this is called in HCP, so it should use schema validation as well. func (cp *configProviderImpl) GetDeployEnvConfiguration(cloud, deployEnv string, configReplacements *ConfigReplacements) (Configuration, error) { config, err := cp.loadConfig(configReplacements) if err != nil { return nil, err } err = cp.Validate(cloud, deployEnv) if err != nil { return nil, err } mergedConfig := Configuration{} MergeConfiguration(mergedConfig, config.GetDefaults()) MergeConfiguration(mergedConfig, config.GetCloudOverrides(cloud)) MergeConfiguration(mergedConfig, config.GetDeployEnvOverrides(cloud, deployEnv)) cp.schema = config.GetSchema() return mergedConfig, nil } // GetRegions returns the list of configured regions func (cp *configProviderImpl) GetRegions(cloud, deployEnv string) ([]string, error) { config, err := cp.loadConfig(DefaultConfigReplacements()) if err != nil { return nil, err } err = cp.Validate(cloud, deployEnv) if err != nil { return nil, err } regions := config.GetRegions(cloud, deployEnv) return regions, nil } // GetRegionOverrides retun a configuration where region overrides have been applied func (cp *configProviderImpl) GetRegionOverrides(cloud, deployEnv, region string, configReplacements *ConfigReplacements) (Configuration, error) { config, err := cp.loadConfig(configReplacements) if err != nil { return nil, err } return config.GetRegionOverrides(cloud, deployEnv, region), nil } func (cp *configProviderImpl) loadConfig(configReplacements *ConfigReplacements) (ConfigurationOverrides, error) { // TODO validate that field names are unique regardless of casing // parse, execute and unmarshal the config file as a template to generate the final config file rawContent, err := PreprocessFile(cp.config, configReplacements.AsMap()) if err != nil { return nil, err } currentVariableOverrides := NewConfigurationOverrides() if err := yaml.Unmarshal(rawContent, currentVariableOverrides); err == nil { return currentVariableOverrides, nil } else { return nil, err } } // PreprocessFile reads and processes a gotemplate // The path will be read as is. It parses the file as a template, and executes it with the provided Configuration. func PreprocessFile(templateFilePath string, vars map[string]any) ([]byte, error) { content, err := os.ReadFile(templateFilePath) if err != nil { return nil, fmt.Errorf("failed to read file %s: %w", templateFilePath, err) } processedContent, err := PreprocessContent(content, vars) if err != nil { return nil, fmt.Errorf("failed to preprocess file %s: %w", templateFilePath, err) } return processedContent, nil } // PreprocessContent processes a gotemplate from memory func PreprocessContent(content []byte, vars map[string]any) ([]byte, error) { var tmplBytes bytes.Buffer if err := PreprocessContentIntoWriter(content, vars, &tmplBytes); err != nil { return nil, err } return tmplBytes.Bytes(), nil } func PreprocessContentIntoWriter(content []byte, vars map[string]any, writer io.Writer) error { tmpl, err := template.New("file").Parse(string(content)) if err != nil { return fmt.Errorf("failed to parse template: %w", err) } if err := tmpl.Option("missingkey=error").Execute(writer, vars); err != nil { return fmt.Errorf("failed to execute template: %w", err) } return nil }