Files
kairos-agent/pkg/config/collector/collector.go
Mauro Morales b513c69a0c sparkles: Custom partitioning refactor config (#1180)
* 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>
2023-03-29 16:25:38 +02:00

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
}