Skip to content

Latest commit

 

History

History
193 lines (126 loc) · 11.1 KB

fisopfs.md

File metadata and controls

193 lines (126 loc) · 11.1 KB

fisop-fs

Filesystem que utiliza el mecanismo FUSE (Filesystem in USErspace) provisto por el kernel, que permite definir en modo usuario la implementación de un filesystem.

Tabla de contenidos:

  1. Estructura del filesystem
    1. Tamaño total en memoria
  2. Serialización y guardado persistente
  3. Carga de archivo persistente en memoria
  4. Resolución del path
  5. Eliminación de archivos
  6. Cómo correr los tests

Estructura del filesystem

La estructura del filesystem guardada en memoria RAM, implementada en totalidad de manera estática (sin uso de memoria dinámica) que logra cumplir los requerimientos es la siguiente:

En el nivel más alto, se tiene: used_inodes para registrar la cantidad de inodos que están en uso, un "bitmap" que está representado por un array de bools (en rigor técnico no sería un bitmap porque no está implementado bit a bit, sino con bools, pero cumple el mismo propósito), y un array de inodos que son la estructura esencial del filesystem.

Analizando con más detalle la estructura inodo:

Inspirado en la estructura del VSFS (Very Simple FileSystem), el inodo es una estructura que contiene metadata del archivo que representa, el cual puede ser un directorio o un archivo regular. La metadata disponible incluye el tipo (dir o file), el id del inodo (que es igual a su índice en el array para facilitar un acceso directo), un campo is_empty, y toda la información disponible que contiene el struct stat, que se procede a detallar:

struct stat {
  dev_t      st_dev;      // ID of device containing file
  ino_t      st_ino;      // Inode number
  mode_t     st_mode;     // File type and mode
  nlink_t    st_nlink;    // Number of hard links
  uid_t      st_uid;      // User ID of owner
  gid_t      st_gid;      // Group ID of owner
  dev_t      st_rdev;     // Device ID (if special file)
  off_t      st_size;     // Total size, in bytes
  blksize_t  st_blksize;  // Block size for filesystem I/O
  blkcnt_t   st_blocks;   // Number of 512 B blocks allocated

  // Since POSIX.1-2008, this structure supports nanosecond
  //      precision for the following timestamp fields.
  //      For the details before POSIX.1-2008, see VERSIONS.

  struct timespec  st_atim;  // Time of last access
  struct timespec  st_mtim;  // Time of last modification
  struct timespec  st_ctim;  // Time of last status change
}

Se incluyó el flag de compilación -D_POSIX_C_SOURCE=200809L para permitir guardar los campos de tipo struct timespec y la implementación de la operación de FUSE utimens (de similar uso que la syscall utimensat(2)).

Para emular el bloque de datos de VSFS se utilizó una union de nombre union data, que reserva, en este caso de manera estática, la memoria del elemento más grande para guardar sólo uno de los campos a la vez. Esto permite que el inodo guarde struct inode_dir ó struct inode_file, siendo un inodo de tipo directorio ó de archivo regular, pudiendo ser sólo uno a la vez.

Si el inodo es de tipo file, la union data guarda un array de bytes (uint8), el size actual de dicho array (el size en bytes) está guardado en el struct stat. Un file puede tener contenido de tamaño máximo de 156.25KB (160000 bytes), teniendo el filesystem una capacidad máxima de almacenamiento de 1023 (1024 - el nodo root que es el de índice 0) archivos de 156.25 KB, es decir ~156.09 MB. Esto sería para todos los archivos siendo sólo de tipo file y almacenados en el root o mountpoint.

Si el inodo es de tipo dir, la union data guarda un array de tipo dentry (directory-entry) y un contador del mismo. Un dentry es un key-value pair que almacena el nombre de un archivo o directorio el cual está bajo dicho directorio y el i-number (id de inodo) de dicho archivo o directorio.

Como se mencionó, al ser una union se reserva memoria para el elemento más grande:

  • Para un file el tamaño es de 160.000 bytes, que sale de tener un array de 160.000 bytes.
  • Para un dir el tamaño es de 159.748 bytes, que sale de tener: 4 bytes para un uint32 + 1024 entradas de un tamaño de 156 bytes (152 chars de 1 byte + 4 bytes de un uint32).

Por lo tanto, el campo union data para un determinado inodo tendrá siempre 160.000 bytes de tamaño. Cuando se use el inodo como un dir, no se estarán usando 252 bytes del espacio total reservado para la union.

Tamaño total en memoria

Sabiendo que un inodo posee el siguiente tamaño:

160.156 bytes ó 156.4 KB = 144 bytes del struct stat + 12 bytes de tres uint32 + 160.000 bytes del union data.

Pudiendo variar ligeramente el tamaño del struct stat dependiendo de la implementación específica del sistema operativo.

El filesystem cargado en memoria ocupa un total de:

160.000.772 bytes ó ~156.4 MB = 4 bytes (por 1 uint32) + 1024 bytes (1024 bools, asumiendo una implementación común de 1 bool con 1 char) + 1024 inodos de 160.156 bytes cada uno.

Serialización y guardado persistente

El guardado del filesystem de manera persistente en disco en el filesystem host (desde donde se monta a fisopfs) se realiza con datos binarios utilizando la syscall write(2).

El guardado no es 1:1 a cómo está representado en memoria, se guardan sólo las estructuras utilizadas, omitiendo las vacías. El proceso de describe a continuación de manera secuencial:

Primero se guarda el uint32 used_inodes y el bitmap en su totalidad. En el caso del array de inodos, se guardan sólo los que están siendo utilizados, referenciados por el bitmap y el campo is_empty del inodo mismo. Este guardado es secuencial, es decir, que si se están usando los inodos de i-number 0 y 2, se escriben los bytes del inodo 0 y a continuación se escriben los bytes del inodo 2.

Para cada inodo que se guarda, la información que se escribe en disco, también de manera secuencial y contigua es: el struct stat y los tres uint32 que guardan metadata. A continuación se guardan los bytes de union data dependiente del tipo de inodo.

Para los inodos de tipo file, que poseen el size de bytes usados en struct stat, se guardan sólo los primeros size bytes (que son los bytes en uso).

Para los inodos de tipo dir, se guarda el uint32 dentries_count, y del array de dentries se guardan los primeros dentries_count dentries (que son los dentries en uso).

Cada dentry se guarda en su totalidad, tanto el uint32 como los 152 caracteres.

Carga de archivo persistente en memoria

Al momento de leer el archivo persistente para cargar el filesystem en memoria se realiza primero una inicialización vacía del mismo y luego se cargan los valores guardados leídos de disco, para tomar el lugar de los datos vacíos. El bitmap, que se guarda en totalidad, se utiliza para saber a qué posición del array de inodos pertenece el inodo leído, y cuál debería dejarse vacío.

Resolución del path

Resolver un path implica encontrar el inodo que corresponde al archivo que se pretende buscar mediante un string con la estructura de un path.

Se propone un ejemplo de dicha estructura, conocida y estándar: /dirB/dirA/fileA. Por ejemplo alojada en una instancia del filesystem con los siguientes directorios y archivos:

/
├── dirA
├── dirB
│   └── dirA
        ├── fileA
        └── fileB
├── fileA
└── fileB

La estrategia general para la resolución es:

  1. Si el path es mayor a MAX_PATH caracteres se devuelve el error correspondiente.
  2. Acceder al directorio root. Si se buscaba el mismo, se retorna el inodo 0 (reservado para el root).
  3. Se establece el inodo 0 como directorio container y se ejecuta el siguiente algoritmo:

Por cada dentry del container:

  • Si el path hasta e incluyendo el container concatenado con el name del dentry es el path buscado, se retorna el inodo encontrado. Este paso es agnóstico al tipo del inodo.
  • Si el inodo es un dir, se hace una llamada recursiva al paso 3, usando como container a este dir.
  1. Si luego de las iteraciones sobre todo el árbol de directorios no se encontró una concatenación que haga match con el path buscado, se devuelve el error de entidad no existente. Si sí se encontró, se devuelve el inodo encontrado.

Una consecuencia de esta manera de tratar los paths es que sólo puede haber un dentry por directorio con un nombre determinado, un corolario es que un directorio no puede contener un archivo regular y un directorio con el mismo nombre, 1 inodo por nombre.

Otra consecuencia, es que en el peor caso se recorre todo el árbol de directorios, es decir, todos los inodos en uso, hasta encontrar el inodo buscado, ya que este es un approach de fuerza bruta.

Eliminación de archivos

Para la eliminación de archivos regulares primero se valida que efectivamente se trate de un archivo regular. Para ello se utiliza la función fs_resolve_path , la cual devuelve un puntero al inodo del archivo en cuestión. Si no se encuentra, se devuelve un error. Y si se encuentra pero no es un archivo regular, también.

Luego se procede a eliminar el dentry correspondiente. Con este motivo se obtiene el path y luego un puntero al inodo del directorio padre que contiene el archivo a eliminar. Entonces se desplazan las entradas restantes del array de dentries para eliminar la entrada correspondiente, y se reduce el contador de entradas del directorio.

Finalmente, se vacía el inodo del archivo y se lo quita del bitmap de inodos.

Con respecto a la eliminación de directorios, se procede de manera similar, pero haciendo las validaciones correspondientes a los directorios.

Cómo correr los tests

Los tests, verificados también para el container de docker, requieren de python las utilidades estándar de GNU para funcionar. Se disponibiliza un script para correrlos de manera automatizada, para ello basta correr desde la carpeta fisopfs:

./test.sh

Las pruebas se encuentran detalladas en el archivo tests/test_main.py, se utilizan sólo bibliotecas estándar de python3, por lo que no se requiere instalar paquetes extras.

Se recomienda correrlos dentro del container de Docker para tener el mismo entorno desde donde las pruebas fueron verificadas, para ello, correr los siguientes comandos:

Para construir la imagen y correr el container:

sudo make docker-build
sudo make docker-run

Recompilar dentro del container, el make puede no reconocer el cambio de plataforma así que se recomienda borrar el ejecutable y compilarlo de nuevo:

make clean && make

Correr el script con pruebas

./test.sh

Se observará output de stdout así como la cantidad de pruebas que pasaron exitosamente.