mirror of
https://github.com/akvorado/akvorado.git
synced 2025-12-11 22:14:02 +01:00
Some checks failed
CI / 🤖 Check dependabot status (push) Has been cancelled
CI / 🐧 Test on Linux (${{ github.ref_type == 'tag' }}, misc) (push) Has been cancelled
CI / 🐧 Test on Linux (coverage) (push) Has been cancelled
CI / 🐧 Test on Linux (regular) (push) Has been cancelled
CI / ❄️ Build on Nix (push) Has been cancelled
CI / 🍏 Build and test on macOS (push) Has been cancelled
CI / 🧪 End-to-end testing (push) Has been cancelled
CI / 🔍 Upload code coverage (push) Has been cancelled
CI / 🔬 Test only Go (push) Has been cancelled
CI / 🔬 Test only JS (${{ needs.dependabot.outputs.package-ecosystem }}, 20) (push) Has been cancelled
CI / 🔬 Test only JS (${{ needs.dependabot.outputs.package-ecosystem }}, 22) (push) Has been cancelled
CI / 🔬 Test only JS (${{ needs.dependabot.outputs.package-ecosystem }}, 24) (push) Has been cancelled
CI / ⚖️ Check licenses (push) Has been cancelled
CI / 🐋 Build Docker images (push) Has been cancelled
CI / 🐋 Tag Docker images (push) Has been cancelled
CI / 🚀 Publish release (push) Has been cancelled
Update Nix dependency hashes / Update dependency hashes (push) Has been cancelled
Using `reflect.MethodByName()` with a constant string does not disable DCE. However, using an interface is cleaner either way and we also check the function signature this way!
247 lines
6.9 KiB
Go
247 lines
6.9 KiB
Go
// SPDX-FileCopyrightText: 2022 Free Mobile
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/go-playground/validator/v10"
|
|
"github.com/go-viper/mapstructure/v2"
|
|
|
|
"akvorado/common/helpers/yaml"
|
|
|
|
"akvorado/common/helpers"
|
|
)
|
|
|
|
// ConfigRelatedOptions are command-line options related to handling a
|
|
// configuration file.
|
|
type ConfigRelatedOptions struct {
|
|
Path string
|
|
Dump bool
|
|
BeforeDump func(mapstructure.Metadata)
|
|
}
|
|
|
|
// Parse parses the configuration file (if present) and the environment
|
|
// variables into the provided configuration. It returns the paths to watch if
|
|
// we want to detect configuration changes.
|
|
func (c ConfigRelatedOptions) Parse(out io.Writer, component string, config any) ([]string, error) {
|
|
var rawConfig gin.H
|
|
var paths []string
|
|
if cfgFile := c.Path; cfgFile != "" {
|
|
if strings.HasPrefix(cfgFile, "http://") || strings.HasPrefix(cfgFile, "https://") {
|
|
u, err := url.Parse(cfgFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot parse configuration URL: %w", err)
|
|
}
|
|
if u.Path == "" {
|
|
u.Path = fmt.Sprintf("/api/v0/orchestrator/configuration/%s", component)
|
|
}
|
|
if u.Fragment != "" {
|
|
u.Path = fmt.Sprintf("%s/%s", u.Path, u.Fragment)
|
|
}
|
|
resp, err := http.Get(u.String())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to fetch configuration file: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
contentType := resp.Header.Get("Content-Type")
|
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
|
if (mediaType != "application/x-yaml" && mediaType != "application/yaml") || err != nil {
|
|
return nil, fmt.Errorf("received configuration file is not YAML (%s)", contentType)
|
|
}
|
|
input, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to read configuration file: %w", err)
|
|
}
|
|
if err := yaml.Unmarshal(input, &rawConfig); err != nil {
|
|
return nil, fmt.Errorf("unable to parse YAML configuration file: %w", err)
|
|
}
|
|
} else {
|
|
cfgFile, err := filepath.EvalSymlinks(cfgFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot follow symlink: %w", err)
|
|
}
|
|
dirname, filename := filepath.Split(cfgFile)
|
|
if dirname == "" {
|
|
dirname = "."
|
|
}
|
|
paths, err = yaml.UnmarshalWithInclude(os.DirFS(dirname), filename, &rawConfig)
|
|
for i := range paths {
|
|
paths[i] = filepath.Clean(filepath.Join(dirname, paths[i]))
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to parse YAML configuration file: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse provided configuration
|
|
defaultHook, disableDefaultHook := DefaultHook()
|
|
zeroSliceHook, disableZeroSliceHook := ZeroSliceHook()
|
|
var metadata mapstructure.Metadata
|
|
decoderConfig := helpers.GetMapStructureDecoderConfig(&config, defaultHook, zeroSliceHook)
|
|
decoderConfig.ErrorUnused = false
|
|
decoderConfig.Metadata = &metadata
|
|
decoder, err := mapstructure.NewDecoder(decoderConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to create configuration decoder: %w", err)
|
|
}
|
|
if err := decoder.Decode(rawConfig); err != nil {
|
|
return nil, fmt.Errorf("unable to parse configuration: %w", err)
|
|
}
|
|
disableDefaultHook()
|
|
disableZeroSliceHook()
|
|
|
|
// Override with environment variables
|
|
for _, keyval := range os.Environ() {
|
|
kv := strings.SplitN(keyval, "=", 2)
|
|
if len(kv) != 2 {
|
|
continue
|
|
}
|
|
kk := strings.Split(kv[0], "_")
|
|
if len(kk) < 4 || kk[0] != "AKVORADO" || kk[1] != "CFG" || kk[2] != strings.ReplaceAll(strings.ToUpper(component), "-", "") {
|
|
continue
|
|
}
|
|
// From AKVORADO_CFG_CMP_SQUID_PURPLE_QUIRK=47, we
|
|
// build a map "squid -> purple -> quirk ->
|
|
// 47". From AKVORADO_CFG_CMP_SQUID_3_PURPLE=47, we
|
|
// build "squid[3] -> purple -> 47"
|
|
var rawConfig any
|
|
rawConfig = kv[1]
|
|
for i := len(kk) - 1; i > 2; i-- {
|
|
if index, err := strconv.Atoi(kk[i]); err == nil {
|
|
newRawConfig := make([]any, index+1)
|
|
newRawConfig[index] = rawConfig
|
|
rawConfig = newRawConfig
|
|
} else {
|
|
rawConfig = gin.H{
|
|
kk[i]: rawConfig,
|
|
}
|
|
}
|
|
}
|
|
if err := decoder.Decode(rawConfig); err != nil {
|
|
return nil, fmt.Errorf("unable to parse override %q: %w", kv[0], err)
|
|
}
|
|
}
|
|
|
|
// Check for unused keys
|
|
invalidKeys := []string{}
|
|
for _, key := range metadata.Unused {
|
|
if !strings.HasPrefix(key, ".") && !strings.Contains(key, "..") {
|
|
invalidKeys = append(invalidKeys, fmt.Sprintf("invalid key %q", key))
|
|
}
|
|
}
|
|
sort.Strings(invalidKeys)
|
|
if len(invalidKeys) > 0 {
|
|
return nil, fmt.Errorf("invalid configuration:\n%s", strings.Join(invalidKeys, "\n"))
|
|
}
|
|
|
|
// Validate and dump configuration if requested
|
|
if c.BeforeDump != nil {
|
|
c.BeforeDump(metadata)
|
|
}
|
|
if err := helpers.Validate.Struct(config); err != nil {
|
|
switch verr := err.(type) {
|
|
case validator.ValidationErrors:
|
|
return nil, fmt.Errorf("invalid configuration:\n%w", verr)
|
|
default:
|
|
return nil, fmt.Errorf("unexpected internal error: %w", verr)
|
|
}
|
|
}
|
|
if c.Dump {
|
|
output, err := yaml.Marshal(config)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to dump configuration: %w", err)
|
|
}
|
|
out.Write([]byte("---\n"))
|
|
out.Write(output)
|
|
out.Write([]byte("\n"))
|
|
}
|
|
|
|
return paths, nil
|
|
}
|
|
|
|
// resettable is an interface for configuration types that can reset themselves
|
|
// to default values.
|
|
type resettable interface {
|
|
Reset()
|
|
}
|
|
|
|
// DefaultHook will reset the destination value to its default using
|
|
// the Reset() method if present.
|
|
func DefaultHook() (mapstructure.DecodeHookFunc, func()) {
|
|
disabled := false
|
|
callReset := func(v reflect.Value) bool {
|
|
if r, ok := v.Interface().(resettable); ok {
|
|
r.Reset()
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
hook := func(from, to reflect.Value) (any, error) {
|
|
if disabled {
|
|
return from.Interface(), nil
|
|
}
|
|
|
|
// For pointers, handle both nil and non-nil cases
|
|
if to.Kind() == reflect.Ptr {
|
|
if to.IsNil() {
|
|
// Try creating new instance and reset it
|
|
newV := reflect.New(to.Type().Elem())
|
|
if callReset(newV) {
|
|
to.Set(newV)
|
|
}
|
|
} else if !callReset(to) {
|
|
// Not resettable directly, try dereferencing (pointer to pointer case)
|
|
callReset(to.Elem())
|
|
}
|
|
} else {
|
|
// Not a pointer, try with its address
|
|
callReset(to.Addr())
|
|
}
|
|
|
|
return from.Interface(), nil
|
|
}
|
|
disable := func() {
|
|
disabled = true
|
|
}
|
|
return hook, disable
|
|
}
|
|
|
|
// ZeroSliceHook clear a list got as a default value if we get a
|
|
// non-nil slice. Like DefaultHook, it can be disabled.
|
|
func ZeroSliceHook() (mapstructure.DecodeHookFunc, func()) {
|
|
disabled := false
|
|
hook := func(from, to reflect.Value) (any, error) {
|
|
if disabled {
|
|
return from.Interface(), nil
|
|
}
|
|
// Do we try to map a slice to a slice? If yes, the source slice should not be nil.
|
|
if from.Kind() != reflect.Slice || to.Kind() != reflect.Slice || from.IsNil() || to.IsNil() {
|
|
return from.Interface(), nil
|
|
}
|
|
|
|
// Zero out the destination slice.
|
|
to.SetLen(0)
|
|
|
|
// Resume decoding
|
|
return from.Interface(), nil
|
|
}
|
|
disable := func() {
|
|
disabled = true
|
|
}
|
|
return hook, disable
|
|
}
|