Skip to content

Blog

Dev report: Providing Type-safe interfaces between NixOS and other languages

When building a consumer-facing project on top of NixOS, one crucial question arises: How can we provide type-safe interfaces within a polyglot software stack?

This blogpost discusses one method for creating type-safe interfaces in a software stack by using JSON-schema to maintain consistent models across layers of an application.


Within the clan project, we explored one possible solution to this challenge. Our tech stack is composed of three main components:

  • Nix: Handles the core business logic.
  • Python: Acts as a thin wrapper, exposing the business logic through a convenient to use CLI and API.
  • TypeScript: Manages the presentation and GUI layer, communicating with Python via an API.

This architecture is a product of our guiding principles: We aim to encapsulate as much business logic as possible in pure Nix, ensuring that anyone familiar with Nix can utilize it.

The Challenge of Polyglot Architectures

Throughout the lifecycle of an application, architectural models, relationships, and fields are typically refined and improved over time. By this, I refer to constructs such as classes, structs, or enums.

These elements are often required across multiple layers of the application and must remain consistent to avoid discrepancies. Logically, maintaining these models in a single location is crucial to prevent discrepancies and eliminate a common source of errors — interface inconsistencies between software components.

This approach can save significant time during development cycles, particularly in dynamically typed environments like Nix and Python, where errors are often caught through extensive unit testing or at runtime when issues arise.

The Nix language presents a significant challenge due to its untyped and dynamic nature. Combined with NixOS, a constantly evolving collection of modules, it becomes incredibly difficult to build stable interfaces. As we develop more complex applications, a crucial question emerges: "How can models (such as classes or structs) be shared between multiple foreign languages and Nix?"

One potential solution is to define the model once in a chosen language and then generate the necessary code for all other languages. This approach ensures consistency and reduces the likelihood of errors caused by manual translations between languages.

Well-defined, statically typed models would provide build-time checks for correctness and prevent many issues and regressions that could have been avoided with robust interfaces.

Building on the earlier blog post about the NixOS modules to JSON-schema converter, a further exploration could involve using JSON-schema as an intermediate format. While not explicitly mentioned in the blog post, the JSON-schema converter operates solely on the interface declaration. It can also populate example values and other metadata that may become important later.

In our case, we decided to use NixOS module interface declarations as the source of truth, as all our models are Nix-first citizens. We will use JSON-schema as an interoperable format that can further be utilized to generate Python classes and TypeScript types.

For example, the desired Python code output could be a TypedDict or a dataclass. Since our input data might contain Nix attribute names that are invalid identifiers in Python, and vice versa, it is preferable to choose dataclasses. This allows us to store more metadata about the mapping relationships within the field properties.

in.nix
{lib, ...}:
let
  types = lib.types;
  option = t: lib.mkOption {
    type = t;
  };
in
{
  options = {
    submodule = option (types.submodule {
      options = {
        string = option types.str;
        list-str = option (types.listOf types.str);
        attrs-str = option (types.attrsOf types.str);
      };
    });
  };
}

With the following nix code this can be converted into python

convert.nix
let
  # Import clan-core flake
  clan-core = builtins.getFlake "git+https://git.clan.lol/clan/clan-core";
  pkgs = import clan-core.inputs.nixpkgs {};

  # Step 1: Convert NixOS module expression to JSON schema
  serialize = expr: builtins.toFile "in.json" (builtins.toJSON expr);
  schema = serialize ((clan-core.lib.jsonschema {}).parseModule ./in.nix);

  # classgenerator
  inherit (clan-core.packages.x86_64-linux) classgen;
in
{
  inherit schema;

  python-classes = pkgs.runCommand "py-cls" {}
  ''
    ${classgen}/bin/classgen ${schema} $out
  '';
}

Now execute the following:

nix build -f convert.nix python-classes

The final Python code ensures that the Python component is always in sync with the Nix code.

out.py
@dataclass
class Submodule:
    string: str
    attrs_str: dict[str, str] = field(default_factory = dict, metadata = {"alias": "attrs-str"})
    list_str: list[str] = field(default_factory = list, metadata = {"alias": "list-str"})


@dataclass
class Module:
    submodule: Submodule

However, this approach comes with some constraints for both the interface itself and the tools surrounding it:

  • All types used in the interface must be JSON-serializable (e.g., Number, String, AttrSet, List, etc.).
  • We identified certain constraints that work best for dataclasses, while also enhancing the final user experience:
    • Top-level options should specify a default value or be nullable.
    • Ideally, option identifiers should use names that don't require a "field-alias," although this might not always be feasible.
    • Neutral values for lists or dictionaries, such as an empty list or empty dictionary, must be supported.

The Python generator adds default constructors for dictionary and list types because the absence of a value would violate our type constraints.

It is also important to note that we control both the JSON schema converter and the class generator, which is crucial. This control allows us to limit their scope to a subset of JSON schema features and ensure interoperability between the two generators.

Another consideration is serialization and deserialization. In Python, Pydantic is typically a great choice, as it also offers custom serializers. However, when working with NixOS modules, we chose not to emit unset or null values because they create merge conflicts in the underlying NixOS modules. We also wanted to use field-aliases for names that are invalid identifiers in Python or TypeScript and wanted validation to catch errors early (in the deserializer) between our frontend and Nix, allowing us to present well-formatted errors instead of Nix evaluation error stack traces. Nevertheless, we ultimately did not use Pydantic because it did not meet our needs.


Catching errors

Interface violations or regressions can be detected during the development cycle at build time.

Submodule(string=1)
> Argument of type "Literal[1]" cannot be assigned to parameter "string" of type "str" in function "__init__"
  "Literal[1]" is incompatible with "str"

Since all our layers communicate through JSON interfaces, any potential runtime type errors are caught in Python during deserialization before they can trigger any Nix stack traces. This allows for errors to be neatly formatted for the consumer.

data = {"submodule": { "string": 1  } }
checked(Model, data)

>>> Traceback (most recent call last):
>>> ...
>>> Expected string, got 1

Future

By adopting this approach, we aim to provide a stable and secure interface for polyglot software stacks built on top of Nixpkgs, ultimately enhancing the reliability and maintainability of complex applications.

Additionally, we will improve the tooling and develop a library, making this methodology applicable to other projects as well.

  • https://docs.clan.lol/blog/2024/05/25/jsonschema-converter/

Introducing NixOS Facter

If you've ever installed NixOS, you'll be familiar with a little Perl script called nixos-generate-config. Unsurprisingly, it generates a couple of NixOS modules based on available hardware, mounted filesystems, configured swap, etc.

It's a critical component of the install process, aiming to ensure you have a good starting point for your NixOS system, with necessary or recommended kernel modules, file system mounts, networking config and much more.

As solutions go, it's a solid one. It has helped many users take their first steps into this rabbit hole we call NixOS. However, it does suffer from one fundamental limitation.

Static Generation

When a user generates a hardware-configuration.nix with nixos-generate-config, it makes choices based on the current state of the world as it sees it. By its very nature, then, it cannot account for changes in NixOS over time.

A recommended configuration option today might be different two NixOS releases from now.

To account for this, you could always run nixos-generate-config again. But that requires a working system, which may have broken due to the historical choices made last time, or worst-case, requiring you to fire up the installer again.

A Layer of Indirection

What if, instead of generating some Nix code, we first describe the current hardware in an intermediate format? This hardware report would be 'pure', devoid of any reference to NixOS, and intended as a stable, longer-term representation of the system.

From here, we can create a series of NixOS modules designed to examine the report's contents and make the same kinds of decisions that nixos-generate-config does. The critical difference is that as NixOS evolves, so can these modules, and with a full hardware report available we can make more interesting config choices about things such as GPUs and other devices.

In a perfect world, we should not need to regenerate the underlying report as long as there are no hardware changes. We can take this one step further.

Provided that certain sensitive information, such as serial numbers and MAC addresses, is filtered out, there is no reason why these hardware reports could not be shared after they are generated for things like EC2 instance types, specific laptop models, and so on, much like NixOS Hardware currently shares Nix configs.

Introducing NixOS Facter

Still in its early stages, NixOS Facter is intended to do what I've described above.

A user can generate a JSON-based hardware report using a (eventually static) Go program: nixos-facter -o facter.json. From there, they can include this report in their NixOS config and make use of our NixOS modules as follows:

{
    inputs = {
        nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
        nixos-facter-modules.url = "github:numtide/nixos-facter-modules";
    };

    outputs = inputs @ {
        nixpkgs,
        ...
    }: {
        nixosConfigurations.basic = nixpkgs.lib.nixosSystem {
            modules = [
                inputs.nixos-facter-modules.nixosModules.facter
                { config.facter.reportPath = ./facter.json; }
                # ...
            ];
        };
    };
}
# configuration.nix
{
    imports = [
      "${(builtins.fetchTarball {
        url = "https://github.com/numtide/nixos-facter-modules/";
      })}/modules/nixos/facter.nix"
    ];

    config.facter.reportPath = ./facter.json;
}

That's it.

We assume that users will rely on disko, so we have not implemented file system configuration yet (it's on the roadmap). In the meantime, if you don't use disko you have to specify that part of the configuration yourself or take it from nixos-generate-config.

Early Days

Please be aware that NixOS Facter is still in early development and is still subject to significant changes especially the output json format as we flesh things out. Our initial goal is to reach feature parity with nixos-generate-config.

From there, we want to continue building our NixOS modules, opening things up to the community, and beginning to capture shared hardware configurations for providers such as Hetzner, etc.

Over the coming weeks, we will also build up documentation and examples to make it easier to play with. For now, please be patient.

Side note: if you are wondering why the repo is in the Numtide org, we started partnering with Clan! Both companies are looking to make self-hosting easier and we're excited to be working together on this. Expect more tools and features to come!

Dev Report: Declarative Backups and Restore

Our goal with Clan is to give users control over their data. However, with great power comes great responsibility, and owning your data means you also need to take care of backups yourself.

In our experience, setting up automatic backups is often a tedious process as it requires custom integration of the backup software and the services that produce the state. More important than the backup is the restore. Restores are often not well tested or documented, and if not working correctly, they can render the backup useless.

In Clan, we want to make backup and restore a first-class citizen. Every service should describe what state it produces and how it can be backed up and restored.

In this article, we will discuss how our backup interface in Clan works. The interface allows different backup software to be used interchangeably and allows module authors to define custom backup and restore logic for their services.

First Comes the State

Our services are built from Clan modules. Clan modules are essentially NixOS modules, the basic configuration components of NixOS. However, we have enhanced them with additional features provided by Clan and restricted certain option types to enable configuration through a graphical interface.

In a simple case, this can be just a bunch of directories, such as what we define for our ZeroTier VPN service.

{
  clan.core.state.zerotier.folders =  [ "/var/lib/zerotier-one" ];
}

For other systems, we need more complex backup and restore logic. For each state, we can provide custom command hooks for backing up and restoring.

In our PostgreSQL module, for example, we define preBackupCommand and postRestoreCommand to use pg_dump and pg_restore to backup and restore individual databases:

preBackupCommand = ''
  # ...
  runuser -u postgres -- pg_dump ${compression} --dbname=${db.name} -Fc -c > "${current}.tmp"
  # ...
'';
postRestoreCommand = ''
  # ...
  runuser -u postgres -- dropdb "${db.name}"
  runuser -u postgres -- pg_restore -C -d postgres "${current}"
  # ...
'';

Then the Backup

Our CLI unifies the different backup providers in one interface.

As of now, we support backups using BorgBackup and a backup module called "localbackup" based on rsnapshot, optimized for backup on locally attached storage media.

To use different backup software, a module needs to set the options provided by our backup interface. The following Nix code is a toy example that uses the tar program to perform backup and restore to illustrate how the backup interface works:

  clan.core.backups.providers.tar = {
    list = ''
      echo /var/lib/system-back.tar
    '';
    create = let
      uniqueFolders = lib.unique (
        lib.flatten (lib.mapAttrsToList (_name: state: state.folders) config.clan.core.state)
      );
    in ''
      # FIXME: a proper implementation should also run `state.preBackupCommand` of each state
      if [ -f /var/lib/system-back.tar ]; then
        tar -uvpf /var/lib/system-back.tar ${builtins.toString uniqueFolders}
      else
        tar -cvpf /var/lib/system-back.tar ${builtins.toString uniqueFolders}
      fi
    '';
    restore = ''
      IFS=':' read -ra FOLDER <<< "''$FOLDERS"
      echo "${FOLDER[@]}" > /run/folders-to-restore.txt
      tar -xvpf /var/lib/system-back.tar -C / -T /run/folders-to-restore.txt
    '';
  };

For better real-world implementations, check out the implementations for BorgBackup and localbackup.

What It Looks Like to the End User

After following the guide for configuring a backup, users can use the CLI to create backups, list them, and restore them.

Backups can be created through the CLI like this:

clan backups create web01

BorgBackup will also create backups itself every day by default.

Completed backups can be listed like this:

clan backups list web01
...
web01::u366395@u366395.your-storagebox.de:/./borgbackup::web01-web01-2024-06-18T01:00:00
web03::u366395@u366395.your-storagebox.de:/./borgbackup::web01-web01-2024-06-19T01:00:00
One cool feature of our backup system is that it is aware of individual services/applications. Let's say we want to restore the state of our Matrix chat server; we can just specify it like this:

clan backups restore --service matrix-synapse web01 borgbackup web03::u366395@u366395.your-storagebox.de:/./borgbackup::web01-web01-2024-06-19T01:00:00

In this case, it will first stop the matrix-synapse systemd service, then delete the PostgreSQL database, restore the database from the backup, and then start the matrix-synapse service again.

Future work

As of now we implemented our backup and restore for a handful of services and we expect to refine the interface as we test the interface for more applications.

Currently, our backup implementation backs up filesystem state from running services. This can lead to inconsistencies if applications change the state while the backup is running. In the future, we hope to make backups more atomic by backing up a filesystem snapshot instead of normal directories. This, however, requires the use of modern filesystems that support these features.

Dev Report: Introducing the NixOS to JSON Schema Converter

Overview

We’ve developed a new library designed to extract interfaces from NixOS modules and convert them into JSON schemas, paving the way for effortless GUI generation. This blog post outlines the motivations behind this development, demonstrates the capabilities of the library, and guides you through leveraging it to create GUIs seamlessly.

Motivation

In recent months, our team has been exploring various graphical user interfaces (GUIs) to streamline NixOS machine configuration. While our opinionated Clan modules simplify NixOS configurations, there's a need to configure these modules from diverse frontends, such as:

  • Command-line interfaces (CLIs)
  • Web-based UIs
  • Desktop applications
  • Mobile applications
  • Large Language Models (LLMs)

Given this need, a universal format like JSON is a natural choice. It is already possible as of now, to import json based NixOS configurations, as illustrated below:

configuration.json:

{ "networking": { "hostName": "my-machine" } }

This configuration can be then imported inside a classic NixOS config:

{config, lib, pkgs, ...}: {
  imports = [
    (lib.importJSON ./configuration.json)
  ];
}

This straightforward approach allows us to build a frontend that generates JSON, enabling the configuration of NixOS machines. But, two critical questions arise:

  1. How does the frontend learn about existing configuration options?
  2. How can it verify user input without running Nix?

Introducing JSON schema, a widely supported standard that defines interfaces in JSON and validates input against them.

Example schema for networking.hostName:

{
  "type": "object",
  "properties": {
    "networking": {
      "type": "object",
      "properties": {
        "hostName": {
          "type": "string",
          "pattern": "^$|^[a-z0-9]([a-z0-9_-]{0,61}[a-z0-9])?$"
        }
      }
    }
  }
}

Client-Side Input Validation

Validating input against JSON schemas is both efficient and well-supported across numerous programming languages. Using JSON schema validators, you can accurately check configurations like our configuration.json.

Validation example:

$ nix-shell -p check-jsonschema
$ jsonschema -o pretty ./schema.json -i ./configuration.json
===[SUCCESS]===(./configuration.json)===

In case of invalid input, schema validators provide explicit error messages:

$ echo '{ "networking": { "hostName": "my/machine" } }' > configuration.json
$ jsonschema -o pretty ./schema.json -i ./configuration.json
===[ValidationError]===(./configuration.json)===

'my/machine' does not match '^$|^[a-z0-9]([a-z0-9_-]{0,61}[a-z0-9])?$'

Failed validating 'pattern' in schema['properties']['networking']['properties']['hostName']:
    {'pattern': '^$|^[a-z0-9]([a-z0-9_-]{0,61}[a-z0-9])?$',
     'type': 'string'}

On instance['networking']['hostName']:
    'my/machine'

Automatic GUI Generation

Certain libraries facilitate straightforward GUI generation from JSON schemas. For instance, the react-jsonschema-form playground auto-generates a form for any given schema.

NixOS Module to JSON Schema Converter

To enable the development of responsive frontends, our library allows the extraction of interfaces from NixOS modules to JSON schemas. Open-sourced for community collaboration, this library supports building sophisticated user interfaces for NixOS.

Here’s a preview of our library's functions exposed through the clan-core flake:

  • lib.jsonschema.parseModule - Generates a schema for a NixOS module.
  • lib.jsonschema.parseOption - Generates a schema for a single NixOS option.
  • lib.jsonschema.parseOptions - Generates a schema from an attrset of NixOS options.

Example: module.nix:

{lib, config, pkgs, ...}: {
  # a simple service with two options
  options.services.example-web-service = {
    enable = lib.mkEnableOption "Example web service";
    port = lib.mkOption {
      type = lib.types.int;
      description = "Port used to serve the content";
    };
  };
}

Converted, using the parseModule function:

$ cd clan-core
$ nix eval --json --impure --expr \
  '(import ./lib/jsonschema {}).parseModule ./module.nix' | jq | head
{
  "properties": {
    "services": {
      "properties": {
        "example-web-service": {
          "properties": {
            "enable": {
              "default": false,
              "description": "Whether to enable Example web service.",
              "examples": [
...

This utility can also generate interfaces for existing NixOS modules or options.

GUI for NGINX in Under a Minute

Creating a prototype GUI for the NGINX module using our library and react-jsonschema-form playground can be done quickly:

  1. Export all NGINX options into a JSON schema using a Nix expression:
# export.nix
let
  pkgs = import <nixpkgs> {};
  clan-core = builtins.getFlake "git+https://git.clan.lol/clan/clan-core";
  options = (pkgs.nixos {}).options.services.nginx;
in
  clan-core.lib.jsonschema.parseOption options
  1. Write the schema into a file:

    $ nix eval --json -f ./export.nix | jq > nginx.json
    

  2. Open the react-jsonschema-form playground, select Blank and paste the nginx.json contents.

This provides a quick look at a potential GUI (screenshot is cropped).

Image title

Limitations

Laziness

JSON schema mandates the declaration of all required fields upfront, which might be configured implicitly or remain unused. For instance, services.nginx.virtualHosts.<name>.sslCertificate must be specified even if SSL isn’t enabled.

Limited Types

Certain NixOS module types, like types.functionTo and types.package, do not map straightforwardly to JSON. For full compatibility, adjustments to NixOS modules might be necessary, such as substituting listOf package with listOf str.

Parsing NixOS Modules

Currently, our converter relies on the options attribute of evaluated NixOS modules, extracting information from the type.name attribute, which is suboptimal. Enhanced introspection capabilities within the NixOS module system would be beneficial.

Future Prospects

We hope these experiments inspire the community, encourage contributions and further development in this space. Share your ideas and contributions through our issue tracker or matrix channel!

New documentation site and weekly new meetup

Last week, we added a new documentation hub for clan at docs.clan.lol. We are still working on improving the installation procedures, so stay tuned. We now have weekly office hours where people are invited to hangout and ask questions. They are every Wednesday 15:30 UTC (17:30 CEST) in our jitsi. Otherwise drop by in our matrix channel.

Introducing Clan: Full-Stack Computing Redefined

In a digital age where users are guided increasingly toward submission and dependence, Clan reclaims computing and networking from the ground up.

Clan enables users to build any system from a git repository, automate secret handling, and join devices in a secure darknet. This control extends beyond applications to communication protocols and the operating system itself, putting you fully in charge of your own digital environment.

Why We're Building Clan

Our mission is simple: to restore fun, freedom, and functionality to computing as an open source project. We believe in building tools that empower users, foster innovation, and challenge the limitations imposed by outdated paradigms. Clan, in its essence, is an open source endeavor; it's our contribution to a future where technology serves humanity, not the other way around.

How Clan Changes the Game

Clan embodies a new philosophy in system, application, and network design. It enables seamless, secure communication across devices, simplifies software distribution and updates, and offers both public and private network configurations. Here are some of the ways it accomplishes this:

  • Nix as a Foundation: Imagine a safety net for your computer's operating system, one that lets you make changes or updates without the fear of causing a crash or losing data. Nix simplifies the complexities of system design, ensuring that updates are safe and systems are more reliable.

  • Simplified System Deployment: Building and managing a computer system, from the operating system to the software you use, often feels like putting together a complex puzzle. With Clan, the puzzle pieces are replaced by a set of building blocks. Leveraging the power of Nix and Clan's innovative toolkit, anyone from tech-savvy administrators to everyday users can create and maintain what we call "full-stack systems" (everything your computer needs to run smoothly).

  • A Leap in Connectivity: Imagine if you could create private, secure pathways between your devices, bypassing the noisy and often insecure internet. Clan makes this possible through something called "overlay networks." These networks are like private tunnels, allowing your devices to talk to each other securely and directly. With Clan's built-in overlay networks and automatically configured services, connecting your devices becomes seamless, secure, and hassle-free.

  • Security Through Separation: Clan employs sandboxing and virtual machines, a technology that runs code in isolated environments - so even if you explore new Clans, your system remains protected from potential threats.

  • Reliable: With Clan, your data and services are preserved for the long haul. We focus on self-hosted backups and integration with the Fediverse, a network of interconnected, independent online communities, so your digital life remains uninterrupted and under your control.

A Glimpse at Clan's Features

  • Social Scaling: Choose between creating a private sanctuary for your closest contacts, a dynamic space for a self-contained community, or embracing the open web with public Clans anyone can join.
  • Seamless VM Integration: Applications running in virtual machines can appear and behave as if they're part of your main operating system — a blend of power and simplicity.
  • Robust Backup Management: Keep your data safe forever - never worry about cloud services disappearing in 10 years.
  • Intuitive Secret Management: Clan simplifies digital security by automating the creation and management of encryption keys and passwords for your services.
  • Remote Install: Set up and manage Clan systems anywhere in the world with just a QR scan or SSH access, making remote installations as easy as snapping a photo or sharing a link.

Who Stands to Benefit?

Clan is for anyone and everyone who believes in the power of open source technology to connect, empower, and protect. From system administrators to less tech-savvy individuals, small business owners to privacy-conscious users, Clan offers something for everyone — a way to reclaim control and redefine how we interact with technology.

Join the Revolution

Ready to control your digital world? Clan is more than a tool—it's a movement. Secure your data, manage your systems easily, or connect with others how you like. Start with Clan for a better digital future.

Connect with us on our Matrix channel at clan.lol or through our IRC bridges (coming soon).

Want to see the code? Check it out on our Gitea or on GitHub.

Or follow our RSS feed!

Join us and be part of changing technology for the better, together.