Como ya he comentado últimamente, en los últimos meses he estado trabajando con Terraform
de
forma bastante intensa. Las primeras semanas lo único que quería era saber como sobrevivir y no sufrir xD, pero pasado
esta agobio inicial empecé a tener la sensación de que había mucha “magia” que no acababa de comprender.
Os pongo un ejemplo sencillo:
- Necesito crear un DNS en mi cuenta de
AWS
. - Para ello necesito usar y configurar un
Provider
deTerraform
, en este caso el deAWS
. - Una vez instalado bastaría con crear un
Resource
de tipoaws_route53_record
. - Dicho recurso necesita saber la zona era la que irá alojada el DNS.
- Para no tener que hardcodear el
id
de la zona, hacemos uso de unDataSource
llamadoaws_route53_zone
que dado un nombre de zona nos devuelve, entre otras muchas cosas, suid
.
En código sería algo tal que así:
# versions.tf file
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# providers.tf file
provider "aws" {
region = "eu-central-1"
}
# data.tf file
data "aws_route53_zone" "pmareke" {
name = "pmareke.com"
}
# dns.tf file
resource "aws_route53_record" "mail" {
zone_id = data.aws_route53_zone.pmareke.zone_id
type = "CNAME"
name = "email.gh-mail.pmareke.com"
ttl = "300"
records = [
"eu.mailgun.org"
]
}
Como podemos ver, más allá de necesitar conocer como se usan los recursos, que por cierto la documentación suele ser brutal, no hace falta mucho más.
Pero claro… ¿Aquí están pasando muchas cosas no?
- ¿Cómo se configura el
Provider
? - ¿Cómo se busca la zona a partir de su nombre?
- ¿Cómo se crea el DNS en AWS?
En este punto fue donde me dije a mí mismo que necesitaba saber un poco más que estaba pasando por detrás, aunque fuera muy por encima.
¿Cómo funciona un Provider
de Terraform
?
Como iremos a viendo continuación, todos los providers de Terraform
no dejan de ser mini apps (algunas no tan mini)
desarrolladas en Go (un lenguaje con un tipado fuerte).
Esto ha permitido a los creados de Terraform
definir de forma brillante una serie de firmas o contratos mediante las cuales
todo aquel que quiera crear su propio Provider
solo tiene que satisfacerlas.
En el caso del Provider
vamos a necesitar definir que campos son necesarios para que se pueda hacer
correctamente la conexión con el servicio de terceros en cuestión.
Por ejemplo el de AWS:
provider "aws" {
region = "eu-central-1"
}
Aquí solo le definimos la región en la que vamos a trabajar, pero leyendo la documentación vemos que necesita algo más.
Al menos un par de variables de entorno (AWS_ACCESS_KEY_ID
y AWS_SECRET_ACCESS_KEY
) para no tener que especificarlas
en código (existen otras maneras para autenticarse contra AWS, pero he elegido está por ser la más sencilla):
provider "aws" {
region = "eu-central-1"
access_key = "my-access-key"
secret_key = "my-secret-key"
}
Para que todos estos campos que configuramos en código Terraform
sean usados por nuestro Provider
simplemente tenemos que seguir estos dos simples pasos:
- Implementar el modelo de datos de nuestro
Provider
definiendo sus campos. - Implementar los métodos
Metadata
,Schema
yConfigure
.
// internal/provider.go file
type customProviderModel struct {
// Aquí definiremos los campos, tanto los obligatorios como los que no, de nuestro Provider.
Region types.String `tfsdk:"region"`
...
}
func (p *hashicupsProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
// Aquí definimos el nombre y la versión de nuestro Provider.
resp.TypeName = "aws"
resp.Version = p.version
}
func (p *customProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {
// Aquí definiremos el esquema de nuestro Provider, tanto los obligatorios como los que no, de nuestro Provider.
// ex: access_key or region
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"region": schema.StringAttribute{
Optional: false,
},
},
...
}
}
func (p *customProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
// Aquí es donde leeremos los valores proporcionados a nuestro Provider
// Haremos las validaciones necesarias para garantizar que toda la configuración es correcta.
// Obtendremos los valores a partir de las variables de entorno.
// Crearemos nuestro cliente, normalmente HTTP, que será el encargado de hablar con el servicio externo.
}
Lo principal de este punto, y donde para mí fue un momento clave, es que al final Terraform
hace uso de
clientes (normalmente HTTP y desarrollados en Go, como por ejemplo el de AWS)
para hacer toda la comunicación con los servicios de terceros y para mantener el estado de nuestra infra.
El lenguaje de Terraform
nos abstrae de tener que saber que ocurre por debajo y nos permite centrarnos
en lo importante, que es la infraestructura.
DataSource
Una vez visto como se configura el Provider
ya podéis imaginar un poco por donde irán los tiros ahora.
Para crear nuestro propio DataSource
que leerá el estado de nuestra infra bastará con seguir los siguientes pasos:
- Implementar el modelo de nuestro
DataSource
. - Implementar el modelo de datos de
DataSourceModel
para mapear nuestra infraestructura. - Implementar los métodos
Configure
,Metadata
,Schema
yRead
- Incluir el nuevo
DataSource
en la configuración delProvider
.
// internal/provider/aws_route53_zone_data_source.go
type customDataSource struct{
// Aquí definiremos los campos, tanto los obligatorios como los que no, de nuestro DataSource.
Name types.String `tfsdk:"name"`
...
}
func NewCustomDataSource() datasource.DataSource {
return &customDataSource{}
}
type customDataSourceModel struct {
// Aquí definiremos los campos, tanto los obligatorios como los que no, de nuestro modelo que mapeara la infra real.
Name types.String `tfsdk:"name"`
...
}
func (d *customDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
// Aquí es donde leeremos los valores proporcionados a nuestro recurso.
// Haremos las validaciones necesarias para garantizar que toda la configuración es correcta.
// Usaremos nuestro Provider para hablar con el servicio externo.
}
func (d *customDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
// Aquí es donde definimos el nombre del tipo de nuestro DataSource
resp.TypeName = req.ProviderTypeName + "_route53_zone"
}
func (d *customDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
// Aquí definiremos el esquema de nuestro DataSource, tanto los obligatorios como los que no, de nuestro Provider.
// ex: access_key or region
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
Optional: false,
},
},
...
}
}
func (d *customDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
// Leemos, usando nuestro cliente, de nuestro servicio de terceros.
// Mapeamos la respuesta a nuestro modelo de datos.
// Actualizamos el estado de Terraform.
}
Ya solo faltaría dar de alta nuestro nuevo DataSource
en nuestro Provider
:
// internal/provider/provider.go
func (p *customProvider) DataSources(_ context.Context) []func() datasource.DataSource {
return []func() datasource.DataSource {
NewCustomDataSource,
}
}
Como podemos ver, un DataSource
no es más que una abstracción de nuestra infraestructura, que usa el cliente para
conocer su estado actual y así mantenerse actualizado.
Resource
Por último, y muy similar a los DataSources
, si queremos crear nuestro propio Resource
bastaría con seguir los
siguientes pasos:
- Implementar el modelo de nuestro
Resource
. - Implementar el modelo de datos del
ResourceModel
. - Implementar los métodos
Configure
,Metadata
,Schema
,Read
como en el caso anterior delDataSource
. - Implementar los métodos
Create
,Update
yDelete
. - Incluir el nuevo
Resource
en la configuración delProvider
.
// internal/provider/route53_record_resource.go
func NewCustomResource() resource.Resource {
return &customResource{}
}
type customResource struct{}
type customResourceModel struct {
// Aquí definiremos los campos, tanto los obligatorios como los que no, de nuestro modelo que mapeara la infra real.
ZoneId types.String `tfsdk:"zone_id"`
...
}
func (r *customResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
// Aquí es donde leeremos los valores proporcionados a nuestro recurso.
// Haremos las validaciones necesarias para garantizar que toda la configuración es correcta.
// Usaremos nuestro Provider para hablar con el servicio externo.
}
func (r *customResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
// Aquí es donde definimos el nombre del tipo de nuestro Resource.
resp.TypeName = req.ProviderTypeName + "_route53_record"
}
func (r *customResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
// Aquí definiremos el esquema de nuestro Resource, tanto los obligatorios como los que no, de nuestro Provider.
// ex: zone_id or type
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"zone_id": schema.StringAttribute{
Optional: false,
},
},
...
}
}
func (r *customResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
// Leemos, usando nuestro cliente, de nuestro servicio de terceros.
// Mapeamos la respuesta a nuestro modelo de datos.
// Actualizamos el estado de Terraform.
}
func (r *customResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
// Validamos que el cliente está correctamente configurado.
// Obtenemos los valores actuales del estado de Terraform.
// Creamos los recursos necesarios haciendo uso del cliente.
// Actualizamos el estado de Terraform.
}
func (r *customResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
// Verificamos el modelo de datos.
// Actualizar el recurso real haciendo uso de nuestro cliente.
// Obtenemos el estado real de nuestra infra.
// Actualizamos el estado de Terraform.
}
func (r *customResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
// Leemos el estado actual de Terraform
// Borramos el recurso de nuestra infraestructura.
// Si no hay ningún tipo de error en la operación, Terraform borra automáticamente el recurso del estado.
}
Ya solo faltaría dar de alta nuestro nuevo Resource
en nuestro Provider
:
// internal/provider/provider.go
func (p *hashicupsProvider) Resources(_ context.Context) []func() resource.Resource {
return []func() resource.Resource{
NewCustomResource,
}
}
Al final no había tanta magia como parecía
Una vez entendido como funciona por detrás un Provider
de Terraform
creo que está más claro que no hay
tanta magia como parecía.
Por una parte creo que ha sido una idea brillante por parte del equipo detrás de Terraform
delegar toda la creación/lectura
de la infraestructura a los clientes ya existentes en Go, que son fácilmente importables en los Providers
y que no requieren
de ninguna adaptación extra.
Además, la definición de las interfaces que tiene que cumplir todo Provider
me fascina. De una forma sencilla, pero superpotente
han sido capaces de definir una serie de contratos claros y concisos que abarcan infinidad de casos de uso.
!Espero que os haya gustado, un saludo!