Compare commits

..

No commits in common. "main" and "flake-update-2024-06-24" have entirely different histories.

187 changed files with 1204 additions and 3571 deletions

3
.gitignore vendored
View File

@ -3,7 +3,6 @@
out.log
.coverage.*
**/qubeclan
pkgs/repro-hook
**/testdir
democlan
example_clan
@ -36,4 +35,4 @@ repo
# node
node_modules
dist
.webui
.webui

View File

@ -1,6 +1,6 @@
# Clan core repository
# Clan Core Repository
Welcome to the Clan core repository, the heart of the [clan.lol](https://clan.lol/) project! This monorepo is the foundation of Clan, a revolutionary open-source project aimed at restoring fun, freedom, and functionality to computing. Here, you'll find all the essential packages, NixOS modules, CLI tools, and tests needed to contribute to and work with the Clan project. Clan leverages the Nix system to ensure reliability, security, and seamless management of digital environments, putting the power back into the hands of users.
Welcome to the Clan Core Repository, the heart of the [clan.lol](https://clan.lol/) project! This monorepo is the foundation of Clan, a revolutionary open-source project aimed at restoring fun, freedom, and functionality to computing. Here, you'll find all the essential packages, NixOS modules, CLI tools, and tests needed to contribute to and work with the Clan project. Clan leverages the Nix system to ensure reliability, security, and seamless management of digital environments, putting the power back into the hands of users.
## Why Clan?
@ -14,13 +14,13 @@ Our mission is simple: to democratize computing by providing tools that empower
- **Robust Backup Management:** Long-term, self-hosted data preservation.
- **Intuitive Secret Management:** Simplified encryption and password management processes.
## Getting started with Clan
## Getting Started with Clan
If you're new to Clan and eager to dive in, start with our quickstart guide and explore the core functionalities that Clan offers:
- **Quickstart Guide**: Check out [getting started](https://docs.clan.lol/#starting-with-a-new-clan-project)<!-- [docs/site/index.md](docs/site/index.md) --> to get up and running with Clan in no time.
### Managing secrets
### Managing Secrets
In the Clan ecosystem, security is paramount. Learn how to handle secrets effectively:
@ -32,11 +32,11 @@ The Clan project thrives on community contributions. We welcome everyone to cont
- **Contribution Guidelines**: Make a meaningful impact by following the steps in [contributing](https://docs.clan.lol/contributing/contributing/)<!-- [contributing.md](docs/CONTRIBUTING.md) -->.
## Join the revolution
## Join the Revolution
Clan is more than a tool; it's a movement towards a better digital future. By contributing to the Clan project, you're part of changing technology for the better, together.
### Community and support
### Community and Support
Connect with us and the Clan community for support and discussion:

View File

@ -12,7 +12,7 @@
{ lib, modulesPath, ... }:
{
imports = [
"${self}/nixosModules/disk-layouts"
self.clanModules.disk-layouts
(modulesPath + "/testing/test-instrumentation.nix") # we need these 2 modules always to be able to run the tests
(modulesPath + "/profiles/qemu-guest.nix")
];

View File

@ -1,11 +1,10 @@
Statically configure borgbackup with sane defaults.
---
description = "Statically configure borgbackup with sane defaults."
---
This module implements the `borgbackup` backend and implements sane defaults
This module implements the `borgbackup` backend and implements sane defaults
for backup management through `borgbackup` for members of the clan.
Configure target machines where the backups should be sent to through `targets`.
Configure machines that should be backuped either through `includeMachines`
which will exclusively add the included machines to be backuped, or through
which will exclusively add the included machines to be backuped, or through
`excludeMachines`, which will add every machine except the excluded machine to the backup.

View File

@ -3,7 +3,7 @@ let
clanDir = config.clan.core.clanDir;
machineDir = clanDir + "/machines/";
in
lib.warn "This module is deprecated use the service via the inventory interface instead." {
{
imports = [ ../borgbackup ];
options.clan.borgbackup-static = {

View File

@ -1,13 +1,2 @@
---
description = "Efficient, deduplicating backup program with optional compression and secure encryption."
categories = ["backup"]
---
BorgBackup (short: Borg) gives you:
- Space efficient storage of backups.
- Secure, authenticated encryption.
- Compression: lz4, zstd, zlib, lzma or none.
- Mountable backups with FUSE.
- Easy installation on multiple platforms: Linux, macOS, BSD, ...
- Free software (BSD license).
- Backed by a large and active open source community.
Efficient, deduplicating backup program with optional compression and secure encryption.
---

View File

@ -28,51 +28,7 @@ let
fi
'';
in
# Each .nix file in the roles directory is a role
# TODO: Helper function to set available roles within module meta.
# roles =
# if builtins.pathExists ./roles then
# lib.pipe ./roles [
# builtins.readDir
# (lib.filterAttrs (_n: v: v == "regular"))
# lib.attrNames
# (map (fileName: lib.removeSuffix ".nix" fileName))
# ]
# else
# null;
# TODO: make this an interface of every module
# Maybe load from readme.md
# metaInfoOption = lib.mkOption {
# readOnly = true;
# description = ''
# Meta is used to retrieve information about this module.
# - `availableRoles` is a list of roles that can be assigned via the inventory.
# - `category` is used to group services in the clan marketplace.
# - `description` is a short description of the service for the clan marketplace.
# '';
# default = {
# description = "Borgbackup is a backup program. Optionally, it supports compression and authenticated encryption.";
# availableRoles = roles;
# category = "backup";
# };
# type = lib.types.submodule {
# options = {
# description = lib.mkOption { type = lib.types.str; };
# availableRoles = lib.mkOption { type = lib.types.nullOr (lib.types.listOf lib.types.str); };
# category = lib.mkOption {
# description = "A category for the service. This is used to group services in the clan ui";
# type = lib.types.enum [
# "backup"
# "network"
# ];
# };
# };
# };
# };
{
# options.clan.borgbackup.meta = metaInfoOption;
options.clan.borgbackup.destinations = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule (
@ -120,7 +76,7 @@ in
lib.nameValuePair "borgbackup-job-${dest.name}" {
# since borgbackup mounts the system read-only, we need to run in a ExecStartPre script, so we can generate additional files.
serviceConfig.ExecStartPre = [
''+${pkgs.writeShellScript "borgbackup-job-${dest.name}-pre-backup-commands" preBackupScript}''
(''+${pkgs.writeShellScript "borgbackup-job-${dest.name}-pre-backup-commands" preBackupScript}'')
];
}
) cfg.destinations;

View File

@ -1,30 +0,0 @@
{ config, lib, ... }:
let
instances = config.clan.inventory.services.borgbackup;
# roles = { ${role_name} :: { machines :: [string] } }
allServers = lib.foldlAttrs (
acc: _instanceName: instanceConfig:
acc
++ (
if builtins.elem machineName instanceConfig.roles.client.machines then
instanceConfig.roles.server.machines
else
[ ]
)
) [ ] instances;
inherit (config.clan.core) machineName;
in
{
config.clan.borgbackup.destinations =
let
destinations = builtins.map (serverName: {
name = serverName;
value = {
repo = "borg@${serverName}:/var/lib/borgbackup/${machineName}";
};
}) allServers;
in
(builtins.listToAttrs destinations);
}

View File

@ -1,45 +0,0 @@
{ config, lib, ... }:
let
clanDir = config.clan.core.clanDir;
machineDir = clanDir + "/machines/";
inherit (config.clan.core) machineName;
instances = config.clan.inventory.services.borgbackup;
# roles = { ${role_name} :: { machines :: [string] } }
allClients = lib.foldlAttrs (
acc: _instanceName: instanceConfig:
acc
++ (
if (builtins.elem machineName instanceConfig.roles.server.machines) then
instanceConfig.roles.client.machines
else
[ ]
)
) [ ] instances;
in
{
config.services.borgbackup.repos =
let
borgbackupIpMachinePath = machines: machineDir + machines + "/facts/borgbackup.ssh.pub";
machinesMaybeKey = builtins.map (
machine:
let
fullPath = borgbackupIpMachinePath machine;
in
if builtins.pathExists fullPath then machine else null
) allClients;
machinesWithKey = lib.filter (x: x != null) machinesMaybeKey;
hosts = builtins.map (machine: {
name = machine;
value = {
path = "/var/lib/borgbackup/${machine}";
authorizedKeys = [ (builtins.readFile (borgbackupIpMachinePath machine)) ];
};
}) machinesWithKey;
in
if (builtins.listToAttrs hosts) != [ ] then builtins.listToAttrs hosts else { };
}

View File

@ -1,5 +1,4 @@
---
description = "Email-based instant messaging for Desktop."
Email-based instant messaging for Desktop.
---
!!! warning "Under construction"

View File

@ -0,0 +1,2 @@
Automatically format a disk drive on clan installation
---

View File

@ -1,3 +1,2 @@
---
description = "A modern IRC server"
A modern IRC server
---

View File

@ -1,16 +1,17 @@
{ ... }:
{
flake.clanModules = {
disk-layouts = {
imports = [ ./disk-layouts ];
};
borgbackup = ./borgbackup;
borgbackup-static = ./borgbackup-static;
deltachat = ./deltachat;
ergochat = ./ergochat;
localbackup = ./localbackup;
localsend = ./localsend;
single-disk = ./single-disk;
matrix-synapse = ./matrix-synapse;
moonlight = ./moonlight;
packages = ./packages;
postgresql = ./postgresql;
root-password = ./root-password;
sshd = ./sshd;

View File

@ -1,3 +1,2 @@
---
description = "Automatically backups current machine to local directory."
Automatically backups current machine to local directory.
---

View File

@ -1,3 +1,2 @@
---
description = "Securely sharing files and messages over a local network without internet connectivity."
Securely sharing files and messages over a local network without internet connectivity.
---

View File

@ -1,3 +1,2 @@
---
description = "A federated messaging server with end-to-end encryption."
A federated messaging server with end-to-end encryption.
---

View File

@ -1,3 +1,2 @@
---
description = "A desktop streaming client optimized for remote gaming and synchronized movie viewing."
A desktop streaming client optimized for remote gaming and synchronized movie viewing.
---

View File

@ -1,4 +0,0 @@
---
description = "Define package sets from nixpkgs and install them on one or more machines"
categories = ["packages"]
---

View File

@ -1,19 +0,0 @@
{
config,
lib,
pkgs,
...
}:
{
options.clan.packages = {
packages = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "The packages to install on the machine";
};
};
config = {
environment.systemPackages = map (
pName: lib.getAttrFromPath (lib.splitString "." pName) pkgs
) config.clan.packages.packages;
};
}

View File

@ -1 +0,0 @@
{ }

View File

@ -1,3 +1,2 @@
---
description = "A free and open-source relational database management system (RDBMS) emphasizing extensibility and SQL compliance."
A free and open-source relational database management system (RDBMS) emphasizing extensibility and SQL compliance.
---

View File

@ -91,7 +91,6 @@ in
options.clan.postgresql = {
# we are reimplemeting ensureDatabase and ensureUser options here to allow to create databases with options
databases = lib.mkOption {
description = "Databases to create";
default = { };
type = lib.types.attrsOf (
lib.types.submodule (
@ -115,7 +114,6 @@ in
description = "Create the database if it does not exist.";
};
create.options = lib.mkOption {
description = "Options to pass to the CREATE DATABASE command.";
type = lib.types.lazyAttrsOf lib.types.str;
default = { };
example = {
@ -137,14 +135,12 @@ in
);
};
users = lib.mkOption {
description = "Users to create";
default = { };
type = lib.types.attrsOf (
lib.types.submodule (
{ name, ... }:
{
options.name = lib.mkOption {
description = "User name";
type = lib.types.str;
default = name;
};

View File

@ -1,5 +1,4 @@
---
description = "Automatically generates and configures a password for the root user."
Automatically generates and configures a password for the root user.
---
After the system was installed/deployed the following command can be used to display the root-password:

View File

@ -13,8 +13,8 @@
mkpasswd
];
generator.script = ''
xkcdpass --numwords 3 --delimiter - --count 1 | tr -d "\n" > $secrets/password
cat $secrets/password | mkpasswd -s -m sha-512 | tr -d "\n" > $secrets/password-hash
xkcdpass --numwords 3 --delimiter - --count 1 > $secrets/password
cat $secrets/password | mkpasswd -s -m sha-512 > $secrets/password-hash
'';
};
}

View File

@ -1,42 +0,0 @@
---
description = "Configures partitioning of the main disk"
categories = ["disk-layout"]
---
# Primary Disk Layout
A module for the "disk-layout" category MUST be choosen.
There is exactly one slot for this type of module in the UI, if you don't fill the slot, your machine cannot boot
This module is a good choice for most machines. In the future clan will offer a broader choice of disk-layouts
The UI will ask for the options of this module:
`device: "/dev/null"`
# Usage example
`inventory.json`
```json
"services": {
"single-disk": {
"default": {
"meta": {
"name": "single-disk"
},
"roles": {
"default": {
"machines": ["jon"]
}
},
"machines": {
"jon": {
"config": {
"device": "/dev/null"
}
}
}
}
}
}
```

View File

@ -1,52 +0,0 @@
{ lib, config, ... }:
{
options.clan.single-disk = {
device = lib.mkOption {
type = lib.types.str;
description = "The primary disk device to install the system on";
# Question: should we set a default here?
# default = "/dev/null";
};
};
config = {
boot.loader.grub.efiSupport = lib.mkDefault true;
boot.loader.grub.efiInstallAsRemovable = lib.mkDefault true;
disko.devices = {
disk = {
main = {
type = "disk";
# This is set through the UI
device = config.clan.single-disk.device;
content = {
type = "gpt";
partitions = {
boot = {
size = "1M";
type = "EF02"; # for grub MBR
priority = 1;
};
ESP = {
size = "512M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
};
};
root = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
};
};
};
}

View File

@ -1 +0,0 @@
{ }

View File

@ -1,3 +1,2 @@
---
description = "Enables secure remote access to the machine over ssh"
Enables secure remote access to the machine over ssh
---

View File

@ -2,10 +2,6 @@
{
services.openssh.enable = true;
services.openssh.settings.PasswordAuthentication = false;
# We might want to remove this once, openssh is fixed everywhere:
# Workaround for CVE-2024-6387
# https://github.com/NixOS/nixpkgs/pull/323753#issuecomment-2199762128
services.openssh.settings.LoginGraceTime = 0;
services.openssh.hostKeys = [
{

View File

@ -1,3 +1,2 @@
---
description = "Statically configure the host names of machines based on their respective zerotier-ip."
Statically configure the host names of machines based on their respective zerotier-ip.
---

View File

@ -1,3 +1,2 @@
---
description = "A desktop streaming server optimized for remote gaming and synchronized movie viewing."
A desktop streaming server optimized for remote gaming and synchronized movie viewing.
---

View File

@ -1,3 +1,2 @@
---
description = "Statically configure syncthing peers through clan"
Statically configure syncthing peers through clan
---

View File

@ -1,5 +1,4 @@
---
description = "A secure, file synchronization app for devices over networks, offering a private alternative to cloud services."
A secure, file synchronization app for devices over networks, offering a private alternative to cloud services.
---
## Usage

View File

@ -7,10 +7,6 @@
{
options.clan.syncthing = {
id = lib.mkOption {
description = ''
The ID of the machine.
It is generated automatically by default.
'';
type = lib.types.nullOr lib.types.str;
example = "BABNJY4-G2ICDLF-QQEG7DD-N3OBNGF-BCCOFK6-MV3K7QJ-2WUZHXS-7DTW4AS";
default = config.clan.core.facts.services.syncthing.public."syncthing.pub".value or null;

View File

@ -1,3 +1,2 @@
---
description = "Modern web IRC client"
Modern web IRC client
---

View File

@ -1,3 +1,2 @@
---
description = "This module sets the `clan.lol` and `nix-community` cache up as a trusted cache."
This module sets the `clan.lol` and `nix-community` cache up as a trusted cache.
----

View File

@ -1,5 +1,4 @@
---
description = "Automatically generates and configures a password for the specified user account."
Automatically generates and configures a password for the specified user account.
---
If setting the option prompt to true, the user will be prompted to type in their desired password.

View File

@ -37,12 +37,12 @@
mkpasswd
];
generator.script = ''
if [[ -n ''${prompt_value-} ]]; then
echo $prompt_value | tr -d "\n" > $secrets/user-password
if [[ -n $prompt_value ]]; then
echo $prompt_value > $secrets/user-password
else
xkcdpass --numwords 3 --delimiter - --count 1 | tr -d "\n" > $secrets/user-password
xkcdpass --numwords 3 --delimiter - --count 1 > $secrets/user-password
fi
cat $secrets/user-password | mkpasswd -s -m sha-512 | tr -d "\n" > $secrets/user-password-hash
cat $secrets/user-password | mkpasswd -s -m sha-512 > $secrets/user-password-hash
'';
};
};

View File

@ -1,3 +1,2 @@
---
description = "A lightweight desktop manager"
A lightweight desktop manager
---

View File

@ -1,5 +1,4 @@
---
description = "Statically configure the `zerotier` peers of a clan network."
Statically configure the `zerotier` peers of a clan network.
---
Statically configure the `zerotier` peers of a clan network.

View File

@ -2,6 +2,7 @@
lib,
config,
pkgs,
inputs,
...
}:
let
@ -60,11 +61,13 @@ in
lib.mkIf (config.clan.networking.zerotier.controller.enable) {
wantedBy = [ "multi-user.target" ];
after = [ "zerotierone.service" ];
path = [ config.clan.core.clanPkgs.zerotierone ];
path = [ pkgs.zerotierone ];
serviceConfig.ExecStart = pkgs.writeScript "static-zerotier-peers-autoaccept" ''
#!/bin/sh
${lib.concatMapStringsSep "\n" (host: ''
${config.clan.core.clanPkgs.zerotier-members}/bin/zerotier-members allow --member-ip ${host}
${
inputs.clan-core.packages.${pkgs.system}.zerotier-members
}/bin/zerotier-members allow --member-ip ${host}
'') hosts}
'';
};

View File

@ -1,3 +1,2 @@
---
description = "Enable ZeroTier VPN over TCP for networks where UDP is blocked."
Enable ZeroTier VPN over TCP for networks where UDP is blocked.
---

View File

@ -26,7 +26,6 @@
devShells.default = pkgs.mkShell {
packages = [
select-shell
pkgs.nix-unit
pkgs.tea
# Better error messages than nix 2.18
pkgs.nixVersions.latest

View File

@ -44,14 +44,6 @@ Let's get your development environment up and running:
```bash
git remote add upstream gitea@git.clan.lol:clan/clan-core.git
```
5. **Create an access token**:
- Log in to Gitea.
- Go to your account settings.
- Navigate to the Applications section.
- Click Generate New Token.
- Name your token and select all available scopes.
- Generate the token and copy it for later use.
- Your access token is now ready to use with all permissions.
5. **Register Your Gitea Account Locally**:
@ -62,8 +54,9 @@ Let's get your development environment up and running:
- Fill out the prompt as follows:
- URL of Gitea instance: `https://git.clan.lol`
- Name of new Login [git.clan.lol]:
- Do you have an access token? Yes
- Token: <yourtoken>
- Do you have an access token? No
- Username: YourUsername
- Password: YourPassword
- Set Optional settings: No

View File

@ -49,20 +49,19 @@ nav:
- Mesh VPN: getting-started/mesh-vpn.md
- Backup & Restore: getting-started/backups.md
- Flake-parts: getting-started/flake-parts.md
- Reference:
- Modules:
- Clan Modules:
- reference/clanModules/borgbackup-static.md
- reference/clanModules/borgbackup.md
- reference/clanModules/deltachat.md
- reference/clanModules/disk-layouts.md
- reference/clanModules/ergochat.md
- reference/clanModules/localbackup.md
- reference/clanModules/localsend.md
- reference/clanModules/matrix-synapse.md
- reference/clanModules/moonlight.md
- reference/clanModules/packages.md
- reference/clanModules/postgresql.md
- reference/clanModules/root-password.md
- reference/clanModules/single-disk.md
- reference/clanModules/sshd.md
- reference/clanModules/static-hosts.md
- reference/clanModules/sunshine.md
@ -75,18 +74,17 @@ nav:
- reference/clanModules/zerotier-static-peers.md
- reference/clanModules/zt-tcp-relay.md
- CLI:
- reference/cli/index.md
- reference/cli/backups.md
- reference/cli/config.md
- reference/cli/facts.md
- reference/cli/flakes.md
- reference/cli/flash.md
- reference/cli/history.md
- reference/cli/index.md
- reference/cli/machines.md
- reference/cli/secrets.md
- reference/cli/show.md
- reference/cli/ssh.md
- reference/cli/state.md
- reference/cli/vms.md
- Clan Core:
- reference/clan-core/index.md

View File

@ -12,14 +12,13 @@
# { clanCore = «derivation JSON»; clanModules = { ${name} = «derivation JSON» }; }
jsonDocs = import ./get-module-docs.nix {
inherit (inputs) nixpkgs;
inherit pkgs;
inherit pkgs self;
inherit (self.nixosModules) clanCore;
inherit (self) clanModules;
};
clanModulesFileInfo = pkgs.writeText "info.json" (builtins.toJSON jsonDocs.clanModules);
# clanModulesReadmes = pkgs.writeText "info.json" (builtins.toJSON jsonDocs.clanModulesReadmes);
# clanModulesMeta = pkgs.writeText "info.json" (builtins.toJSON jsonDocs.clanModulesMeta);
clanModulesReadmes = pkgs.writeText "info.json" (builtins.toJSON jsonDocs.clanModulesReadmes);
# Simply evaluated options (JSON)
renderOptions =
@ -30,7 +29,6 @@
nativeBuildInputs = [
pkgs.python3
pkgs.mypy
self'.packages.clan-cli
];
}
''
@ -51,35 +49,24 @@
sha256 = "sha256-GZMeZFFGvP5GMqqh516mjJKfQaiJ6bL38bSYOXkaohc=";
};
module-docs =
pkgs.runCommand "rendered"
{
nativeBuildInputs = [
pkgs.python3
self'.packages.clan-cli
];
}
''
export CLAN_CORE_PATH=${self}
export CLAN_CORE_DOCS=${jsonDocs.clanCore}/share/doc/nixos/options.json
# A file that contains the links to all clanModule docs
export CLAN_MODULES=${clanModulesFileInfo}
module-docs = pkgs.runCommand "rendered" { nativeBuildInputs = [ pkgs.python3 ]; } ''
export CLAN_CORE=${jsonDocs.clanCore}/share/doc/nixos/options.json
# A file that contains the links to all clanModule docs
export CLAN_MODULES=${clanModulesFileInfo}
export CLAN_MODULES_READMES=${clanModulesReadmes}
mkdir $out
mkdir $out
# The python script will place mkDocs files in the output directory
python3 ${renderOptions}
'';
# The python script will place mkDocs files in the output directory
python3 ${renderOptions}
'';
in
{
devShells.docs = pkgs.callPackage ./shell.nix {
inherit (self'.packages) docs clan-cli-docs;
inherit
asciinema-player-js
asciinema-player-css
module-docs
self'
;
inherit module-docs;
inherit asciinema-player-js;
inherit asciinema-player-css;
};
packages = {
docs = pkgs.python3.pkgs.callPackage ./default.nix {

View File

@ -3,6 +3,7 @@
pkgs,
clanCore,
clanModules,
self,
}:
let
allNixosModules = (import "${nixpkgs}/nixos/modules/module-list.nix") ++ [
@ -30,7 +31,7 @@ let
options:
pkgs.nixosOptionsDoc {
options = options;
warningsAreErrors = true;
warningsAreErrors = false;
};
# clanModules docs
@ -38,10 +39,15 @@ let
name: module: (evalDocs ((getOptionsWithoutCore [ module ]).clan.${name} or { })).optionsJSON
) clanModules;
clanModulesReadmes = builtins.mapAttrs (
module_name: _module: self.lib.modules.getReadme module_name
) clanModules;
# clanCore docs
clanCoreDocs = (evalDocs (getOptions [ ]).clan.core).optionsJSON;
in
{
inherit clanModulesReadmes;
clanCore = clanCoreDocs;
clanModules = clanModulesDocs;
}

View File

@ -28,12 +28,10 @@ import os
from pathlib import Path
from typing import Any
from clan_cli.api.modules import Frontmatter, extract_frontmatter, get_roles
# Get environment variables
CLAN_CORE_PATH = os.getenv("CLAN_CORE_PATH")
CLAN_CORE_DOCS = os.getenv("CLAN_CORE_DOCS")
CLAN_CORE = os.getenv("CLAN_CORE")
CLAN_MODULES = os.environ.get("CLAN_MODULES")
CLAN_MODULES_READMES = os.environ.get("CLAN_MODULES_READMES")
OUT = os.environ.get("out")
@ -78,9 +76,7 @@ def render_option(name: str, option: dict[str, Any], level: int = 3) -> str:
res = f"""
{"#" * level} {sanitize(name)}
{"**Readonly**" if read_only else ""}
{"Readonly" if read_only else ""}
{option.get("description", "No description available.")}
**Type**: `{option["type"]}`
@ -113,19 +109,18 @@ def render_option(name: str, option: dict[str, Any], level: int = 3) -> str:
"""
decls = option.get("declarations", [])
if decls:
source_path, name = replace_store_path(decls[0])
print(source_path, name)
res += f"""
source_path, name = replace_store_path(decls[0])
print(source_path, name)
res += f"""
:simple-git: [{name}]({source_path})
"""
res += "\n"
res += "\n"
return res
def module_header(module_name: str) -> str:
return f"# {module_name}\n\n"
return f"# {module_name}\n"
def module_usage(module_name: str) -> str:
@ -151,9 +146,9 @@ options_head = "\n## Module Options\n"
def produce_clan_core_docs() -> None:
if not CLAN_CORE_DOCS:
if not CLAN_CORE:
raise ValueError(
f"Environment variables are not set correctly: $CLAN_CORE_DOCS={CLAN_CORE_DOCS}"
f"Environment variables are not set correctly: $CLAN_CORE={CLAN_CORE}"
)
if not OUT:
@ -161,7 +156,7 @@ def produce_clan_core_docs() -> None:
# A mapping of output file to content
core_outputs: dict[str, str] = {}
with open(CLAN_CORE_DOCS) as f:
with open(CLAN_CORE) as f:
options: dict[str, dict[str, Any]] = json.load(f)
module_name = "clan-core"
for option_name, info in options.items():
@ -193,42 +188,14 @@ def produce_clan_core_docs() -> None:
of.write(output)
def render_roles(roles: list[str] | None, module_name: str) -> str:
if roles:
roles_list = "\n".join([f" - `{r}`" for r in roles])
return f"""
???+ tip "Inventory usage"
Predefined roles:
{roles_list}
Usage:
```{{.nix hl_lines="4"}}
buildClan {{
inventory.services = {{
{module_name}.instance_1 = {{
roles.{roles[0]}.machines = [ "sara_machine" ];
# ...
}};
}};
}}
```
"""
return ""
def produce_clan_modules_docs() -> None:
if not CLAN_MODULES:
raise ValueError(
f"Environment variables are not set correctly: $CLAN_MODULES={CLAN_MODULES}"
)
if not CLAN_CORE_PATH:
if not CLAN_MODULES_READMES:
raise ValueError(
f"Environment variables are not set correctly: $CLAN_CORE_PATH={CLAN_CORE_PATH}"
f"Environment variables are not set correctly: $CLAN_MODULES_READMES={CLAN_MODULES_READMES}"
)
if not OUT:
@ -237,36 +204,18 @@ def produce_clan_modules_docs() -> None:
with open(CLAN_MODULES) as f:
links: dict[str, str] = json.load(f)
# with open(CLAN_MODULES_READMES) as readme:
# readme_map: dict[str, str] = json.load(readme)
# with open(CLAN_MODULES_META) as f:
# meta_map: dict[str, Any] = json.load(f)
# print(meta_map)
with open(CLAN_MODULES_READMES) as readme:
readme_map: dict[str, str] = json.load(readme)
# {'borgbackup': '/nix/store/hi17dwgy7963ddd4ijh81fv0c9sbh8sw-options.json', ... }
for module_name, options_file in links.items():
readme_file = Path(CLAN_CORE_PATH) / "clanModules" / module_name / "README.md"
print(module_name, readme_file)
with open(readme_file) as f:
readme = f.read()
frontmatter: Frontmatter
frontmatter, readme_content = extract_frontmatter(readme, str(readme_file))
print(frontmatter, readme_content)
with open(Path(options_file) / "share/doc/nixos/options.json") as f:
options: dict[str, dict[str, Any]] = json.load(f)
print(f"Rendering options for {module_name}...")
output = module_header(module_name)
if frontmatter.description:
output += f"**{frontmatter.description}**\n\n"
output += f"{readme_content}\n"
# get_roles(str) -> list[str] | None
roles = get_roles(str(Path(CLAN_CORE_PATH) / "clanModules" / module_name))
if roles:
output += render_roles(roles, module_name)
if readme_map.get(module_name, None):
output += f"{readme_map[module_name]}\n"
output += module_usage(module_name)

View File

@ -7,14 +7,10 @@
asciinema-player-css,
roboto,
fira-code,
self',
...
}:
pkgs.mkShell {
inputsFrom = [
docs
self'.devShells.default
];
inputsFrom = [ docs ];
shellHook = ''
mkdir -p ./site/reference/cli
cp -af ${module-docs}/* ./site/reference/

View File

@ -1,132 +0,0 @@
---
title: "Dev Report: Declarative Backups and Restore"
description: "An extension to the NixOS module system to declaratively describe how application state is backed up and restored."
authors:
- Mic92
date: 2024-06-24
slug: backups
---
Our goal with [Clan](https://clan.lol/) 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](https://wiki.nixos.org/wiki/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](https://docs.clan.lol/blog/2024/05/25/jsonschema-converter/).
In a simple case, this can be just a bunch of directories, such as what we define for our [ZeroTier](https://www.zerotier.com/) VPN service.
```nix
{
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:
```nix
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](https://docs.clan.lol/reference/cli/backups/).
As of now, we support backups using [BorgBackup](https://www.borgbackup.org/) and
a backup module called "localbackup" based on [rsnapshot](https://rsnapshot.org/), 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:
```nix
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](https://git.clan.lol/clan/clan-core/src/branch/main/clanModules/borgbackup/default.nix)
and [localbackup](https://git.clan.lol/clan/clan-core/src/branch/main/clanModules/localbackup/default.nix).
## What It Looks Like to the End User
After following the guide for configuring a [backup](https://docs.clan.lol/getting-started/backups/),
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](https://matrix.org/) 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](https://www.postgresql.org/) 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.

View File

@ -4,14 +4,14 @@
In the `flake.nix` file:
- [x] set a unique `name`.
- [x] set a unique `clanName`.
=== "**buildClan**"
```nix title="clan-core.lib.buildClan"
buildClan {
# Set a unique name
meta.name = "Lobsters";
clanName = "Lobsters";
# Should usually point to the directory of flake.nix
directory = ./.;
@ -31,7 +31,7 @@ In the `flake.nix` file:
```nix title="clan-core.flakeModules.default"
clan = {
# Set a unique name
meta.name = "Lobsters";
clanName = "Lobsters";
machines = {
jon = {

View File

@ -16,7 +16,7 @@ inputs = {
# New flake-parts input
flake-parts.url = "github:hercules-ci/flake-parts";
flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs";
clan-core = {
url = "git+https://git.clan.lol/clan/clan-core";
inputs.nixpkgs.follows = "nixpkgs"; # Needed if your configuration uses nixpkgs unstable.
@ -35,7 +35,7 @@ After updating your flake inputs, the next step is to import the `clan-core` fla
inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } (
{
#
#
imports = [
inputs.clan-core.flakeModules.default
];
@ -63,7 +63,7 @@ Below is a guide on how to structure this in your flake.nix:
# Define your clan
clan = {
# Clan wide settings. (Required)
meta.name = ""; # Ensure to choose a unique name.
clanName = ""; # Ensure to choose a unique name.
machines = {
jon = {
@ -84,7 +84,7 @@ Below is a guide on how to structure this in your flake.nix:
# There needs to be exactly one controller per clan
clan.networking.zerotier.controller.enable = true;
};
};
};

View File

@ -40,11 +40,24 @@
"type": "github"
}
},
"nixlib": {
"locked": {
"lastModified": 1712450863,
"narHash": "sha256-K6IkdtMtq9xktmYPj0uaYc8NsIqHuaAoRBaMgu9Fvrw=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "3c62b6a12571c9a7f65ab037173ee153d539905f",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"nixos-generators": {
"inputs": {
"nixlib": [
"nixpkgs"
],
"nixlib": "nixlib",
"nixpkgs": [
"nixpkgs"
]
@ -86,11 +99,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1719451888,
"narHash": "sha256-Ky0sgEEJMcBmNEJztY6KcVn+6bq74EKM7pd1CR1wnPQ=",
"lastModified": 1719146883,
"narHash": "sha256-DAyIfQgyqalov0DcEKRvDOUin7axELasaP6NCPt7UQA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3664857c48feacb35770c00abfdc671e55849be5",
"rev": "084f8df2f3ff80cdec6f515931036f63c5d2f36c",
"type": "github"
},
"original": {

View File

@ -15,7 +15,6 @@
sops-nix.inputs.nixpkgs-stable.follows = "";
nixos-generators.url = "github:nix-community/nixos-generators";
nixos-generators.inputs.nixpkgs.follows = "nixpkgs";
nixos-generators.inputs.nixlib.follows = "nixpkgs";
nixos-images.url = "github:nix-community/nixos-images";
nixos-images.inputs.nixos-unstable.follows = "nixpkgs";
# unused input
@ -32,7 +31,7 @@
{ ... }:
{
clan = {
meta.name = "clan-core";
# meta.name = "clan-core";
directory = self;
};
systems = [
@ -52,9 +51,10 @@
./formatter.nix
./lib/flake-module.nix
./nixosModules/flake-module.nix
./nixosModules/clanCore/vars/flake-module.nix
./pkgs/flake-module.nix
./templates/flake-module.nix
./inventory/flake-module.nix
];
}
);

View File

@ -13,6 +13,7 @@ let
inherit lib clan-core;
inherit (inputs) nixpkgs;
};
cfg = config.clan;
in
{
@ -90,11 +91,6 @@ in
clanInternals = lib.mkOption {
type = lib.types.submodule {
options = {
inventory = lib.mkOption { type = lib.types.attrsOf lib.types.unspecified; };
inventoryFile = lib.mkOption { type = lib.types.unspecified; };
clanModules = lib.mkOption { type = lib.types.attrsOf lib.types.path; };
source = lib.mkOption { type = lib.types.path; };
meta = lib.mkOption { type = lib.types.attrsOf lib.types.unspecified; };
all-machines-json = lib.mkOption { type = lib.types.attrsOf lib.types.unspecified; };
machines = lib.mkOption { type = lib.types.attrsOf (lib.types.attrsOf lib.types.unspecified); };

View File

@ -45,53 +45,5 @@
];
includes = [ "*.py" ];
};
# FIXME: currently broken in CI
#treefmt.settings.formatter.vale =
# let
# vocab = "cLAN";
# style = "Docs";
# config = pkgs.writeText "vale.ini" ''
# StylesPath = ${styles}
# Vocab = ${vocab}
# [*.md]
# BasedOnStyles = Vale, ${style}
# Vale.Terms = No
# '';
# styles = pkgs.symlinkJoin {
# name = "vale-style";
# paths = [
# accept
# headings
# ];
# };
# accept = pkgs.writeTextDir "config/vocabularies/${vocab}/accept.txt" ''
# Nix
# NixOS
# Nixpkgs
# clan.lol
# Clan
# monorepo
# '';
# headings = pkgs.writeTextDir "${style}/headings.yml" ''
# extends: capitalization
# message: "'%s' should be in sentence case"
# level: error
# scope: heading
# # $title, $sentence, $lower, $upper, or a pattern.
# match: $sentence
# '';
# in
# {
# command = "${pkgs.vale}/bin/vale";
# options = [ "--config=${config}" ];
# includes = [ "*.md" ];
# # TODO: too much at once, fix piecemeal
# excludes = [
# "docs/*"
# "clanModules/*"
# "pkgs/*"
# ];
# };
};
}

View File

@ -1,74 +0,0 @@
{
"machines": {
"minimal-inventory-machine": {
"name": "foo",
"system": "x86_64-linux",
"description": "A nice thing",
"icon": "./path/to/icon.png",
"tags": ["1", "2", "3"]
}
},
"services": {
"packages": {
"editors": {
"meta": {
"name": "Some editor packages"
},
"roles": {
"default": {
"machines": ["minimal-inventory-machine"]
}
},
"machines": {
"minimal-inventory-machine": {
"config": {
"packages": ["zed-editor"]
}
}
},
"config": {
"packages": ["vim"]
}
},
"browsing": {
"meta": {
"name": "Web browsing packages"
},
"roles": {
"default": {
"machines": ["minimal-inventory-machine"]
}
},
"machines": {
"minimal-inventory-machine": {
"config": {
"packages": ["chromium"]
}
}
},
"config": {
"packages": ["firefox"]
}
}
},
"single-disk": {
"default": {
"meta": {
"name": "single-disk"
},
"roles": {
"default": {
"machines": ["minimal-inventory-machine"]
}
},
"machines": {
"minimal-inventory-machine": {
"config": {
"device": "/dev/null"
}
}
}
}
}
}
}

57
inventory/README.md Normal file
View File

@ -0,0 +1,57 @@
# Inventory
This part provides a specification for the inventory.
It is used for design phase and as validation helper.
> Cue is less verbose and easier to understand and maintain than json-schema.
> Json-schema, if needed can be easily generated on-the fly.
## Checking validity
Directly check a json against the schema
`cue vet inventory.json root.cue -d '#Root'`
## Json schema
Export the json-schema i.e. for usage in python / javascript / nix
`cue export --out openapi root.cue`
## Usage
Comments are rendered as descriptions in the json schema.
```cue
// A name of the clan (primarily shown by the UI)
name: string
```
Cue open sets. In the following `foo = {...}` means that the key `foo` can contain any arbitrary json object.
```cue
foo: { ... }
```
Cue dynamic keys.
```cue
[string]: {
attr: string
}
```
This is the schema of
```json
{
"a": {
"attr": "foo"
},
"b": {
"attr": "bar"
}
// ... Indefinitely more dynamic keys of type "string"
}
```

137
inventory/example_flake.nix Normal file
View File

@ -0,0 +1,137 @@
{
description = "<Put your description here>";
inputs.clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
outputs =
{ clan-core, ... }:
let
pkgs = clan-core.inputs.nixpkgs.legacyPackages.${system};
system = "x86_64-linux";
in
# Usage see: https://docs.clan.lol
# nice_flake_interface -> buildInventory() -> Inventory -> buildClanFromInventory() -> nixosConfigurations
# buildClanFromInventory = inventory: evalModules {
# extraAttrs = { inherit inventory; };
# # (attrNames inventory.machines)
# };
# clan =
# clan-core.lib.buildClanFromInventory [
# # Inventory 0 (loads the json file managed by the Python API)
# (builtins.fromJSON (builtins.readFile ./inventory.json))
# # ->
# # {
# # services."backups_1".autoIncludeMachines = true;
# # services."backups_1".module = "borgbackup";
# # ... etc.
# # }
# ]
# ++ (buildInventory {
# clanName = "nice_flake_interface";
# description = "A nice flake interface";
# icon = "assets/icon.png";
# machines = {
# jon = {
# # Just regular nixos/clan configuration ?
# # config = {
# # imports = [
# # ./modules/shared.nix
# # ./machines/jon/configuration.nix
# # ];
# # nixpkgs.hostPlatform = system;
# # # Set this for clan commands use ssh i.e. `clan machines update`
# # # If you change the hostname, you need to update this line to root@<new-hostname>
# # # This only works however if you have avahi running on your admin machine else use IP
# # clan.networking.targetHost = pkgs.lib.mkDefault "root@jon";
# # # ssh root@flash-installer.local lsblk --output NAME,ID-LINK,FSTYPE,SIZE,MOUNTPOINT
# # disko.devices.disk.main = {
# # device = "/dev/disk/by-id/__CHANGE_ME__";
# # };
# # # IMPORTANT! Add your SSH key here
# # # e.g. > cat ~/.ssh/id_ed25519.pub
# # users.users.root.openssh.authorizedKeys.keys = throw ''
# # Don't forget to add your SSH key here!
# # users.users.root.openssh.authorizedKeys.keys = [ "<YOUR SSH_KEY>" ]
# # '';
# # # Zerotier needs one controller to accept new nodes. Once accepted
# # # the controller can be offline and routing still works.
# # clan.networking.zerotier.controller.enable = true;
# # };
# };
# };
# })
# ++ [
# # Low level inventory overrides (comes at the end)
# {
# services."backups_2".autoIncludeMachines = true;
# services."backups_2".module = "borgbackup";
# }
# ];
# # buildClan :: [ Partial<Inventory> ] -> Inventory
# # foldl' (acc: v: lib.recursiveUpdate acc v) {} []
# inventory = [
# # import json
# {...}
# # power user flake
# {...}
# ]
# # With Module system
# # Pros: Easy to understand,
# # Cons: Verbose, hard to maintain
# # buildClan :: { modules = [ { config = Partial<Inventory>; options :: InventoryOptions; } } ]; } -> Inventory
# eval = lib.evalModules {
# modules = [
# {
# # Inventory Schema
# # Python validation
# options = {...}
# }
# {
# config = map lib.mkDefault
# (builtins.fromJSON (builtins.readFile ./inventory.json))
# }
# {
# # User provided
# config = {...}
# }
# # Later overrides.
# {
# lib.mkForce ...
# }
# ];
# }
# nixosConfigurations = lib.evalModules inventory;
# eval.config.inventory
# #
# eval.config.machines.jon#nixosConfig
# eval.config.machines.sara#nixosConfig
#
# {inventory, config, ...}:{
# hostname = config.machines.sara # Invalid
# hostname = inventory.machines.sara.hostname # Valid
# }
/*
# Type
buildInventory :: {
clanName :: string
machines :: {
${name} :: {
config :: {
# NixOS configuration
};
};
};
# ... More mapped inventory options
# i.e. shared config for all machines
} -> Inventory
*/
{
# all machines managed by Clan
inherit (clan) nixosConfigurations clanInternals;
# add the Clan cli tool to the dev shell
devShells.${system}.default = pkgs.mkShell {
packages = [ clan-core.packages.${system}.clan-cli ];
};
};
}

View File

@ -0,0 +1,46 @@
{ ... }:
{
perSystem =
{ pkgs, config, ... }:
{
packages.inventory-schema = pkgs.stdenv.mkDerivation {
name = "inventory-schema";
src = ./src;
buildInputs = [ pkgs.cue ];
installPhase = ''
mkdir -p $out
'';
};
devShells.inventory-schema = pkgs.mkShell { inputsFrom = [ config.packages.inventory-schema ]; };
checks.inventory-schema-checks = pkgs.stdenv.mkDerivation {
name = "inventory-schema-checks";
src = ./src;
buildInputs = [ pkgs.cue ];
buildPhase = ''
echo "Running inventory tests..."
echo "Export cue as json-schema..."
cue export --out openapi root.cue
echo "Validate test/*.json against inventory-schema..."
test_dir="test"
for file in "$test_dir"/*; do
# Check if the item is a file
if [ -f "$file" ]; then
# Print the filename
echo "Running test on: $file"
# Run the cue vet command
cue vet "$file" root.cue -d "#Root"
fi
done
touch $out
'';
};
};
}

View File

@ -0,0 +1,8 @@
package machines
#machine: machines: [string]: {
name: string,
description?: string,
icon?: string
}

View File

@ -1,7 +1,8 @@
package inventory
import (
"clan.lol/inventory/schema"
"clan.lol/inventory/services"
"clan.lol/inventory/machines"
)
@jsonschema(schema="http://json-schema.org/schema#")
@ -15,9 +16,9 @@ import (
icon?: string
}
// // A map of services
schema.#service
// A map of services
services.#service
// // A map of machines
schema.#machine
// A map of machines
machines.#machine
}

View File

@ -1,39 +1,32 @@
package schema
package services
#machine: machines: [string]: {
name: string,
description?: string,
icon?: string
tags: [...string]
}
#ServiceRole: "server" | "client" | "both"
#role: string
#service: services: [string]: [string]: {
#service: services: [string]: {
// Required meta fields
meta: {
name: string,
icon?: string
description?: string,
},
// Required module specifies the behavior of the service.
module: string,
// We moved the machine sepcific config to "machines".
// It may be moved back depending on what makes more sense in the future.
roles: [#role]: {
machines: [...string],
tags: [...string],
}
machines?: {
machineConfig: {
[string]: {
roles?: [ ...#ServiceRole ],
config?: {
...
}
}
},
// Global Configuration for the service
config?: {
// Configuration for the service
config: {
// Schema depends on the module.
// It declares the interface how the service can be configured.
...
}
}
}

View File

@ -0,0 +1,38 @@
{
"machines": {
"camina_machine": {
"name": "camina"
},
"vyr_machine": {
"name": "vyr"
},
"vi_machine": {
"name": "vi"
}
},
"meta": {
"name": "kenjis clan"
},
"services": {
"backup": {
"meta": {
"name": "My backup"
},
"module": "borbackup-static",
"machineConfig": {
"vyr": {
"roles": ["server"]
},
"vi": {
"roles": ["client"]
},
"camina_machine": {
"roles": ["client"]
}
},
"config": {
"folders": ["/home", "/root", "/var", "/etc"]
}
}
}
}

View File

@ -0,0 +1,45 @@
{
"machines": {
"camina_machine": {
"name": "camina"
},
"vyr": {
"name": "vyr"
},
"vi": {
"name": "vi"
}
},
"meta": {
"name": "kenjis clan"
},
"services": {
"sync_files": {
"meta": {
"name": "My sync"
},
"module": "syncthing-static-peers",
"machineConfig": {
"vyr": {},
"vi": {},
"camina_machine": {}
},
"config": {
"folders": {
"test": {
"path": "~/data/docs",
"devices": ["camina", "vyr", "vi"]
},
"videos": {
"path": "~/data/videos",
"devices": ["camina", "vyr", "ezra"]
},
"playlist": {
"path": "~/data/playlist",
"devices": ["camina", "vyr", "ezra"]
}
}
}
}
}
}

View File

@ -0,0 +1,36 @@
{
"machines": {
"camina_machine": {
"name": "camina"
},
"vyr_machine": {
"name": "vyr"
},
"vi_machine": {
"name": "vi"
}
},
"meta": {
"name": "kenjis clan"
},
"services": {
"backup": {
"meta": {
"name": "My backup"
},
"module": "borbackup-static",
"machineConfig": {
"vyr_machine": {
"roles": ["server"]
},
"vi_machine": {
"roles": ["peer"]
},
"camina_machine": {
"roles": ["peer"]
}
},
"config": {}
}
}
}

View File

@ -12,131 +12,73 @@
# DEPRECATED: use meta.icon instead
clanIcon ? null, # A path to an icon to be used for the clan, should be the same for all machines
meta ? { }, # A set containing clan meta: name :: string, icon :: string, description :: string
# A map from arch to pkgs, if specified this nixpkgs will be only imported once for each system.
# This improves performance, but all nipxkgs.* options will be ignored.
pkgsForSystem ? (_system: null),
/*
Low level inventory configuration.
Overrides the services configuration.
*/
inventory ? { },
pkgsForSystem ? (_system: null), # A map from arch to pkgs, if specified this nixpkgs will be only imported once for each system.
# This improves performance, but all nipxkgs.* options will be ignored.
}:
let
# Internal inventory, this is the result of merging all potential inventory sources:
# - Default instances configured via 'services'
# - The inventory overrides
# - Machines that exist in inventory.machines
# - Machines explicitly configured via 'machines' argument
# - Machines that exist in the machines directory
# Checks on the module level:
# - Each service role must reference a valid machine after all machines are merged
mergedInventory =
(lib.evalModules {
modules = [
clan-core.lib.inventory.interface
{ inherit meta; }
(
if
builtins.pathExists "${directory}/inventory.json"
# Is recursively applied. Any explicit nix will override.
then
(builtins.fromJSON (builtins.readFile "${directory}/inventory.json"))
else
{ }
)
inventory
# Machines explicitly configured via 'machines' argument
{
# { ${name} :: meta // { name, tags } }
machines = lib.mapAttrs (
name: config:
(lib.attrByPath [
"clan"
"meta"
] { } config)
// {
# meta.name default is the attribute name of the machine
name = lib.mkDefault (
lib.attrByPath [
"clan"
"meta"
"name"
] name config
);
tags = lib.attrByPath [
"clan"
"tags"
] [ ] config;
system = lib.attrByPath [
"nixpkgs"
"hostSystem"
] null config;
}
) machines;
}
# Will be deprecated
{
machines = lib.mapAttrs (
name: _:
# Use mkForce to make sure users migrate to the inventory system.
# When the settings.json exists the evaluation will print the deprecation warning.
lib.mkForce {
inherit name;
system = (machineSettings name).nixpkgs.hostSystem or null;
}
) machinesDirs;
}
# Deprecated interface
(if clanName != null then { meta.name = clanName; } else { })
(if clanIcon != null then { meta.icon = clanIcon; } else { })
];
}).config;
inherit (clan-core.lib.inventory) buildInventory;
# map from machine name to service configuration
# { ${machineName} :: Config }
serviceConfigs = buildInventory mergedInventory;
deprecationWarnings = [
(lib.warnIf (
clanName != null
) "clanName is deprecated, please use meta.name instead. ${clanName}" null)
(lib.warnIf (clanIcon != null) "clanIcon is deprecated, please use meta.icon instead" null)
];
machinesDirs = lib.optionalAttrs (builtins.pathExists "${directory}/machines") (
builtins.readDir (directory + /machines)
);
mergedMeta =
let
metaFromFile =
if (builtins.pathExists "${directory}/clan/meta.json") then
let
settings = builtins.fromJSON (builtins.readFile "${directory}/clan/meta.json");
in
settings
else
{ };
legacyMeta = lib.filterAttrs (_: v: v != null) {
name = clanName;
icon = clanIcon;
};
optionsMeta = lib.filterAttrs (_: v: v != null) meta;
warnings =
builtins.map (
name:
if
metaFromFile.${name} or null != optionsMeta.${name} or null && optionsMeta.${name} or null != null
then
lib.warn "meta.${name} is set in different places. (exlicit option meta.${name} overrides ${directory}/clan/meta.json)" null
else
null
) (builtins.attrNames metaFromFile)
++ [ (if (res.name or null == null) then (throw "meta.name should be set") else null) ];
res = metaFromFile // legacyMeta // optionsMeta;
in
# Print out warnings before returning the merged result
builtins.deepSeq warnings res;
machineSettings =
machineName:
let
warn = lib.warn ''
Usage of Settings.json is only supported for test compatibility.
!!! Consider using the inventory system. !!!
File: ${directory + /machines/${machineName}/settings.json}
If there are still features missing in the inventory system, please open an issue on the clan-core repository.
'';
in
# CLAN_MACHINE_SETTINGS_FILE allows to override the settings file temporarily
# This is useful for doing a dry-run before writing changes into the settings.json
# Using CLAN_MACHINE_SETTINGS_FILE requires passing --impure to nix eval
if builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE" != "" then
warn (builtins.fromJSON (builtins.readFile (builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE")))
builtins.fromJSON (builtins.readFile (builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE"))
else
lib.optionalAttrs (builtins.pathExists "${directory}/machines/${machineName}/settings.json") (
warn (builtins.fromJSON (builtins.readFile (directory + /machines/${machineName}/settings.json)))
builtins.fromJSON (builtins.readFile (directory + /machines/${machineName}/settings.json))
);
# Read additional imports specified via a config option in settings.json
# This is not an infinite recursion, because the imports are discovered here
# before calling evalModules.
# It is still useful to have the imports as an option, as this allows for type
# checking and easy integration with the config frontend(s)
machineImports =
machineSettings: map (module: clan-core.clanModules.${module}) (machineSettings.clanImports or [ ]);
deprecationWarnings = [
(lib.warnIf (
clanName != null
) "clanName in buildClan is deprecated, please use meta.name instead." null)
(lib.warnIf (clanIcon != null) "clanIcon is deprecated, please use meta.icon instead" null)
];
# TODO: remove default system once we have a hardware-config mechanism
nixosConfiguration =
{
@ -156,9 +98,6 @@ let
clan-core.nixosModules.clanCore
extraConfig
(machines.${name} or { })
# Inherit the inventory assertions ?
{ inherit (mergedInventory) assertions; }
{ imports = serviceConfigs.${name} or { }; }
(
{
# Settings
@ -186,7 +125,7 @@ let
} // specialArgs;
};
allMachines = mergedInventory.machines or { };
allMachines = machinesDirs // machines;
supportedSystems = [
"x86_64-linux"
@ -238,13 +177,9 @@ builtins.deepSeq deprecationWarnings {
inherit nixosConfigurations;
clanInternals = {
inherit (clan-core) clanModules;
source = "${clan-core}";
meta = mergedInventory.meta;
inventory = mergedInventory;
inventoryFile = "${directory}/inventory.json";
# Evaluated clan meta
# Merged /clan/meta.json with overrides from buildClan
meta = mergedMeta;
# machine specifics
machines = configsPerSystem;

View File

@ -5,10 +5,7 @@
...
}:
{
evalClanModules = import ./eval-clan-modules { inherit clan-core nixpkgs lib; };
buildClan = import ./build-clan { inherit clan-core lib nixpkgs; };
facts = import ./facts.nix { inherit lib; };
inventory = import ./inventory { inherit lib clan-core; };
jsonschema = import ./jsonschema { inherit lib; };
modules = import ./description.nix { inherit clan-core lib; };
buildClan = import ./build-clan { inherit clan-core lib nixpkgs; };
}

View File

@ -1,4 +1,5 @@
{ clan-core, lib }:
{ lib, clan-core, ... }:
rec {
getReadme =
modulename:
@ -15,20 +16,18 @@ rec {
getShortDescription =
modulename:
let
content = getReadme modulename;
content = (getReadme modulename);
parts = lib.splitString "---" content;
# Partition the parts into the first part (the readme content) and the rest (the metadata)
parsed = builtins.partition ({ index, ... }: if index >= 2 then false else true) (
lib.filter ({ index, ... }: index != 0) (lib.imap0 (index: part: { inherit index part; }) parts)
);
# Use this if the content is needed
# readmeContent = lib.concatMapStrings (v: "---" + v.part) parsed.wrong;
meta = builtins.fromTOML (builtins.head parsed.right).part;
description = builtins.head parts;
number_of_newlines = builtins.length (lib.splitString "\n" description);
in
if (builtins.length parts >= 3) then
meta.description
if (builtins.length parts) > 1 then
if number_of_newlines > 4 then
throw "Short description in README.md for module ${modulename} is too long. Max 3 newlines."
else if number_of_newlines <= 1 then
throw "Missing short description in README.md for module ${modulename}."
else
description
else
throw "Short description delimiter `---` not found in README.md for module ${modulename}";
}

View File

@ -1,34 +0,0 @@
{
nixpkgs,
clan-core,
lib,
}:
let
inherit (import nixpkgs { system = "x86_64-linux"; }) pkgs;
inherit (clan-core) clanModules;
baseModule = {
imports = (import (pkgs.path + "/nixos/modules/module-list.nix")) ++ [
{
nixpkgs.hostPlatform = "x86_64-linux";
clan.core.clanName = "dummy";
}
];
};
# This function takes a list of module names and evaluates them
# evalClanModules :: [ String ] -> { config, options, ... }
evalClanModules =
modulenames:
let
evaled = lib.evalModules {
modules = [
baseModule
clan-core.nixosModules.clanCore
] ++ (map (name: clanModules.${name}) modulenames);
};
in
evaled;
in
evalClanModules

View File

@ -1,71 +0,0 @@
{ lib, ... }:
machineDir:
let
allMachineNames = lib.mapAttrsToList (name: _: name) (builtins.readDir machineDir);
getFactPath = fact: machine: "${machineDir}/${machine}/facts/${fact}";
readFact =
fact: machine:
let
path = getFactPath fact machine;
in
if builtins.pathExists path then builtins.readFile path else null;
# Example:
#
# readFactFromAllMachines zerotier-ip
# => {
# machineA = "1.2.3.4";
# machineB = "5.6.7.8";
# };
readFactFromAllMachines =
fact:
let
machines = allMachineNames;
facts = lib.genAttrs machines (readFact fact);
filteredFacts = lib.filterAttrs (_machine: fact: fact != null) facts;
in
filteredFacts;
# all given facts are are set and factvalues are never null.
#
# Example:
#
# readFactsFromAllMachines [ "zerotier-ip" "syncthing.pub" ]
# => {
# machineA =
# {
# "zerotier-ip" = "1.2.3.4";
# "synching.pub" = "1234";
# };
# machineB =
# {
# "zerotier-ip" = "5.6.7.8";
# "synching.pub" = "23456719";
# };
# };
readFactsFromAllMachines =
facts:
let
# machine -> fact -> factvalue
machinesFactsAttrs = lib.genAttrs allMachineNames (
machine: lib.genAttrs facts (fact: readFact fact machine)
);
# remove all machines which don't have all facts set
filteredMachineFactAttrs = lib.filterAttrs (
_machine: values: builtins.all (fact: values.${fact} != null) facts
) machinesFactsAttrs;
in
filteredMachineFactAttrs;
in
{
inherit
allMachineNames
getFactPath
readFact
readFactFromAllMachines
readFactsFromAllMachines
;
}

View File

@ -5,12 +5,9 @@
...
}:
{
imports = [
./jsonschema/flake-module.nix
./inventory/flake-module.nix
];
imports = [ ./jsonschema/flake-module.nix ];
flake.lib = import ./default.nix {
inherit lib inputs;
inherit lib;
inherit (inputs) nixpkgs;
clan-core = self;
};

View File

@ -1,90 +0,0 @@
# Inventory
The inventory is our concept for distributed services. Users can configure multiple machines with minimal effort.
- The inventory acts as a declarative source of truth for all machine configurations.
- Users can easily add or remove machines to/from services.
- Ensures that all machines and services are configured consistently, across multiple nixosConfigs.
- Defaults and predefined roles in our modules minimizes the need for manual configuration.
Open questions:
- [ ] How do we set default role, description and other metadata?
- It must be accessible from Python.
- It must set the value in the module system.
- [ ] Inventory might use assertions. Should each machine inherit the inventory assertions ?
- [ ] Is the service config interface the same as the module config interface ?
- [ ] As a user do I want to see borgbackup as the high level category?
Architecture
```
nixosConfig < machine_module < inventory
---------------------------------------------
nixos < borgbackup <- inventory <-> UI
creates the config Maps from high level services to the borgbackup clan module
for ONE machine Inventory is completely serializable.
UI can interact with the inventory to define machines, and services
Defining Users is out of scope for the first prototype.
```
## Provides a specification for the inventory
It is used for design phase and as validation helper.
> Cue is less verbose and easier to understand and maintain than json-schema.
> Json-schema, if needed can be easily generated on-the fly.
## Checking validity
Directly check a json against the schema
`cue vet inventory.json root.cue -d '#Root'`
## Json schema
Export the json-schema i.e. for usage in python / javascript / nix
`cue export --out openapi root.cue`
## Usage
Comments are rendered as descriptions in the json schema.
```cue
// A name of the clan (primarily shown by the UI)
name: string
```
Cue open sets. In the following `foo = {...}` means that the key `foo` can contain any arbitrary json object.
```cue
foo: { ... }
```
Cue dynamic keys.
```cue
[string]: {
attr: string
}
```
This is the schema of
```json
{
"a": {
"attr": "foo"
},
"b": {
"attr": "bar"
}
// ... Indefinitely more dynamic keys of type "string"
}
```

View File

@ -1,117 +0,0 @@
# Generate partial NixOS configurations for every machine in the inventory
# This function is responsible for generating the module configuration for every machine in the inventory.
{ lib, clan-core }:
inventory:
let
machines = machinesFromInventory inventory;
resolveTags =
# Inventory, { machines :: [string], tags :: [string] }
inventory: members: {
machines =
members.machines or [ ]
++ (builtins.foldl' (
acc: tag:
let
# For error printing
availableTags = lib.foldlAttrs (
acc: _: v:
v.tags or [ ] ++ acc
) [ ] inventory.machines;
tagMembers = builtins.attrNames (
lib.filterAttrs (_n: v: builtins.elem tag v.tags or [ ]) inventory.machines
);
in
if tagMembers == [ ] then
throw "Tag: '${tag}' not found. Available tags: ${builtins.toJSON (lib.unique availableTags)}"
else
acc ++ tagMembers
) [ ] members.tags or [ ]);
};
/*
Returns a NixOS configuration for every machine in the inventory.
machinesFromInventory :: Inventory -> { ${machine_name} :: NixOSConfiguration }
*/
machinesFromInventory =
inventory:
# For every machine in the inventory, build a NixOS configuration
# For each machine generate config, forEach service, if the machine is used.
builtins.mapAttrs (
machineName: machineConfig:
lib.foldlAttrs (
# [ Modules ], String, { ${instance_name} :: ServiceConfig }
acc: moduleName: serviceConfigs:
acc
# Collect service config
++ (lib.foldlAttrs (
# [ Modules ], String, ServiceConfig
acc2: instanceName: serviceConfig:
let
resolvedRoles = builtins.mapAttrs (
_roleName: members: resolveTags inventory members
) serviceConfig.roles;
isInService = builtins.any (members: builtins.elem machineName members.machines) (
builtins.attrValues resolvedRoles
);
# Inverse map of roles. Allows for easy lookup of roles for a given machine.
# { ${machine_name} :: [roles]
inverseRoles = lib.foldlAttrs (
acc: roleName:
{ machines }:
acc
// builtins.foldl' (
acc2: machineName: acc2 // { ${machineName} = (acc.${machineName} or [ ]) ++ [ roleName ]; }
) { } machines
) { } resolvedRoles;
machineServiceConfig = (serviceConfig.machines.${machineName} or { }).config or { };
globalConfig = serviceConfig.config or { };
# TODO: maybe optimize this dont lookup the role in inverse roles. Imports are not lazy
roleModules = builtins.map (
role:
let
path = "${clan-core.clanModules.${moduleName}}/roles/${role}.nix";
in
if builtins.pathExists path then
path
else
throw "Module doesn't have role: '${role}'. Path: ${path} not found."
) inverseRoles.${machineName} or [ ];
in
if isInService then
acc2
++ [
{
imports = [ clan-core.clanModules.${moduleName} ] ++ roleModules;
config.clan.${moduleName} = lib.mkMerge [
globalConfig
machineServiceConfig
];
}
{
config.clan.inventory.services.${moduleName}.${instanceName} = {
roles = resolvedRoles;
# TODO: Add inverseRoles to the service config if needed
# inherit inverseRoles;
};
}
]
else
acc2
) [ ] serviceConfigs)
) [ ] inventory.services
# Append each machine config
++ [
(lib.optionalAttrs (machineConfig.system or null != null) {
config.nixpkgs.hostPlatform = machineConfig.system;
})
]
) inventory.machines or { };
in
machines

View File

@ -1,136 +0,0 @@
{ config, lib, ... }:
let
t = lib.types;
metaOptions = {
name = lib.mkOption { type = t.str; };
description = lib.mkOption {
default = null;
type = t.nullOr t.str;
};
icon = lib.mkOption {
default = null;
type = t.nullOr t.str;
};
};
machineRef = lib.mkOptionType {
name = "machineRef";
description = "Machine :: [${builtins.concatStringsSep " | " (builtins.attrNames config.machines)}]";
check = v: lib.isString v && builtins.elem v (builtins.attrNames config.machines);
merge = lib.mergeEqualOption;
};
allTags = lib.unique (
lib.foldlAttrs (
tags: _: m:
tags ++ m.tags or [ ]
) [ ] config.machines
);
tagRef = lib.mkOptionType {
name = "tagRef";
description = "Tags :: [${builtins.concatStringsSep " | " allTags}]";
check = v: lib.isString v && builtins.elem v allTags;
merge = lib.mergeEqualOption;
};
in
{
options.assertions = lib.mkOption {
type = t.listOf t.unspecified;
internal = true;
default = [ ];
};
config.assertions =
let
serviceAssertions = lib.foldlAttrs (
ass1: serviceName: c:
ass1
++ lib.foldlAttrs (
ass2: instanceName: instanceConfig:
let
serviceMachineNames = lib.attrNames instanceConfig.machines;
topLevelMachines = lib.attrNames config.machines;
# All machines must be defined in the top-level machines
assertions = builtins.map (m: {
assertion = builtins.elem m topLevelMachines;
message = "${serviceName}.${instanceName}.machines.${m}. Should be one of [ ${builtins.concatStringsSep " | " topLevelMachines} ]";
}) serviceMachineNames;
in
ass2 ++ assertions
) [ ] c
) [ ] config.services;
machineAssertions = map (
{ name, value }:
{
assertion = true;
message = "Machine ${name} should define its host system in the inventory. ()";
}
) (lib.attrsToList (lib.filterAttrs (_n: v: v.system or null == null) config.machines));
in
machineAssertions ++ serviceAssertions;
options.meta = metaOptions;
options.machines = lib.mkOption {
default = { };
type = t.attrsOf (
t.submodule {
options = {
inherit (metaOptions) name description icon;
tags = lib.mkOption {
default = [ ];
apply = lib.unique;
type = t.listOf t.str;
};
system = lib.mkOption {
default = null;
type = t.nullOr t.str;
};
};
}
);
};
options.services = lib.mkOption {
default = { };
type = t.attrsOf (
t.attrsOf (
t.submodule {
options.meta = metaOptions;
options.config = lib.mkOption {
default = { };
type = t.anything;
};
options.machines = lib.mkOption {
default = { };
type = t.attrsOf (
t.submodule {
options.config = lib.mkOption {
default = { };
type = t.anything;
};
}
);
};
options.roles = lib.mkOption {
default = { };
type = t.attrsOf (
t.submodule {
options.machines = lib.mkOption {
default = [ ];
type = t.listOf machineRef;
};
options.tags = lib.mkOption {
default = [ ];
apply = lib.unique;
type = t.listOf tagRef;
};
}
);
};
}
)
);
};
}

View File

@ -1,5 +0,0 @@
{ lib, clan-core }:
{
buildInventory = import ./build-inventory { inherit lib clan-core; };
interface = ./build-inventory/interface.nix;
}

View File

@ -1,39 +0,0 @@
{ self, ... }:
self.lib.buildClan {
# Name of the clan in the UI, should be unique
meta.name = "Inventory clan";
# Should usually point to the directory of flake.nix
directory = self;
inventory = {
services = {
borgbackup.instance_1 = {
roles.server.machines = [ "backup_server" ];
roles.client.tags = [ "backup" ];
};
};
};
# merged with
machines = {
"backup_server" = {
clan.tags = [ "all" ];
# ... rest of the machine config
};
"client_1_machine" = {
clan.tags = [
"all"
"backup"
];
};
"client_2_machine" = {
clan.tags = [
"all"
"backup"
];
# Name of the machine in the UI
clan.meta.name = "camina";
};
};
}

View File

@ -1,53 +0,0 @@
{
"machines": {
"camina_machine": {
"name": "camina",
"tags": ["laptop"]
},
"vyr_machine": {
"name": "vyr"
},
"vi_machine": {
"name": "vi",
"tags": ["laptop"]
}
},
"meta": {
"name": "kenjis clan"
},
"services": {
"borgbackup": {
"instance_1": {
"meta": {
"name": "My backup"
},
"roles": {
"server": {
"machines": ["vyr_machine"]
},
"client": {
"machines": ["vyr_machine"],
"tags": ["laptop"]
}
},
"machines": {},
"config": {}
},
"instance_2": {
"meta": {
"name": "My backup"
},
"roles": {
"server": {
"machines": ["vi_machine"]
},
"client": {
"machines": ["camina_machine"]
}
},
"machines": {},
"config": {}
}
}
}
}

View File

@ -1,47 +0,0 @@
{
"machines": {
"camina_machine": {
"name": "camina"
},
"vyr_machine": {
"name": "vyr"
},
"vi_machine": {
"name": "vi"
}
},
"meta": {
"name": "kenjis clan"
},
"services": {
"syncthing": {
"instance_1": {
"meta": {
"name": "My sync"
},
"roles": {
"peer": {
"machines": ["vyr_machine", "vi_machine", "camina_machine"]
}
},
"machines": {},
"config": {
"folders": {
"test": {
"path": "~/data/docs",
"devices": ["camina_machine", "vyr_machine", "vi_machine"]
},
"videos": {
"path": "~/data/videos",
"devices": ["camina_machine", "vyr_machine"]
},
"playlist": {
"path": "~/data/playlist",
"devices": ["camina_machine", "vi_machine"]
}
}
}
}
}
}
}

View File

@ -1,36 +0,0 @@
{
"machines": {
"camina_machine": {
"name": "camina"
},
"vyr_machine": {
"name": "vyr"
},
"vi_machine": {
"name": "vi"
}
},
"meta": {
"name": "kenjis clan"
},
"services": {
"zerotier": {
"instance_1": {
"meta": {
"name": "My Network"
},
"roles": {
"controller": { "machines": ["vyr_machine"] },
"moon": { "machines": ["vyr_machine"] },
"peer": { "machines": ["vi_machine", "camina_machine"] }
},
"machines": {
"vyr_machine": {
"config": {}
}
},
"config": {}
}
}
}
}

View File

@ -1,83 +0,0 @@
{ self, inputs, ... }:
let
inputOverrides = builtins.concatStringsSep " " (
builtins.map (input: " --override-input ${input} ${inputs.${input}}") (builtins.attrNames inputs)
);
in
{
flake.inventory = import ./example.nix { inherit self; };
perSystem =
{
pkgs,
lib,
config,
system,
self',
...
}:
let
buildInventory = import ./build-inventory {
clan-core = self;
inherit lib;
};
in
{
devShells.inventory-schema = pkgs.mkShell {
inputsFrom = with config.checks; [
lib-inventory-schema
lib-inventory-eval
self'.devShells.default
];
};
# Run: nix-unit --extra-experimental-features flakes --flake .#legacyPackages.x86_64-linux.evalTests
legacyPackages.evalTests-inventory = import ./tests {
inherit buildInventory;
clan-core = self;
};
checks = {
lib-inventory-eval = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } ''
export HOME="$(realpath .)"
nix-unit --eval-store "$HOME" \
--extra-experimental-features flakes \
${inputOverrides} \
--flake ${self}#legacyPackages.${system}.evalTests-inventory
touch $out
'';
lib-inventory-schema = pkgs.stdenv.mkDerivation {
name = "inventory-schema-checks";
src = ./.;
buildInputs = [ pkgs.cue ];
buildPhase = ''
echo "Running inventory tests..."
# Cue is easier to run in the same directory as the schema
cd spec
echo "Export cue as json-schema..."
cue export --out openapi root.cue
echo "Validate test/*.json against inventory-schema..."
test_dir="../examples"
for file in "$test_dir"/*; do
# Check if the item is a file
if [ -f "$file" ]; then
# Print the filename
echo "Running test on: $file"
# Run the cue vet command
cue vet "$file" root.cue -d "#Root"
fi
done
touch $out
'';
};
};
};
}

View File

@ -1,155 +0,0 @@
{ buildInventory, clan-core, ... }:
{
test_inventory_empty = {
# Empty inventory should return an empty module
expr = buildInventory { };
expected = { };
};
test_inventory_role_imports =
let
configs = buildInventory {
services = {
borgbackup.instance_1 = {
roles.server.machines = [ "backup_server" ];
roles.client.machines = [
"client_1_machine"
"client_2_machine"
];
};
};
machines = {
"backup_server" = { };
"client_1_machine" = { };
"client_2_machine" = { };
};
};
in
{
expr = {
server_imports = (builtins.head configs."backup_server").imports;
client_1_imports = (builtins.head configs."client_1_machine").imports;
client_2_imports = (builtins.head configs."client_2_machine").imports;
};
expected = {
server_imports = [
clan-core.clanModules.borgbackup
"${clan-core.clanModules.borgbackup}/roles/server.nix"
];
client_1_imports = [
clan-core.clanModules.borgbackup
"${clan-core.clanModules.borgbackup}/roles/client.nix"
];
client_2_imports = [
clan-core.clanModules.borgbackup
"${clan-core.clanModules.borgbackup}/roles/client.nix"
];
};
};
test_inventory_tag_resolve =
let
configs = buildInventory {
services = {
borgbackup.instance_1 = {
roles.client.tags = [ "backup" ];
};
};
machines = {
"not_used_machine" = { };
"client_1_machine" = {
tags = [ "backup" ];
};
"client_2_machine" = {
tags = [ "backup" ];
};
};
};
in
{
expr = {
# A machine that includes the backup service should have 3 imports
# - one for some service agnostic properties of the machine itself
# - One for the service itself (default.nix)
# - one for the role (roles/client.nix)
client_1_machine = builtins.length configs.client_1_machine;
client_2_machine = builtins.length configs.client_2_machine;
not_used_machine = builtins.length configs.not_used_machine;
};
expected = {
client_1_machine = 3;
client_2_machine = 3;
not_used_machine = 1;
};
};
test_inventory_multiple_roles =
let
configs = buildInventory {
services = {
borgbackup.instance_1 = {
roles.client.machines = [ "machine_1" ];
roles.server.machines = [ "machine_1" ];
};
};
machines = {
"machine_1" = { };
};
};
in
{
expr = {
machine_1_imports = (builtins.head configs."machine_1").imports;
};
expected = {
machine_1_imports = [
clan-core.clanModules.borgbackup
"${clan-core.clanModules.borgbackup}/roles/client.nix"
"${clan-core.clanModules.borgbackup}/roles/server.nix"
];
};
};
test_inventory_role_doesnt_exist =
let
configs = buildInventory {
services = {
borgbackup.instance_1 = {
roles.roleXYZ.machines = [ "machine_1" ];
};
};
machines = {
"machine_1" = { };
};
};
in
{
expr = configs;
expectedError = {
type = "ThrownError";
msg = "Module doesn't have role.*";
};
};
test_inventory_tag_doesnt_exist =
let
configs = buildInventory {
services = {
borgbackup.instance_1 = {
roles.client.machines = [ "machine_1" ];
roles.client.tags = [ "tagXYZ" ];
};
};
machines = {
"machine_1" = {
tags = [ "tagABC" ];
};
};
};
in
{
expr = configs;
expectedError = {
type = "ThrownError";
msg = "Tag: '\\w+' not found";
};
};
}

View File

@ -47,7 +47,7 @@ rec {
let
evaled = lib.evalModules { modules = [ module ]; };
in
{ "$schema" = "http://json-schema.org/draft-07/schema#"; } // parseOptions evaled.options;
parseOptions evaled.options;
# parses a set of evaluated nixos options to a jsonschema
parseOptions =
@ -66,7 +66,6 @@ rec {
// {
type = "object";
inherit properties;
additionalProperties = false;
};
# parses and evaluated nixos option to a jsonschema property definition

View File

@ -1,7 +1,5 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
@ -40,7 +38,6 @@
},
"services": {
"type": "object",
"additionalProperties": false,
"properties": {
"opt": {
"type": "string",
@ -62,8 +59,9 @@
"type": "string"
}
},
"required": ["repo"],
"additionalProperties": false,
"required": [
"repo"
],
"type": "object"
},
"default": {},

View File

@ -278,7 +278,6 @@ in
expr = slib.parseOption (evalType (lib.types.submodule subModule) { });
expected = {
type = "object";
additionalProperties = false;
properties = {
opt = {
type = "boolean";
@ -302,7 +301,6 @@ in
expr = slib.parseOption (evalType (lib.types.submodule subModule) { });
expected = {
type = "object";
additionalProperties = false;
properties = {
opt = {
type = "boolean";
@ -333,7 +331,6 @@ in
type = "object";
additionalProperties = {
type = "object";
additionalProperties = false;
properties = {
opt = {
type = "boolean";
@ -366,7 +363,6 @@ in
type = "array";
items = {
type = "object";
additionalProperties = false;
properties = {
opt = {
type = "boolean";

View File

@ -4,9 +4,16 @@
lib ? (import <nixpkgs> { }).lib,
slib ? import ./. { inherit lib; },
}:
let
evaledOptions =
let
evaledConfig = lib.evalModules { modules = [ ./example-interface.nix ]; };
in
evaledConfig.options;
in
{
testParseOptions = {
expr = slib.parseModule ./example-interface.nix;
expr = slib.parseOptions evaledOptions;
expected = builtins.fromJSON (builtins.readFile ./example-schema.json);
};
@ -19,10 +26,8 @@
{
expr = slib.parseOptions evaled.options;
expected = {
additionalProperties = false;
properties = {
foo = {
additionalProperties = false;
properties = {
bar = {
type = "boolean";

View File

@ -14,9 +14,5 @@
./vm.nix
./wayland-proxy-virtwl.nix
./zerotier
# Inventory
./inventory/interface.nix
./meta/interface.nix
./vars
];
}

View File

@ -67,19 +67,9 @@
publicDirectory = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
The directory where public facts are stored.
'';
};
services = lib.mkOption {
description = ''
Services to generate secrets and facts for.
Each service can have a generator script which generates the secrets and facts.
The generator script is expected to generate all secrets and facts defined for this service.
A `service` does not need to ba analogous to a systemd service, it can be any group of facts and secrets that need to be generated together.
'';
default = { };
type = lib.types.attrsOf (
lib.types.submodule (service: {
@ -92,9 +82,6 @@
'';
};
generator = lib.mkOption {
description = ''
The generator to generate the secrets and facts for this service.
'';
type = lib.types.submodule (
{ config, ... }:
{
@ -119,9 +106,8 @@
description = ''
Shell script snippet to generate the secrets and facts.
The script has access to the following environment variables:
- prompt_value: prompted value in case a prompt was defined
- facts: path to a directory where facts can be stored
- secrets: path to a directory where secrets can be stored
- facts: path to a directory where facts can be stored
- secrets: path to a directory where secrets can be stored
The script is expected to generate all secrets and facts defined for this service.
'';
};
@ -135,27 +121,26 @@
export PATH="${lib.makeBinPath config.path}:${pkgs.coreutils}/bin"
${lib.optionalString (pkgs.stdenv.hostPlatform.isLinux) ''
# prepare sandbox user on platforms where this is supported
mkdir -p /etc
# prepare sandbox user
mkdir -p /etc
cat > /etc/group <<EOF
root:x:0:
nixbld:!:$(id -g):
nogroup:x:65534:
EOF
cat > /etc/group <<EOF
root:x:0:
nixbld:!:$(id -g):
nogroup:x:65534:
EOF
cat > /etc/passwd <<EOF
root:x:0:0:Nix build user:/build:/noshell
nixbld:x:$(id -u):$(id -g):Nix build user:/build:/noshell
nobody:x:65534:65534:Nobody:/:/noshell
EOF
cat > /etc/passwd <<EOF
root:x:0:0:Nix build user:/build:/noshell
nixbld:x:$(id -u):$(id -g):Nix build user:/build:/noshell
nobody:x:65534:65534:Nobody:/:/noshell
EOF
cat > /etc/hosts <<EOF
127.0.0.1 localhost
::1 localhost
EOF
cat > /etc/hosts <<EOF
127.0.0.1 localhost
::1 localhost
EOF
''}
${config.script}
'';
};
@ -164,9 +149,6 @@
);
};
secret = lib.mkOption {
description = ''
Secret facts to generate for this service.
'';
default = { };
type = lib.types.attrsOf (
lib.types.submodule (secret: {
@ -198,11 +180,11 @@
};
})
);
description = ''
path where the secret is located in the filesystem
'';
};
public = lib.mkOption {
description = ''
Public facts to generate for this service.
'';
default = { };
type = lib.types.attrsOf (
lib.types.submodule (fact: {
@ -224,9 +206,6 @@
config.clan.core.clanDir + "/machines/${config.clan.core.machineName}/facts/${fact.config.name}";
};
value = lib.mkOption {
description = ''
The value of the public fact.
'';
defaultText = lib.literalExpression "\${config.clan.core.clanDir}/\${fact.config.path}";
type = lib.types.nullOr lib.types.str;
default =
@ -250,5 +229,15 @@
./public/in_repo.nix
./public/vm.nix
# (lib.mkRenamedOptionModule
# [
# "clanCore"
# ]
# [
# "clan"
# "core"
# ]
# )
];
}

View File

@ -1,35 +0,0 @@
{ lib, ... }:
let
# {
# roles = {
# client = {
# machines = [
# "camina_machine"
# "vi_machine"
# ];
# };
# server = {
# machines = [ "vyr_machine" ];
# };
# };
# }
instanceOptions = lib.types.submodule {
options.roles = lib.mkOption { type = lib.types.attrsOf machinesList; };
};
# {
# machines = [
# "camina_machine"
# "vi_machine"
# "vyr_machine"
# ];
# }
machinesList = lib.types.submodule {
options.machines = lib.mkOption { type = lib.types.listOf lib.types.str; };
};
in
{
options.clan.inventory.services = lib.mkOption {
type = lib.types.attrsOf (lib.types.attrsOf instanceOptions);
};
}

View File

@ -1,10 +0,0 @@
{ lib, ... }:
let
optStr = lib.types.nullOr lib.types.str;
in
{
options.clan.meta.name = lib.mkOption { type = lib.types.str; };
options.clan.meta.description = lib.mkOption { type = optStr; };
options.clan.meta.icon = lib.mkOption { type = optStr; };
options.clan.tags = lib.mkOption { type = lib.types.listOf lib.types.str; };
}

View File

@ -7,9 +7,6 @@
{
# interface
options.clan.core.state = lib.mkOption {
description = ''
Define state directories to backup and restore
'';
default = { };
type = lib.types.attrsOf (
lib.types.submodule (

View File

@ -1,16 +0,0 @@
{ lib, ... }:
{
options.clan.core.vars = lib.mkOption {
visible = false;
description = ''
Generated Variables
Define generators that prompt for or generate variables like facts and secrets to store, deploy, and rotate them easily.
For example, generators can be used to:
- prompt the user for input, like passwords or host names
- generate secrets like private keys automatically when they are needed
- output multiple values like private and public keys simultaneously
'';
type = lib.types.submoduleWith { modules = [ ./interface.nix ]; };
};
}

View File

@ -1,107 +0,0 @@
{ lib, pkgs, ... }:
let
eval =
module:
(lib.evalModules {
modules = [
../interface.nix
module
];
specialArgs.pkgs = pkgs;
}).config;
usage_simple = {
generators.my_secret = {
files.password = { };
files.username.secret = false;
prompts.prompt1 = { };
script = ''
cp $prompts/prompt1 $files/password
'';
};
};
in
{
single_file_single_prompt =
let
config = eval usage_simple;
in
{
# files are always secret by default
test_file_secret_by_default = {
expr = config.generators.my_secret.files.password.secret;
expected = true;
};
# secret files must not provide a value
test_secret_value_access_raises_error = {
expr = config.generators.my_secret.files.password.value;
expectedError.type = "ThrownError";
expectedError.msg = "Cannot access value of secret file";
};
# public values must provide a value at eval time
test_public_value_access = {
expr = config.generators.my_secret.files.username ? value;
expected = true;
};
# both secret and public values must provide a path
test_secret_has_path = {
expr = config.generators.my_secret.files.password ? path;
expected = true;
};
test_public_var_has_path = {
expr = config.generators.my_secret.files.username ? path;
expected = true;
};
};
# Ensure that generators.imports works
# This allows importing generators from third party projects without providing
# them access to other settings.
test_generator_modules =
let
generator_module = {
my-generator.files.password = { };
};
config = eval { generators.imports = [ generator_module ]; };
in
{
expr = config.generators ? my-generator;
expected = true;
};
# script can be text
test_script_text =
let
config = eval {
# imports = [ usage_simple ];
generators.my_secret.script = ''
echo "Hello, world!"
'';
};
in
{
expr = config.generators.my_secret.script;
expected = "echo \"Hello, world!\"\n";
};
# script can be a derivation
test_script_writer =
let
config = eval {
# imports = [ usage_simple ];
generators.my_secret.script = derivation {
system = pkgs.system;
name = "my-script";
builder = "/bin/sh";
args = [
"-c"
''touch $out''
];
};
};
in
{
expr = lib.hasPrefix builtins.storeDir config.generators.my_secret.script;
expected = true;
};
}

View File

@ -1,32 +0,0 @@
{
self,
inputs,
lib,
...
}:
let
inputOverrides = builtins.concatStringsSep " " (
builtins.map (input: " --override-input ${input} ${inputs.${input}}") (builtins.attrNames inputs)
);
in
{
perSystem =
{ system, pkgs, ... }:
{
legacyPackages.evalTests-module-clan-vars = import ./eval-tests {
inherit lib;
clan-core = self;
pkgs = inputs.nixpkgs.legacyPackages.${system};
};
checks.module-clan-vars-eval = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } ''
export HOME="$(realpath .)"
nix-unit --eval-store "$HOME" \
--extra-experimental-features flakes \
${inputOverrides} \
--flake ${self}#legacyPackages.${system}.evalTests-module-clan-vars
touch $out
'';
};
}

View File

@ -1,36 +0,0 @@
{
lib,
config,
pkgs,
...
}:
{
finalScript = lib.mkOptionDefault ''
set -eu -o pipefail
export PATH="${lib.makeBinPath config.runtimeInputs}:${pkgs.coreutils}/bin"
${lib.optionalString (pkgs.stdenv.hostPlatform.isLinux) ''
# prepare sandbox user on platforms where this is supported
mkdir -p /etc
cat > /etc/group <<EOF
root:x:0:
nixbld:!:$(id -g):
nogroup:x:65534:
EOF
cat > /etc/passwd <<EOF
root:x:0:0:Nix build user:/build:/noshell
nixbld:x:$(id -u):$(id -g):Nix build user:/build:/noshell
nobody:x:65534:65534:Nobody:/:/noshell
EOF
cat > /etc/hosts <<EOF
127.0.0.1 localhost
::1 localhost
EOF
''}
${config.script}
'';
}

View File

@ -1,147 +0,0 @@
{ lib, ... }:
let
inherit (lib) mkOption;
inherit (lib.types)
anything
attrsOf
bool
either
enum
listOf
package
path
str
submoduleWith
;
# the original types.submodule has strange behavior
submodule = module: submoduleWith { modules = [ module ]; };
options = lib.mapAttrs (_: mkOption);
subOptions = opts: submodule { options = options opts; };
in
{
options = options {
settings = {
description = ''
Settings for the generated variables.
'';
type = submodule {
freeformType = anything;
imports = [ ./settings.nix ];
};
};
generators = {
default = {
imports = [
# implementation of the generator
./generator.nix
];
};
type = submodule {
freeformType = attrsOf (subOptions {
dependencies = {
description = ''
A list of other generators that this generator depends on.
The output values of these generators will be available to the generator script as files.
For example, the file 'file1' of a dependency named 'dep1' will be available via $dependencies/dep1/file1.
'';
type = listOf str;
default = [ ];
};
files = {
description = ''
A set of files to generate.
The generator 'script' is expected to produce exactly these files under $out.
'';
type = attrsOf (subOptions {
secret = {
description = ''
Whether the file should be treated as a secret.
'';
type = bool;
default = true;
};
path = {
description = ''
The path to the file containing the content of the generated value.
This will be set automatically
'';
type = str;
readOnly = true;
};
value = {
description = ''
The content of the generated value.
Only available if the file is not secret.
'';
type = str;
default = throw "Cannot access value of secret file";
defaultText = "Throws error because the value of a secret file is not accessible";
};
});
};
prompts = {
description = ''
A set of prompts to ask the user for values.
Prompts are available to the generator script as files.
For example, a prompt named 'prompt1' will be available via $prompts/prompt1
'';
type = attrsOf (subOptions {
description = {
description = ''
The description of the prompted value
'';
type = str;
example = "SSH private key";
};
type = {
description = ''
The input type of the prompt.
The following types are available:
- hidden: A hidden text (e.g. password)
- line: A single line of text
- multiline: A multiline text
'';
type = enum [
"hidden"
"line"
"multiline"
];
default = "line";
};
});
};
runtimeInputs = {
description = ''
A list of packages that the generator script requires.
These packages will be available in the PATH when the script is run.
'';
type = listOf package;
default = [ ];
};
script = {
description = ''
The script to run to generate the files.
The script will be run with the following environment variables:
- $dependencies: The directory containing the output values of all declared dependencies
- $out: The output directory to put the generated files
- $prompts: The directory containing the prompted values as files
The script should produce the files specified in the 'files' attribute under $out.
'';
type = either str path;
};
finalScript = {
description = ''
The final generator script, wrapped, so:
- all required programs are in PATH
- sandbox is set up correctly
'';
type = lib.types.str;
readOnly = true;
internal = true;
visible = false;
};
});
};
};
};
}

View File

@ -1,72 +0,0 @@
{ lib, ... }:
{
options = {
secretStore = lib.mkOption {
type = lib.types.enum [
"sops"
"password-store"
"vm"
"custom"
];
default = "sops";
description = ''
method to store secret facts
custom can be used to define a custom secret fact store.
'';
};
secretModule = lib.mkOption {
type = lib.types.str;
internal = true;
description = ''
the python import path to the secret module
'';
};
secretUploadDirectory = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
The directory where secrets are uploaded into, This is backend specific.
'';
};
secretPathFunction = lib.mkOption {
type = lib.types.raw;
description = ''
The function to use to generate the path for a secret.
The default function will use the path attribute of the secret.
The function will be called with the secret submodule as an argument.
'';
};
publicStore = lib.mkOption {
type = lib.types.enum [
"in_repo"
"vm"
"custom"
];
default = "in_repo";
description = ''
method to store public facts.
custom can be used to define a custom public fact store.
'';
};
publicModule = lib.mkOption {
type = lib.types.str;
internal = true;
description = ''
the python import path to the public module
'';
};
publicDirectory = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
The directory where public facts are stored.
'';
};
};
}

Some files were not shown because too many files have changed in this diff Show More