Terraform: convert user to generic provider (#69400)

Contributes to https://github.com/gravitational/teleport/issues/69301.
This commit is contained in:
rosstimothy
2026-08-07 16:02:36 +00:00
committed by GitHub
parent fed581a3ec
commit 1ab9183cdd
7 changed files with 143 additions and 515 deletions
-19
View File
@@ -448,23 +448,6 @@ var (
GetCanReturnNil: true,
}
user = payload{
Name: "User",
TypeName: "UserV2",
VarName: "user",
GetMethod: "GetUser",
CreateMethod: "CreateUser",
UpdateMethod: "UpsertUser",
UpsertMethodArity: 2,
DeleteMethod: "DeleteUser",
WithSecrets: "false",
ID: "user.Metadata.Name",
Kind: "user",
HasStaticID: false,
TerraformResourceType: "teleport_user",
HasCheckAndSetDefaults: true,
}
loginRule = payload{
Name: "LoginRule",
TypeName: "LoginRule",
@@ -1097,8 +1080,6 @@ func genTFSchema() {
generateDataSource(sessionRecording, singularDataSource)
generateResource(uiConfig, singularResource)
generateDataSource(uiConfig, singularDataSource)
generateResource(user, pluralResource)
generateDataSource(user, pluralDataSource)
generateResource(loginRule, pluralResource)
generateDataSource(loginRule, pluralDataSource)
generateResource(deviceTrust, pluralResource)
@@ -1,97 +0,0 @@
// Code generated by _gen/main.go DO NOT EDIT
/*
Copyright 2015-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 legacy
import (
"context"
apitypes "github.com/gravitational/teleport/api/types"
"github.com/gravitational/trace"
"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"
"github.com/gravitational/teleport/integrations/terraform/tfschema"
"github.com/gravitational/teleport/integrations/terraform/provider/internal/tfdiag"
)
// dataSourceTeleportUserType is the data source metadata type
type dataSourceTeleportUserType struct{}
// dataSourceTeleportUser is the resource
type dataSourceTeleportUser struct {
p Provider
}
// GetSchema returns the data source schema
func (r dataSourceTeleportUserType) GetSchema(ctx context.Context) (tfsdk.Schema, diag.Diagnostics) {
return tfschema.GenSchemaUserV2(ctx)
}
// NewDataSource creates the empty data source
func (r dataSourceTeleportUserType) NewDataSource(_ context.Context, p tfsdk.Provider) (tfsdk.DataSource, diag.Diagnostics) {
return dataSourceTeleportUser{
p: p.(Provider),
}, nil
}
// Read reads teleport User
func (r dataSourceTeleportUser) Read(ctx context.Context, req tfsdk.ReadDataSourceRequest, resp *tfsdk.ReadDataSourceResponse) {
var id types.String
diags := req.Config.GetAttribute(ctx, path.Root("metadata").AtName("name"), &id)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
userI, err := r.p.Client().GetUser(ctx, id.Value, false)
if err != nil {
resp.Diagnostics.Append(tfdiag.DiagFromWrappedErr("Error reading User", trace.Wrap(err), "user"))
return
}
var state types.Object
resp.Diagnostics.Append(req.Config.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
// Todo: Remove after updating terraform-plugin to >=v1.5.0.
// terraform-plugin-testing version <1.5.0 requires data resources to
// implement the 'id' attribute.
// https://developer.hashicorp.com/terraform/plugin/framework/acctests#no-id-found-in-attributes
v, ok := state.Attrs["id"]
if !ok || v.IsNull() {
state.Attrs["id"] = id
}
user := userI.(*apitypes.UserV2)
diags = tfschema.CopyUserV2ToTerraform(ctx, user, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
diags = resp.State.Set(ctx, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
@@ -34,7 +34,6 @@ func DataSourceTypes() map[string]tfsdk.DataSourceType {
"teleport_session_recording_config": dataSourceTeleportSessionRecordingConfigType{},
"teleport_trusted_cluster": dataSourceTeleportTrustedClusterType{},
"teleport_ui_config": dataSourceTeleportUIConfigType{},
"teleport_user": dataSourceTeleportUserType{},
"teleport_login_rule": dataSourceTeleportLoginRuleType{},
"teleport_trusted_device": dataSourceTeleportDeviceV1Type{},
"teleport_okta_import_rule": dataSourceTeleportOktaImportRuleType{},
@@ -77,7 +76,6 @@ func ResourceTypes() map[string]tfsdk.ResourceType {
"teleport_session_recording_config": resourceTeleportSessionRecordingConfigType{},
"teleport_trusted_cluster": resourceTeleportTrustedClusterType{},
"teleport_ui_config": resourceTeleportUIConfigType{},
"teleport_user": resourceTeleportUserType{},
"teleport_bot": resourceTeleportBotType{},
"teleport_login_rule": resourceTeleportLoginRuleType{},
"teleport_trusted_device": resourceTeleportDeviceV1Type{},
@@ -1,397 +0,0 @@
// Code generated by _gen/main.go DO NOT EDIT
/*
Copyright 2015-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 legacy
import (
"context"
"fmt"
apitypes "github.com/gravitational/teleport/api/types"
"github.com/gravitational/trace"
"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"
"github.com/gravitational/teleport/integrations/terraform/tfschema"
"github.com/gravitational/teleport/integrations/terraform/provider/internal/tfdiag"
)
// resourceTeleportUserType is the resource metadata type
type resourceTeleportUserType struct{}
// resourceTeleportUser is the resource
type resourceTeleportUser struct {
p Provider
}
// GetSchema returns the resource schema
func (r resourceTeleportUserType) GetSchema(ctx context.Context) (tfsdk.Schema, diag.Diagnostics) {
return tfschema.GenSchemaUserV2(ctx)
}
// NewResource creates the empty resource
func (r resourceTeleportUserType) NewResource(_ context.Context, p tfsdk.Provider) (tfsdk.Resource, diag.Diagnostics) {
return resourceTeleportUser{
p: p.(Provider),
}, nil
}
// Create creates the User
func (r resourceTeleportUser) Create(ctx context.Context, req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse) {
var err error
if !r.p.IsConfigured(resp.Diagnostics) {
return
}
var plan types.Object
diags := req.Plan.Get(ctx, &plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
user := &apitypes.UserV2{}
diags = tfschema.CopyUserV2FromTerraform(ctx, plan, user)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
userResource := user
err = userResource.CheckAndSetDefaults()
if err != nil {
resp.Diagnostics.Append(tfdiag.DiagFromWrappedErr("Error setting User defaults", trace.Wrap(err), "user"))
return
}
id := userResource.Metadata.Name
_, err = r.p.Client().GetUser(ctx, id, false)
if !trace.IsNotFound(err) {
if err == nil {
existErr := fmt.Sprintf("User exists in Teleport. Either remove it (tctl rm user/%v)"+
" or import it to the existing state (terraform import teleport_user.%v %v)", id, id, id)
resp.Diagnostics.Append(tfdiag.DiagFromErr("User exists in Teleport", trace.Errorf(existErr)))
return
}
resp.Diagnostics.Append(tfdiag.DiagFromWrappedErr("Error reading User", trace.Wrap(err), "user"))
return
}
_, err = r.p.Client().CreateUser(ctx, userResource)
if err != nil {
resp.Diagnostics.Append(tfdiag.DiagFromWrappedErr("Error creating User", trace.Wrap(err), "user"))
return
}
// Not really an inferface, just using the same name for easier templating.
var userI apitypes.User
// Try getting the resource until it exists.
tries := 0
retry, err := r.p.Retry()
if err != nil {
return
}
for {
tries = tries + 1
userI, err = r.p.Client().GetUser(ctx, id, false)
if trace.IsNotFound(err) {
select {
case <-ctx.Done():
resp.Diagnostics.Append(tfdiag.DiagFromWrappedErr("Error reading User", trace.Wrap(ctx.Err()), "user"))
return
case <-retry.After():
}
if tries >= r.p.MaxRetries() {
diagMessage := fmt.Sprintf("Error reading User (tried %d times) - state outdated, please import resource", tries)
resp.Diagnostics.AddError(diagMessage, "user")
return
}
continue
}
break
}
if err != nil {
resp.Diagnostics.Append(tfdiag.DiagFromWrappedErr("Error reading User", trace.Wrap(err), "user"))
return
}
userResource, ok := userI.(*apitypes.UserV2)
if !ok {
resp.Diagnostics.Append(tfdiag.DiagFromWrappedErr("Error reading User", trace.Errorf("Can not convert %T to UserV2", userI), "user"))
return
}
user = userResource
diags = tfschema.CopyUserV2ToTerraform(ctx, user, &plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
plan.Attrs["id"] = types.String{Value: user.Metadata.Name}
diags = resp.State.Set(ctx, &plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
// Read reads teleport User
func (r resourceTeleportUser) Read(ctx context.Context, req tfsdk.ReadResourceRequest, resp *tfsdk.ReadResourceResponse) {
var state types.Object
diags := req.State.Get(ctx, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
var id types.String
diags = req.State.GetAttribute(ctx, path.Root("metadata").AtName("name"), &id)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
userI, err := r.p.Client().GetUser(ctx, id.Value, false)
if trace.IsNotFound(err) {
resp.State.RemoveResource(ctx)
return
}
if err != nil {
resp.Diagnostics.Append(tfdiag.DiagFromWrappedErr("Error reading User", trace.Wrap(err), "user"))
return
}
user := userI.(*apitypes.UserV2)
diags = tfschema.CopyUserV2ToTerraform(ctx, user, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
diags = resp.State.Set(ctx, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
// Update updates teleport User
func (r resourceTeleportUser) Update(ctx context.Context, req tfsdk.UpdateResourceRequest, resp *tfsdk.UpdateResourceResponse) {
if !r.p.IsConfigured(resp.Diagnostics) {
return
}
var plan types.Object
diags := req.Plan.Get(ctx, &plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
user := &apitypes.UserV2{}
diags = tfschema.CopyUserV2FromTerraform(ctx, plan, user)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
userResource := user
if err := userResource.CheckAndSetDefaults(); err != nil {
resp.Diagnostics.Append(tfdiag.DiagFromWrappedErr("Error updating User", err, "user"))
return
}
name := userResource.Metadata.Name
userBefore, err := r.p.Client().GetUser(ctx, name, false)
if err != nil {
resp.Diagnostics.Append(tfdiag.DiagFromWrappedErr("Error reading User", err, "user"))
return
}
_, err = r.p.Client().UpsertUser(ctx, userResource)
if err != nil {
resp.Diagnostics.Append(tfdiag.DiagFromWrappedErr("Error updating User", err, "user"))
return
}
// Not really an inferface, just using the same name for easier templating.
var userI apitypes.User
tries := 0
retry, err := r.p.Retry()
if err != nil {
return
}
for {
tries = tries + 1
userI, err = r.p.Client().GetUser(ctx, name, false)
if err != nil {
resp.Diagnostics.Append(tfdiag.DiagFromWrappedErr("Error reading User", err, "user"))
return
}
if userBefore.GetMetadata().Revision != userI.GetMetadata().Revision || false {
break
}
select {
case <-ctx.Done():
resp.Diagnostics.Append(tfdiag.DiagFromWrappedErr("Error reading User", trace.Wrap(ctx.Err()), "user"))
return
case <-retry.After():
}
if tries >= r.p.MaxRetries() {
diagMessage := fmt.Sprintf("Error reading User (tried %d times) - state outdated, please import resource", tries)
resp.Diagnostics.AddError(diagMessage, "user")
return
}
}
userResource, ok := userI.(*apitypes.UserV2)
if !ok {
resp.Diagnostics.Append(tfdiag.DiagFromWrappedErr("Error reading User", trace.Errorf("Can not convert %T to UserV2", userI), "user"))
return
}
user = userResource
diags = tfschema.CopyUserV2ToTerraform(ctx, user, &plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
diags = resp.State.Set(ctx, plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
// Delete deletes Teleport User
func (r resourceTeleportUser) Delete(ctx context.Context, req tfsdk.DeleteResourceRequest, resp *tfsdk.DeleteResourceResponse) {
var id types.String
diags := req.State.GetAttribute(ctx, path.Root("metadata").AtName("name"), &id)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
err := r.p.Client().DeleteUser(ctx, id.Value)
if err != nil {
resp.Diagnostics.Append(tfdiag.DiagFromWrappedErr("Error deleting UserV2", trace.Wrap(err), "user"))
return
}
resp.State.RemoveResource(ctx)
}
// ImportState imports User state
func (r resourceTeleportUser) ImportState(ctx context.Context, req tfsdk.ImportResourceStateRequest, resp *tfsdk.ImportResourceStateResponse) {
user, err := r.p.Client().GetUser(ctx, req.ID, false)
if err != nil {
resp.Diagnostics.Append(tfdiag.DiagFromWrappedErr("Error reading User", trace.Wrap(err), "user"))
return
}
userResource := user.(*apitypes.UserV2)
var state types.Object
diags := resp.State.Get(ctx, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
diags = tfschema.CopyUserV2ToTerraform(ctx, userResource, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
id := userResource.GetName()
state.Attrs["id"] = types.String{Value: id}
diags = resp.State.Set(ctx, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
// ModifyPlan modifies the planned value, normalizing null values.
func (r resourceTeleportUser) ModifyPlan(ctx context.Context, req tfsdk.ModifyResourcePlanRequest, resp *tfsdk.ModifyResourcePlanResponse) {
// If the entire plan is null, the resource is planned for destruction.
if req.Plan.Raw.IsNull() {
return
}
// If the state is null, the resource is being created. No need to modify plan.
if req.State.Raw.IsNull() {
return
}
var config types.Object
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}
user := &apitypes.UserV2{}
resp.Diagnostics.Append(tfschema.CopyUserV2FromTerraform(ctx, config, user)...)
if resp.Diagnostics.HasError() {
return
}
userResource := user
if err := userResource.CheckAndSetDefaults(); err != nil {
resp.Diagnostics.Append(tfdiag.DiagFromWrappedErr("Error setting User defaults", trace.Wrap(err), "user"))
return
}
user = userResource
const preserveUnknown = true
resp.Diagnostics.Append(tfschema.CopyUserV2ToTerraformPreserveUnknown(ctx, user, &config, preserveUnknown)...)
if resp.Diagnostics.HasError() {
return
}
var plan types.Object
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
plan.Attrs["spec"] = config.Attrs["spec"]
resp.Diagnostics.Append(resp.Plan.Set(ctx, &plan)...)
}
@@ -0,0 +1,65 @@
// Teleport
// Copyright (C) 2026 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package resources
import (
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/integrations/terraform/provider/internal/teleport"
"github.com/gravitational/teleport/integrations/terraform/provider/internal/tfdriver"
"github.com/gravitational/teleport/integrations/terraform/tfschema"
)
// NewUserDataSourceType returns the user data source type.
func NewUserDataSourceType() tfdriver.DataSourceType[types.UserV2, tfdriver.NameIdentifier] {
return tfdriver.DataSourceType[types.UserV2, tfdriver.NameIdentifier]{
NewDataSourceClient: func(p tfsdk.Provider) tfdriver.DataSourceClient[types.UserV2, tfdriver.NameIdentifier] {
return teleport.NewUserClient(clientFromProvider(p))
},
Kind: types.KindUser,
Codec: tfdriver.DataSourceCodecFuncs[types.UserV2]{
SchemaFunc: tfschema.GenSchemaUserV2,
ToStateFunc: tfschema.CopyUserV2ToTerraform,
},
Identifier: tfdriver.NameIdentifierFromPath(path.Root("metadata").AtName("name")),
}
}
// NewUserResourceType returns the user resource type.
func NewUserResourceType() tfdriver.ResourceType[types.UserV2, tfdriver.NameIdentifier] {
return tfdriver.ResourceType[types.UserV2, tfdriver.NameIdentifier]{
NewResourceClient: func(p tfsdk.Provider) tfdriver.ResourceClient[types.UserV2, tfdriver.NameIdentifier] {
return teleport.NewUserClient(clientFromProvider(p))
},
Kind: types.KindUser,
Codec: tfdriver.ResourceCodecFuncs[types.UserV2]{
SchemaFunc: tfschema.GenSchemaUserV2,
ToStateFunc: tfschema.CopyUserV2ToTerraform,
FromPlanFunc: tfschema.CopyUserV2FromTerraform,
},
Normalizer: tfdriver.CheckAndSetDefaults[types.UserV2](),
Identifier: tfdriver.NameIdentifierPolicy(path.Root("metadata").AtName("name"), func(user *types.UserV2) string {
return user.GetMetadata().Name
}),
ResourceRevision: func(st *types.UserV2) string {
return st.GetMetadata().Revision
},
}
}
@@ -0,0 +1,76 @@
// Teleport
// Copyright (C) 2026 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package teleport
import (
"context"
"github.com/gravitational/trace"
"github.com/gravitational/teleport/api/client"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/integrations/terraform/provider/internal/tfdriver"
)
// NewUserClient returns a user client.
func NewUserClient(c *client.Client) UserClient {
return UserClient{client: c}
}
// UserClient manages user resources.
type UserClient struct {
client *client.Client
}
// Get reads a user by name.
func (r UserClient) Get(ctx context.Context, id tfdriver.NameIdentifier) (*types.UserV2, error) {
user, err := r.client.GetUser(ctx, id.Name, false)
if err != nil {
return nil, trace.Wrap(err)
}
userv2, ok := user.(*types.UserV2)
if !ok {
return nil, trace.BadParameter("unexpected user type: %T", user)
}
return userv2, nil
}
// Create creates a user.
func (r UserClient) Create(ctx context.Context, user *types.UserV2) error {
if _, err := r.client.CreateUser(ctx, user); err != nil {
return trace.Wrap(err)
}
return nil
}
// Upsert updates a user.
func (r UserClient) Upsert(ctx context.Context, user *types.UserV2) error {
if _, err := r.client.UpsertUser(ctx, user); err != nil {
return trace.Wrap(err)
}
return nil
}
// Delete deletes a user by name.
func (r UserClient) Delete(ctx context.Context, id tfdriver.NameIdentifier) error {
return trace.Wrap(r.client.DeleteUser(ctx, id.Name))
}
@@ -575,6 +575,7 @@ func (p *Provider) GetResources(_ context.Context) (map[string]tfsdk.ResourceTyp
"teleport_scoped_role": resources.NewScopedRoleResourceType(),
"teleport_scoped_role_assignment": resources.NewScopedRoleAssignmentResourceType(),
"teleport_scoped_token": resources.NewScopedTokenResourceType(),
"teleport_user": resources.NewUserResourceType(),
}
maps.Insert(resourceTypes, maps.All(genericResourceTypes))
@@ -598,6 +599,7 @@ func (p *Provider) GetDataSources(_ context.Context) (map[string]tfsdk.DataSourc
"teleport_scoped_role": resources.NewScopedRoleDataSourceType(),
"teleport_scoped_role_assignment": resources.NewScopedRoleAssignmentDataSourceType(),
"teleport_scoped_token": resources.NewScopedTokenDataSourceType(),
"teleport_user": resources.NewUserDataSourceType(),
}
maps.Insert(dataSourceTypes, maps.All(genericDataSourceTypes))