diff --git a/api/constants/constants.go b/api/constants/constants.go index c91517a6839c6..ee5e604975da1 100644 --- a/api/constants/constants.go +++ b/api/constants/constants.go @@ -500,4 +500,8 @@ const ( EnvVarTerraformRetryMaxTries = "TF_TELEPORT_RETRY_MAX_TRIES" // EnvVarTerraformDialTimeoutDuration is the environment variable configuring the Terraform provider dial timeout. EnvVarTerraformDialTimeoutDuration = "TF_TELEPORT_DIAL_TIMEOUT_DURATION" + // EnvVarTerraformJoinMethod is the environment variable configuring the Terraform provider native MachineID join method. + EnvVarTerraformJoinMethod = "TF_TELEPORT_JOIN_METHOD" + // EnvVarTerraformJoinToken is the environment variable configuring the Terraform provider native MachineID join token. + EnvVarTerraformJoinToken = "TF_TELEPORT_JOIN_TOKEN" ) diff --git a/docs/pages/reference/terraform-provider.mdx b/docs/pages/reference/terraform-provider.mdx index 1ed0af6b46f8b..a54926db2150a 100644 --- a/docs/pages/reference/terraform-provider.mdx +++ b/docs/pages/reference/terraform-provider.mdx @@ -137,6 +137,8 @@ This auth method has the following limitations: - `identity_file` (String, Sensitive) Teleport identity file content. This can also be set with the environment variable `TF_TELEPORT_IDENTITY_FILE`. - `identity_file_base64` (String, Sensitive) Teleport identity file content base64 encoded. This can also be set with the environment variable `TF_TELEPORT_IDENTITY_FILE_BASE64`. - `identity_file_path` (String) Teleport identity file path. This can also be set with the environment variable `TF_TELEPORT_IDENTITY_FILE_PATH`. +- `join_method` (String) Enables the native Terraform MachineID support. When set, Terraform uses MachineID to securely join the Teleport cluster and obtain credentials. See [the join method reference](./join-methods.mdx) for possible values, you must use [a delegated join method](./join-methods.mdx#secret-vs-delegated). This can also be set with the environment variable `TF_TELEPORT_JOIN_METHOD`. +- `join_token` (String) Name of the token used for the native MachineID joining. This value is not sensitive for [delegated join methods](./join-methods.mdx#secret-vs-delegated). This can also be set with the environment variable `TF_TELEPORT_JOIN_TOKEN`. - `key_base64` (String, Sensitive) Base64 encoded TLS auth key. This can also be set with the environment variable `TF_TELEPORT_KEY_BASE64`. - `key_path` (String) Path to Teleport auth key file. This can also be set with the environment variable `TF_TELEPORT_KEY`. - `profile_dir` (String) Teleport profile path. This can also be set with the environment variable `TF_TELEPORT_PROFILE_PATH`. diff --git a/integrations/lib/embeddedtbot/bot.go b/integrations/lib/embeddedtbot/bot.go index 9573ab52c99e0..e693b40793fe5 100644 --- a/integrations/lib/embeddedtbot/bot.go +++ b/integrations/lib/embeddedtbot/bot.go @@ -136,7 +136,7 @@ func (b *EmbeddedBot) Start(ctx context.Context) error { } } -func (b *EmbeddedBot) waitForClient(ctx context.Context, deadline time.Duration) (*client.Client, error) { +func (b *EmbeddedBot) waitForCredentials(ctx context.Context, deadline time.Duration) (client.Credentials, error) { waitCtx, cancel := context.WithTimeout(ctx, deadline) defer cancel() @@ -148,20 +148,32 @@ func (b *EmbeddedBot) waitForClient(ctx context.Context, deadline time.Duration) log.Infof("credential ready") } - c, err := b.buildClient(ctx) - return c, trace.Wrap(err) - + return b.credential, nil } // StartAndWaitForClient starts the EmbeddedBot and waits for a client to be available. -// This is the proper way of starting the EmbeddedBot. It returns an error if the -// EmbeddedBot is not able to get a certificate before the deadline. +// It returns an error if the EmbeddedBot is not able to get a certificate before the deadline. +// If you need a client.Credentials instead, you can use StartAndWaitForCredentials. func (b *EmbeddedBot) StartAndWaitForClient(ctx context.Context, deadline time.Duration) (*client.Client, error) { b.start(ctx) - c, err := b.waitForClient(ctx, deadline) + _, err := b.waitForCredentials(ctx, deadline) + if err != nil { + return nil, trace.Wrap(err) + } + + c, err := b.buildClient(ctx) return c, trace.Wrap(err) } +// StartAndWaitForCredentials starts the EmbeddedBot and waits for credentials to become ready. +// It returns an error if the EmbeddedBot is not able to get a certificate before the deadline. +// If you need a client.Client instead, you can use StartAndWaitForClient. +func (b *EmbeddedBot) StartAndWaitForCredentials(ctx context.Context, deadline time.Duration) (client.Credentials, error) { + b.start(ctx) + creds, err := b.waitForCredentials(ctx, deadline) + return creds, trace.Wrap(err) +} + // buildClient reads tbot's memory disttination, retrieves the certificates // and builds a new Teleport client using those certs. func (b *EmbeddedBot) buildClient(ctx context.Context) (*client.Client, error) { diff --git a/integrations/lib/embeddedtbot/bot_test.go b/integrations/lib/embeddedtbot/bot_test.go index 18075e81cf945..ca4bc9480af43 100644 --- a/integrations/lib/embeddedtbot/bot_test.go +++ b/integrations/lib/embeddedtbot/bot_test.go @@ -134,7 +134,7 @@ func TestBotJoinAuth(t *testing.T) { require.NoError(t, err) require.Equal(t, clusterName, pong.ClusterName) - botClient, err := bot.waitForClient(ctx, 10*time.Second) + botClient, err := bot.StartAndWaitForClient(ctx, 10*time.Second) require.NoError(t, err) botPong, err := botClient.Ping(ctx) require.NoError(t, err) diff --git a/integrations/terraform/go.mod b/integrations/terraform/go.mod index cfb971022b0aa..bbb51ddf36d60 100644 --- a/integrations/terraform/go.mod +++ b/integrations/terraform/go.mod @@ -220,6 +220,7 @@ require ( github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jmoiron/sqlx v1.3.5 // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/julienschmidt/httprouter v1.3.0 // indirect github.com/kelseyhightower/envconfig v1.4.0 // indirect @@ -290,6 +291,7 @@ require ( github.com/spf13/cast v1.6.0 // indirect github.com/spf13/cobra v1.8.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/spiffe/go-spiffe/v2 v2.3.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/thales-e-security/pool v0.0.2 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect diff --git a/integrations/terraform/go.sum b/integrations/terraform/go.sum index 02ed32106d312..380b4e20ea1ea 100644 --- a/integrations/terraform/go.sum +++ b/integrations/terraform/go.sum @@ -1060,6 +1060,8 @@ github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= +github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U= github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk= @@ -1501,6 +1503,10 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= +github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 h1:hgVxRoDDPtQE68PT4LFvNlPz2nBKd3OMlGKIQ69OmR4= +github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531/go.mod h1:fqTUQpVYBvhCNIsMXGl2GE9q6z94DIP6NtFKXCSTVbg= +github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d h1:J8tJzRyiddAFF65YVgxli+TyWBi0f79Sld6rJP6CBcY= +github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d/go.mod h1:b+Q3v8Yrg5o15d71PSUraUzYb+jWl6wQMSBXSGS/hv0= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -1827,6 +1833,8 @@ github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyh github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spiffe/go-spiffe/v2 v2.3.0 h1:g2jYNb/PDMB8I7mBGL2Zuq/Ur6hUhoroxGQFyD6tTj8= +github.com/spiffe/go-spiffe/v2 v2.3.0/go.mod h1:Oxsaio7DBgSNqhAO9i/9tLClaVlfRok7zvJnTV8ZyIY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -1918,6 +1926,8 @@ github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8 github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/errs v1.3.0 h1:hmiaKqgYZzcVgRL1Vkc1Mn2914BbzB0IBxs+ebeutGs= +github.com/zeebo/errs v1.3.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE= github.com/zmap/rc2 v0.0.0-20190804163417-abaa70531248/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE= @@ -2425,6 +2435,8 @@ golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNq golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= gonum.org/v1/gonum v0.9.3/go.mod h1:TZumC3NeyVQskjXqmyWt4S3bINhy7B4eYwW69EbyX+0= diff --git a/integrations/terraform/provider/credentials.go b/integrations/terraform/provider/credentials.go index 9e04ed9639909..0e6a26804ad65 100644 --- a/integrations/terraform/provider/credentials.go +++ b/integrations/terraform/provider/credentials.go @@ -31,9 +31,13 @@ import ( "github.com/gravitational/teleport/api/client" "github.com/gravitational/teleport/api/constants" + apitypes "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/integrations/lib/embeddedtbot" + tbotconfig "github.com/gravitational/teleport/lib/tbot/config" ) var supportedCredentialSources = CredentialSources{ + CredentialsFromNativeMachineID{}, CredentialsFromKeyAndCertPath{}, CredentialsFromKeyAndCertBase64{}, CredentialsFromIdentityFilePath{}, @@ -453,6 +457,85 @@ func (CredentialsFromProfile) Credentials(ctx context.Context, config providerDa return client.LoadProfile(profileDir, profileName), nil } +// CredentialsFromNativeMachineID builds credentials by performing a MachineID join and +type CredentialsFromNativeMachineID struct{} + +// Name implements CredentialSource and returns the source name. +func (CredentialsFromNativeMachineID) Name() string { + return "by performing native MachineID joining" +} + +// IsActive implements CredentialSource and returns if the source is active and why. +func (CredentialsFromNativeMachineID) IsActive(config providerData) (bool, string) { + joinMethod := stringFromConfigOrEnv(config.JoinMethod, constants.EnvVarTerraformJoinMethod, "") + joinToken := stringFromConfigOrEnv(config.JoinToken, constants.EnvVarTerraformJoinToken, "") + + // This method is active as soon as a token or a join method are set. + active := joinMethod != "" || joinToken != "" + return activeReason( + active, + attributeTerraformJoinMethod, attributeTerraformJoinToken, + constants.EnvVarTerraformJoinMethod, constants.EnvVarTerraformJoinToken, + ) +} + +// Credentials implements CredentialSource and returns a client.Credentials for the provider. +func (CredentialsFromNativeMachineID) Credentials(ctx context.Context, config providerData) (client.Credentials, error) { + joinMethod := stringFromConfigOrEnv(config.JoinMethod, constants.EnvVarTerraformJoinMethod, "") + joinToken := stringFromConfigOrEnv(config.JoinToken, constants.EnvVarTerraformJoinToken, "") + addr := stringFromConfigOrEnv(config.Addr, constants.EnvVarTerraformAddress, "") + caPath := stringFromConfigOrEnv(config.RootCaPath, constants.EnvVarTerraformRootCertificates, "") + + if joinMethod == "" { + return nil, trace.BadParameter("missing parameter %q or environment variable %q", attributeTerraformJoinMethod, constants.EnvVarTerraformJoinMethod) + } + if joinToken == "" { + return nil, trace.BadParameter("missing parameter %q or environment variable %q", attributeTerraformJoinMethod, constants.EnvVarTerraformJoinMethod) + } + if addr == "" { + return nil, trace.BadParameter("missing parameter %q or environment variable %q", attributeTerraformAddress, constants.EnvVarTerraformAddress) + } + + if apitypes.JoinMethod(joinMethod) == apitypes.JoinMethodToken { + return nil, trace.BadParameter(`the secret token join method ('token') is not supported for native Machine ID joining. + +Secret tokens are single use and the Terraform provider does not save the certificates it obtained, so the token join method can only be used once. +If you want to run the Terraform provider in the CI (GitHub Actions, GitlabCI, Circle CI) or in a supported runtime (AWS, GCP, Azure, Kubernetes, machine with a TPM) +you should use the join method specific to your environment. +If you want to use MachineID with secret tokens, the best approach is to run a local tbot on the server where the terraform provider runs. + +See https://goteleport.com/docs/reference/join-methods for more details.`) + } + + if err := apitypes.ValidateJoinMethod(apitypes.JoinMethod(joinMethod)); err != nil { + return nil, trace.Wrap(err, "Invalid Join Method") + } + botConfig := &embeddedtbot.BotConfig{ + AuthServer: addr, + Onboarding: tbotconfig.OnboardingConfig{ + TokenValue: joinToken, + CAPath: caPath, + JoinMethod: apitypes.JoinMethod(joinMethod), + }, + CertificateTTL: time.Hour, + RenewalInterval: 20 * time.Minute, + } + bot, err := embeddedtbot.New(botConfig) + if err != nil { + return nil, trace.Wrap(err, "Failed to create bot configuration, this is a provider bug, please open a GitHub issue.") + } + + preflightCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + _, err = bot.Preflight(preflightCtx) + if err != nil { + return nil, trace.Wrap(err, "Failed to preflight bot configuration") + } + + creds, err := bot.StartAndWaitForCredentials(ctx, 20*time.Second /* deadline */) + return creds, trace.Wrap(err, "Waiting for bot to obtain credentials") +} + // activeReason renders a user-friendly active reason message describing if the credentials source is active // and which parameters are controlling its activity. func activeReason(active bool, params ...string) (bool, string) { diff --git a/integrations/terraform/provider/provider.go b/integrations/terraform/provider/provider.go index 19b6a4a4f2cc2..a3e54686f5e93 100644 --- a/integrations/terraform/provider/provider.go +++ b/integrations/terraform/provider/provider.go @@ -82,6 +82,10 @@ const ( attributeTerraformRetryMaxTries = "retry_max_tries" // attributeTerraformDialTimeoutDuration is the attribute configuring the Terraform provider dial timeout. attributeTerraformDialTimeoutDuration = "dial_timeout_duration" + // attributeTerraformJoinMethod is the attribute configuring the Terraform provider native MachineID join method. + attributeTerraformJoinMethod = "join_method" + // attributeTerraformJoinToken is the attribute configuring the Terraform provider native MachineID join token. + attributeTerraformJoinToken = "join_token" ) type RetryConfig struct { @@ -95,6 +99,7 @@ type Provider struct { configured bool Client *client.Client RetryConfig RetryConfig + cancel context.CancelFunc } // providerData provider schema struct @@ -131,6 +136,10 @@ type providerData struct { RetryMaxTries types.String `tfsdk:"retry_max_tries"` // DialTimeout sets timeout when trying to connect to the server. DialTimeoutDuration types.String `tfsdk:"dial_timeout_duration"` + // JoinMethod is the MachineID join method. + JoinMethod types.String `tfsdk:"join_method"` + // JoinMethod is the MachineID join token. + JoinToken types.String `tfsdk:"join_token"` } // New returns an empty provider struct @@ -229,6 +238,18 @@ func (p *Provider) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnostics) Optional: true, Description: fmt.Sprintf("DialTimeout sets timeout when trying to connect to the server. This can also be set with the environment variable `%s`.", constants.EnvVarTerraformDialTimeoutDuration), }, + attributeTerraformJoinMethod: { + Type: types.StringType, + Sensitive: false, + Optional: true, + Description: fmt.Sprintf("Enables the native Terraform MachineID support. When set, Terraform uses MachineID to securely join the Teleport cluster and obtain credentials. See [the join method reference](./join-methods.mdx) for possible values, you must use [a delegated join method](./join-methods.mdx#secret-vs-delegated). This can also be set with the environment variable `%s`.", constants.EnvVarTerraformJoinMethod), + }, + attributeTerraformJoinToken: { + Type: types.StringType, + Sensitive: false, + Optional: true, + Description: fmt.Sprintf("Name of the token used for the native MachineID joining. This value is not sensitive for [delegated join methods](./join-methods.mdx#secret-vs-delegated). This can also be set with the environment variable `%s`.", constants.EnvVarTerraformJoinToken), + }, }, }, nil } @@ -249,6 +270,13 @@ func (p *Provider) IsConfigured(diags diag.Diagnostics) bool { func (p *Provider) Configure(ctx context.Context, req tfsdk.ConfigureProviderRequest, resp *tfsdk.ConfigureProviderResponse) { p.configureLog() + // We wrap the provider's context into a cancellable one. + // This allows us to cancel the context and properly close the client and any background task potentially running + // (e.g. MachineID bot renewing creds). This is required during the tests as the provider is run multiple times. + // You can cancel the context by calling Provider.Close() + ctx, cancel := context.WithCancel(ctx) + p.cancel = cancel + var config providerData diags := req.Config.Get(ctx, &config) resp.Diagnostics.Append(diags...) @@ -486,3 +514,16 @@ func (p *Provider) GetDataSources(_ context.Context) (map[string]tfsdk.DataSourc "teleport_access_list": dataSourceTeleportAccessListType{}, }, nil } + +// Close closes the provider's client and cancels its context. +// This is needed in the tests to avoid accumulating clients and running out of file descriptors. +func (p *Provider) Close() error { + var err error + if p.Client != nil { + err = p.Client.Close() + } + if p.cancel != nil { + p.cancel() + } + return err +} diff --git a/integrations/terraform/testlib/machineid_join_test.go b/integrations/terraform/testlib/machineid_join_test.go new file mode 100644 index 0000000000000..52299c3cf457e --- /dev/null +++ b/integrations/terraform/testlib/machineid_join_test.go @@ -0,0 +1,164 @@ +/* +Copyright 2024 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 testlib + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/require" + + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/integrations/lib/testing/fakejoin" + "github.com/gravitational/teleport/integrations/lib/testing/integration" + "github.com/gravitational/teleport/lib/kubernetestoken" + "github.com/gravitational/teleport/lib/services" + + "github.com/gravitational/teleport/integrations/terraform/provider" +) + +func TestTerraformJoin(t *testing.T) { + require.NoError(t, os.Setenv("TF_ACC", "true")) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + // Test setup: start a Telpeort auth server + authHelper := &integration.MinimalAuthHelper{} + clt := authHelper.StartServer(t) + + var err error + // Test setup: create the terraform role + tfRole := services.NewPresetTerraformProviderRole() + tfRole, err = clt.CreateRole(ctx, tfRole) + require.NoError(t, err) + + // Test setup: create a fake Kubernetes signer that will allow us to use the kubernetes/jwks join method + clock := clockwork.NewRealClock() + signer, err := fakejoin.NewKubernetesSigner(clock) + require.NoError(t, err) + + jwks, err := signer.GetMarshaledJWKS() + require.NoError(t, err) + + // Test setup: create a token and a bot that can join the cluster with JWT signed by our fake Kubernetes signer + testBotName := "testBot" + testTokenName := "testToken" + fakeNamespace := "test-namespace" + fakeServiceAccount := "test-service-account" + token, err := types.NewProvisionTokenFromSpec( + testTokenName, + clock.Now().Add(time.Hour), + types.ProvisionTokenSpecV2{ + Roles: types.SystemRoles{types.RoleBot}, + JoinMethod: types.JoinMethodKubernetes, + BotName: testBotName, + Kubernetes: &types.ProvisionTokenSpecV2Kubernetes{ + Allow: []*types.ProvisionTokenSpecV2Kubernetes_Rule{ + { + ServiceAccount: fmt.Sprintf("%s:%s", fakeNamespace, fakeServiceAccount), + }, + }, + Type: types.KubernetesJoinTypeStaticJWKS, + StaticJWKS: &types.ProvisionTokenSpecV2Kubernetes_StaticJWKSConfig{ + JWKS: jwks, + }, + }, + }) + require.NoError(t, err) + err = clt.CreateToken(ctx, token) + require.NoError(t, err) + + bot := &machineidv1.Bot{ + Metadata: &headerv1.Metadata{ + Name: testBotName, + }, + Spec: &machineidv1.BotSpec{ + Roles: []string{tfRole.GetName()}, + }, + } + _, err = clt.BotServiceClient().CreateBot(ctx, &machineidv1.CreateBotRequest{Bot: bot}) + require.NoError(t, err) + + // Test setup: sign a Kube JWT for our bot to join the cluster + // We sign the token, write it to a temporary file, and point the embedded tbot to it + // with an environment variable. + pong, err := clt.Ping(ctx) + require.NoError(t, err) + clusterName := pong.ClusterName + jwt, err := signer.SignServiceAccountJWT("pod-name-doesnt-matter", fakeNamespace, fakeServiceAccount, clusterName) + require.NoError(t, err) + + tempDir := t.TempDir() + jwtPath := filepath.Join(tempDir, "token") + require.NoError(t, os.WriteFile(jwtPath, []byte(jwt), 0600)) + require.NoError(t, os.Setenv(kubernetestoken.EnvVarCustomKubernetesTokenPath, jwtPath)) + + // Test setup: craft a Terraform provider configuration + terraformConfig := fmt.Sprintf(` + provider "teleport" { + addr = %q + join_token = %q + join_method = %q + retry_base_duration = "900ms" + retry_cap_duration = "4s" + retry_max_tries = "12" + } + `, authHelper.ServerAddr(), testTokenName, types.JoinMethodKubernetes) + + terraformProvider := provider.New() + terraformProviders := make(map[string]func() (tfprotov6.ProviderServer, error)) + terraformProviders["teleport"] = func() (tfprotov6.ProviderServer, error) { + // Terraform configures provider on every test step, but does not clean up previous one, which produces + // to "too many open files" at some point. + // + // With this statement we try to forcefully close previously opened client, which stays cached in + // the provider variable. + p, ok := terraformProvider.(*provider.Provider) + require.True(t, ok) + require.NoError(t, p.Close()) + return providerserver.NewProtocol6(terraformProvider)(), nil + } + + // Test execution: apply a TF resource with the provider joining via MachineID + dummyResource, err := fixtures.ReadFile(filepath.Join("fixtures", "app_0_create.tf")) + require.NoError(t, err) + testConfig := terraformConfig + "\n" + string(dummyResource) + name := "teleport_app.test" + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: terraformProviders, + Steps: []resource.TestStep{ + { + Config: testConfig, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(name, "kind", "app"), + resource.TestCheckResourceAttr(name, "spec.uri", "localhost:3000"), + ), + }, + }, + }) +} diff --git a/integrations/terraform/testlib/main_test.go b/integrations/terraform/testlib/main_test.go index c714f234d2b33..97d7d35aa805c 100644 --- a/integrations/terraform/testlib/main_test.go +++ b/integrations/terraform/testlib/main_test.go @@ -205,9 +205,7 @@ func (s *TerraformBaseSuite) closeClient() { s.T().Helper() p, ok := s.terraformProvider.(*provider.Provider) require.True(s.T(), ok) - if p != nil && p.Client != nil { - require.NoError(s.T(), p.Client.Close()) - } + require.NoError(s.T(), p.Close()) } // getFixture loads fixture and returns it as string or if failed diff --git a/lib/kubernetestoken/token_source.go b/lib/kubernetestoken/token_source.go index 5d40296f9dd6b..55a506937cc89 100644 --- a/lib/kubernetestoken/token_source.go +++ b/lib/kubernetestoken/token_source.go @@ -24,7 +24,10 @@ import ( "github.com/gravitational/trace" ) -const kubernetesDefaultTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" +const ( + kubernetesDefaultTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" + EnvVarCustomKubernetesTokenPath = "KUBERNETES_TOKEN_PATH" +) type getEnvFunc func(key string) string type readFileFunc func(name string) ([]byte, error) @@ -33,7 +36,7 @@ func GetIDToken(getEnv getEnvFunc, readFile readFileFunc) (string, error) { // We check if we should use a custom location instead of the default one. This env var is not standard. // This is useful when the operator wants to use a custom projected token, or another service account. path := kubernetesDefaultTokenPath - if customPath := getEnv("KUBERNETES_TOKEN_PATH"); customPath != "" { + if customPath := getEnv(EnvVarCustomKubernetesTokenPath); customPath != "" { path = customPath }