Generare il tuo sistema operativo in modo dichiarativo

Generare il tuo sistema operativo in modo dichiarativo

Chi lavora nell’informatica (o semplicemente è uno smanettone) ha bisogno di un sistema operativo stabile, che non si rompa e che quando si rompe può tornare ad una situazione funzionante. Linux è considerato un sistema stabile server-side, ma quando lo si utilizza quotidianamente come desktop si incorre spesso a diverse problematiche: il kernel non parte dopo che abbiamo installato qualche patch, problemi con grub che non riconosce le partizioni, pacchetti che si rompono dopo un’aggiornamento … In questo post parleremo di una distribuzione linux che risolve molti problemi comuni ad altre distribuzioni: NixOS.


Cosa è NixOS

NixOS è una distribuzione Linux che si distingue dalle altre per essere totalmente replicabile. L’intero sistema operativo viene generato da un file di configurazione: i pacchetti, i servizi, tutta la customizzazione (neovim, window manager, ssh…) vengono gestiti da un’unica sorgente. In altre parole, se due computer hanno lo stesso file di configurazione, sostanzialmente hanno un sistema operativo identico. Questo ci permette di condividere il file di configurazione con altri utenti, o con altri PC, così da sincronizzarli.

Se quello che hai letto ti interessa, ora andremo a svelare come questa magia funziona e come sfruttare la potenza di NixOS al meglio.

NixOS website
The official NixOS website, showing the latest release 23.11

Elementi di un file di configurazione

Andiamo a vedere quali sono i componenti fondamentali di un file di configurazione.

Quando installi NixOS, verranno generati due file in /etc/nixos: hardware-configuration.nix e configuration.nix. Da questi file verrà generato tutto il sistema operativo (non è necessario che si trovino in /etc infatti sarà possibile specificare la loro posizione quando si builda tutto). Viene divisa la configurazione hardware dal resto, così che il file configuration.nix è totalmente indipendente dal sistema e funzionerà ovunque. Questi file sono scritti in un linguaggio di programmazione funzionale chiamato Nix, non è essenziale sapere come Nix funziona per configurare il suo sistema ma se vuoi approfondire puoi guardare qua.

Andiamo a vedere la configurazione default di configuration.nix:

# Edit this configuration file to define what should be installed on
# your system.  Help is available in the configuration.nix(5) man page
# and in the NixOS manual (accessible by running ‘nixos-help’).

{ config, pkgs, ... }:

{
  imports =
    [ # Include the results of the hardware scan.
      ./hardware-configuration.nix
    ];

  # Bootloader.
  boot.loader.systemd-boot.enable = true;
  boot.loader.efi.canTouchEfiVariables = true;

  networking.hostName = "nixos"; # Define your hostname.
  # networking.wireless.enable = true;  # Enables wireless support via wpa_supplicant.

  # Configure network proxy if necessary
  # networking.proxy.default = "http://user:password@proxy:port/";
  # networking.proxy.noProxy = "127.0.0.1,localhost,internal.domain";

  # Enable networking
  networking.networkmanager.enable = true;

  # Set your time zone.
  time.timeZone = "Europe/Rome";

  # Select internationalisation properties.
  i18n.defaultLocale = "en_US.UTF-8";

  i18n.extraLocaleSettings = {
    LC_ADDRESS = "it_IT.UTF-8";
    LC_IDENTIFICATION = "it_IT.UTF-8";
    LC_MEASUREMENT = "it_IT.UTF-8";
    LC_MONETARY = "it_IT.UTF-8";
    LC_NAME = "it_IT.UTF-8";
    LC_NUMERIC = "it_IT.UTF-8";
    LC_PAPER = "it_IT.UTF-8";
    LC_TELEPHONE = "it_IT.UTF-8";
    LC_TIME = "it_IT.UTF-8";
  };

  # Configure keymap in X11
  services.xserver = {
    layout = "it";
    xkbVariant = "";
  };

  # Configure console keymap
  console.keyMap = "it2";

  # Define a user account. Don't forget to set a password with ‘passwd’.
  users.users.lanto = {
    isNormalUser = true;
    description = "lanto";
    extraGroups = [ "networkmanager" "wheel" ];
    packages = with pkgs; [];
  };

  # Allow unfree packages
  nixpkgs.config.allowUnfree = true;

  # List packages installed in system profile. To search, run:
  # $ nix search wget
  environment.systemPackages = with pkgs; [
  #  vim # Do not forget to add an editor to edit configuration.nix! The Nano editor is also installed by default.
  #  wget
     git
  ];

  # Some programs need SUID wrappers, can be configured further or are
  # started in user sessions.
  # programs.mtr.enable = true;
  # programs.gnupg.agent = {
  #   enable = true;
  #   enableSSHSupport = true;
  # };

  # List services that you want to enable:

  # Enable the OpenSSH daemon.
  # services.openssh.enable = true;

  # Open ports in the firewall.
  # networking.firewall.allowedTCPPorts = [ ... ];
  # networking.firewall.allowedUDPPorts = [ ... ];
  # Or disable the firewall altogether.
  # networking.firewall.enable = false;

  # This value determines the NixOS release from which the default
  # settings for stateful data, like file locations and database versions
  # on your system were taken. It‘s perfectly fine and recommended to leave
  # this value at the release version of the first install of this system.
  # Before changing this value read the documentation for this option
  # (e.g. man configuration.nix or on https://nixos.org/nixos/options.html).
  system.stateVersion = "23.05"; # Did you read the comment?

}

Break down

All’inizio importiamo delle dipendenze che ci servono all’interno del blocco principale: config e pkgs.

{ config, pkgs, ... }:

Segue un blocco imports: questa funzione prende una lista di files e li importa, ossia gli esegue. Dunque tutto il codice dentro ./hardware-configuration.nix verrà eseguito.

imports =
    [ # Include the results of the hardware scan.
      ./hardware-configuration.nix
    ];

Nel resto del file vengono settati vari campi e impostazioni, sono abbastanza intuitivi. Per esempio, time.timeZone = "Europe/Rome;" imposta la timezone a quella di Roma, se volessi usare un’altra timezone, posso mettere per esempio America/New_York

# Enable networking
networking.networkmanager.enable = true;

# Set your time zone.
time.timeZone = "Europe/Rome";

# Select internationalisation properties.
i18n.defaultLocale = "en_US.UTF-8";

# ...

La lista completa delle opzioni può essere trovata qui, puoi fare sempre riferimento a questo sito per vedere quali opzioni devi scegliere per impostare tutto quello che vuoi.

Capita che alcune cose non sono intuitive o c’è bisogno di fare dell’ulteriore setup per far funzionare qualcosa, per questo puoi cercare quello che devi installare nella wiki. Per esempio questa è la pagina per installare i drivers nvidia . Come altra risorsa puoi vedere i file di configurazione di altre persone che trovi pubblici su github, spesso questa è la cosa migliore (per esempio vedere come qualcuno ha impostato un certo plugin di vim).

Vediamo un’esempio di hardware-configuration

# Do not modify this file!  It was generated by ‘nixos-generate-config’
# and may be overwritten by future invocations.  Please make changes
# to /etc/nixos/configuration.nix instead.
{ config, lib, pkgs, modulesPath, ... }:

{
  imports =
    [ (modulesPath + "/installer/scan/not-detected.nix")
    ];

  boot.initrd.availableKernelModules = [ "vmd" "xhci_pci" "ahci" "nvme" "usb_storage" "sd_mod" ];
  boot.initrd.kernelModules = [ ];
  boot.kernelModules = [ "kvm-intel" ];
  boot.extraModulePackages = [ ];

  fileSystems."/" =
    { device = "/dev/disk/by-uuid/6c8225f5-d190-4e1f-b58c-2fe3ea7a86c6";
      fsType = "ext4";
    };

  fileSystems."/boot" =
    { device = "/dev/disk/by-uuid/6014-271C";
      fsType = "vfat";
    };

  swapDevices = [ ];

  # Enables DHCP on each ethernet and wireless interface. In case of scripted networking
  # (the default) this is the recommended approach. When using systemd-networkd it's
  # still possible to use this option, but it's recommended to use it in conjunction
  # with explicit per-interface declarations with `networking.interfaces.<interface>.useDHCP`.
  networking.useDHCP = lib.mkDefault true;
  # networking.interfaces.eno1.useDHCP = lib.mkDefault true;
  # networking.interfaces.enp0s20f0u2.useDHCP = lib.mkDefault true;
  # networking.interfaces.wlo1.useDHCP = lib.mkDefault true;

  nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
  powerManagement.cpuFreqGovernor = lib.mkDefault "powersave";
  hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
}

Questo file è generato automaticamente in base all’hardware del tuo pc, dovrebbe essere cambiato (rigenerato) solo se modifichi qualcosa a livello hardware o di partizioni.


Buildare il sistema (+ magia)

Per buildare il sistema operativo dato un file di configurazione basterà eseguire il comando

sudo nixos-rebuild switch

A questo punto una nuova versione del sistema verrà generata e, quando avvieremo grub, avremo l’elenco di tutte le versioni precedenti, dandoci la possibilità di scegliere quale avviare! Questa cosa è potentissima e mi ha salvato tanto tempo, parecchie volte, quando non mi si avviava nemmeno tty: so che potrò sempre tornare indietro ad una versione precedente.


Strutturare un file di configurazione

Grazie alla funzione import possiamo dividere il file monolitico configuration.nix in un file più strutturato. Possiamo dividere i settaggi per argomenti come window manager, code editor, shells… Vedremo più avanti la struttura che utilizzo per il mio file di configurazione, ma prima dobbiamo parlare di altre funzionalità interessanti.

imports = [
	./main-user.nix
]

In ./main-user.nix

{ whatever, ... }:

{
   # Config here
}

Nix packet manager

NixOS si basa sul Nix packet manager, uno dei più grandi e forniti packet managers disponibile su Linux e MacOS, con più di 80 000 pacchetti disponibili. Per installare un pacchetto basterà aggiungere il nome del pacchetto nel file di configurazione, dentro systemPackages. E’ possibile cercare il nome di un pacchetto nel sito ufficiale. Questo è un’estratto dal mio file che puoi trovare qui:

modules/default.nix

environment.systemPackages = with pkgs; [
    # Applications
    firefox
    discord
    telegram-desktop
    obsidian
];

In questo modo i pacchetti vengono installati a livello di sistema, quindi per tutti gli utenti. Più avanti andremo a vedere un metodo per installare pacchetti specifici per utente.


Nix-Shell

Per garantire un’ambiente di sviluppo consistente tra diverse configurazioni, Nix introduce il comando nix-shell. Possiamo passare al comando nix-shell i pacchetti da utilizzare nell’ambiente della shell con la flag -p oppure utilizzando un file come il seguente:

nix-shell -p jdk

java-shell.nix

with (import <nixpkgs> {});
mkShell {
    buildInputs = [ 
        jdk
    ];
}
nix-shell java-shell.nix

I due approcci sono equivalenti, ma avere un file può essere più comodo e persistente. In questo modo verranno portati in scope i pacchetti specificati, o scaricati qualora non lo fossero già.


Flakes

I flakes sono una funzione sperimentale, ma ormai adottata da tutta la community, che permette di bloccare i pacchetti ad una certa versione, come il file Cargo.toml per Rust oppure package.json per javascript.

Per utilizzare i flakes, bisogna abilitare le experimental features nella tua configurazione:

settings.experimental-features = [ "nix-command" "flakes" ];

Puoi poi generare il file flake.nix con questo comando

nxi flake init

Ecco un’esempio

{
  description = "A very basic flake";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
  };

  outputs = { self, nixpkgs }: {

    packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello;

    packages.x86_64-linux.default = self.packages.x86_64-linux.hello;

  };
}

Il file è composto da due sezioni principali

  • inputs contiene una lista di attributi con tutte le dipendenze del flake
  • outputs è una funzione che data una lista di attributi degli inputs, genera un nuovo schema per nixos (puoi approfondire qui)

Puoi poi eseguire la tua flake con questo comando

sudo nixos-rebuild switch --flake /path/tp/flake.nix

Creare profili con flakes

Possiamo decidere cosa buildare da riga di comando creando dei profili all’interno di flake.nix, per esempio questo profilo mi esegue tutto il codice dentro ./hosts/hp-laptop e ./modules

# Profile name is "lanto@hp"
"lanto@hp" = nixpkgs.lib.nixosSystem {
    system = "x86_64-linux";
    modules = [

		# System and Hardware Configuration
	    ./hosts/hp-laptop
	    ./modules
    ];
};

Ovviamente posso avere un numero arbitrario di profili, poi decidere quale usare mettendo un carattere # dopo il path della tua flake, in questo modo:

sudo nixos-rebuild switch --flake /path/to/flake.nix#lanto@hp

Home Manager

Home manager è un sistema per gestire la configurazione per un’utente o più utenti, quindi senza usare sudo. I comandi e le configurazioni che abbiamo visto in precedenza vengono applicati all’intero sistema e quindi a tutti gli users, con home-manager puoi differenziare i vari users con delle configurazioni ad hoc senza influenzare l’intero sistema. E’ molto utile se hai altre persone che stanno usando il sistema e non hanno accesso a sudo ma vogliono un certo grado di autonomia su quello che possono gestire. La cosa interessante è che per usare home manager non c’è bisogno di avere NixOS come sistema operativo ma può funzionare un qualunque sistema.

Puoi installare home-manager li livello di sistema come un pacchetto qualunque (ma questo non ti permetterà di usarlo senza sudo) oppure installarlo separatamente con i seguenti comandi (ho riportato i comandi per nixpkgs 23.11):

nix-channel --add https://github.com/nix-community/home-manager/archive/release-23.11.tar.gz home-manager

nix-channel --update
nix-shell '<home-manager>' -A install

Puoi specificare la tua configurazione in un file come questo

{ config, pkgs, ... }:

{
  # TODO please change the username & home directory to your own
  home.username = "santo";
  home.homeDirectory = "/home/santo";

  # Packages that should be installed to the user profile.
  home.packages = with pkgs; [

    neofetch
    # ...
    
  ];

  # The rest of the configuration

  
  };

  # This value determines the home Manager release that your
  # configuration is compatible with. This helps avoid breakage
  # when a new home Manager release introduces backwards
  # incompatible changes.
  #
  # You can update home Manager without changing this value. See
  # the home Manager release notes for a list of state version
  # changes in each release.
  home.stateVersion = "23.11";

  # Let home Manager install and manage itself.
  programs.home-manager.enable = true;
}

Puoi buildare con il comando:

home-manager switch

Home-manger è anche parecchio più veloce rispetto a buildare l’intero sistema, inoltre non aggiunge nuove entrare al boot loader, quindi occupa meno memoria. Consiglio: è comodo importare la configurazione di home-manager all’interno della configurazione del sistema (per esempio in un profilo di una flake) così che si rebuilda anche la home quando si builda l’intero sistema.


Altri comandi utili

NixOS può occupare tanto spazio specialmente se si rebuilda il sistema parecchie volte. Si possono usare questi comandi per liberare un po’ di memoria

nix-channel --update
sudo nix-channel --update
sudo rm /nix/var/nix/gcroots/auto/*
nix-collect-garbage -d
sudo nix-collect-garbage -d
sudo nix-store --optimize

Puoi anche decidere di buildare il sistema non in modo permanente, quindi senza aggiungere una nuova entrata nel boot loader, in questo modo (molto utile per testare configurazioni):

sudo nixos-requild test

Tour della mia configurazione

Puoi trovare la configurazione che uso aggiornata sul mio github. Ho organizzato la gerarchia in questo modo:

├── build.sh                        # Easy to use build script
├── flake.lock                      # Lock file for flakes
├── flake.nix                       # All flakes profiles are defined here
├── home                            # Home Manager configurations
│   ├── lanto                       # Minimal user with just the necessary stuff
│   │   ├── default.nix             
│   │   └── dev
│   │       ├── default.nix
│   │       └── git.nix             # Git configurations and setting up credentials
│   ├── santo                       # Power user with many programs
│   │   ├── default.nix
│   │   ├── dev
│   │   │   ├── default.nix
│   │   │   └── git.nix
│   │   └── programs
│   │       └── default.nix
│   └── shared                      # Pakages and configurations shared between users
│       ├── default.nix
│       ├── desktop
│       │   ├── default.nix
│       │   ├── hyprland.conf       # Hyprland
│       │   ├── hyprpaper.conf      # Wallpapers
│       │   ├── i3.nix
│       │   ├── neofetch.nix
│       │   ├── ranger.nix
│       │   ├── rofi.nix
│       │   └── waybar.nix
│       ├── dev
│       │   ├── default.nix
│       │   └── neovim.nix          # Neovim Plugins
│       └── shells
│           ├── alacritty.nix
│           ├── bash.nix
│           ├── default.nix
│           ├── fhs.nix             # FHS filesystem
│           ├── kitty.nix           # I use kitty
│           ├── shell.nix
│           └── zsh.nix
├── hosts                           # Configuration specific per machine
│   ├── acer-laptop                 # Backup / Second Laptop
│   │   ├── configuration.nix
│   │   ├── default.nix
│   │   └── hardware-configuration.nix
│   ├── desktop                     # Main workstation, nvidia drivers
│   │   ├── configuration.nix
│   │   ├── default.nix
│   │   └── hardware-configuration.nix
│   └── hp-laptop                   # Unversity note taking and programming
│       ├── configuration.nix
│       ├── default.nix
│       └── hardware-configuration.nix
├── misc                            # Some notes I took that I might need in future
│   ├── powertop.md
│   └── screenshots
│       ├── 01.jpg
│       ├── 02.jpg
│       ├── 03.jpg
│       └── 04.jpg
├── modules                         # System-wide configuration and packages
│   ├── cache-server.nix
│   ├── default.nix                 # All system packages
│   ├── memory-optimization.nix
│   ├── network-manager.nix
│   ├── nvidia.nix                  # Nvidia settings
│   └── users.nix
├── README.md
└── wallpapers                      # A bunch of wallpapers 
    ├── anime1.jpeg
    ├── anime2.jpeg
    ├── anime3.jpeg
    ├── fishing.png
    ├── free-as-in-freedom.jpeg
    ├── grass.jpg
    ├── lake.png
    ├── mountain.png
    ├── nixos-dark.png
    ├── nixos-light.png
    ├── only-grey.png
    └── telescope.png

  • Ho diviso l’hardware dal resto, le configurazioni hardware stanno in hosts/
  • la configurazione del sistema sta in modules/ mentre le configurazioni di home-manager stanno in home/<user-name>. Invece in home/shared sono le configurazioni comuni a tutti gli users (come il window manager o neovim)

Ho uno script build.sh dove posso specificare che profilo usare senza dover scrivere l’intero comando nix-rebuild

#! /run/current-system/sw/bin/sh

if [[ $# != 2  ]] then
	echo -e "Usage: build.sh <system|home> <build_profie>\n"
	echo "Currently supported users:"
	echo " - \"santo@acer\" "
	echo " - \"santo@desktop\" "
	echo " - \"lanto@hp\" "
	exit
fi

if [[ $1 == "home" ]]; then
	home-manager switch --flake .#$2 --impure
    cowsay "Everything is fine"
	exit

fi
if [[ $1 == "system" ]]; then

	sudo nixos-rebuild switch --flake .#$2 --impure
    cowsay "Everything is fine"
	exit
fi

echo "Wrong arguments"

Per quanto riguarda salvare credenziali (per esempio github), salvo le credenziali in .secret/ poi le leggo e le scrivo su file come nel codice che vedi sotto. Questa soluzioni è opinabile e bisognerebbe usare un credential manager come agenix, prima o poi cambierò ad un sistema più sicuro.

{ config, pkgs, ...}:

let
        # Get input from file .secret/github-access-token
	# Make sure It has no new line at the end
  	github-access-token = builtins.readFile "/home/santo/.config/nixos/.secret/github-access-token";
in
{
  # Setup .get-credentials file with the input from file .secret/github-access-token
  home.file.".git-credentials".text = ''
https://San7o:${github-access-token}@github.com
  '';
  
  # .gitconfig configration
  home.file.".gitconfig".text = ''
    [user]
        name = San7o
        email = santigio2003@gmail.com

    [credential]
 	helper = store
  '';
}
Neovim screenshot
Neovim screenshot

Risorse Esterne

I siti di riferimento per cercare pacchetti e opzioni sono questi:

Per un’introduzione completa a NixOS, consiglio questa guida:

Questo video a una visione più generale di NixOS e delle sue potenzialità:

Questo canale parla in modo estensivo di NixOS:

Sistemi simili

Esistono altre tecnologie per risolvere lo stesso problema come Ansible o Terraform, ma questi hanno uno scopo diverso: per esempio Ansible è ideato per gestire un grande numero di dispositivi e Terraform lavora più nel cloud. NixOS invece è estremamente veloce e comodo per quanto riguarda l’esperienza desktop, non è stato progettato per scalare (anche se può farlo bene). Adesso utilizzo un sistema ibrido con NixOS per gestire il sistema operativo e Ansible per eseguire comandi o rebuildare il sistema su più pc.


Conclusione

Questa è la punta dell’iceberg per quanto riguarda configurare NixOS, ma penso sia abbastanza per lasciarti approfondire o semplicemente sapere qualcosa in più su una distribuzione “particolare” di linux. Personalmente mi trovo molto bene con NixOS e ne è valsa la pena impararlo, ci vediamo nei prossimi post sempre a tema tech!

Share: Twitter Facebook
Giovanni Santini's Picture

About Giovanni Santini

Ciao! Sono Giovanni e qui voglio condividere tutto ciò che trovo di bello intorno a me e cose a cui tengo molto.

Trento, Italia