Siesta Write

Reader

Read the latest posts from Siesta Write.

from Tech log

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 Tech log

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 Tech log

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.example/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.example/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 Tech log

Bizcochito 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...