Machine ID: Configuration migration (#27468)

* Introduce new tbot output configuration

* Remove code that will be included in future test refactor PR

* Add config version header

* Fix TestInitSymlink

* Add support for `standard` database type

* Set CA type

* `make fix-imports`

* More closely mimic original database output behaviour

* Make output of additional TLS files for application output optional

* Spell compatability properly

* Add test for config marshalling

* Fix cluster field yaml name

* Fix YAML marshalling/unmarshalling

* Fix sidecar invocation of tbot

* Add WrapDestination helper function to protect wrappedDestination

* Apply changes to sidecar

* Spell Marshalling the way the linter insists

* Fix some logging

* Tidy mutex usage on outputRenewalCache

* Fix ssh_host generation

* TBot Config V2 migration support

* Fix misspelling

* Get rid of `destinationWrapper`

* Single l in Unmarshaling

* Fix operator sidecar tbot

* Add UnmarshalYAML for SSHHostOutput

* Fix migration for removed destinationWrapper

* Use updated KubernetesCluster field name

* Add real world test case for migration

* Add additional migration tests

* Use `KubernetesCluster` instead of `ClusterName` for clarity

* Add additional "real-world" migrations from customer feedback

* Rename `Subtype` -> `Format` for `DatabaseOutput`

* Rename Subtype -> Format for DatabaseOutput

* Remove a very british "u" from Behaviour

* Add godoc for interfacemethods

* Fix double dot in comment

Co-authored-by: Michael Wilson <mike@mdwn.dev>

* Use const for database formats

* Reuse constant type string in Stringer

* Add godoc comment explaining behaviour if no destination found

* Inject executablePathGetter rather than using package level variable

* Use correct case in error

* Add warning for destination reuse

* Try to improve confusing log message

* Remove 'u' from behaviour

* Emit error when v2 config possibly being migrated as v1

* Fix imports

* Ensure they dont overwrite original config

* Remove redundant check

---------

Co-authored-by: Michael Wilson <mike@mdwn.dev>
This commit is contained in:
Noah Stride 2023-06-28 10:10:47 +01:00 committed by GitHub
parent bcf6e8fd30
commit 2e47bf740d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 1651 additions and 10 deletions

View file

@ -238,6 +238,7 @@ func (conf *OnboardingConfig) Token() (string, error) {
}
// BotConfig is the bot's root config object.
// This is currently at version "v2".
type BotConfig struct {
Version Version `yaml:"version"`
Onboarding OnboardingConfig `yaml:"onboarding,omitempty"`
@ -507,7 +508,7 @@ func FromCLIConf(cf *CLIConf) (*BotConfig, error) {
var err error
if cf.ConfigPath != "" {
config, err = ReadConfigFromFile(cf.ConfigPath)
config, err = ReadConfigFromFile(cf.ConfigPath, false)
if err != nil {
return nil, trace.Wrap(err, "loading bot config from path %s", cf.ConfigPath)
@ -619,14 +620,14 @@ func FromCLIConf(cf *CLIConf) (*BotConfig, error) {
}
// ReadConfigFromFile reads and parses a YAML config from a file.
func ReadConfigFromFile(filePath string) (*BotConfig, error) {
func ReadConfigFromFile(filePath string, manualMigration bool) (*BotConfig, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, trace.Wrap(err, fmt.Sprintf("failed to open file: %v", filePath))
}
defer f.Close()
return ReadConfig(f)
return ReadConfig(f, manualMigration)
}
type Version string
@ -637,7 +638,7 @@ var (
)
// ReadConfig parses a YAML config file from a Reader.
func ReadConfig(reader io.ReadSeeker) (*BotConfig, error) {
func ReadConfig(reader io.ReadSeeker, manualMigration bool) (*BotConfig, error) {
var version struct {
Version Version `yaml:"version"`
}
@ -651,12 +652,29 @@ func ReadConfig(reader io.ReadSeeker) (*BotConfig, error) {
return nil, trace.Wrap(err)
}
decoder = yaml.NewDecoder(reader)
decoder.KnownFields(true)
switch version.Version {
case V1, "":
panic("migration code will be inserted here in follow up PR")
if !manualMigration {
log.Warn("Deprecated config version (V1) detected. Attempting to perform an on-the-fly in-memory migration to latest version. Please persist the config migration by following the guidance at https://goteleport.com/docs/machine-id/reference/v14-upgrade-guide/")
}
config := &configV1{}
if err := decoder.Decode(config); err != nil {
return nil, trace.BadParameter("failed parsing config file: %s", strings.Replace(err.Error(), "\n", "", -1))
}
latestConfig, err := config.migrate()
if err != nil {
return nil, trace.WithUserMessage(
trace.Wrap(err, "migrating v1 config"),
"Failed to migrate. See https://goteleport.com/docs/machine-id/reference/v14-upgrade-guide/",
)
}
return latestConfig, nil
case V2:
if manualMigration {
return nil, trace.BadParameter("configuration already the latest version. nothing to migrate.")
}
decoder.KnownFields(true)
config := &BotConfig{}
if err := decoder.Decode(config); err != nil {
return nil, trace.BadParameter("failed parsing config file: %s", strings.Replace(err.Error(), "\n", "", -1))

View file

@ -42,7 +42,7 @@ func (sc *StorageConfig) CheckAndSetDefaults() error {
}
}
return trace.Wrap(sc.Destination.CheckAndSetDefaults())
return trace.Wrap(sc.Destination.CheckAndSetDefaults(), "validating storage")
}
func (sc *StorageConfig) MarshalYAML() (interface{}, error) {

View file

@ -78,7 +78,7 @@ func TestConfigCLIOnlySample(t *testing.T) {
func TestConfigFile(t *testing.T) {
configData := fmt.Sprintf(exampleConfigFile, "foo")
cfg, err := ReadConfig(strings.NewReader(configData))
cfg, err := ReadConfig(strings.NewReader(configData), false)
require.NoError(t, err)
require.Equal(t, "auth.example.com", cfg.AuthServer)
@ -114,7 +114,7 @@ func TestLoadTokenFromFile(t *testing.T) {
require.NoError(t, os.WriteFile(tokenFile, []byte("xxxyyy"), 0660))
configData := fmt.Sprintf(exampleConfigFile, tokenFile)
cfg, err := ReadConfig(strings.NewReader(configData))
cfg, err := ReadConfig(strings.NewReader(configData), false)
require.NoError(t, err)
token, err := cfg.Onboarding.Token()

415
lib/tbot/config/migrate.go Normal file
View file

@ -0,0 +1,415 @@
/*
Copyright 2023 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 config
import (
"time"
"github.com/gravitational/trace"
"golang.org/x/exp/slices"
"gopkg.in/yaml.v3"
"github.com/gravitational/teleport/lib/tbot/bot"
)
type destinationMixinV1 struct {
Directory *DestinationDirectory `yaml:"directory"`
Memory *DestinationMemory `yaml:"memory"`
}
func (c *destinationMixinV1) migrate() (bot.Destination, error) {
switch {
case c.Memory != nil && c.Directory != nil:
return nil, trace.BadParameter("both 'memory' and 'directory' cannot be specified")
case c.Memory != nil:
return c.Memory, nil
case c.Directory != nil:
return c.Directory, nil
default:
return nil, trace.BadParameter("at least one of `memory' and 'directory' must be specified")
}
}
type storageConfigV1 struct {
Mixin destinationMixinV1 `yaml:",inline"`
}
func (c *storageConfigV1) migrate() (*StorageConfig, error) {
dest, err := c.Mixin.migrate()
if err != nil {
return nil, trace.Wrap(err, "migrating destination mixin")
}
return &StorageConfig{
Destination: dest,
}, nil
}
type configV1Database struct {
Service string `yaml:"service"`
Database string `yaml:"database"`
Username string `yaml:"username"`
}
type configV1DestinationConfigHostCert struct {
Principals []string `yaml:"principals"`
}
type configV1DestinationConfig struct {
SSHClient map[string]any `yaml:"ssh_client"`
Identity map[string]any `yaml:"identity"`
TLS map[string]any `yaml:"tls"`
TLSCAs map[string]any `yaml:"tls_cas"`
Mongo map[string]any `yaml:"mongo"`
Cockroach map[string]any `yaml:"cockroach"`
Kubernetes map[string]any `yaml:"kubernetes"`
SSHHostCert *configV1DestinationConfigHostCert `yaml:"ssh_host_cert"`
}
func (c *configV1DestinationConfig) UnmarshalYAML(node *yaml.Node) error {
var simpleTemplate string
if err := node.Decode(&simpleTemplate); err == nil {
switch simpleTemplate {
case TemplateSSHClientName:
c.SSHClient = map[string]any{}
case TemplateIdentityName:
c.Identity = map[string]any{}
case TemplateTLSName:
c.TLS = map[string]any{}
case TemplateTLSCAsName:
c.TLSCAs = map[string]any{}
case TemplateMongoName:
c.Mongo = map[string]any{}
case TemplateCockroachName:
c.Cockroach = map[string]any{}
case TemplateKubernetesName:
c.Kubernetes = map[string]any{}
case TemplateSSHHostCertName:
c.SSHHostCert = &configV1DestinationConfigHostCert{}
default:
return trace.BadParameter("unrecognized config template %q", simpleTemplate)
}
return nil
}
// Fall back to the full struct; alias it to get standard unmarshal
// behavior and avoid recursion
type rawTemplate configV1DestinationConfig
return trace.Wrap(node.Decode((*rawTemplate)(c)))
}
type configV1Destination struct {
Mixin destinationMixinV1 `yaml:",inline"`
Roles []string `yaml:"roles"`
Configs []configV1DestinationConfig `yaml:"configs"`
Database *configV1Database `yaml:"database"`
KubernetesCluster string `yaml:"kubernetes_cluster"`
App string `yaml:"app"`
Cluster string `yaml:"cluster"`
}
func validateTemplates(configs []configV1DestinationConfig, allowedTypes []string, requiredTypes []string) error {
var allConfiguredTypes []string
configUnsupportedErr := func(typeName string) error {
return trace.BadParameter("configuration options are not supported by migration for %s config template", typeName)
}
for _, templateConfig := range configs {
var configuredTypes []string
if templateConfig.SSHClient != nil {
if len(templateConfig.SSHClient) > 0 {
return configUnsupportedErr(TemplateSSHClientName)
}
configuredTypes = append(configuredTypes, TemplateSSHClientName)
}
if templateConfig.Identity != nil {
if len(templateConfig.Identity) > 0 {
return configUnsupportedErr(TemplateIdentityName)
}
configuredTypes = append(configuredTypes, TemplateIdentityName)
}
if templateConfig.TLS != nil {
if len(templateConfig.TLS) > 0 {
return configUnsupportedErr(TemplateTLSName)
}
configuredTypes = append(configuredTypes, TemplateTLSName)
}
if templateConfig.TLSCAs != nil {
if len(templateConfig.TLSCAs) > 0 {
return configUnsupportedErr(TemplateTLSCAsName)
}
configuredTypes = append(configuredTypes, TemplateTLSCAsName)
}
if templateConfig.Mongo != nil {
if len(templateConfig.Mongo) > 0 {
return configUnsupportedErr(TemplateMongoName)
}
configuredTypes = append(configuredTypes, TemplateMongoName)
}
if templateConfig.Cockroach != nil {
if len(templateConfig.Cockroach) > 0 {
return configUnsupportedErr(TemplateCockroachName)
}
configuredTypes = append(configuredTypes, TemplateCockroachName)
}
if templateConfig.Kubernetes != nil {
if len(templateConfig.Kubernetes) > 0 {
return configUnsupportedErr(TemplateKubernetesName)
}
configuredTypes = append(configuredTypes, TemplateKubernetesName)
}
if templateConfig.SSHHostCert != nil {
if len(templateConfig.SSHHostCert.Principals) == 0 {
return trace.BadParameter("no principals specified for %s config template", TemplateSSHHostCertName)
}
configuredTypes = append(configuredTypes, TemplateSSHHostCertName)
}
if len(configuredTypes) == 0 {
return trace.BadParameter("config template must not be empty")
}
if len(configuredTypes) > 1 {
return trace.BadParameter("config template must have exactly one configuration")
}
allConfiguredTypes = append(allConfiguredTypes, configuredTypes...)
}
// Ensure all types are allowed by the new output type
for _, typeName := range allConfiguredTypes {
if !slices.Contains(allowedTypes, typeName) {
return trace.BadParameter("config template %q unsupported by new output type", typeName)
}
}
// Ensure the required types are specified for the new output type
for _, typeName := range requiredTypes {
if !slices.Contains(allConfiguredTypes, typeName) {
return trace.BadParameter("old config templates missing required template %s", typeName)
}
}
// Check for any weird duplicates we can't handle correctly
typeCounts := map[string]int{}
for _, typeName := range allConfiguredTypes {
typeCounts[typeName]++
}
for typeName, count := range typeCounts {
if count > 1 {
return trace.BadParameter("multiple config template entries found for %q", typeName)
}
}
return nil
}
func (c *configV1Destination) migrate() (Output, error) {
dest, err := c.Mixin.migrate()
if err != nil {
return nil, trace.Wrap(err, "migrating destination")
}
appConfigured := c.App != ""
databaseConfigured := c.Database != nil
kubernetesConfigured := c.KubernetesCluster != ""
hostCertConfigured := false
for _, templateConfig := range c.Configs {
if templateConfig.SSHHostCert != nil {
hostCertConfigured = true
}
}
outputTypesCount := 0
for _, val := range []bool{appConfigured, databaseConfigured, kubernetesConfigured, hostCertConfigured} {
if val {
outputTypesCount++
}
}
if outputTypesCount > 1 {
return nil, trace.BadParameter("multiple potential output types detected, cannot determine correct type")
}
switch {
case appConfigured:
if err := validateTemplates(
c.Configs,
[]string{TemplateTLSCAsName, TemplateTLSName, TemplateIdentityName},
[]string{},
); err != nil {
return nil, trace.Wrap(err, "validating template configs")
}
specificTLSExtensions := false
for _, templateConfig := range c.Configs {
if templateConfig.TLS != nil {
specificTLSExtensions = true
break
}
}
return &ApplicationOutput{
Destination: dest,
Roles: c.Roles,
AppName: c.App,
SpecificTLSExtensions: specificTLSExtensions,
}, nil
case databaseConfigured:
if err := validateTemplates(
c.Configs,
[]string{TemplateTLSCAsName, TemplateIdentityName, TemplateMongoName, TemplateCockroachName, TemplateTLSName},
[]string{},
); err != nil {
return nil, trace.Wrap(err, "validating template configs")
}
format := UnspecifiedDatabaseFormat
for _, templateConfig := range c.Configs {
if templateConfig.Mongo != nil {
if format != UnspecifiedDatabaseFormat {
return nil, trace.BadParameter("multiple candidate formats for database output")
}
format = MongoDatabaseFormat
}
if templateConfig.Cockroach != nil {
if format != UnspecifiedDatabaseFormat {
return nil, trace.BadParameter("multiple candidate formats for database output")
}
format = CockroachDatabaseFormat
}
if templateConfig.TLS != nil {
if format != UnspecifiedDatabaseFormat {
return nil, trace.BadParameter("multiple candidate formats for database output")
}
format = TLSDatabaseFormat
}
}
return &DatabaseOutput{
Destination: dest,
Roles: c.Roles,
Format: format,
Database: c.Database.Database,
Service: c.Database.Service,
Username: c.Database.Username,
}, nil
case kubernetesConfigured:
if err := validateTemplates(
c.Configs,
[]string{TemplateTLSCAsName, TemplateIdentityName, TemplateKubernetesName},
[]string{},
); err != nil {
return nil, trace.Wrap(err, "validating template configs")
}
return &KubernetesOutput{
Destination: dest,
Roles: c.Roles,
KubernetesCluster: c.KubernetesCluster,
}, nil
case hostCertConfigured:
if err := validateTemplates(
c.Configs,
[]string{TemplateSSHHostCertName},
[]string{TemplateSSHHostCertName},
); err != nil {
return nil, trace.Wrap(err, "validating template configs")
}
// Extract principals from template config
principals := []string{}
for _, c := range c.Configs {
if c.SSHHostCert != nil {
principals = c.SSHHostCert.Principals
break
}
}
return &SSHHostOutput{
Destination: dest,
Roles: c.Roles,
Principals: principals,
}, nil
default:
if err := validateTemplates(
c.Configs,
[]string{TemplateTLSCAsName, TemplateIdentityName, TemplateSSHClientName},
[]string{},
); err != nil {
return nil, trace.Wrap(err, "validating template configs")
}
return &IdentityOutput{
Destination: dest,
Roles: c.Roles,
Cluster: c.Cluster,
}, nil
}
}
type configV1 struct {
Onboarding OnboardingConfig `yaml:"onboarding"`
Debug bool `yaml:"debug"`
AuthServer string `yaml:"auth_server"`
CertificateTTL time.Duration `yaml:"certificate_ttl"`
RenewalInterval time.Duration `yaml:"renewal_interval"`
Oneshot bool `yaml:"oneshot"`
FIPS bool `yaml:"fips"`
DiagAddr string `yaml:"diag_addr"`
Destinations []configV1Destination `yaml:"destinations"`
StorageConfig *storageConfigV1 `yaml:"storage"`
// This field doesn't exist in V1, but, it exists here so we can detect
// a scenario where for some reason we're trying to migrate a V2 config
// that's missing the version header.
Outputs []any `yaml:"outputs"`
}
func (c *configV1) migrate() (*BotConfig, error) {
if len(c.Outputs) > 0 {
return nil, trace.BadParameter("config has been detected as potentially v1, but includes the v2 outputs field")
}
var storage *StorageConfig
var err error
if c.StorageConfig != nil {
storage, err = c.StorageConfig.migrate()
if err != nil {
return nil, trace.Wrap(err, "migrating storage config")
}
}
var outputs []Output
for _, d := range c.Destinations {
o, err := d.migrate()
if err != nil {
return nil, trace.Wrap(err, "migrating output")
}
outputs = append(outputs, o)
}
return &BotConfig{
Version: V2,
Onboarding: c.Onboarding,
Debug: c.Debug,
AuthServer: c.AuthServer,
CertificateTTL: c.CertificateTTL,
RenewalInterval: c.RenewalInterval,
Oneshot: c.Oneshot,
FIPS: c.FIPS,
DiagAddr: c.DiagAddr,
Storage: storage,
Outputs: outputs,
}, nil
}

File diff suppressed because it is too large Load diff

View file

@ -112,7 +112,7 @@ func testConfigFromCLI(t *testing.T, cf *config.CLIConf) *config.BotConfig {
// testConfigFromString parses a YAML config file from a string.
func testConfigFromString(t *testing.T, yaml string) *config.BotConfig {
cfg, err := config.ReadConfig(strings.NewReader(yaml))
cfg, err := config.ReadConfig(strings.NewReader(yaml), false)
require.NoError(t, err)
return cfg

View file

@ -108,6 +108,9 @@ func Run(args []string, stdout io.Writer) error {
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)
migrateCmd := app.Command("migrate", "Migrates a config file from an older version to the newest version. Outputs to stdout by default.")
migrateCmd.Flag("output", "Path to write the generated configuration file to rather than write to stdout.").Short('o').StringVar(&cf.ConfigureOutput)
dbCmd := app.Command("db", "Execute database commands through tsh.")
dbCmd.Flag("proxy", "The Teleport proxy server to use, in host:port form.").Required().StringVar(&cf.Proxy)
dbCmd.Flag("destination-dir", "The destination directory with which to authenticate tsh").StringVar(&cf.DestinationDir)
@ -151,6 +154,12 @@ func Run(args []string, stdout io.Writer) error {
utils.InitLogger(utils.LoggingForDaemon, logrus.DebugLevel)
}
// If migration is specified, we want to run this before the config is
// loaded normally.
if migrateCmd.FullCommand() == command {
return onMigrate(cf, stdout)
}
botConfig, err := config.FromCLIConf(&cf)
if err != nil {
return trace.Wrap(err)
@ -232,6 +241,60 @@ func onConfigure(
return nil
}
func onMigrate(
cf config.CLIConf,
stdout io.Writer,
) error {
if cf.ConfigPath == "" {
return trace.BadParameter("source config file must be provided with -c")
}
out := stdout
outPath := cf.ConfigureOutput
if outPath != "" {
if outPath == cf.ConfigPath {
return trace.BadParameter("migrated config output path should not be the same as the source config path")
}
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.
cfg, err := config.ReadConfigFromFile(cf.ConfigPath, true)
if err != nil {
return trace.Wrap(err)
}
if err := cfg.CheckAndSetDefaults(); err != nil {
return trace.Wrap(err, "validating new config")
}
fmt.Fprintln(out, "# tbot config file generated by `migrate` command")
enc := yaml.NewEncoder(out)
enc.SetIndent(2)
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
}
func onStart(botConfig *config.BotConfig) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()