Skip to content

Commit

Permalink
cmd: implement modular configs
Browse files Browse the repository at this point in the history
This takes a page out of k8s' and systemd's playbooks and implements
drop-in configs. The method of handling JSON and YAML is borrowed from
k8s, but simplified because the configurations aren't arbitrary. The
drop-in scheme is inspired by systemd's drop-in overrides.

This allows for operators to modularize the config and put secrets in a
different file than the main config. It also allows for easier
configuration skew -- please note that running multiple different
configurations in the same system is not supported.

Signed-off-by: Hank Donnay <hdonnay@redhat.com>
  • Loading branch information
hdonnay committed Jun 15, 2023
1 parent 5d30ed6 commit 3ff924a
Show file tree
Hide file tree
Showing 28 changed files with 569 additions and 0 deletions.
187 changes: 187 additions & 0 deletions cmd/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package cmd

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"

jsonpatch "github.com/evanphx/json-patch/v5"
"github.com/quay/clair/config"
"gopkg.in/yaml.v3"
)

// LoadConfig loads the named config file or reports an error.
//
// JSON and YAML formatted files are supported, as determined by the file extension ("json" or "yaml" -- "yml" is not supported).
// If a directory suffixed with ".d" exists (e.g. a file "config.json" and a directory "config.json.d"),
// then all files with the same extension or the same extension suffixed with "-patch" will be loaded in lexical order and merged with the main configuration or applied as an RFC6902 patch, respectively.
//
// For example, given the paths:
//
// config.yaml
// config.yaml.d/
// config.yaml.d/secrets.yaml
// config.yaml.d/override.yaml-patch
// config.yaml.d/unloved.json-patch
//
// "Config.yaml" will be the base config,
// "override.yaml-patch" will be applied as a patch to the base config,
// "secrets.yaml" will be merged into the base config,
// and "unloved.json-patch" will be ignored.
//
// The "strict" argument controls whether the function returns on the first
// error, or runs the full routine and returns all accumulated errors at the
// end.
func LoadConfig(cfg *config.Config, name string, strict bool) error {
// This function would probably benefit from some logging, but the logging
// configuration is specified _inside_ the configuration, so it's hard to
// say what should be done here.
name = filepath.Clean(name)
ext := filepath.Ext(name)
switch ext {
case ".yaml": // OK
case ".json": // OK
default:
return fmt.Errorf("unknown config kind %q", ext)
}
var errs []error

b, err := loadAsJSON(name)
if err != nil {
if strict {
return err
}
errs = append(errs, err)
}
dropinDir := name + ".d"
err = filepath.WalkDir(dropinDir, func(path string, d fs.DirEntry, err error) error {
switch {
case path == dropinDir:
return nil
case !errors.Is(err, nil):
return fmt.Errorf("error walking filesystem: %w", err)
case d.IsDir():
return fs.SkipDir
}
// After this, make sure everything assigns errors to "err" so that the
// non-strict behavior works.

var doc []byte
switch dext := filepath.Ext(path); {
case dext == ext:
doc, err = loadAsJSON(path)
if err != nil {
break
}
b, err = jsonpatch.MergePatch(b, doc)
if err != nil {
err = fmt.Errorf("error merging drop-in %q: %w", path, err)
break
}
case dext == ext+"-patch":
doc, err = loadAsJSON(path)
if err != nil {
break
}
var p jsonpatch.Patch
p, err = jsonpatch.DecodePatch(doc)
if err != nil {
err = fmt.Errorf("bad patch %q: %w", path, err)
break
}
b, err = p.Apply(b)
if err != nil {
err = fmt.Errorf("error applying patch %q: %w", path, err)
break
}
}
if err != nil {
if strict {
return err
}
errs = append(errs, err)
}
return nil
})
switch {
case errors.Is(err, nil):
case errors.Is(err, fs.ErrNotExist): // OK
case strict:
return err
default:
errs = append(errs, err)
}

if len(b) == 0 {
err := fmt.Errorf("error load config %q: empty document after merges", name)
if strict {
return err
}
errs = append(errs, err)
}
dec := json.NewDecoder(bytes.NewReader(b))
dec.DisallowUnknownFields()
if err := dec.Decode(cfg); err != nil {
// Hide that this error is coming from the `json` package, as it might
// confuse people.
err := fmt.Errorf("error decoding config %q: %s", name, strings.TrimPrefix(err.Error(), `json: `))
if strict {
return err
}
errs = append(errs, err)
}
return errors.Join(errs...)
}

func loadAsJSON(path string) ([]byte, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("error reading file %q: %w", path, err)
}
ext := filepath.Ext(path)
switch ext {
case ".json", ".json-patch":
if len(b) < 2 {
return nil, fmt.Errorf("malformed file %q: not a JSON document", path)
}
case ".yaml", ".yaml-patch":
var y interface{}
if err := yaml.Unmarshal(b, &y); err != nil {
msg := strings.TrimPrefix(err.Error(), `yaml: `)
return nil, fmt.Errorf("malformed file %q: %v", path, msg)
}
// For arbitrary yaml documents we'd have to do a step to ensure there's
// no disallowed constructs (binary keys, binary data tags) but we know
// this should only ever be some snippet of our config.Config type.
b, err = json.Marshal(y)
if err != nil { // Not sure how this would happen. 🤔
msg := strings.TrimPrefix(err.Error(), `json: `)
return nil, fmt.Errorf("malformed file %q: %s", path, msg)
}
default:
panic("programmer error: called on bad path")
}
switch ext {
case ".json":
if b[0] != '{' {
return nil, fmt.Errorf("malformed file %q: not a JSON object", path)
}
case ".json-patch", ".yaml-patch":
if b[0] != '[' {
return nil, fmt.Errorf("malformed file %q: not a patch document", path)
}
case ".yaml":
if b[0] != '{' {
// If this was an empty file (for some reason), note it and return an
// empty JSON object. This can't happen with JSON -- we checked if it
// meets the minimum size above.
b = []byte("{}")
}
}
return b, nil
}
60 changes: 60 additions & 0 deletions cmd/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package cmd_test

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"

"github.com/google/go-cmp/cmp"
"github.com/quay/clair/config"

"github.com/quay/clair/v4/cmd"
)

func TestLoadConfig(t *testing.T) {
ms, err := filepath.Glob(`testdata/*/config.*[^d]`)
if err != nil {
panic("programmer error")
}
for _, m := range ms {
name := filepath.Base(filepath.Dir(m))
t.Run(name, func(t *testing.T) {
wantpath := filepath.Join(filepath.Dir(m), "want.json")
wf, err := os.Open(wantpath)
if err != nil {
t.Fatal(err)
}
defer wf.Close()
var got, want config.Config
if err := json.NewDecoder(wf).Decode(&want); err != nil {
t.Error(err)
}
if err := cmd.LoadConfig(&got, m, true); err != nil {
t.Error(err)
}
if !cmp.Equal(got, want) {
t.Error(cmp.Diff(got, want))
}
})
}
ms, err = filepath.Glob(`testdata/Error/*[^d]`)
if err != nil {
panic("programmer error")
}
t.Run("Error", func(t *testing.T) {
for _, m := range ms {
name := filepath.Base(m)
name = strings.TrimSuffix(name, filepath.Ext(name))
t.Run(name, func(t *testing.T) {
var got config.Config
err := cmd.LoadConfig(&got, m, false)
t.Log(err)
if err == nil {
t.Fail()
}
})
}
})
}
5 changes: 5 additions & 0 deletions cmd/testdata/ComplexJSON/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"http_listen_addr": ":80",
"log_level": "error",
"matcher": {}
}
3 changes: 3 additions & 0 deletions cmd/testdata/ComplexJSON/config.json.d/dropin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"log_level": "error"
}
4 changes: 4 additions & 0 deletions cmd/testdata/ComplexJSON/want.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"http_listen_addr": ":80",
"log_level": "error"
}
57 changes: 57 additions & 0 deletions cmd/testdata/ComplexYAML/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
log_level: debug-color
introspection_addr: ":8089"
http_listen_addr: ":6060"
updaters:
sets:
- ubuntu
- debian
- rhel
- alpine
auth:
psk:
key: 'c2VjcmV0'
iss:
- quay
- clairctl
indexer:
connstring: host=clair-database user=clair dbname=indexer sslmode=disable
scanlock_retry: 10
layer_scan_concurrency: 5
migrations: true
matcher:
indexer_addr: http://clair-indexer:6060/
connstring: host=clair-database user=clair dbname=matcher sslmode=disable
max_conn_pool: 100
migrations: true
matchers: {}
notifier:
indexer_addr: http://clair-indexer:6060/
matcher_addr: http://clair-matcher:6060/
connstring: host=clair-database user=clair dbname=notifier sslmode=disable
migrations: true
delivery_interval: 30s
poll_interval: 1m
webhook:
target: "http://webhook-target/"
callback: "http://clair-notifier:6060/notifier/api/v1/notification/"
# amqp:
# direct: true
# exchange:
# name: ""
# type: "direct"
# durable: true
# auto_delete: false
# uris: ["amqp://guest:guest@clair-rabbitmq:5672/"]
# routing_key: "notifications"
# callback: "http://clair-notifier/notifier/api/v1/notifications"
# tracing and metrics config
trace:
name: "jaeger"
# probability: 1
jaeger:
agent:
endpoint: "clair-jaeger:6831"
service_name: "clair"
metrics:
name: "prometheus"
1 change: 1 addition & 0 deletions cmd/testdata/ComplexYAML/config.yaml.d/dropin.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
log_level: null
1 change: 1 addition & 0 deletions cmd/testdata/ComplexYAML/config.yaml.d/empty.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

8 changes: 8 additions & 0 deletions cmd/testdata/ComplexYAML/config.yaml.d/ignored.json-patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# This file is ignored -- look at all this invalid JSON!
[
{
"op": "add",
"path": "/updaters/sets/-",
"value": "osv",
},
]
4 changes: 4 additions & 0 deletions cmd/testdata/ComplexYAML/config.yaml.d/later.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
log_level: panic
updaters:
sets:
- rhel
3 changes: 3 additions & 0 deletions cmd/testdata/ComplexYAML/config.yaml.d/updater.yaml-patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- op: add
path: /updaters/sets/-
value: osv
Loading

0 comments on commit 3ff924a

Please sign in to comment.