implement mcfg package, which includes both configuration, runtime setup, and organizing both into heirarchies
This commit is contained in:
parent
3f2f00d367
commit
3d43caba18
195
mcfg/cli.go
Normal file
195
mcfg/cli.go
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
package mcfg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SourceCLI is a Source which will parse configuration from the CLI.
|
||||||
|
//
|
||||||
|
// Possible CLI options are generated by joining the Path to a Param, and its
|
||||||
|
// name, with dashes. For example:
|
||||||
|
//
|
||||||
|
// cfg := mcfg.New().Child("foo").Child("bar")
|
||||||
|
// addr := cfg.ParamString("addr", "", "Some address")
|
||||||
|
// // the CLI option to fill addr will be "--foo-bar-addr"
|
||||||
|
//
|
||||||
|
// If the "-h" option is seen then a help page will be printed to
|
||||||
|
// stdout and the process will exit. Since all normally-defined parameters must
|
||||||
|
// being with double-dash ("--") they won't ever conflict with the help option.
|
||||||
|
//
|
||||||
|
type SourceCLI struct {
|
||||||
|
Args []string // if nil then os.Args[1:] is used
|
||||||
|
|
||||||
|
DisableHelpPage bool
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
cliKeyJoin = "-"
|
||||||
|
cliKeyPrefix = "--"
|
||||||
|
cliValSep = "="
|
||||||
|
cliHelpArg = "-h"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parse implements the method for the Source interface
|
||||||
|
func (cli SourceCLI) Parse(cfg *Cfg) ([]ParamValue, error) {
|
||||||
|
args := cli.Args
|
||||||
|
if cli.Args == nil {
|
||||||
|
args = os.Args[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
pvM, err := cli.cliParamVals(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pvs := make([]ParamValue, 0, len(args))
|
||||||
|
var (
|
||||||
|
key string
|
||||||
|
pv ParamValue
|
||||||
|
pvOk bool
|
||||||
|
pvStrVal string
|
||||||
|
pvStrValOk bool
|
||||||
|
)
|
||||||
|
for _, arg := range args {
|
||||||
|
if pvOk {
|
||||||
|
pvStrVal = arg
|
||||||
|
pvStrValOk = true
|
||||||
|
} else if !cli.DisableHelpPage && arg == cliHelpArg {
|
||||||
|
cli.printHelp(os.Stdout, pvM)
|
||||||
|
os.Stdout.Sync()
|
||||||
|
os.Exit(1)
|
||||||
|
} else {
|
||||||
|
for key, pv = range pvM {
|
||||||
|
if arg == key {
|
||||||
|
pvOk = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := key + cliValSep
|
||||||
|
if !strings.HasPrefix(arg, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pvOk = true
|
||||||
|
pvStrVal = strings.TrimPrefix(arg, prefix)
|
||||||
|
pvStrValOk = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !pvOk {
|
||||||
|
return nil, fmt.Errorf("unexpected config parameter %q", arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pvOk is always true at this point, and so pv is filled in
|
||||||
|
|
||||||
|
if pv.IsBool {
|
||||||
|
// if it's a boolean we don't expect there to be a following value,
|
||||||
|
// it's just a flag
|
||||||
|
if pvStrValOk {
|
||||||
|
return nil, fmt.Errorf("param %q is a boolean and cannot have a value", arg)
|
||||||
|
}
|
||||||
|
pv.Value = json.RawMessage("true")
|
||||||
|
|
||||||
|
} else if !pvStrValOk {
|
||||||
|
// everything else should have a value. if pvStrVal isn't filled it
|
||||||
|
// means the next arg should be one. Continue the loop, it'll get
|
||||||
|
// filled with the next one (hopefully)
|
||||||
|
continue
|
||||||
|
|
||||||
|
} else if pv.IsString && (pvStrVal == "" || pvStrVal[0] != '"') {
|
||||||
|
pv.Value = json.RawMessage(`"` + pvStrVal + `"`)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
pv.Value = json.RawMessage(pvStrVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
pvs = append(pvs, pv)
|
||||||
|
key = ""
|
||||||
|
pv = ParamValue{}
|
||||||
|
pvOk = false
|
||||||
|
pvStrVal = ""
|
||||||
|
pvStrValOk = false
|
||||||
|
}
|
||||||
|
if pvOk && !pvStrValOk {
|
||||||
|
return nil, fmt.Errorf("param %q expected a value", key)
|
||||||
|
}
|
||||||
|
return pvs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cli SourceCLI) cliParamVals(cfg *Cfg) (map[string]ParamValue, error) {
|
||||||
|
m := map[string]ParamValue{}
|
||||||
|
for _, param := range cfg.Params {
|
||||||
|
key := cliKeyPrefix
|
||||||
|
if len(cfg.Path) > 0 {
|
||||||
|
key += strings.Join(cfg.Path, cliKeyJoin) + cliKeyJoin
|
||||||
|
}
|
||||||
|
key += param.Name
|
||||||
|
m[key] = ParamValue{
|
||||||
|
Param: param,
|
||||||
|
Path: cfg.Path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, child := range cfg.Children {
|
||||||
|
childM, err := cli.cliParamVals(child)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for key, pv := range childM {
|
||||||
|
if _, ok := m[key]; ok {
|
||||||
|
return nil, fmt.Errorf("multiple params use the same CLI arg %q", key)
|
||||||
|
}
|
||||||
|
m[key] = pv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cli SourceCLI) printHelp(w io.Writer, pvM map[string]ParamValue) {
|
||||||
|
type pvEntry struct {
|
||||||
|
arg string
|
||||||
|
ParamValue
|
||||||
|
}
|
||||||
|
|
||||||
|
pvA := make([]pvEntry, 0, len(pvM))
|
||||||
|
for arg, pv := range pvM {
|
||||||
|
pvA = append(pvA, pvEntry{arg: arg, ParamValue: pv})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(pvA, func(i, j int) bool {
|
||||||
|
return pvA[i].arg < pvA[j].arg
|
||||||
|
})
|
||||||
|
|
||||||
|
fmtDefaultVal := func(ptr interface{}) string {
|
||||||
|
if ptr == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
val := reflect.Indirect(reflect.ValueOf(ptr))
|
||||||
|
zero := reflect.Zero(val.Type())
|
||||||
|
if reflect.DeepEqual(val.Interface(), zero.Interface()) {
|
||||||
|
return ""
|
||||||
|
} else if val.Type().Kind() == reflect.String {
|
||||||
|
return fmt.Sprintf("%q", val.Interface())
|
||||||
|
}
|
||||||
|
return fmt.Sprint(val.Interface())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pvE := range pvA {
|
||||||
|
fmt.Fprintf(w, "\n%s", pvE.arg)
|
||||||
|
if pvE.IsBool {
|
||||||
|
fmt.Fprintf(w, " (Flag)")
|
||||||
|
} else if defVal := fmtDefaultVal(pvE.Into); defVal != "" {
|
||||||
|
fmt.Fprintf(w, " (Default: %s)", defVal)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "\n")
|
||||||
|
if pvE.Usage != "" {
|
||||||
|
fmt.Fprintln(w, "\t"+pvE.Usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "\n")
|
||||||
|
}
|
313
mcfg/cli_test.go
Normal file
313
mcfg/cli_test.go
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
package mcfg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
. "testing"
|
||||||
|
|
||||||
|
"github.com/mediocregopher/mediocre-go-lib/mtest"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// * dimension
|
||||||
|
// - dimension value
|
||||||
|
//
|
||||||
|
// * Cfg path
|
||||||
|
// - New()
|
||||||
|
// - New.Child("a")
|
||||||
|
// - New.Child("a-b")
|
||||||
|
// - New.Child("a=b")
|
||||||
|
// * Param name
|
||||||
|
// - normal
|
||||||
|
// - w/ "-"
|
||||||
|
// - w/ "=" ?
|
||||||
|
// * Param type
|
||||||
|
// - bool
|
||||||
|
// - non-bool
|
||||||
|
// * non-bool type
|
||||||
|
// - int
|
||||||
|
// - string
|
||||||
|
// * Str value
|
||||||
|
// - empty
|
||||||
|
// - normal
|
||||||
|
// - w/ -
|
||||||
|
// - w/ =
|
||||||
|
// * Value format
|
||||||
|
// - w/ =
|
||||||
|
// - w/o =
|
||||||
|
|
||||||
|
func combinate(slices ...[]string) [][]string {
|
||||||
|
out := [][]string{{}}
|
||||||
|
for _, slice := range slices {
|
||||||
|
if len(slice) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prev := out
|
||||||
|
out = make([][]string, 0, len(prev)*len(slice))
|
||||||
|
for _, prevSet := range prev {
|
||||||
|
for _, sliceElem := range slice {
|
||||||
|
prevSetCp := make([]string, len(prevSet), len(prevSet)+1)
|
||||||
|
copy(prevSetCp, prevSet)
|
||||||
|
prevSetCp = append(prevSetCp, sliceElem)
|
||||||
|
out = append(out, prevSetCp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCombinate(t *T) {
|
||||||
|
type combTest struct {
|
||||||
|
args [][]string
|
||||||
|
exp [][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []combTest{
|
||||||
|
{
|
||||||
|
args: [][]string{},
|
||||||
|
exp: [][]string{{}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
args: [][]string{{"a"}},
|
||||||
|
exp: [][]string{{"a"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
args: [][]string{{"a"}, {"b"}},
|
||||||
|
exp: [][]string{{"a", "b"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
args: [][]string{{"a", "aa"}, {"b"}},
|
||||||
|
exp: [][]string{
|
||||||
|
{"a", "b"},
|
||||||
|
{"aa", "b"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
args: [][]string{{"a"}, {"b", "bb"}},
|
||||||
|
exp: [][]string{
|
||||||
|
{"a", "b"},
|
||||||
|
{"a", "bb"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
args: [][]string{{"a", "aa"}, {"b", "bb"}},
|
||||||
|
exp: [][]string{
|
||||||
|
{"a", "b"},
|
||||||
|
{"aa", "b"},
|
||||||
|
{"a", "bb"},
|
||||||
|
{"aa", "bb"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, test := range tests {
|
||||||
|
msgAndArgs := []interface{}{"test:%d args:%v", i, test.args}
|
||||||
|
got := combinate(test.args...)
|
||||||
|
assert.Len(t, got, len(test.exp), msgAndArgs...)
|
||||||
|
for _, expHas := range test.exp {
|
||||||
|
assert.Contains(t, got, expHas, msgAndArgs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourceCLI(t *T) {
|
||||||
|
var (
|
||||||
|
paths = []string{
|
||||||
|
"root",
|
||||||
|
"child",
|
||||||
|
"childDash",
|
||||||
|
"childEq",
|
||||||
|
}
|
||||||
|
|
||||||
|
paramNames = []string{
|
||||||
|
"normal",
|
||||||
|
"wDash",
|
||||||
|
"wEq",
|
||||||
|
}
|
||||||
|
|
||||||
|
isBool = []string{
|
||||||
|
"isBool",
|
||||||
|
"isNotBool",
|
||||||
|
}
|
||||||
|
|
||||||
|
nonBoolTypes = []string{
|
||||||
|
"int",
|
||||||
|
"str",
|
||||||
|
}
|
||||||
|
|
||||||
|
nonBoolFmts = []string{
|
||||||
|
"wEq",
|
||||||
|
"woEq",
|
||||||
|
}
|
||||||
|
|
||||||
|
nonBoolStrValues = []string{
|
||||||
|
"empty",
|
||||||
|
"normal",
|
||||||
|
"wDash",
|
||||||
|
"wEq",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type cliTest struct {
|
||||||
|
path string
|
||||||
|
name string
|
||||||
|
isBool bool
|
||||||
|
nonBoolType string
|
||||||
|
nonBoolStrValue string
|
||||||
|
nonBoolFmt string
|
||||||
|
|
||||||
|
// it's kinda hacky to make this a pointer, but it makes the code a lot
|
||||||
|
// easier to read later
|
||||||
|
exp *ParamValue
|
||||||
|
}
|
||||||
|
|
||||||
|
var tests []cliTest
|
||||||
|
for _, comb := range combinate(paths, paramNames, isBool) {
|
||||||
|
var test cliTest
|
||||||
|
test.path = comb[0]
|
||||||
|
test.name = comb[1]
|
||||||
|
test.isBool = comb[2] == "isBool"
|
||||||
|
if test.isBool {
|
||||||
|
tests = append(tests, test)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, nonBoolComb := range combinate(nonBoolTypes, nonBoolFmts) {
|
||||||
|
test.nonBoolType = nonBoolComb[0]
|
||||||
|
test.nonBoolFmt = nonBoolComb[1]
|
||||||
|
if test.nonBoolType != "str" {
|
||||||
|
tests = append(tests, test)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, nonBoolStrValue := range nonBoolStrValues {
|
||||||
|
test.nonBoolStrValue = nonBoolStrValue
|
||||||
|
tests = append(tests, test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
childName := mtest.RandHex(8)
|
||||||
|
childDashName := mtest.RandHex(4) + "-" + mtest.RandHex(4)
|
||||||
|
childEqName := mtest.RandHex(4) + "=" + mtest.RandHex(4)
|
||||||
|
|
||||||
|
var args []string
|
||||||
|
rootCfg := New()
|
||||||
|
childCfg := rootCfg.Child(childName)
|
||||||
|
childDashCfg := rootCfg.Child(childDashName)
|
||||||
|
childEqCfg := rootCfg.Child(childEqName)
|
||||||
|
|
||||||
|
for i := range tests {
|
||||||
|
var pv ParamValue
|
||||||
|
tests[i].exp = &pv
|
||||||
|
|
||||||
|
switch tests[i].name {
|
||||||
|
case "normal":
|
||||||
|
pv.Name = mtest.RandHex(8)
|
||||||
|
case "wDash":
|
||||||
|
pv.Name = mtest.RandHex(4) + "-" + mtest.RandHex(4)
|
||||||
|
case "wEq":
|
||||||
|
pv.Name = mtest.RandHex(4) + "=" + mtest.RandHex(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
pv.IsBool = tests[i].isBool
|
||||||
|
pv.IsString = !tests[i].isBool && tests[i].nonBoolType == "str"
|
||||||
|
|
||||||
|
var arg string
|
||||||
|
switch tests[i].path {
|
||||||
|
case "root":
|
||||||
|
rootCfg.ParamAdd(pv.Param)
|
||||||
|
arg = "--" + pv.Name
|
||||||
|
case "child":
|
||||||
|
childCfg.ParamAdd(pv.Param)
|
||||||
|
pv.Path = append(pv.Path, childName)
|
||||||
|
arg = "--" + childName + "-" + pv.Name
|
||||||
|
case "childDash":
|
||||||
|
childDashCfg.ParamAdd(pv.Param)
|
||||||
|
pv.Path = append(pv.Path, childDashName)
|
||||||
|
arg = "--" + childDashName + "-" + pv.Name
|
||||||
|
case "childEq":
|
||||||
|
childEqCfg.ParamAdd(pv.Param)
|
||||||
|
pv.Path = append(pv.Path, childEqName)
|
||||||
|
arg = "--" + childEqName + "-" + pv.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
if pv.IsBool {
|
||||||
|
pv.Value = json.RawMessage("true")
|
||||||
|
args = append(args, arg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var val string
|
||||||
|
switch tests[i].nonBoolType {
|
||||||
|
case "int":
|
||||||
|
val = strconv.Itoa(mtest.Rand.Int())
|
||||||
|
pv.Value = json.RawMessage(val)
|
||||||
|
case "str":
|
||||||
|
switch tests[i].nonBoolStrValue {
|
||||||
|
case "empty":
|
||||||
|
// ez
|
||||||
|
case "normal":
|
||||||
|
val = mtest.RandHex(8)
|
||||||
|
case "wDash":
|
||||||
|
val = mtest.RandHex(4) + "-" + mtest.RandHex(4)
|
||||||
|
case "wEq":
|
||||||
|
val = mtest.RandHex(4) + "=" + mtest.RandHex(4)
|
||||||
|
}
|
||||||
|
pv.Value = json.RawMessage(`"` + val + `"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch tests[i].nonBoolFmt {
|
||||||
|
case "wEq":
|
||||||
|
arg += "=" + val
|
||||||
|
args = append(args, arg)
|
||||||
|
case "woEq":
|
||||||
|
args = append(args, arg, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
src := SourceCLI{Args: args}
|
||||||
|
pvals, err := src.Parse(rootCfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, pvals, len(tests))
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
assert.Contains(t, pvals, *test.exp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// an extra bogus param or value should generate an error
|
||||||
|
src = SourceCLI{Args: append(args, "foo")}
|
||||||
|
_, err = src.Parse(rootCfg)
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourceCLIHelp(t *T) {
|
||||||
|
cfg := New()
|
||||||
|
cfg.ParamInt("foo", 5, "Test int param")
|
||||||
|
cfg.ParamBool("bar", "Test bool param")
|
||||||
|
cfg.ParamString("baz", "baz", "Test string param")
|
||||||
|
cfg.ParamString("baz2", "", "")
|
||||||
|
src := SourceCLI{}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
pvM, err := src.cliParamVals(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
SourceCLI{}.printHelp(buf, pvM)
|
||||||
|
|
||||||
|
exp := `
|
||||||
|
--bar (Flag)
|
||||||
|
Test bool param
|
||||||
|
|
||||||
|
--baz (Default: "baz")
|
||||||
|
Test string param
|
||||||
|
|
||||||
|
--baz2
|
||||||
|
|
||||||
|
--foo (Default: 5)
|
||||||
|
Test int param
|
||||||
|
|
||||||
|
`
|
||||||
|
assert.Equal(t, exp, buf.String())
|
||||||
|
}
|
266
mcfg/mcfg.go
Normal file
266
mcfg/mcfg.go
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
// Package mcfg provides a simple foundation for complex service/binary
|
||||||
|
// configuration, initialization, and destruction
|
||||||
|
package mcfg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO Sources:
|
||||||
|
// - Env
|
||||||
|
// - Env file
|
||||||
|
// - JSON file
|
||||||
|
// - YAML file
|
||||||
|
|
||||||
|
// Hook describes a function which can have other Hook functions appended to it
|
||||||
|
// via the Then method. A Hook is expected to return context.Canceled on context
|
||||||
|
// cancellation.
|
||||||
|
type Hook func(context.Context) error
|
||||||
|
|
||||||
|
// Nop returns a Hook which does nothing
|
||||||
|
func Nop() Hook {
|
||||||
|
return func(context.Context) error { return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then modifies the called upon Hook such that it will first perform whatever
|
||||||
|
// it's original functionality was, and then if that doesn't return an error it
|
||||||
|
// will subsequently perform the given Hook.
|
||||||
|
func (h *Hook) Then(h2 Hook) {
|
||||||
|
oldh := *h
|
||||||
|
*h = func(ctx context.Context) error {
|
||||||
|
if err := oldh(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return h2(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO Having Also here might be more confusing than it's worth, since Child
|
||||||
|
// effectively does the same thing wrt Hook handling
|
||||||
|
|
||||||
|
// Also modifies the called upon Hook such that it will perform the original
|
||||||
|
// functionality at the same time as the given Hook, wait for both to complete,
|
||||||
|
// and return an error if there is one.
|
||||||
|
func (h *Hook) Also(h2 Hook) {
|
||||||
|
oldh := *h
|
||||||
|
*h = func(ctx context.Context) error {
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errCh <- oldh(ctx)
|
||||||
|
}()
|
||||||
|
err := h2(ctx)
|
||||||
|
if err := <-errCh; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParamValue describes a value for a parameter which has been parsed by a
|
||||||
|
// Source
|
||||||
|
type ParamValue struct {
|
||||||
|
Param
|
||||||
|
Path []string // nil if root
|
||||||
|
Value json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source parses ParamValues out of a particular configuration source
|
||||||
|
type Source interface {
|
||||||
|
Parse(*Cfg) ([]ParamValue, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cfg describes a set of configuration parameters and run-time behaviors.
|
||||||
|
// Parameters are defined using the Param* methods, and run-time behaviors by
|
||||||
|
// the Hook fields on this struct.
|
||||||
|
//
|
||||||
|
// Each Cfg can have child Cfg's spawned off of it using the Child method, which
|
||||||
|
// allows for namespacing related params/behaviors into heirarchies.
|
||||||
|
type Cfg struct {
|
||||||
|
// Read-only. The set of names passed into Child methods used to generate
|
||||||
|
// this Cfg and all of its parents. Path will be nil if this Cfg was created
|
||||||
|
// with New and not a Child call.
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
// New().Path is nil
|
||||||
|
// New().Child("foo").Path is []string{"foo"}
|
||||||
|
// New().Child("foo").Child("bar").Path is []string{"foo", "bar"}
|
||||||
|
Path []string
|
||||||
|
|
||||||
|
// Read-only. The set of children spawned off of this Cfg via the Child
|
||||||
|
// method, keyed by the children's names.
|
||||||
|
Children map[string]*Cfg
|
||||||
|
|
||||||
|
// Read-only. The set of Params which have been added to the Cfg instance
|
||||||
|
// via its Add method.
|
||||||
|
Params map[string]Param
|
||||||
|
|
||||||
|
// Start hook is performed after configuration variables have been parsed
|
||||||
|
// and populated. All Start hooks are expected to run in a finite amount of
|
||||||
|
// time, any long running processes spun off from them should do so in a
|
||||||
|
// separate go-routine
|
||||||
|
Start Hook
|
||||||
|
|
||||||
|
// Default 2 minutes. Timeout within which the Start Hook (and the Start
|
||||||
|
// Hooks of all children of this Cfg) must complete.
|
||||||
|
StartTimeout time.Duration
|
||||||
|
|
||||||
|
// Stop hook is performed on interrupt signal, and should stop all
|
||||||
|
// go-routines and close all resource handlers created during Start
|
||||||
|
Stop Hook
|
||||||
|
|
||||||
|
// Default 30 seconds. Timeout within which the Stop Hook (and the Stop
|
||||||
|
// Hooks of all children of this Cfg) must complete.
|
||||||
|
StopTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// New initializes and returns an empty Cfg with default values filled in
|
||||||
|
func New() *Cfg {
|
||||||
|
return &Cfg{
|
||||||
|
Children: map[string]*Cfg{},
|
||||||
|
Params: map[string]Param{},
|
||||||
|
Start: Nop(),
|
||||||
|
StartTimeout: 2 * time.Minute,
|
||||||
|
Stop: Nop(),
|
||||||
|
StopTimeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cfg) populateParams(src Source) error {
|
||||||
|
pvs, err := src.Parse(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// first dedupe the params. We use this param struct as the key by which to
|
||||||
|
// dedupe by. Its use depends on the json.Marshaler always ordering fields
|
||||||
|
// in a marshaled struct the same way, which isn't the best assumption but
|
||||||
|
// it's ok for now
|
||||||
|
type param struct {
|
||||||
|
Path []string `json:",omitempty"`
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
pvM := map[string]ParamValue{}
|
||||||
|
for _, pv := range pvs {
|
||||||
|
keyB, err := json.Marshal(param{Path: pv.Path, Name: pv.Name})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pvM[string(keyB)] = pv
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for required params, again using the param struct and the existing
|
||||||
|
// pvM
|
||||||
|
var requiredParams func(*Cfg) []param
|
||||||
|
requiredParams = func(c *Cfg) []param {
|
||||||
|
var out []param
|
||||||
|
for _, p := range c.Params {
|
||||||
|
if !p.Required {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, param{Path: c.Path, Name: p.Name})
|
||||||
|
}
|
||||||
|
for _, child := range c.Children {
|
||||||
|
out = append(out, requiredParams(child)...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, reqP := range requiredParams(c) {
|
||||||
|
keyB, err := json.Marshal(reqP)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if _, ok := pvM[string(keyB)]; !ok {
|
||||||
|
return fmt.Errorf("param %s is required but wasn't populated by any configuration source", keyB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pv := range pvM {
|
||||||
|
if pv.Into == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(pv.Value, pv.Into); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run blocks while performing all steps of a Cfg run. The steps, in order, are;
|
||||||
|
// * Populate all configuration parameters
|
||||||
|
// * Recursively perform Start hooks, depth first
|
||||||
|
// * Block till the passed in context is cancelled
|
||||||
|
// * Recursively perform Stop hooks, depth first
|
||||||
|
//
|
||||||
|
// If any step returns an error then everything returns that error immediately.
|
||||||
|
//
|
||||||
|
// A caveat about Run is that the error case doesn't leave a lot of room for a
|
||||||
|
// proper cleanup. If you care about that sort of thing you'll need to handle it
|
||||||
|
// yourself.
|
||||||
|
func (c *Cfg) Run(ctx context.Context, src Source) error {
|
||||||
|
if err := c.populateParams(src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
startCtx, cancel := context.WithTimeout(ctx, c.StartTimeout)
|
||||||
|
err := c.startHooks(startCtx)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
|
||||||
|
stopCtx, cancel := context.WithTimeout(context.Background(), c.StopTimeout)
|
||||||
|
defer cancel()
|
||||||
|
return c.stopHooks(stopCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cfg) startHooks(ctx context.Context) error {
|
||||||
|
return c.recurseHooks(ctx, func(c *Cfg) Hook { return c.Start })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cfg) stopHooks(ctx context.Context) error {
|
||||||
|
return c.recurseHooks(ctx, func(c *Cfg) Hook { return c.Stop })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cfg) recurseHooks(ctx context.Context, pickHook func(*Cfg) Hook) error {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(len(c.Children))
|
||||||
|
errCh := make(chan error, len(c.Children))
|
||||||
|
for name := range c.Children {
|
||||||
|
childCfg := c.Children[name]
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := childCfg.recurseHooks(ctx, pickHook); err != nil {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(errCh)
|
||||||
|
if err := <-errCh; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return pickHook(c)(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Child returns a sub-Cfg of the callee with the given name. The name will be
|
||||||
|
// prepended to all configuration options created in the returned sub-Cfg, and
|
||||||
|
// must not be empty.
|
||||||
|
func (c *Cfg) Child(name string) *Cfg {
|
||||||
|
if _, ok := c.Children[name]; ok {
|
||||||
|
panic(fmt.Sprintf("child Cfg named %q already exists", name))
|
||||||
|
}
|
||||||
|
c2 := New()
|
||||||
|
c2.Path = make([]string, 0, len(c.Path)+1)
|
||||||
|
c2.Path = append(c2.Path, c.Path...)
|
||||||
|
c2.Path = append(c2.Path, name)
|
||||||
|
c.Children[name] = c2
|
||||||
|
return c2
|
||||||
|
}
|
122
mcfg/mcfg_test.go
Normal file
122
mcfg/mcfg_test.go
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
package mcfg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
. "testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHook(t *T) {
|
||||||
|
{ // test Then
|
||||||
|
aCh := make(chan bool)
|
||||||
|
bCh := make(chan bool)
|
||||||
|
h := Nop()
|
||||||
|
h.Then(func(context.Context) error {
|
||||||
|
aCh <- true
|
||||||
|
<-aCh
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
h.Then(func(context.Context) error {
|
||||||
|
bCh <- true
|
||||||
|
<-bCh
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
errCh := make(chan error)
|
||||||
|
go func() {
|
||||||
|
errCh <- h(nil)
|
||||||
|
}()
|
||||||
|
|
||||||
|
assert.True(t, <-aCh)
|
||||||
|
// make sure bCh isn't being written to till aCh is closed
|
||||||
|
select {
|
||||||
|
case <-bCh:
|
||||||
|
assert.Fail(t, "bCh shouldn't be written to yet")
|
||||||
|
case <-time.After(250 * time.Millisecond):
|
||||||
|
close(aCh)
|
||||||
|
}
|
||||||
|
assert.True(t, <-bCh)
|
||||||
|
// make sure errCh isn't being written to till bCh is closed
|
||||||
|
select {
|
||||||
|
case <-errCh:
|
||||||
|
assert.Fail(t, "errCh shouldn't be written to yet")
|
||||||
|
case <-time.After(250 * time.Millisecond):
|
||||||
|
close(bCh)
|
||||||
|
}
|
||||||
|
assert.Nil(t, <-errCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
{ // test Also
|
||||||
|
aCh := make(chan bool)
|
||||||
|
bCh := make(chan bool)
|
||||||
|
h := Nop()
|
||||||
|
h.Also(func(context.Context) error {
|
||||||
|
aCh <- true
|
||||||
|
<-aCh
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
h.Also(func(context.Context) error {
|
||||||
|
bCh <- true
|
||||||
|
<-bCh
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
errCh := make(chan error)
|
||||||
|
go func() {
|
||||||
|
errCh <- h(nil)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// both channels should get written to, then closed, then errCh should
|
||||||
|
// get written to
|
||||||
|
assert.True(t, <-aCh)
|
||||||
|
assert.True(t, <-bCh)
|
||||||
|
// make sure errCh isn't being written to till both channels are written
|
||||||
|
select {
|
||||||
|
case <-errCh:
|
||||||
|
assert.Fail(t, "errCh shouldn't be written to yet")
|
||||||
|
case <-time.After(250 * time.Millisecond):
|
||||||
|
close(aCh)
|
||||||
|
close(bCh)
|
||||||
|
}
|
||||||
|
assert.Nil(t, <-errCh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPopulateParams(t *T) {
|
||||||
|
{
|
||||||
|
cfg := New()
|
||||||
|
a := cfg.ParamInt("a", 0, "")
|
||||||
|
cfgChild := cfg.Child("foo")
|
||||||
|
b := cfgChild.ParamInt("b", 0, "")
|
||||||
|
c := cfgChild.ParamInt("c", 0, "")
|
||||||
|
|
||||||
|
err := cfg.populateParams(SourceCLI{
|
||||||
|
Args: []string{"--a=1", "--foo-b=2"},
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, *a)
|
||||||
|
assert.Equal(t, 2, *b)
|
||||||
|
assert.Equal(t, 0, *c)
|
||||||
|
}
|
||||||
|
|
||||||
|
{ // test that required params are enforced
|
||||||
|
cfg := New()
|
||||||
|
a := cfg.ParamInt("a", 0, "")
|
||||||
|
cfgChild := cfg.Child("foo")
|
||||||
|
b := cfgChild.ParamInt("b", 0, "")
|
||||||
|
c := cfgChild.ParamRequiredInt("c", "")
|
||||||
|
|
||||||
|
err := cfg.populateParams(SourceCLI{
|
||||||
|
Args: []string{"--a=1", "--foo-b=2"},
|
||||||
|
})
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
err = cfg.populateParams(SourceCLI{
|
||||||
|
Args: []string{"--a=1", "--foo-b=2", "--foo-c=3"},
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, *a)
|
||||||
|
assert.Equal(t, 2, *b)
|
||||||
|
assert.Equal(t, 3, *c)
|
||||||
|
}
|
||||||
|
}
|
115
mcfg/param.go
Normal file
115
mcfg/param.go
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
package mcfg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/mediocregopher/mediocre-go-lib/mtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Param is a configuration parameter which can be added to a Cfg. The Param
|
||||||
|
// will exist relative to a Cfg's Path. For example, a Param with name "addr"
|
||||||
|
// under a Cfg with Path of []string{"foo","bar"} will be setabble on the CLI
|
||||||
|
// via "--foo-bar-addr". Other configuration Sources may treat the path/name
|
||||||
|
// differently, however.
|
||||||
|
type Param struct {
|
||||||
|
// How the parameter will be identified within a Cfg instance
|
||||||
|
Name string
|
||||||
|
// A helpful description of how a parameter is expected to be used
|
||||||
|
Usage string
|
||||||
|
|
||||||
|
// If the parameter's value is expected to be read as a go string. This is
|
||||||
|
// used for configuration sources like CLI which will automatically escape
|
||||||
|
// the parameter's value with double-quotes
|
||||||
|
IsString bool
|
||||||
|
|
||||||
|
// If the parameter's value is expected to be a boolean. This is used for
|
||||||
|
// configuration sources like CLI which treat boolean parameters (aka flags)
|
||||||
|
// differently.
|
||||||
|
IsBool bool
|
||||||
|
|
||||||
|
// If true then the parameter _must_ be set by at least one configuration
|
||||||
|
// source
|
||||||
|
Required bool
|
||||||
|
|
||||||
|
// The pointer/interface into which the configuration value will be
|
||||||
|
// json.Unmarshal'd. The value being pointed to also determines the default
|
||||||
|
// value of the parameter.
|
||||||
|
Into interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParamAdd adds the given Param to the Cfg. It will panic if a Param of the
|
||||||
|
// same Name already exists in the Cfg.
|
||||||
|
func (c *Cfg) ParamAdd(p Param) {
|
||||||
|
if _, ok := c.Params[p.Name]; ok {
|
||||||
|
panic(fmt.Sprintf("Cfg.Path:%#v name:%q already exists", c.Path, p.Name))
|
||||||
|
}
|
||||||
|
c.Params[p.Name] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParamInt64 returns an *int64 which will be populated once the Cfg is run
|
||||||
|
func (c *Cfg) ParamInt64(name string, defaultVal int64, usage string) *int64 {
|
||||||
|
i := defaultVal
|
||||||
|
c.ParamAdd(Param{Name: name, Usage: usage, Into: &i})
|
||||||
|
return &i
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParamRequiredInt64 returns an *int64 which will be populated once the Cfg is
|
||||||
|
// run, and which must be supplied by a configuration Source
|
||||||
|
func (c *Cfg) ParamRequiredInt64(name string, usage string) *int64 {
|
||||||
|
var i int64
|
||||||
|
c.ParamAdd(Param{Name: name, Required: true, Usage: usage, Into: &i})
|
||||||
|
return &i
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParamInt returns an *int which will be populated once the Cfg is run
|
||||||
|
func (c *Cfg) ParamInt(name string, defaultVal int, usage string) *int {
|
||||||
|
i := defaultVal
|
||||||
|
c.ParamAdd(Param{Name: name, Usage: usage, Into: &i})
|
||||||
|
return &i
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParamRequiredInt returns an *int which will be populated once the Cfg is run,
|
||||||
|
// and which must be supplied by a configuration Source
|
||||||
|
func (c *Cfg) ParamRequiredInt(name string, usage string) *int {
|
||||||
|
var i int
|
||||||
|
c.ParamAdd(Param{Name: name, Required: true, Usage: usage, Into: &i})
|
||||||
|
return &i
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParamString returns a *string which will be populated once the Cfg is run
|
||||||
|
func (c *Cfg) ParamString(name, defaultVal, usage string) *string {
|
||||||
|
s := defaultVal
|
||||||
|
c.ParamAdd(Param{Name: name, Usage: usage, IsString: true, Into: &s})
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParamRequiredString returns a *string which will be populated once the Cfg is
|
||||||
|
// run, and which must be supplied by a configuration Source
|
||||||
|
func (c *Cfg) ParamRequiredString(name, usage string) *string {
|
||||||
|
var s string
|
||||||
|
c.ParamAdd(Param{Name: name, Required: true, Usage: usage, IsString: true, Into: &s})
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParamBool returns a *bool which will be populated once the Cfg is run, and
|
||||||
|
// which defaults to false if unconfigured
|
||||||
|
func (c *Cfg) ParamBool(name, usage string) *bool {
|
||||||
|
var b bool
|
||||||
|
c.ParamAdd(Param{Name: name, Usage: usage, IsBool: true, Into: &b})
|
||||||
|
return &b
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParamTS returns an *mtime.TS which will be populated once the Cfg is run
|
||||||
|
func (c *Cfg) ParamTS(name string, defaultVal mtime.TS, usage string) *mtime.TS {
|
||||||
|
t := defaultVal
|
||||||
|
c.ParamAdd(Param{Name: name, Usage: usage, Into: &t})
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParamDuration returns an *mtime.Duration which will be populated once the Cfg
|
||||||
|
// is run
|
||||||
|
func (c *Cfg) ParamDuration(name string, defaultVal mtime.Duration, usage string) *mtime.Duration {
|
||||||
|
d := defaultVal
|
||||||
|
c.ParamAdd(Param{Name: name, Usage: usage, IsString: true, Into: &d})
|
||||||
|
return &d
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user