logocloudstic

Resumen

Los produtos habituales para la Infraestructura como código, como Terraform, Ansible, Chef, Puppet, y demás, suelen contar con su propia sintaxis, lo que dificulta en cierta medida su uso. Pulumi permite la creación de la infraestructura usando lenguajes de programación populares, como TypeScript, JavaScript, Python, Go o C#. De esta forma podemos usar nuestro lenguaje habitual o preferido para la creación de la infraestructura de forma sencilla, sin tener que pasar a otro lenguaje y beneficiándonos del manejo de variables y construcciones de programación habituales, como los bucles y condiciones, aspectos básicos pero que no son demasiado sencillos de usar en el resto de productos.

Objetivos
  • Aprender a desplegar infraestuctura mediante código.

  • Realizar cambios sobre la infraestructura a través del código.

  • Organizar el código despliegue.

  • Gestionar errores de despliegues corruptos.

Disponibles los repositorios usados en este tutorial

1. Introducción

La Infraestructura como código (IaC, Infrastructure as Code) permite la definición y mantenimiento de la infraestructura mediante código. Esto permite la automatización de la creación de la infraestructura en lugar de hacerlo manualmente. Se trata de un proceso declarativo en el que mediante una serie de archivos se especifica de forma declarativa la infraestructura y la configuración que se necesita (p.e. un entorno de backend formado por un volumen de almacenamiento, una máquina virtual con MySQL instalado y conectada al volumen creado, una máquina virtual con un framework instalado como Express, la configuración de red y de grupos de seguridad, …​.). De esta forma se evita tener que preparar manualmente las máquinas virtuales, sistemas operativos, almacenamiento y configuración, obteniéndose en su lugar la definición de un entorno que garantiza ser repetible, replicable, documentando las especificaciones y evitando la práctica de realización de cambios sobre la marcha y que no queden documentados. Con IaC, los cambios se hacen sobre el código y se aplican ejecutando el código desarrollado, en lugar de hacerlos en caliente mediante comandos sobre la infraestructura. Además, la combinación de IaC con la práctica de control de versiones permitirá, como en cualquier otro desarrollo software, llevar un control de cada uno de los cambios introducidos en la configuración de la infraestructura.

Entre los productos más populares de IaC se encuentran Terraform, Ansible, Chef, Puppet, …​ En estas propuestas, la infraestructura se especifica de formas muy variadas, como en formato YAML en el caso de Ansible, o en su propio lenguaje DSL (Domain Specific Language) como el HCL (Hashicorp Configuration Language) de Terraform y el Puppet Language de Puppet, pasando por Chef, que usa Ruby como sintaxis para la definición de la infraestructura.

En estos enlaces puedes consultar otros tutoriales sobre Terraform y Ansible.

En este contexto surge Pulumi, un nuevo producto de IaC que se diferencia del resto en que usa lenguajes de programación convencionales para la configuración de la infraestructura, como TypeScript, JavaScript, Python, Go o C#. Esto lo hace especialmente interesante, ya que no se tendrá que aprender un lenguaje extra para el manejo de infraestructura cloud y se facilita el uso de variables y construcciones de programación como bucles y condiciones.

Pulumi ofrece soporte para los principales proveedores cloud, como Amazon, Google Cloud, Microsoft Azure, Digital Ocean, Linode, OpenStack, vSphere, y demás, así como para Kubernetes, proveedores de infraestructura, bases de datos, monitorización y sistemas de control de versiones.

En este tutorial nos centraremos en la creación de un cluster de Kubernetes gestionado con Rancher y usando OpenStack como proveedor de infraestructura. Usaremos TypeScript como lenguaje de desarrollo.

2. Configuración de Pulumi

La instalación la haremos siguiendo la guía oficial de instalación.

  • En macOS la haremos mediante Homebrew con brew install pulumi.

  • En Windows la haremos mediante Chocolatey con choco install pulumi.

  • En Linux la haremos ejecutando el script curl -fsSL https://get.pulumi.com | sh.

Una vez instalado comprobaremos que todo es correcto con:

$ pulumi version
v3.7.0

3. Flujo de trabajo con Pulumi

El flujo de trabajo habitual en Pulumi consiste en la creación del proyecto, codificación de la infraestuctura inicial, despliegue de cambios. Sin embargo, no se trata de un proceso secuencial, sino que en realidad, tras la creación del proyecto se entra en un ciclo iterativo de codificación de infraestructura y despliegue de cambios.

El proyecto se crea con el comando pulumi new y normalmente le pasaremos una plantilla como argumento. En la plantilla se configura la plataforma con la que vamos a trabajar (proveedor cloud, base de datos, …​) y el lenguaje de programación que vamos a utilizar para codificar la infraestructura. Esto lo veremos en el apartado Creación y configuración inicial del proyecto.

Los cambios introducidos en el ciclo de desarrollo con Pulumi pueden deberse bien a la creación de código para nuevos recursos, bien a la modificación o eliminación del código de recursos creados previamente. La forma de llevar a cabo estos cambios siempre es con pulumi up. Tras confirmar la acción de despliegue, los cambios se trasladarán a la infraestructura y Pulumi guardará el estado del despliegue, que consiste en anotar cada uno de los recursos configurados.

Cuando más adelante se introduzcan cambios en el código y se incluya nuevo código para nuevos recursos, o se modifique o elimine código de recursos creados previamente, se comparará el último estado guardado del proyecto con el nuevo estado al que se llegaría tras aplicar los cambios. Tras confirmar la acción de despliegue, Pulumi sólo desplegaría los recursos relativos a los cambios realizados, dejando intactos los recursos que no han sido modificados. En este sentido se dice que Pulumi es idempotente ya que no vuelve a crear un recurso que no ha sido modificado. Tras un despliegue exitoso, Pulumi vuelve a guardar el estado del despliegue realizado.

Si eliminamos un recurso del código y ejecutamos pulumi up se eliminará ese recurso de la infraestructura. Por tanto, no hay operaciones de eliminación propiamente para cada recurso. Simplemente se elimina su código del proyecto y se despliegan los cambios comn pulumi up.

La infraestructura creada se elimina con pulumi destroy. Es una operación peligrosa ya que elimina toda la infraestructura. Si hay datos o configuraciones almacenadas en el despliegue se corre el riesgo de pérdida de datos.

4. Caso de uso. Creación de una máquina virtual configurada con Rancher en OpenStack

El caso de uso que estudiaremos parte de un proyecto OpenStack creado previamente. En dicho proyecto se configurarán mediante Pulumi los grupos de seguridad, se creará una instancia que se aprovisionará durante su inicio con Rancher y se finalizará asignándole una dirección IP flotante.

4.1. Recursos del paquete Pulumi para OpenStack

De acuerdo con la documentación del paquete Pulumi para OpenStack, existen gran cantidad de módulos para la gestión de recursos OpenStack con Pulumi, entre los que destacan almacenamiento de bloques para Cinder, instancias de cómputo para Nova, identidades para Keystone, imágenes para Glance, redes para Neutron y shares para Manila.

4.2. Descarga de las credenciales del proyecto OpenStack

Desde la interfaz gráfica Horizon de OpenStack seguiremos estos casos para la descarga de credenciales del usuario en el proyecto OpenStack a utilizar.

  • Seleccionar el proyecto en OpenStack en el desplegable de proyectos del usuario.

  • En el desplegable del menú del usuario seleccionar OpenStack RC File.

  • Cargar las credenciales descargadas con source <credentials-filename>. Introducir la contraseña solicitada de acceso a OpenStack.

Para usuarios de Windows se recomienda tener instalado WSL.

4.3. Creación y configuración inicial del proyecto

Desde dentro de un directorio vacío creado para el proyecto crearemos el proyecto Pulumi con el comando pulumi new. Si no indicamos nada más, habrá que seleccionar el tipo de proyecto eligiendo tanto la plataforma como el lenguaje. A esta combinación de tipo de proyecto (AWS, Azure, Google Cloud, Kubernetes, Linode, OpenStack) y lenguaje (Go, JavaScript, TypeScript, Python, C#) se le conoce como plantilla. Una forma más rápida es pasar el parámetro de configuración de la plantilla directamente al crear el proyecto

$ pulumi new openstack-typescript (1)
1 Nuevo proyecto usando la plantilla con OpenStack como provider y TypeScript como lenguaje.

A continuación:

  • Aceptaremos el nombre del proyecto, cuyo valor predeterminado es el del directorio en el que se encuentra.

  • Completaremos la descripción con Configuración de MV OpenStack.

  • Aceptaremos el nombre del stack (dev).

Un stack es un concepto similar al de entorno de despliegue de aplicaciones. Podremos tener stacks diferentes para desarrollo, staging y producción.

Una vez aceptadas las opciones de creación del proyecto se instalarán las dependencias del proyecto y unos instantes después el proyecto estará listo para ejecutarse.

Como resultado tendremos un proyecto con la estructura siguiente:

├── .gitignore
├── index.ts (1)
├── package.json (2)
├── Pulumi.yaml (3)
└── tsconfig.json
1 Archivo con los recursos a desplegar. Incopora un ejemplo
2 Archivo de dependencias. La dependencia de OpenStack aparece como instalada al haber creado el proyecto con la plantilla openstack-typescript
3 Configuración del nombre y descripción del proyecto y runtime de ejecución
Una instancia como ejemplo de recurso de OpenStack

Tras crear el proyecto con la plantilla de OpenStack, Pulumi incluye un ejemplo de recurso en el archivo index.ts. Se trata de la creación de una instancia OpenStack.

...
import * as os from "@pulumi/openstack"; (1)

const instance = new os.compute.Instance("test", { (2)
	flavorName: "s1-2",
	imageName: "Ubuntu 16.04",
});
...
1 Importación del paquete de recursos de OpenStack
2 Creación de una instancia

Para la creación de la instancia:

  • Se usa os como alias dado al paquete OpenStack.

  • Se usa el módulo compute y el recurso Instance.

  • Se asigna un nombre para la instancia (test en este caso)

  • Se usa un objeto JSON para especificar los parámetros de configuración de la instancia.

4.4. Configuración de las reglas de seguridad

Las reglas de seguridad configuran el cortafuegos del proyecto de OpenStack. Para el ejemplo que nos ocupa, Rancher necesita inicialmente que estén abiertos los puertos TCP 80 y 443 para el tráfico HTTP (HTTP y HTTPS). Para implementarlo podemos incluir estas dos reglas de seguridad en el grupo default del proyecto o crear un grupo de seguridad específico para estas dos reglas. Posteriormente, al configurar la instancia se le aplicaría el grupo de seguridad default o el grupo específico creado para las reglas HTTP. En este ejemplo optamos por crear un grupo de seguridad específico.

Crear grupos de seguridad específicos para grupos de reglas de reglas de seguriddad es más laborioso que ir incluyendo las reglas en el grupo default. Sin embargo, el tener todas las reglas en el grupo de seguridad default provoca que haya instancias que tengan abiertos puertos de forma innecesaria, lo que puede derivar en un problema de seguridad.

4.4.1. Creación de un grupo de seguridad

Los grupos de seguridad se crean con el recurso SecGroup del módulo networking. Basta con indicar un nombre para el grupo de seguridad y un JSON para las opciones. En nuestro caso incluiremos la descripción del grupo de seguridad.

const webSecGroup = new os.networking.SecGroup("web", {
	description: "Web security group"
})

Esto define un grupo de seguridad asignado a una constante webSecGroup. Asignar el recurso creado a una constante o una variable permite manipularlo posteriormente. En nuestro caso se añadirán reglas de seguridad.

4.4.2. Añadir reglas de seguridad

Las reglas de seguridad se añaden a los grupos de seguridad creando un recurso SecGroupRule del módulo networking. Se trata de indicar un nombre para la reglas de seguridad y un JSON para las opciones. En nuestro caso incluiremos una descripción, dirección, si es IPv4 o IPv6, el puerto abierto (definido como un rango), el protocolo, las direcciones IP remotas a las que se les da acceso y el grupo de seguridad al que se asigna la regla creada

const web80 = new os.networking.SecGroupRule("web80", {
	description: "HTTP",
    direction: "ingress",
    ethertype: "IPv4",
    portRangeMax: 80,
    portRangeMin: 80,
    protocol: "tcp",
    remoteIpPrefix: '0.0.0.0/0',
    securityGroupId: webSecGroup.id, (1)
});

const web443 = new os.networking.SecGroupRule("web443", {
	description: "HTTPS",
    direction: "ingress",
    ethertype: "IPv4",
    portRangeMax: 443,
    portRangeMin: 443,
    protocol: "tcp",
    remoteIpPrefix: '0.0.0.0/0',
    securityGroupId: webSecGroup.id, (2)
});
1 Asignación de la regla a un grupo de seguridad.
2 Asignación de la regla a un grupo de seguridad.

4.4.3. Despliegue de la configuración de seguridad

La configuración de seguridad completa para un entorno con Rancher y Kubernetes residiendo en el mismo proyecto OpenStack incluye una gran variedad de grupos y reglas de seguridad. La documentación oficial de Rancher especifica la lista de puertos a abrir para cada componente.

Hacer una definición exhaustiva de todos los grupos y reglas de seguridad de un proyecto para producción está fuera del ámbito de este tutorial. Por tanto, aquí nos limitaremos a incluir otro grupo de seguridad a modo de ejemplo para ver cómo configurar varios grupos de seguridad. Tomaremos como ejemplo la configuración de seguridad de los puertos 2379 y 2380 de la base de datos etcd que usa Kubernetes para el almacenamiento de la configuración.

Finalmente, la configuración inicial de seguridad quedaría definida así en el archivo index.ts.

import * as os from "@pulumi/openstack";

const cidr = '192.168.129.0/24' (1)

// Create security group (2)
const etcdSecGroup = new os.networking.SecGroup("etcd", {
	description: "Kubernetes security group"
})

// Create security rule and assing to a security group (3)
const etcd2379 = new os.networking.SecGroupRule("etcd2379", {
	description: "etcd",
    direction: "ingress",
    ethertype: "IPv4",
    portRangeMax: 2379,
    portRangeMin: 2379,
    protocol: "tcp",
    remoteIpPrefix: cidr, (4)
    securityGroupId: etcdSecGroup.id, (5)
});

// Create security rule and assing to a security group
const etcd2380 = new os.networking.SecGroupRule("etcd2380", {
	description: "etcd",
    direction: "ingress",
    ethertype: "IPv4",
    portRangeMax: 2380,
    portRangeMin: 2380,
    protocol: "tcp",
    remoteIpPrefix: cidr,
    securityGroupId: etcdSecGroup.id,
});

// Create web security group
const webSecGroup = new os.networking.SecGroup("web", {
	description: "Web security group"
})

// Create security rule and assing to a security group
const web80 = new os.networking.SecGroupRule("web80", {
	description: "HTTP",
    direction: "ingress",
    ethertype: "IPv4",
    portRangeMax: 80,
    portRangeMin: 80,
    protocol: "tcp",
    remoteIpPrefix: '0.0.0.0/0',
    securityGroupId: webSecGroup.id,
});

// Create security rule and assing to a security group
const web443 = new os.networking.SecGroupRule("web443", {
	description: "HTTPS",
    direction: "ingress",
    ethertype: "IPv4",
    portRangeMax: 443,
    portRangeMin: 443,
    protocol: "tcp",
    remoteIpPrefix: '0.0.0.0/0',
    securityGroupId: webSecGroup.id,
});
1 CIDR para permitir el acceso remoto a instancias a las que se apliquen reglas de seguridad para ese CIDR
2 Creación de un grupo de seguridad
3 Creación de una regla para un grupo de seguridad
4 Aplicación del CIDR a la regla de seguridad
5 Asignación de la regla de seguridad a un grupo de seguridad

Los cambios se desplegarían con pulumi up y seleccionando la opción yes. La opción details muestra los detalles de cada uno de los recursos a crear, modificar o eliminar en la infraestructura.

Si al realizar el despliegue nos aparece el error One of 'auth_url' or 'cloud' must be specified se debe a que no se han cargado las credenciales de OpenStack. Consultar el apartado Descarga de las credenciales del proyecto OpenStack.

La figura siguiente muestra el efecto del despliegue con los dos grupos de seguridad creados.

grupos de seguridad

La figura siguiente ilustra las reglas de seguridad del grupo web. Para ese grupo se permite el acceso a estos puertos desde cualquier dirección de Internet.

reglas de seguridad web

4.5. Creación de la instancia

Tras definir los grupos de seguridad aplicables a la instancia continuamos ahora con la creación de un recurso de instancia en OpenStack, lo que nos permitirá tener una máquina virtual desplegada con código mediante Pulumi.

Las instancias de OpenStack en Pulumi se crean con el recurso Instance del módulo compute. Basta con indicar un nombre para la instancia y un JSON para las opciones. En nuestro caso incluiremos la zona de disponibilidad, el nombre de la imagen tal y como está definida en OpenStack, el nombre del flavour o sabor a utilizar para crear la instancia, las redes a las que se conectará la instancia, el nombre del par de claves a inyectar en la instancia y los grupos de seguridad que controlan el acceso a la instancia. Además, incluiremos un script de inicialización de la instancia en su creación (lo que se conoce como user data en otros sistemas). En la sección Script de inicialización de Rancher se aportan los detalles de este script. Este script instalará Docker en la máquina virtual y ejecutará Rancher con Docker.

El fragmento siguiente ilustra el código para la creación de una instancia en el archivo index.ts

import * as os from "@pulumi/openstack";

const fs = require('fs') (1)
...
// Create an OpenStack resource (Compute Instance)
const rancherInstance = new os.compute.Instance("rancher-sistemas-prod", {
	availabilityZone: "stic-prod",
	imageName: "Ubuntu 18.04 LTS",
	flavorName: "large",
	networks: [
		{
            name: "Sistemas-prod-net",
        }
	],
	keyPair: "os-sistemas",
	userData: fs.readFileSync('./rancher-setup.sh', 'utf8'), (2)
	securityGroups: [etcdSecGroup.name, webSecGroup.name] (3)
});
...
1 Paquete TypeScript para la interacción con archivos.
2 Carga del archivo que contiene el script de inicialización. Importante usar utf8.
3 Lista de grupos de seguridad a aplicar a la instancia.

Los cambios se desplegarían con pulumi up y seleccionando la opción yes. La opción details muestra los detalles de cada uno de los recursos a crear, modificar o eliminar en la infraestructura.

La figura siguiente muestra el efecto del despliegue con la instancia creada.

instancia creada

4.6. Asignación de IP flotante

Para poder acceder a la instancia desde el exterior le asignaremos una dirección IP flotante. En nuestro caso ya tenemos la dirección IP flotante adjudicada al proyecto y está registrada en un DNS para poder realizar una instalación de Rancher con nombre DNS. Por tanto, no será necesario crear la dirección IP flotante en el proyecto, sino que pasaremos directamente al paso de asignar dicha dirección IP flotante a la instancia. No obstante, también veremos cómo sería el script si hubiese que crear la dirección IP flotante.

Las direcciones IP flotantes de OpenStack en Pulumi se asignan con el recurso FloatingIpAssociate del módulo compute. Basta con indicar un nombre para la asociación de la IP y un JSON para las opciones. En nuestro caso incluiremos la dirección IP flotante y el identificador de la instancia de Rancher.

El fragmento siguiente ilustra el código para la creación de una instancia en el archivo index.ts

...
const floatingIP = '192.168.129.1' (1)
...
// Associate a floating IP to the instance
const fipFloatingIpAssociate = new os.compute.FloatingIpAssociate("fip", {
    floatingIp: floatingIP, (2)
    instanceId: rancherInstance.id, (3)
});
...
1 Dirección IP flotante a utilizar disponible previamente en el proyecto OpenStack
2 String con la dirección IP flotante
3 Identificador de la instancia

Los cambios se desplegarían con pulumi up y seleccionando la opción yes. La opción details muestra los detalles de cada uno de los recursos a crear, modificar o eliminar en la infraestructura.

La figura siguiente muestra el efecto del despliegue con la dirección IP flotante asignada a la instancia.

ip flotante asignada

La figura siguiente ilustra los detalles de la instancia con la dirección IP flotante asignada y los grupos de seguridad configurados.

instancia configurada
Creación de una dirección IP flotante

Si el proyecto no tiene reservada previamente la dirección IP flotante que vamos a usar, necesitamos crear una nueva.

Las direcciones IP flotantes de OpenStack en Pulumi se crean con el recurso FloatingIp del módulo networking. Basta con indicar un nombre para la dirección IP flotante y un JSON para las opciones. En nuestro caso incluiremos el nombre del pool de direcciones IP flotantes de OpenStack (en nuestro caso es ual-net).

...
const rancherFloatingIp = new openstack.networking.FloatingIp("rancherFloatingIP", {
    pool: "ual-net",
});
...

A continuación asignaríamos la dirección IP flotante recién creada a la instancia creada. El proceso es similar al realizado anteriormente, pero sustituyendo la dirección IP en forma de cadena por la dirección IP flotante recién creada.

...
// Associate a floating IP to the instance
const fipFloatingIpAssociate = new os.compute.FloatingIpAssociate("fip", {
    floatingIp: rancherFloatingIp.address, (1)
    instanceId: rancherInstance.id,
});
...
1 Dirección IP flotante creada.

4.7. Script de inicialización de Rancher

#!/bin/bash

RANCHERPASSWORD='yourpasswordhere' (1)
RANCHERSERVER='https://your.url.here.com' (2)

echo "Instalando Docker" (3)

apt-get update
apt-get install -y \
    apt-transport-https \
    ca-certificates \
    curl \
    software-properties-common \
    jq
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add -
apt-key fingerprint 0EBFCD88
add-apt-repository \
   "deb [arch=amd64] https://download.docker.com/linux/ubuntu \
   $(lsb_release -cs) \
   stable"
apt-get update
apt-get install -y docker-ce
groupadd docker
usermod -aG docker ubuntu
systemctl enable docker

echo "Obteniendo certificados"

mkdir /home/ubuntu/rancherdata
mkdir /home/ubuntu/certificados (4)

wget -O /home/ubuntu/certificados/star_stic_ual_es.crt https://your.certificate.server.here.com/star_stic_ual_es_completa.crt
wget -O /home/ubuntu/certificados/star_stic_ual_es.key https://your.certificate.server.here.com/star_stic_ual_es.key
wget -O /home/ubuntu/certificados/DigiCertCA.crt https://your.certificate.server.here.com/DigiCertCA.crt

docker run \ (5)
    --privileged -d \
    --restart=unless-stopped \
    -p 80:80 -p 443:443 \
    -v /home/ubuntu/rancherdata:/var/lib/rancher \
    -v /home/ubuntu/certificados/star_stic_ual_es.crt:/etc/rancher/ssl/cert.pem \
    -v /home/ubuntu/certificados/star_stic_ual_es.key:/etc/rancher/ssl/key.pem \
    -v /home/ubuntu/certificados/DigiCertCA.crt:/etc/rancher/ssl/cacerts.pem \
    --name rancher \
    rancher/rancher:v2.5.8 \
    --features=unsupported-storage-drivers=true (6)

echo "Configurando Rancher"

while ! curl -k https://localhost/ping; do sleep 3; done (7)

# First Rancher Login
LOGINRESPONSE=`curl -s <8> 'https://127.0.0.1/v3-public/localProviders/local?action=login' -H 'content-type: application/json' --data-binary '{"username":"admin","password":"admin"}' --insecure`
LOGINTOKEN=`echo $LOGINRESPONSE | jq -r .token` (9)

# Change password (10)
curl -s 'https://127.0.0.1/v3/users?action=changepassword' \
    -H 'content-type: application/json' \
    -H "Authorization: Bearer $LOGINTOKEN" \
    --data-binary '{"currentPassword":"admin","newPassword":"'$RANCHERPASSWORD'"}' \
    --insecure

# Configure server-url (11)
curl -s 'https://127.0.0.1/v3/settings/server-url' \
    -H 'content-type: application/json' \
    -H "Authorization: Bearer $LOGINTOKEN" \
    -X PUT \
    --data-binary '{"name":"server-url","value":"'$RANCHERSERVER'"}' \
    --insecure

# Activate OpenStack node driver (12)
curl -s 'https://127.0.0.1/v3/nodeDrivers/openstack?action=activate' \
    -H 'content-type: application/json' \
    -H "Authorization: Bearer $LOGINTOKEN" \
    -X POST \
    --insecure

exit 0
1 Variable con la contraseña de administrador
2 Variable con nombre DNS a asignar a Rancher
3 Instalación de paquetes necesarios para Docker
4 Descarga de certificados
5 Iniciar un contenedor Rancher con los certificados descargados anteriormente
6 Activar los drivers de almacenamiento experimentales para permitir el uso de OpenStack Cinder como proveedor de almacenamiento
7 Esperar a que Rancher esté activo
8 Usar la API de Rancher con las credenciales admin/admin y capturar la respuesta
9 Obtener el token de login a partir de la llamada anterior
10 Usar la API de Rancher con el token de login para configurar la nueva contraseña con la variable configurada al inicio del script
11 Usar la API de Rancher con el token de login para configurar el nombre DNS con la variable configurada al inicio del script
12 Usar la API de Rancher con el token de login para activar el driver de OpenStack

La figura siguiente muestra Rancher disponible tras el inicio de la instancia

rancher

La figura siguiente muestra activada las características de drivers de almacenamiento no soportados para permitir el uso de volúmenes de OpenStack Cinder.

driver cinder

La figura siguiente muestra activado el driver de OpenStack para la creación de nodos Kubernetes

openstack node driver

4.8. Reorganización del código

Hasta ahora hemos ido creando recursos poco a poco, comenzando con los grupos de seguridad para centrarnos posteriormente en la creación de la instancia. Actualmente tenemos toda la configuración de la infraestructura en un único archivo index.ts. A medida que incorporemos nuevos grupos de seguridad, nuevas reglas, nuevas instancias, el código de index.ts se terminará haciendo inmanejable. Actualmente, el archivo index.js luce de esta manera.

Example 1. index.ts con todos los recursos juntos
import * as os from "@pulumi/openstack";
import * as sg from './security-groups'

const cidr = '192.168.129.0/24'
const floatingIP = '192.168.129.1'
const fs = require('fs')

// Create security group
const etcdSecGroup = new os.networking.SecGroup("etcd", {
	description: "Kubernetes security group"
})

// Create security rule and assing to a security group
const etcd2379 = new os.networking.SecGroupRule("etcd2379", {
	description: "etcd",
    direction: "ingress",
    ethertype: "IPv4",
    portRangeMax: 2379,
    portRangeMin: 2379,
    protocol: "tcp",
    remoteIpPrefix: cidr,
    securityGroupId: etcdSecGroup.id,
});

// Create security rule and assing to a security group
const etcd2380 = new os.networking.SecGroupRule("etcd2380", {
	description: "etcd",
    direction: "ingress",
    ethertype: "IPv4",
    portRangeMax: 2380,
    portRangeMin: 2380,
    protocol: "tcp",
    remoteIpPrefix: cidr,
    securityGroupId: etcdSecGroup.id,
});

// Create web security group
const webSecGroup = new os.networking.SecGroup("web", {
	description: "Web security group"
})

// Create security rule and assing to a security group
const web80 = new os.networking.SecGroupRule("web80", {
	description: "HTTP",
    direction: "ingress",
    ethertype: "IPv4",
    portRangeMax: 80,
    portRangeMin: 80,
    protocol: "tcp",
    remoteIpPrefix: '0.0.0.0/0',
    securityGroupId: webSecGroup.id,
});

// Create security rule and assing to a security group
const web443 = new os.networking.SecGroupRule("web443", {
	description: "HTTPS",
    direction: "ingress",
    ethertype: "IPv4",
    portRangeMax: 443,
    portRangeMin: 443,
    protocol: "tcp",
    remoteIpPrefix: '0.0.0.0/0',
    securityGroupId: webSecGroup.id,
});

// Create an OpenStack resource (Compute Instance)
const rancherInstance = new os.compute.Instance("rancher-sistemas-prod", {
	availabilityZone: "stic-prod",
	imageName: "Ubuntu 18.04 LTS",
	flavorName: "large",
	networks: [
		{
            name: "Sistemas-prod-net",
        }
	],
	keyPair: "os-sistemas",
	userData: fs.readFileSync('./rancher-setup.sh', 'utf8'),
	securityGroups: [etcdSecGroup.name, webSecGroup.name]
});

// Associate a floating IP to the instance
const fipFloatingIpAssociate = new os.compute.FloatingIpAssociate("fip", {
    floatingIp: floatingIP,
    instanceId: rancherInstance.id,
});

La refactorización que se propone consiste en:

  • Crear un archivo de variables (values.ts) en el que se configuren los valores de las variables a utilizar. En este ejemplo configuraremos por un lado el CIDR para permitir el acceso desde direcciones IP remotas y, por otro lado, la dirección IP flotante que tenemos reservada para Rancher.

  • Separar la configuración de los grupos y reglas de seguridad en un archivo aparte (security-groups.ts)

  • Mantener en index.ts sólo la configuración de la instancia de Rancher y la asignación a la IP flotante.

A continuación se muestra el código de cada uno de estos archivos tras la refactorización.

Example 2. values.ts con los valores de configuración del despliegue
const cidr = '192.168.129.0/24'
const floatingIP = '192.168.129.1'

export {cidr, floatingIP} (1)
1 Constantes exportadas para ser reutilizadas
Example 3. security-groups.ts con la configuración de los grupos y reglas de seguridad del despliegue
import * as os from "@pulumi/openstack";
import * as values from './values' (1)

// Create security group
const etcdSecGroup = new os.networking.SecGroup("etcd", {
	description: "Kubernetes security group"
})

// Create security rule and assing to a security group
const etcd2379 = new os.networking.SecGroupRule("etcd2379", {
	description: "etcd",
    direction: "ingress",
    ethertype: "IPv4",
    portRangeMax: 2379,
    portRangeMin: 2379,
    protocol: "tcp",
    remoteIpPrefix: values.cidr, (2)
    securityGroupId: etcdSecGroup.id,
});

// Create security rule and assing to a security group
const etcd2380 = new os.networking.SecGroupRule("etcd2380", {
	description: "etcd",
    direction: "ingress",
    ethertype: "IPv4",
    portRangeMax: 2380,
    portRangeMin: 2380,
    protocol: "tcp",
    remoteIpPrefix: values.cidr,
    securityGroupId: etcdSecGroup.id,
});

// Create web security group
const webSecGroup = new os.networking.SecGroup("web", {
	description: "Web security group"
})

// Create security rule and assing to a security group
const web80 = new os.networking.SecGroupRule("web80", {
	description: "HTTP",
    direction: "ingress",
    ethertype: "IPv4",
    portRangeMax: 80,
    portRangeMin: 80,
    protocol: "tcp",
    remoteIpPrefix: '0.0.0.0/0',
    securityGroupId: webSecGroup.id,
});

// Create security rule and assing to a security group
const web443 = new os.networking.SecGroupRule("web443", {
	description: "HTTPS",
    direction: "ingress",
    ethertype: "IPv4",
    portRangeMax: 443,
    portRangeMin: 443,
    protocol: "tcp",
    remoteIpPrefix: '0.0.0.0/0',
    securityGroupId: webSecGroup.id,
});

export {webSecGroup, etcdSecGroup} (3)
1 Importación del archivo de parámetros y configuración del prefijo values para usar los objetos que ha exportado
2 Uso de las constantes definidas en el archivo de parámetros
3 Se exportan los grupos de seguridad para poder ser reutilizados
Example 4. index.ts con la configuración de la instancia del despliegue y la asignación de una IP flotante asignada previamente al proyecto
import * as os from "@pulumi/openstack";
import * as values from './values' (1)
import * as sg from './security-groups' (2)

const fs = require('fs')

// Create an OpenStack resource (Compute Instance)
const rancherInstance = new os.compute.Instance("rancher-sistemas-prod", {
	availabilityZone: "stic-prod",
	imageName: "Ubuntu 18.04 LTS",
	flavorName: "large",
	networks: [
		{
            name: "Sistemas-prod-net",
        }
	],
	keyPair: "os-sistemas",
	userData: fs.readFileSync('./rancher-setup.sh', 'utf8'),
	securityGroups: [sg.etcdSecGroup.name, sg.webSecGroup.name] (3)
});

// Associate a floating IP to the instance
const fipFloatingIpAssociate = new os.compute.FloatingIpAssociate("fip", {
    floatingIp: values.floatingIP, (4)
    instanceId: rancherInstance.id,
});
1 Importación del archivo de parámetros y configuración del prefijo values para usar los objetos que ha exportado
2 Importación del archivo de grupos de seguridad y configuración del prefijo sg (security-groups) para usar los objetos que ha exportado
3 Uso de los grupos de seguridad del archivo de grupos de seguridad
4 Uso de los parámetros del archivo de parámetros

5. Caso de uso. Creación de clusters Kubernetes con Rancher y OpenStack como proveedor de infraestructura

Pulumi cuenta con un paquete Rancher que permite la configuración de recursos Rancher. En este caso de uso partimos de una instalación previa de Rancher (ver Caso de uso. Creación de una máquina virtual configurada con Rancher en OpenStack). El objetivo será la creación de un cluster de Kubernetes usando Pulumi obteniendo un despliegue replicable y repetible en nuestro propósito de tener la infraestructura como código. La infraestructura del cluster de Kubernetes será ofrecida por un cloud OpenStack. Por tanto, deberemos comenzar con la creación de diferentes plantillas con las configuraciones necesarias de los recursos de los nodos del cluster (usaremos plantillas diferentes para los roles de control, base de datos etcd y los nodos worker). Una vez creadas las plantillas de los nodos procederemos a crear los nodos del cluster Kubernetes personalizados a la plantilla adecuada.

5.1. Generación de las credenciales de la API de Rancher

Para usar el paquete Rancher de Pulumi es necesario realizar un proceso de configuración de claves de la API de Rancher con el objetivo de poder crear y actualizar recursos de Rancher desde Pulumi. Las credenciales se obtendrán desde Rancher siguiendo estos pasos:

  1. En el menú desplegable de usuario seleccionar API & Keys.

    menu api keys
  2. En la pantalla API & Keys pulsar el botón Add Key.

  3. Dejar los valores predeterminados en el cuadro de diálogo.

    add api key
  4. Copiar los valores generados. Estos valores será la única vez que se muestran y no se podrán volver a recuperar.

    api key created

Si se pierden estas credenciales habrá que volver a generar otras nuevas ya que no se podrá recuperar la contraseña generada (secret key).

Una vez seguidos estos pasos habremos generado las credenciales de acceso para la interacción con Rancher a través de su API. Esto es lo que necesitábamos para poder gestionar recursos Rancher desde Pulumi.

5.2. Creación y configuración inicial del proyecto

Desde dentro de un directorio vacío creado para el proyecto crearemos el proyecto Pulumi con el comando pulumi new. Como no hay plantillas definidas para Rancher, crearemos el proyecto con

$ pulumi new typescript

A continuación:

  • Aceptaremos el nombre del proyecto, cuyo valor predeterminado es el del directorio en el que se encuentra.

  • Completaremos la descripción con Configuración de cluster K8s.

  • Aceptaremos el nombre del stack (dev).

Una vez aceptadas las opciones de creación del proyecto se instalarán las dependencias del proyecto y unos instantes después el proyecto estará listo para ejecutarse.

Como resultado tendremos un proyecto con la estructura siguiente:

├── .gitignore
├── index.ts (1)
├── package.json (2)
├── Pulumi.yaml (3)
└── tsconfig.json
1 Archivo donde incluiremos los recursos a desplegar.
2 Archivo de dependencias.
3 Configuración del nombre y descripción del proyecto y runtime de ejecución

5.3. Instalación del paquete Rancher2

Instalaremos el paquete Rancher2 para Pulumi con el comando

npm install @pulumi/rancher2

Esto actualizará el archivo de dependencias package.json del proyecto.

5.4. Configuración de credenciales de acceso a Rancher desde Pulumi

Con los valores obtenidos en Generación de las credenciales de la API de Rancher hay que pasarlos a Pulumi. Siguiendo los pasos de configuración de credenciales de Rancher en Pulumi hay dos opciones:

  • Configurar las variables de entorno RANCHER_URL, RANCHER_ACCESS_KEY y RANCHER_SECRET_KEY. En nuestro caso sería

export RANCHER_URL=https://ranchitodesa.stic.ual.es/v3
export RANCHER_ACCESS_KEY=token-tj6vf
export RANCHER_SECRET_KEY=8pq6g2dpf7njgmncglqrsfggrbwx57.........
  • Establecer la configuración en el stack del proyecto para facilitar el trabajo colaborativo.

    $ pulumi config set rancher2:apiUrl https://ranchitodesa.stic.ual.es/v3
    $ pulumi config set rancher2:accessKey token-tj6vf --secret
    $ pulumi config set rancher2:secretKey 8pq6g2dpf7njgmncglqrsfggrbwx57......... --secret

    Las credenciales configuradas no son enviadas a pulumi.com.

    Si seguimos esta alternativa, tras hacer esta configuración se genera un archivo Pulumi.dev.yaml con la configuración realizada. Por motivos de seguridad, no se deberá enviar esta información de forma indiscriminada a repositorios públicos

5.5. Creación de las plantillas de los nodos

Como el cluster de Kubernetes que vamos a crear usa OpenStack como proveedor de infraestructura y cada instalación particular tiene sus propios valores, hay que crear una plantilla en la que se indiquen todos estos parámetros particulares, desde la URL, credenciales de acceso, y demás, hasta los datos particulares del proyecto del que se van a consumir los recursos, pasando por los datos de la imagen a utilizar para los nodos que usen la plantilla.

Las plantillas se crean con el recurso NodeTemplate del módulo rancher2. Basta con indicar un nombre para la plantilla y un JSON para las opciones. En nuestro caso incluiremos la descripción de la plantilla.

const sistemas_ssh_key = fs.readFileSync('/Users/manolo/.ssh/os-sistemas','utf8');

// Create a new rancher2 Large Ubuntu Node Template up to Rancher 2.1.x

const ubuntuLargeTemplate = new rancher2.NodeTemplate("ubuntu-18-04-large-pulumi", {
    openstackConfig: {
        authUrl: "http://openstack.stic.ual.es:5000/v3", (1)
        availabilityZone: "nova", (2)
        domainName: "default", (3)
        endpointType: "publicURL",
        flavorName: "large", (4)
        floatingIpPool: "ual-net", (5)
        imageName: "Ubuntu 18.04 LTS", (6)
        keypairName: "os-sistemas", (7)
        netName: "Sistemas-prod-net", (8)
        password: "xxxx", (9)
        privateKeyFile: fs.readFileSync('/Users/manolo/.ssh/os-sistemas', 'utf8'), (10)
        region: "RegionOne", (11)
        secGroups: "default", (12)
        sshPort: "22", (13)
        sshUser: "ubuntu", (14)
        tenantName: "Sistemas-prod", (15)
        username: "sistemas", (16)
        userDataFile: fs.readFileSync('./ubuntu-node-setup.sh', 'utf8') (17)
    },
    description: "Ubuntu 18.04 LTS large Pulumi",
});
1 URL de autenticación de la instalación particular de OpenStack
2 Nombre de la zona de disponibilidad
3 Nombre del dominio OpenStack
4 Nombre del sabor para las instancias que usen esta plantilla
5 Nombre de la red externa que propociona las direcciones IP flotantes a las instancias que usen esta plantilla
6 Nombre de la imagen a usar como base para las instancias que usen esta plantilla
7 Nombre del archivo de clave pública del usuario OpenStack para inyectar en las instancias que usen esta plantilla
8 Red del proyecto OpenStack a la que se conectarán las instancias que usen esta plantilla
9 Contraseña del usuario OpenStack
10 Ubicación del archivo de clave privada para poder interactuar con la instancia. Otra opción (menos segura) es pasar el contenido del archivo de clave privada.
11 Región OpenStack
12 Grupos de seguridad OpenStack aplicables a la instancia
13 Puerto de acceso SSH a la instancia creada
14 Nombre de usuario SSH de la imagen de sistema operativo utilizada en los nodos del cluster de Kubernetes
15 Nombre del proyecto que propociona los recursos al cluster de Kubernetes
16 Nombre de usuario OpenStack propietario del proyecto que propociona los recursos al cluster de Kubernetes
17 Opción de pasar un script para configurar la instancia (p.e. para hacer una configuración particular de seguridad de nuestra organización)

Esto define una plantilla asignada a una constante ubuntuLargeTemplate. Asignar el recurso creado a una constante o una variable permite manipularlo posteriormente. En nuestro caso se usará para la crear nodos del cluster de Kubernetes.

Análogamente crearemos una plantilla similar pero con un sabor medium para nodos que necesiten menos recursos.

// Create a new rancher2 Medium Ubuntu Node Template up to Rancher 2.1.x

const ubuntuMediumTemplate = new rancher2.NodeTemplate("ubuntu-18-04-medium-pulumi", {
    openstackConfig: {
        authUrl: "http://openstack.stic.ual.es:5000/v3",
        availabilityZone: "nova",
        domainName: "default",
        endpointType: "publicURL",
        flavorName: "medium", (1)
        floatingIpPool: "ual-net",
        imageName: "Ubuntu 18.04 LTS",
        keypairName: "os-sistemas",
        netName: "Sistemas-prod-net",
        password: "xxxx",
        privateKeyFile: fs.readFileSync('/Users/manolo/.ssh/os-sistemas', 'utf8'),
        region: "RegionOne",
        secGroups: "default",
        sshPort: "22",
        sshUser: "ubuntu",
        tenantName: "Sistemas-prod",
        username: "sistemas",
        userDataFile: fs.readFileSync('./ubuntu-node-setup.sh', 'utf8')

    },
    description: "Ubuntu 18.04 LTS medium Pulumi",
});
1 Sabor medium para nodos menos exigentes

5.6. Creación del cluster

Los clusters se crean con el recurso Cluster del módulo rancher2. Basta con indicar un nombre para el cluster y un JSON para las opciones. En nuestro caso incluiremos la descripción de la plantilla, y la configuración de RKE (Rancher Kubernetes Engine), la distribución de Kubernetes certificada por CNCF y que funciona sobre Docker. RKE facilita la creación del cluster de Kubernetes. En nuestro caso configuraremos el plugin de red y usaremos OpenStack como cloud provider. En la configuración de OpenStack hay que indicar valores relacionados con nombre de usuario, contraseña, URL, proyecto, red, y demás. En la documentación de Rancher OpenStack cloud provider se puede encontrar más información. También hay información de utilidad en Uso de OpenStack como proveedor de infraestructura en Rancher

A continuación se muestra el código para crear un cluster usando OpenStack como proveedor de infraestuctura

// Create a new rancher2 RKE Cluster
const cluster = new rancher2.Cluster("cluster-pulumi", {
    description: "Cluster Pulumi Desa",
    rkeConfig: {
        network: {
            plugin: "canal",
        },

        cloudProvider: {
            name: "openstack",
            openstackCloudProvider: {
                blockStorage: {
                    ignoreVolumeAz: true,
                    trustDevicePath: false
                },
                global: {
                    authUrl: "http://openstack.stic.ual.es:5000/v3", (1)
                    domainName: "default", (2)
                    tenantName: "Sistemas-prod", (3)
                    password: "sistemas", (4)
                    username: "xxxx", (5)
                },
                loadBalancer: {
                    createMonitor: false,
                    floatingNetworkId: "30bf68df-xxxxxx", (6)
                    manageSecurityGroups: false,
                    monitorMaxRetries: 0,
                    subnetId: "aabe1065-xxxxxx", (7)
                    useOctavia: false
                },
                metadata: {
                    requestTimeout: 0
                },
                route: {}
            }
        },

    },
    clusterAuthEndpoint: {
        enabled: true
    }
});
1 URL de autenticación
2 Dominio al que pertenece el usuario
3 Nombre del proyecto que proporciona la infraestructura
4 Contraseña
5 Nombre de usuario
6 ID de la red externa, la que proporciona las IPs flotantes
7 ID de la subred del proyecto que proporciona la infraestructura

Los id de redes y subredes se encuntran en el menú Network | Networks de OpenStack. Basta con seleccionar la red correspondiente y aparecerán todas sus propiedades, su id es una de ella. Para el caso de las subredes se usa el ID, no el Network ID

5.7. Creación de los grupos de nodos (node pools) del cluster

Los clusters están formados por grupos de nodos (node pools). Un grupo de nodos es un conjunto de nodos definidos de acuerdo con una plantilla y puede tener uno o varios de estos roles: etcd, control y worker.

Consideraciones sobre el número de nodos de un rol

El cluster tendrá que tener nodos que soporten las funciones de etcd, control y worker. Estas funciones pueden estar en nodos separados o compartidas entre nodos. En todo caso, en el cluster se deben cumplir las siguientes restricciones en cuanto al número de nodos de cada rol:

  • etcd: 1, 3 ó 5

  • control: 1 o más

  • worker: 1 o más

En nuestro caso crearemos un grupo de nodos para cada función y tendremos las funciones separadas en grupos de nodos diferentes.

Los grupos de nodos se crean con el recurso NodePool del módulo rancher2. Basta con indicar un nombre para el grupo de nodos y un JSON para las opciones. En nuestro caso incluiremos el cluster al que se aplican, el prefijo que se usará para asignarle nombre a las instancias del grupo de nodos en OpenStack, la plantilla OpenStack a utilizar, la cantidad de nodos que tendrá el grupo y los roles activados (etcd, control y worker). De forma predeterminada, los roles están desactivados.

A continuación se muestra el código de los tres grupos de nodos a configurar para el cluster creado anteriormente. Cada grupo de nodos se corresponde con cada uno de los roles (etcd, control y worker). Como es sólo para un ejemplo, a cada grupo de nodos sólo se le define un nodo (quantity: 1).

// Create a new control rancher2 Node Pool (1)
const controlNodePool = new rancher2.NodePool("control-node-pool-pulumi-desa", {
    clusterId: cluster.id, (2)
    hostnamePrefix: "control-pulumi-desa", (3)
    nodeTemplateId: ubuntuLargeTemplate.id, (4)
    quantity: 1, (5)
    controlPlane: true, (6)
});

// Create a new etcd rancher2 Node Pool (7)
const etcdNodePool = new rancher2.NodePool("etcd-node-pool-pulumi-desa", {
    clusterId: cluster.id,
    hostnamePrefix: "etcd-pulumi-desa",
    nodeTemplateId: ubuntuMediumTemplate.id, (8)
    quantity: 1,
    etcd: true (9)
});

// Create a new worker rancher2 Node Pool (10)
const workerNodePool = new rancher2.NodePool("worker-node-pool-pulumi-desa", {
    clusterId: cluster.id,
    hostnamePrefix: "worker-pulumi-desa",
    nodeTemplateId: ubuntuLargeTemplate.id, (11)
    quantity: 1,
    worker: true (12)
});
1 Grupo de nodos del rol control
2 Id del grupo al que pertence el grupo de nodos
3 Prefijo para las máquinas virtuales correspondientes a los nodos del grupo
4 Plantilla large en los nodos del grupo control
5 1 nodo en el grupo
6 Activación del rol control en el grupo de nodos control
7 Grupo de nodos del rol etcd
8 Plantilla medium en los nodos del grupo etcd
9 Activación del rol etcd en el grupo de nodos etcd
10 Grupo de nodos del rol worker
11 Plantilla large en los nodos del grupo worker
12 Activación del rol worker en el grupo de nodos worker

6. Eliminación de un proyecto

La eliminación de los recursos de un proyecto se realiza con el comando pulumi destroy.

El comando pulumi destroy es una operación muy peligrosa ya que elimina totalmente los recursos de un despliegue. Hay que asegurarse primero de ejecutarla sobre el directorio adecuado.

La eliminación de los recursos de un proyecto con pulumi destroy elimina los recursos pero se sigue conservando el historial de operaciones y la configuración del stack. Para eliminar el stack totalmente, y no sólo sus recursos, ejecutar el comando siguiente

$ pulumi stack rm <<nombre-del-stack (p.e. dev)>>

7. Solución de problemas en despliegues corruptos

Si mientras se realiza un despliegue se cancela antes de que finalice aparecerá un mensaje que indica que el despligue tiene recursos con operaciones pendientes. Como motivos de la cancelación podemos tener cancelaciones del proceso por parte del usuario, cortes en la red o un error de ejecución en el CLI de Pulumi.

Como Pulumi no tiene forma de conocer si una operación que ha iniciado se ha llevado a cabo correctamente o ha fallado, esto se traduce en que pueden haberse creado recursos y que Pulumi no tenga conocimiento. Por ello, lo siguiente que se debe hace es cancelar el despliegue con el comando siguiente

$ pulumi cancel

Tras esto, se debe exportar y volver a importar el stack

$ pulumi stack export | pulumi stack import

Para actualizar el estado el stack hay que ejecutar el comando

$ pulumi refresh

7.1. Restaurar a partir de un estado corrupto

A veces, la única forma de restaurar un despliegue corrupto es revisar el archivo de despliegue y ver las operaciones que se han quedado pendientes. Para ello, primero exportaremos a un archivo (p.e. state.json) el estado del despliegue con el comando siguiente:

$ pulumi stack export --file state.json

Seguir estos pasos:

  1. Ir a la sección pending_operations del archivo de despliegue (p.e. state.json) y eliminar dichos recursos de la infraestructura en el caso de que hubiesen sido creados y que Pulumi no tuviese conocimiento.

  2. Editar ese archivo y eliminar el contenido del array pending_operations dejando el array vacío. Guardar los cambios en el archivo.

  3. Volver a importar en el que ya no aparecen operaciones pendientes

    $ pulumi stack import --file state.json

Para más información sobre solución de errores consultar las secciones Recovering from an Interrupted Update y Manually Editing Your Deployment de la guía de solución de errores de Pulumi.