Terraform Essentials II: Cómo desplegar Traefik y Wordpress en Docker con soporte para Lets Encrypt [Entendiendo Argumentos]
Bienvenidos al segundo episodio de la serie Terraform Essentials. En este artículo te mostraré una receta de Terraform que nos servirá para desplegar Traefik, NGINX, Wordpress y MariaDB.
Una vez más, gracias por leer, si caiste derecho a través de Google o un enlace, podés leer el primer artículo de esta serie, donde, explico de manera básica, cómo conectar Terraform y ejecutar una receta en una instancia de Docker local y lanzar un WebServer (NGINX). En ese artículo vimos cómo configurar en los archivos "tf" para que exponga un puerto de un contenedor y como accedemos al mismo.
En el segundo artículo, voy a usar la misma instancia de Docker pero para desplegar un ambiente totalmente funcional que involucra a Traefik, como Reverse Proxy, un Servidor Web, que es un NGINX, un contenedor corriendo Wordpress y otro corriendo un MariaDB que podremos usar como Back End de ese Wordpress, todo esto, con la capacidad de tener certificados de Lets Encrypt.
No solo eso, esta receta, te va a permitir tener la redirección automática hacía HTTPS de los recursos expuestos hacía Traefik y también la habilitación del Dashboard para controlar cómo funciona todo con un autenticación básica para acceder al mismo.
Ahora bien, suena bueno, ¿no? Yo se que si, pero lo que les quiero mostrar con el despliegue de cada una de estas aplicaciones con Terraform, no solo es el hecho de tener una receta que funcione y cubra todo esto, sino que veamos y entendamos cada uno de los argumentos que necesitan cada una estas aplicaciones. De esta manera, creo, entenderemos el poder que tiene Terraform.
¿Empezamos?
En el artículo anterior, vimos, desde dónde descargar e Instalar Terraform, así que esa parte me la voy a saltear. La primera parada es el árbol de archivos. Veamos:
-rw-r--r-- 1 nacho staff 747B Feb 29 15:06 maria.tf
-rw-r--r-- 1 nacho staff 95B Feb 25 23:57 provider.tf
-rw-r--r-- 1 nacho staff 3.0K Feb 29 16:23 traefik.tf
-rw-r--r-- 1 nacho staff 519B Feb 29 12:39 webserver.tf
-rw-r--r-- 1 nacho staff 944B Feb 29 15:22 wordpress.tf
FIJENSE... que separe cada carga de trabajo (Contenedor, Aplicación) en un archivo diferente, esto es para que sea fácil analizarlo después, pero podríamos agarrar un "gran" archivo y poner todo ahí.
Analicemos el provider.tf
El "provider.tf", como comenté en el artículo anterior, es el que va a indicar sobre que plataforma vamos a trabajar y como nos vamos a conectar. Si se acuerdan, les mostre que para ese caso, le pegaba a un socket local, así que ahora, te voy a mostrar un ejemplo, de cómo quedaría, conectando por SSH hacía un Host que corre Docker.
# Configure the Docker provider
provider "docker" {
host = "ssh://root@localhost:puerto"
}
Ahora bien, continuemos. El siguiente archivo para analizar se llama "traefik.tf", que es nuestro Reverse Proxy y que, con la configuración que veremos, nos va a dar capacidades para desplegar, de manera automática, certificados SSL de Lets Encrypt, como comente más adelante, también tiene una configuración específica para redireccionar el tráfico hacia HTTPS, así como también, la habilitación de su dashboard con una autenticación básica.
Traefik.tf
# Contenedor Traefik
resource "docker_container" "traefik" {
name = "cduser_traefik"
image = "traefik:v2.1.4"
command = ["--entrypoints.web.address=:80", "--entrypoints.websecure.address=:443", "--providers.docker", "--api", "--api.dashboard=true", "--log.level=ERROR", "--accesslog.filepath=/Users/nacho/terraform/files/traefik/log/access.log", "--accesslog.format=json", "--accesslog.filters.statuscodes=200,300-302", "--accesslog.filters.retryattempts", "--accesslog.filters.minduration=10ms", "--certificatesresolvers.leresolver.acme.httpchallenge=true", "--certificatesresolvers.leresolver.acme.email=nombre@email.com", "--certificatesresolvers.leresolver.acme.storage=/acme.json", "--certificatesresolvers.leresolver.acme.httpchallenge.entrypoint=web"]
# Especificamos los puertos del contenedor que necesita exponer. Traefik necesita si o si el 80 y el 443.
ports {
internal = 80
external = 80
}
ports {
internal = 443
external = 443
}
# Creamos los volumenes para que guarde los certificados y la configuración de Traefik sea constante.
volumes {
container_path = "/var/log"
read_only = false
host_path = "/Users/nacho/terraform/files/traefik/log"
}
volumes {
container_path = "/var/run/docker.sock"
read_only = true
host_path = "/var/run/docker.sock"
}
volumes {
container_path = "/acme.json"
read_only = true
host_path = "//Users/nacho/terraform/files/traefik/acme/acme.json"
}
# Labels
# Global Redirect to https
labels {
label = "traefik.http.routers.http-catchall.rule"
value = "hostregexp(`{host:.+}`)"
}
labels {
label = "traefik.http.routers.http-catchall.entrypoints"
value = "web"
}
labels {
label = "traefik.http.routers.http-catchall.middlewares"
value = "redirect-to-https"
}
# Making Dashboard Work with https
labels {
label = "traefik.enable"
value = "true"
}
labels {
label = "traefik.http.routers.traefik.rule"
value = "Host(`dashboard.cduser.com`)" # esto es un registro en mi /etc/hosts
}
labels {
label = "traefik.http.routers.traefik.service"
value = "api@internal"
}
labels {
label = "traefik.http.routers.traefik.tls.certresolver"
value = "leresolver"
}
labels {
label = "traefik.http.routers.traefik.entrypoints"
value = "websecure" # Si quisieramos que el dashboard funcione con https, aquí debemos cambiar 'web' por 'websecure'
}
labels {
label = "traefik.http.routers.traefik.middlewares"
value = "authtraefik"
}
labels {
label = "traefik.http.middlewares.authtraefik.basicauth.users"
value = "nacho:{SHA}KfCVPY4DC/T32ix/QdaKZXgYhkg=" # El usuario aquí es nacho y la contraseña es también nacho.
}
# middleware redirect
labels {
label = "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme"
value = "https"
}
}
¡Que pedazo de archivo!. Lo que vemos acá es el despliegue del contenedor, con todos los argumentos que son propios de Docker y que Traefik necesita para correr como corresponde.
Veamos uno por uno estos argumentos, de manera que nos permita entender mejor este archivo.
# Contenedor Traefik
resource "docker_container" "traefik" {
name = "cduser_traefik"
image = "traefik:v2.1.4"
command = ["--entrypoints.web.address=:80", "--entrypoints.websecure.address=:443", "--providers.docker", "--api", "--api.dashboard=true", "--log.level=ERROR", "--accesslog.filepath=/Users/nacho/terraform/files/traefik/log/access.log", "--accesslog.format=json", "--accesslog.filters.statuscodes=200,300-302", "--accesslog.filters.retryattempts", "--accesslog.filters.minduration=10ms", "--certificatesresolvers.leresolver.acme.httpchallenge=true", "--certificatesresolvers.leresolver.acme.email=nombre@email.com", "--certificatesresolvers.leresolver.acme.storage=/acme.json", "--certificatesresolvers.leresolver.acme.httpchallenge.entrypoint=web"]
En este bloque he definido, el resource que en este caso es "docker_container", le doy una propiedad a ese resource que se llame "traefik". Específico un nombre para el contenedor que se llamará "cduser_traefik" y la imágen a descargar con el argumento "image". Por último, vemos el "command". Este argumento nos servirá para especificar qué configuración tendrá Traefik.
Luego vemos los puertos que necesitamos exponer hacia afuera, en este caso, Traefik escuchará esos dos puertos y en función de la configuración que tengan nuestros contenedores, despachará el tráfico hacia uno u otro. Fijense la sintaxis y como se definen los puertos.
# Especificamos los puertos del contenedor que necesita exponer. Traefik necesita si o si el 80 y el 443.
ports {
internal = 80
external = 80
}
ports {
internal = 443
external = 443
}
Traefik también necesita que especifiquemos volúmenes, estos son claves, para guardar los certificados que generemos con Lets Encrypt (acme.json), otro en el cual le exponemos el socket de Docker y por último uno donde vamos a guardar los Logs.
# Creamos los volumenes para que guarde los certificados y la configuración de Traefik sea constante.
volumes {
container_path = "/var/log"
read_only = false
host_path = "/Users/nacho/terraform/files/traefik/log"
}
volumes {
container_path = "/var/run/docker.sock"
read_only = true
host_path = "/var/run/docker.sock"
}
volumes {
container_path = "/acme.json"
read_only = true
host_path = "//Users/nacho/terraform/files/traefik/acme/acme.json"
}
Por último, Traefik necesita de "Labels" y fijense como es la sintaxis de estos (solo pongo algunos para no hacerlo muy largo)
labels {
label = "traefik.http.routers.traefik.service"
value = "api@internal"
}
labels {
label = "traefik.http.routers.traefik.tls.certresolver"
value = "leresolver"
}
Hasta acá, vimos como usar argumentos, especificamente, Labels, Volumes, Ports y Command. Ahora, que terminamos de analizar este archivo, pasemos al de la base de datos, a ver que nos encontramos ahí.
maria.tf
# Contenedor
resource "docker_container" "mariadb" {
name = "cduser_mariadb"
image = "mariadb:10.5.1"
# Especificamos el volumen a montar de mariadb. Siempre es recomendable que quede persistente en un volumen la carpeta /var/lib/mysql.
volumes {
container_path = "/var/lib/mysql"
read_only = false
host_path = "/Users/nacho/terraform/files/mariadb/lib"
}
# Especificamos variables de entorno para crear una base, generar un usuario root, una contraseña para ese usuario, el usuario de base de datos para wordpress y su contraseña.
env = ["MYSQL_DATABASE=wordpress", "MYSQL_USER=user", "MYSQL_PASSWORD=pass", "MYSQL_RANDOM_ROOT_PASSWORD=yes"]
}
Fijense, que acá vemos, el contenedor, el nombre, la imágen que va a descargar y luego el volumen que montaremos para asegurarnos de que la información de ese contenedor sea persistente. Aquí también agregamos algo más: Variables de entorno.
Estás variables, nos permiten pasarle configuración al contenedor, en este caso, vemos cómo estoy creando una base de datos llamada "wordpress", genero un usuario llamado "user" (muy original), un password y una variable para que genere un contraseña de manera aleatoria para el usuario root.
Sigamos. El siguiente archivo es el wordpress.tf.
wordpress.tf
# Contenedor
resource "docker_container" "wordpress" {
name = "cduser_wordpress"
image = "wordpress:php7.4"
env = ["WORDPRESS_DB=cduser_mariadb", "WORDPRESS_DB_USER=user", "WORDPRESS_DB_PASSWORD=pass", "WORDPRESS_DB_NAME=wordpress"]
# Especificamos el volumen a montar de Wordpress. Siempre es recomendable que quede persistente en un volumen la carpeta wp-content.
volumes {
container_path = "/var/www/html/wp-content"
read_only = false
host_path = "/Users/nacho/terraform/files/wordpress/wp-content"
}
# Especificamos las labels para que podamos consumir este contenedor a través de Traefik con la url a.com
labels {
label = "traefik.http.routers.wordpress.rule"
value = "Host(`c.com`)"
}
labels {
label = "traefik.http.routers.wordpress.entrypoints"
value = "websecure"
}
labels {
label = "traefik.http.routers.wordpress.tls.certresolver"
value = "leresolver"
}
}
En este archivo, no vamos a ver nada que ya no hayamos visto, aunque si quiero que se detengan en dos cosas: Una, los labels, en estos ya vamos a especificar configuración para que Traefik haga su magia. Por ejemplo, el nombre del host (c.com), cuál va a ser el EntryPoint (WebSecure = 443) y lo relacionado a certificado SSL de Lets Encrypt. Lo otro es que no está especificado ningún puerto, en este caso, el contenedor de Wordpress, así como muchos otros, exponen hacia adentro del host de Docker, el puerto 80 y eso para Traefik es suficiente.
Lo mismo con el NGINX. Dicho sea de paso.
webserver.tf
# Contenedor
resource "docker_container" "hello_world" {
name = "cduser_hello_world"
image = "tutum/hello-world"
# Especificamos las labels para que podamos consumir este contenedor a través de Traefik con la url a.com
labels {
label = "traefik.http.routers.hello.rule"
value = "Host(`a.com`)"
}
labels {
label = "traefik.http.routers.hello.entrypoints"
value = "websecure"
}
labels {
label = "traefik.http.routers.hello.tls.certresolver"
value = "leresolver"
}
}
Este archivo es muy parecido al de Wordpress, nada del otro mundo, vemos el host (a.com) y el resto, casi igual, fijense, que los "labels" son diferentes y tiene que ser así para cada contenedor que vayamos a exponer a través de Traefik.
Así que estamos listos, con los ingredientes y sus cantidades revisadas, todo parece estar bien, así que vamos a "cocinar" esta receta.
Cooking
Al igual que en el artículo anterior, vamos a ejecutar primero un...
terraform validate
De esta manera, nos vamos a asegurar que la sintaxis sea la correcta. Si todo esta bien, nos devolverá algo como esto:
Success! The configuration is valid.
Ahora bien, podemos ir con el "terraform plan" para validar todo lo que va a hacer o podemos tomar el atajo de directo escribir "terraform apply", que nos mostrará exactamente lo mismo pero con la oportunidad de confirmarlo tipeando "yes".
terraform apply
Nos devolverá todo esto:
An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# docker_container.hello_world will be created
+ resource "docker_container" "hello_world" {
+ attach = false
+ bridge = (known after apply)
+ command = (known after apply)
+ container_logs = (known after apply)
+ entrypoint = (known after apply)
+ env = (known after apply)
+ exit_code = (known after apply)
+ gateway = (known after apply)
+ hostname = (known after apply)
+ id = (known after apply)
+ image = "tutum/hello-world"
+ ip_address = (known after apply)
+ ip_prefix_length = (known after apply)
+ ipc_mode = (known after apply)
+ log_driver = "json-file"
+ logs = false
+ must_run = true
+ name = "cduser_hello_world"
+ network_data = (known after apply)
+ read_only = false
+ restart = "no"
+ rm = false
+ shm_size = (known after apply)
+ start = true
+ labels {
+ label = "traefik.http.routers.hello.entrypoints"
+ value = "websecure"
}
+ labels {
+ label = "traefik.http.routers.hello.rule"
+ value = "Host(`a.com`)"
}
+ labels {
+ label = "traefik.http.routers.hello.tls.certresolver"
+ value = "leresolver"
}
}
# docker_container.mariadb will be created
+ resource "docker_container" "mariadb" {
+ attach = false
+ bridge = (known after apply)
+ command = [
+ "--default-authentication-plugin=mysql_native_password",
]
+ container_logs = (known after apply)
+ entrypoint = (known after apply)
+ env = [
+ "MYSQL_DATABASE=wordpress",
+ "MYSQL_PASSWORD=pass",
+ "MYSQL_RANDOM_ROOT_PASSWORD=yes",
+ "MYSQL_USER=user",
]
+ exit_code = (known after apply)
+ gateway = (known after apply)
+ hostname = (known after apply)
+ id = (known after apply)
+ image = "mariadb:10.5.1"
+ ip_address = (known after apply)
+ ip_prefix_length = (known after apply)
+ ipc_mode = (known after apply)
+ log_driver = "json-file"
+ logs = false
+ must_run = true
+ name = "cduser_mariadb"
+ network_data = (known after apply)
+ read_only = false
+ restart = "no"
+ rm = false
+ shm_size = (known after apply)
+ start = true
+ labels {
+ label = (known after apply)
+ value = (known after apply)
}
+ volumes {
+ container_path = "/var/lib/mysql"
+ host_path = "/Users/nacho/terraform/files/mariadb/lib"
+ read_only = false
}
}
# docker_container.traefik will be created
+ resource "docker_container" "traefik" {
+ attach = false
+ bridge = (known after apply)
+ command = [
+ "--entrypoints.web.address=:80",
+ "--entrypoints.websecure.address=:443",
+ "--providers.docker",
+ "--api",
+ "--api.dashboard=true",
+ "--log.level=ERROR",
+ "--accesslog.filepath=/Users/nacho/terraform/files/traefik/log/access.log",
+ "--accesslog.format=json",
+ "--accesslog.filters.statuscodes=200,300-302",
+ "--accesslog.filters.retryattempts",
+ "--accesslog.filters.minduration=10ms",
+ "--certificatesresolvers.leresolver.acme.httpchallenge=true",
+ "--certificatesresolvers.leresolver.acme.email=nombre@email.com",
+ "--certificatesresolvers.leresolver.acme.storage=/acme.json",
+ "--certificatesresolvers.leresolver.acme.httpchallenge.entrypoint=web",
]
+ container_logs = (known after apply)
+ entrypoint = (known after apply)
+ env = (known after apply)
+ exit_code = (known after apply)
+ gateway = (known after apply)
+ hostname = (known after apply)
+ id = (known after apply)
+ image = "traefik:v2.1.4"
+ ip_address = (known after apply)
+ ip_prefix_length = (known after apply)
+ ipc_mode = (known after apply)
+ log_driver = "json-file"
+ logs = false
+ must_run = true
+ name = "cduser_traefik"
+ network_data = (known after apply)
+ read_only = false
+ restart = "no"
+ rm = false
+ shm_size = (known after apply)
+ start = true
+ labels {
+ label = "traefik.enable"
+ value = "true"
}
+ labels {
+ label = "traefik.http.middlewares.authtraefik.basicauth.users"
+ value = "nacho:{SHA}KfCVPY4DC/T32ix/QdaKZXgYhkg="
}
+ labels {
+ label = "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme"
+ value = "https"
}
+ labels {
+ label = "traefik.http.routers.http-catchall.entrypoints"
+ value = "web"
}
+ labels {
+ label = "traefik.http.routers.http-catchall.middlewares"
+ value = "redirect-to-https"
}
+ labels {
+ label = "traefik.http.routers.http-catchall.rule"
+ value = "hostregexp(`{host:.+}`)"
}
+ labels {
+ label = "traefik.http.routers.traefik.entrypoints"
+ value = "websecure"
}
+ labels {
+ label = "traefik.http.routers.traefik.middlewares"
+ value = "authtraefik"
}
+ labels {
+ label = "traefik.http.routers.traefik.rule"
+ value = "Host(`dashboard.cduser.com`)"
}
+ labels {
+ label = "traefik.http.routers.traefik.service"
+ value = "api@internal"
}
+ labels {
+ label = "traefik.http.routers.traefik.tls.certresolver"
+ value = "leresolver"
}
+ ports {
+ external = 80
+ internal = 80
+ ip = "0.0.0.0"
+ protocol = "tcp"
}
+ ports {
+ external = 443
+ internal = 443
+ ip = "0.0.0.0"
+ protocol = "tcp"
}
+ volumes {
+ container_path = "/acme.json"
+ host_path = "//Users/nacho/terraform/files/traefik/acme/acme.json"
+ read_only = true
}
+ volumes {
+ container_path = "/var/log"
+ host_path = "/Users/nacho/terraform/files/traefik/log"
+ read_only = false
}
+ volumes {
+ container_path = "/var/run/docker.sock"
+ host_path = "/var/run/docker.sock"
+ read_only = true
}
}
# docker_container.wordpress will be created
+ resource "docker_container" "wordpress" {
+ attach = false
+ bridge = (known after apply)
+ command = (known after apply)
+ container_logs = (known after apply)
+ entrypoint = (known after apply)
+ env = [
+ "WORDPRESS_DB=cduser_mariadb",
+ "WORDPRESS_DB_NAME=wordpress",
+ "WORDPRESS_DB_PASSWORD=pass",
+ "WORDPRESS_DB_USER=user",
]
+ exit_code = (known after apply)
+ gateway = (known after apply)
+ hostname = (known after apply)
+ id = (known after apply)
+ image = "wordpress:php7.4"
+ ip_address = (known after apply)
+ ip_prefix_length = (known after apply)
+ ipc_mode = (known after apply)
+ log_driver = "json-file"
+ logs = false
+ must_run = true
+ name = "cduser_wordpress"
+ network_data = (known after apply)
+ read_only = false
+ restart = "no"
+ rm = false
+ shm_size = (known after apply)
+ start = true
+ labels {
+ label = "traefik.http.routers.wordpress.entrypoints"
+ value = "websecure"
}
+ labels {
+ label = "traefik.http.routers.wordpress.rule"
+ value = "Host(`c.com`)"
}
+ labels {
+ label = "traefik.http.routers.wordpress.tls.certresolver"
+ value = "leresolver"
}
+ volumes {
+ container_path = "/var/www/html/wp-content"
+ host_path = "/Users/nacho/terraform/files/wordpress/wp-content"
+ read_only = false
}
}
Plan: 4 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
Apretamos "enter" y vemos que sucede:
docker_container.hello_world: Creating...
docker_container.mariadb: Creating...
docker_container.wordpress: Creating...
docker_container.traefik: Creating...
docker_container.hello_world: Creation complete after 2s [id=5381ed88f3cf22875b8fb21427518cf42f2f6a123498e01bc3a8fc057dd647b1]
docker_container.traefik: Creation complete after 2s [id=cd2c29f1189d64c23e003911a904c6787385182a3a83de7575f41ae96efe1a58]
docker_container.mariadb: Creation complete after 2s [id=929b97f954e995a27217380aee084a2b578d21f31dc789bf03bf42c02c39b568]
docker_container.wordpress: Creation complete after 2s [id=1923c8b3e05c5f2d5026cfd91392e2b6be515bfb63f5f57de907abd0761ce6ef]
Apply complete! Resources: 4 added, 0 changed, 0 destroyed.
Divino, todo creado, ahora, vamos a ver si todo funciona.
docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
1923c8b3e05c wordpress:php7.4 "docker-entrypoint.s…" 30 seconds ago Up 28 seconds 80/tcp cduser_wordpress
cd2c29f1189d traefik:v2.1.4 "/entrypoint.sh --en…" 30 seconds ago Up 28 seconds 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp cduser_traefik
929b97f954e9 mariadb:10.5.1 "docker-entrypoint.s…" 30 seconds ago Up 28 seconds 3306/tcp cduser_mariadb
5381ed88f3cf tutum/hello-world "/bin/sh -c 'php-fpm…" 30 seconds ago Up 28 seconds 80/tcp cduser_hello_world
Todo parece estar bien. Veamos que tal el Hello World. A ver si funciona a través de Traefik y responde cuando invoco "a.com"
Veamos el Wordpress (c.com)
Divino también, hasta se conectó con la base de datos y solo debemos especificar el lenguaje y algunos datos más del wordpress.
Por último, veamos si el dashboard, que tenemos "asegurado" con auth basic, funciona y si funciona perfectamente.
Para ir cerrando
En este segundo artículo, viste cómo desplegar Traefik, con soporte para certificados SSL, con su dashboard activado y con la redirección automática hacia HTTPS, así como también el despliegue de Wordpress, MariaDB y un NGINX. Todo esto, de manera totalmente funciona y usando los argumentos de Terraform.
Tal vez, este artículo tiene algún parecido con este, pero la diferencia, es que no ejecute un docker-compose desde Terraform, sino que construí con argumentos y configuración propia y totalmente funcional.
Aclaración
Si sos nuevo en esto, tal vez te confunda que use los dominios a.com y c.com, estos dominios no son de mi propiedad, lo único que hago es generar un registro en mi archivo /etc/hosts para que a.com y c.com, así como dashboard.cduser.com para que apunten a mi localhost que es donde está corriendo Docker.
Lo otro para aclarar, es que si bien en la barra de tareas, el navegador acusa "not secure" en realidad, se está navegando por SSL, sucede que Let's Encrypt no tiene forma de validar mi localhost y los dominios que estoy utilizando (a.com, c.com) de forma local.
Recursos
Los archivos para descargar utilizados para este artículo, están en el siguiente GitLab. Este es el artículo II, así que vayan a buscar los archivos a esa carpeta.
Despedida
Espero que este artículo te haya servidor para entender un poco más cómo funciona Terraform, quédate atento al próximo miércoles, donde te mostraré, como utilizar Terraform con VMware vSphere. Comentarios son más que bienvenidos así como mejoras en el repo de Gitlab.