Apache Freemarker para construir código a partir de modelos gráficos

En esta entrada vamos a describir como utilizamos Freemarker en el Onesait Platform Center para construir código ejecutable a partir de los modelos gráficos construidos con nuestro editor de diagramas.

En muchas aplicaciones es habitual tener que generar textos y ficheros a partir de plantillas, esto son, ficheros con una estructura predetermina y organizada en base a un lenguaje de etiquetas, para que, al ser procesados y cuando se sustituyen ciertas variables en la plantilla o se añaden ciertos bloques, su resultado se convierta en algo concreto y personalizado. Ejemplos de este tipo de aplicaciones pueden ir desde algo tan simple como generar un correo electrónico genérico, a enviarlo a cientos de usuarios donde queremos personalizar el nombre del destinario. Otro ejemplo son herramientas de generación automática de código fuente, donde a partir de un modelo gráfico generamos código ejecutable.

Apache Freemarker destaca entre los principales motores de plantillas de código abierto, tanto por su sencillez, como por su velocidad de procesado de las plantillas. Se trata de una librería Java, que añadida a nuestros proyectos, nos permite definir plantillas mediante ficheros en su propio lenguaje, denominado FTL, y procesarlas en Java mediante su API, pasándoles los datos para generar texto o ficheros en cualquier formato, (incluyendo ficheros en formato Office Word).

El lenguaje FTL de Freemarker nos pemite construir plantillas donde entre otras operaciones se puede:

  • Asignar valores a variables simples.
  • Incluir bloques condicionales.
  • Iterar sobre una lista y aplicarla a un bloque.
  • Realizar tratamiento y formateo de cadenas de texto.
  • Ejecutar operaciones aritméticas.

Dentro del Onesait Platform Center, Apache Freemarker es una librería ampliamente utilizada, siendo interesante indicar que se utiliza para generar emails personalizados al proyecto y al usuario, documentación automática, pero su principal potencia dentro de Center la encontramos en la generación de código:

  • Generación de ficheros Dockerfile: a partir de diagramas de contenerización.
  • Generación de Charts Helm: a partir de diagamas de Helm.
  • Generación de código Terraform: para despliegue de infraestructura a partir de Diagramas de Terraform.

En todos los casos, a partir de un modelo visual como es un diagrama modelado en la herramienta gráfica del Center y que internamente se almacena en formato JSON en una ontología, se genera código ejecutable por el Onesait Platform Center, por ejemplo: generando un Dockerfile con el que invocar al demonio de Docker para generar y publicar una imagen de contenedor.

Ilustraremos el uso de Freemarker repasando como convertimos un Diagrama Helm en un Chart instalable en Kubernetes.

  • Gráficamente, un Diagrama Helm consiste en un conjunto de elementos gráficos relacionados entre sí, cada uno con un conjunto de propiedades y que modelan el despliegue de nuestra aplicación en Kubernetes:
  • Al salvar el diagrama, internamente se convierte en un documento JSON almacenado en una entidad/ontología de Onesait Platform:
  • Para poder convertir dicho JSON en un Chart instalable en Kubernetes, lo primero es incluir la dependencia de Freemarker en todos los módulos donde se vaya a utilizar. En este caso en el módulo onesaitplatform-center-helm-generator:
<dependency>
    <groupId>org.freemarker</groupId>
    <artifactId>freemarker</artifactId>
</dependency>
  • Lo siguiente es definir las plantillas FTL de Freemarker. Un Chart Helm consiste en un conjunto de ficheros YAML dentro de una estructura de directorios. Cada directorio almacena un tipo de descriptor:

En cada directorio se almacena un descriptor YAML por cada elemento de ese tipo en el Chart. Por ejemplo: en el directorio deployments se almacenan todos los descriptores de los objetos de tipo Deployment:

De esta manera, podemos intuir que en nuestro proyecto onesaitplatform-center-helm-generator tendremos una plantilla Freemarker por cada tipo de elemento:

Cada una de estas plantillas representa el descriptor de un tipo de objeto Helm, donde utilizando el lenguaje de Freemarker, se ha externalizado todo lo que se tiene que completar para cada caso concreto con la información extraída del JSON del diagrama. Por ejemplo, la plantilla de Deployment que podemos ver a continuación:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: ${name}
  namespace: ${namespace}
spec:
  selector:
    matchLabels:
      workload.user.cattle.io/workloadselector: ${workloadselector}
  progressDeadlineSeconds: 600
  replicas: 1
  strategy:
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
    type: RollingUpdate
  template:
    metadata:
      labels:
        workload.user.cattle.io/workloadselector: ${workloadselector}
    spec:
{{- if .Values.global.setNodesOnDeployments }}
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
            - matchExpressions:
              - key: {{ .Values.global.nodeKey }}
                operator: In
                values:
                {{- range .Values.global.workerNodes }}
                - {{ .node }}
                {{- end }}
{{ end }} 
      containers:
      - name: ${containerName}
        image: ${containerImage}
        resources:
          limits:
            cpu: ${containerCpuLimit}
            memory: ${containerMemoryLimit}
          requests:
            cpu: 1m
            memory: 1Mi
<#if envVars?has_content || envVarsFromConfigMap?has_content || envVarsFromSecret?has_content >
        env:
   <#list envVars as envVar>
        - name: ${envVar.name}
          value: ${envVar.value}
   </#list>       
   <#list envVarsFromConfigMap as envVarFromConfigMap>
        - name: ${envVarFromConfigMap.envVarName}
          valueFrom:
            configMapKeyRef:
              key: ${envVarFromConfigMap.configMapKey}
              name: ${envVarFromConfigMap.configMapName}
   </#list>
   <#list envVarsFromSecret as envVarFromSecret>
        - name: ${envVarFromSecret.envVarName}
          valueFrom:
            secretKeyRef:
              key: ${envVarFromSecret.secretKey}
              name: ${envVarFromSecret.secretName}
   </#list>
</#if>
        imagePullPolicy: ${imagePulPolicy}
<#if containerPorts?has_content>
        ports:
   <#list containerPorts as port>
        - containerPort: ${port.containerPort}
          name:  ${port.containerPortName}
          protocol: ${port.containerPortProtocol}
   </#list>
</#if>
<#if volumeMounts?has_content || volumeMountsConfigMap?has_content || volumeMountsSecret?has_content>
        volumeMounts:
   <#list volumeMounts as volumeMount>
        - name: ${volumeMount.mountName}
          mountPath: ${volumeMount.mountPath}
   </#list> 
   <#list volumeMountsConfigMap as volumeMountConfigMap>
        - name: ${volumeMountConfigMap.volumeName}
          mountPath: ${volumeMountConfigMap.mountPath}
          subPath: ${volumeMountConfigMap.subPath}
   </#list>
   <#list volumeMountsSecret as volumeMountSecret>
        - name: ${volumeMountSecret.volumeName}
          mountPath: ${volumeMountSecret.mountPath}
          subPath: ${volumeMountSecret.subPath}
   </#list>
</#if>
<#if imagePullSecrets?has_content>       
      imagePullSecrets:
      - name: ${imagePullSecrets}
</#if> 
      restartPolicy: Always
      terminationGracePeriodSeconds: 30
<#if volumeMounts?has_content || volumeMountsConfigMap?has_content || volumeMountsSecret?has_content>      
      volumes:
   <#list volumeMounts as volumeMount>
      - name: ${volumeMount.mountName}
        persistentVolumeClaim:
          claimName:  ${volumeMount.pvcName}
   </#list>
   <#list volumeMountsConfigMap as volumeMountConfigMap>
      - name: ${volumeMountConfigMap.volumeName}
        configMap:
         defaultMode: 256
         items:
         - key: ${volumeMountConfigMap.configMapKey}
           mode: 420
           path: ${volumeMountConfigMap.configMapPath}
         name: ${volumeMountConfigMap.configMapName}
         optional: false
   </#list>
   <#list volumeMountsSecret as volumeMountSecret>
      - name: ${volumeMountSecret.volumeName}
        secret:
         defaultMode: 256
         items:
         - key: ${volumeMountSecret.secretKey}
           mode: 420
           path: ${volumeMountSecret.secretPath}
         secretName: ${volumeMountSecret.secretName}
         optional: false
   </#list>
</#if>

externaliza:

  • Valores simples, como puede ser el nombre del Deployment (name: ${name}).
  • Bloques condicionales, como puede ser la posibilidad de tener que añadir variables de entorno (<#if envVars?has_content || envVarsFromConfigMap?has_content || envVarsFromSecret?has_content >).
  • Listas, como puede ser la lista de las variables de entorno a añadir (<#list envVars as envVar>).

Así, cada uno de los ficheros YAML (descriptores Helm de un objeto Deployment), que veíamos en la imagen:

se corresponde con el hecho de aplicar a la plantilla deployment.ftl que hemos descrito, las propiedades extraídas del JSON del diagrama para cada elemento de tipo Deployment del diagrama anterior.

  • Definidas las plantillas, utilizamos el API de Freemarker para cargarlas en nuestro código y poder procesarlas. Para ello cargamos cada plantilla ftl en un objeto Java de tipo Template:
private Template templateChartDescriptorFile;
private Template templateHelmIgnoreFile;
private Template templateNotesFile;
private Template templateNamespace;
private Template templateSecret;
private Template templateRegistryCredentials;
private Template templateCertificate;
private Template templateConfigMap;
private Template templateStorageClass;
private Template templateDeployment;
private Template templateIngress;
private Template templatePersistentVolume;
private Template templatePersistentVolumeClaim;
private Template templateSevice;
private Template templateHelper;

@PostConstruct
public void init() throws IOException {
	Configuration cfg = new Configuration(Configuration.VERSION_2_3_27);
	cfg.setClassForTemplateLoading(this.getClass(), "/");
	cfg.setDefaultEncoding("UTF-8");
	cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
	cfg.setLogTemplateExceptions(false);
	cfg.setWrapUncheckedExceptions(true);

	try {
		// Load Templates
		this.templateChartDescriptorFile = cfg.getTemplate("templates/chartDescriptorFile.ftl");
		this.templateHelmIgnoreFile = cfg.getTemplate("templates/helmignore.ftl");
		this.templateNotesFile = cfg.getTemplate("templates/notes.ftl");
		this.templateNamespace = cfg.getTemplate("templates/namespace.ftl");
		this.templateSecret = cfg.getTemplate("templates/secret.ftl");
		this.templateRegistryCredentials = cfg.getTemplate("templates/registryCredentials.ftl");
		this.templateCertificate = cfg.getTemplate("templates/certificate.ftl");
		this.templateConfigMap = cfg.getTemplate("templates/configMap.ftl");
		this.templateStorageClass = cfg.getTemplate("templates/storageClass.ftl");
		this.templateDeployment = cfg.getTemplate("templates/deployment.ftl");
		this.templateIngress = cfg.getTemplate("templates/ingress.ftl");
		this.templatePersistentVolume = cfg.getTemplate("templates/pv.ftl");
		this.templatePersistentVolumeClaim = cfg.getTemplate("templates/pvc.ftl");
		this.templateSevice = cfg.getTemplate("templates/service.ftl");
		this.templateHelper = cfg.getTemplate("templates/helpers.ftl");

	} catch (IOException e) {
		log.error("Error loading templates", e);
		throw e;
	}
}
  • A continuación, analizamos el JSON del diagrama y por cada elemento que encontramos, extraemos sus propiedades, las metemos en el mapa de propiedades que pasaremos al Template de la plantilla correspondiente al elemento e invocamos a Freemarker para generar el fichero resultante en la ruta deseada:
 Map<String, Object> model = new HashMap<>();

	model.put("name", json.getString("certName"));
	model.put("namespace", json.getString("namespace"));
	model.put("certificate",	json.getString("certificate"));
	model.put("certificateKey", json.getString("certificateKey"));

        Writer reg = new FileWriter(
				new File(chartBaseDirectory.concat("templates").concat(File.separator).concat("secrets"),
						json.getString("certName").toLowerCase().concat("-certificate.yaml")));
	templateCertificate.process(model, reg);

De este modo, extrayendo todos los componentes del JSON del Diagrama Helm, extrayendo sus propiedades y pasandolas a la plantilla Freemarker correspondiente, vamos generando todos los descriptores YAML que componen el Chart Helm instalable en Kubernetes.

Conclusión

Una de las principales tareas del Onesait Platform Center como herramienta aceleradora de soporte al desarrollo, es liberar al usuario de realizar tareas tediosas y complicadas que aporten poco valor al negocio del producto en desarrollo. En este contexto, contar con librerías Open Source como Freemarker, que permitan generar código a partir de un modelo gráfico de forma sencilla y sin apenas latencia es fundamental.

Imagen de cabecera: de Lawrence Hookham en Unsplash.

Autor

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *