There are a long and detailed guides showing how to bootstrap a
provider and how to configure resources. Here
we will go over the steps that will take us to v1beta1
quality without going
into much detail so that it can be followed repeatedly quickly.
The steps are generally identical, so we'll just take a resource issue from AWS
#90 and you can generalize steps pretty much to all other
resources in all official providers. It has several resources from different API
groups, such as glue
, grafana
, guardduty
and iam
.
-
Assign issue to yourself.
-
Start from the top and click the link for the first resource,
aws_glue_workflow
in this case. -
Here we'll look for clues about how the Terraform ID is shaped so that we can infer the external name configuration. In this case, there is a
name
argument seen underArgument Reference
section and when we look atImport
section, we see that this is what's used to import, i.e. Terraform ID is same asname
argument. This means that we can useconfig.NameAsIdentifier
configuration from Upjet as our external name config. See section External Name Cases to see how you can infer in many different cases of Terraform ID. -
First of all, please see the Moving Untested Resources to v1beta1 documentation.
Go to
config/externalname.go
and add the following line toExternalNameConfigs
table:// glue // // Imported using "name". "aws_glue_workflow": config.NameAsIdentifier,
-
Run
make reviewable
. -
Go through the "Warning" boxes (if any) in the Terraform Registry page to see whether any of the fields are represented as separate resources as well. It usually goes like
Routes can be defined either directly on the azurerm_iothub resource, or using the azurerm_iothub_route resource - but the two cannot be used together.
In such cases, the field should be moved to status since we prefer to represent it only as a separate CRD. Go ahead and add a configuration block for that resource similar to the following:
p.AddResourceConfigurator("azurerm_iothub", func(r *config.Resource) { // Mutually exclusive with azurerm_iothub_route config.MoveToStatus(r.TerraformResource, "route") })
-
Go to the end of the TF registry page to see the timeouts. If they are longer than 10 minutes, then we need to set the
UseAsync
property of the resource totrue
. Go ahead and add a configuration block for that resource similar to the following if it doesn't exist already:p.AddResourceConfigurator("azurerm_iothub", func(r *config.Resource) { r.UseAsync = true })
Note that some providers have certain defaults, like Azure has this on by default, in such cases you need to set this parameter to
false
if the timeouts are less than 10 minutes. -
Resource configuration is largely done, so we need to prepare the example YAML for testing. Copy
examples-generated/glue/workflow.yaml
intoexamples/glue/workflow.yaml
and then removespec.forProvider.name
field. If there is nothing left underspec.forProvider
, then give it empty struct, e.g.forProvider: {}
-
Repeat the same process for other resources under
glue
. -
Once
glue
is completed, the following would be the additions we made to the external name table and we'd have new examples underexamples/glue
folder.// glue // // Imported using "name". "aws_glue_workflow": config.NameAsIdentifier, // Imported using arn: arn:aws:glue:us-west-2:123456789012:schema/example/example "aws_glue_schema": config.IdentifierFromProvider, // Imported using "name". "aws_glue_trigger": config.NameAsIdentifier, // Imported using the catalog_id:database_name:function_name // 123456789012:my_database:my_func "aws_glue_user_defined_function": config.TemplatedStringAsIdentifier("name", "{{ .parameters.catalog_id }}:{{ .parameters.database_name }}:{{ .externalName }}"), "aws_glue_security_configuration": config.NameAsIdentifier, // Imported using the account ID: 12356789012 "aws_glue_resource_policy": config.IdentifierFromProvider,
-
Create a commit to cover all manual changes so that it's easier for reviewer with a message like the following
aws: add glue group
. -
Run
make reviewable
so that new resources are generated. -
Create another commit with a message like
aws: regenerate for glue group
.
That's pretty much all we need to do in the codebase. With these two commits, we can open a new PR.
Our first option is to run it by the automated testing tool we have. In order to trigger it, you can drop a comment on the PR containing the following:
# Wildcards like provider-aws/examples/glue/*.yaml also work.
/test-examples="provider-aws/examples/glue/catalogdatabase.yaml,provider-aws/examples/glue/catalogtable.yaml"
Once the automated tests pass, we're good to go. However, in some cases there is a bug you can fix right away and in others resource is just not suitable for automated testing, such as the ones that require you to take a special action that a Crossplane provider cannot, such as uploading a file.
Our goal is to make it work with automated testing as much as possible. So, the next step is to test the resources manually in your local and try to spot the problems that prevent it from working with the automated testing. The steps for manual testing are roughly like the following (no Crossplane is needed):
kubectl apply -f package/crds
to install all CRDs into cluster.make run
to start the controllers.- You need to create a
ProviderConfig
named asdefault
with correct credentials. - Now, you can create the examples you've got generated and check events/logs to spot problems and fix them.
There are cases where the resource requires user to take an action that is not possible with a Crossplane provider or automated testing tool. In such cases, we should leave the actions to be taken as annotation on the resource like the following:
apiVersion: apigatewayv2.aws.upbound.io/v1beta1
kind: VPCLink
metadata:
name: example
annotations:
upjet.upbound.io/manual-intervention: "User needs to upload a authorization script and give its path in spec.forProvider.filePath"
If, for some reason, we cannot successfully test a managed resource even manually,
then we do not ship it with the v1beta1
version and thus the external-name
configuration should be commented out with an appropriate code comment
explaining the situation.
An issue in the official-providers repo explaining the situation should be opened preferably with the example manifests (and any resource configuration) already tried.
As explained above, if the resource can successfully be manually tested but
not as part of the automated tests, the example manifest successfully validated
should still be included under the examples directory but with the proper
upjet.upbound.io/manual-intervention
annotation.
And successful manual testing still meets the v1beta1
criteria.
There is a name
argument under Argument Reference
section and Import
section suggests to use name
to import the resource.
Use config.NameAsIdentifier
.
An example would be
aws_eks_cluster
and
here
is its configuration.
There is an argument under Argument Reference
section that is used like name,
i.e. cluster_name
or group_name
, and Import
section suggests to use the
value of that argument to import the resource.
Use config.ParameterAsIdentifier(<name of the argument parameter>)
.
An example would be
aws_elasticache_cluster
and
here
is its configuration.
The ID used in Import
section is completely random and assigned by provider,
like a UUID, where you don't have any means of impact on it.
Use config.IdentifierFromProvider
.
An example would be
aws_vpc
and
here
is its configuration.
The ID used in Import
section is partially random and assigned by provider.
For example, a node in a cluster could have a random ID like 13213
but the
Terraform Identifier could include the name of the cluster that's represented as
an argument field under Argument Reference
, i.e. cluster-name:23123
. In that
case, we'll use only the randomly assigned part as external name and we need to
tell Upjet how to construct the full ID back and forth.
func resourceName() config.ExternalName{
e := config.IdentifierFromProvider
e.GetIDFn = func(_ context.Context, externalName string, parameters map[string]interface{}, _ map[string]interface{}) (string, error) {
cl, ok := parameters["cluster_name"]
if !ok {
return "", errors.New("cluster_name cannot be empty")
}
return fmt.Sprintf("%s:%s", cl.(string), externalName), nil
}
e.GetExternalNameFn = func(tfstate map[string]interface{}) (string, error) {
id, ok := tfstate["id"]
if !ok {
return "", errors.New("id in tfstate cannot be empty")
}
w := strings.Split(s.(string), ":")
return w[len(w)-1], nil
}
}
There are more than a single argument under Argument Reference
that are
concatenated to make up the whole identifier, e.g. <region>/<cluster name>/<node name>
. We will need to tell Upjet to use <node name>
as external
name and take the rest from parameters.
Use config.TemplatedStringAsIdentifier("<name argument>", "<go template>")
in
such cases. The following is the list of available parameters for you to use in
your go template:
parameters: A tree of parameters that you'd normally see in a Terraform HCL
file. You can use TF registry documentation of given resource to
see what's available.
terraformProviderConfig: The Terraform configuration object of the provider. You can
take a look at the TF registry provider configuration object
to see what's available. Not to be confused with ProviderConfig
custom resource of the Crossplane provider.
externalName: The value of external name annotation of the custom resource.
It is required to use this as part of the template.
You can see example usages in the big three providers below.
For aws_glue_user_defined_function
, we see that name
argument is used to
name the resource and the import instructions read as following:
Glue User Defined Functions can be imported using the
`catalog_id:database_name:function_name`. If you have not set a Catalog ID
specify the AWS Account ID that the database is in, e.g.,
$ terraform import aws_glue_user_defined_function.func 123456789012:my_database:my_func
Our configuration would look like the following:
"aws_glue_user_defined_function": config.TemplatedStringAsIdentifier("name", "{{ .parameters.catalog_id }}:{{ .parameters.database_name }}:{{ .externalName }}")
Another prevalent case in AWS is the usage of Amazon Resource Name (ARN) to
identify a resource. We can use config.TemplatedStringAsIdentifier
in many of
those cases like the following:
"aws_glue_registry": config.TemplatedStringAsIdentifier("registry_name", "arn:aws:glue:{{ .parameters.region }}:{{ .setup.client_metadata.account_id }}:registry/{{ .external_name }}"),
However, there are cases where the ARN includes random substring and that would fall under Case 4. The following is such an example:
// arn:aws:acm-pca:eu-central-1:609897127049:certificate-authority/ba0c7989-9641-4f36-a033-dee60121d595
"aws_acmpca_certificate_authority_certificate": config.IdentifierFromProvider,
Most Azure resources fall under this case since they use fully qualified identifier as Terraform ID.
For azurerm_mariadb_firewall_rule
, we see that name
argument is used to name
the resource and the import instructions read as following:
MariaDB Firewall rules can be imported using the resource id, e.g.
terraform import azurerm_mariadb_firewall_rule.rule1 /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/mygroup1/providers/Microsoft.DBforMariaDB/servers/server1/firewallRules/rule1
Our configuration would look like the following:
"azurerm_mariadb_firewall_rule": config.TemplatedStringAsIdentifier("name", "/subscriptions/{{ .terraformProviderConfig.subscription_id }}/resourceGroups/{{ .parameters.resource_group_name }}/providers/Microsoft.DBforMariaDB/servers/{{ .parameters.server_name }}/firewallRules/{{ .externalName }}")
In some resources, an argument requires ID, like azurerm_cosmosdb_sql_function
where it has container_id
and name
but no separate resource_group_name
which would be required to build the full ID. Our configuration would look like
the following in this case:
config.TemplatedStringAsIdentifier("name", "{{ .parameters.container_id }}/userDefinedFunctions/{{ .externalName }}")
Most GCP resources fall under this case since they use fully qualified identifier as Terraform ID.
For google_container_cluster
, we see that name
argument is used to name the
resource and the import instructions read as following:
GKE clusters can be imported using the project , location, and name.
If the project is omitted, the default provider value will be used.
Examples:
$ terraform import google_container_cluster.mycluster projects/my-gcp-project/locations/us-east1-a/clusters/my-cluster
$ terraform import google_container_cluster.mycluster my-gcp-project/us-east1-a/my-cluster
$ terraform import google_container_cluster.mycluster us-east1-a/my-cluster
In cases where there are multiple ways to construct the ID, we should take the one with the least parameters so that we rely only on required fields because optional fields may have some defaults that are assigned after the creation which may make it tricky to work with. In this case, the following would be our configuration:
"google_compute_instance": config.TemplatedStringAsIdentifier("name", "{{ .parameters.location }}/{{ .externalName }}")
There are cases where one of the example import commands uses just name
, like
google_compute_instance
:
terraform import google_compute_instance.default {{name}}
In such cases, we should use config.NameAsIdentifier
since we'd like to have
the least complexity in our configuration as possible.
There is no instructions under Import
section of the resource page in
Terraform Registry, like aws_acm_certificate_validation
from AWS.
Use the following in such cases with comment indicating the case:
// No import documented.
"aws_acm_certificate_validation": config.IdentifierFromProvider,
There are auxiliary resources that don't have an ID and since they map one-to-one to another resource, they just opt to use the identifier of that other resource. In many cases, the identifier is also a valid argument, maybe even the only argument, to configure this resource.
An example would be
aws_ecrpublic_repository_policy
from AWS where the identifier is repository_name
.
Use config.IdentifierFromProvider
because in these cases repository_name
is
more meaningful as an argument rather than the name of the policy for users,
hence we assume the ID is coming from provider.
There are resources that mostly represent a relation between two resources
without any particular name that identifies the relation. An example would be
azurerm_subnet_nat_gateway_association
where the ID is made up of two arguments nat_gateway_id
and subnet_id
without any particular field used to give a name to the resource.
Use config.IdentifierFromProvider
because in these cases, there is no name
argument to be used as external name and both creation and import scenarios
would work the same way even if you configured the resources with conversion
functions between arguments and ID.
If it doesn't match any of the cases above, then we'll need to implement the external name configuration from the ground up. Though in most cases, it's just a little bit different that we only need to override a few things on top of common functions.
One example is aws_route
resource where the ID could use a different
argument depending on which one is given. You can take a look at the
implementation here. This section in the
detailed guide could also help you.