From 7be24638d1fa6e3d3c41adcc9f0eaec959291f87 Mon Sep 17 00:00:00 2001 From: Dan Upton Date: Thu, 23 Oct 2025 10:42:23 +0100 Subject: [PATCH 1/5] Terraform: modernize `teleport_bot` resource --- .../provider/resource_teleport_bot.go | 769 +++++++++++++++--- integrations/terraform/testlib/bot_test.go | 110 ++- .../testlib/fixtures/bot_2_new_schema.tf | 38 + .../fixtures/bot_3_new_schema_update.tf | 39 + .../terraform/tfschema/custom_types.go | 4 +- 5 files changed, 839 insertions(+), 121 deletions(-) create mode 100644 integrations/terraform/testlib/fixtures/bot_2_new_schema.tf create mode 100644 integrations/terraform/testlib/fixtures/bot_3_new_schema_update.tf diff --git a/integrations/terraform/provider/resource_teleport_bot.go b/integrations/terraform/provider/resource_teleport_bot.go index 80910de555fa7..14be19fe4dfa7 100644 --- a/integrations/terraform/provider/resource_teleport_bot.go +++ b/integrations/terraform/provider/resource_teleport_bot.go @@ -19,96 +19,210 @@ package provider import ( "context" "fmt" + "time" "github.com/gravitational/trace" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" apitypes "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/api/utils" + "github.com/gravitational/teleport/lib/utils/slices" "github.com/gravitational/teleport/integrations/terraform/tfschema" ) +// GenSchemaBot returns the schema of the `teleport_bot` resource. +// +// This is quite different to our other Terraform resources because it was hand- +// written rather than generated from protobufs. As such, it originally did not +// follow the RFD 153 conventions of having `metadata` and `spec` attributes, +// but we added them later. +// +// In order to make migration as seamless as possible, and to avoid breaking the +// user's existing configuration or introducing a new `teleport_bot_v2` resource, +// this resource supports both the old (top-level) and new (spec.* and metadata.*) +// attributes. +// +// See resourceTeleportBot.botFromProto for more information. func GenSchemaBot(ctx context.Context) (tfsdk.Schema, diag.Diagnostics) { return tfsdk.Schema{ Attributes: map[string]tfsdk.Attribute{ "id": { - Type: types.StringType, - Computed: true, + Computed: true, + PlanModifiers: []tfsdk.AttributePlanModifier{tfsdk.UseStateForUnknown()}, + Type: types.StringType, }, + "kind": { + Computed: true, + Description: "The kind of resource represented.", + Optional: true, + PlanModifiers: []tfsdk.AttributePlanModifier{tfsdk.UseStateForUnknown()}, + Type: types.StringType, + }, + "sub_kind": { + Computed: true, + Description: "Differentiates variations of the same kind. All resources should contain one, even if it is never populated.", + Optional: true, + PlanModifiers: []tfsdk.AttributePlanModifier{tfsdk.UseStateForUnknown()}, + Type: types.StringType, + }, + "version": { + Computed: true, + Description: "The version of the resource being represented.", + Optional: true, + PlanModifiers: []tfsdk.AttributePlanModifier{tfsdk.UseStateForUnknown()}, + Type: types.StringType, + Validators: []tfsdk.AttributeValidator{tfschema.UseVersionBetween(1, 1)}, + }, + "metadata": { + Attributes: tfsdk.SingleNestedAttributes(map[string]tfsdk.Attribute{ + "description": { + Description: "Description is object description", + Optional: true, + Type: types.StringType, + }, + "expires": { + Description: "Expires is a global expiry time header can be set on any resource in the system.", + Optional: true, + Type: tfschema.UseRFC3339Time(), + Validators: []tfsdk.AttributeValidator{tfschema.MustTimeBeInFuture()}, + }, + "labels": { + Description: "Labels is a set of labels", + Optional: true, + Type: types.MapType{ElemType: types.StringType}, + Validators: []tfsdk.AttributeValidator{tfschema.UseMapKeysPresentValidator("teleport.dev/origin")}, + }, + "name": { + Description: "Name is an object name", + PlanModifiers: []tfsdk.AttributePlanModifier{tfsdk.RequiresReplace()}, + Required: true, + Type: types.StringType, + }, + "namespace": { + Computed: true, + Description: "Namespace is object namespace. The field should be called \"namespace\" when it returns in Teleport 2.4.", + Optional: true, + PlanModifiers: []tfsdk.AttributePlanModifier{tfsdk.UseStateForUnknown()}, + Type: types.StringType, + }, + "revision": { + Description: "Revision is an opaque identifier which tracks the versions of a resource over time. Clients should ignore and not alter its value but must return the revision in any updates of a resource.", + Optional: true, + Type: types.StringType, + }, + }), + Description: "Common metadata that all resources share", + Optional: true, + Validators: []tfsdk.AttributeValidator{ + requiredUnlessLegacyValidator{}, + }, + }, + "spec": { + Attributes: tfsdk.SingleNestedAttributes(map[string]tfsdk.Attribute{ + "traits": tfschema.GenSchemaTraits(ctx, tfsdk.Attribute{ + Description: "The traits that will be associated with the bot for the purposes of role templating.\n\nWhere multiple specified with the same name, these will be merged by the server.", + }), + "roles": { + Optional: true, + Description: "A list of roles the created bot should be allowed to assume via role impersonation.", + Type: types.ListType{ElemType: types.StringType}, + }, + "max_session_ttl": { + Optional: true, + Computed: true, + Description: "The max session TTL value for the bot's internal role. Unless specified, bots may not request a value beyond the default maximum TTL of 12 hours. This value may not be larger than 7 days (168 hours).", + Type: tfschema.DurationType{}, + }, + }), + Description: "The configured properties of a bot.", + Optional: true, + Validators: []tfsdk.AttributeValidator{ + requiredUnlessLegacyValidator{}, + }, + }, + "status": { + Attributes: tfsdk.SingleNestedAttributes(map[string]tfsdk.Attribute{ + "user_name": { + Description: "The name of the user associated with the bot.", + Type: types.StringType, + Computed: true, + }, + "role_name": { + Description: "The name of the role associated with the bot.", + Type: types.StringType, + Computed: true, + }, + }), + Description: "Fields that are set by the server as results of operations. These should not be modified by users.", + Computed: true, + }, + + // Deprecated fields. "name": { - Type: types.StringType, - Required: true, - Description: "The name of the bot, i.e. the unprefixed User name", - PlanModifiers: []tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + Type: types.StringType, + Optional: true, + Description: "The name of the bot, i.e. the unprefixed User name", + DeprecationMessage: "Deprecated. Used `metadata.name` instead.", + Validators: []tfsdk.AttributeValidator{ + rfd153OnlyValidator{}, }, + PlanModifiers: []tfsdk.AttributePlanModifier{tfsdk.RequiresReplace()}, }, "user_name": { - Type: types.StringType, - Computed: true, - Description: "The name of the generated bot user", + Type: types.StringType, + Computed: true, + Description: "The name of the generated bot user", + DeprecationMessage: "Deprecated. Use `status.user_name` instead.", }, "role_name": { - Type: types.StringType, - Computed: true, - Description: "The name of the generated bot role", + Type: types.StringType, + Computed: true, + Description: "The name of the generated bot role", + DeprecationMessage: "Deprecated. Use `status.role_name` instead.", }, "token_ttl": { - Type: types.StringType, - Optional: true, - Computed: true, - Description: "Deprecated. This field is not required anymore and has no effect.", + Type: types.StringType, + Optional: true, + Computed: true, + DeprecationMessage: "Deprecated. This field is not required anymore and has no effect.", }, "token_id": { Type: types.StringType, // Implementation note: this is not used anymore, we can skip this // This will go away eventually when we'll generate the bot provider instead - Optional: true, - Sensitive: true, - Description: "Deprecated. This field is not required anymore and has no effect.", + Optional: true, + Sensitive: true, + DeprecationMessage: "Deprecated. This field is not required anymore and has no effect.", }, "roles": { Type: types.ListType{ ElemType: types.StringType, }, - Required: true, - Description: "A list of roles the created bot should be allowed to assume via role impersonation.", - - // TODO: Consider dropping RequiresReplace() in the future if a - // UpdateBotRoles() API becomes available that can modify the - // underlying bot user. - PlanModifiers: []tfsdk.AttributePlanModifier{ - tfsdk.RequiresReplace(), + Optional: true, + Description: "A list of roles the created bot should be allowed to assume via role impersonation.", + DeprecationMessage: "Deprecated. Use `spec.roles` instead.", + Validators: []tfsdk.AttributeValidator{ + rfd153OnlyValidator{}, }, }, - // Implementation note: This needs RequiresReplace() to handle - // updates properly but we aren't able to attach plan modifiers to - // fields from schema methods here. See ModifyPlan below. - "traits": tfschema.GenSchemaTraits(ctx, tfsdk.Attribute{}), + "traits": tfschema.GenSchemaTraits(ctx, tfsdk.Attribute{ + DeprecationMessage: "Deprecated. Use `spec.traits` instead.", + Validators: []tfsdk.AttributeValidator{ + rfd153OnlyValidator{}, + }, + }), }, }, nil } -// Bot is a deserializes representation of the terraform state for this -// resource. -type Bot struct { - ID types.String `tfsdk:"id"` - Name types.String `tfsdk:"name"` - Roles []types.String `tfsdk:"roles"` - TokenID types.String `tfsdk:"token_id"` - Traits types.Map `tfsdk:"traits"` - TTL types.String `tfsdk:"token_ttl"` - - UserName types.String `tfsdk:"user_name"` - RoleName types.String `tfsdk:"role_name"` -} - // resourceTeleportBotType is the resource metadata type type resourceTeleportBotType struct{} @@ -143,113 +257,261 @@ func (r resourceTeleportBot) Create(ctx context.Context, req tfsdk.CreateResourc return } - var roles []string - for _, role := range plan.Roles { - roles = append(roles, role.Value) + response, err := r.p.Client.BotServiceClient(). + CreateBot(ctx, &machineidv1.CreateBotRequest{Bot: plan.ToProto()}) + if err != nil { + resp.Diagnostics.Append(diagFromWrappedErr("Error creating Bot", trace.Wrap(err), "bot")) + return } - traits := make([]*machineidv1.Trait, 0, len(plan.Traits.Elems)) - for name, e := range plan.Traits.Elems { - l, ok := e.(types.List) - if !ok { - diags.AddError("Error reading from Terraform object", fmt.Sprintf("Can not convert %T to types.List", l)) - return + diags = resp.State.Set(ctx, r.botFromProto(ctx, response, plan)) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r resourceTeleportBot) Read(ctx context.Context, req tfsdk.ReadResourceRequest, resp *tfsdk.ReadResourceResponse) { + var state Bot + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + bot, err := r.p.Client.BotServiceClient().GetBot(ctx, &machineidv1.GetBotRequest{ + BotName: state.GetName(), + }) + switch { + case trace.IsNotFound(err): + resp.State.RemoveResource(ctx) + return + case err != nil: + resp.Diagnostics.Append(diagFromWrappedErr("Error reading Bot", trace.Wrap(err), "bot")) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.botFromProto(ctx, bot, state))...) +} + +// botFromProto converts the server/protobuf representation of a bot to Terraform +// using the given "base" (plan or state) to determine whether to fill the legacy +// or new attributes. +// +// NOTE: Terraform is *very* fussy about non-computed attributes changing between +// plan and apply, even in benign ways such as null becoming an empty list, so +// exercise caution! +func (r resourceTeleportBot) botFromProto(ctx context.Context, bot *machineidv1.Bot, base Bot) Bot { + schema, _ := GenSchemaBot(ctx) + + attrTypes := func(key string) map[string]attr.Type { + result := make(map[string]attr.Type) + for k, v := range schema.Attributes[key].Attributes.GetAttributes() { + result[k] = v.Type } + return result + } - values := make(utils.Strings, len(l.Elems)) + stringValue := func(v string) types.String { + return types.String{Value: v, Null: v == ""} + } - for i, v := range l.Elems { - s, ok := v.(types.String) - if !ok { - diags.AddError("Error reading from Terraform object", fmt.Sprintf("Can not convert %T to types.String", s)) - return - } + timeValue := func(v *timestamppb.Timestamp) tfschema.TimeValue { + if v == nil { + return tfschema.TimeValue{Null: true} + } + return tfschema.TimeValue{ + Value: v.AsTime(), + Format: time.RFC3339, + } + } - values[i] = s.Value + durationValue := func(v *durationpb.Duration) tfschema.DurationValue { + if v == nil { + return tfschema.DurationValue{Null: true} } - traits = append(traits, &machineidv1.Trait{ - Name: name, - Values: values, - }) + return tfschema.DurationValue{Value: v.AsDuration()} } - // This is a temporary workaround to fix the provider compilation in v16 (the legacy RPC got removed). - // We must do a breaking change in v16 and rely on the new bot schema and the tf code generator. - response, err := r.p.Client.BotServiceClient().CreateBot(ctx, &machineidv1.CreateBotRequest{Bot: &machineidv1.Bot{ - Kind: apitypes.KindBot, - Version: apitypes.V1, - Metadata: &headerv1.Metadata{ - Name: plan.Name.Value, + attrNull := func(obj types.Object, name string) bool { + if obj.IsNull() { + return true + } + if obj.Attrs == nil { + return true + } + attr, ok := obj.Attrs[name] + return !ok || attr.IsNull() + } + + result := Bot{ + // User-provided attributes. Will be marked as null based on whether the + // user provided legacy or RFD 153-style attributes. + Metadata: types.Object{AttrTypes: attrTypes("metadata")}, + Spec: types.Object{AttrTypes: attrTypes("spec")}, + + // Deprecated user-provided attributes. + Name: types.String{}, + + // Computed attributes. + ID: stringValue(bot.GetMetadata().GetName()), + Kind: stringValue(bot.GetKind()), + SubKind: stringValue(bot.GetSubKind()), + Version: stringValue(bot.GetVersion()), + Status: types.Object{ + AttrTypes: attrTypes("status"), + Attrs: map[string]attr.Value{ + "user_name": stringValue(bot.GetStatus().GetUserName()), + "role_name": stringValue(bot.GetStatus().GetRoleName()), + }, }, - Spec: &machineidv1.BotSpec{ - Roles: roles, - Traits: traits, + + // Deprecated computed attributes. We still set them for backward-compatibility. + UserName: stringValue(bot.GetStatus().GetUserName()), + RoleName: stringValue(bot.GetStatus().GetRoleName()), + TokenID: base.TokenID, + } + + if base.TTL.Unknown { + result.TTL = types.String{Null: true} + } else { + result.TTL = base.TTL + } + + // If the plan or state includes the metadata or spec attribute, it means + // the user has "opted in" to the new RFD 153-style attributes. Otherwise, + // for backward-compatibility we'll populate the old fields. + rfd153Style := attrPresent(base.Metadata) || attrPresent(base.Spec) + + if rfd153Style { + result.Name.Null = true + + labels := types.Map{ + Elems: make(map[string]attr.Value), + ElemType: types.StringType, + Null: len(bot.GetMetadata().GetLabels()) == 0 && attrNull(base.Metadata, "labels"), + } + for k, v := range bot.GetMetadata().GetLabels() { + labels.Elems[k] = types.String{Value: v} + } + + result.Metadata.Attrs = map[string]attr.Value{ + "description": stringValue(bot.GetMetadata().GetDescription()), + "expires": timeValue(bot.GetMetadata().GetExpires()), + "name": stringValue(bot.GetMetadata().GetName()), + "namespace": stringValue(bot.GetMetadata().GetNamespace()), + "revision": stringValue(bot.GetMetadata().GetRevision()), + "labels": labels, + } + } else { + result.Metadata.Null = true + result.Name.Value = bot.GetMetadata().GetName() + } + + // If the traits list is empty, check the plan or state for whether we + // should treat the attribute as null or an empty list. + traitsNull := (!rfd153Style && base.Traits.IsNull()) || + (rfd153Style && attrNull(base.Spec, "traits")) + + traits := types.Map{ + Null: len(bot.GetSpec().GetTraits()) == 0 && traitsNull, + Elems: map[string]attr.Value{}, + ElemType: types.ListType{ + ElemType: types.StringType, }, - }}) - if err != nil { - resp.Diagnostics.Append(diagFromWrappedErr("Error creating Bot", trace.Wrap(err), "bot")) - return + } + for _, trait := range bot.GetSpec().GetTraits() { + traits.Elems[trait.GetName()] = types.List{ + Elems: slices.Map(trait.GetValues(), func(s string) attr.Value { + return types.String{Value: s} + }), + ElemType: types.StringType, + } } - plan.TTL = types.String{Value: ""} - plan.UserName = types.String{Value: response.Status.UserName} - plan.RoleName = types.String{Value: response.Status.RoleName} + if rfd153Style { + result.Spec.Attrs = map[string]attr.Value{ + "traits": traits, + "roles": types.List{ + Elems: slices.Map(bot.GetSpec().GetRoles(), func(s string) attr.Value { + return types.String{Value: s} + }), + ElemType: types.StringType, + Null: len(bot.GetSpec().GetRoles()) == 0 && attrNull(base.Spec, "roles"), + }, + "max_session_ttl": durationValue(bot.GetSpec().GetMaxSessionTtl()), + } - // ID is for terraform-plugin-framework's acctests - plan.ID = types.String{Value: plan.Name.Value} + // Duration values can have different equivalent string representations + // that cause Terraform to believe state has drifted or changed between + // plan and apply (e.g. "5m" vs "5m0s"). + // + // This is already handled by DurationValue.ToTerraformValue, but it + // relies on storing the raw string in an unexported struct member, so + // we restore the original value if it's equivalent to what the server + // returned. + if attrPresent(base.Spec) { + if prev, ok := base.Spec.Attrs["max_session_ttl"]; ok { + if dur, ok := prev.(tfschema.DurationValue); ok { + if dur.Value == bot.GetSpec().GetMaxSessionTtl().AsDuration() { + result.Spec.Attrs["max_session_ttl"] = prev + } + } + } + } - diags = resp.State.Set(ctx, &plan) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return + result.Traits = types.Map{ + Null: true, + ElemType: types.ListType{ElemType: types.StringType}, + } + } else { + result.Spec.Null = true + result.Traits = traits + + if len(bot.GetSpec().GetRoles()) != 0 || base.Roles != nil { + result.Roles = slices.Map(bot.GetSpec().GetRoles(), func(s string) types.String { + return types.String{Value: s} + }) + } } + + return result } -func (r resourceTeleportBot) Read(ctx context.Context, req tfsdk.ReadResourceRequest, resp *tfsdk.ReadResourceResponse) { - // Not much to do here: bots are currently immutable. We'll just check for - // deletion. +func (r resourceTeleportBot) Update(ctx context.Context, req tfsdk.UpdateResourceRequest, resp *tfsdk.UpdateResourceResponse) { + if !r.p.IsConfigured(resp.Diagnostics) { + return + } var plan Bot - diags := req.State.Get(ctx, &plan) + diags := req.Plan.Get(ctx, &plan) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } - _, err := r.p.Client.GetUser(ctx, plan.UserName.Value, false) - if trace.IsNotFound(err) { - resp.State.RemoveResource(ctx) + rsp, err := r.p.Client.BotServiceClient(). + UpsertBot(ctx, &machineidv1.UpsertBotRequest{Bot: plan.ToProto()}) + if err != nil { + resp.Diagnostics.Append(diagFromWrappedErr("Error updating Bot", err, "bot")) return } - if err != nil { - resp.Diagnostics.Append(diagFromWrappedErr("Error reading Bot", trace.Wrap(err), "bot")) + diags = resp.State.Set(ctx, r.botFromProto(ctx, rsp, plan)) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { return } } -func (r resourceTeleportBot) Update(ctx context.Context, req tfsdk.UpdateResourceRequest, resp *tfsdk.UpdateResourceResponse) { - // Nothing to do here: bots are currently immutable. In the future we'd - // ideally want to add specific RPCs for desired mutable attributes, e.g. - // UpdateBotRoles(), UpdateBotToken(), etc. -} - -func (r resourceTeleportBot) ModifyPlan(ctx context.Context, req tfsdk.ModifyResourcePlanRequest, resp *tfsdk.ModifyResourcePlanResponse) { - // Add .traits to RequiresReplace to ensure changes to this field trigger a - // replacement. We can't set it in the schema as the attribute is generated - // by a helper method. - resp.RequiresReplace = append(resp.RequiresReplace, path.Root("traits")) -} - func (r resourceTeleportBot) Delete(ctx context.Context, req tfsdk.DeleteResourceRequest, resp *tfsdk.DeleteResourceResponse) { - var name types.String - diags := req.State.GetAttribute(ctx, path.Root("name"), &name) + var state Bot + diags := req.State.Get(ctx, &state) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } - - _, err := r.p.Client.BotServiceClient().DeleteBot(ctx, &machineidv1.DeleteBotRequest{BotName: name.Value}) + _, err := r.p.Client.BotServiceClient(). + DeleteBot(ctx, &machineidv1.DeleteBotRequest{BotName: state.GetName()}) if err != nil { resp.Diagnostics.Append(diagFromWrappedErr("Error deleting Bot", trace.Wrap(err), "bot")) return @@ -257,3 +519,276 @@ func (r resourceTeleportBot) Delete(ctx context.Context, req tfsdk.DeleteResourc resp.State.RemoveResource(ctx) } + +// rfd153OnlyValidator is used to ensure the user doesn't provide a mix of old +// and new style attributes (e.g. `spec.roles` and `.traits`). +type rfd153OnlyValidator struct{} + +func (rfd153OnlyValidator) Description(context.Context) string { + return "Checks that deprecated attributes are not mixed and matched with their replacements" +} + +func (v rfd153OnlyValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v rfd153OnlyValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, rsp *tfsdk.ValidateAttributeResponse) { + if !attrPresent(req.AttributeConfig) { + return + } + + var meta, spec types.Object + req.Config.GetAttribute(ctx, path.Root("metadata"), &meta) + req.Config.GetAttribute(ctx, path.Root("spec"), &spec) + + if attrPresent(meta) || attrPresent(spec) { + rsp.Diagnostics.AddAttributeError( + req.AttributePath, + "Attribute Validation Error", + fmt.Sprintf("The deprecated `%s` attribute cannot be used in combination with `spec` or `metadata`.", req.AttributePath), + ) + } +} + +// requiredUnlessLegacyValidator makes the attribute required *unless* the user +// provided a deprecated/legacy attribute instead (i.e. because they haven't yet +// migrated their configuration). +type requiredUnlessLegacyValidator struct{} + +func (requiredUnlessLegacyValidator) Description(context.Context) string { + return "Marks attributes required unless an equivalent legacy/deprecated attribute is provided" +} + +func (v requiredUnlessLegacyValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v requiredUnlessLegacyValidator) Validate(ctx context.Context, req tfsdk.ValidateAttributeRequest, rsp *tfsdk.ValidateAttributeResponse) { + if attrPresent(req.AttributeConfig) { + return + } + + var legacyConfig bool + for _, name := range []string{"name", "roles", "traits"} { + var attr attr.Value + req.Config.GetAttribute(ctx, path.Root(name), &attr) + if attrPresent(attr) { + legacyConfig = true + break + } + } + + if !legacyConfig { + rsp.Diagnostics.AddAttributeError( + req.AttributePath, + "Missing required argument", + fmt.Sprintf("The argument `%s` is required, but not definition was found.", req.AttributePath), + ) + } +} + +var ( + _ tfsdk.AttributeValidator = rfd153OnlyValidator{} + _ tfsdk.AttributeValidator = requiredUnlessLegacyValidator{} +) + +// Bot is the Terraform (i.e. state or plan) representation of a bot. +type Bot struct { + ID types.String `tfsdk:"id"` + + Kind types.String `tfsdk:"kind"` + SubKind types.String `tfsdk:"sub_kind"` + Version types.String `tfsdk:"version"` + + Metadata types.Object `tfsdk:"metadata"` + Spec types.Object `tfsdk:"spec"` + Status types.Object `tfsdk:"status"` + + // Deprecated fields + Name types.String `tfsdk:"name"` + Roles []types.String `tfsdk:"roles"` + TokenID types.String `tfsdk:"token_id"` + Traits types.Map `tfsdk:"traits"` + TTL types.String `tfsdk:"token_ttl"` + UserName types.String `tfsdk:"user_name"` + RoleName types.String `tfsdk:"role_name"` +} + +func (b Bot) ToProto() *machineidv1.Bot { + return &machineidv1.Bot{ + Kind: apitypes.KindBot, + Version: apitypes.V1, + Metadata: b.GetMetadata(), + Spec: b.GetSpec(), + } +} + +func (b Bot) GetMetadata() *headerv1.Metadata { + return &headerv1.Metadata{ + Name: b.GetName(), + Description: b.GetDescription(), + Expires: b.GetExpires(), + Namespace: b.GetNamespace(), + Revision: b.GetRevision(), + Labels: b.GetLabels(), + } +} + +func (b Bot) GetSpec() *machineidv1.BotSpec { + return &machineidv1.BotSpec{ + Traits: b.GetTraits(), + Roles: b.GetRoles(), + MaxSessionTtl: b.GetMaxSessionTTL(), + } +} + +func (b Bot) GetName() string { + if attrPresent(b.Name) { + return b.Name.Value + } else { + if nm, ok := b.Metadata.Attrs["name"]; ok { + if str, ok := nm.(types.String); ok { + return str.Value + } + } + } + return "" +} + +func (b Bot) GetDescription() string { + if desc, ok := b.Metadata.Attrs["description"]; ok { + if str, ok := desc.(types.String); ok { + return str.Value + } + } + return "" +} + +func (b Bot) GetExpires() *timestamppb.Timestamp { + if exp, ok := b.Metadata.Attrs["expires"]; ok { + if tv, ok := exp.(tfschema.TimeValue); ok { + return timestamppb.New(tv.Value) + } + } + return nil +} + +func (b Bot) GetNamespace() string { + if ns, ok := b.Metadata.Attrs["namespace"]; ok { + if str, ok := ns.(types.String); ok { + return str.Value + } + } + return "" +} + +func (b Bot) GetRevision() string { + if rev, ok := b.Metadata.Attrs["revision"]; ok { + if str, ok := rev.(types.String); ok { + return str.Value + } + } + return "" +} + +func (b Bot) GetLabels() map[string]string { + if !attrPresent(b.Metadata) { + return nil + } + + lbs, ok := b.Metadata.Attrs["labels"] + if !ok { + return nil + } + + mp, ok := lbs.(types.Map) + if !ok { + return nil + } + + labels := make(map[string]string, len(mp.Elems)) + for key, val := range mp.Elems { + if str, ok := val.(types.String); ok { + labels[key] = str.Value + } + } + return labels +} + +func (b Bot) GetTraits() []*machineidv1.Trait { + var traitMap map[string]attr.Value + if attrPresent(b.Spec) { + if ts, ok := b.Spec.Attrs["traits"]; ok && attrPresent(ts) { + if mp, ok := ts.(types.Map); ok { + traitMap = mp.Elems + } + } + } else if attrPresent(b.Traits) { + traitMap = b.Traits.Elems + } + + if traitMap == nil { + return nil + } + + traits := make([]*machineidv1.Trait, 0, len(traitMap)) + for name, val := range traitMap { + list, ok := val.(types.List) + if !ok { + continue + } + values := slices.FilterMapUnique(list.Elems, func(v attr.Value) (string, bool) { + if str, ok := v.(types.String); ok { + return str.Value, true + } + return "", false + }) + traits = append(traits, &machineidv1.Trait{ + Name: name, + Values: values, + }) + } + return traits +} + +func (b Bot) GetRoles() []string { + if attrPresent(b.Spec) { + if rs, ok := b.Spec.Attrs["roles"]; ok { + if list, ok := rs.(types.List); ok { + return slices.FilterMapUnique(list.Elems, func(v attr.Value) (string, bool) { + if str, ok := v.(types.String); ok { + return str.Value, true + } + return "", false + }) + } + } + return nil + } + + return slices.Map(b.Roles, func(v types.String) string { + return v.Value + }) +} + +func (b Bot) GetMaxSessionTTL() *durationpb.Duration { + if !attrPresent(b.Spec) { + return nil + } + + ttl, ok := b.Spec.Attrs["max_session_ttl"] + if !ok { + return nil + } + + dur, ok := ttl.(tfschema.DurationValue) + if !ok { + return nil + } + + return durationpb.New(dur.Value) +} + +func attrPresent(v attr.Value) bool { + return !v.IsNull() && !v.IsUnknown() +} diff --git a/integrations/terraform/testlib/bot_test.go b/integrations/terraform/testlib/bot_test.go index f83b995feef3e..6f482adbf7117 100644 --- a/integrations/terraform/testlib/bot_test.go +++ b/integrations/terraform/testlib/bot_test.go @@ -47,6 +47,8 @@ func (s *TerraformSuiteOSS) TestBot() { tokenName := "teleport_provision_token.bot_test" botName := "teleport_bot.test" + + // Test starting with old schema. resource.Test(s.T(), resource.TestCase{ ProtoV6ProviderFactories: s.terraformProviders, CheckDestroy: checkResourcesDestroyed, @@ -81,10 +83,69 @@ func (s *TerraformSuiteOSS) TestBot() { resource.TestCheckResourceAttr(botName, "role_name", "bot-test"), resource.TestCheckResourceAttr(botName, "token_id", "bot-test"), resource.TestCheckResourceAttr(botName, "roles.0", "terraform"), + resource.TestCheckResourceAttr(botName, "traits.logins1.0", "example"), + ), + }, + { + Config: s.getFixture("bot_1_update.tf"), + PlanOnly: true, + }, - // Note: traits are immutable and the plan will not converge - // if the resource is not recreated when traits are - // modified. + // Switch to new schema (same resource attributes). + { + Config: s.getFixture("bot_2_new_schema.tf"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(tokenName, "kind", "token"), + resource.TestCheckResourceAttr(tokenName, "metadata.name", "bot-test"), + resource.TestCheckResourceAttr(tokenName, "spec.roles.0", "Bot"), + resource.TestCheckResourceAttr(botName, "metadata.name", "test"), + resource.TestCheckResourceAttr(botName, "user_name", "bot-test"), + resource.TestCheckResourceAttr(botName, "status.user_name", "bot-test"), + resource.TestCheckResourceAttr(botName, "role_name", "bot-test"), + resource.TestCheckResourceAttr(botName, "status.role_name", "bot-test"), + resource.TestCheckResourceAttr(botName, "spec.roles.0", "terraform"), + resource.TestCheckResourceAttr(botName, "spec.traits.logins1.0", "example"), + ), + }, + { + Config: s.getFixture("bot_2_new_schema.tf"), + PlanOnly: true, + }, + + // Update with the new schema. + { + Config: s.getFixture("bot_3_new_schema_update.tf"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(tokenName, "kind", "token"), + resource.TestCheckResourceAttr(tokenName, "metadata.name", "bot-test"), + resource.TestCheckResourceAttr(tokenName, "spec.roles.0", "Bot"), + resource.TestCheckResourceAttr(botName, "metadata.name", "test"), + resource.TestCheckResourceAttr(botName, "metadata.labels.team", "engineering"), + resource.TestCheckResourceAttr(botName, "user_name", "bot-test"), + resource.TestCheckResourceAttr(botName, "status.user_name", "bot-test"), + resource.TestCheckResourceAttr(botName, "role_name", "bot-test"), + resource.TestCheckResourceAttr(botName, "status.role_name", "bot-test"), + resource.TestCheckResourceAttr(botName, "spec.roles.1", "deployer"), + resource.TestCheckResourceAttr(botName, "spec.max_session_ttl", "1h"), + ), + }, + { + Config: s.getFixture("bot_3_new_schema_update.tf"), + PlanOnly: true, + }, + + // Test reverting back to the old schema. + { + Config: s.getFixture("bot_1_update.tf"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(tokenName, "kind", "token"), + resource.TestCheckResourceAttr(tokenName, "metadata.name", "bot-test"), + resource.TestCheckResourceAttr(tokenName, "spec.roles.0", "Bot"), + resource.TestCheckResourceAttr(botName, "name", "test"), + resource.TestCheckResourceAttr(botName, "user_name", "bot-test"), + resource.TestCheckResourceAttr(botName, "role_name", "bot-test"), + resource.TestCheckResourceAttr(botName, "token_id", "bot-test"), + resource.TestCheckResourceAttr(botName, "roles.0", "terraform"), resource.TestCheckResourceAttr(botName, "traits.logins1.0", "example"), ), }, @@ -95,4 +156,47 @@ func (s *TerraformSuiteOSS) TestBot() { }, }) + // Test with only new schema. + resource.Test(s.T(), resource.TestCase{ + ProtoV6ProviderFactories: s.terraformProviders, + CheckDestroy: checkResourcesDestroyed, + IsUnitTest: true, + Steps: []resource.TestStep{ + { + Config: s.getFixture("bot_2_new_schema.tf"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(tokenName, "kind", "token"), + resource.TestCheckResourceAttr(tokenName, "metadata.name", "bot-test"), + resource.TestCheckResourceAttr(tokenName, "spec.roles.0", "Bot"), + resource.TestCheckResourceAttr(botName, "metadata.name", "test"), + resource.TestCheckResourceAttr(botName, "user_name", "bot-test"), + resource.TestCheckResourceAttr(botName, "status.user_name", "bot-test"), + resource.TestCheckResourceAttr(botName, "role_name", "bot-test"), + resource.TestCheckResourceAttr(botName, "status.role_name", "bot-test"), + resource.TestCheckResourceAttr(botName, "spec.roles.0", "terraform"), + resource.TestCheckResourceAttr(botName, "spec.traits.logins1.0", "example"), + ), + }, + { + Config: s.getFixture("bot_2_new_schema.tf"), + PlanOnly: true, + }, + { + Config: s.getFixture("bot_3_new_schema_update.tf"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(tokenName, "kind", "token"), + resource.TestCheckResourceAttr(tokenName, "metadata.name", "bot-test"), + resource.TestCheckResourceAttr(tokenName, "spec.roles.0", "Bot"), + resource.TestCheckResourceAttr(botName, "metadata.name", "test"), + resource.TestCheckResourceAttr(botName, "metadata.labels.team", "engineering"), + resource.TestCheckResourceAttr(botName, "user_name", "bot-test"), + resource.TestCheckResourceAttr(botName, "status.user_name", "bot-test"), + resource.TestCheckResourceAttr(botName, "role_name", "bot-test"), + resource.TestCheckResourceAttr(botName, "status.role_name", "bot-test"), + resource.TestCheckResourceAttr(botName, "spec.roles.1", "deployer"), + resource.TestCheckResourceAttr(botName, "spec.max_session_ttl", "1h"), + ), + }, + }, + }) } diff --git a/integrations/terraform/testlib/fixtures/bot_2_new_schema.tf b/integrations/terraform/testlib/fixtures/bot_2_new_schema.tf new file mode 100644 index 0000000000000..3999b25027e9c --- /dev/null +++ b/integrations/terraform/testlib/fixtures/bot_2_new_schema.tf @@ -0,0 +1,38 @@ +locals { + bot_name = "test" +} + +resource "teleport_provision_token" "bot_test" { + version = "v2" + metadata = { + expires = "2038-01-01T00:00:00Z" + name = "bot-test" + } + + spec = { + roles = ["Bot"] + bot_name = local.bot_name + join_method = "token" + } +} + +resource "teleport_bot" "test" { + version = "v1" + + metadata = { + name = local.bot_name + } + + spec = { + roles = ["terraform"] + + traits = { + logins1 = ["example"] + logins2 = ["example"] + } + } + + depends_on = [ + teleport_provision_token.bot_test + ] +} diff --git a/integrations/terraform/testlib/fixtures/bot_3_new_schema_update.tf b/integrations/terraform/testlib/fixtures/bot_3_new_schema_update.tf new file mode 100644 index 0000000000000..b3d757c64c588 --- /dev/null +++ b/integrations/terraform/testlib/fixtures/bot_3_new_schema_update.tf @@ -0,0 +1,39 @@ +locals { + bot_name = "test" +} + +resource "teleport_provision_token" "bot_test" { + version = "v2" + metadata = { + expires = "2038-01-01T00:00:00Z" + name = "bot-test" + } + + spec = { + roles = ["Bot"] + bot_name = local.bot_name + join_method = "token" + } +} + +resource "teleport_bot" "test" { + version = "v1" + + metadata = { + name = local.bot_name + labels = { + "teleport.dev/origin" = "config-file" + "team" = "engineering" + } + expires = "2038-01-01T00:00:00Z" + } + + spec = { + max_session_ttl = "1h" + roles = ["terraform", "deployer"] + } + + depends_on = [ + teleport_provision_token.bot_test + ] +} diff --git a/integrations/terraform/tfschema/custom_types.go b/integrations/terraform/tfschema/custom_types.go index 5a0fd458a629b..d05c2e1aae4d4 100644 --- a/integrations/terraform/tfschema/custom_types.go +++ b/integrations/terraform/tfschema/custom_types.go @@ -50,7 +50,9 @@ func GenSchemaTraits(_ context.Context, attr tfsdk.Attribute) tfsdk.Attribute { ElemType: types.StringType, }, }, - Description: attr.Description, + Description: attr.Description, + DeprecationMessage: attr.DeprecationMessage, + Validators: attr.Validators, } } From 998b9bd1c1658ac14a63b32e35087e5c913a4fc6 Mon Sep 17 00:00:00 2001 From: Dan Upton Date: Mon, 27 Oct 2025 15:43:20 +0000 Subject: [PATCH 2/5] Terraform: support for importing `teleport_bot` resources --- .../provider/resource_teleport_bot.go | 15 +++++ integrations/terraform/testlib/bot_test.go | 66 +++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/integrations/terraform/provider/resource_teleport_bot.go b/integrations/terraform/provider/resource_teleport_bot.go index 14be19fe4dfa7..afa839706f4a5 100644 --- a/integrations/terraform/provider/resource_teleport_bot.go +++ b/integrations/terraform/provider/resource_teleport_bot.go @@ -520,6 +520,21 @@ func (r resourceTeleportBot) Delete(ctx context.Context, req tfsdk.DeleteResourc resp.State.RemoveResource(ctx) } +func (r resourceTeleportBot) ImportState(ctx context.Context, req tfsdk.ImportResourceStateRequest, rsp *tfsdk.ImportResourceStateResponse) { + bot, err := r.p.Client.BotServiceClient(). + GetBot(ctx, &machineidv1.GetBotRequest{BotName: req.ID}) + if err != nil { + rsp.Diagnostics.Append(diagFromWrappedErr("Error reading Bot", trace.Wrap(err), "bot")) + return + } + + diags := rsp.State.Set(ctx, r.botFromProto(ctx, bot, Bot{})) + rsp.Diagnostics.Append(diags...) + if rsp.Diagnostics.HasError() { + return + } +} + // rfd153OnlyValidator is used to ensure the user doesn't provide a mix of old // and new style attributes (e.g. `spec.roles` and `.traits`). type rfd153OnlyValidator struct{} diff --git a/integrations/terraform/testlib/bot_test.go b/integrations/terraform/testlib/bot_test.go index 6f482adbf7117..a166439c27b56 100644 --- a/integrations/terraform/testlib/bot_test.go +++ b/integrations/terraform/testlib/bot_test.go @@ -18,10 +18,18 @@ package testlib import ( "context" + "fmt" + "time" + 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/trace" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" ) func (s *TerraformSuiteOSS) TestBot() { @@ -200,3 +208,61 @@ func (s *TerraformSuiteOSS) TestBot() { }, }) } + +func (s *TerraformSuiteOSS) TestImportBot() { + t := s.T() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + r := "teleport_bot" + id := "test_import" + name := r + "." + id + + bot := &machineidv1.Bot{ + Kind: types.KindBot, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: id, + }, + Spec: &machineidv1.BotSpec{ + Roles: []string{"deployer"}, + Traits: []*machineidv1.Trait{ + { + Name: "logins", + Values: []string{"root", "ubuntu"}, + }, + }, + MaxSessionTtl: durationpb.New(5 * time.Minute), + }, + } + bot, err := s.client.BotServiceClient(). + CreateBot(ctx, &machineidv1.CreateBotRequest{Bot: bot}) + require.NoError(t, err) + + require.Eventually(t, func() bool { + _, err := s.client.BotServiceClient(). + GetBot(ctx, &machineidv1.GetBotRequest{BotName: bot.Metadata.Name}) + return err == nil + }, 5*time.Second, time.Second) + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: s.terraformProviders, + IsUnitTest: true, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf("%s\nresource %q %q { }", s.terraformConfig, r, id), + ResourceName: name, + ImportState: true, + ImportStateId: id, + ImportStateCheck: func(state []*terraform.InstanceState) error { + assert.Equal(t, types.KindBot, state[0].Attributes["kind"]) + assert.Equal(t, "5m0s", state[0].Attributes["spec.max_session_ttl"]) + assert.Equal(t, "deployer", state[0].Attributes["spec.roles.0"]) + assert.Equal(t, "root", state[0].Attributes["spec.traits.logins.0"]) + + return nil + }, + }, + }, + }) +} From 2446002e005eb7223be7ee91f27a1a9372b5e85e Mon Sep 17 00:00:00 2001 From: Dan Upton Date: Mon, 27 Oct 2025 15:51:40 +0000 Subject: [PATCH 3/5] Terraform: update the provider reference docs --- .../terraform-provider/resources/bot.mdx | 60 +++++++++++++++---- .../resources/teleport_bot/resource.tf | 9 ++- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/docs/pages/reference/infrastructure-as-code/terraform-provider/resources/bot.mdx b/docs/pages/reference/infrastructure-as-code/terraform-provider/resources/bot.mdx index a36a1adc8b663..d8520492124a3 100644 --- a/docs/pages/reference/infrastructure-as-code/terraform-provider/resources/bot.mdx +++ b/docs/pages/reference/infrastructure-as-code/terraform-provider/resources/bot.mdx @@ -50,26 +50,64 @@ resource "teleport_provision_token" "bot_example" { } resource "teleport_bot" "example" { - name = local.bot_name - roles = ["access"] + metadata = { + name = local.bot_name + } + + spec = { + roles = ["access"] + } } ``` {/* schema generated by tfplugindocs */} ## Schema -### Required +### Optional + +- `metadata` (Attributes) Common metadata that all resources share (see [below for nested schema](#nested-schema-for-metadata)) +- `name` (String, Deprecated) The name of the bot, i.e. the unprefixed User name +- `roles` (List of String, Deprecated) A list of roles the created bot should be allowed to assume via role impersonation. +- `spec` (Attributes) The configured properties of a bot. (see [below for nested schema](#nested-schema-for-spec)) +- `sub_kind` (String) Differentiates variations of the same kind. All resources should contain one, even if it is never populated. +- `token_id` (String, Sensitive, Deprecated) +- `token_ttl` (String, Deprecated) +- `traits` (Map of List of String, Deprecated) +- `version` (String) The version of the resource being represented. + +### Read-Only + +- `role_name` (String, Deprecated) The name of the generated bot role +- `status` (Attributes) Fields that are set by the server as results of operations. These should not be modified by users. (see [below for nested schema](#nested-schema-for-status)) +- `user_name` (String, Deprecated) The name of the generated bot user + +### Nested Schema for `metadata` -- `name` (String) The name of the bot, i.e. the unprefixed User name +Required: + +- `name` (String) Name is an object name + +Optional: + +- `description` (String) Description is object description +- `expires` (String) Expires is a global expiry time header can be set on any resource in the system. +- `labels` (Map of String) Labels is a set of labels + + +### Nested Schema for `spec` + +Optional: + +- `max_session_ttl` (String) The max session TTL value for the bot's internal role. Unless specified, bots may not request a value beyond the default maximum TTL of 12 hours. This value may not be larger than 7 days (168 hours). - `roles` (List of String) A list of roles the created bot should be allowed to assume via role impersonation. +- `traits` (Map of List of String) The traits that will be associated with the bot for the purposes of role templating. -### Optional +Where multiple specified with the same name, these will be merged by the server. -- `token_id` (String, Sensitive) Deprecated. This field is not required anymore and has no effect. -- `token_ttl` (String) Deprecated. This field is not required anymore and has no effect. -- `traits` (Map of List of String) -### Read-Only +### Nested Schema for `status` + +Read-Only: -- `role_name` (String) The name of the generated bot role -- `user_name` (String) The name of the generated bot user +- `role_name` (String) The name of the role associated with the bot. +- `user_name` (String) The name of the user associated with the bot. diff --git a/integrations/terraform/examples/resources/teleport_bot/resource.tf b/integrations/terraform/examples/resources/teleport_bot/resource.tf index 0298ca1982f08..c681688974519 100644 --- a/integrations/terraform/examples/resources/teleport_bot/resource.tf +++ b/integrations/terraform/examples/resources/teleport_bot/resource.tf @@ -29,6 +29,11 @@ resource "teleport_provision_token" "bot_example" { } resource "teleport_bot" "example" { - name = local.bot_name - roles = ["access"] + metadata = { + name = local.bot_name + } + + spec = { + roles = ["access"] + } } From 6fe044268e50ba9c07daad338945ad209250e261 Mon Sep 17 00:00:00 2001 From: Dan Upton Date: Mon, 27 Oct 2025 16:31:41 +0000 Subject: [PATCH 4/5] Fix imports --- integrations/terraform/testlib/bot_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/integrations/terraform/testlib/bot_test.go b/integrations/terraform/testlib/bot_test.go index a166439c27b56..9d6c0da12e7f2 100644 --- a/integrations/terraform/testlib/bot_test.go +++ b/integrations/terraform/testlib/bot_test.go @@ -21,15 +21,16 @@ import ( "fmt" "time" - 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/trace" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/durationpb" + + 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" ) func (s *TerraformSuiteOSS) TestBot() { From 490f750cbc60fa54cc9b239cbffdbaba1cda5ede Mon Sep 17 00:00:00 2001 From: Dan Upton Date: Tue, 28 Oct 2025 12:35:40 +0000 Subject: [PATCH 5/5] Simplify bot resource example --- .../terraform-provider/resources/bot.mdx | 30 +------------------ .../resources/teleport_bot/resource.tf | 30 +------------------ 2 files changed, 2 insertions(+), 58 deletions(-) diff --git a/docs/pages/reference/infrastructure-as-code/terraform-provider/resources/bot.mdx b/docs/pages/reference/infrastructure-as-code/terraform-provider/resources/bot.mdx index d8520492124a3..3d69a3aed05a3 100644 --- a/docs/pages/reference/infrastructure-as-code/terraform-provider/resources/bot.mdx +++ b/docs/pages/reference/infrastructure-as-code/terraform-provider/resources/bot.mdx @@ -21,37 +21,9 @@ This page describes the supported values of the teleport_bot resource of the Tel ```hcl # Teleport Machine ID Bot creation example -locals { - bot_name = "example" -} - -resource "random_password" "bot_token" { - length = 32 - special = false -} - -resource "time_offset" "bot_example_token_expiry" { - offset_hours = 1 -} - -resource "teleport_provision_token" "bot_example" { - metadata = { - expires = time_offset.bot_example_token_expiry.rfc3339 - description = "Bot join token for ${local.bot_name} generated by Terraform" - - name = random_password.bot_token.result - } - - spec = { - roles = ["Bot"] - bot_name = local.bot_name - join_method = "token" - } -} - resource "teleport_bot" "example" { metadata = { - name = local.bot_name + name = "example" } spec = { diff --git a/integrations/terraform/examples/resources/teleport_bot/resource.tf b/integrations/terraform/examples/resources/teleport_bot/resource.tf index c681688974519..2738c225a9091 100644 --- a/integrations/terraform/examples/resources/teleport_bot/resource.tf +++ b/integrations/terraform/examples/resources/teleport_bot/resource.tf @@ -1,36 +1,8 @@ # Teleport Machine ID Bot creation example -locals { - bot_name = "example" -} - -resource "random_password" "bot_token" { - length = 32 - special = false -} - -resource "time_offset" "bot_example_token_expiry" { - offset_hours = 1 -} - -resource "teleport_provision_token" "bot_example" { - metadata = { - expires = time_offset.bot_example_token_expiry.rfc3339 - description = "Bot join token for ${local.bot_name} generated by Terraform" - - name = random_password.bot_token.result - } - - spec = { - roles = ["Bot"] - bot_name = local.bot_name - join_method = "token" - } -} - resource "teleport_bot" "example" { metadata = { - name = local.bot_name + name = "example" } spec = {