Hi and thank you for wanting to contribute to this Terraform provider! This guide will take you through the most important steps of writing code for this library and getting it merged.
It can be tempting to quickly add a new function to create something in Terraform. However, Terraform is not like Ansible, it isn't just about creating things. In Terraform, you will need to implement the full lifecycle. Think about what happens if a certain parameter of a resource changes? Can you update the resource? Do you have to re-create it? What happens if someone manually destroys the resource on the oVirt Engine and Terraform doesn't know about it?
Or, most importantly, what happens if you need to send two API calls for one resource, but the second one fails? This is why Terraform resources should match API calls as close as possible. Avoid creating composite resources that require sending more than one API call.
If you think about all these, your Terraform resource will be robust. If you don't, you'll see random errors happen.
The general rule for this library is: one API call = one resource.
Why? Because Terraform does a pretty good job at state management. This saves you from a lot of trouble.
Think of this: you want to create a VM and then resize its disk. What happens if you successfully create the VM, but then fail on the resize? If the two API calls are separate resources Terraform will handle it for you. If you do it in one resource you will have to delete the VM so Terraform can try the whole process again.
Hence, if you can, please try and create separate resources for separate API calls.
This provider is based on the go-ovirt-client library, a hand-written overlay for the Go oVirt SDK. This library provides many functions we rely on, most importantly mocking the oVirt Engine so we don't have to run one for testing.
You may run into a situation where you don't have the necessary API calls you need to implement a Terraform resource. In this case you must first get your API call into that library. Don't worry, there's a contributing guide there too.
Once your change to go-ovirt-client has been merged, you can start developing against it in this Terraform provider by running:
go get github.com/ovirt/go-ovirt-client/v3@<your commit hash>
👉 Tip: Even if you don't want to create a new resource, this section is worth reading through.
Before you even begin writing actual code, you will need to decide on the schema of your provider. This typically looks like this:
package ovirt
import (
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
var diskSchema = map[string]*schema.Schema{
"id": {
Type: schema.TypeString,
Computed: true,
},
// More schema here
}
There are two types of fields: the computed ones and the non-computed ones.
Computed fields should be used for attributes that are read-only, such as identifiers automatically assigned by oVirt, or statuses automatically managed by oVirt.
Non-computed fields are ones where the user needs to provide the value. You can still update them, but the initial value should in all cases be provided by the user, or the field should have a default value.
When it comes to non-computed fields you should also decide on the update strategy: can you update a resource in-place without deleting and re-creating it? For example, you may be able to change the VM's name without destroying it, but not the template ID it's based on. If your field cannot be updated, you should set the ForceNew
field to true
. If you have at least one field which is not computed (Computed=true
) and ForceNew
is also not set, you will need to provide an update function.
When writing the schema you should make sure to provide ample description and validation so that users can reasonably write their Terraform code without tripping over low level errors. The validation.go file already contains a number of validators you can add to your schema.
Next, we need to declare the resource with the schema we created. We create the resource on the provider struct like this:
func (p *provider) vmResource() *schema.Resource {
return &schema.Resource{
CreateContext: p.vmCreate,
ReadContext: p.vmRead,
UpdateContext: p.vmUpdate,
DeleteContext: p.vmDelete,
Importer: &schema.ResourceImporter{
StateContext: p.vmImport,
},
Schema: vmSchema,
Description: "The ovirt_vm resource creates a virtual machine in oVirt.",
}
}
Each of the functions mentioned here (vmCreate
, vmRead
, vmUpdate
, vmDelete
, and vmImport
) will need to be implemented here. If the resource doesn't have any fields that can be updated, you can leave the vmUpdate
function empty.
Next, you will need to add the resource in provider.go;
func (p *provider) provider() *schema.Provider {
return &schema.Provider{
Schema: providerSchema,
ConfigureContextFunc: p.configureProvider,
ResourcesMap: map[string]*schema.Resource{
"ovirt_vm": p.vmResource(),
// More resources here.
},
DataSourcesMap: map[string]*schema.Resource{
// Data sources here
},
}
}
The create function is responsible for creating the resource the first time. The function signature looks like this:
func (p *provider) vmCreate(
ctx context.Context,
data *schema.ResourceData,
_ interface{},
) diag.Diagnostics {
// Code here
}
It accepts three parameters:
- The context. You should pass this context to any go-ovirt-client functions you call using
ovirtclient.ContextStrategy()
as the last parameter. - The data record. This is where you can get your parameters from. You will also need to update this data set once your resource has been created. At the very least, you will need to set the
id
field on it so that Terraform knows which ID it belongs to. - The unused provider interface. We don't use this as we access the go-ovirt-client over the
p
receiver.
This function returns a list of diagnostics. If there is a diagnostic with the type diag.Error
, the VM creation will return with an error.
Since you will need to update the data
record after the resource is done, and this update will need to be done in the update as well, you should create a function like this:
func vmResourceUpdate(vm ovirtclient.VMData, data *schema.ResourceData) diag.Diagnostics {
diags := diag.Diagnostics{}
data.SetId(vm.ID())
diags = setResourceField(data, "cluster_id", vm.ClusterID(), diags)
//...
return diags
}
The signature of the read and update functions look exactly the same as the create function. The difference is, that update
should take the parameters from data
and update the resource denoted in id
. Both read and update should then update data
with the current state of the resource. (This is what you need the vmResourceUpdate
helper function described above.)
It is worth noting, that in both cases you should explicitly check if the resource has been deleted and set the ID to ""
if that is the case. For read:
vm, err := p.client.GetVM(id, ovirtclient.ContextStrategy(ctx))
if isNotFound(err) {
data.SetId("")
// This is fine, return no error
return nil
}
if err != nil {
// Handle other errors
}
For update:
vm, err := p.client.GetVM(id, ovirtclient.ContextStrategy(ctx))
if isNotFound(err) {
data.SetId("")
// Continue processing errors below
}
if err != nil {
// Handle error and return diagnostics.
}
The delete function does exactly what the name says: it takes the ID and possibly other fields from data
and deletes the resource, then sets the ID to ""
.
The import function is a tricky one: the signature is exactly the same as before, but the data
parameter will contain only a single ID, nothing else. This ID is not necessarily the resource ID, it is whatever the user entered.
You can use this to your advantage when needing multiple parameters on import, for example by splitting the ID by a slash (/
).
You must then use the provided information to get the current state of the resource and update the data
records as before.
There is a special case when you want to create resource blocks, such as this:
resource "ovirt_foo" "bar" {
some_block {
other_property = "baz"
}
}
This is very tricky to program and should generally be avoided. However, if you need such a block you can define it in the schema as follows:
var fooSchema = map[string]*schema.Schema{
"some_block": {
Type: schema.TypeSet,
Optional: true,
MaxItems: 1,
ForceNew: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"other_property": {
Type: schema.TypeString,
Optional: true,
ForceNew: true,
},
},
},
},
}
There are several limitations with this approach:
- You cannot define a validation function.
- You cannot define defaults.
- You need to handle keys and values manually (see below).
Now, on how to handle these cases. The some_block
attribute will be a set (or a list). This means that you can have either 0 or 1 entries. (If you remove the MaxItems
it can have more.) You need to handle both cases.
if someBlockSet, ok := data.GetOk("some_block"); ok {
someBlockList := someBlockSet.(*schema.Set).List()
if len(someBlockList) == 1 {
someBlockEntries := someBlockList[0].(map[string]interface{})
otherProperty := ""
if otherPropertyContents, ok := someBlockEntries["other_property"]; ok {
otherProperty = otherPropertyContents.(string)
}
// Use otherProperty here
}
}
Now, this is part 1, and as you can see it's already pretty complicated. Now comes part 2: reading the resource. Here you must make sure that the output of the read produces the exact same output. For example, the oVirt Engine may set a default, but you must ignore that default if the user didn't provide the some_block
block.
ovirtEngineSomeEntry := ovirtClient.GetSomeEntry()
if rawSomeBlock, ok := data.GetOk("some_block"); ok {
someBlockList := rawSomeBlock.(*schema.Set).List()
if len(someBlockList) == 1 {
// The user provided input.
someBlockEntry := someBlockList[0].(map[string]interface{})
// Get the original value
otherProperty := osEntry["other_property"]
if otherProperty != ovirtEngineSomeEntry {
// The engine returned a different value from the user input, set the value.
data.Set("some_block", []map[string]interface{}{{
"other_property": ovirtEngineSomeEntry,
}})
}
}
} else if ovirtEngineSomeEntry != "defaultValue" {
// The user didn't provide input, but the oVirt Engine returned a non-default value.
data.Set("some_block", []map[string]interface{}{{
"other_property": ovirtEngineSomeEntry,
}})
}
Did we mention you may want to avoid blocks whenever possible?
So far so good, you have a resource that works in theory. In practice Terraform can be a tricky beast to deal with though, so you should always write a test for your resource. We exclusively rely on the mocks provided by go-ovirt-client for this functionality, otherwise this provider would be a headache to test.
Additionally, all examples in the examples directory are executed automatically against a live engine if you provide the OVIRT_URL
, OVIRT_USERNAME
, and OVIRT_PASSWORD
environment variables.
In order to write a test you must create the appropriate test file and add your test:
func TestVMResource(t *testing.T) {
}
This is a regular Go test. Next, we will initialize the provider and the test helper:
func TestVMResource(t *testing.T) {
p := newProvider(ovirtclientlog.NewTestLogger(t))
}
The provider has multiple functions: first, you can obtain a go-ovirt-sdk client to run API calls for setup/teardown:
client := p.getTestHelper().GetClient()
Second, you can use the test helper to get a variety of IDs for testing:
clusterID := p.getTestHelper().GetClusterID()
Now that we have this sorted out, let's set up the Terraform tests:
resource.UnitTest(t, resource.TestCase{
ProviderFactories: p.getProviderFactories(),
Steps: []resource.TestStep{
}
})
Here you can add your test steps. Each unit test has a number of options, we'll list the more important ones here:
- Config: This is the Terraform config to apply on this step.
- Destroy: Set to true to destroy instead of apply.
- ImportState: Set to true to import instead of apply. You must set the
ImportStateIdFunc
option. - ResourceName: Contains the name of the resource in the
Config
that the test is meant for. This is especially important for import tests. - ImportStateIdFunc: This function will be run to determine the ID to import. Use this function to create resources to tests against dynamically.
- Check: You can add a test function here to verify that the apply/destroy/import was completed successfully. You will have access to the Terraform state here for verification.
Config
field must include the Terraform provider {}
section with the mock = true
option!
When you're done, run go test -v ./...
to run the tests.
Now that your resource works, tests are done, the only thing left to do is generate the documentation. Go ahead and run go generate
.
From here it's simple: push to your fork and submit a PR on GitHub. Follow the description there and we'll review your change in short order.