Puppet Types y Providers
Todos los que utilizamos puppet en algún momento nos encontraremos con la necesidad de crear un módulo que no se puede implementar sólo utilizando los recursos existentes. Es en ese momento donde surge la necesidad de crear nuestro propio tipo.

Puppet provee la posibilidad de crear nuestros propios tipos, desarrollados en ruby. Contar con la posibilidad de programar expande muchísimo nuestro horizonte de cosas que se pueden hacer con puppet.

Desde mi punto de vista, ruby es horrible para programar, pero es la única opción que existe actualmente en puppet, así que no nos quedará otra que aprender aunque sea las bases de este lenguaje.

Puppet separa la definición de un recurso en dos partes: types y providers
  • El archivo type define todas las propiedades y parámetros que se pueden usar en nuestro recurso. Es decir, define la estructura del recurso y como interactuaremos desde un manifest con él. 
  • Por otra parte, el provider se encarga de implementar el type que definimos.

Algo interesante es que podemos tener múltiples providers para el mismo type. El ejemplo más claro de esto es la implementación del type package. Existen varios providers para este tipo: apt, yum, pacman, etc. La forma en que interactuamos con el type es independiente de cómo luego se implemente.

Como todo en puppet, es importante la estructura y los nombres de nuestros directorios y archivos. Para definir nuestro type y provider, hay que seguir el siguiente árbol:
module
  |-- metadata.json
  |-- README.md
  |-- manifests
  |-- lib
       |-- puppet
             |-- provider
             |     |-- configjs
             |           |-- configjs.rb
             |-- type
                   |-- configjs.rb


La idea es que la explicación sea bastante dinámica, así que plantearé un ejemplo que seguiremos a lo largo del artículo.


Crear nuestro type

Como mencioné, los types deben ubicarse en el path <module name>/lib/puppet/type/<nombre del tipo>.rb para satisfacer la nomenclatura de puppet.

Por ejemplo, voy a definir un tipo para administrar configuraciones en formato JSON. Llamaré al tipo configjs:
  #configjs/lib/puppet/type/configjs.rb
  Puppet::Type.newtype(:configjs) do
    @doc = "Manage json configs"

    ensurable

    newparam(:path, :namevar => true) do
      desc "Path to the JSON File"

      newvalues(/\A\/\w+/)
    end

    newparam(:showdiff) do
      desc "Show a diff when changing values"

      defaultto :true
      newvalues(:true, :false)
    end

    newproperty(:config) do
      desc "The configuration to set"

      isrequired

      def insync?(is)
        is.sort == should.sort
      end
    end
  end
  
La forma de invocar un tipo es la misma que cuando lo definimos con define en un .pp. Este tipo se utilizaría de la siguiente manera en un manifest:
  $myconfig = {
    'name' => 'itfreek',
    'url' => 'http://itfreekzone.blogspot.com'
  }
  configjs {'/etc/myconfig.js':
    config => $myconfig
  }
  
Veamos un poco qué significa cada parte del tipo configjs.


Propiedades y parámetros

Para empezar declaramos un parámetro (path) y una propiedad (config). Si bien lucen similares, la diferencia entre ambos es muy importante:
  • El parámetro se utitliza para designar un valor desde puppet. En el ejemplo, path define la ubicación del archivo en el servidor. 
  • Una propiedad es algo que se puede inspeccionar y cambiar. La misma cuenta con dos valores. Por un lado, tenemos el valor que se indica desde puppet, denominado "should", y por otro el valor que tiene actualmente en el sistema, denominado "is". En el ejemplo, una config en el sistema será el JSON contenido en el file, que pudo haber sido modificado manualmente (esto es el "is"). Si puppet setea un JSON, este es el valor que esa propiedad debe tener, y por eso se denomina "should". La diferencia entre ambos valores es lo que puppet muestra al momento de ejecutar una corrida.


Namevar

Otro punto destacable es el uso de ":namevar => true" en el parámetro path. Como saben puppet usa el concepto namevar para distinguir un recurso en el sistema, y es el valor que asignamos al crear el recurso. En nuestro caso, el path es el nombre que nos permite distinguir un file de otro, lo mismo que sucede con el recurso file. En dicho ejemplo, el valor que tendrá el path es /etc/myconfig.js y será el nombre de nuestro recurso.


Otras opciones

Finalmente también encontramos "isrequired" y "defaultsto". Con el primero indicamos que la propiedad es requerida, es decir, si no se setea puppet dará error. El segundo indica cuál es el valor default que tomará el parámetro si no se especifica ninguno.


Validación y munging

Veamos el uso de newvalues. Con este método podemos restringir qué valores son aceptables para el parámetro. Podríamos incluir una lista de valores (newvalues(:true, :false)), o, como hice yo, una expresión regular a matchear. Más adelante veremos cómo invocar funciones a partir de valores con ensurable. Si el valor otorgado no cumple con esta restricción, puppet dará error.

Las otras opciones que tenemos son validar el input (validate) y transformar (o sanitizar) el valor (munge).

Por ejemplo, si queremos validar que el path sea un path absoluto, podemos agregar lo siguiente:
  newparam(:path, :namevar => true) do
    ...
    validate do |value|
      unless Puppet::Util.absolute_path? value
        raise ArgumentError, "archive path must be absolute: #{value}"
      end
    end
  end
  
Por otra parte, si quisieramos setear los permisos del file, podríamos agregar un parámetro mode. Mode debería ser un string en la definición del recurso, pero nosotros necesitamos que sea un integer en notación octal para poder setearlo en el filesystem (ej: 0644). Podríamos hacer esto con munge:
  newparam(:mode) do
    munge do |value|
      value.to_i(8)
    end
  end
  


Providers

Ahora que contamos con la definición del tipo, pasemos al provider. El mismo debe ubicarse en /lib/puppet/provider//.rb. Un tipo puede tener varios providers (como package, o vcsrepo).

En nuestro ejemplo, el provider se llamará igual al tipo (aunque podría llamarse diferente) y lo definimos así:

  #configjs/lib/puppet/provider/configjs/configjs.rb
  Puppet::Type.type(:configjs).provide(:configjs) do
    def config
      if File.exists?(resource[:path])
        content = File.read(resource[:path])
        PSON.load(content)
      else
        {}
      end
    end

    def config=(value)
      create
    end

    def exists?
      File.exists?(resource[:path])
    end

    def create
      config_file = File.new(resource[:path], 'w+')
      config_file.write(JSON.pretty_generate(resource[:config]))
      config_file.close
    end

    def destroy
      FileUtils.rm(resource[:path])
    end
  end
  
Acá aparece el hash 'resource'. Este hash contiene los valores para cada uno de los parámetros y las propiedades que se setearon. Dentro del type también se puede acceder a este hash, pero como self (ejemplo: self[:path]).


Definiendo la funcionalidad

Ensurable

Si prestaron atención, el tipo configjs invoca el método ensurable. Un recurso se considera 'ensurable' cuando su presencia se puede verificar. Esto es, si no existe y debería existir (ensure => present), hay que crearlo, mientras que si existe y no debería existir (ensure => absent), eliminarlo. Al realizar esta invocación, deberemos definir tres funciones:
  •  exists? debe retornar true si nuestro recurso existe en el sistema y falso sino.
  • create: crear el recurso si no existe y debería existir.
  • destroy: destruir el recurso si existe y no debería.

En el ejemplo anterior, exists? nos dice si el file existe o no. Si no existe, creamos el archivo con el contenido indicado en la propiedad config. Si queremos destruirlo, simplemente hacemos un rm del file.

Es posible redefinir los métodos invocados al utilizar "ensure => present/absent", e incluso agregar más valores. La forma de hacerlo es la siguiente:
  #configjs/lib/puppet/type/configjs.rb
  Puppet::Type.newtype(:configjs) do
    ...

    ensurable do
      newvalue(:present) do
        provider.generate
      end

      newvalue(:absent) do
        provider.remove
      end

      newvalue(:empty) do
        provider.empty
      end
    end
  end
  

Esto es, indicamos que si ensure es present invoque la función generate en lugar de create, mientras que si es absent invoque remove en lugar de destroy. Además, agregamos el valor emtpy que nos permite invocar la función emtpy en el provider.

Como habrán notado, es posible invocar funciones del provider desde la definición del type.


Refresheable

Ahora, qué pasa si realizamos un cambio en el file desde fuera del resource, como por ejemplo, un exec en un manifest? La simple lógica anterior nos indica que si el file existe en el filesystem, entonces no hay que realizar ninguna acción, no verifica si el contenido del file es el correcto.

Este comportamiento lo cambiaremos con los setters y getters (explicado a continuación), pero hay otra forma si no queremos utilizar propiedades, podemos hacerlo indicando que el recurso es refresheable.

Que nuestro tipo sea refresheable significa que se ejecutará algo con un cambio en la propiedad, o si tenemos una relación notify/subscribe. En el ejemplo podríamos realizar estos cambios:
  #configjs/lib/puppet/type/configjs.rb
  Puppet::Type.newtype(:configjs, :self_refresh => true)) do
    ...
    ...
    def refresh
      provider.create
    end
  end
  
Lo que hicimos es, primero indicar que nuestro tipo es refresheable con ":self_refresh => true". Luego definimos la función refresh, donde indicamos qué se debe ejecutar en un refresh. En nuestro caso lo que hacemos es invocar el método create del provider.

Con esta nueva funcionalidad, además de crearse el file si no existe en el filesystem, también se pisará el contenido si por alguna razón cambia una propiedad o algún otro recurso le notifica que debe hacer un refresh (notify).


Setters y Getters

Siempre que definamos una propiedad, puppet utilizará distintas funciones para determinar si el valor de la propiedad difiere de la que está actualmente en el host. Si este es el caso, seteará el valor que corresponda. Esto es, /etc/myconfig.js tiene el contenido que se indicó por puppet?

Para saber cuál es el valor actual de la propiedad y setear el que corresponde, puppet invoca métodos cuyos nombres son igual a la propiedad. Estos métodos se definen en el provider.

Para el ejemplo anterior, la propiedad se llama config, entonces las funciones get y set se definen de la siguiente manera:
  #Getter: decime qué valor tiene la config ahora en el server
  def config
  end

  #Setter: setea este value como contenido de la config
  def config=(value)
  end
  
Puppet determina si el valor actual es el que debería tener invocando el método "insync?", es decir, esta propiedad está sincronizada? Por defecto esta función realiza una comparación entre el valor actual y el que debería mediante el operador "==". Para comparar strgins va genial, pero si, por ejemplo, estamos comparando hashes, la comparación podría no resultar como esperamos. Para estos casos, podemos redefinir el método insync? en la definición de la propiedad (en el type).

Siguiendo nuestro ejemplo, vemos esta definición:
  #configjs/lib/puppet/type/configjs.rb
  Puppet::Type.newtype(:configjs) do
    ...
    newproperty(:config) do
      ...

      def insync?
        is.sort == should.sort
      end
    end
    ...
  end
  


Prefetch y flush

En ciertos casos no queremos definir setters y getters para todas las propiedades de nuestro tipo. Si estamos en esa situación, nuestro approach debería ser utilizar un esquema prefetch/flush.

Un provider prefetch/flush implementa sólo dos métodos:
  • prefetch: dada una lista de recursos, retornará y seteará las instancias con los valores obtenidos. 
  • flush: se invoca luego de que todos los valores se setearon.

Dado que puppet intentará invocar los setter/getters para cada una de las propiedades, podemos evitar la definición de todos estos métodos invocando mkresource_method, que define setter/getters default para todas las propiedades.

Luego definimos el método prefetch para realizar todas las tareas que haríamos con los getters, y flush para todos los setters.

No utilicé este esquema aún, pero el formato básico es:
  #configjs/lib/puppet/provider/configjs/configjs.rb
  Puppet::Type.type(:configjs).provide(:configjs) do
    mkresource_method

    def prefetch
      ...
    end

    def flush
      ...
    end
  end
  


Initialize

Otra opción útil para la definición de types y providers es el uso del método initialize. Este método se ejecuta cuando ruby crea un objeto, por lo que podremos realizar algunas inicializaciones ahí si lo necesitamos.


Ejecutar Comandos

En el ejemplo sólo utilizamos files y no necesitamos ejecutar ningún comando. Pero qué pasa si queremos tener un recurso que utilice comandos? Tomando nuestro ejemplo de referencia, tal vez utilicemos el config json generado como input de un comando.

Sólo para ejemplificar el uso de comandos, redefiniré el método create del provider agregando la creación de un container lxd a partir del json. No probé este comando, por lo que puede tener algún error, pero sirve para ejemplificar.
  #configjs/lib/puppet/provider/configjs/configjs.rb
  Puppet::Type.type(:configjs).provide(:configjs) do
    commands :curl => 'curl'

    def create
      config_file = File.new(resource[:path], 'w+')
      config_file.write(JSON.pretty_generate(resource[:config]))
      config_file.close

      curl("-k", "-L", "--cert", ""~/.config/lxc/client.crt", "--key", ~/.config/lxc/client.key", "-H", "Content-Type: application/json", "-X", "POST", "-d", "@#{resource[:path]}", "https://127.0.0.1:8443/1.0/containers)
    end

    ...
  end
  


Cambiar el output

Cuando puppet cambia un recurso, se registra un evento. Vemos la notificación de que algo cambió de la siguiente forma:
Notice: /Stage[main]/<resource>/Config_js[/etc/myconfig.js]/Configjs[/etc/myconfig.js]/config: config changed <IS VALUE> to <SHOULD VALUE>

La forma en que puppet crea el mensaje es convirtiendo el valor actual y el que debería tener en strings (usando el método to_s de ruby). Dependiendo el valor de la propiedad, la conversión a string puede no ser la deseada y el cambio no resulta claro.

Por suerte podemos customizar tanto la forma en que dichos valores se convierten a string, como el mensaje de lo que va a cambiar. Para ello debemos definir los métodos is_to_s, should_to_s y change_to_s dentro de la propiedad o parámetro:
  • is_to_s: se ejecuta para convertir a string el valor que actualmente tiene el server. 
  • should_to_s: debe convertir el valor a aplicar a string. 
  • change_to_s: se invoca para mostrar lo que va a cambiar. Si no redefinimos esta función puppet mostrará el valor actual devuelto por is_to_s y el aplicable retornado por should_to_s.
En general nos alcanzará con redefinir is_to_s y should_to_s, o sólamente change_to_s, es muy raro que utilicemos las tres, ya que si definimos change_to_s no tiene mucho sentido definir también las otras dos.

Siguiendo el ejemplo anterior, la propiedad config es un hash. La función to_s no nos retorna un string muy lindo de los hashes, así que en su lugar, podríamos usar inspect en is_to_s y should_to_s:

  #configjs/lib/puppet/type/configjs.rb
  Puppet::Type.newtype(:configjs) do
    ...

    newproperty(:config) do
      ...

      def is_to_s(value)
        value.inspect
      end

      def should_to_s(value)
        value.inspect
      end
    end
  end
  
En lugar de lo anterior, podríamos simplemente redefinir change_to_s:
  #configjs/lib/puppet/type/configjs.rb
  Puppet::Type.newtype(:configjs) do
    ...

    newproperty(:config) do
      ...

      def change_to_s(is, should)
        "old value #{(is).inspect},
        new #{(should).inspect}"
    end
  end
  

Update 1: descubrí que cuando ejecutamos puppet con --noop, no se ejecuta change_to_s, sino una función noop que no podemos redefinir y tiene un output predefinido de "current value %s, should be %s (noop), param.is_to_s(current_value), param.should_to_s(param.should)". Ver reporte.

Como no podemos cambiar esta funcionalidad, encontré un workaround en el tipo file. Lo que hace file es imprimir las diferencias entre los files en la función issync? y hacer que is_to_s y should_to_s devuelvan checksums en lugar de printear ambos files.

La forma de implementar esto sería:
  Puppet::Type.newtype(:configjs) do
    ...
    def is_to_s(is)
      '{md5}' + Digest::MD5.hexdigest(is.to_s)
    end

    def should_to_s(should)
      '{md5}' + Digest::MD5.hexdigest(should.to_s)
    end

    def insync?(is)
      return @is_insync unless @is_insync.nil?

      @is_insync = super(is)
      if ! @is_insync && Puppet[:show_diff]
        send @resource[:loglevel], "old value #{(is).inspect},
        new #{(should).inspect}"
      end
      @is_insync
    end
  end

Con esto logramos hacer nuestro propio print de los cambios tanto con --noop como si se van a aplicar de verdad.

Tuve que reescribir las funciones is_to_s y should_to_s para que no retornen el contenido, sino un md5. Ese cambio es necesario porque el método noop de puppet llama estas funciones siempre. Si no modifico su output, obtendremos un print de los cambios dos veces.

También tuve que agregar una variable a la clase para detectar si ya ejecuté insync anteriormente. La clase property llama el método insync? dos veces, así que si no determinamos que ya se ejecutó terminaremos imprimiendo el mismo diff dos veces.


Update 2: Utilizar gems externos en nuestro provider

Algo interesante que me surgió durante la creación de un módulo fue la necesidad de utilizar un gem externo. En mi caso, el gem era inifile.

Ej:
  Puppet::Type.type(:mytype).provide(:mytype) do
    ...
    require 'inifile'
    ini_file = IniFile.load('/etc/config.ini')
    ...
  end
Como muchas cosas en puppet, un poco de magia negra es necesaria, dado que puppet no provee una forma de definir dependencias de librerías ruby en la definición del módulo, así que debemos utilizar un workaround.

Luego de leer en comentarios en reportes de bugs y foros, llegué a esta solución.
Básicamente, lo que podemos hacer es:
  • Instalar la dependencia gem con puppet.
  • Hacer que nuestro recurso dependa de la instalación de este paquete.
  • Agregar una feature y un confine para que el provider no se ejecute si la librería no está instalada.

Vamos por partes, primero instalemos el gem necesario:
  package {'inifile':
    ensure   => 'installed',
    provider => 'puppet_gem',
  }

Agregamos la dependencia a la definición de nuestro tipo:
  mytype { 'nombre':
    ...
    require => Package['inifile'],
  }

Ahora, creemos una feature (lib/puppet/feature/inifilelib.rb) con el siguiente contenido:
  Puppet.features.add(:inifilelib, :libs => ["inifile"])

Por último agreguemos el confine en nuestro provider: 
  require 'inifile' if Puppet.features.inifilelib?
  Puppet::Type.type(:mytype).provide(:mytype) do
    confine :feature => :inifilelib
    ...
    require 'inifile'
    ini_file = IniFile.load('/etc/config.ini')
    ...
  end

Esto hará que el provider no se evalúe hasta que el gem inifile no esté instalado. Pueden encontrar una explicación un poco más detallada en el link.


Final, pero hay más para aprender

Si bien este artículo debería cubrir un buen porcentaje de lo que podemos necesitar al crear un type, surgiran cosas que no están acá. Una de ellas es la herencia de providers para crear nuevos providers, el uso de autorequire, features, etc. Recomiendo dar un vistaso a las referencias para más info.

Me decidí a escribir sobre types porque no encontré información muy buena. Si bien hay artículos interesantes que te introducen a la creación de tipos, a medida que avanzas y necesitas entender cómo funcionan ciertas cosas me costó bastante. La info está muy desparramada y se asumen muchas cosas que cuesta entender de dónde salen. Reconozco que no leí completamente el libro Puppet Types and Providers by Nan Liu, Dan Bode que supuestamente es la referencia más completa al respecto.


Referencias

Puppet Types and Providers

Puppet Custom Types 

Puppet Provider Development

Puppet Complete Resource Example

Fun With Puppet Providers - Part 1 of Whatever

Who Abstracted My Ruby?

Seriously, What Is This Provider Doing?

Puppet Extension Points - Part 2

Chapter 4. Advanced Types and Providers

Writing Custom Puppet Types and Providers to Manage Web-Based Applications (slides)