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 endLa 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 endPor 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
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 endAcá 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 endLo 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) endPuppet 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.
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 endEn 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') ... endComo 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)