From 548fae43fe8761dc26987e1c032d65f9290d3872 Mon Sep 17 00:00:00 2001 From: Noah Stride Date: Tue, 10 May 2022 10:04:54 +0100 Subject: [PATCH] `tbot configure` command for assisting Machine ID configuration (#12517) * add `tbot configure` command * introduce golden file test helper * address PR comments by zmb --- lib/utils/golden/golden.go | 130 ++++++++++++++++++ tool/tbot/config/config.go | 6 +- tool/tbot/main.go | 67 +++++++-- tool/tbot/main_test.go | 101 ++++++++++++++ .../all_parameters_provided/file.golden | 17 +++ .../all_parameters_provided/stdout.golden | 17 +++ .../no_parameters_provided/file.golden | 11 ++ .../no_parameters_provided/stdout.golden | 11 ++ 8 files changed, 350 insertions(+), 10 deletions(-) create mode 100644 lib/utils/golden/golden.go create mode 100644 tool/tbot/main_test.go create mode 100644 tool/tbot/testdata/TestRun_Configure/all_parameters_provided/file.golden create mode 100644 tool/tbot/testdata/TestRun_Configure/all_parameters_provided/stdout.golden create mode 100644 tool/tbot/testdata/TestRun_Configure/no_parameters_provided/file.golden create mode 100644 tool/tbot/testdata/TestRun_Configure/no_parameters_provided/stdout.golden diff --git a/lib/utils/golden/golden.go b/lib/utils/golden/golden.go new file mode 100644 index 0000000000000..23ceb38b28dde --- /dev/null +++ b/lib/utils/golden/golden.go @@ -0,0 +1,130 @@ +/* +Copyright 2022 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Golden files are a convenient way of storing data that we want to assert in +// unit tests. They are stored under the `testdata/` directory in a directory +// based on the name of the test. They are especially useful for storing large +// pieces of data that can be unwieldy to embed directly into your test tables. +// +// The convenience factor comes from the update mode which causes the tests to +// write data, rather than assert against it. This allows expected outputs +// to be updated easily when the underlying implementation is adjusted. +// This mode can be enabled by setting `GOLDEN_UPDATE=1` when running the tests +// you wish to update. +// +// Usage: +// +// Golden is ideal for testing the results of marshalling, or units that output +// large amounts of data to stdout or a file: +// +// func TestMarshalFooStruct(t *testing.T) { +// got, err := json.Marshal(FooStruct{Some: "Data"}) +// require.NoError(t, err) +// +// if golden.Update() { +// golden.Set(t, got) +// } +// require.Equal(t, golden.Get(t), got) +// } +// +// It is possible to have multiple golden files per test using `GetNamed` and +// `SetNamed`. This is useful for cases where your unit under test produces +// multiple pieces of output e.g stdout and stderr: +// +// func TestFooCommand(t *testing.T) { +// stdoutBuf := new(bytes.Buffer) +// stderrBuf := new(bytes.Buffer) +// +// FooCommand(stdoutBuf, stderrBuf) +// +// stdout := stdoutBuf.Bytes() +// stderr := stderrBuf.Bytes() +// +// if golden.Update() { +// golden.SetNamed(t, "stdout", stdout) +// golden.SetNamed(t, "stderr", stderr) +// } +// require.Equal(t, golden.GetNamed(t, "stdout"), stdout) +// require.Equal(t, golden.GetNamed(t, "stderr"), stderr) +// } + +package golden + +import ( + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/stretchr/testify/require" +) + +func pathForFile(t *testing.T, name string) string { + pathComponents := []string{ + "testdata", + t.Name(), + } + + if name != "" { + pathComponents = append(pathComponents, name) + } + + return filepath.Join(pathComponents...) + ".golden" +} + +// ShouldSet provides a boolean value that indicates if your code should then +// call `Set` or `SetNamed` to update the stored golden file value with new +// data. +func ShouldSet() bool { + env := os.Getenv("GOLDEN_UPDATE") + should, _ := strconv.ParseBool(env) + return should +} + +// SetNamed writes the supplied data to a named golden file for the current +// test. +func SetNamed(t *testing.T, name string, data []byte) { + p := pathForFile(t, name) + dir := filepath.Dir(p) + + err := os.MkdirAll(dir, 0o755) + require.NoError(t, err) + + err = os.WriteFile(p, data, 0o644) + require.NoError(t, err) +} + +// Set writes the supplied data to the golden file for the current test. +func Set(t *testing.T, data []byte) { + SetNamed(t, "", data) +} + +// GetNamed returns the contents of a named golden file for the current test. If +// the specified golden file does not exist for the test, the test will be +// failed. +func GetNamed(t *testing.T, name string) []byte { + p := pathForFile(t, name) + data, err := os.ReadFile(p) + require.NoError(t, err) + + return data +} + +// Get returns the contents of the golden file for the current test. If there is +// no golden file for the test, the test will be failed. +func Get(t *testing.T) []byte { + return GetNamed(t, "") +} diff --git a/tool/tbot/config/config.go b/tool/tbot/config/config.go index a5a1523086f3b..d12ed351e04b3 100644 --- a/tool/tbot/config/config.go +++ b/tool/tbot/config/config.go @@ -95,6 +95,10 @@ type CLIConf struct { // Clean is a flag that, if set, instructs `tbot init` to remove existing // unexpected files. Clean bool + + // ConfigureOutput provides a path that the generated configuration file + // should be written to + ConfigureOutput string } // OnboardingConfig contains values only required on first connect. @@ -292,7 +296,7 @@ func FromCLIConf(cf *CLIConf) (*BotConfig, error) { } if err := config.CheckAndSetDefaults(); err != nil { - return nil, trace.Wrap(err, "validing merged bot config") + return nil, trace.Wrap(err, "validating merged bot config") } return config, nil diff --git a/tool/tbot/main.go b/tool/tbot/main.go index 010a273db221c..4234ead8108f1 100644 --- a/tool/tbot/main.go +++ b/tool/tbot/main.go @@ -21,6 +21,8 @@ import ( "context" "crypto/sha256" "encoding/hex" + "fmt" + "io" "os" "os/signal" "syscall" @@ -33,8 +35,8 @@ import ( "github.com/gravitational/teleport/tool/tbot/config" "github.com/gravitational/teleport/tool/tbot/identity" "github.com/gravitational/trace" - "github.com/kr/pretty" "github.com/sirupsen/logrus" + "gopkg.in/yaml.v2" ) var log = logrus.WithFields(logrus.Fields{ @@ -47,19 +49,20 @@ const ( ) func main() { - if err := Run(os.Args[1:]); err != nil { + if err := Run(os.Args[1:], os.Stdout); err != nil { utils.FatalError(err) trace.DebugReport(err) } } -func Run(args []string) error { +func Run(args []string, stdout io.Writer) error { var cf config.CLIConf utils.InitLogger(utils.LoggingForDaemon, logrus.InfoLevel) app := utils.InitCLIParser("tbot", "tbot: Teleport Machine ID").Interspersed(false) app.Flag("debug", "Verbose logging to stdout").Short('d').BoolVar(&cf.Debug) - app.Flag("config", "Path to a configuration file. Defaults to `/etc/tbot.yaml` if unspecified.").Short('c').StringVar(&cf.ConfigPath) + app.Flag("config", "Path to a configuration file.").Short('c').StringVar(&cf.ConfigPath) + app.HelpFlag.Short('h') versionCmd := app.Command("version", "Print the version") @@ -82,7 +85,16 @@ func Run(args []string) error { initCmd.Flag("init-dir", "If using a config file and multiple destinations are configured, controls which destination dir to configure.").StringVar(&cf.InitDir) initCmd.Flag("clean", "If set, remove unexpected files and directories from the destination.").BoolVar(&cf.Clean) - configCmd := app.Command("config", "Parse and dump a config file").Hidden() + configureCmd := app.Command("configure", "Creates a config file based on flags provided, and writes it to stdout or a file (-c ).") + configureCmd.Flag("auth-server", "Address of the Teleport Auth Server (On-Prem installs) or Proxy Server (Cloud installs).").Short('a').Envar(authServerEnvVar).StringVar(&cf.AuthServer) + configureCmd.Flag("ca-pin", "CA pin to validate the Teleport Auth Server; used on first connect.").StringsVar(&cf.CAPins) + configureCmd.Flag("certificate-ttl", "TTL of short-lived machine certificates.").Default("60m").DurationVar(&cf.CertificateTTL) + configureCmd.Flag("data-dir", "Directory to store internal bot data. Access to this directory should be limited.").StringVar(&cf.DataDir) + configureCmd.Flag("join-method", "Method to use to join the cluster, can be \"token\" or \"iam\".").Default(config.DefaultJoinMethod).EnumVar(&cf.JoinMethod, "token", "iam") + configureCmd.Flag("oneshot", "If set, quit after the first renewal.").BoolVar(&cf.Oneshot) + configureCmd.Flag("renewal-interval", "Interval at which short-lived certificates are renewed; must be less than the certificate TTL.").DurationVar(&cf.RenewalInterval) + configureCmd.Flag("token", "A bot join token, if attempting to onboard a new bot; used on first connect.").Envar(tokenEnvVar).StringVar(&cf.Token) + configureCmd.Flag("output", "Path to write the generated configuration file to rather than write to stdout.").Short('o').StringVar(&cf.ConfigureOutput) watchCmd := app.Command("watch", "Watch a destination directory for changes.").Hidden() @@ -107,8 +119,8 @@ func Run(args []string) error { err = onVersion() case startCmd.FullCommand(): err = onStart(botConfig) - case configCmd.FullCommand(): - err = onConfig(botConfig) + case configureCmd.FullCommand(): + err = onConfigure(cf, stdout) case initCmd.FullCommand(): err = onInit(botConfig, &cf) case watchCmd.FullCommand(): @@ -126,8 +138,45 @@ func onVersion() error { return nil } -func onConfig(botConfig *config.BotConfig) error { - pretty.Println(botConfig) +func onConfigure( + cf config.CLIConf, + stdout io.Writer, +) error { + out := stdout + outPath := cf.ConfigureOutput + if outPath != "" { + f, err := os.Create(outPath) + if err != nil { + return trace.Wrap(err) + } + defer f.Close() + out = f + } + + // We do not want to load an existing configuration file as this will cause + // it to be merged with the provided flags and defaults. + cf.ConfigPath = "" + cfg, err := config.FromCLIConf(&cf) + if err != nil { + return nil + } + + fmt.Fprintln(out, "# tbot config file generated by `configure` command") + + enc := yaml.NewEncoder(out) + if err := enc.Encode(cfg); err != nil { + return trace.Wrap(err) + } + + if err := enc.Close(); err != nil { + return trace.Wrap(err) + } + + if outPath != "" { + log.Infof( + "Generated config file written to file: %s", outPath, + ) + } return nil } diff --git a/tool/tbot/main_test.go b/tool/tbot/main_test.go new file mode 100644 index 0000000000000..53ba28a27f1b8 --- /dev/null +++ b/tool/tbot/main_test.go @@ -0,0 +1,101 @@ +/* +Copyright 2022 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package main + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/gravitational/teleport/lib/utils/golden" + "github.com/stretchr/testify/require" +) + +func TestRun_Configure(t *testing.T) { + t.Parallel() + + // This is slightly rubbish, but due to the global nature of `botfs`, + // it's difficult to configure the default acl and symlink values to be + // the same across dev laptops and GCB. + // If we switch to a more dependency injected model for botfs, we can + // ensure that the test one returns the same value across operating systems. + normalizeOSDependentValues := func(data []byte) []byte { + cpy := append([]byte{}, data...) + cpy = bytes.ReplaceAll( + cpy, []byte("symlinks: try-secure"), []byte("symlinks: secure"), + ) + cpy = bytes.ReplaceAll( + cpy, []byte(`acls: "off"`), []byte("acls: try"), + ) + return cpy + } + + baseArgs := []string{"configure"} + tests := []struct { + name string + args []string + }{ + { + name: "no parameters provided", + args: baseArgs, + }, + { + name: "all parameters provided", + args: append(baseArgs, []string{ + "-a", "example.com", + "--token", "xxyzz", + "--ca-pin", "sha256:capindata", + "--data-dir", "/custom/data/dir", + "--join-method", "token", + "--oneshot", + "--certificate-ttl", "42m", + "--renewal-interval", "21m", + }...), + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Run("file", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + args := append(tt.args, []string{"-o", path}...) + err := Run(args, nil) + require.NoError(t, err) + + data, err := os.ReadFile(path) + data = normalizeOSDependentValues(data) + require.NoError(t, err) + if golden.ShouldSet() { + golden.Set(t, data) + } + require.Equal(t, string(golden.Get(t)), string(data)) + }) + + t.Run("stdout", func(t *testing.T) { + stdout := new(bytes.Buffer) + err := Run(tt.args, stdout) + require.NoError(t, err) + data := normalizeOSDependentValues(stdout.Bytes()) + if golden.ShouldSet() { + golden.Set(t, data) + } + require.Equal(t, string(golden.Get(t)), string(data)) + }) + }) + } +} diff --git a/tool/tbot/testdata/TestRun_Configure/all_parameters_provided/file.golden b/tool/tbot/testdata/TestRun_Configure/all_parameters_provided/file.golden new file mode 100644 index 0000000000000..430440116090a --- /dev/null +++ b/tool/tbot/testdata/TestRun_Configure/all_parameters_provided/file.golden @@ -0,0 +1,17 @@ +# tbot config file generated by `configure` command +onboarding: + token: xxyzz + ca_path: "" + ca_pins: + - sha256:capindata + join_method: token +storage: + directory: + path: /custom/data/dir + symlinks: secure + acls: try +debug: false +auth_server: example.com +certificate_ttl: 42m0s +renewal_interval: 21m0s +oneshot: true diff --git a/tool/tbot/testdata/TestRun_Configure/all_parameters_provided/stdout.golden b/tool/tbot/testdata/TestRun_Configure/all_parameters_provided/stdout.golden new file mode 100644 index 0000000000000..430440116090a --- /dev/null +++ b/tool/tbot/testdata/TestRun_Configure/all_parameters_provided/stdout.golden @@ -0,0 +1,17 @@ +# tbot config file generated by `configure` command +onboarding: + token: xxyzz + ca_path: "" + ca_pins: + - sha256:capindata + join_method: token +storage: + directory: + path: /custom/data/dir + symlinks: secure + acls: try +debug: false +auth_server: example.com +certificate_ttl: 42m0s +renewal_interval: 21m0s +oneshot: true diff --git a/tool/tbot/testdata/TestRun_Configure/no_parameters_provided/file.golden b/tool/tbot/testdata/TestRun_Configure/no_parameters_provided/file.golden new file mode 100644 index 0000000000000..453a0054bb551 --- /dev/null +++ b/tool/tbot/testdata/TestRun_Configure/no_parameters_provided/file.golden @@ -0,0 +1,11 @@ +# tbot config file generated by `configure` command +storage: + directory: + path: /var/lib/teleport/bot + symlinks: secure + acls: try +debug: false +auth_server: "" +certificate_ttl: 1h0m0s +renewal_interval: 20m0s +oneshot: false diff --git a/tool/tbot/testdata/TestRun_Configure/no_parameters_provided/stdout.golden b/tool/tbot/testdata/TestRun_Configure/no_parameters_provided/stdout.golden new file mode 100644 index 0000000000000..453a0054bb551 --- /dev/null +++ b/tool/tbot/testdata/TestRun_Configure/no_parameters_provided/stdout.golden @@ -0,0 +1,11 @@ +# tbot config file generated by `configure` command +storage: + directory: + path: /var/lib/teleport/bot + symlinks: secure + acls: try +debug: false +auth_server: "" +certificate_ttl: 1h0m0s +renewal_interval: 20m0s +oneshot: false