Terraform: ¿Dónde está la magia?

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 de Terraform, en este caso el de AWS.
  • Una vez instalado bastaría con crear un Resource de tipo aws_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 un DataSource llamado aws_route53_zone que dado un nombre de zona nos devuelve, entre otras muchas cosas, su id.

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 y Configure.
// 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 y Read
  • Incluir el nuevo DataSource en la configuración del Provider.
// 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 del DataSource.
  • Implementar los métodos Create, Update y Delete.
  • Incluir el nuevo Resource en la configuración del Provider.
// 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!