Terraform Cloud Project Bootcamp with Andrew Brown — 2.4.0 CRUD Resource
This article is part of my Terraform journey with Terraform Bootcamp by Andrew Brown and Andrew Bayko with Chris Williams and Shala.
My wild career adventure into Cloud Computing continues with Andrews, and I highly, highly recommend you to come join us if you are interested. Check out Andrews’ free youtube learning contents. You can also buy some paid courses here to support their good cause.
Agenda
Video here: 2 4 0 CRUD Resource
Issue #49 Goals
- ✅ 1) Finish the implementation of Provider Plugin by
- ✅ 2) implementing the CRUD functions.
- ✅ 3) Test the Plugin and troubleshoot.
Workflow
- ✅ 1) Update
Resource()
function inmain.go
- ✅ 2) Create resource in
main.tf
- ✅ 2–1) Troubleshooting: Failed to query available provider packages
- ✅ 3) Implement CRUD functions
- ✅ 4) Go troubleshootiong
Faithful campers follow Andrew along every step of his way! Andrew’s been craving for payday bars and this is the closest I can find where I’m based in. Our SCT (Standard Camping Time ⛺) is usually the wee morning hours for me (Canada and Korea) but whatever. I’ll burn it at the gym later🏃
1. Update Resource() function in main.go
In reality, it is the best if we can break down every resource we implement into atomic building blocks. In Andrews’ bootcamp, however, we just put everything into the single Resource()
for simplicity and learning purposes.
Define the details of Resource()
as below. The major updates to Resource()
in this episode are:
- Define
Schema
, which will be used as the attributes of theresource
block in HCL later in themain.tf
file.
func Resource() *schema.Resource {
log.Print("Resource:start")
resource := &schema.Resource{
CreateContext: resourceHouseCreate,
ReadContext: resourceHouseRead,
UpdateContext: resourceHouseUpdate,
DeleteContext: resourceHouseDelete,
Schema: map[string]*schema.Schema{
"name": {
Type: schema.TypeString,
Required: true,
Description: "Name of home",
},
"description": {
Type: schema.TypeString,
Required: true,
Description: "Description of home",
},
"domain_name": {
Type: schema.TypeString,
Required: true,
Description: "Domain name of home eg. *.cloudfornt.net",
},
"town": {
Type: schema.TypeString,
Required: true,
Description: "The town to which the home will belong to",
},
"content_version": {
Type: schema.TypeInt,
Required: true,
Description: "The content version of home",
},
}
}
log.Print("Resource:end")
return resource
}
Then run ./bin/build_provider
to update the plugin.
2. Create resource in main.tf
Now that our Plugin’s Resource
is updated, we can use the updated feature immediately:
- Create a
resource
block in HCL inmain.tf
. - Provide values to
Schema
’s attributes.
resource "terratown_home" "home" {
name = "Nomadiachi's nomadic life"
description = <<DESCRIPTION
Mariachi works nomadically so popping in and checking out here and there.
Pictures of some memorable moments, quick snapshots of days that pass by.
Come join my nomadinary journey? :D
DESCRIPTION
# domain_name = module.terrahouse_aws.cloudfront_url
domain_name = "3fdq3gz.cloudfront.net" // mock url
town = "the-nomad-pad"
content_version = 1
}
2–1. Troubleshooting: Failed to query available provider packages
Then we run terraform init
and run into the below error.
Andrew decides to keep implementing the code to see if the problem persists.
3. Implement CRUD functions
For below walkthrough, I will be using only the Create
function as an example. What I explain below is the common flow of the HTTP requests using CRUD functions. Please note that all the four CRUD functions must be updated, and they are slightly different from each other as each function performs different tasks. If you run into trouble executing the code later, feel free to copy Andrew’s entire source code.
Add config
Config
contains three attributes:user_uuid
,token
, andendpoint
.Config
is used in thepayload
for HTTP request (the data that’s passed to manipulate the TerraTowns database) which requires credential data.
Make HTTP request
- In the
req, err
block below, see howconfig
is used. HTTP request is sending a request to an endpoint, so we always need a URL.
Set request headers
Headers
are used to provide additional information about the message being sent or received.- We include the
config.Token
data for authentication at the TerraTowns website.
func resourceHouseCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
var diags diag.Diagnostics
// Add config
config := m.(*Config)
// Make HTTP request
req, err := http.NewRequest("POST", config.Endpoint + "/u/" + config.UserUuid + "/homes", bytes.NewBuffer(payloadBytes))
if err != nil {
return diag.FromErr(err)
}
// Set headers
req.Header.Set("Authorization", "Bearer "+config.Token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
// Get response
client := http.Client{}
resp, err := client.Do(req)
if err != nil {
return diag.FromErr(err)
}
defer resp.Body.Close()
return diags
}
Payload
- We create
payload
which is amap
of string key — value pairs. Payload
is used only in theCreate
andUpdate
functions.json.Marshal()
function handles the conversion of Go data structures into a valid JSON format.- Note that the
payload
and its marshaling happens prior to making the HTTP request. The json-formattedpayload
will be included in the http request (req
) down the code to the TerraTowns website.
payload := map[string]interface{}{
"name": d.Get("name").(string),
"description": d.Get("description").(string),
"domain_name": d.Get("domain_name").(string),
"town": d.Get("town").(string),
"content_version": d.Get("content_version").(int),
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return diag.FromErr(err)
}
Parse response JSON
responseData
is amap
ofinterface{}
, which is a type that can hold values of any type. It will hold the parsed Json data.- Just like we converted Go into Json (
marshal
ed) when sending HTTP request, we have to convert the response Json back for Go (parse
). json.NewDecoder(resp.Body)
will do the job.- The
json.NewDecoder(resp.Body)
will.Decode
the&responseData
. if
any error occurs while decoding, the error will be stored inerr
.if
there is no error (nil
)
StatusOK = 200 HTTP Response Code
- To check if Status is OK, we are actually checking if it’s not okay.
- If it’s not okay, the plugin will tell us the status code that it received from the TerraTowns server.
// parse response JSON
var responseData map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&responseData); err != nil {
return diag.FromErr(err)
}
// StatusOK = 200 HTTP Response Code
if resp.StatusCode != http.StatusOK {
return diag.FromErr(fmt.Errorf("failed to create home resource, status_code: %d, status: %s, body: %s", resp.StatusCode, resp.Status, responseData))
}
The CRUD implementation is completed at 45:45. The complete code is found in Andrew’s main.tf
.
4. Go troubleshootiong
Now that the CRUD functions are complete, we want to test our plugin package. Run ./bin/build_provider
.
We run into the following errors:
- Some
if err
statements were missingnil
. - Import libraries if missing:
“encoding/json”
,“net/http”
client := http.Client{}
: Client plus curly, not parenthesis.- For
undefined: responseData
: compare your code against Andrew’s.responseData
is used inCreate
andRead
only. - Add library
“bytes”
. - Correct
diag.FrontErr
todiag.FromErr
- For
undefined: responseData
, addinterface{}
with curlies:
var responseData map[string]interface{}
4. Test out our custom provider
Run terraform init
to test if the provider is compiled correctly. And we are still stuck with the same error:
Failed to query available provider packages
In our ./terraform-provider-terratowns/
, check main.go
file and correct terratown_home
(singluar) to terratowns_home
(plural).
func Provider() *schema.Provider {
var p *schema.Provider
p = &schema.Provider{
ResourcesMap: map[string]*schema.Resource{
"terratowns_home": Resource(), // terratown should be plural
},
Build
the plugin again, then run terraform plan
to see if: terraform is creating any resource
! It works and you can hear Andrew’s excitement (56:14)! This is why we learn to code!!! 😃
According to Andrew’s words,
I remember that our mock server is not real, so it will not work exactly correctly. But we can play a little bit around before trying it on the real TerraTowns.
Now let’s run terraform apply
.
Troubleshooting: Plugin did not respond.
In resourceHouseCreate.payload
, check if content_version
type is int
or string
.
payload := map[string]interface{}{
"name": d.Get("name").(string),
"description": d.Get("description").(string),
"domain_name": d.Get("domain_name").(string),
"town": d.Get("town").(string),
"content_version": d.Get("content_version").(int),
}
Troubleshooting: failed to create home resource, status_code: 401 Unauthorized
I kept running into the above error. At this stage, we are still using the mock values Andrew used in the videos for domain_name
, user_uuid
and token
.
- Check the value of
domain_name
which is a mock cloudfront.net url. - Check the List of files where
user_uuid
&token
are used: ./bin/terratowns/create
./bin/terratowns/delete
./bin/terratowns/read
./bin/terratowns/update
./terratowns_mock_server/server.rb
./main.tf
Troubleshooting: json: Unmarshal(non-pointer map[string]interface {})
The error message json: Unmarshal(non-pointer map[string]interface {})
indicates that there’s an issue with unmarshaling JSON data into a non-pointer map[string]interface{}
type in your Terraform configuration.
So I scan through the code and I see the yellow underline below Decode()
. I hover over and I get the tip the call of Decode passes non-pointer
. It turns out, I was missing the ampersand sign (&) which is a pointer in Golang.
The Decode function expects a “pointed (&
)” argument so make sure to attach an ampersand before the argument.
💡Quick tip: difference between pointer and non-pointer arguments
pointer argument
: it points to the actual value. So if the value is updated, your functions are working with the updated value.non-pointer argument
: it is a copy of the argument. The copy’s value will stay the same even after the argument’s current value is updated.
So I add an ampersand as a pointer (&) as below and run terraform apply
. It works a charm 😃!
// resourceHouseCreate()
// parse response JSON
var responseData map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&responseData); err != nil {
return diag.FromErr(err)
}
This is how I spotted the difference. WinMerge is amazing!
Now that the complete Provider Plugin is working, we are good to define terratown_homes
and deploy them to TerraTowns.
📑 Notes
Go language
json.Marshal()
converts Go into json-formatted bytes.Schema
in Go is used to define inputs (attributes of code blocks such asprovider
orresource
) in Terraform provider.
Go code flow
payload
exists only inCreate
andUpdate
.responseData
is used inCreate
andRead
only.
Resource
Bootcamp
- Video: 2 4 0 CRUD Resource
- My feature branch: 2 4 0
- My complete Learning Journal