Estructura simple de módulos

Por R. I. Pienaar Artículo original En septiembre de 2009, escribí una entrada de blog llamada “Estructura simple para módulos de Puppet” que introduce un método simple para escribir módulos Puppet. Este post ha sido muy popular en la comunidad, pero muchas cosas han cambiado en Puppet desde ese entonces,

Recent Posts

Por R. I. Pienaar

Artículo original

En septiembre de 2009, escribí una entrada de blog llamada “Estructura simple para módulos de Puppet” que introduce un método simple para escribir módulos Puppet. Este post ha sido muy popular en la comunidad, pero muchas cosas han cambiado en Puppet desde ese entonces, con lo cual, es momento de una versión actualizada de ese post.

Como antes, voy a mostrar un módulo simple para un escenario común. En lugar de considerar este módulo un modelo para todos los módulos que hay, debes estudiar su diseño y utilizarlo como punto de partida cuando escribas tus propios módulos. Puedes construir sobre él y adaptarlo, pero el enfoque básico debería ser aplicable a módulos más complejos.

Debería aclarar que aunque trabaje para Puppet Labs no sé si esto refleja algún tipo de enfoque estándar sugerido por Puppet Labs. Esto es lo que hago cuando manejo mis propias máquinas y nada más.

Los conceptos más importantes

Cuando escribo un módulo hay algunas cosas que tengo en mente, todas centradas en futuros usuarios de mi módulo e incluso yo mismo, intentando averiguar lo que esté sucediendo:

  • Un módulo debería tener un punto de entrada único donde alguien que lo revise pueda obtener un panorama de su comportamiento.
  • Los módulos que tengan configuración deben ser configurables en un único modo y lugar.
  • Los módulos deben estar compuestos por varias clases de responsabilidad única. En lo posible, estas clases deberían tener los detalles privados ocultos para el usuario.
  • Para los casos de uso comunes, no debería ser necesario para los usuarios saber los nombres de los recursos individuales.
  • Para los casos de uso más comunes, no debería ser necesario para los usuarios proveer ningún parámetro; deben usarse los defaults.
  • Los módulos que escribo deben tener un diseño y comportamiento consistente.

El diseño del módulo que presentaré a continuación está pensado para que alguien que tenga curiosidad respecto del comportamiento del módulo, sólo precise mirar en init.pp para ver:

  • Todos los parámetros y sus defaults usados para configurar el comportamiento del módulo.
  • Un panorama de la estructura interna del módulo por medio de nombres de clase descriptivos.
  • Las relaciones y notificaciones que existen dentro del módulo y qué clases pueden notificar.

Este diseño no eliminará la necesidad de documentar tus módulos, pero un diseño claro guiará a tus usuarios en el descubrimiento del funcionamiento interno de tu módulo y cómo interactúan con él.

Los más importante sobre el módulo no es qué hace, sino cuán accesible es para ti y para otros, qué tan fácil es entenderlo, debuguearlo, y extenderlo.

Piensa en tu módulo

Para este post, escribiré un módulo muy simple para manejar NTP. Es verdaderamente simple, busca en el Forge para ver módulos más completos.

Para pasar de no tener nada, a tener NTP en tu máquina, deberás hacer lo siguiente:

  • Instalar los paquetes y dependencias
  • Escribir archivos de configuración apropiados con valores específicos de entorno
  • Comenzar el servicio o servicios que necesites una vez que los archivos de configuración estén escritos. Reiniciarlo si el archivo de configuración cambia.

Existe una cadena de dependencia implícita aquí, y éste es un patrón básico que se aplica a la mayoría de las piezas de software.

Básicamente, estos 3 puntos se traducen en diferentes grupos de acciones, y respetando al principio de clases de responsabilidad única, crearé una clase para cada grupo.

Para mantener las cosas simples y obvias, llamaré a estas clases install, config y service. Los nombres no son importantes mientras sean descriptivos, pero sí es importante elegir uno y mantenerlo en todos tus módulos.

Escribir el módulo

Les mostraré las 3 clases que hacen el trabajo pesado aquí, y luego discutiremos sus partes.

class ntp::install {
       package{'ntpd':
              ensure => $ntp::version
       }
}
 
class ntp::config {
       $ntpservers = $ntp::ntpservers
 
    File{
          owner   => root,
          group   => root,
          mode    => 644,
       }
 
       file{'/etc/ntp.conf':
            content => template('ntp/ntp.conf.erb');
 
            '/etc/ntp/step-tickers':
         content => template('ntp/step-tickers.erb');
        }
}
 
class ntp::service {
       $ensure = $ntp::start ? {true => running, default => stopped}

service{"ntp":
          ensure  => $ensure,
          enable  => $ntp::enable,
       }
}

Aquí tengo 3 clases que sirven a un solo propósito cada una, y no tienen ningún detalle del tipo relación, orden o notificaciones en ellas. Sólo hacen, a grandes rasgos, la única cosa que se supone que deben hacer.

Mira cada clase y verás que utilizan variables del tipo $ntp::version,$ntp::ntpservers etc. Son variables de la clase ntp principal, echemos un vistazo a esa clase:

# == Class: ntp
#
# Un módulo básico para manejar ntp
#
# === Parámetros
# [*version*]
#   La versión del paquete a instalar
#
# [*ntpservers*]
#   Un array de los servidores de NTP a usar
#
# [*enable*]
#   Debe habilitarse este servicio al inicio?
#
# [*start*]
#   Puede o no ser iniciado por Puppet este servicio
class ntp(
   $version = "present",
   $ntpservers = ["1.pool.ntp.org", "2.pool.ntp.org"],
   $enable = true,
   $start = true
) {
   class{'ntp::install': } ->
   class{'ntp::config': } ~>
   class{'ntp::service': } ->
   Class["ntp"]
}

  Este es el punto de entrada principal al módulo que he mencionado antes. Todas las variables que usa el módulo están documentadas en un sólo lugar, el diseño básico y las partes del módulo son claros, y puedes ver  que la clase de servicio puede ser notificada y puede verse la relación entre las partes.

Yo uso la nueva función de encadenamiento para introducir las dependencias y las relaciones aquí, la cual trae a la superficie estas importantes interacciones entre las clases a la clase de entrada principal para que los usuarios puedan verlas fácilmente.

Toda esta información está disponible inmediatamente en un lugar obvio, sin necesidad de buscar en archivos adicionales o estancarse en detalles de implementación.

La línea 26 aquí requiere de más explicación. Esto asegura que todas las clases miembros de NTP se aplican antes que la clase principal NTP, con lo cual en esos casos donde alguien dice * require => Class["ntp"] * en otro lugar, puede estar seguro que se completaron las tareas asociadas. Esto es una versión liviana del Patrón de anclaje.

Usar el módulo

Veamos cómo puedes usar este módulo si no sabes nada. Idealmente, incluir el punto de entrada principal en un nodo debería ser suficiente:

include ntp

Esto realiza lo que generalmente se espera: Instala, configura e inicia el servicio NTP.

Luego de ver el init.pp, puedes suministrar algunos valores nuevos para algunos de los parámetros, para ajustarlo a tus necesidades:

class{"ntp": ntpservers => ["ntp1.example.com", "ntp2.example.com"]}

O, puedes usar los nuevos data bindings en Puppet 3 y colocar información nueva en Hiera para sustituir esas variables brindando información para las claves como ntp::ntpservers.

Por último, si por algún motivo u otro necesitas reiniciar el servicio, mirando la clase ntp ya sabes que puedes notificar la clase ntp::servers para lograrlo.

Utilizar clases para relaciones

Hay algo importante que tener en cuenta en la clase ntp principal; Yo particularmente especifico todas las relaciones y notificaciones en las clases y no en los recursos.

Mi estilo personal es sólo mencionar recursos por su nombre dentro de la clase que lo contiene. Si alguna vez tengo que acceder a un recurso fuera de la clase en que se encuentra, accedo a la clase.

No escribiría:

class ntp::service {
       service{"ntp": require => File["/etc/ntp.conf"]}
}

Son muchos los problemas con este enfoque que en su mayoría devienen en problemas de mantenimiento. Aquí necesito el archivo de configuración ntp, pero ¿Qué sucede si un servicio tiene más de un archivo? ¿Haces una lista con todos los archivos y luego editas todas las clases que hagan referencia a éstos cuando se agregue otro archivo?

Estos problemas se multiplican rápidamente en una base de código grande. Actuando siempre con los nombres de la clase y creando muchas clases pequeñas de propósito único como aquí, las contengo eficazmente agrupando nombres y no con recursos individuales. De esta manera cualquier futura refactorización de las clases individuales no tendría impacto en otras clases.

Entonces, el fragmento a continuación debería ser, preferentemente, algo así:

class ntp::service {
       service{"ntp": require => Class["ntp::config"]}
}

Aquí necesito la clase contenedora y no el recurso. Esto requiere de todos los recursos dentro de esa clase, aisla los cambios de esa clase y evita que el usuario tenga que preocuparse por los detalles de implementación internos de la otra clase. Del mismo modo, puedes también notificar una clase, y todos los recursos dentro de ella reciben una notificación.

Yo sólo incluyo otras clases en el nivel ntp superior y nunca incluyo declaraciones como ntp::config y demás en mis clases. Esto significa que cuando requiero la clase ntp::config o notifico a ntp::service, obtengo sólo lo que necesito.

Si creas clases grandes y complejas, corres el riesgo de encontrarte con execs del tipo refreshonly que se relacionen con la configuración o instalación, asociados a servicios en la misma clase; lo cual podría tener consecuencias desastrosas si notificas al recurso o clase equivocado o si el usuario no estudia tu código antes de usarlo.

Un estilo consistente de clases pequeñas de propósito único con nombres descriptivos evita estos y otros problemas.

Lo que hemos aprendido y más links

Hay muchas cosas por aprender aquí, y la mayoría son temas “blandos” como el valor que tiene la consistencia y claridad del diseño, como también el pensar en tus usuarios y en ti mismo en el futuro.

Por el lado técnico, debes aprender sobre los efectos de las relaciones y notificaciones basados en clases contenedoras y no llamar a los recursos individuales.

Y nos encontramos con una serie de características Puppet agregadas recientemente:

Las clases parametrizadas nos brindan múltiples métodos convenientes para suministrar datos a tu módulo: defaults en el módulo, específicamente en el código; usando Hiera, y un ENC (este último no está desarrollado en este post).

Las flechas de encadenación se usan en la clase principal para inyectar las dependencias y notificaciones de forma visible sin necesidad de estudiar las clases individuales.

Éstas son incorporaciones importantes a Puppet y, en mi humilde opinión, algunas características nuevas como las clases parametrizadas, no están del todo listas para su publicación; pero en Puppet 3, cuando las combinas con data bindings, muchos de los puntos débiles desaparecen.

Por último, hay otras varias cosas útiles que no he mencionado aquí:

Debes estudiar especialmente la Guía de estilo de Puppet y usar la herramienta Puppet Lint para validar tus módulos. Debes considerar escribir pruebas para tus módulos usando rspec-puppet. Finalmente, comparte todo esto en Puppet Forge.

Y quizás lo más importante: No reinventes la rueda, chequea Puppet Forge primero.