Terraform Custom Provider with Data Source

With Google Workspace API

(λx.x)eranga
Effectz.AI
11 min readMay 15, 2023

--

Background

In my previous post I have discussed introduction about terraform with Terraform Kubernetes Provider. In this blog post, I will explore how to create a custom Terraform provider with data source to integrate with the Google Licensing API. We will cover the steps involved in creating the provider, defining terraform data sources and testing the provider.

Terraform Providers

Terraform is an open-source infrastructure as code (IaC) tool that allows users to define, manage and version infrastructure as code. With Terraform, infrastructure can be described using a high-level configuration language and managed as a single unit. Terraform can create and manage resources across multiple cloud platforms, on-premises systems, and other third-party providers. Terraform providers are plugins that allow Terraform to interact with external APIs and resources. Providers provide a standardized interface to create, read, update, and delete resources in the underlying infrastructure. Basically, provider is the logical abstraction of an upstream API. Terraform providers enable infrastructure automation at scale and are a powerful tool for managing infrastructure in a declarative, repeatable, and version-controlled manner.

Custom Terraform Providers

A custom Terraform provider is an implementation of the Terraform Provider interface that allows Terraform to interact with a custom API or service. Custom Terraform providers are particularly useful for organizations that use proprietary or custom services that are not supported by the existing Terraform providers. With a custom provider, developers can define and manage resources using Terraform, which helps to maintain consistency and enables version control, testing, and collaboration on infrastructure changes. To create a custom Terraform provider, developers can use the Terraform Plugin SDK, which provides a set of interfaces and tools to simplify the implementation process. The SDK includes functionality for resource management, schema generation, and state management, among other features. There are a few possible reasons for authoring a custom Terraform provider, such as: 1) An internal private cloud whose functionality is either proprietary or would not benefit the open source community, 2) A `work in progress` provider being tested locally before contributing back, 3) Extensions of an existing provider.

Terraform Data Source

In Terraform, a data source is a way to retrieve and reference external data that is not managed by Terraform itself. Data sources enable the use of external data within Terraform configuration files, allowing Terraform to reference this data and incorporate it into the infrastructure it manages. This can include information such as metadata about cloud resources, information from external APIs, or even data from configuration files that are not managed by Terraform. By using data sources, Terraform can intelligently track and manage changes to the infrastructure it manages, and ensure that changes made to external resources are correctly reflected in the Terraform state. In this case, a custom Terraform provider will be create with data source using the Google Licensing API to retrieve information about Google Workspace licenses in a G Suite domain. Following is the high level architecture of this provider with data source.

To create a Terraform provider with the Google Licensing API data source, there are several main steps that need to be followed. These steps include implementing the provider data source logic with schema in Go, compiling/building the provider, integrate a provider block in the Terraform configuration file and use the provider data source functions. The source code related to this implementation is published in a GitLab repository, which can be cloned and used as a reference for this blog post. By following these steps, you will be able to create a custom Terraform provider that interacts with the Google Licensing API and can be used to manage your Google Workspace licensing resources.

1. Implement the Provider

The custom Terraform provider consists of three main files: main.go, provider.go, and data_source_licensing.go.

❯❯ ls -al
total 41928
drwxr-xr-x@ 9 lambda.eranga staff 288 May 14 17:00 .
drwxr-xr-x@ 6 lambda.eranga staff 192 May 14 16:50 ..
-rw-r--r--@ 1 lambda.eranga staff 303 May 14 16:49 main.go
-rw-r--r--@ 1 lambda.eranga staff 2630 May 14 16:49 provider.go
-rw-r--r--@ 1 lambda.eranga staff 3513 May 14 16:49 data_source_licensing.go

The main.go file is the entry point of the custom Terraform provider. It sets up the provider configuration, initializes the necessary resources, and starts the provider's main loop. It typically handles the initialization of logging, configuration parsing, and registering the provider's resources and data sources.

package main

import (
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/plugin"
)

// Go entry point function is main.go.
func main() {
plugin.Serve(&plugin.ServeOpts{
ProviderFunc: func() *schema.Provider {
return Provider()
},
})
}

The provider.go file defines the implementation of the custom Terraform provider. It includes the necessary functions to initialize the provider, configure the API client, and register the provider's resources and data sources with Terraform. First, it defines the Config struct, which represents the configuration parameters for the provider. These parameters include the Credentials, which can either be a path to a service account key file or the contents of the key file in JSON format. The Customer field specifies the Google Workspace customer ID associated with the subscription. The ImpersonatedUserEmail field allows specifying the email of a user with access to the Admin SDK Directory API, required for certain services. Next, the Provider function creates and configures the custom provider. It defines the schema for the provider's configuration parameters, specifying their types, descriptions, and default values. It also maps the provider's data source and resource configurations, allowing Terraform to manage and interact with the licensing data source (rahasak_licensing) and the UUID resource (rahasak_uuid). The configure function is responsible for handling the configuration of the provider. It retrieves the configuration values from the Terraform schema.ResourceData object, assigns them to the corresponding fields in the Config struct, and performs validation checks. In particular, it ensures that the customer_id is provided and raises an error if it is missing. It returns the populated Config struct along with any encountered diagnostics, such as errors or warnings.

package main

import (
"context"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

type Config struct {
Credentials string
Customer string
ImpersonatedUserEmail string
}

func Provider() *schema.Provider {
p := &schema.Provider{
Schema: map[string]*schema.Schema{
"credentials": {
Description: "Either the path to or the contents of a service account key file in JSON format " +
"you can manage key files using the Cloud Console). If not provided, the application default " +
"credentials will be used.",
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.MultiEnvDefaultFunc([]string{
"GOOGLEWORKSPACE_CREDENTIALS",
"GOOGLEWORKSPACE_CLOUD_KEYFILE_JSON",
"GOOGLE_CREDENTIALS",
}, nil),
},

"customer_id": {
Description: "The customer id provided with your Google Workspace subscription. It is found " +
"in the admin console under Account Settings.",
Type: schema.TypeString,
DefaultFunc: schema.MultiEnvDefaultFunc([]string{
"GOOGLEWORKSPACE_CUSTOMER_ID",
}, nil),
Optional: true,
},

"impersonated_user_email": {
Description: "The impersonated user's email with access to the Admin APIs can access the Admin SDK Directory API. " +
"`impersonated_user_email` is required for all services except group and user management.",
Type: schema.TypeString,
DefaultFunc: schema.MultiEnvDefaultFunc([]string{
"GOOGLEWORKSPACE_IMPERSONATED_USER_EMAIL",
}, nil),
Optional: true,
},
},

DataSourcesMap: map[string]*schema.Resource{
"rahasak_licensing": dataSourceLicensing(),
},

ResourcesMap: map[string]*schema.Resource{
"rahasak_uuid": resourceUuid(),
},
}

p.ConfigureContextFunc = configure(p)
return p
}

func configure(p *schema.Provider) func(context.Context, *schema.ResourceData) (interface{}, diag.Diagnostics) {
return func(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) {
var diags diag.Diagnostics
config := Config{}

// Get credentials
if v, ok := d.GetOk("credentials"); ok {
config.Credentials = v.(string)
}

// Get customer id
if v, ok := d.GetOk("customer_id"); ok {
config.Customer = v.(string)
} else {
diags = append(diags, diag.Diagnostic{
Severity: diag.Error,
Summary: "customer_id is required",
})

return nil, diags
}

// Get impersonated user email
if v, ok := d.GetOk("impersonated_user_email"); ok {
config.ImpersonatedUserEmail = v.(string)
}

return &config, diags
}
}

The data_source_licensing.go file contains the implementation of the data source for the custom Terraform provider that interacts with the Google Workspace Licensing API. This data source allows retrieving information about license assignments within Google Workspace. The dataSourceLicensing function defines and configures the data source. It provides a description of the data source and specifies the schema for the resource properties. The assignments property represents a list of license assignments and includes properties such as product_id, user_id, sku_id, sku_name, and product_name. The product_id, sku_id, and max_results properties are optional and have default values. The dataSourceLicensesRead function is responsible for performing the actual data retrieval. It is invoked when Terraform executes the terraform apply or terraform refresh commands. Inside this function, the Google Workspace Licensing API is accessed using the provided credentials, customer ID, and impersonated user email. The function retrieves the license assignments for the specified product and SKU, iterates through the results, and constructs a list of assignment maps. Each assignment map contains the relevant assignment information. Once the assignments have been retrieved and constructed, the function sets the assignments property on the resource data using d.Set. Finally, the function sets the resource ID to "assignments" using d.SetId, indicating that the data source has been successfully read. In case of any errors during the data retrieval process, the function returns a diag.Diagnostics value containing the corresponding error information. Otherwise, it returns nil to indicate a successful data retrieval.

package main

import (
"context"

googleoauth "golang.org/x/oauth2/google"
licensing "google.golang.org/api/licensing/v1"
"google.golang.org/api/option"
"google.golang.org/api/transport"

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

func dataSourceLicensing() *schema.Resource {
return &schema.Resource{
Description: "Licensing data source in the Terraform Googleworkspace provider. Licensing resides " +
"under the `https://www.googleapis.com/auth/apps.licensing` client scope.",

ReadContext: dataSourceLicensesRead,

Schema: map[string]*schema.Schema{
"assignments": &schema.Schema{
Type: schema.TypeList,
Computed: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"product_id": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"user_id": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"sku_id": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"sku_name": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"product_name": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
},
},
},
"product_id": &schema.Schema{
Type: schema.TypeString,
Optional: true,
Default: "Google-Apps",
},
"sku_id": &schema.Schema{
Type: schema.TypeString,
Optional: true,
Default: "",
},
"max_results": &schema.Schema{
Type: schema.TypeInt,
Optional: true,
Default: 100,
},
},
}
}

func dataSourceLicensesRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
config := meta.(*Config)

credParams := googleoauth.CredentialsParams{
Scopes: []string{"https://www.googleapis.com/auth/apps.licensing"},
Subject: config.ImpersonatedUserEmail,
}
creds, err := googleoauth.CredentialsFromJSONWithParams(ctx, []byte(config.Credentials), credParams)
if err != nil {
return diag.FromErr(err)
}

client, _, err := transport.NewHTTPClient(ctx, option.WithTokenSource(creds.TokenSource))
if err != nil {
return diag.FromErr(err)
}

service, err := licensing.NewService(ctx, option.WithHTTPClient(client))
if err != nil {
return diag.FromErr(err)
}

var assignments []map[string]interface{}
var pageToken string
productId := d.Get("product_id").(string)
skuId := d.Get("sku_id").(string)
maxResults := d.Get("max_results").(int)

for {
resp, err := service.LicenseAssignments.ListForProductAndSku(productId, skuId, config.Customer).MaxResults(int64(maxResults)).PageToken(pageToken).Do()
if err != nil {
return diag.FromErr(err)
}

for _, assignment := range resp.Items {
assignmentMap := map[string]interface{}{
"user_id": assignment.UserId,
"product_id": assignment.ProductId,
"sku_id": assignment.SkuId,
"sku_name": assignment.SkuName,
"product_name": assignment.ProductName,
}
assignments = append(assignments, assignmentMap)
}
if resp.NextPageToken == "" {
break
}

pageToken = resp.NextPageToken
}

if err := d.Set("assignments", assignments); err != nil {
return diag.FromErr(err)
}

d.SetId("assignments")

return nil
}

2. Compile and Build the Provider

The next step is to compile and build the provider binaries and copy it into the ~/.terraform.d/plugins directory. The ~/.terraform.d/plugins directory is the default location where Terraform looks for third-party provider plugins. By placing the provider binary in this directory, you make it accessible to Terraform during its runtime.

In this case, the command go build -o terraform-provider-licensing *.go compiles the provider source code and generates the terraform-provider-licensing binary. After compiling the provider binary, the next step is to create the directory structure in the ~/.terraform.d/plugins directory to store the custom provider. I have created a directory ~/.terraform.d/plugins/terraform.rahasak.dev/rahasak/licensing/1.0.0/darwin_arm64 . The terraform.rahasak.dev/rahasak/licensing/1.0.0 directory structure within the plugins directory is a convention used to organize and identify the plugin by its namespace and version. The darwin_arm64 directory indicates that the plugin binary is specifically built for the ARM64 architecture used in Apple Silicon processors. This means that the plugin is optimized to run on Mac computers that utilize Apple Silicon chips. It takes advantage of the unique features and capabilities of the ARM64 architecture, resulting in efficient and optimized performance when executing Terraform operations on compatible Mac systems.

Once the directories are in place, I can copy the compiled provider binary into the appropriate location. In this case, the command cp terraform-provider-licensing ~/.terraform.d/plugins/terraform.rahasak.dev/rahasak/licensing/1.0.0/darwin_arm64 copies the binary file to the desired location.

# compile and build provider binary
❯❯ go build -o terraform-provider-licensing *.go

# compile go binary with specific cpu architecture e.g linux amd64
❯❯ GOOS=linux GOARCH=amd64 go build -o terraform-provider-licensing *.go

# create plugins drecitory
❯❯ mkdir -p ~/.terraform.d/plugins/terraform.rahasak.dev/rahasak/licensing/1.0.0/darwin_arm64

# copy binary
❯❯ cp terraform-provider-licensing ~/.terraform.d/plugins/terraform.rahasak.dev/rahasak/licensing/1.0.0/darwin_arm64

3. Integrate the Provider

In the provider.tf file, the custom provider is configured and specified for use in the Terraform configuration. The terraform block declares that the configuration requires a provider with the namespace rahasak and the name licensing, and it specifies that version 1.0.0 of the provider is required.

The provider block defines the configuration for the rahasak provider. It sets the customer_id to "d1121w2", indicating the customer ID associated with the Google Workspace subscription. The credentials parameter is set using the base64decode function on the value of the var.googleworkspace_sa_base64 variable, which decodes a base64-encoded value representing the Google Workspace service account credentials. Finally, the impersonated_user_email is set to "account-admin@rahasak.com", specifying the email of the impersonated user who has access to the Admin APIs.

By configuring the provider in this way, Terraform will use the custom rahasak provider to interact with the licensing functionality provided by the custom provider plugin. This enables Terraform to manage and manipulate licensing data in the Google Workspace environment associated with the specified customer ID, utilizing the provided credentials and impersonated user email for authentication and authorization.

terraform {
required_providers {
rahasak = {
source = "terraform.rahasak.dev/rahasak/licensing"
version = "1.0.0"
}
}
}

variable "googleworkspace_sa_base64" {
type = string
}

provider "rahasak" {
customer_id = "d1121w2"
credentials = base64decode(var.googleworkspace_sa_base64)
impersonated_user_email = "account-admin@rahasak.com"
}

4. Use Custom Data Source

The final step is to utilize the custom data source rahasak_licensing that was defined in the rahasak provider in the main.tf file. This data source allows us to retrieve licensing information from the Google Workspace environment associated with the specified customer ID. We can use the data source like any other Terraform resource by referencing it in the data block and specifying the required arguments. For example, we can retrieve the licensing assignments by referencing data.rahasak_licensing.assignments in other resources or outputs in the configuration. This integration with the custom provider enables us to leverage the licensing data within our Terraform configuration and perform operations based on the retrieved information.

data "rahasak_licensing" "all-licenses" {
product_id = "Google-Apps"
}

data "rahasak_licensing" "enterprice-licenses" {
product_id = "Google-Apps"
sku_id = "1010010011"
}

data "rahasak_licensing" "starter-licenses" {
product_id = "Google-Apps"
sku_id = "1010010011"
}

output "total_licenses_count" {
value = length(data.rahasak_licensing.all-licenses.assignments)
}

output "starter_licenses_count" {
value = length(data.rahasak_licensing.enterprice-licenses.assignments)
}

output "enterpice_licenses_count" {
value = length(data.rahasak_licensing.starter-licenses.assignments)
}

Following is the way to initialize and run Terraform with the custom provider and configuration.

# sets the value of the TF_VAR_googleworkspace_sa_base64 environment variable to the base64-encoded service account key
❯❯ export TF_VAR_googleworkspace_sa_base64='<base64_encoded_service_account_key>'


# initialize Terraform. it will download the required provider plugin based on the provider configuration specified in your provider.tf file.
# in this case, it will download the rahasak/licensing provider plugin
❯❯ terraform init

Initializing the backend...

Initializing provider plugins...
- Finding hashicorp/time versions matching "~> 0.9.1"...
- Finding terraform.rahasak.dev/rahasak/licensing versions matching "1.0.0"...
- Installing hashicorp/time v0.9.1...
- Installed hashicorp/time v0.9.1 (signed by HashiCorp)
- Installing terraform.rahasak.dev/rahasak/licensing v1.0.0...
- Installed terraform.rahasak.dev/rahasak/licensing v1.0.0 (unauthenticated)

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.


# review the execution plan for the changes that Terraform will apply
❯❯ terraform plan
data.rahasak_licensing.all-licenses: Reading...
data.rahasak_licensing.all-licenses: Read complete after 7s [id=assignments]

Changes to Outputs:
+ total_licenses_count = 451

You can apply this plan to save these new output values to the Terraform state, without changing any real
infrastructure.


# apply the changes and provision the resources defined in your configuration.
# Terraform will communicate with the rahasak/licensing provider, using the specified customer ID, credentials, and impersonated user email, to retrieve licensing information.
❯❯ terraform apply
data.rahasak_licensing.all-licenses: Reading...
data.rahasak_licensing.all-licenses: Read complete after 8s [id=assignments]

Changes to Outputs:
+ total_licenses_count = 340

You can apply this plan to save these new output values to the Terraform state, without changing any real
infrastructure.

Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.

Enter a value:

Reference

  1. https://robertnorthard.com/writing-a-custom-terraform-provider/
  2. https://www.infracloud.io/blogs/developing-terraform-custom-provider/
  3. https://www.integralist.co.uk/posts/terraform-provider/
  4. https://debruyn.dev/2020/setting-up-your-machine-for-local-terraform-provider-development/
  5. https://unicoeding.com/blog/terraform-provider/
  6. https://daftkid.github.io/terraform-provider-how-it-works-part-2/
  7. https://allanjohn909.medium.com/building-a-terraform-provider-part-i-initial-setup-22e20bd19fd0
  8. https://terraform-eap.website.yandexcloud.net/docs/extend/writing-custom-providers.html

--

--