Skip to content

Commit

Permalink
Add config package (#2)
Browse files Browse the repository at this point in the history
YAML & JSON configuration handling added

---------

Signed-off-by: Igor Shishkin <me@teran.dev>
  • Loading branch information
teran authored May 31, 2024
1 parent fef676e commit 06ed69d
Show file tree
Hide file tree
Showing 9 changed files with 424 additions and 0 deletions.
84 changes: 84 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package config

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

"github.com/pkg/errors"
yaml "gopkg.in/yaml.v3"
)

type Check struct {
Kind string `json:"kind"`
Spec json.RawMessage `json:"spec"`
}

type Peer struct {
Name string `json:"name"`
RemoteAddress string `json:"remote_address"`
RemoteAS uint64 `json:"remote_as"`
LocalAddress string `json:"local_address"`
LocalAS uint64 `json:"local_as"`
Routes []string `json:"routes"`
}

type Service struct {
Name string `json:"name"`
CheckOperator string `json:"check_operator"`
CheckInterval Duration `json:"check_interval"`
Checks []Check `json:"checks"`
Peers []Peer `json:"peers"`
}

type Metrics struct {
Enabled bool `json:"enabled"`
Address string `json:"address"`
}

type Config struct {
Services []Service `json:"services"`
Metrics Metrics `json:"metrics"`
}

func NewFromFile(filename string) (*Config, error) {
cfg := &Config{}

data, err := os.ReadFile(filename)
if err != nil {
return nil, errors.Wrap(err, "error reading configuration file")
}

ext := strings.ToLower(filepath.Ext(filename))

switch ext {
case ".yml", ".yaml":
type intermediate struct {
Services []any `yaml:"services"`
Metrics map[string]any `yaml:"metrics"`
}

d := intermediate{}

err := yaml.Unmarshal(data, &d)
if err != nil {
return nil, errors.Wrap(err, "error unmarshaling intermediate configuration")
}

data, err = json.Marshal(d)
if err != nil {
return nil, errors.Wrap(err, "error marshaling intermediate configuration")
}
case ".json":
// no additional action needed
default:
return nil, errors.Errorf("unexpected file format: `%s`", ext)
}

if err := json.Unmarshal(data, &cfg); err != nil {
return nil, errors.Wrap(err, "error unmarshaling config")
}

return cfg, nil
}
108 changes: 108 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package config

import (
"encoding/json"
"testing"
"time"

"github.com/pkg/errors"
"github.com/stretchr/testify/require"
)

func TestConfig(t *testing.T) {
type testCase struct {
name string
samplePath string
expOut Config
expError error
}

sampleConfig := Config{
Services: []Service{
{
Name: "http",
CheckOperator: "and",
CheckInterval: Duration(time.Duration(10 * time.Second)),
Checks: []Check{
{
Kind: "dns_lookup",
Spec: json.RawMessage(`{"interval":"100ms","query":"example.com","resolver":"127.0.0.1","tries":3}`),
},
{
Kind: "http_2xx",
Spec: json.RawMessage(`{"address":"127.0.0.1:8080","interval":"100ms","path":"/","timeout":"2s","tries":3}`),
},
{
Kind: "assigned_address",
Spec: json.RawMessage(`{"interface":"dummy0","ipv4":"10.0.0.128"}`),
},
},
Peers: []Peer{
{
Name: "some_router_1",
RemoteAddress: "10.0.0.252",
RemoteAS: 65000,
LocalAddress: "10.0.0.1",
LocalAS: 65999,
Routes: []string{"10.0.0.128/32"},
},
{
Name: "some_router_2",
RemoteAddress: "10.0.0.253",
RemoteAS: 65000,
LocalAddress: "10.0.0.1",
LocalAS: 65999,
Routes: []string{"10.0.0.128/32"},
},
},
},
},
Metrics: Metrics{
Enabled: true,
Address: "127.0.0.1:9090",
},
}

tcs := []testCase{
{
name: "YAML configuration",
samplePath: "testdata/sample.yaml",
expOut: sampleConfig,
},
{
name: "JSON configuration",
samplePath: "testdata/sample.json",
expOut: sampleConfig,
},
{
name: "wrong config file extension",
samplePath: "testdata/sample.unknown",
expError: errors.New("unexpected file format: `.unknown`"),
},
}

for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
r := require.New(t)

cfg, err := NewFromFile(tc.samplePath)
if tc.expError == nil {
r.NoError(err)
for i := range tc.expOut.Services {
r.Equalf(tc.expOut.Services[i].Name, cfg.Services[i].Name, "svc#%d", i)
r.Equalf(tc.expOut.Services[i].CheckInterval, cfg.Services[i].CheckInterval, "svc#%d", i)
r.Equalf(tc.expOut.Services[i].CheckOperator, cfg.Services[i].CheckOperator, "svc#%d", i)
for j := range tc.expOut.Services[i].Checks {
r.Equalf(tc.expOut.Services[i].Checks[j].Kind, cfg.Services[i].Checks[j].Kind, "svc#%d check#%d", i, j)
r.JSONEqf(string(tc.expOut.Services[i].Checks[j].Spec), string(cfg.Services[i].Checks[j].Spec), "svc#%d check#%d", i, j)
}
r.Equalf(tc.expOut.Services[i].Peers, cfg.Services[i].Peers, "svc#%d", i)
}
r.Equal(tc.expOut.Metrics, cfg.Metrics)
} else {
r.Error(err)
r.Equal(tc.expError.Error(), err.Error())
}
})
}
}
37 changes: 37 additions & 0 deletions config/duration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package config

import (
"encoding/json"
"time"

"github.com/pkg/errors"
)

type Duration time.Duration

func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Duration(d).String())
}

func (d *Duration) UnmarshalJSON(b []byte) error {
var v any

if err := json.Unmarshal(b, &v); err != nil {
return err
}

switch value := v.(type) {
case float64:
*d = Duration(time.Duration(value))
return nil
case string:
tmp, err := time.ParseDuration(value)
if err != nil {
return err
}
*d = Duration(tmp)
return nil
default:
return errors.Errorf("invalid value: `%v` (%T)", value, value)
}
}
57 changes: 57 additions & 0 deletions config/duration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package config

import (
"encoding/json"
"testing"
"time"

"github.com/pkg/errors"
"github.com/stretchr/testify/require"
)

func TestDuration(t *testing.T) {
type testCase struct {
name string
in string
expOut Duration
expError error
}
tcs := []testCase{
{
name: "number",
in: "10",
expOut: Duration(time.Duration(10)),
},
{
name: "number w/ suffix",
in: `"10s"`,
expOut: Duration(time.Duration(10 * time.Second)),
},
{
name: "invalid string",
in: `"blah"`,
expError: errors.Errorf(`time: invalid duration "blah"`),
},
{
name: "boolean",
in: "true",
expError: errors.Errorf("invalid value: `true` (bool)"),
},
}

for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
r := require.New(t)

v := new(Duration)
err := json.Unmarshal([]byte(tc.in), v)
if tc.expError == nil {
r.NoError(err)
r.Equal(&tc.expOut, v)
} else {
r.Error(err)
r.Equal(tc.expError.Error(), err.Error())
}
})
}
}
63 changes: 63 additions & 0 deletions config/testdata/sample.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{
"services": [
{
"name": "http",
"check_operator": "and",
"check_interval": "10s",
"checks": [
{
"kind": "dns_lookup",
"spec": {
"query": "example.com",
"resolver": "127.0.0.1",
"tries": 3,
"interval": "100ms"
}
},
{
"kind": "http_2xx",
"spec": {
"address": "127.0.0.1:8080",
"path": "/",
"tries": 3,
"interval": "100ms",
"timeout": "2s"
}
},
{
"kind": "assigned_address",
"spec": {
"interface": "dummy0",
"ipv4": "10.0.0.128"
}
}
],
"peers": [
{
"name": "some_router_1",
"remote_address": "10.0.0.252",
"remote_as": 65000,
"local_address": "10.0.0.1",
"local_as": 65999,
"routes": [
"10.0.0.128/32"
]
},
{
"name": "some_router_2",
"remote_address": "10.0.0.253",
"remote_as": 65000,
"local_address": "10.0.0.1",
"local_as": 65999,
"routes": [
"10.0.0.128/32"
]
}
]
}
],
"metrics": {
"enabled": true,
"address": "127.0.0.1:9090"
}
}
1 change: 1 addition & 0 deletions config/testdata/sample.unknown
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nothing here
41 changes: 41 additions & 0 deletions config/testdata/sample.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
services:
- name: http
check_operator: and
check_interval: 10s
checks:
- kind: dns_lookup
spec:
query: example.com
resolver: 127.0.0.1
tries: 3
interval: 100ms
- kind: http_2xx
spec:
address: 127.0.0.1:8080
path: /
tries: 3
interval: 100ms
timeout: 2s
- kind: assigned_address
spec:
interface: dummy0
ipv4: 10.0.0.128
peers:
- name: some_router_1
remote_address: 10.0.0.252
remote_as: 65000
local_address: 10.0.0.1
local_as: 65999
routes:
- 10.0.0.128/32
- name: some_router_2
remote_address: 10.0.0.253
remote_as: 65000
local_address: 10.0.0.1
local_as: 65999
routes:
- 10.0.0.128/32
metrics:
enabled: true
address: 127.0.0.1:9090
Loading

0 comments on commit 06ed69d

Please sign in to comment.