Siesta Write

Reader

Read the latest posts from Siesta Write.

from Sugui's tech trip

Hacía tiempo que quería tener algún tipo de almacenamiento compartido, algo que quería sobre todo para guardar mi música localmente, sin usar servicios externos, y poder reproducirla en múltiples dispositivos.

El dilema de la sincronización

La primera opción que se me vino a la cabeza es un software que ya uso actualmente para sincronizar ficheros, llamado Syncthing. Esta es una herramienta para sincronizar ficheros entre dispositivos de manera peer-to-peer. El problema de usarla es que los ficheros ocupan espacio en cada uno de los dispositivos, y el tamaño total de los ficheros que quiero guardar sube a unos cuantos GiB, por lo que descarté esa opción.

Dado que tengo un homelab en casa, pensé en una manera mejor: montarme un servidor NFS. ¿Y qué es NFS? Pues es un protocolo sencillo para compartir sistemas de ficheros sobre una red. La topología de NFS consiste en un servidor y varios clientes, que acceden a través de la red al sistema de ficheros que se encuentra en el servidor. Esto me elimina la desventaja que tenía con Syncthing, ya que los ficheros estarían en el vasto almacenamiento del homelab, por lo que evitamos gastar espacio innecesario en cada dispositivo.

Sobre el homelab

Este homelab que he comentado consiste en un clúster de Kubernetes con varias máquinas o nodos. Uno de ellos, llamado bastiodon, está dedicado a servir aplicaciones stateful, es decir, que usan almacenamiento persistente. Parece bastante claro que un servidor NFS debe de usar almacenamiento persistente, ¡no queremos que se reinicie la máquina y se hayan perdido nuestros datos! Así que decidí montarlo en ese nodo.

Si tienes curiosidad de por qué se llama bastiodon, sí, es por Pokémon. Bastiodon es una bestia grande y pesada, igual que el nodo :)

Pruebas en local

El primer paso fue buscar una imagen Docker para poder usar, y encontré la siguiente: https://github.com/ehough/docker-nfs-server. Antes de probar a meterla en el clúster, hice pruebas con ella en local.

He de decir que, a pesar de que estaba bastante bien documentada, hubo detalles que tuve que averiguar por mí misma. Este es el comando que ejecuté para desplegar localmente el servidor NFS:

docker run \
  -v /home/sugui/nfs:/shared \
  -v /lib/modules:/lib/modules:ro
  -e NFS_EXPORT_0='/shared                  *(rw,insecure,no_subtree_check,fsid=0)' \
  -e NFS_DISABLE_VERSION_3=true \
  --cap-add SYS_ADMIN \
  --cap-add SYS_MODULE \
  -p 2049:2049 \
  erichough/nfs-server

Pasaré a detallar algunas partes de este comando: – -v /home/sugui/nfs:/shared: aquí monto el directorio de mi host como un volumen para el contenedor, será el que se sirva por NFS. – -e NFS_EXPORT_0='/shared *(rw,insecure,no_subtree_check,fsid=0)': esta variable de entorno la interpreta el propio contenedor para generar el fichero /etc/exports, usado para definir los directorios a exportar por NFS. Se pueden especificar más líneas definiendo NFS_EXPORT_1, NFS_EXPORT_2, ... O incluso pasándole el propio fichero con otro volumen. – -e NFS_DISABLE_VERSION_3=true: esta es una de las cosas que tuve que indagar por mi cuenta... Al tener habilitada la versión 3 de NFS, el contenedor que creaba localmente se quedaba sin memoria por alguna razón. Y como solo quiero usar la versión 4, desactivé la otra versión. ¡Los problemas de memoria desaparecieron! – --cap-add SYS_ADMIN: esto es un privilegio dado al contenedor sobre el host, necesario ya que NFS necesita montar sistemas de ficheros internamente dentro del contenedor. – -v /lib/modules:/lib/modules:ro y --cap-add SYS_MODULE: el contenedor necesita que el kernel del host tenga habilitados los módulos nfs y nfsd. Para dar acceso al contenedor a los módulos del host, compartimos el directorio /lib/modules como read-only. Además, para que el contenedor habilite estos módulos automáticamente en el kernel del host, le damos el permiso SYS_MODULE.

Después de mucha prueba y error, conseguí que funcionase el directorio perfectamente con el comando mount. Así que empecé a pasarlo al clúster.

Despliegue en el clúster Kubernetes

Dado que el servidor NFS es una aplicación stateful, no usé un Deployment, como se suele hacer con las aplicaciones serverless. En su lugar, usé StatefulSet, que es similar, pero tiene la característica de guardar las asociaciones entre los Pods y los volumenes, de forma que si un pod se elimina y se vuelve a crear, siempre se desplegará junto a su volumen.

En mi homelab uso k3s, que viene con algunas cosas predefinidas. En concreto, viene con un StorageClass por defecto llamado local-path, que crea volúmenes (PersistentVolume) usando el almacenamiento local del nodo. Debido a que uso la sección volumeClaimTemplates para crear un volumen y no especifico el StorageClass a usar, se usa el local-path que viene por defecto, por lo que se crea un volumen usando el almacenamiento local del nodo.

Es importante destacar que para entornos más empresariales en general no es recomendable usar el storage del propio nodo, sino usar otras soluciones más robustas como Ceph, o el propio storage que te ofrezca la nube.

# statefulset.yaml

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: nfs
spec:
  selector:
    matchLabels:
      app: nfs
  serviceName: nfs
  template:
    metadata:
      labels:
        app: nfs
    spec:
      nodeSelector:
        kubernetes.io/hostname: bastiodon
      containers:
        - name: nfs
          image: erichough/nfs-server:2.2.1
          resources:
            limits:
              memory: "512Mi"
              cpu: "500m"
          ports:
            - containerPort: 2049
          env:
            - name: NFS_EXPORT_0
              value: "/shared *(rw,insecure,no_subtree_check,fsid=0)"
            - name: NFS_DISABLE_VERSION_3
              value: "true"
            - name: NFS_PORT
              value: "2049"
          securityContext:
            capabilities:
              add: ["SYS_ADMIN", "SYS_MODULE"]
          volumeMounts:
            - mountPath: /shared
              name: shared-claim
            - mountPath: /lib/modules
              name: lib-modules
              readOnly: true
      volumes:
        - name: lib-modules
          hostPath:
            path: /lib/modules
            type: Directory
  volumeClaimTemplates:
    - metadata:
        name: shared-claim
        namespace: nfs
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: local-path
        resources:
          requests:
            storage: 30Gi

También hice un Service de tipo LoadBalancer para poder exponer el servidor fuera del clúster. Dado que solamente estoy exponiendo este puerto en mi red interna, no pasa nada. Pero en general hay tener cuidado de no exponer un servidor NFS a internet sin más, ya que cualquiera podría tener acceso. NFS no proporciona demasiada seguridad aparte de poder limitar el rango de IPs que pueden acceder a la carpeta en el /etc/exports.

# service.yaml

apiVersion: v1
kind: Service
metadata:
  name: nfs
spec:
  type: LoadBalancer
  selector:
    app: nfs
  ports:
    - port: 2049
      targetPort: 2049

Persistiendo el montaje del directorio NFS en los dispositivos

Una vez desplegados estos recursos en el cluster, ya podemos usar nuestro directorio compartido por red. Si queremos que este directorio siempre esté montado cuando se inicie cualquiera de mis dispositivos, podemos añadir la siguiente línea al /etc/fstab:

# <file system>     <dir>       <type>   <options>   <dump>	<pass>
192.168.1.12:/	/home/sugui/Music/shared	nfs	defaults,nofail	0 0

De esta forma, cada vez que se inicie el dispositivo, se montará el volumen NFS automáticamente.

Finalizamos

Todavía sigue habiendo algunas posibles mejoras que se pueden aplicar, como limitar el rango de IPs a las de la red privada, a pesar de no estar exponiendo ningún puerto, más que nada por seguridad.

Este proyecto ha sido muy interesante, no solo por la utilidad que aporta el tener un directorio compartido entre dispositivos, sino también por aprender más sobre NFS, Kubernetes y Linux en general.

 
Leer más...

from Sugui's tech trip

La automatización es esencial en cualquier proyecto de desarrollo. Nos permite ahorrar tiempo, evitando centrarnos en tareas repetitivas y centrarnos en tareas más creativas, reduciendo la carga mental. Además, también ayuda a reducir el error humano.

Poco a poco han surgido un montón de prácticas y herramientas para automatizar el desarrollo. Algunas de las más importantes son la integración continua y el despliegue continuo (CI/CD), mediante herramientas como GitHub Actions. Estas nos permiten analizar, empaquetar y desplegar el software automáticamente.

Otra automatización que tiene un impacto directo en la efectividad del CI/CD es el testing automático, ya que nos permite ser más ágiles creando nuevas funcionalidades y desplegarlas en producción sin preocupaciones, además de permitirnos usar técnicas de manejo de ramas más livianas como el trunk based development, mucho más sencillo que otras estrategias como GitFlow.

Una automatización que puede no parecer tan obvia a primera vista es el versionado de nuestra aplicación. Esto se debe a que para poder automatizarlo con éxito necesitamos cumplir algunos requisitos: dotar de un significado a la versión y registrar la naturaleza de los cambios que realizamos.

El significado del versionado

Cuando cambiamos el código de una aplicación, ya sea introduciendo una corrección o una nueva funcionalidad, el resultado final deja de ser el mismo que teníamos antes, y su funcionamiento general cambia. Aquí es cuando decimos que hemos generado una nueva versión de software. Para tener registradas estas versiones, las nombramos asignándole a cada una de ellas un identificador único. Pero, ¿qué estructura debe tener este identificador?

Una primera aproximación para nombrar una versión puede ser usar la fecha de construcción del paquete de software, el hash del último commit, o un simple número que se incremente en cada proceso de versionado. Pero no es lo ideal.

Imaginemos que estamos desarrollando una librería L de la que dependen otros proyectos. Hemos hecho varios desarrollos y queremos actualizarla, por lo que subimos una nueva versión. El proyecto P, que lo lleva otro equipo, tiene a la librería L como dependencia, y el equipo decide actualizarla a la última versión, ya que parece una tarea trivial. Después de actualizarla, ejecutan los tests, y algunos dejan de funcionar. Suena frustrante, ¿verdad?

Para que no sucedan estas situaciones, el versionado debe tener un significado. Este significado debe basarse en la magnitud de los cambios realizados. Por ejemplo, si hubiéramos versionado la librería L con un identificador que indique si se ha modificado el comportamiento de la funcionalidad expuesta, el equipo que desarrolla P podría haber no considerado la actualización de L como algo trivial, y dedicaría un tiempo a investigar los cambios en la funcionalidad proporcionada por la librería. De esta manera se evitarían sorpresas.

Dotando de significado al versionado: Semantic versioning

Una de las convenciones de versionado más utilizadas hoy en día es el versionado semántico o semantic versioning. Es bastante sencillo y está explicado en la página semver.org.

En esta convención, el número de versión tiene la forma X.Y.Z, donde X, Y y Z son números no negativos. Cada uno de estos tres números representa diferentes magnitudes de cambios realizados a la API pública, es decir, a la funcionalidad que nuestro software expone públicamente a usuarios u otros programas.

Patch version

La patch version viene representada por Z, y se incrementará cuando se introduzcan correcciones a bugs que no alteran el funcionamiento esperado por un usuario final. Es decir, aunque la implementación cambie, la API pública permanecer sin cambios. De esta manera es totalmente seguro pasar a usar una nueva versión de un software que solo incremente la patch version. Ejemplo de incremento: 1.6.1 –> 1.6.2.

Minor version

La minor version es representada por Y, y debe incrementarse cuando los cambios añadan nueva funcionalidad, pero sin afectar al comportamiento de la API pública que existía hasta ahora. Por tanto también puede incluir cambios que afectarían a la patch version. Es posible también que se marquen como obsoletas algunas funcionalidades de la API pública, por lo que si esto ocurre conviene buscar alternativas a estas, antes de que sean eliminadas.

Suele ser seguro pasar a una nueva versión de un software que incremente la minor version. Sin embargo, en el momento que empecemos a usar la nueva funcionalidad de una nueva versión de un software, ya no podemos pasar a usar una versión con una minor version anterior. En muchos casos, es posible indicar esta restricción en nuestro fichero de dependencias, como package.json en el caso de JS, o cargo.toml en el caso de Rust.

Cuando se incremente la minor version, la patch version debe establecerse a 0. Ejemplo: 1.6.1 –> 1.7.0.

Major version

La major version es representada por X, y se incrementará cuando se realicen cambios incompatibles con versiones anterior. Funcionalidades de la API pública pueden sufrir cambios o dejar de existir. Puede incluir cambios que afectarían a la patch version y a la minor version. Al incrementar este valor, el resto de valores de versión de establecerán a 0. Ejemplo: 1.6.1 –> 2.0.0.

Registrando la naturaleza de los cambios

Antes de poder automatizar nuestros números de versión, necesitamos una forma de identificar el tipo de cambios que realizamos en el software. Como seguramente estemos usando un sistema de control de versiones como Git, podemos añadir información en los commits a modo de metadatos, que especifiquen el tipo de los cambios realizados.

Una de las convenciones más usadas a la hora de hacer esto es Conventional Commits. Su objetivo es definir un formato estándar para los commits, de manera que sea mucho más sencillo escribir herramientas que realicen tareas automáticas en base al historial de commits. Lo mejor es que esta convención está especialmente diseñada para trabajar con semantic versioning. 🎉

Esta convención nos dice que debemos estructurar nuestros commits de la siguiente manera:

<type>[optional scope]: <description>

[optional body]

[optional footer(s)]

Lo que más nos importa de este formato es el tipo de los commits, indicados en la parte <type>. Los principales son los siguientes:

  • fix para commits que arreglen un bug en el software. Ejemplo de mensaje de commit: fix(auth): fixed authentication via google on the browser webapp.
  • feat para commits que añadan una nueva funcionalidad. Ejemplo: feat(emoji): added support for new unicode emojis.
  • BREAKING CHANGE, o !, si nuestro commit realiza un cambio que rompe funcionalidad existente en la API pública de nuestro software. Ejemplos:
refactor(protocol): update of the messaging protocol`

BREAKING CHANGE: This new version of the protocol breaks some functionality with older versions.
feat!: remove support for Chrome 80 and earlier due to outdated Web API compatibility

Ejemplo práctico con Git y GitHub

Una vez explicados todos los conceptos, ya podemos ponerlos en práctica. 👩‍💻

Vamos a tratar de hacer algo muy sencillo, que nos dará como resultado final una imagen Docker cuyo propósito será imprimir el número de versión por pantalla.

NOTA: es importante que dispongas de Docker instalado en tu máquina y de una cuenta de GitHub si quieres seguir el proceso.

Pasos a seguir

Para simplificar las cosas usaremos un simple script, en vez de crear un proyecto de software al estilo de Spring o Node. Por tanto, simplemente crearemos un directorio vacío, iniciando en él un repositorio Git.

mkdir automatic-versioning
cd automatic-versioning
git init

Crearemos además un repositorio de GitHub vacío, al que daremos permisos de escritura para las actions. Esto se hace accediendo al repositorio en Settings > Actions > General > Workflow permissions.

Lo primer será crear un fichero donde especifiquemos la versión de nuestro software. En nuestro ejemplo, vamos a crear un fichero VERSION.txt, que contendrá la versión actual del proyecto, con el siguiente contenido:

v0.0.1

NOTA: En un proyecto real, dependiendo del ecosistema, puede que sea más conveniente establecer este número de versión en otro sitio. Por ejemplo, si trabajamos con JavaScript/TypeScript, podríamos indicar la versión en el fichero package.json.

Posteriormente, crearemos el script donde mostraremos la versión por pantalla, en este caso, con el nombre echo_version.sh:

#!/bin/sh

VERSION=$(cat VERSION.txt)

echo "Current version is: $VERSION"

Por último, crearemos un fichero Dockerfile como este, para poder construir la imagen:

FROM alpine:latest
WORKDIR /app
COPY echo_version.sh VERSION.txt ./
RUN chmod +x echo_version.sh
CMD ["/app/echo_version.sh"]

Como última fase, vamos a crear un workflow de GitHub Actions. Este se ejecutará en cada commit realizado a la rama main, y ejecutará las siguientes acciones:

  • Calcular la siguiente versión, a partir de los commits generados desde la última tag
  • Modificar el fichero VERSION.txt con la versión del cálculo anterior
  • Realizar un commit con los cambios del paso anterior.
  • Crear una tag con la versión calculada anteriormente.
  • Logearnos en el repositorio Docker asociado a nuestra cuenta de GitHub
  • Construir y subir la imagen Docker generada, etiquetada con la versión, al repositorio de imágenes.

Para ello, crearemos el siguiente archivo en .github/workflows/version-bump.yml:

name: Version Bump

on:
  push:
    branches:
      - main

jobs:
  version-bump:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Set up Git
        run: |
          git config --global user.name "github-actions[bot]"
          git config --global user.email "github-actions[bot]@users.noreply.github.com"
          git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git

      - name: Get next version
        id: semver
        uses: ietf-tools/semver-action@v1
        with:
          token: ${{ github.token }}
          branch: main

      - name: Write version on VERSION.txt
        run: |
          echo "${{ steps.semver.outputs.next }}" > VERSION.txt

      - name: Commit version bump
        run: |
          git add VERSION.txt
          git commit -m "chore(version): bump version to ${{ steps.semver.outputs.next }}"
          git push

      - name: Create a lightweight tag
        run: |
          git tag ${{ steps.semver.outputs.next }}
          git push origin ${{ steps.semver.outputs.next }}

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Push to Docker Hub
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: |
            ghcr.io/<username>/<repo-name>:latest
            ghcr.io/<username>/<repo-name>:${{ steps.semver.outputs.next }}
        env:
          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
          DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}

NOTA: en la parte que pone ghcr.io/<username>/<repo-name>, asegúrate de poner tu nombre de usuario y nombre del repo, ambos en minúsculas.

Ahora, necesitaremos hacer un commit siguiendo conventional commits, y una tag siguendo el versionado semántico. Esto es necesario ya que necesitamos tener al menos una tag con la que comparar los nuevos commits:

git add .
git commit -m "chore: first working version"
git tag v0.0.1

Podemos probar ahora a construir y ejecutar la imagen en nuestro equipo. Para ello, ejecutaremos las siguientes instrucciones:

docker build . --tag=automatic-versioning-example
docker run automatic-versioning-example

Y obtendremos el resultado siguiente:

Current version is: v0.0.1

Ahora haremos un nuevo commit extra. En este caso será un commit vacío dado que no vamos a modificar ningún fichero:

git commit --allow-empty -m "fix: empty commit"

Finalmente subiremos todo a nuestro repositorio de GitHub. Es importante subir la tag que hemos creado en local también. En mi caso lo he hecho así:

git remote add origin [email protected]:Suguis/version-test.git
git branch -M main
git push -u origin main --tags

Podemos ejecutar nuestra imagen Docker de la manera siguiente. En mi caso sería así:

docker run ghcr.io/suguis/automatic-versioning-example:latest

NOTA: si has creado el repositorio como privado, tendrás que cambiar la visibilidad de los paquetes a “Public”.

Aclaraciones

Es importante destacar que cuando la action se ejecuta, el incremento se realiza una sola vez, independientemente del número de commits que existan. Si por ejemplo hacemos una pull request hacia main con 2 commits marcados como feat y 8 marcados como fix, la minor version solo se incrementará en 1 y la patch version se establecerá a 0. Si tenemos estos mismos 10 commits en nuestra rama main local y los subimos a la rama main remota, también sucederá lo mismo.

A la hora de trabajar en un proyecto real, dependiendo de nuestra estrategia de ramas, implantaremos esta action de una u otra forma. Por ejemplo, si trabajamos con trunk based development, probablemente querremos ejecutar la action con cada push a main. Si en cambio trabajamos con GitFlow, podríamos querer ejecutarla con cada push a develop, ya que al tener el versionado en develop podríamos crear fácilmente las ramas release/X.Y.Z correspondientes.

Repositorio

En este respositorio puedes ver el código realizado. Puedes probar a ejecutar la última versión de la imagen Docker correspondiente de esta manera:

docker run ghcr.io/suguis/automatic-versioning-example:latest

¡También puedes realizar un fork y experimentar! Ten en cuenta que en ese caso necesitarás cambiar el fichero .github/workflows/version-bump.yml para poner en estas dos líneas tu nombre de usuario y nombre de tu repositorio, en minúsculas:

ghcr.io/<username>/<repo-name>:latest
ghcr.io/<username>/<repo-name>:${{ steps.semver.outputs.next }}

Si quieres experimentar con tu propio fork y realizar commits para observar cómo se modifica la versión, puedes crear un fichero aparte dentro del propio repositorio para poder realizarlos, si no quieres tocar el resto de ficheros. Otra opción igual de válida, es crear commits en blanco, de la siguiente manera:

git commit --allow-empty -m "feat: empty commit"

Al hacer push a main, ya sea commiteando directamente o a través de una PR, la action se ejecutará generarando una nueva imagen Docker, que imprimirá por pantalla la nueva versión calculada a partir de tus commits.

Conclusiones

En este artículo hemos visto la importancia del versionado en el mundo del software, y de los dolores de cabeza que podemos ahorrarle a los demśa utilizando un buen versionado. Además hemos conocido técnicas como el versionado semántico y los conventional commits.

Al utilizar prácticas y convenciones standard, es más fácil encontrar software disponible para usar, de forma que podemos empezar a aplicar estas prácticas de manera fácil y sencilla. Por supuesto, para implementar algunas de ellas será necesario explicárselas a todo el equipo para que se familiaricen con ellas. En el caso del versionado automático, por ejemplo, podemos hablar sobre los conventional commits.

Además, las prácticas que hemos aprendido no solo son útiles para automatizar la versión, sino que pueden dar lugar a muchas otras automatizaciones interesantes, como un changelog automático, algo también esencial cuando trabajamos con software.

Recuerda que muchos procesos son automatizables. Detectarlos y automatizarlos mejorará el rendimiento y la motivación del equipo.

(Artículo publicado en SirviendoCódigo)

 
Leer más...

from Sugui's tech trip

Hace poco empecé a leer “Unit Testing Principles, Practices and Patterns”, de Vladimir Khorikov. Hacía tiempo que no leía un libro de este estilo, y lo estoy disfrutando mucho.

Quería aprender sobre testing de forma más profunda, ya que además de querer mejorar en ello como desarrolladora, me parece un tema muy interesante. Hasta el momento, he aprendido sobre: las dos principales corrientes del testing (la clásica y la de Londres), los tipos de dependencias, y algunas técnicas, como el uso de tests parametrizados.

Leer libros técnicos es una forma más de aprender. Pero es muy diferente a otras como hacer cursos o proyectos, ya que se aprende de manera más teórica. No obstante, hay muchos libros que también incluyen ejercicios para poner en práctica lo aprendido, como por ejemplo algunos que tratan sobre lenguajes de programación.

Personalmente, creo que el conocimiento teórico que hay en estos libros es muy importante para dar un paso más allá. Entender por qué las cosas se hacen de cierta manera, su origen y tener una visión general del tema, nos permite crear un conocimiento amplio y cohesionado, algo indispensable para ser realmente profesionales sobre el tema.

En estos libros, la información que se da contiene un montón de detalles. Por ejemplo, algunos que he encontrado en mi lectura son las razones por las que los tests unitarios deben tratar sobre el comportamiento y no sobre las clases, o las ventajas y desventajas de usar tests parametrizados. Esto puede ser menos detallado e incluso inexistente en otro tipo de recursos. Además, estos detalles proporcionan un conocimiento más profundo y sólido al aprendizaje, permitiendo entender las razones y el contexto.

Otro punto a favor de los libros, bastante ligado al anterior, es que muchas veces suelen adentrarse en temas más especializados, lo que nos permite profundizar dentro del área de conocimiento sobre el que se está leyendo. Una vez más, esto es algo que no muchos recursos nos pueden proporcionar.

Y es que el conocimiento más avanzado y especializado empieza por escribirse, mediante libros y publicaciones científicas o “papers”. Sin embargo, los libros son una opción más estructurada, completa y más abierta al público que los papers, por tanto son geniales si queremos especializarnos sobre un tema que ya conocemos un poco.

Si hay un área de conocimiento que nos despierte especial interés, como el testing, la programación funcional, los patrones de diseño etc., leer sobre ello es una experiencia súper recomendable y gratificante. ☀️

 
Leer más...

from Sugui's tech trip

Stickee is a web application that we are currently building. Its main goal is to be an easy self-hosted solution to share and store text temporarily in a secure way.

Since the application is in early development, its structure is still very volatile and everything can change, like the database schema. However, we want to make sure that updating the application doesn't break anything and doesn't require any manual intervention.

When we design new features and improvements, we do it with backward compatibility in mind. If there is some ugly design that we need to make in order to maintain this compatibility, we add it into a list to change in a future breaking change release.

The previous behaviour

When a note is created, its link has the following structure https://stickee.siesta.cat/notes/<note-id>. This <note-id> is unique for each note, and is a fixed-length string composed from any uppercase letters, lowercase letters, or digits.

In our domain model, we have a Note class with a value object NoteId. This NoteId implements the specification we designed for the note ids, which is the following:

  • It must have a specified fixed length.
  • It must be ASCII.
  • It must contain only alphanumeric characters.

The new breaking changes

In the last version, we applied a change to make that the ids generated for the notes must have at least one digit in them. This way, the <note-id> will not have conflicts with any endpoints we could have, like https://stickee.siesta.cat/notes/create. We also added a new constraint when creating a NoteId:

  • It must contain at least one digit.

However, there is a problem with that.

When a note is retrieved, the NoteService maps the NoteEntity obtained from the NoteRepository to the Note class:

public Optional<Note> get(String id) {
    return noteRepository.findById(id).map(entity -> mapper.toModel(entity));
}

Now, imagine we have already a bunch of notes created in previous versions, with at least one with an id that follows the previous specification, that doesn't have any digits. When the NoteService does the mapping, it will fail because that id no longer follows the specification.

So... What should we do? Well, we have two options:

  1. Do nothing to tackle this so some previous notes will no longer be visible. They will be auto-deleted after a few days anyways.

  2. Be able to retrieve notes with ids that don't follow the specification but are already in the database.

If we care about the people that is using our software, we should make an effort to maintain this backward compatibility.

Implementing the solution

First, we write a test with an id that doesn't follow the specifications, then we check that indeed doesn't follow them, and also that the service retrieves the note we stored earlier. This last check will fail for now.

// This is to avoid breaking compatibility with older versions if the
// specification of the note id changes
@Test
void shouldGetAlreadyExistingNoteWithInvalidId() {
    var invalidId = "invalidId";
    var entity = new NoteEntity(invalidId, "text", LocalDateTime.now(), TextCipher.PLAIN);

    noteRepository.save(entity);

    assertThrows(IllegalArgumentException.class, () -> new NoteId(invalidId));
    assertEquals(entity.getText(), noteService.get(invalidId).get().getText());
}

Next, we add a new static method in the NoteId class, allowing us to create a NoteId without doing any validation. Its use is exclusive for mapping database entities to domain model instances. We do some refactor to achieve this:

NoteId.java:

Before:

public NoteId(String id) {
    if (!isValid(id)) {
        throw new IllegalArgumentException("id doesn't follow the specification");
    }
    this.id = id;
}

After:

public NoteId(String id) {
    this(id, true);
}

private NoteId(String id, boolean shouldValidate) {
    if (shouldValidate && !isValid(id)) {
        throw new IllegalArgumentException("id doesn't follow the specification");
    }
    this.id = id;
}

/**
 * Allows the creation of note ids without validation, used to
 * maintain compatibility with previous versions of the application.
 * 
 * The use of this method should be strictly to maintain this compatibility,
 * and will be removed in future updates.
 * 
 * @param id the id of the note, that can be invalid.
 * @return an unvalidated NoteId.
 */
public static NoteId createWithoutValidation(String id) {
    var noteId = new NoteId(id, false);
    return noteId;
}

Finally we refactor the mapper to use this new method:

NoteEntityMapper.java:

Before:

public Note toModel(NoteEntity entity) {
    var decryptedText = switch (entity.getTextCipher()) {
        case PLAIN -> entity.getText();
        case AES256 -> encryptor.decrypt(entity.getText());
    };

    return new Note(Optional.ofNullable(new NoteId(entity.getId())), decryptedText,
            new NoteTimestamp(entity.getCreationTimestamp()));
}

After:

public Note toModel(NoteEntity entity) {
    var decryptedText = switch (entity.getTextCipher()) {
        case PLAIN -> entity.getText();
        case AES256 -> encryptor.decrypt(entity.getText());
    };

    return new Note(Optional.ofNullable(NoteId.createWithoutValidation(entity.getId())), decryptedText,
            new NoteTimestamp(entity.getCreationTimestamp()));
}

With these changes, all of our tests pass, including the last one we made to maintain the backward compatibility. We can push and deploy and check that we don't have any problems getting notes with invalid ids. Yay!

 
Read more...

from Sugui's tech trip

Alie and me recently started doing a project to learn both Spring Boot and test-driven development (TDD). The project, named Stickee, is intended to be a Pastebin clone, but we plan to add more features in the future, like uploading other kind of files.

The project follows a typical Spring Boot architecture with controllers, models and services.

Today we did the two basic operations of the NoteService: get(), which returns a note given its resource locator, and create() which creates a new note. Since we used TDD, we created tests for each method before implementing them.

Doing these tests made me realize that, at the moment we were writing them, we were basically imposing restrictions on all different implementation posibilities. And not only on the output, but on the internal behaviour, since we were mocking the NoteRepository, and this forces the NoteService to use the NoteRepository methods that we mocked, to pass the tests.

The test for the create() method is very simple:

@Test
void shouldSave() {
    var note = NoteStubBuilder.create().build();
    
    noteService.create(note);

    verify(noteRepository).save(note);
}

We are simply checking that NoteService calls the save() method with the Note given as a parameter on its create() method.

For the get() method, we made two tests:

@Test
void shouldGetWhenExisting() {
    var note = NoteStubBuilder.create().withText("I should be get").build();

    given(noteRepository.findByResourceLocator(note.getResourceLocator())).willReturn(Optional.of(note));
    var maybeNote = noteService.get(note.getResourceLocator());

    assertTrue(maybeNote.isPresent());
    assertEquals(note, maybeNote.get());        
}
@Test
void shouldGetEmptyWhenAbsent() {
    String resourceLocator = "f0f0f0";

    given(noteRepository.findByResourceLocator(resourceLocator)).willReturn(Optional.empty());
    var maybeNote = noteService.get(resourceLocator);

    assertTrue(maybeNote.isEmpty());
}

The first one mocks the repository call so it will return a note when given its resource locator. The method can't create a note by its own, because it needs the Note's text, only provided by the repository. So we force the get() method to actually return the Note provided by the repository.

In the second test, we are mocking the repository call to behave like it doesn't find a note with the given resource locator. This test is necesary because if a Note doesn't exist, the NoteService could still creates a Note by its own with arbitrary text. But with this test, we are forcing it to return an Optional.empty() if the repository doesn't find any note.

 
Read more...