Skip to content

Commit

Permalink
tbot configure command for assisting Machine ID configuration (#12517)
Browse files Browse the repository at this point in the history
* add `tbot configure` command

* introduce golden file test helper

* address PR comments by zmb
  • Loading branch information
strideynet authored May 10, 2022
1 parent bc09235 commit 2f00cb4
Show file tree
Hide file tree
Showing 8 changed files with 349 additions and 10 deletions.
130 changes: 130 additions & 0 deletions lib/utils/golden/golden.go
Original file line number Diff line number Diff line change
@@ -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, "")
}
6 changes: 5 additions & 1 deletion tool/tbot/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
66 changes: 57 additions & 9 deletions tool/tbot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"os/signal"
"syscall"
Expand All @@ -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{
Expand All @@ -47,19 +49,19 @@ 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")
Expand All @@ -83,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 <path>).")
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()

Expand All @@ -109,8 +120,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():
Expand All @@ -128,8 +139,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
}
Expand Down
101 changes: 101 additions & 0 deletions tool/tbot/main_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
})
}
}
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 2f00cb4

Please sign in to comment.