mirror of
https://github.com/kairos-io/kairos-agent.git
synced 2025-09-17 23:37:44 +00:00
* Introduce config/collector package to split the collection of config sources out of the config package. Each consumer of the new package will take care of unmarshalling the yaml to a specific Config struct, do validations etc. * Add tests and remove garbage * Follow all config_url chains and test it * Add missing options file and refactor cmdline code * Consolidate the way we merge configs no matter where they come from * Allow and use only files with valid headers Config is specific to Kairos while Collector is generic. This will allow us to do validations which are just related to Kairos at the config level, while including every type of key and querying of the full yaml at the Collector level splitting the responsibilities of each package. --------- Signed-off-by: Mauro Morales <mauro.morales@spectrocloud.com> Signed-off-by: Dimitris Karakasilis <dimitris@karakasilis.me>
342 lines
7.3 KiB
Go
342 lines
7.3 KiB
Go
// Package configcollector can be used to merge configuration from different
|
|
// sources into one YAML.
|
|
package collector
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/avast/retry-go"
|
|
"github.com/google/shlex"
|
|
"github.com/imdario/mergo"
|
|
"github.com/itchyny/gojq"
|
|
"github.com/kairos-io/kairos-sdk/unstructured"
|
|
"gopkg.in/yaml.v1"
|
|
)
|
|
|
|
const DefaultHeader = "#cloud-config"
|
|
|
|
var ValidFileHeaders = []string{
|
|
"#cloud-config",
|
|
"#kairos-config",
|
|
"#node-config",
|
|
}
|
|
|
|
type Configs []*Config
|
|
|
|
// We don't allow yamls that are plain arrays because is has no use in Kairos
|
|
// and there is no way to merge an array yaml with a "map" yaml.
|
|
type Config map[string]interface{}
|
|
|
|
// MergeConfigURL looks for the "config_url" key and if it's found
|
|
// it downloads the remote config and merges it with the current one.
|
|
// If the remote config also has config_url defined, it is also fetched
|
|
// recursively until a remote config no longer defines a config_url.
|
|
// NOTE: The "config_url" value of the final result is the value of the last
|
|
// config file in the chain because we replace values when we merge.
|
|
func (c *Config) MergeConfigURL() error {
|
|
// If there is no config_url, just return (do nothing)
|
|
configURL := c.ConfigURL()
|
|
if configURL == "" {
|
|
return nil
|
|
}
|
|
|
|
// fetch the remote config
|
|
remoteConfig, err := fetchRemoteConfig(configURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// recursively fetch remote configs
|
|
if err := remoteConfig.MergeConfigURL(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// merge remoteConfig back to "c"
|
|
return c.MergeConfig(remoteConfig)
|
|
}
|
|
|
|
// MergeConfig merges the config passed as parameter back to the receiver Config.
|
|
func (c *Config) MergeConfig(newConfig *Config) error {
|
|
return mergo.Merge(c, newConfig, func(c *mergo.Config) { c.Overwrite = true })
|
|
}
|
|
|
|
// String returns a string which is a Yaml representation of the Config.
|
|
func (c *Config) String() (string, error) {
|
|
data, err := yaml.Marshal(c)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return fmt.Sprintf("%s\n\n%s", DefaultHeader, string(data)), nil
|
|
}
|
|
|
|
func (cs Configs) Merge() (*Config, error) {
|
|
result := &Config{}
|
|
|
|
for _, c := range cs {
|
|
if err := c.MergeConfigURL(); err != nil {
|
|
return result, err
|
|
}
|
|
|
|
if err := result.MergeConfig(c); err != nil {
|
|
return result, err
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func Scan(o *Options) (*Config, error) {
|
|
configs := Configs{}
|
|
|
|
configs = append(configs, parseFiles(o.ScanDir, o.NoLogs)...)
|
|
|
|
if o.MergeBootCMDLine {
|
|
cConfig, err := ParseCmdLine(o.BootCMDLineFile)
|
|
o.SoftErr("parsing cmdline", err)
|
|
if err == nil { // best-effort
|
|
configs = append(configs, cConfig)
|
|
}
|
|
}
|
|
|
|
return configs.Merge()
|
|
}
|
|
|
|
func allFiles(dir []string) []string {
|
|
files := []string{}
|
|
for _, d := range dir {
|
|
if f, err := listFiles(d); err == nil {
|
|
files = append(files, f...)
|
|
}
|
|
}
|
|
return files
|
|
}
|
|
|
|
// parseFiles returns a list of Configs parsed from files.
|
|
func parseFiles(dir []string, nologs bool) Configs {
|
|
result := Configs{}
|
|
files := allFiles(dir)
|
|
for _, f := range files {
|
|
if fileSize(f) > 1.0 {
|
|
if !nologs {
|
|
fmt.Printf("warning: skipping %s. too big (>1MB)\n", f)
|
|
}
|
|
continue
|
|
}
|
|
if strings.Contains(f, "userdata") || filepath.Ext(f) == ".yml" || filepath.Ext(f) == ".yaml" {
|
|
b, err := os.ReadFile(f)
|
|
if err != nil {
|
|
if !nologs {
|
|
fmt.Printf("warning: skipping %s. %s\n", f, err.Error())
|
|
}
|
|
continue
|
|
}
|
|
|
|
if !HasValidHeader(string(b)) {
|
|
if !nologs {
|
|
fmt.Printf("warning: skipping %s because it has no valid header\n", f)
|
|
}
|
|
continue
|
|
}
|
|
|
|
var newConfig Config
|
|
err = yaml.Unmarshal(b, &newConfig)
|
|
if err != nil && !nologs {
|
|
fmt.Printf("warning: failed to parse config:\n%s\n", err.Error())
|
|
}
|
|
result = append(result, &newConfig)
|
|
} else {
|
|
if !nologs {
|
|
fmt.Printf("warning: skipping %s (extension).\n", f)
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func fileSize(f string) float64 {
|
|
file, err := os.Open(f)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
defer file.Close()
|
|
|
|
stat, err := file.Stat()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
|
|
bytes := stat.Size()
|
|
kilobytes := (bytes / 1024)
|
|
megabytes := (float64)(kilobytes / 1024) // cast to type float64
|
|
|
|
return megabytes
|
|
}
|
|
|
|
func listFiles(dir string) ([]string, error) {
|
|
content := []string{}
|
|
|
|
err := filepath.Walk(dir,
|
|
func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if !info.IsDir() {
|
|
content = append(content, path)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
return content, err
|
|
}
|
|
|
|
// ParseCmdLine reads options from the kernel cmdline and returns the equivalent
|
|
// Config.
|
|
func ParseCmdLine(file string) (*Config, error) {
|
|
result := &Config{}
|
|
|
|
if file == "" {
|
|
file = "/proc/cmdline"
|
|
}
|
|
dat, err := os.ReadFile(file)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
|
|
d, err := unstructured.ToYAML(stringToConfig(string(dat)))
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
err = yaml.Unmarshal(d, &result)
|
|
|
|
return result, err
|
|
}
|
|
|
|
func stringToConfig(s string) Config {
|
|
v := Config{}
|
|
|
|
splitted, _ := shlex.Split(s)
|
|
for _, item := range splitted {
|
|
parts := strings.SplitN(item, "=", 2)
|
|
value := "true"
|
|
if len(parts) > 1 {
|
|
value = strings.Trim(parts[1], `"`)
|
|
}
|
|
key := strings.Trim(parts[0], `"`)
|
|
v[key] = value
|
|
}
|
|
|
|
return v
|
|
}
|
|
|
|
// ConfigURL returns the value of config_url if set or empty string otherwise.
|
|
func (c Config) ConfigURL() string {
|
|
if val, hasKey := c["config_url"]; hasKey {
|
|
if s, isString := val.(string); isString {
|
|
return s
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func fetchRemoteConfig(url string) (*Config, error) {
|
|
var body []byte
|
|
result := &Config{}
|
|
|
|
err := retry.Do(
|
|
func() error {
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err = io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}, retry.Delay(time.Second), retry.Attempts(3),
|
|
)
|
|
|
|
if err != nil {
|
|
// TODO: improve logging
|
|
fmt.Printf("WARNING: Couldn't fetch config_url: %s", err)
|
|
return result, nil
|
|
}
|
|
|
|
if !HasValidHeader(string(body)) {
|
|
// TODO: Print a warning when we implement proper logging
|
|
fmt.Println("No valid header in remote config: %w", err)
|
|
return result, nil
|
|
}
|
|
|
|
if err := yaml.Unmarshal(body, result); err != nil {
|
|
return result, fmt.Errorf("could not unmarshal remote config to an object: %w", err)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func HasValidHeader(data string) bool {
|
|
header := strings.SplitN(data, "\n", 2)[0]
|
|
|
|
// Trim trailing whitespaces
|
|
header = strings.TrimRightFunc(header, unicode.IsSpace)
|
|
|
|
// NOTE: we also allow "legacy" headers. Should only allow #cloud-config at
|
|
// some point.
|
|
return (header == DefaultHeader) || (header == "#kairos-config") || (header == "#node-config")
|
|
}
|
|
|
|
func (c Config) Query(s string) (res string, err error) {
|
|
s = fmt.Sprintf(".%s", s)
|
|
|
|
var dat map[string]interface{}
|
|
|
|
yamlStr, err := c.String()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
if err := yaml.Unmarshal([]byte(yamlStr), &dat); err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
query, err := gojq.Parse(s)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
|
|
iter := query.Run(dat) // or query.RunWithContext
|
|
for {
|
|
v, ok := iter.Next()
|
|
if !ok {
|
|
break
|
|
}
|
|
if err, ok := v.(error); ok {
|
|
return res, fmt.Errorf("failed parsing, error: %w", err)
|
|
}
|
|
|
|
dat, err := yaml.Marshal(v)
|
|
if err != nil {
|
|
break
|
|
}
|
|
res += string(dat)
|
|
}
|
|
return
|
|
}
|