View on GitHub

emersonprado.github.io

Página inicial
English

Vagrantfiles eficientes – Parte 4 – Integração de código e dados

Emerson Prado - 02/08/2020

Na parte 3 desta série de artigos, vimos o formato YAML e como ele armazena dados. Vamos usar este formato pra, finalmente, deixar nosso ambiente fácil de ler e manter.

  1. TL;DR
  2. Ruby e YAML
    1. Conversão Ruby pra YAML
    2. Conversão YAML pra Ruby
  3. Nosso Vagrantfile
  4. Mais configurações
  5. Mais arquivos
  6. E depois?

TL;DR

Se você já sabe converter de objetos Ruby para arquivos YAML e vice-versa, talvez você possa pular esta parte dos artigos, e fazer tudo funcionar com o resumo abaixo.

Na parte 2, ficamos com um hash com dados de todas as MVs, e um código com um laço iterando a partir do hash. Vamos usar o método dump do Ruby pra criar o arquivo externo etc/mvs.yaml, no formato YAML, com este hash:

  mkdir etc
  ruby -e "
    require 'yaml'
    puts(YAML.dump({
      'vm_1' => {
        :box => 'ARTACK/debian-jessie',
        :ip => '192.168.1.2'
      },
      'vm_2' => {
        :box => 'ARTACK/debian-jessie',
        :ip => '192.168.1.3'
      },
      'vm_3' => {
        :box => 'centos/7'
      }
    }))
  " > etc/mvs.yaml
  

Nas minhas experiências, eu costumo precisar de mais algumas configurações do VirtualBox. Então incluí no arquivo YAML gerado.

  cat etc/mvs.yaml
  ---
  vm_1:
    :box: ARTACK/debian-jessie    # Gerado no comando dump
    :ram: 256                     # Incluído manualmente
    :ip: 192.168.1.2              # Gerado no comando dump
    :custom:                      # Incluído manualmente
      '--usb': 'off'
      '--usbehci': 'off'
      '--usbxhci': 'off'
      '--vrde': 'off'
  vm_2:
    :box: ARTACK/debian-jessie
    :ram: 256
    :ip: 192.168.1.3
    :custom:
      '--usb': 'off'
      '--usbehci': 'off'
      '--usbxhci': 'off'
      '--vrde': 'off'
  vm_3:
    :box: centos/7
    :cpu: 2
    :custom:
      '--usb': 'off'
      '--usbehci': 'off'
      '--usbxhci': 'off'
      '--vrde': 'off'
  

Agora, no Vagrantfile, vamos carregar este arquivo em uma variável, que terá o mesmo hash de antes. Depois, usar esta variável no laço, com as configurações adicionais:

  require 'yaml'
  MVS = YAML.load_file('etc/mvs.yaml')    # Carrega arquivo YAML em variável

  Vagrant.configure("2") do |config|
    MVS.each do |name, confs|             # Laço a partir da variável
      config.vm.define name do |vm|
        config.vbguest.auto_update = false
        vm.vm.box = confs[:box]
        vm.vm.network "private_network", ip: confs[:ip] if confs.has_key?(:ip)
        # Configurações adicionais
        vm.vm.provider 'virtualbox' do |virtualbox|
          virtualbox.memory = confs[:ram] if confs.has_key?(:ram)
          virtualbox.cpus = confs[:cpu] if confs.has_key?(:cpu)
          confs[:custom].each do |custom, value|
            virtualbox.customize ['modifyvm', :id, custom, value]
          end if confs.has_key?(:custom)
        end
      end
    end
  end
  

Você deve ter percebido (palmas!) que o arquivo de MVs tem praticamente os mesmos dados para MVs que usam o mesmo box. Vamos dividir em 2 arquivos - MVs e boxes:

Arquivo dos boxes - etc/boxes.yaml

  ---
  debian_jessie:
    :name: ARTACK/debian-jessie
    :ram: 256
    :custom:
      '--usb': 'off'
      '--usbehci': 'off'
      '--usbxhci': 'off'
      '--vrde': 'off'
  centos_7:
    :name: centos/7
    :cpu: 2
    :custom:
      '--usb': 'off'
      '--usbehci': 'off'
      '--usbxhci': 'off'
      '--vrde': 'off'
  

Arquivo das MVs - etc/mvs.yaml

  ---
  vm_1:
    :box: debian_jessie   # Configurações da MV incluem o box usado
    :ip: 192.168.1.2
  vm_2:
    :box: debian_jessie
    :ip: 192.168.1.3
  vm_3:
    :box: centos_7
  

Então, no Vagrantfile

  require 'yaml'
  BOXES = YAML.load_file('etc/boxes.yaml')        # Variável dos boxes
  MVS = YAML.load_file('etc/mvs.yaml')            # Variável das MVs

  Vagrant.configure("2") do |config|
    # Laço a partir da variável das MVs
    MVS.each do |name, confs|
      config.vm.define name do |vm|
        config.vbguest.auto_update = false
        # Configurações do box do arquivo dos boxes
        box = BOXES[confs[:box]]
        vm.vm.box = box[:name]
        vm.vm.network "private_network", ip: confs[:ip] if confs.has_key?(:ip)
        vm.vm.provider 'virtualbox' do |virtualbox|
          virtualbox.memory = box[:ram] if box.has_key?(:ram)
          virtualbox.cpus = box[:cpu] if box.has_key?(:cpu)
          box[:custom].each do |custom, value|
            virtualbox.customize ['modifyvm', :id, custom, value]
          end if box.has_key?(:custom)
        end
      end
    end
  end
  

Agora, terminamos com 3 arquivos pequenos e organizados, cada um tendo só dados ou só código, e todos bem simples de entender e manter. Ou seja: missão cumprida!

Complexidade é o limite. Você pode ter só um arquivo de dados, ou dividir os dados em vários arquivos. Procure equilibrar concisão e simplicidade. O objetivo é facilitar a compreensão e a manutenção do conjunto, inclusive por outros, muitas vezes não programadores. Bom senso é seu amigo!

Ruby e YAML

Na parte 3, eu disse que Ruby e YAML são amigos íntimos. Na língua de programadores, isso quer dizer que existe um módulo especializado, na biblioteca padrão do Ruby, que trata YAML. Em palavras mais do mundo real, quer dizer que dá pra usar métodos de conversão pra YAML (e mais) instalando nada mais que o próprio Ruby. É só carregar o módulo antes de usar as funções, com require 'yaml'.

Conversão Ruby pra YAML

Pra converter objetos Ruby em YAML, usamos o método dump, que devolve uma string YAML com o objeto passado.

  ruby -e '
    require "yaml"
    puts(YAML.dump([
      "Array com",
      3,
      "Itens"
    ]))
  '
  ---
  - Array com
  - 3
  - Itens

  ruby -e '
    require "yaml"
    puts(YAML.dump({
      "Primeiro"  => "Elemento do hash",
      "Segundo" => "Elemento do hash",
      "Terceiro"  => "Elemento do hash"
    }))
  '
  ---
  Primeiro: Elemento do hash
  Segundo: Elemento do hash
  Terceiro: Elemento do hash
  

Conversão YAML pra Ruby

Pro vice-versa, converter YAML em objetos Ruby, usamos o método load, que carrega a string YAML passada em um objeto Ruby. Vou tirar a indentação das seções com dados abaixo porque o YAML não permite indentação a gosto (observação: YAML é exigente).

  ruby -e '
    require "yaml"
    YAML.load("
  ---
  - 1
  - 2
  - 3
    ").each do |item|
      puts("Item: #{item}")
    end
  '
  Item: 1
  Item: 2
  Item: 3

  ruby -e '
    require "yaml"
    YAML.load("
  ---
  1: Um
  2: Dois
  3: Três
    ").each do |chave, valor|
      puts("Chave: #{chave} - Valor: #{valor}")
    end
  '
  Chave: 1 - Valor: Um
  Chave: 2 - Valor: Dois
  Chave: 3 - Valor: Três
  

Nosso Vagrantfile

Nossa missão agora é transformar o hash enorme que ficou pendurado na parte 2 em um belo arquivo só com dados, e deixar o Vagrantfile só com código.

É claro que o método dump pode fazer a conversão. Mas com uma diferença: o resultado precisa ir pra um arquivo, em vez da tela. É só redirecionar a saída do comando para um arquivo. Vamos batizar o arquivo de mvs.yaml. Pra mais limpeza, vamos criar um diretório para este arquivo de configurações. Não imagino qualquer nome melhor que etc.

  mkdir etc

  ruby -e "
    require 'yaml'
    puts(YAML.dump({
      'vm_1' => {
        :box => 'ARTACK/debian-jessie',
        :ip => '192.168.1.2'
      },
      'vm_2' => {
        :box => 'ARTACK/debian-jessie',
        :ip => '192.168.1.3'
      },
      'vm_3' => {
        :box => 'centos/7',
      }
    }))
  " > etc/mvs.yaml

  cat etc/mvs.yaml
  ---
  vm_1:
    :box: ARTACK/debian-jessie
    :ip: 192.168.1.2
  vm_2:
    :box: ARTACK/debian-jessie
    :ip: 192.168.1.3
  vm_3:
    :box: centos/7
  

Depois, pra carregar o objeto no Vagrantfile, é só fazer a conversão inversa, com a mesma diferença: vamos carregar a string do arquivo, em vez de digitar. Simples: e só trocar o método load pelo load_file.

  require 'yaml'
  MVS = YAML.load_file('etc/mvs.yaml')    # Carrega arquivo YAML na variável

  Vagrant.configure("2") do |config|
    MVS.each do |nome, confs|             # Itera a partir da variável
      config.vm.define nome do |vm|
        config.vbguest.auto_update = false
        vm.vm.box = confs[:box]
        vm.vm.network "private_network", ip: confs[:ip] if confs.has_key?(:ip)
      end
    end
  end
  

Mais configurações

É claro que uma máquina virtual não é feita só de nome e IPs. Que tal mexer, por exemplo, nas configurações de RAM e/ou CPU?

  require 'yaml'
  MVS = YAML.load_file('etc/mvs.yaml')

  Vagrant.configure("2") do |config|
    MVS.each do |nome, confs|
      config.vm.define nome do |vm|
        config.vbguest.auto_update = false
        vm.vm.box = confs[:box]
        vm.vm.network "private_network", ip: confs[:ip] if confs.has_key?(:ip)
        vm.vm.provider 'virtualbox' do |virtualbox|
          virtualbox.memory = confs[:ram] if confs.has_key?(:ram)
          virtualbox.cpus = confs[:cpu] if confs.has_key?(:cpu)
        end
      end
    end
  end
  

Então, o arquivo de dados fica:

  ---
  vm_1:
    :box: ARTACK/debian-jessie
    :ram: 256
    :ip: 192.168.1.2
  vm_2:
    :box: ARTACK/debian-jessie
    :ram: 256
    :ip: 192.168.1.3
  vm_3:
    :box: centos/7
    :cpu: 2
  

No meu caso, também precisei modificar configurações específicas do VirtualBox. No Vagrant, a diretiva que faz isso é a virtualbox.customize.

  require 'yaml'
  MVS = YAML.load_file('etc/mvs.yaml')

  Vagrant.configure("2") do |config|
    MVS.each do |nome, confs|
      config.vm.define nome do |vm|
        config.vbguest.auto_update = false
        vm.vm.box = confs[:box]
        vm.vm.network "private_network", ip: confs[:ip] if confs.has_key?(:ip)
        vm.vm.provider 'virtualbox' do |virtualbox|
          virtualbox.memory = confs[:ram] if confs.has_key?(:ram)
          virtualbox.cpus = confs[:cpu] if confs.has_key?(:cpu)
          confs[:custom].each do |custom, valor|
            virtualbox.customize ['modifyvm', :id, custom, valor]
          end if confs.has_key?(:custom)
        end
      end
    end
  end
  

Mudamos o código, que continua independente dos dados (dados fora do código!). Agora, no arquivo de dados, vamos desabilitar USB e a VirtualBox Remote Desktop Extension, que já me causaram problemas em algumas MVs:

As configurações personalizadas exigem nomes e valores - ou seja, um hash como valor da chave ':custom'
  ---
  vm_1:
    :box: ARTACK/debian-jessie
    :ram: 256
    :ip: 192.168.1.2
    :custom:
      '--usb': 'off'
      '--usbehci': 'off'
      '--usbxhci': 'off'
      '--vrde': 'off'
  vm_2:
    :box: ARTACK/debian-jessie
    :ram: 256
    :ip: 192.168.1.3
    :custom:
      '--usb': 'off'
      '--usbehci': 'off'
      '--usbxhci': 'off'
      '--vrde': 'off'
  vm_3:
    :box: centos/7
    :cpu: 2
    :custom:
      '--usb': 'off'
      '--usbehci': 'off'
      '--usbxhci': 'off'
      '--vrde': 'off'
  

Mais arquivos

Eu ouvi você dizer "Peraí! Os arquivos de dados estão muito repetitivos!". Fiquei orgulhoso de você!

Sim, muitas configurações serão parecidas. Todas as MVs de um mesmo box têm, pelo menos, o mesmo nome e versão de box. Às vezes, os mesmos recursos e/ou configurações personalizadas. Pra que repetir configurações de MVs se podemos ter um arquivo pros próprios boxes?

Arquivo de dados dos boxes

  ---
  debian_jessie:
    :nome: ARTACK/debian-jessie
    :ram: 256
    :custom:
      '--usb': 'off'
      '--usbehci': 'off'
      '--usbxhci': 'off'
      '--vrde': 'off'
  centos_7:
    :nome: centos/7
    :cpu: 2
    :custom:
      '--usb': 'off'
      '--usbehci': 'off'
      '--usbxhci': 'off'
      '--vrde': 'off'
  

Arquivo de dados das MVs

  ---
  vm_1:
    :box: debian_jessie
    :ip: 192.168.1.2
  vm_2:
    :box: debian_jessie
    :ip: 192.168.1.3
  vm_3:
    :box: centos_7
  

Então, no Vagrantfile:

  require 'yaml'
  BOXES = YAML.load_file('etc/boxes.yaml')
  MVS = YAML.load_file('etc/mvs.yaml')

  Vagrant.configure("2") do |config|
    MVS.each do |nome, confs|
      config.vm.define nome do |vm|
        config.vbguest.auto_update = false
        box = BOXES[confs[:box]]
        vm.vm.box = box[:nome]
        vm.vm.network "private_network", ip: confs[:ip] if confs.has_key?(:ip)
        vm.vm.provider 'virtualbox' do |virtualbox|
          virtualbox.memory = box[:ram] if box.has_key?(:ram)
          virtualbox.cpus = box[:cpu] if box.has_key?(:cpu)
          box[:custom].each do |custom, value|
            virtualbox.customize ['modifyvm', :id, custom, value]
          end if box.has_key?(:custom)
        end
      end
    end
  end
  

Não há fórmula mágica que determine quantos arquivos e o que colocar em cada um. Use o bom senso, equilibrando flexibilidade e simplicidade.


Agora temos 3 arquivos com menos de 20 linhas cada um, todos fáceis de ler, e que resultam em 3 MVs com configurações múltiplas de SO, RAM, CPU, IP e particularidades do virtualizador. E que são fáceis de mudar, expandir e/ou reutilizar. Satisfeito? Eu também.

E depois?

Que outros dados podem ser colocados em arquivos separados para o código usar? Talvez algumas configurações de hardware, rede, algum provisionamento?

Procure padrões de repetição. Só conseguimos começar a separar dados de código quando ficou claro que o código era praticamente o mesmo para todas as MVs. Pudemos criar arquivos com dados dos boxes porque vimos que as configurações das MVs também se repetiam. É por aí.

Isso não quer dizer que seja uma boa ideia colocar cada mínima repetição em um arquivo separado. Dependendo do caso, você pode acabar com arquivos demais, com conteúdo de menos, e desenvolvendo sobre um emaranhado de arquivos e variáveis.

O que você precisa ter sempre em mente é que dividir dados em arquivos deve deixar a manutenção mais fácil. Outros, às vezes não programadores Ruby, e até você meses depois, precisam conseguir (re)usar seu Vagrantfile. Se você mudou a arquitetura dos dados, e desconfia que ficou mais complicada, provavelmente ficou mesmo - Principalmente pra quem não criou os arquivos. Refaça ou desfaça.

Agora que já sabe por que e como separar os dados do código, mãos à obra! Bons Vagrantfiles!