feat: add YAML support to server (#6934)

This commit is contained in:
Ammar Bandukwala
2023-04-07 22:58:21 +00:00
committed by GitHub
parent a3c6cb1768
commit 4b99e2d07e
32 changed files with 1605 additions and 468 deletions
+4 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+4 -4
View File
@@ -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{
+60
View File
@@ -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
View File
@@ -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
View File
@@ -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)
})
}
}
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+117
View File
@@ -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))
}
+3
View File
@@ -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
View File
@@ -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.
Config Options
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.
Introspection / Logging Options
--log-human string, $CODER_LOGGING_HUMAN (default: /dev/stderr)
Output human-readable logs to a given file.
+336
View File
@@ -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: ""