mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add YAML support to server (#6934)
This commit is contained in:
@@ -14,15 +14,10 @@ import (
|
||||
|
||||
// Group describes a hierarchy of groups that an option or command belongs to.
|
||||
type Group struct {
|
||||
Parent *Group `json:"parent,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Children []Group `json:"children,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
func (g *Group) AddChild(child Group) {
|
||||
child.Parent = g
|
||||
g.Children = append(g.Children, child)
|
||||
Parent *Group `json:"parent,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
YAML string `json:"yaml,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// Ancestry returns the group and all of its parents, in order.
|
||||
|
||||
+35
-11
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/spf13/pflag"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/xerrors"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Cmd describes an executable command.
|
||||
@@ -76,10 +77,8 @@ func (c *Cmd) PrepareAll() error {
|
||||
}
|
||||
var merr error
|
||||
|
||||
slices.SortFunc(c.Options, func(a, b Option) bool {
|
||||
return a.Flag < b.Flag
|
||||
})
|
||||
for _, opt := range c.Options {
|
||||
for i := range c.Options {
|
||||
opt := &c.Options[i]
|
||||
if opt.Name == "" {
|
||||
switch {
|
||||
case opt.Flag != "":
|
||||
@@ -102,6 +101,10 @@ func (c *Cmd) PrepareAll() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortFunc(c.Options, func(a, b Option) bool {
|
||||
return a.Name < b.Name
|
||||
})
|
||||
slices.SortFunc(c.Children, func(a, b *Cmd) bool {
|
||||
return a.Name() < b.Name()
|
||||
})
|
||||
@@ -262,17 +265,38 @@ func (inv *Invocation) run(state *runState) error {
|
||||
parsedArgs = inv.parsedFlags.Args()
|
||||
}
|
||||
|
||||
// Set defaults for flags that weren't set by the user.
|
||||
skipDefaults := make(map[int]struct{}, len(inv.Command.Options))
|
||||
// Set value sources for flags.
|
||||
for i, opt := range inv.Command.Options {
|
||||
if fl := inv.parsedFlags.Lookup(opt.Flag); fl != nil && fl.Changed {
|
||||
skipDefaults[i] = struct{}{}
|
||||
}
|
||||
if opt.envChanged {
|
||||
skipDefaults[i] = struct{}{}
|
||||
inv.Command.Options[i].ValueSource = ValueSourceFlag
|
||||
}
|
||||
}
|
||||
err = inv.Command.Options.SetDefaults(skipDefaults)
|
||||
|
||||
// Read YAML configs, if any.
|
||||
for _, opt := range inv.Command.Options {
|
||||
path, ok := opt.Value.(*YAMLConfigPath)
|
||||
if !ok || path.String() == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
byt, err := os.ReadFile(path.String())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("reading yaml: %w", err)
|
||||
}
|
||||
|
||||
var n yaml.Node
|
||||
err = yaml.Unmarshal(byt, &n)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("decoding yaml: %w", err)
|
||||
}
|
||||
|
||||
err = inv.Command.Options.UnmarshalYAML(&n)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("applying yaml: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = inv.Command.Options.SetDefaults()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("setting defaults: %w", err)
|
||||
}
|
||||
|
||||
+73
-34
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -555,42 +556,80 @@ func TestCommand_EmptySlice(t *testing.T) {
|
||||
func TestCommand_DefaultsOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var got string
|
||||
cmd := &clibase.Cmd{
|
||||
Options: clibase.OptionSet{
|
||||
{
|
||||
Name: "url",
|
||||
Flag: "url",
|
||||
Default: "def.com",
|
||||
Env: "URL",
|
||||
Value: clibase.StringOf(&got),
|
||||
},
|
||||
},
|
||||
Handler: (func(i *clibase.Invocation) error {
|
||||
_, _ = fmt.Fprintf(i.Stdout, "%s", got)
|
||||
return nil
|
||||
}),
|
||||
test := func(name string, want string, fn func(t *testing.T, inv *clibase.Invocation)) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
got string
|
||||
config clibase.YAMLConfigPath
|
||||
)
|
||||
cmd := &clibase.Cmd{
|
||||
Options: clibase.OptionSet{
|
||||
{
|
||||
Name: "url",
|
||||
Flag: "url",
|
||||
Default: "def.com",
|
||||
Env: "URL",
|
||||
Value: clibase.StringOf(&got),
|
||||
YAML: "url",
|
||||
},
|
||||
{
|
||||
Name: "config",
|
||||
Flag: "config",
|
||||
Default: "",
|
||||
Value: &config,
|
||||
},
|
||||
},
|
||||
Handler: (func(i *clibase.Invocation) error {
|
||||
_, _ = fmt.Fprintf(i.Stdout, "%s", got)
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
|
||||
inv := cmd.Invoke()
|
||||
stdio := fakeIO(inv)
|
||||
fn(t, inv)
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, stdio.Stdout.String())
|
||||
})
|
||||
}
|
||||
|
||||
// Base case
|
||||
inv := cmd.Invoke()
|
||||
stdio := fakeIO(inv)
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "def.com", stdio.Stdout.String())
|
||||
test("DefaultOverNothing", "def.com", func(t *testing.T, inv *clibase.Invocation) {})
|
||||
|
||||
// Flag overrides
|
||||
inv = cmd.Invoke("--url", "good.com")
|
||||
stdio = fakeIO(inv)
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "good.com", stdio.Stdout.String())
|
||||
test("FlagOverDefault", "good.com", func(t *testing.T, inv *clibase.Invocation) {
|
||||
inv.Args = []string{"--url", "good.com"}
|
||||
})
|
||||
|
||||
// Env overrides
|
||||
inv = cmd.Invoke()
|
||||
inv.Environ.Set("URL", "good.com")
|
||||
stdio = fakeIO(inv)
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "good.com", stdio.Stdout.String())
|
||||
test("EnvOverDefault", "good.com", func(t *testing.T, inv *clibase.Invocation) {
|
||||
inv.Environ.Set("URL", "good.com")
|
||||
})
|
||||
|
||||
test("FlagOverEnv", "good.com", func(t *testing.T, inv *clibase.Invocation) {
|
||||
inv.Environ.Set("URL", "bad.com")
|
||||
inv.Args = []string{"--url", "good.com"}
|
||||
})
|
||||
|
||||
test("FlagOverYAML", "good.com", func(t *testing.T, inv *clibase.Invocation) {
|
||||
fi, err := os.CreateTemp(t.TempDir(), "config.yaml")
|
||||
require.NoError(t, err)
|
||||
defer fi.Close()
|
||||
|
||||
_, err = fi.WriteString("url: bad.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
inv.Args = []string{"--config", fi.Name(), "--url", "good.com"}
|
||||
})
|
||||
|
||||
test("YAMLOverDefault", "good.com", func(t *testing.T, inv *clibase.Invocation) {
|
||||
fi, err := os.CreateTemp(t.TempDir(), "config.yaml")
|
||||
require.NoError(t, err)
|
||||
defer fi.Close()
|
||||
|
||||
_, err = fi.WriteString("url: good.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
inv.Args = []string{"--config", fi.Name()}
|
||||
})
|
||||
}
|
||||
|
||||
+41
-9
@@ -2,12 +2,23 @@ package clibase
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/spf13/pflag"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
type ValueSource string
|
||||
|
||||
const (
|
||||
ValueSourceNone ValueSource = ""
|
||||
ValueSourceFlag ValueSource = "flag"
|
||||
ValueSourceEnv ValueSource = "env"
|
||||
ValueSourceYAML ValueSource = "yaml"
|
||||
ValueSourceDefault ValueSource = "default"
|
||||
)
|
||||
|
||||
// Option is a configuration option for a CLI application.
|
||||
type Option struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
@@ -47,7 +58,18 @@ type Option struct {
|
||||
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
|
||||
envChanged bool
|
||||
ValueSource ValueSource `json:"value_source,omitempty"`
|
||||
}
|
||||
|
||||
func (o Option) YAMLPath() string {
|
||||
if o.YAML == "" {
|
||||
return ""
|
||||
}
|
||||
var gs []string
|
||||
for _, g := range o.Group.Ancestry() {
|
||||
gs = append(gs, g.YAML)
|
||||
}
|
||||
return strings.Join(append(gs, o.YAML), ".")
|
||||
}
|
||||
|
||||
// OptionSet is a group of options that can be applied to a command.
|
||||
@@ -135,8 +157,7 @@ func (s *OptionSet) ParseEnv(vs []EnvVar) error {
|
||||
continue
|
||||
}
|
||||
|
||||
opt.envChanged = true
|
||||
(*s)[i] = opt
|
||||
(*s)[i].ValueSource = ValueSourceEnv
|
||||
if err := opt.Value.Set(envVal); err != nil {
|
||||
merr = multierror.Append(
|
||||
merr, xerrors.Errorf("parse %q: %w", opt.Name, err),
|
||||
@@ -148,8 +169,8 @@ func (s *OptionSet) ParseEnv(vs []EnvVar) error {
|
||||
}
|
||||
|
||||
// SetDefaults sets the default values for each Option, skipping values
|
||||
// that have already been set as indicated by the skip map.
|
||||
func (s *OptionSet) SetDefaults(skip map[int]struct{}) error {
|
||||
// that already have a value source.
|
||||
func (s *OptionSet) SetDefaults() error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -158,10 +179,8 @@ func (s *OptionSet) SetDefaults(skip map[int]struct{}) error {
|
||||
|
||||
for i, opt := range *s {
|
||||
// Skip values that may have already been set by the user.
|
||||
if len(skip) > 0 {
|
||||
if _, ok := skip[i]; ok {
|
||||
continue
|
||||
}
|
||||
if opt.ValueSource != ValueSourceNone {
|
||||
continue
|
||||
}
|
||||
|
||||
if opt.Default == "" {
|
||||
@@ -178,6 +197,7 @@ func (s *OptionSet) SetDefaults(skip map[int]struct{}) error {
|
||||
)
|
||||
continue
|
||||
}
|
||||
(*s)[i].ValueSource = ValueSourceDefault
|
||||
if err := opt.Value.Set(opt.Default); err != nil {
|
||||
merr = multierror.Append(
|
||||
merr, xerrors.Errorf("parse %q: %w", opt.Name, err),
|
||||
@@ -186,3 +206,15 @@ func (s *OptionSet) SetDefaults(skip map[int]struct{}) error {
|
||||
}
|
||||
return merr.ErrorOrNil()
|
||||
}
|
||||
|
||||
// ByName returns the Option with the given name, or nil if no such option
|
||||
// exists.
|
||||
func (s *OptionSet) ByName(name string) *Option {
|
||||
for i := range *s {
|
||||
opt := &(*s)[i]
|
||||
if opt.Name == name {
|
||||
return opt
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestOptionSet_ParseFlags(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
err := os.SetDefaults(nil)
|
||||
err := os.SetDefaults()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.FlagSet().Parse([]string{"--name", "foo", "--name", "bar"})
|
||||
@@ -111,7 +111,7 @@ func TestOptionSet_ParseEnv(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
err := os.SetDefaults(nil)
|
||||
err := os.SetDefaults()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.ParseEnv(clibase.ParseEnviron([]string{"CODER_WORKSPACE_NAME="}, "CODER_"))
|
||||
@@ -133,7 +133,7 @@ func TestOptionSet_ParseEnv(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
err := os.SetDefaults(nil)
|
||||
err := os.SetDefaults()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.ParseEnv([]clibase.EnvVar{
|
||||
@@ -157,7 +157,7 @@ func TestOptionSet_ParseEnv(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
err := os.SetDefaults(nil)
|
||||
err := os.SetDefaults()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.ParseEnv([]clibase.EnvVar{
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -194,6 +195,17 @@ func (Duration) Type() string {
|
||||
return "duration"
|
||||
}
|
||||
|
||||
func (d *Duration) MarshalYAML() (interface{}, error) {
|
||||
return yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: d.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Duration) UnmarshalYAML(n *yaml.Node) error {
|
||||
return d.Set(n.Value)
|
||||
}
|
||||
|
||||
type URL url.URL
|
||||
|
||||
func URLOf(u *url.URL) *URL {
|
||||
@@ -214,6 +226,17 @@ func (u *URL) String() string {
|
||||
return uu.String()
|
||||
}
|
||||
|
||||
func (u *URL) MarshalYAML() (interface{}, error) {
|
||||
return yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: u.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (u *URL) UnmarshalYAML(n *yaml.Node) error {
|
||||
return u.Set(n.Value)
|
||||
}
|
||||
|
||||
func (u *URL) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(u.String())
|
||||
}
|
||||
@@ -277,6 +300,17 @@ func (hp *HostPort) UnmarshalJSON(b []byte) error {
|
||||
return hp.Set(s)
|
||||
}
|
||||
|
||||
func (hp *HostPort) MarshalYAML() (interface{}, error) {
|
||||
return yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: hp.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (hp *HostPort) UnmarshalYAML(n *yaml.Node) error {
|
||||
return hp.Set(n.Value)
|
||||
}
|
||||
|
||||
func (*HostPort) Type() string {
|
||||
return "host:port"
|
||||
}
|
||||
@@ -317,6 +351,13 @@ func (s *Struct[T]) MarshalYAML() (interface{}, error) {
|
||||
}
|
||||
|
||||
func (s *Struct[T]) UnmarshalYAML(n *yaml.Node) error {
|
||||
// HACK: for compatibility with flags, we use nil slices instead of empty
|
||||
// slices. In most cases, nil slices and empty slices are treated
|
||||
// the same, so this behavior may be removed at some point.
|
||||
if typ := reflect.TypeOf(s.Value); typ.Kind() == reflect.Slice && len(n.Content) == 0 {
|
||||
reflect.ValueOf(&s.Value).Elem().Set(reflect.Zero(typ))
|
||||
return nil
|
||||
}
|
||||
return n.Decode(&s.Value)
|
||||
}
|
||||
|
||||
@@ -382,3 +423,22 @@ func (e *Enum) Type() string {
|
||||
func (e *Enum) String() string {
|
||||
return *e.Value
|
||||
}
|
||||
|
||||
var _ pflag.Value = (*YAMLConfigPath)(nil)
|
||||
|
||||
// YAMLConfigPath is a special value type that encodes a path to a YAML
|
||||
// configuration file where options are read from.
|
||||
type YAMLConfigPath string
|
||||
|
||||
func (p *YAMLConfigPath) Set(v string) error {
|
||||
*p = YAMLConfigPath(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *YAMLConfigPath) String() string {
|
||||
return string(*p)
|
||||
}
|
||||
|
||||
func (*YAMLConfigPath) Type() string {
|
||||
return "yaml-config-path"
|
||||
}
|
||||
|
||||
+202
-12
@@ -1,12 +1,20 @@
|
||||
package clibase
|
||||
|
||||
import (
|
||||
"github.com/iancoleman/strcase"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mitchellh/go-wordwrap"
|
||||
"golang.org/x/xerrors"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
_ yaml.Marshaler = new(OptionSet)
|
||||
_ yaml.Unmarshaler = new(OptionSet)
|
||||
)
|
||||
|
||||
// deepMapNode returns the mapping node at the given path,
|
||||
// creating it if it doesn't exist.
|
||||
func deepMapNode(n *yaml.Node, path []string, headComment string) *yaml.Node {
|
||||
@@ -36,27 +44,43 @@ func deepMapNode(n *yaml.Node, path []string, headComment string) *yaml.Node {
|
||||
return deepMapNode(&valueNode, path[1:], headComment)
|
||||
}
|
||||
|
||||
// ToYAML converts the option set to a YAML node, that can be
|
||||
// MarshalYAML converts the option set to a YAML node, that can be
|
||||
// converted into bytes via yaml.Marshal.
|
||||
//
|
||||
// The node is returned to enable post-processing higher up in
|
||||
// the stack.
|
||||
func (s OptionSet) ToYAML() (*yaml.Node, error) {
|
||||
//
|
||||
// It is isomorphic with FromYAML.
|
||||
func (s *OptionSet) MarshalYAML() (any, error) {
|
||||
root := yaml.Node{
|
||||
Kind: yaml.MappingNode,
|
||||
}
|
||||
|
||||
for _, opt := range s {
|
||||
for _, opt := range *s {
|
||||
if opt.YAML == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
defValue := opt.Default
|
||||
if defValue == "" {
|
||||
defValue = "<unset>"
|
||||
}
|
||||
comment := wordwrap.WrapString(
|
||||
fmt.Sprintf("%s\n(default: %s, type: %s)", opt.Description, defValue, opt.Value.Type()),
|
||||
80,
|
||||
)
|
||||
nameNode := yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: opt.YAML,
|
||||
HeadComment: wordwrap.WrapString(opt.Description, 80),
|
||||
HeadComment: comment,
|
||||
}
|
||||
var valueNode yaml.Node
|
||||
if m, ok := opt.Value.(yaml.Marshaler); ok {
|
||||
if opt.Value == nil {
|
||||
valueNode = yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: "null",
|
||||
}
|
||||
} else if m, ok := opt.Value.(yaml.Marshaler); ok {
|
||||
v, err := m.MarshalYAML()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf(
|
||||
@@ -71,21 +95,43 @@ func (s OptionSet) ToYAML() (*yaml.Node, error) {
|
||||
)
|
||||
}
|
||||
} else {
|
||||
valueNode = yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: opt.Value.String(),
|
||||
// The all-other types case.
|
||||
//
|
||||
// A bit of a hack, we marshal and then unmarshal to get
|
||||
// the underlying node.
|
||||
byt, err := yaml.Marshal(opt.Value)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf(
|
||||
"marshal %q: %w", opt.Name, err,
|
||||
)
|
||||
}
|
||||
|
||||
var docNode yaml.Node
|
||||
err = yaml.Unmarshal(byt, &docNode)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf(
|
||||
"unmarshal %q: %w", opt.Name, err,
|
||||
)
|
||||
}
|
||||
if len(docNode.Content) != 1 {
|
||||
return nil, xerrors.Errorf(
|
||||
"unmarshal %q: expected one node, got %d",
|
||||
opt.Name, len(docNode.Content),
|
||||
)
|
||||
}
|
||||
|
||||
valueNode = *docNode.Content[0]
|
||||
}
|
||||
var group []string
|
||||
for _, g := range opt.Group.Ancestry() {
|
||||
if g.Name == "" {
|
||||
if g.YAML == "" {
|
||||
return nil, xerrors.Errorf(
|
||||
"group name is empty for %q, groups: %+v",
|
||||
"group yaml name is empty for %q, groups: %+v",
|
||||
opt.Name,
|
||||
opt.Group,
|
||||
)
|
||||
}
|
||||
group = append(group, strcase.ToLowerCamel(g.Name))
|
||||
group = append(group, g.YAML)
|
||||
}
|
||||
var groupDesc string
|
||||
if opt.Group != nil {
|
||||
@@ -103,3 +149,147 @@ func (s OptionSet) ToYAML() (*yaml.Node, error) {
|
||||
}
|
||||
return &root, nil
|
||||
}
|
||||
|
||||
// mapYAMLNodes converts parent into a map with keys of form "group.subgroup.option"
|
||||
// and values as the corresponding YAML nodes.
|
||||
func mapYAMLNodes(parent *yaml.Node) (map[string]*yaml.Node, error) {
|
||||
if parent.Kind != yaml.MappingNode {
|
||||
return nil, xerrors.Errorf("expected mapping node, got type %v", parent.Kind)
|
||||
}
|
||||
if len(parent.Content)%2 != 0 {
|
||||
return nil, xerrors.Errorf("expected an even number of k/v pairs, got %d", len(parent.Content))
|
||||
}
|
||||
var (
|
||||
key string
|
||||
m = make(map[string]*yaml.Node, len(parent.Content)/2)
|
||||
merr error
|
||||
)
|
||||
for i, child := range parent.Content {
|
||||
if i%2 == 0 {
|
||||
if child.Kind != yaml.ScalarNode {
|
||||
// We immediately because the rest of the code is bound to fail
|
||||
// if we don't know to expect a key or a value.
|
||||
return nil, xerrors.Errorf("expected scalar node for key, got type %v", child.Kind)
|
||||
}
|
||||
key = child.Value
|
||||
continue
|
||||
}
|
||||
|
||||
// We don't know if this is a grouped simple option or complex option,
|
||||
// so we store both "key" and "group.key". Since we're storing pointers,
|
||||
// the additional memory is of little concern.
|
||||
m[key] = child
|
||||
if child.Kind != yaml.MappingNode {
|
||||
continue
|
||||
}
|
||||
|
||||
sub, err := mapYAMLNodes(child)
|
||||
if err != nil {
|
||||
merr = errors.Join(merr, xerrors.Errorf("mapping node %q: %w", key, err))
|
||||
continue
|
||||
}
|
||||
for k, v := range sub {
|
||||
m[key+"."+k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (o *Option) setFromYAMLNode(n *yaml.Node) error {
|
||||
o.ValueSource = ValueSourceYAML
|
||||
if um, ok := o.Value.(yaml.Unmarshaler); ok {
|
||||
return um.UnmarshalYAML(n)
|
||||
}
|
||||
|
||||
switch n.Kind {
|
||||
case yaml.ScalarNode:
|
||||
return o.Value.Set(n.Value)
|
||||
case yaml.SequenceNode:
|
||||
// We treat empty values as nil for consistency with other option
|
||||
// mechanisms.
|
||||
if len(n.Content) == 0 {
|
||||
o.Value = nil
|
||||
return nil
|
||||
}
|
||||
return n.Decode(o.Value)
|
||||
case yaml.MappingNode:
|
||||
return xerrors.Errorf("mapping nodes must implement yaml.Unmarshaler")
|
||||
default:
|
||||
return xerrors.Errorf("unexpected node kind %v", n.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// UnmarshalYAML converts the given YAML node into the option set.
|
||||
// It is isomorphic with ToYAML.
|
||||
func (s *OptionSet) UnmarshalYAML(rootNode *yaml.Node) error {
|
||||
// The rootNode will be a DocumentNode if it's read from a file. We do
|
||||
// not support multiple documents in a single file.
|
||||
if rootNode.Kind == yaml.DocumentNode {
|
||||
if len(rootNode.Content) != 1 {
|
||||
return xerrors.Errorf("expected one node in document, got %d", len(rootNode.Content))
|
||||
}
|
||||
rootNode = rootNode.Content[0]
|
||||
}
|
||||
|
||||
yamlNodes, err := mapYAMLNodes(rootNode)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("mapping nodes: %w", err)
|
||||
}
|
||||
|
||||
matchedNodes := make(map[string]*yaml.Node, len(yamlNodes))
|
||||
|
||||
var merr error
|
||||
for i := range *s {
|
||||
opt := &(*s)[i]
|
||||
if opt.YAML == "" {
|
||||
continue
|
||||
}
|
||||
var group []string
|
||||
for _, g := range opt.Group.Ancestry() {
|
||||
if g.YAML == "" {
|
||||
return xerrors.Errorf(
|
||||
"group yaml name is empty for %q, groups: %+v",
|
||||
opt.Name,
|
||||
opt.Group,
|
||||
)
|
||||
}
|
||||
group = append(group, g.YAML)
|
||||
delete(yamlNodes, strings.Join(group, "."))
|
||||
}
|
||||
|
||||
key := strings.Join(append(group, opt.YAML), ".")
|
||||
node, ok := yamlNodes[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
matchedNodes[key] = node
|
||||
if opt.ValueSource != ValueSourceNone {
|
||||
continue
|
||||
}
|
||||
if err := opt.setFromYAMLNode(node); err != nil {
|
||||
merr = errors.Join(merr, xerrors.Errorf("setting %q: %w", opt.YAML, err))
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all matched nodes and their descendants from yamlNodes so we
|
||||
// can accurately report unknown options.
|
||||
for k := range yamlNodes {
|
||||
var key string
|
||||
for _, part := range strings.Split(k, ".") {
|
||||
if key != "" {
|
||||
key += "."
|
||||
}
|
||||
key += part
|
||||
if _, ok := matchedNodes[key]; ok {
|
||||
delete(yamlNodes, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
for k := range yamlNodes {
|
||||
merr = errors.Join(merr, xerrors.Errorf("unknown option %q", k))
|
||||
}
|
||||
|
||||
return merr
|
||||
}
|
||||
|
||||
+151
-6
@@ -3,13 +3,15 @@ package clibase_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/exp/slices"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/coder/coder/cli/clibase"
|
||||
)
|
||||
|
||||
func TestOption_ToYAML(t *testing.T) {
|
||||
func TestOptionSet_YAML(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("RequireKey", func(t *testing.T) {
|
||||
@@ -23,9 +25,9 @@ func TestOption_ToYAML(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
node, err := os.ToYAML()
|
||||
node, err := os.MarshalYAML()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, node.Content, 0)
|
||||
require.Len(t, node.(*yaml.Node).Content, 0)
|
||||
})
|
||||
|
||||
t.Run("SimpleString", func(t *testing.T) {
|
||||
@@ -39,15 +41,15 @@ func TestOption_ToYAML(t *testing.T) {
|
||||
Value: &workspaceName,
|
||||
Default: "billie",
|
||||
Description: "The workspace's name.",
|
||||
Group: &clibase.Group{Name: "Names"},
|
||||
Group: &clibase.Group{YAML: "names"},
|
||||
YAML: "workspaceName",
|
||||
},
|
||||
}
|
||||
|
||||
err := os.SetDefaults(nil)
|
||||
err := os.SetDefaults()
|
||||
require.NoError(t, err)
|
||||
|
||||
n, err := os.ToYAML()
|
||||
n, err := os.MarshalYAML()
|
||||
require.NoError(t, err)
|
||||
// Visually inspect for now.
|
||||
byt, err := yaml.Marshal(n)
|
||||
@@ -55,3 +57,146 @@ func TestOption_ToYAML(t *testing.T) {
|
||||
t.Logf("Raw YAML:\n%s", string(byt))
|
||||
})
|
||||
}
|
||||
|
||||
func TestOptionSet_YAMLUnknownOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
os := clibase.OptionSet{
|
||||
{
|
||||
Name: "Workspace Name",
|
||||
Default: "billie",
|
||||
Description: "The workspace's name.",
|
||||
YAML: "workspaceName",
|
||||
Value: new(clibase.String),
|
||||
},
|
||||
}
|
||||
|
||||
const yamlDoc = `something: else`
|
||||
err := yaml.Unmarshal([]byte(yamlDoc), &os)
|
||||
require.Error(t, err)
|
||||
require.Empty(t, os[0].Value.String())
|
||||
|
||||
os[0].YAML = "something"
|
||||
|
||||
err = yaml.Unmarshal([]byte(yamlDoc), &os)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "else", os[0].Value.String())
|
||||
}
|
||||
|
||||
// TestOptionSet_YAMLIsomorphism tests that the YAML representations of an
|
||||
// OptionSet converts to the same OptionSet when read back in.
|
||||
func TestOptionSet_YAMLIsomorphism(t *testing.T) {
|
||||
t.Parallel()
|
||||
// This is used to form a generic.
|
||||
//nolint:unused
|
||||
type kid struct {
|
||||
Name string `yaml:"name"`
|
||||
Age int `yaml:"age"`
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
os clibase.OptionSet
|
||||
zeroValue func() pflag.Value
|
||||
}{
|
||||
{
|
||||
name: "SimpleString",
|
||||
os: clibase.OptionSet{
|
||||
{
|
||||
Name: "Workspace Name",
|
||||
Default: "billie",
|
||||
Description: "The workspace's name.",
|
||||
Group: &clibase.Group{YAML: "names"},
|
||||
YAML: "workspaceName",
|
||||
},
|
||||
},
|
||||
zeroValue: func() pflag.Value {
|
||||
return clibase.StringOf(new(string))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Array",
|
||||
os: clibase.OptionSet{
|
||||
{
|
||||
YAML: "names",
|
||||
Default: "jill,jack,joan",
|
||||
},
|
||||
},
|
||||
zeroValue: func() pflag.Value {
|
||||
return clibase.StringArrayOf(&[]string{})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ComplexObject",
|
||||
os: clibase.OptionSet{
|
||||
{
|
||||
YAML: "kids",
|
||||
Default: `- name: jill
|
||||
age: 12
|
||||
- name: jack
|
||||
age: 13`,
|
||||
},
|
||||
},
|
||||
zeroValue: func() pflag.Value {
|
||||
return &clibase.Struct[[]kid]{}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DeepGroup",
|
||||
os: clibase.OptionSet{
|
||||
{
|
||||
YAML: "names",
|
||||
Default: "jill,jack,joan",
|
||||
Group: &clibase.Group{YAML: "kids", Parent: &clibase.Group{YAML: "family"}},
|
||||
},
|
||||
},
|
||||
zeroValue: func() pflag.Value {
|
||||
return clibase.StringArrayOf(&[]string{})
|
||||
},
|
||||
},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Set initial values.
|
||||
for i := range tc.os {
|
||||
tc.os[i].Value = tc.zeroValue()
|
||||
}
|
||||
err := tc.os.SetDefaults()
|
||||
require.NoError(t, err)
|
||||
|
||||
y, err := tc.os.MarshalYAML()
|
||||
require.NoError(t, err)
|
||||
|
||||
toByt, err := yaml.Marshal(y)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Logf("Raw YAML:\n%s", string(toByt))
|
||||
|
||||
var y2 yaml.Node
|
||||
err = yaml.Unmarshal(toByt, &y2)
|
||||
require.NoError(t, err)
|
||||
|
||||
os2 := slices.Clone(tc.os)
|
||||
for i := range os2 {
|
||||
os2[i].Value = tc.zeroValue()
|
||||
os2[i].ValueSource = clibase.ValueSourceNone
|
||||
}
|
||||
|
||||
// os2 values should be zeroed whereas tc.os should be
|
||||
// set to defaults.
|
||||
// This check makes sure we aren't mixing pointers.
|
||||
require.NotEqual(t, tc.os, os2)
|
||||
err = os2.UnmarshalYAML(&y2)
|
||||
require.NoError(t, err)
|
||||
|
||||
want := tc.os
|
||||
for i := range want {
|
||||
want[i].ValueSource = clibase.ValueSourceYAML
|
||||
}
|
||||
|
||||
require.Equal(t, tc.os, os2)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +226,8 @@ func StartWithWaiter(t *testing.T, inv *clibase.Invocation) *ErrorWaiter {
|
||||
// down Postgres.
|
||||
t.Logf("command %q timed out during test cleanup", inv.Command.FullName())
|
||||
}
|
||||
// Whether or not this fails the test is left to the caller.
|
||||
t.Logf("command %q exited with error: %v", inv.Command.FullName(), err)
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
|
||||
+27
@@ -16,6 +16,7 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
@@ -250,6 +251,26 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) {
|
||||
return nil, merr
|
||||
}
|
||||
|
||||
var debugOptions bool
|
||||
|
||||
// Add a wrapper to every command to enable debugging options.
|
||||
cmd.Walk(func(cmd *clibase.Cmd) {
|
||||
h := cmd.Handler
|
||||
cmd.Handler = func(i *clibase.Invocation) error {
|
||||
if !debugOptions {
|
||||
return h(i)
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(i.Stdout, 0, 0, 4, ' ', 0)
|
||||
_, _ = fmt.Fprintf(tw, "Option\tValue Source\n")
|
||||
for _, opt := range cmd.Options {
|
||||
_, _ = fmt.Fprintf(tw, "%q\t%v\n", opt.Name, opt.ValueSource)
|
||||
}
|
||||
tw.Flush()
|
||||
return nil
|
||||
}
|
||||
})
|
||||
|
||||
if r.agentURL == nil {
|
||||
r.agentURL = new(url.URL)
|
||||
}
|
||||
@@ -269,6 +290,12 @@ func (r *RootCmd) Command(subcommands []*clibase.Cmd) (*clibase.Cmd, error) {
|
||||
Value: clibase.URLOf(r.clientURL),
|
||||
Group: globalGroup,
|
||||
},
|
||||
{
|
||||
Flag: "debug-options",
|
||||
Description: "Print all options, how they're set, then exit.",
|
||||
Value: clibase.BoolOf(&debugOptions),
|
||||
Group: globalGroup,
|
||||
},
|
||||
{
|
||||
Flag: varToken,
|
||||
Env: envSessionToken,
|
||||
|
||||
+33
-27
@@ -107,20 +107,7 @@ ExtractCommandPathsLoop:
|
||||
actual = bytes.ReplaceAll(actual, []byte(k), []byte(v))
|
||||
}
|
||||
|
||||
// Replace any timestamps with a placeholder.
|
||||
actual = timestampRegex.ReplaceAll(actual, []byte("[timestamp]"))
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
|
||||
configDir := config.DefaultDir()
|
||||
actual = bytes.ReplaceAll(actual, []byte(configDir), []byte("~/.config/coderv2"))
|
||||
|
||||
actual = bytes.ReplaceAll(actual, []byte(codersdk.DefaultCacheDir()), []byte("[cache dir]"))
|
||||
|
||||
// The home directory changes depending on the test environment.
|
||||
actual = bytes.ReplaceAll(actual, []byte(homeDir), []byte("~"))
|
||||
|
||||
actual = normalizeGoldenFile(t, actual)
|
||||
goldenPath := filepath.Join("testdata", strings.Replace(tt.name, " ", "_", -1)+".golden")
|
||||
if *updateGoldenFiles {
|
||||
t.Logf("update golden file for: %q: %s", tt.name, goldenPath)
|
||||
@@ -131,19 +118,7 @@ ExtractCommandPathsLoop:
|
||||
expected, err := os.ReadFile(goldenPath)
|
||||
require.NoError(t, err, "read golden file, run \"make update-golden-files\" and commit the changes")
|
||||
|
||||
// Normalize files to tolerate different operating systems.
|
||||
for _, r := range []struct {
|
||||
old string
|
||||
new string
|
||||
}{
|
||||
{"\r\n", "\n"},
|
||||
{`~\.cache\coder`, "~/.cache/coder"},
|
||||
{`C:\Users\RUNNER~1\AppData\Local\Temp`, "/tmp"},
|
||||
{os.TempDir(), "/tmp"},
|
||||
} {
|
||||
expected = bytes.ReplaceAll(expected, []byte(r.old), []byte(r.new))
|
||||
actual = bytes.ReplaceAll(actual, []byte(r.old), []byte(r.new))
|
||||
}
|
||||
expected = normalizeGoldenFile(t, expected)
|
||||
require.Equal(
|
||||
t, string(expected), string(actual),
|
||||
"golden file mismatch: %s, run \"make update-golden-files\", verify and commit the changes",
|
||||
@@ -153,6 +128,37 @@ ExtractCommandPathsLoop:
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeGoldenFiles replaces any strings that are system or timing dependent
|
||||
// with a placeholder so that the golden files can be compared with a simple
|
||||
// equality check.
|
||||
func normalizeGoldenFile(t *testing.T, byt []byte) []byte {
|
||||
// Replace any timestamps with a placeholder.
|
||||
byt = timestampRegex.ReplaceAll(byt, []byte("[timestamp]"))
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
|
||||
configDir := config.DefaultDir()
|
||||
byt = bytes.ReplaceAll(byt, []byte(configDir), []byte("~/.config/coderv2"))
|
||||
|
||||
byt = bytes.ReplaceAll(byt, []byte(codersdk.DefaultCacheDir()), []byte("[cache dir]"))
|
||||
|
||||
// The home directory changes depending on the test environment.
|
||||
byt = bytes.ReplaceAll(byt, []byte(homeDir), []byte("~"))
|
||||
for _, r := range []struct {
|
||||
old string
|
||||
new string
|
||||
}{
|
||||
{"\r\n", "\n"},
|
||||
{`~\.cache\coder`, "~/.cache/coder"},
|
||||
{`C:\Users\RUNNER~1\AppData\Local\Temp`, "/tmp"},
|
||||
{os.TempDir(), "/tmp"},
|
||||
} {
|
||||
byt = bytes.ReplaceAll(byt, []byte(r.old), []byte(r.new))
|
||||
}
|
||||
return byt
|
||||
}
|
||||
|
||||
func extractVisibleCommandPaths(cmdPath []string, cmds []*clibase.Cmd) [][]string {
|
||||
var cmdPaths [][]string
|
||||
for _, c := range cmds {
|
||||
|
||||
+7
-3
@@ -176,12 +176,12 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
defer cancel()
|
||||
|
||||
if cfg.WriteConfig {
|
||||
// TODO: this should output to a file.
|
||||
n, err := opts.ToYAML()
|
||||
n, err := opts.MarshalYAML()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("generate yaml: %w", err)
|
||||
}
|
||||
enc := yaml.NewEncoder(inv.Stderr)
|
||||
enc := yaml.NewEncoder(inv.Stdout)
|
||||
enc.SetIndent(2)
|
||||
err = enc.Encode(n)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("encode yaml: %w", err)
|
||||
@@ -193,6 +193,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.Config != "" {
|
||||
cliui.Warnf(inv.Stderr, "YAML support is experimental and offers no compatibility guarantees.")
|
||||
}
|
||||
|
||||
// Print deprecation warnings.
|
||||
for _, opt := range opts {
|
||||
if opt.UseInstead == nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package cli_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
@@ -12,12 +13,14 @@ import (
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -29,6 +32,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/coder/coder/cli"
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
@@ -1411,6 +1415,80 @@ func TestServer(t *testing.T) {
|
||||
waitFile(t, fi3, testutil.WaitSuperLong)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("YAML", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("WriteThenReadConfig", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
args := []string{
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--log-human", filepath.Join(t.TempDir(), "coder-logging-test-human"),
|
||||
// We use ecdsa here because it's the fastest alternative algorithm.
|
||||
"--ssh-keygen-algorithm", "ecdsa",
|
||||
"--cache-dir", t.TempDir(),
|
||||
}
|
||||
|
||||
// First, we get the base config as set via flags (like users before
|
||||
// migrating).
|
||||
inv, cfg := clitest.New(t,
|
||||
args...,
|
||||
)
|
||||
ptytest.New(t).Attach(inv)
|
||||
inv = inv.WithContext(ctx)
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
gotURL := waitAccessURL(t, cfg)
|
||||
client := codersdk.New(gotURL)
|
||||
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
wantConfig, err := client.DeploymentConfig(ctx)
|
||||
require.NoError(t, err)
|
||||
cancel()
|
||||
w.RequireSuccess()
|
||||
|
||||
// Next, we instruct the same server to display the YAML config
|
||||
// and then save it.
|
||||
inv = inv.WithContext(testutil.Context(t, testutil.WaitMedium))
|
||||
inv.Args = append(args, "--write-config")
|
||||
fi, err := os.OpenFile(testutil.TempFile(t, "", "coder-config-test-*"), os.O_WRONLY|os.O_CREATE, 0o600)
|
||||
require.NoError(t, err)
|
||||
defer fi.Close()
|
||||
var conf bytes.Buffer
|
||||
inv.Stdout = io.MultiWriter(fi, &conf)
|
||||
t.Logf("%+v", inv.Args)
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Reset the context.
|
||||
ctx = testutil.Context(t, testutil.WaitMedium)
|
||||
// Finally, we restart the server with just the config and no flags
|
||||
// and ensure that the live configuration is equivalent.
|
||||
inv, cfg = clitest.New(t, "server", "--config="+fi.Name())
|
||||
w = clitest.StartWithWaiter(t, inv)
|
||||
client = codersdk.New(waitAccessURL(t, cfg))
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
gotConfig, err := client.DeploymentConfig(ctx)
|
||||
require.NoError(t, err, "config:\n%s\nargs: %+v", conf.String(), inv.Args)
|
||||
gotConfig.Options.ByName("Config Path").Value.Set("")
|
||||
// We check the options individually for better error messages.
|
||||
for i := range wantConfig.Options {
|
||||
assert.Equal(
|
||||
t, wantConfig.Options[i],
|
||||
gotConfig.Options[i],
|
||||
"option %q",
|
||||
wantConfig.Options[i].Name,
|
||||
)
|
||||
}
|
||||
w.RequireSuccess()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func generateTLSCertificate(t testing.TB, commonName ...string) (certPath, keyPath string) {
|
||||
@@ -1470,3 +1548,42 @@ func waitAccessURL(t *testing.T, cfg config.Root) *url.URL {
|
||||
|
||||
return accessURL
|
||||
}
|
||||
|
||||
func TestServerYAMLConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var deployValues codersdk.DeploymentValues
|
||||
opts := deployValues.Options()
|
||||
|
||||
err := opts.SetDefaults()
|
||||
require.NoError(t, err)
|
||||
|
||||
n, err := opts.MarshalYAML()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Sanity-check that we can read the config back in.
|
||||
err = opts.UnmarshalYAML(n.(*yaml.Node))
|
||||
require.NoError(t, err)
|
||||
|
||||
var wantBuf bytes.Buffer
|
||||
enc := yaml.NewEncoder(&wantBuf)
|
||||
enc.SetIndent(2)
|
||||
err = enc.Encode(n)
|
||||
require.NoError(t, err)
|
||||
|
||||
wantByt := wantBuf.Bytes()
|
||||
|
||||
goldenPath := filepath.Join("testdata", "server-config.yaml.golden")
|
||||
|
||||
wantByt = normalizeGoldenFile(t, wantByt)
|
||||
if *updateGoldenFiles {
|
||||
require.NoError(t, os.WriteFile(goldenPath, wantByt, 0o600))
|
||||
return
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(goldenPath)
|
||||
require.NoError(t, err)
|
||||
got = normalizeGoldenFile(t, got)
|
||||
|
||||
require.Equal(t, string(wantByt), string(got))
|
||||
}
|
||||
|
||||
Vendored
+3
@@ -47,6 +47,9 @@ Coder v0.0.0-devel — A tool for provisioning self-hosted development environme
|
||||
Global options are applied to all commands. They can be set using environment
|
||||
variables or flags.
|
||||
|
||||
--debug-options bool
|
||||
Print all options, how they're set, then exit.
|
||||
|
||||
--global-config string, $CODER_CONFIG_DIR (default: ~/.config/coderv2)
|
||||
Path to the global `coder` config directory.
|
||||
|
||||
|
||||
+10
@@ -57,6 +57,16 @@ Clients include the coder cli, vs code extension, and the web UI.
|
||||
--ssh-hostname-prefix string, $CODER_SSH_HOSTNAME_PREFIX (default: coder.)
|
||||
The SSH deployment prefix is used in the Host of the ssh config.
|
||||
|
||||
[1mConfig Options[0m
|
||||
Use a YAML configuration file when your server launch become unwieldy.
|
||||
|
||||
-c, --config yaml-config-path, $CODER_CONFIG_PATH
|
||||
Specify a YAML file to load configuration from.
|
||||
|
||||
--write-config bool
|
||||
|
||||
Write out the current server config as YAML to stdout.
|
||||
|
||||
[1mIntrospection / Logging Options[0m
|
||||
--log-human string, $CODER_LOGGING_HUMAN (default: /dev/stderr)
|
||||
Output human-readable logs to a given file.
|
||||
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
networking:
|
||||
# The URL that users will use to access the Coder deployment.
|
||||
# (default: <unset>, type: url)
|
||||
accessURL:
|
||||
# Specifies the wildcard hostname to use for workspace applications in the form
|
||||
# "*.example.com".
|
||||
# (default: <unset>, type: url)
|
||||
wildcardAccessURL:
|
||||
# Specifies whether to redirect requests that do not match the access URL host.
|
||||
# (default: <unset>, type: bool)
|
||||
redirectToAccessURL: false
|
||||
http:
|
||||
# HTTP bind address of the server. Unset to disable the HTTP endpoint.
|
||||
# (default: 127.0.0.1:3000, type: string)
|
||||
httpAddress: 127.0.0.1:3000
|
||||
# The maximum lifetime duration users can specify when creating an API token.
|
||||
# (default: 876600h0m0s, type: duration)
|
||||
maxTokenLifetime: 876600h0m0s
|
||||
# The token expiry duration for browser sessions. Sessions may last longer if they
|
||||
# are actively making requests, but this functionality can be disabled via
|
||||
# --disable-session-expiry-refresh.
|
||||
# (default: 24h0m0s, type: duration)
|
||||
sessionDuration: 24h0m0s
|
||||
# Disable automatic session expiry bumping due to activity. This forces all
|
||||
# sessions to become invalid after the session expiry duration has been reached.
|
||||
# (default: <unset>, type: bool)
|
||||
disableSessionExpiryRefresh: false
|
||||
# Disable password authentication. This is recommended for security purposes in
|
||||
# production deployments that rely on an identity provider. Any user with the
|
||||
# owner role will be able to sign in with their password regardless of this
|
||||
# setting to avoid potential lock out. If you are locked out of your account, you
|
||||
# can use the `coder server create-admin` command to create a new admin user
|
||||
# directly in the database.
|
||||
# (default: <unset>, type: bool)
|
||||
disablePasswordAuth: false
|
||||
# Configure TLS / HTTPS for your Coder deployment. If you're running
|
||||
# Coder behind a TLS-terminating reverse proxy or are accessing Coder over a
|
||||
# secure link, you can safely ignore these settings.
|
||||
tls:
|
||||
# HTTPS bind address of the server.
|
||||
# (default: 127.0.0.1:3443, type: host:port)
|
||||
address: 127.0.0.1:3443
|
||||
# Whether TLS will be enabled.
|
||||
# (default: <unset>, type: bool)
|
||||
enable: false
|
||||
# Whether HTTP requests will be redirected to the access URL (if it's a https URL
|
||||
# and TLS is enabled). Requests to local IP addresses are never redirected
|
||||
# regardless of this setting.
|
||||
# (default: true, type: bool)
|
||||
redirectHTTP: true
|
||||
# Path to each certificate for TLS. It requires a PEM-encoded file. To configure
|
||||
# the listener to use a CA certificate, concatenate the primary certificate and
|
||||
# the CA certificate together. The primary certificate should appear first in the
|
||||
# combined file.
|
||||
# (default: <unset>, type: string-array)
|
||||
certFiles: []
|
||||
# PEM-encoded Certificate Authority file used for checking the authenticity of
|
||||
# client.
|
||||
# (default: <unset>, type: string)
|
||||
clientCAFile: ""
|
||||
# Policy the server will follow for TLS Client Authentication. Accepted values are
|
||||
# "none", "request", "require-any", "verify-if-given", or "require-and-verify".
|
||||
# (default: none, type: string)
|
||||
clientAuth: none
|
||||
# Paths to the private keys for each of the certificates. It requires a
|
||||
# PEM-encoded file.
|
||||
# (default: <unset>, type: string-array)
|
||||
keyFiles: []
|
||||
# Minimum supported version of TLS. Accepted values are "tls10", "tls11", "tls12"
|
||||
# or "tls13".
|
||||
# (default: tls12, type: string)
|
||||
minVersion: tls12
|
||||
# Path to certificate for client TLS authentication. It requires a PEM-encoded
|
||||
# file.
|
||||
# (default: <unset>, type: string)
|
||||
clientCertFile: ""
|
||||
# Path to key for client TLS authentication. It requires a PEM-encoded file.
|
||||
# (default: <unset>, type: string)
|
||||
clientKeyFile: ""
|
||||
# Controls if the 'Strict-Transport-Security' header is set on all static file
|
||||
# responses. This header should only be set if the server is accessed via HTTPS.
|
||||
# This value is the MaxAge in seconds of the header.
|
||||
# (default: 0, type: int)
|
||||
strictTransportSecurity: 0
|
||||
# Two optional fields can be set in the Strict-Transport-Security header;
|
||||
# 'includeSubDomains' and 'preload'. The 'strict-transport-security' flag must be
|
||||
# set to a non-zero value for these options to be used.
|
||||
# (default: <unset>, type: string-array)
|
||||
strictTransportSecurityOptions: []
|
||||
# Most Coder deployments never have to think about DERP because all connections
|
||||
# between workspaces and users are peer-to-peer. However, when Coder cannot
|
||||
# establish
|
||||
# a peer to peer connection, Coder uses a distributed relay network backed by
|
||||
# Tailscale and WireGuard.
|
||||
derp:
|
||||
# Whether to enable or disable the embedded DERP relay server.
|
||||
# (default: true, type: bool)
|
||||
enable: true
|
||||
# Region ID to use for the embedded DERP server.
|
||||
# (default: 999, type: int)
|
||||
regionID: 999
|
||||
# Region code to use for the embedded DERP server.
|
||||
# (default: coder, type: string)
|
||||
regionCode: coder
|
||||
# Region name that for the embedded DERP server.
|
||||
# (default: Coder Embedded Relay, type: string)
|
||||
regionName: Coder Embedded Relay
|
||||
# Addresses for STUN servers to establish P2P connections. Set empty to disable
|
||||
# P2P connections.
|
||||
# (default: stun.l.google.com:19302, type: string-array)
|
||||
stunAddresses:
|
||||
- stun.l.google.com:19302
|
||||
# An HTTP URL that is accessible by other replicas to relay DERP traffic. Required
|
||||
# for high availability.
|
||||
# (default: <unset>, type: url)
|
||||
relayURL:
|
||||
# URL to fetch a DERP mapping on startup. See:
|
||||
# https://tailscale.com/kb/1118/custom-derp-servers/.
|
||||
# (default: <unset>, type: string)
|
||||
url: ""
|
||||
# Path to read a DERP mapping from. See:
|
||||
# https://tailscale.com/kb/1118/custom-derp-servers/.
|
||||
# (default: <unset>, type: string)
|
||||
configPath: ""
|
||||
# Headers to trust for forwarding IP addresses. e.g. Cf-Connecting-Ip,
|
||||
# True-Client-Ip, X-Forwarded-For.
|
||||
# (default: <unset>, type: string-array)
|
||||
proxyTrustedHeaders: []
|
||||
# Origin addresses to respect "proxy-trusted-headers". e.g. 192.168.1.0/24.
|
||||
# (default: <unset>, type: string-array)
|
||||
proxyTrustedOrigins: []
|
||||
# Controls if the 'Secure' property is set on browser session cookies.
|
||||
# (default: <unset>, type: bool)
|
||||
secureAuthCookie: false
|
||||
# Whether Coder only allows connections to workspaces via the browser.
|
||||
# (default: <unset>, type: bool)
|
||||
browserOnly: false
|
||||
# Interval to poll for scheduled workspace builds.
|
||||
# (default: 1m0s, type: duration)
|
||||
autobuildPollInterval: 1m0s
|
||||
introspection:
|
||||
prometheus:
|
||||
# Serve prometheus metrics on the address defined by prometheus address.
|
||||
# (default: <unset>, type: bool)
|
||||
enable: false
|
||||
# The bind address to serve prometheus metrics.
|
||||
# (default: 127.0.0.1:2112, type: host:port)
|
||||
address: 127.0.0.1:2112
|
||||
pprof:
|
||||
# Serve pprof metrics on the address defined by pprof address.
|
||||
# (default: <unset>, type: bool)
|
||||
enable: false
|
||||
# The bind address to serve pprof.
|
||||
# (default: 127.0.0.1:6060, type: host:port)
|
||||
address: 127.0.0.1:6060
|
||||
tracing:
|
||||
# Whether application tracing data is collected. It exports to a backend
|
||||
# configured by environment variables. See:
|
||||
# https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md.
|
||||
# (default: <unset>, type: bool)
|
||||
enable: false
|
||||
# Enables capturing of logs as events in traces. This is useful for debugging, but
|
||||
# may result in a very large amount of events being sent to the tracing backend
|
||||
# which may incur significant costs. If the verbose flag was supplied, debug-level
|
||||
# logs will be included.
|
||||
# (default: <unset>, type: bool)
|
||||
captureLogs: false
|
||||
logging:
|
||||
# Output debug-level logs.
|
||||
# (default: <unset>, type: bool)
|
||||
verbose: false
|
||||
# Output human-readable logs to a given file.
|
||||
# (default: /dev/stderr, type: string)
|
||||
humanPath: /dev/stderr
|
||||
# Output JSON logs to a given file.
|
||||
# (default: <unset>, type: string)
|
||||
jsonPath: ""
|
||||
# Output Stackdriver compatible logs to a given file.
|
||||
# (default: <unset>, type: string)
|
||||
stackdriverPath: ""
|
||||
oauth2:
|
||||
github:
|
||||
# Client ID for Login with GitHub.
|
||||
# (default: <unset>, type: string)
|
||||
clientID: ""
|
||||
# Organizations the user must be a member of to Login with GitHub.
|
||||
# (default: <unset>, type: string-array)
|
||||
allowedOrgs: []
|
||||
# Teams inside organizations the user must be a member of to Login with GitHub.
|
||||
# Structured as: <organization-name>/<team-slug>.
|
||||
# (default: <unset>, type: string-array)
|
||||
allowedTeams: []
|
||||
# Whether new users can sign up with GitHub.
|
||||
# (default: <unset>, type: bool)
|
||||
allowSignups: false
|
||||
# Allow all logins, setting this option means allowed orgs and teams must be
|
||||
# empty.
|
||||
# (default: <unset>, type: bool)
|
||||
allowEveryone: false
|
||||
# Base URL of a GitHub Enterprise deployment to use for Login with GitHub.
|
||||
# (default: <unset>, type: string)
|
||||
enterpriseBaseURL: ""
|
||||
oidc:
|
||||
# Whether new users can sign up with OIDC.
|
||||
# (default: true, type: bool)
|
||||
allowSignups: true
|
||||
# Client ID to use for Login with OIDC.
|
||||
# (default: <unset>, type: string)
|
||||
clientID: ""
|
||||
# Email domains that clients logging in with OIDC must match.
|
||||
# (default: <unset>, type: string-array)
|
||||
emailDomain: []
|
||||
# Issuer URL to use for Login with OIDC.
|
||||
# (default: <unset>, type: string)
|
||||
issuerURL: ""
|
||||
# Scopes to grant when authenticating with OIDC.
|
||||
# (default: openid,profile,email, type: string-array)
|
||||
scopes:
|
||||
- openid
|
||||
- profile
|
||||
- email
|
||||
# Ignore the email_verified claim from the upstream provider.
|
||||
# (default: <unset>, type: bool)
|
||||
ignoreEmailVerified: false
|
||||
# OIDC claim field to use as the username.
|
||||
# (default: preferred_username, type: string)
|
||||
usernameField: preferred_username
|
||||
# OIDC claim field to use as the email.
|
||||
# (default: email, type: string)
|
||||
emailField: email
|
||||
# OIDC auth URL parameters to pass to the upstream provider.
|
||||
# (default: {"access_type": "offline"}, type: struct[map[string]string])
|
||||
authURLParams:
|
||||
access_type: offline
|
||||
# Ignore the userinfo endpoint and only use the ID token for user information.
|
||||
# (default: false, type: bool)
|
||||
ignoreUserInfo: false
|
||||
# Change the OIDC default 'groups' claim field. By default, will be 'groups' if
|
||||
# present in the oidc scopes argument.
|
||||
# (default: <unset>, type: string)
|
||||
groupField: ""
|
||||
# A map of OIDC group IDs and the group in Coder it should map to. This is useful
|
||||
# for when OIDC providers only return group IDs.
|
||||
# (default: {}, type: struct[map[string]string])
|
||||
groupMapping: {}
|
||||
# The text to show on the OpenID Connect sign in button.
|
||||
# (default: OpenID Connect, type: string)
|
||||
signInText: OpenID Connect
|
||||
# URL pointing to the icon to use on the OepnID Connect login button.
|
||||
# (default: <unset>, type: url)
|
||||
iconURL:
|
||||
# Telemetry is critical to our ability to improve Coder. We strip all personal
|
||||
# information before sending data to our servers. Please only disable telemetry
|
||||
# when required by your organization's security policy.
|
||||
telemetry:
|
||||
# Whether telemetry is enabled or not. Coder collects anonymized usage data to
|
||||
# help improve our product.
|
||||
# (default: false, type: bool)
|
||||
enable: false
|
||||
# Whether Opentelemetry traces are sent to Coder. Coder collects anonymized
|
||||
# application tracing to help improve our product. Disabling telemetry also
|
||||
# disables this option.
|
||||
# (default: false, type: bool)
|
||||
trace: false
|
||||
# URL to send telemetry.
|
||||
# (default: https://telemetry.coder.com, type: url)
|
||||
url: https://telemetry.coder.com
|
||||
# Tune the behavior of the provisioner, which is responsible for creating,
|
||||
# updating, and deleting workspace resources.
|
||||
provisioning:
|
||||
# Number of provisioner daemons to create on start. If builds are stuck in queued
|
||||
# state for a long time, consider increasing this.
|
||||
# (default: 3, type: int)
|
||||
daemons: 3
|
||||
# Time to wait before polling for a new job.
|
||||
# (default: 1s, type: duration)
|
||||
daemonPollInterval: 1s
|
||||
# Random jitter added to the poll interval.
|
||||
# (default: 100ms, type: duration)
|
||||
daemonPollJitter: 100ms
|
||||
# Time to force cancel provisioning tasks that are stuck.
|
||||
# (default: 10m0s, type: duration)
|
||||
forceCancelInterval: 10m0s
|
||||
# Enable one or more experiments. These are not ready for production. Separate
|
||||
# multiple experiments with commas, or enter '*' to opt-in to all available
|
||||
# experiments.
|
||||
# (default: <unset>, type: string-array)
|
||||
experiments: []
|
||||
# Periodically check for new releases of Coder and inform the owner. The check is
|
||||
# performed once per day.
|
||||
# (default: false, type: bool)
|
||||
updateCheck: false
|
||||
# Expose the swagger endpoint via /swagger.
|
||||
# (default: <unset>, type: bool)
|
||||
enableSwagger: false
|
||||
# The directory to cache temporary files. If unspecified and $CACHE_DIRECTORY is
|
||||
# set, it will be used for compatibility with systemd.
|
||||
# (default: [cache dir], type: string)
|
||||
cacheDir: [cache dir]
|
||||
# Controls whether data will be stored in an in-memory database.
|
||||
# (default: <unset>, type: bool)
|
||||
inMemoryDatabase: false
|
||||
# The algorithm to use for generating ssh keys. Accepted values are "ed25519",
|
||||
# "ecdsa", or "rsa4096".
|
||||
# (default: ed25519, type: string)
|
||||
sshKeygenAlgorithm: ed25519
|
||||
# URL to use for agent troubleshooting when not set in the template.
|
||||
# (default:
|
||||
# https://coder.com/docs/coder-oss/latest/templates#troubleshooting-templates,
|
||||
# type: url)
|
||||
agentFallbackTroubleshootingURL: https://coder.com/docs/coder-oss/latest/templates#troubleshooting-templates
|
||||
# Disable workspace apps that are not served from subdomains. Path-based apps can
|
||||
# make requests to the Coder API and pose a security risk when the workspace
|
||||
# serves malicious JavaScript. This is recommended for security purposes if a
|
||||
# --wildcard-access-url is configured.
|
||||
# (default: <unset>, type: bool)
|
||||
disablePathApps: false
|
||||
# These options change the behavior of how clients interact with the Coder.
|
||||
# Clients include the coder cli, vs code extension, and the web UI.
|
||||
client:
|
||||
# The SSH deployment prefix is used in the Host of the ssh config.
|
||||
# (default: coder., type: string)
|
||||
sshHostnamePrefix: coder.
|
||||
# These SSH config options will override the default SSH config options. Provide
|
||||
# options in "key=value" or "key value" format separated by commas.Using this
|
||||
# incorrectly can break SSH to your deployment, use cautiously.
|
||||
# (default: <unset>, type: string-array)
|
||||
sshConfigOptions: []
|
||||
# Support links to display in the top right drop down menu.
|
||||
# (default: <unset>, type: struct[[]codersdk.LinkConfig])
|
||||
supportLinks: []
|
||||
# Hostname of HTTPS server that runs https://github.com/coder/wgtunnel. By
|
||||
# default, this will pick the best available wgtunnel server hosted by Coder. e.g.
|
||||
# "tunnel.example.com".
|
||||
# (default: <unset>, type: string)
|
||||
wgtunnelHost: ""
|
||||
Reference in New Issue
Block a user