Skip to content

Commit

Permalink
Merge pull request #1390 from hashicorp/TF-17010-provider-resource-tf…
Browse files Browse the repository at this point in the history
…e-stack

New resource: tfe_stack
  • Loading branch information
brandonc authored Jul 3, 2024
2 parents d5c41d2 + c419687 commit e1fda79
Show file tree
Hide file tree
Showing 8 changed files with 505 additions and 6 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ provider_tfe_override.tfrc

# mkdocs build output
site/
.envrc
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ require (
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/go-slug v0.15.2
github.com/hashicorp/go-tfe v1.57.0
github.com/hashicorp/go-version v1.6.0
github.com/hashicorp/go-tfe v1.58.0
github.com/hashicorp/go-version v1.7.0
github.com/hashicorp/hcl v1.0.0
github.com/hashicorp/hcl/v2 v2.19.1 // indirect
github.com/hashicorp/terraform-plugin-framework v1.8.0
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,13 @@ github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISH
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/hashicorp/go-slug v0.15.2 h1:/ioIpE4bWVN/d7pG2qMrax0a7xe9vOA66S+fz7fZmGY=
github.com/hashicorp/go-slug v0.15.2/go.mod h1:THWVTAXwJEinbsp4/bBRcmbaO5EYNLTqxbG4tZ3gCYQ=
github.com/hashicorp/go-tfe v1.57.0 h1:sggR6C4CrtNAJqoCRNoZwBQnRfnWuImR6xbg2sUAbiU=
github.com/hashicorp/go-tfe v1.57.0/go.mod h1:XnTtBj3tVQ4uFkcFsv8Grn+O1CVcIcceL1uc2AgUcaU=
github.com/hashicorp/go-tfe v1.58.0 h1:aJXrStDBG+YJLkgDYswfNiKTRHQxKqT/9C1VuvujRkE=
github.com/hashicorp/go-tfe v1.58.0/go.mod h1:XnTtBj3tVQ4uFkcFsv8Grn+O1CVcIcceL1uc2AgUcaU=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/hc-install v0.6.3 h1:yE/r1yJvWbtrJ0STwScgEnCanb0U9v7zp0Gbkmcoxqs=
github.com/hashicorp/hc-install v0.6.3/go.mod h1:KamGdbodYzlufbWh4r9NRo8y6GLHWZP2GBtdnms1Ln0=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
Expand Down
1 change: 1 addition & 0 deletions internal/provider/provider_next.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ func (p *frameworkProvider) Resources(ctx context.Context) []func() resource.Res
NewDataRetentionPolicyResource,
NewResourceWorkspaceSettings,
NewSAMLSettingsResource,
NewStackResource,
NewTestVariableResource,
NewWorkspaceRunTaskResource,
}
Expand Down
269 changes: 269 additions & 0 deletions internal/provider/resource_tfe_stack.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package provider

import (
"context"
"fmt"

"github.com/hashicorp/go-tfe"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
)

// Ensure provider defined types fully satisfy framework interfaces.
var _ resource.Resource = &resourceTFEStack{}
var _ resource.ResourceWithConfigure = &resourceTFEStack{}
var _ resource.ResourceWithImportState = &resourceTFEStack{}

func NewStackResource() resource.Resource {
return &resourceTFEStack{}
}

// resourceTFEStack implements the tfe_stack resource type
type resourceTFEStack struct {
config ConfiguredClient
}

func (r *resourceTFEStack) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_stack"
}

func (r *resourceTFEStack) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
pathVCSRepoOAuthTokenID := path.Expressions{
path.MatchRelative().AtParent().AtName("oauth_token_id"),
}
pathGHAInstallationID := path.Expressions{
path.MatchRelative().AtParent().AtName("github_app_installation_id"),
}

resp.Schema = schema.Schema{
Description: "Defines a Stack resource. Note that a Stack cannot be destroyed if it contains deployments that have underlying managed resources.",
Version: 1,

Blocks: map[string]schema.Block{
"vcs_repo": schema.SingleNestedBlock{
Description: "VCS repository configuration for the Stack.",
Attributes: map[string]schema.Attribute{
"identifier": schema.StringAttribute{
Description: "Identifier of the VCS repository.",
Required: true,
},
"branch": schema.StringAttribute{
Description: "The repository branch that Terraform should use. This defaults to the respository's default branch (e.g. main).",
Optional: true,
},
"github_app_installation_id": schema.StringAttribute{
Description: "The installation ID of the GitHub App. This conflicts with `oauth_token_id` and can only be used if `oauth_token_id` is not used.",
Optional: true,
Validators: []validator.String{
stringvalidator.AtLeastOneOf(pathVCSRepoOAuthTokenID...),
stringvalidator.ConflictsWith(pathVCSRepoOAuthTokenID...),
},
},
"oauth_token_id": schema.StringAttribute{
Description: "The VCS Connection to use. This ID can be obtained from a `tfe_oauth_client` resource. This conflicts with `github_app_installation_id` and can only be used if `github_app_installation_id` is not used.",
Optional: true,
Validators: []validator.String{
stringvalidator.AtLeastOneOf(pathGHAInstallationID...),
stringvalidator.ConflictsWith(pathGHAInstallationID...),
},
},
},
},
},

Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "ID of the Stack.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"project_id": schema.StringAttribute{
Description: "ID of the project that the Stack belongs to.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"name": schema.StringAttribute{
Description: "Name of the Stack",
Required: true,
},
"description": schema.StringAttribute{
Description: "Description of the Stack",
Optional: true,
},
"deployment_names": schema.SetAttribute{
Description: "The time when the Stack was created.",
Computed: true,
ElementType: types.StringType,
},
"created_at": schema.StringAttribute{
Description: "The time when the stack was created.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"updated_at": schema.StringAttribute{
Description: "The time when the stack was last updated.",
Computed: true,
},
},
}
}

// Configure implements resource.ResourceWithConfigure
func (r *resourceTFEStack) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
// Prevent panic if the provider has not been configured.
if req.ProviderData == nil {
return
}

client, ok := req.ProviderData.(ConfiguredClient)
if !ok {
resp.Diagnostics.AddError(
"Unexpected resource Configure type",
fmt.Sprintf("Expected tfe.ConfiguredClient, got %T. This is a bug in the tfe provider, so please report it on GitHub.", req.ProviderData),
)
}
r.config = client
}

func (r *resourceTFEStack) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan modelTFEStack

// Read Terraform plan data into the model
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)

if resp.Diagnostics.HasError() {
return
}

if resp.Diagnostics.HasError() {
return
}

options := tfe.StackCreateOptions{
Name: plan.Name.ValueString(),
VCSRepo: &tfe.StackVCSRepo{
Identifier: plan.VCSRepo.Identifier.ValueString(),
Branch: plan.VCSRepo.Branch.ValueString(),
GHAInstallationID: plan.VCSRepo.GHAInstallationID.ValueString(),
OAuthTokenID: plan.VCSRepo.OAuthTokenID.ValueString(),
},
Project: &tfe.Project{
ID: plan.ProjectID.ValueString(),
},
}

if !plan.Description.IsNull() {
options.Description = tfe.String(plan.Description.ValueString())
}

tflog.Debug(ctx, "Creating stack")
stack, err := r.config.Client.Stacks.Create(ctx, options)
if err != nil {
resp.Diagnostics.AddError("Unable to create stack", err.Error())
return
}

result := modelFromTFEStack(stack)

// Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &result)...)
}

func (r *resourceTFEStack) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state modelTFEStack

// Read Terraform prior state data into the model
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)

if resp.Diagnostics.HasError() {
return
}

tflog.Debug(ctx, fmt.Sprintf("Reading stack %q", state.ID.ValueString()))
stack, err := r.config.Client.Stacks.Read(ctx, state.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Unable to read stack", err.Error())
return
}

result := modelFromTFEStack(stack)

// Save updated data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &result)...)
}

func (r *resourceTFEStack) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan modelTFEStack
var state modelTFEStack

// Read Terraform plan data into the model
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)

// Read Terraform prior state data into the model
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)

if resp.Diagnostics.HasError() {
return
}

options := tfe.StackUpdateOptions{
Name: tfe.String(plan.Name.ValueString()),
Description: tfe.String(plan.Description.ValueString()),
VCSRepo: &tfe.StackVCSRepo{
Identifier: plan.VCSRepo.Identifier.ValueString(),
Branch: plan.VCSRepo.Branch.ValueString(),
GHAInstallationID: plan.VCSRepo.GHAInstallationID.ValueString(),
OAuthTokenID: plan.VCSRepo.OAuthTokenID.ValueString(),
},
}

tflog.Debug(ctx, "Updating stack")
stack, err := r.config.Client.Stacks.Update(ctx, state.ID.ValueString(), options)
if err != nil {
resp.Diagnostics.AddError("Unable to update stack", err.Error())
return
}

result := modelFromTFEStack(stack)

// Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &result)...)
}

func (r *resourceTFEStack) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state modelTFEStack

// Read Terraform prior state data into the model
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)

if resp.Diagnostics.HasError() {
return
}

tflog.Debug(ctx, "Deleting stack")
err := r.config.Client.Stacks.Delete(ctx, state.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Unable to delete stack", err.Error())
return
}
}

func (r *resourceTFEStack) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), req.ID)...)
}
78 changes: 78 additions & 0 deletions internal/provider/resource_tfe_stack_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package provider

import (
"fmt"
"math/rand"
"testing"
"time"

"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
)

func TestAccTFEStackResource_basic(t *testing.T) {
skipUnlessBeta(t)

rInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int()
orgName := fmt.Sprintf("tst-terraform-%d", rInt)

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV5ProviderFactories: testAccMuxedProviders,
Steps: []resource.TestStep{
{
Config: testAccTFEStackResourceConfig(orgName, envGithubToken, "brandonc/pet-nulls-stack"),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttrSet("tfe_stack.foobar", "id"),
resource.TestCheckResourceAttrSet("tfe_stack.foobar", "project_id"),
resource.TestCheckResourceAttr("tfe_stack.foobar", "name", "example-stack"),
resource.TestCheckResourceAttr("tfe_stack.foobar", "description", "Just an ordinary stack"),
resource.TestCheckResourceAttr("tfe_stack.foobar", "vcs_repo.identifier", "brandonc/pet-nulls-stack"),
resource.TestCheckResourceAttrSet("tfe_stack.foobar", "vcs_repo.oauth_token_id"),
resource.TestCheckResourceAttrSet("tfe_stack.foobar", "created_at"),
resource.TestCheckResourceAttrSet("tfe_stack.foobar", "updated_at"),
),
},
{
ResourceName: "tfe_stack.foobar",
ImportState: true,
ImportStateVerify: true,
},
},
})
}

func testAccTFEStackResourceConfig(orgName, ghToken, ghRepoIdentifier string) string {
return fmt.Sprintf(`
resource "tfe_organization" "foobar" {
name = "%s"
email = "[email protected]"
}
resource "tfe_project" "example" {
name = "example"
organization = tfe_organization.foobar.name
}
resource "tfe_oauth_client" "foobar" {
organization = tfe_organization.foobar.name
api_url = "https://api.github.com"
http_url = "https://github.com"
oauth_token = "%s"
service_provider = "github"
}
resource "tfe_stack" "foobar" {
name = "example-stack"
description = "Just an ordinary stack"
project_id = tfe_project.example.id
vcs_repo {
identifier = "%s"
oauth_token_id = tfe_oauth_client.foobar.oauth_token_id
}
}
`, orgName, ghToken, ghRepoIdentifier)
}
Loading

0 comments on commit e1fda79

Please sign in to comment.