Konfigkoll and paketkoll
This repository contains two tools, described below.
Paketkoll
Paketkoll does a bunch of things:
- On Arch Linux (and derivatives):
- Faster alternative to
pacman -Qkk
/paccheck
: Checking integrity of installed files with respect to packages. - Faster alternative to
pacman -Qo
: Listing which package owns files
- Faster alternative to
- On Debian (and derivatives, like Ubuntu):
- Faster alternative to
debsums
: Checking integrity of installed files with respect to packages. - Faster alternative to
dpkg-query -S
: Listing which package owns a given file
- Faster alternative to
- Listing installed packages in a Linux distro neutral way (Debian, Arch Linux, and derivatives).
Also supports listing flatpak. - Getting the original file contents for a given path.
Konfigkoll
Konfigkoll is a work in progress cross distro configuration manager. It aims to solve the problem "I have too many computers and want to keep the system configs in sync", rather than "I am a sysadmin and want to manage a fleet". As such it is a personal system configuration manager.
The design of konfigkoll is heavily inspired by the excellent Aconfmgr, but with a few key differences:
- Aconfmgr is Arch Linux specific, konfigkoll aims to be cross distro (currently Arch Linux + work in progress support for Debian & derivatives).
- Aconfmgr is written in Bash, and is rather slow. Konfigkoll is written in Rust, and is much faster.
As an example, applying my personal config with aconfmgr on my system takes about 30 seconds, while konfigkoll takes about 2 seconds for the equivalent config. (This is assuming--trust-mtime
, both are significantly slowed down if checksums are verified for every file). - Aconfmgr uses bash as the configuration language, konfigkoll uses Rune.
Comparisons
Unlike tools such as ansible, puppet, etc.:
- Konfigkoll only manages the computer it is running on, not remote systems over the network.
- Konfigkoll can save the system state to a file, giving you a full template config to work from. (You definitely want to customise this saved config though.)
There is perhaps more similarity with NixOS and Guix, but, unlike those:
- You can still use normal management tools and save changes to the config afterwards.
With NixOS/Guix every change starts at the config. - NixOS provides specific config keys for every package, konfigkoll is more general: You can patch any config file with sed-like instructions (or custom code), there is no special support for specific packages. (There is special support for enabling systemd services and working with systemd-sysusers though, since those are such common operations.)
Installation
The preferred method of installing konfigkoll is via your package manager. For Arch Linux it is available in AUR.
For other systems you will currently have to download the binary from GitHub releases
or build it yourself. The way to build it yourself is from the [git repository],
cargo install
from crates.io is not recommended (it will work, but you won't get
shell completion nor man pages).
There are three binaries of interest:
konfigkoll
- The main binary that will apply and save your configuration.konfigkoll-rune
- This provides LSP language server for the scripting language (Rune) used in konfigkoll. as well as some generic Rune utilities (such as auto-formatting code, though that has limitations currently).paketkoll
- A query tool similar todebsums
. Parts of its code is also used in konfigkoll, and as such they are maintained in the same git repository.
To build from source:
git clone https://github.com/VorpalBlade/paketkoll \
--branch konfigkoll-v0.1.0 # Replace with whatever the current version is
cd paketkoll
# Use one of these:
make install-konfigkoll
make install-paketkoll
# Or use this if you want both
make install
You can also select which features to build with, for example to skip the Arch Linux or Debian backends:
make install CARGO_FLAGS='--no-default-features --features debian,arch_linux,json,vendored'
# CARGO_FLAGS also work with the other install targets of course
Remove features from the comma separated list that you don't want. The features are:
arch_linux
- Pacman supportdebian
- Dpkg/Apt supportjson
- JSON output support (only relevant for paketkoll)vendored
- Use static libraries instead of linking to dynamic libraries on the host. This affects compression libraries currently, and not all compression libraries are in use for all distros. Currently, this only affects liblzma and libbz2 (both only needed on Debian).
Getting started
Creating a new configuration directory
The first step is to create a new configuration directory. You can get a template created using:
konfigkoll -c my_conf_dir init
This will create a few skeleton files in my_conf_dir
. It is useful to look
at what these files are:
main.rn
: This is the main entry module to your configuration. You can of course (and probably should, to keep things manageable) create additional modules and import them here.unsorted.rn
: This file will be overwritten when doingkonfigkoll save
. The idea is that you should look at this and move the changes you want to keep into yourmain.rn
(or supporting files)..gitignore
: This is a starting point for files to ignore when you check your config into git. You are going to version control it, right?files/
:save
will put files that have changed on the system here, and there are special commands to copy files fromfiles
to the system for use in your configuration.
The path infiles
should normally be the same as the path on the system (e.g.files/etc/fstab
), but if you have host specific configs you can use a different scheme (e.g.files/etc/fstab.hostname
).
The only hard requirements from konfigkoll
is main.rn
and unsorted.rn
. files
also has special convenient support. The rest is just a suggestion. You can
structure your configuration however you like.
If you are coming from aconfmgr this structure should feel somewhat familiar.
The configuration language
The configuration language in use is Rune, which is based on Rust when it comes to syntax. Unlike Rust, it is a dynamically typed language with reference counting, no need to worry about borrow checking, strict types or any of the other features that make Rust a bit of a learning curve.
The best documentation on the language itself is the Rune book, however for a basic configuration you won't need advanced features.
The main config file is structured in four phases that are called in order. This is done in order to speed up execution and allow file system and package scanning to start early in the background.
This is the basic structure of main.rn
(don't worry, we will go through it piece
by piece below):
/// This phase is for configuring konfigkoll itself and for system discovery.
/// You need to select which backends (pacman, apt, flatpak) to use here
///
/// Parameters:
/// - props: A persistent properties object that the script can use to store
/// data between phases
/// - settings: Settings for konfigkoll (has methods to enable backends etc)
pub async fn phase_system_discovery(props, settings) {
// Enable backends (if you want to be generic to support multiple distros
// you would do this based on distro in use and maybe hostname)
settings.enable_pkg_backend("pacman")?;
settings.enable_pkg_backend("flatpak")?;
settings.set_file_backend("pacman")?
Ok(())
}
/// Here you need to configure which directories to ignore when scanning the
/// file system for changes
pub async fn phase_ignores(props, cmds) {
// Note! Some ignores are built in to konfigkoll, so you don't need to add them here:
// These are things like /dev, /proc, /sys, /home etc. See below for the full list.
cmds.ignore_path("/var/cache")?;
cmds.ignore_path("/var/lib/flatpak")?;
cmds.ignore_path("/var/lib/pacman")?;
// ...
Ok(())
}
/// This is for installing any packages immediately that are later needed to be
/// *executed* by your main configuration. This should very rarely be needed.
pub async fn phase_script_dependencies(props, cmds) {
Ok(())
}
/// Main phase, this is where the bulk of your configration should go
///
/// It is recommended to use the "save" sub-command to create an initial
/// `unsorted.rn` file that you can then copy the parts you want from into here.
///
/// A tip is to use `konfigkoll -p dry-run save` the first few times to not
/// *actually* save all the files, this helps you figure out what ignores to add
/// above in `phase_ignores()` without copying a ton of files. Once you are happy
/// with the ignores, you can remove the `-p dry-run` part.
pub async fn phase_main(props, cmds, package_managers) {
Ok(())
}
Let's look at it once piece at a time:
System discovery
If you want to make your configuration generic to support multiple distros you need to do some conditional logic based on things detected by the system. This can vary in how refined it is. Let's say you just want to do this based on OD and hostname, then something like this might be a good starting point
pub async fn phase_system_discovery(props, settings) {
let sysinfo = sysinfo::SysInfo::new();
let os_id = sysinfo.os_id();
let host_name = sysinfo.host_name()?;
println!("Configuring for host {} (distro: {})", host_name, os_id);
// We need to enable the backends that we want to use
match os_id {
"arch" => {
settings.enable_pkg_backend("pacman")?;
settings.set_file_backend("pacman")?
}
"debian" => {
settings.enable_pkg_backend("apt")?;
settings.set_file_backend("apt")?
}
"ubuntu" => {
settings.enable_pkg_backend("apt")?;
settings.set_file_backend("apt")?
}
_ => return Err("Unsupported OS")?,
}
match host_name {
"mydesktop" => {
settings.enable_pkg_backend("flatpak")?;
}
"myserver" => {
// This doesn't have flatpak
}
}
Ok(())
}
Some Rune language features of interest here:
- The
match
statement. This is like acase
orswitch
statement in many other languages. - The use of
?
to propagate errors. This is a common pattern in Rust and Rune, and is used instead of exceptions that some other languages uses. Basically it means "if this is aResult::Error
, abort the function and propagate the error to the caller". - The use of
Result
is also why the function has a finalOk(())
at the end. This is because the function needs to return aResult
type, andOk(())
is a way to return a successful result with no value. - Why
()
you might ask? Well,()
is an empty tuple, and is used in Rust and Rune to represent "no value". This is a bit different from many other languages wherevoid
orNone
is used for this purpose. - You might expect to see
return Ok(());
instead ofOk(())
, but in Rust and Rune thereturn
keyword is optional if it is the final expression in the function. println!
is a macro that prints to stdout. It is similar toprintf
in C orconsole.log
in JavaScript. The!
is a special syntax for macros in Rust and Rune (and the reason it is a macro and not a function isn't really important here).
The other thing you might want to do in this phase is to set properties that you can then refer back to later. For example, you might want to abstract away checks like "install video editing software if this is one of these two computers" by setting a property in this phase and then checking it in the main phase instead of having checks for which specific hosts to install on everywhere. This makes it easier should you add yet another computer (fewer places to update in).
To support this props
can be used:
pub async fn phase_system_discovery(props, settings) {
// ...
props.set("tasks.videoediting", true);
// ...
Ok(())
}
pub async fn phase_main(props, settings) {
// ...
if props.get("tasks.videoediting") {
// Install kdenlive and some other things
}
// ...
Ok(())
}
Props is a simple key-value store that is persisted between phases. You can use
it however you want. It is basically a HashMap<String, Value>
where Value
can
be any type.
Even if you only have a single if statement for a particular property, it can be cleaner to separate out the checking for hardware and host name from the actual installations. This is especially true as the configuration grows.
Ignoring files
The next phase is to ignore files that you don't want to track. This is absolutely
required, as there is a bunch of things (especially in /var
) that aren't managed
by the package manager. In fact /var
is awkward since there also are managed
things under it. As such the ignore section grows long, it can be a good idea
to put this into a separate file and include it. Let's look at how that would be
done:
Your main.rn could look like this:
mod ignores;
// System discovery goes here still
/// Ignored paths
pub async fn phase_ignores(props, cmds) {
ignores::ignores(props, cmds)?
Ok(())
}
// The other later phases
In ignores.rn
you would then have:
pub fn ignores(props, cmds) {
cmds.ignore_path("/var/cache")?;
cmds.ignore_path("/var/lib/flatpak")?;
cmds.ignore_path("/var/lib/pacman")?;
// ...
Ok(())
}
The key here is the use of the mod
keyword to declare another module in the
same directory. This is similar to how you would do it in Rust, and is a way to
split up your configuration into multiple files.
You can also create nested submodules, which is covered in a later section of the manual.
Script dependencies
You probably won't need this phase, but it is there if you do. If you need to call out from your configuration to a program that isn't installed by default on a clean system, you should put it here. For example:
pub fn phase_script_dependencies(props, cmds) {
// We use patch in the main phase to apply some diff files to a package
cmds.add_pkg("pacman", "patch")?;
Ok(())
}
We can see here how to add a package, but this will be covered in more details in the documentation of the main phase.
The main phase
This is the bread and butter of your configuration. This is where you will do most of your work. This is where you will install packages, copy files, patch configurations, etc.
Let's look at the signature again:
pub async fn phase_main(props, cmds, package_managers) {
Ok(())
}
This takes three parameters:
props
we already know, it is the key value store introduced in the system discovery phase.cmds
we have seen (for how to add ignores for example) but it hasn't been covered in detail, we will get to that now.package_managers
is new, and is your interface to query for what the original contents of a file is. That is, before you changed it. This can be used to apply small changes such as "I want the stock/etc/nanorc
, but uncomment just this one line".
In fact, let's dwell a bit more on that last bullet point. That (apart from
wholesale copying and replacing configuration files) is the main approach to
configuration management in konfigkoll
.
This means you don't have to merge .pacnew
or .dpkg-dist
files anymore, just
reapply your config: it will apply the same change to the new version of the config.
Of course, it is possible the config has changed drastically, in which case you
still have to intervene manually, but almost always that isn't the case.
Now lets look at the cmds
parameter. This is where you describe your configuration.
It builds up a list of actions internally that will then be compared to the system
at the end by konfigkoll. That comparison is then used to either apply changes to the
system or save missing actions to unsorted.rn
.
The brunt of how this works in covered in the next two chapters (to prevent this section getting far too long):
There are also some speciality topics that are covered in a later chapter:
- Systemd (and other integrations)
- There are examples of how to solve specific things in the cookbook chapter.
There are also plans to publish a complete (but sanitised from sensitive info) example configuration in the future, this is not yet done.
Managing packages
This assumes you have read Getting started before. This chapter builds directly on that, specifically the section about the main phase (which in turn builds on earlier sections of that chapter).
Commands: Installing packages
As noted in the previous chapter, the key type for describing the system configuration
is Commands
. This includes installing packages. Let's look at a short example:
pub async fn phase_main(props, cmds, package_managers) {
cmds.add_pkg("pacman", "linux")?;
cmds.add_pkg("pacman", "linux-firmware")?;
Ok(())
}
This says that the packages linux
and linux-firmware
should be installed
if the package manager pacman
is enabled.
There are two things of note here:
- Konfigkoll ignores instructions to install packages for non-enabled package managers. This allows sharing a config across distros more easily.
- The above example actually says that only
linux
andlinux-firmware
should be installed. Any package that isn't explicitly mentioned (or a dependency of an explicitly mentioned package) will be removed. As such, you need to list all packages you want to keep.
There is also a cmds.remove_pkg
. You probably don't want to use it (since
all unmentioned packages are removed), the main purpose of it as a marker in
unsorted.rn
to tell you that a package is removed on the system compared to
your configuration.
Optional dependencies
Since Konfigkoll wants you to list all packages you want to keep (except for their dependencies which are automatically included), what about optional dependencies?
The answer is that you need to list them too, Konfigkoll (like aconfmgr) doesn't consider optional dependencies for the purpose of keeping packages installed.
Note: This is true for Arch Linux. For Debian the situation is currently different, but likely to change in the future to match that of Arch Linux. Debian support is currently highly experimental.
Note about early packages
As mentioned in the previous chapter
you can use phase_script_dependencies
to install packages that are needed by
the script itself during the main phase. The syntax (cmds.add_pkg
) is identical
to the main phase.
Package manager specific notes
Not all package managers are created equal, and konfigkoll tries to abstract over them. Sometimes details leak through though. Here are some notes on those leaks.
Flatpak
Flatpak doesn't really have the notion of manual installed packages vs dependencies. Instead, it has the notion of "applications" and "runtimes". That means you cannot yourself set a package as explicit/implicit installed. Konfigkoll maps "runtimes" to dependency and "applications" to explicit packages.
Managing files
This assumes you have read Getting started before. This chapter builds directly on that, specifically the section about the main phase (which in turn builds on earlier sections of that chapter).
Copying files
The most basic operation is to copy a file from the files
directory in your
configuration to the system. This is what save
will use when saving changes.
For example
pub async fn phase_main(props, cmds, package_managers) {
cmds.copy("/etc/fstab")?;
cmds.copy("/etc/ssh/sshd_config.d/99-local.conf")?;
Ok(())
}
This config would mean that:
- The file
files/etc/fstab
in your configuration should be copied to/etc/fstab
- The file
files/etc/ssh/sshd_config.d/99-local.conf
in your configuration should be copied to/etc/ssh/sshd_config.d/99-local.conf
- Every other (non-ignored) file on the system should be unchanged compared to the package manager.
Like with packages the configuration is total, that is, it should describe the system state fully.
Sometimes you might want to rename a file as you copy it. For example to have
host specific configs. /etc/fstab
is an example of where this can be a good
solution. Then you can use copy_from
instead of copy
:
pub async fn phase_main(props, cmds, package_managers) {
let sysinfo = sysinfo::SysInfo::new();
let host_name = sysinfo.host_name();
cmds.copy_from("/etc/fstab", `/etc/fstab.${host_name}`)?;
Ok(())
}
Here we can also see another feature: In strings surrounded by backquotes you
can use ${}
to interpolate variables. This is a feature of the Rune language.
You can also check if a file exists:
let candidate = std::fmt::format!("/etc/conf.d/lm_sensors.{}", host_name);
if cmds.has_source_file(candidate) {
cmds.copy_from("/etc/conf.d/lm_sensors", candidate)?;
}
This shows another way to format strings, using std::fmt::format!
. You can use
either.
Writing a file directly from the configuration
Sometimes you want to write a file directly from the configuration (maybe it is short,
maybe you have complex logic to generate it). This can be done with write
:
pub async fn phase_main(props, cmds, package_managers) {
ctx.cmds.write("/etc/NetworkManager/conf.d/dns.conf", b"[main]\ndns=dnsmasq\n");
ctx.cmds.write("/etc/hostname",
std::fmt::format!("{}\n", ctx.system.host_name).as_bytes())?;
ctx.cmds.write("/etc/sddm.conf", b"");
Ok(())
}
Some notes on what we just saw:
- We see here the notion of byte strings (
b"..."
). Unlike normal strings these don't have to be Unicode (UTF-8) encoded, though the Rune source file itself still does. But you can use escape codes (b"\001\003"
) to create non-UTF-8 data. write
only take byte strings, if you want to write a UTF-8 string you need to use.as_bytes()
on that string, as can be seen for/etc/hostname
.- The file
sddm.conf
will end up empty here. write
replaces the whole file in one go, there isn't anappend
. For patching files, see the next section.
Patching a file compared to the package manager state
Often times you want to use the standard config file but change one or two things about it. This can be done by extracting the file from the package manager, patching it and then writing it.
Here is a short example appending a line to a config file
// Specifically the package manager that is responsible for general
// files (as opposed to say flatpak)
let package_manager = package_managers.files();
// Get the contents of /etc/default/grub, then convert it
// to a UTF-8 string (it is a Bytes by default)
let contents = String::from_utf8(
package_manager.original_file_contents("grub", "/etc/default/grub")?)?;
// Push an extra line to it
contents.push_str("GRUB_FONT=\"/boot/grubfont.pf2\"\n");
// Add a command to write the file
cmds.write(file, contents.as_bytes())?;
This is a bit cumbersome, but abstractions can be built on top of this general pattern. In fact, a few such abstractions are already provided by Konfigkoll.
Patching a file with LineEditor
If you are at all familiar with sed, ::patch::LineEditor
is basically a Rune/Rust variant of that.
The syntax is different though (not a terse one-liner but a bit more verbose).
Let's look at patching the grub config again:
use patch::LineEditor;
use patch::Action;
use patch::Selector;
pub fn patch_grub(cmds, package_managers) {
let package_manager = package_managers.files();
let orig = String::from_utf8(package_manager.original_file_contents(package, file)?)?;
let editor = LineEditor::new();
// Replace the GRUB_CMDLINE_LINUX line with a new one
editor.add(Selector::Regex("GRUB_CMDLINE_LINUX="),
Action::RegexReplace("=\"(.*)\"$", "=\"loglevel=3 security=apparmor\""));
// Uncomment the GRUB_DISABLE_OS_PROBER line
editor.add(Selector::Regex("^#GRUB_DISABLE_OS_PROBER"),
Action::RegexReplace("^#", ""));
// Add a line at the end of the file (EOF)
editor.add(Selector::Eof,
Action::InsertAfter("GRUB_FONT=\"/boot/grubfont.pf2\""));
// Apply the commands to the file contents and get the new file contents
let contents = editor.apply(orig);
// Write it back
cmds.write(target_file, contents.as_bytes())?;
}
Here we can see the use of LineEditor
to:
- Replace a line matching a regex (and the replacement itself is a regex matching part of that line)
- Uncomment a line
- Add a line at the end of the file
The above also seems a bit cumbersome, but see the cookbook for a utility function that encapsulates this pattern.
LineEditor
has many more features, see the
API documentation
for more details. However, the general idea if that you have a Selector
that
selects what lines a given rule should affect, and an Action
that describes
how those lines should be changed.
Most powerfully a selector or an action can be a function that you write, so arbitrary complex manipulations are possible. Nested programs are also possible to operate on multiple consecutive lines:
// Uncomment two consecutive lines when we encounter [multilib]
// This is equivalent to /\[multilib\]/ { s/^#// ; n ; s/^#// } in sed
let sub_prog = LineEditor::new();
sub_prog.add(Selector::All, Action::RegexReplace("^#", ""));
sub_prog.add(Selector::All, Action::NextLine);
sub_prog.add(Selector::All, Action::RegexReplace("^#", ""));
editor.add(Selector::Regex("\\[multilib\\]"), Action::sub_program(sub_prog));
Patching a file via invoking an external command
Sometimes sed line expressions don't cut it, and you don't want to write the
code in Rune, you just want to reuse an existing command. This can be done with
the process
module to invoke an external command. This will be
covered in the advanced section.
Other file operations (permissions, mkdir, symlinks etc.)
Writing files is not all you can do, you can also:
- Change permissions (owner, group, mode)
- Create symlinks
- Create directories
These are all covered in the API documentation, but they are relatively simple operations compared to all the variations of writing file contents, so there will only be a short example:
// Create a directory and make it root only access
cmds.mkdir("/etc/cni")?;
cmds.chmod("/etc/cni", 0o700)?;
// You could also write either of these and they would mean the same thing:
cmds.chmod("/etc/cni", "u=rwx")?;
cmds.chmod("/etc/cni", "u=rwx,g=,o=")?;
// Create a directory owned by colord:colord
cmds.mkdir("/etc/colord")?;
cmds.chown("/etc/colord", "colord")?;
cmds.chgrp("/etc/colord", "colord")?;
// Create a symlink
cmds.ln("/etc/localtime", "/usr/share/zoneinfo/Europe/Stockholm")?;
Integrations (passwd, systemd, etc)
Konfigkoll has various convenient integrations for common system files and services.
See the various sub-pages in this chapter for more details.
Systemd units
Konfigkoll has special support for enabling and masking systemd units. This
simplifies what would otherwise be a bunch of cmds.ln()
calls. In particular,
it will handle Alias
and WantedBy
correctly
Enabling units from packages
The basic form is:
systemd::Unit::from_pkg("gpm",
"gpm.service",
package_managers.files())
.enable(cmds)?;
This will load the unit file from the package manager and figure out what symlinks needs to be created to enable the unit.
Some units are parameterised, this can be handled by using the name
method:
systemd::Unit::from_pkg("systemd",
"getty@.service",
package_managers.files())
.name("getty@tty1.service")
.enable(cmds)?;
User units can also be enabled. This enables user units globally (/etc/systemd/user
),
not per-user:
systemd::Unit::from_pkg("xdg-user-dirs",
"xdg-user-dirs-update.service",
package_managers.files())
.user()
.enable(cmds)?;
You can skip automatically installing WantedBy
symlinks by using:
systemd::Unit::from_pkg("avahi",
"avahi-daemon.service",
package_managers.files())
.skip_wanted_by()
.enable(cmds)?;
A similar option is also available for Alias
.
Enabling custom units
If you have a unit you install yourself that doesn't come from a package you can do this:
cmds.copy("/etc/systemd/system/kdump.service")?;
systemd::Unit::from_file("/etc/systemd/system/kdump.service", cmds)?
.enable(cmds)?;
All the other options described in the previous section are also available for these types of units.
Caveats
While WantedBy
and Alias
are handled correctly, Also
is not processed,
if you want such units you have to add them manually. The reason is that these
could come from a different package, and we don't know which one.
We could find out for installed packages, but what if it is from a package that isn't yet installed? This can happen since we build the configuration first, then install packages.
Managing /etc/passwd, /etc/group and shadow files
Konfigkoll has special support for managing /etc/passwd
, /etc/group
and
/etc/shadow
. This is because these files contain contents from multiple
sources (various packages add their own users) and it is difficult to manage
these otherwise.
The interface to this is the ::passwd::Passwd
type
(API docs).
Typically, you would:
- Create an instance of
::passwd::Passwd
early in the main phase - Add things to it as needed (next to the associated packages)
- Apply it at the end of the main phase
A rough example (we will break it into chunks down below):
// Mappings for the IDs that systemd auto-assigns inconsistently from computer to computer
const USER_MAPPING = [("systemd-journald", 900), /* ... */]
const GROUP_MAPPING = [("systemd-journald", 900), /* ... */]
pub async fn phase_main(props, cmds, package_managers) {
let passwd = passwd::Passwd::new(USER_MAPPING, GROUP_MAPPING)?;
let files = package_managers.files();
// These two files MUST come first as other files later on refer to them,
// and we are not order independent (unlike the real sysusers.d).
passwd.add_from_sysusers(files, "systemd", "/usr/lib/sysusers.d/basic.conf")?;
passwd.add_from_sysusers(files, "filesystem", "/usr/lib/sysusers.d/arch.conf")?;
// Various other packages and other changes ...
passwd.add_from_sysusers(files, "dbus", "/usr/lib/sysusers.d/dbus.conf")?;
// ...
// Give root a login shell, we don't want the default /usr/bin/nologin!
passwd.update_user("root", |user| {
user.shell = "/bin/zsh";
user
});
// Add human user
let me = passwd::User::new(1000, "me", "me", "");
me.shell = "/bin/zsh";
me.home = "/home/me";
passwd.add_user_with_group(me);
passwd.add_user_to_groups("me", ["wheel", "optical", "uucp", "users"]);
// Don't store passwords in your git repo, load them from the system instead
passwd.passwd_from_system(["me", "root"]);
// Deal with the IDs not matching (because the mappings were created
// before konfigkoll was in use for example)
passwd.align_ids_with_system()?;
// Apply changes
passwd.apply(cmds)?;
}
USER_MAPPING
and GROUP_MAPPING
First up, there is special support for systemd's /usr/lib/sysusers.d/
files.
These often don't declare the specific user/group IDs, but instead auto-assign them.
This creates a bit of chaos between computers and there is no auto-assign logic
in Konfigkoll (yet?). To solve both of these issues we need to declare which
IDs we want for the auto-assigned IDs if we are to use sysusers.d
-integration.
That is what the USER_MAPPING
and GROUP_MAPPING
constants are for.
General workflow
The idea is (as stated above) to create one instance of Passwd
, update it
as you go along, and then write out the result at the end:
let passwd = passwd::Passwd::new(USER_MAPPING, GROUP_MAPPING)?;
// Do stuff
passwd.apply(cmds)?;
Now, what about the "stuff" you can "do"?
Adding a system user / group
The easiest option (when available) is passwd.add_from_sysusers
. Arch Linux
uses this for (almost?) all users created by packages. Debian however doesn't.
If there isn't a corresponding sysusers file to add you need to create the user yourself. This will be pretty much like the example of adding a human user below.
Patching a user or group
Sometimes you need to make changes to a user or group created by sysusers. This
can be done by passing a function to passwd.update_user
or passwd.update_group
.
// Give root a login shell, we don't want the default /usr/bin/nologin!
passwd.update_user("root", |user| {
user.shell = "/bin/zsh";
user
});
The |...| { code }
syntax is a closure, a way to declare an inline function
that you can pass to another function. The bits between the |
are the parameters
that the function takes.
Adding a human user
There isn't too much code needed for this (and remember, you could always create a utility function if you need this a lot):
// Add human user
let me = passwd::User::new(1000, "me", "me", "");
me.shell = "/bin/zsh";
me.home = "/home/me";
// Add them to the passwd database (and automatically create a corresponding group)
passwd.add_user_with_group(me);
// Add the user to some extra groups as well
passwd.add_user_to_groups("me", ["wheel", "optical", "uucp", "users"]);
Passwords
What about setting the password? Well, it isn't good practise to store those passwords in your git repository. Instead, you can read them from the system:
passwd.passwd_from_system(["me", "root"]);
This will make me
and root
have whatever password hashes they current already
have on the system.
IDs not matching
If you already have several computers before starting with konfigkoll, chances
are the user and group IDs don't match up. This can be fixed with passwd.align_ids_with_system
.
This will copy the IDs from the system so they match up.
Of course the assignment of IDs on now your computers won't match, but the users and groups will match whatever IDs are on the local file system.
Getting system information
Getting information about the system (host name, distro, architecture, hardware, etc.) is important in order to make a robust config for multiple computers.
For example, rather than listing exactly which computers should have intel-ucode
installed for the microcode firmware, you can look at the CPU vendor and determine
if it should have Intel or AMD microcode.
Konfigkoll exposes this via the sysinfo
module
(API docs).
Currently, this is a bit of work in progress and the API is likely to be expanded, in particular around detecting PCI devices (GPUs etc.).
Advanced topics
This chapter covers some more advanced features of Konfigkoll.
See the sub-pages for more details.
Invoking external commands
This assumes you have read Managing Files before. This chapter builds directly on that.
If using LineEditor
or custom Rune code doesn't cut it you can invoke external
commands. Be careful with this as you could easily make your config non-idempotent.
Idempotency is a fancy way of saying "running the same thing multiple times gives the same result". This is important for a configuration management system as you want it to be deterministic.
In particular, you should not use external commands to write directly to the system. Instead, you should use a temporary directory if you need filesystem operations.
Example with patch
The following example shows how to use patch
to apply a patch file:
async fn patch_zsh(cmds, package_managers) {
// This is relative the config directory
let patch_file = "patches/zsh-modutils.patch";
// Package we will patch
let pkg = "zsh";
// The file we want to patch
let file = "/usr/share/zsh/functions/Completion/Linux/_modutils";
// Create a temporary directory to operate in
let tmpdir = filesystem::TempDir::new()?;
let tmpdir_path = tmpdir.path();
// Read the original file from the package manager
let orig = package_managers.files().original_file_contents(pkg, file)?;
// Write out the original file to the temporary directory, and store the
// full path to it for later use
let orig_path = tmpdir.write("orig", orig)?;
// We need to know the full path to the patch file, to give it to patch
let absolute_patch_path = filesystem::config_path() + "/" + patch_path;
// Create a command that describes how to invoke patch
let command = process::Command::new("patch");
command.arg(orig_path);
command.arg(absolute_patch_path);
// Start the command
let child = command.spawn()?;
// Wait for the command to complete
child.wait().await?;
// Load contents back after patch applied it
let patched = tmpdir.read("orig")?;
// Add a command to write out the changed file
cmds.write(file, patched)?;
Ok(())
}
As can be seen this is quite a bit more involved than using LineEditor
(but the pattern can be encapsulated, see the cookbook).
There are also some other things to note here:
- What's up with
async
andawait
? This will be covered in the next section. - The use of
TempDir
to create a temporary directory. The temporary directory will be automatically removed once the variable goes out of scope. - External processes are built up using a builder object
process::Command
, and are then invoked. You can build pipelines and handle stdin/stdout/stderr as well, see the API docs for details on that.
Async and await
You might have noticed async fn
a few times before, without it ever being
explained. It is an advanced feature and not one you really need to use much
for Konfigkoll.
However, the basic idea is that Rust and Rune have functions that can run concurrently. These are not quite like threads, instead they can run on the same thread (or separate ones) but can be paused and resumed at certain points. For example when waiting for IO (or an external process to complete), you could be doing something else.
Konfigkoll uses this internally on the Rust side to do things like scanning the file system for changes at the same time as processing your configuration.
For talking to external processes this leaks through into the Rune code (otherwise you don't really need to care about it).
Here is what you have to keep in mind:
-
When you see an
async fn
in the API docs, you need to call it like so:let result = some_async_fn().await;
This means that when
some_async_fn
is called we should wait for it's output. -
You can only use async functions from other async functions. That is, you can't call an async function from a non-async function. So your
phase_main
must also be async and so does the whole chain in between your phase_main and the async API function. -
Async functions don't execute *until they are awaited. That means, they do nothing until you
await
them. They won't magically run in the background unless you specifically make them do so (see below).
Awaiting multiple things
If you want to do multiple things in parallel yourself, you don't need to
immediately await
the async fn
, the key here is that it has to be awaited
eventually. Using std::future::join
you can wait for multiple async functions:
// Prepare a whole bunch of patch jobs
let patches = [];
patches.push(do_patch(cmds, package_managers, "patches/etckeeper-post-install.patch"));
patches.push(do_patch(cmds, package_managers, "patches/etckeeper-pre-install.patch"));
patches.push(do_patch(cmds, package_managers, "patches/zsh-modutils.patch"));
// Run them and wait for them all
let results = std::future::join(patches).await;
// Process the results to propagate any errors
for result in results {
result?;
}
Host file system access
This assumes you have read Managing Files before. This chapter builds directly on that.
Like with the previous chapter on processes this is an advanced feature that can be dangerous! In particular be careful, you could easily make your config non-idempotent.
Idempotency is a fancy way of saying "running the same thing multiple times gives the same result". This is important for a configuration management system as you want it to be deterministic.
With that said: Konfigkoll allows you read-only access to files on the host. Some example of use cases:
- The main purpose of this is for things that shouldn't be stored in your git
managed configuration, in particular for passwords and other secrets:
- Hashed passwords from
/etc/shadow
(use the special support forpasswd
instead though, it is a better option) - Passwords for wireless networks
- Passwords for any services needed (such as databases)
- Hashed passwords from
- Another use case is to read some system information from
/sys
that isn't already exposed by other APIs
Now, the use case of /etc/shadow
is better served by the built-in
passwd
module. But let's look at some of the
other use cases.
Read from /sys
let is_uefi = filesystem::exists("/sys/firmware/efi")?;
This determines if /sys/firmware/efi
exists, which indicates that this system
is using UEFI.
Read password for a NetworkManager network
The idea here is that we still want to manage our network configurations, but we don't want to store the password in our git repository. Instead, we can read that back from the system before applying the config.
// Get the type of network (wifi or not) and the password for the network
fn parse_sys_network(network_name) {
// Open the file (with root privileges)
let fname = `/etc/NetworkManager/system-connections/${network_name}.nmconnection`;
let f = filesystem::File::open_as_root(fname)?;
// Read the contents of the file
let old_contents = f.read_all_string()?;
// Split it out and parse it
let lines = old_contents.split("\n").collect::<Vec>();
// Iterate over the lines to find the psk one
let psk = lines.iter()
.find(|line| line.starts_with("psk="))
.map(|v| v.split("=")
.collect::<Vec>()[1]);
// Do the same, but for the network type
let net_type = lines.iter()
.find(|line| line.starts_with("type="))
.map(|v| v.split("=")
.collect::<Vec>()[1]);
Ok((net_type, psk))
}
We can then use this to patch our network configs before we apply them:
pub fn nm_add_network(cmds, package_managers, hw_type, network_name) {
// Get PSK from system
if let (net_type, psk) = parse_sys_network(network_name)? {
if net_type == Some("wifi") {
let fname = `/etc/NetworkManager/system-connections/${network_name}.nmconnection`;
let edit_actions = [
(Selector::Regex("^psk=PLACEHOLDER"),
Action::Replace(format!("psk={}", psk.unwrap()))),
];
// Laptops should auto-connect to Wifi, desktops shouldn't,
// they use ethernet normally
if hw_type == SystemType::Laptop {
edit_actions.push((Selector::Regex("^autoconnect=false"),
Action::Delete));
}
// This is a wrapper for LineEditor, see the cook-book chapter
patch_file_from_config(cmds, package_managers, fname, edit_actions)?;
// The file should be root only
cmds.chmod(fname, 0o600)?;
}
} else {
return Err("Network not found")?;
}
Ok(())
}
This could then be used like this:
nm_add_network(cmds, package_managers, hw_type, "My Phone Hotspot")?;
nm_add_network(cmds, package_managers, hw_type, "My Home Wifi")?;
nm_add_network(cmds, package_managers, hw_type, "Some other wifi")?;
Cookbook: Examples & snippets
This contains a bunch of useful patterns and functions you can use in your own configuration.
Using strong types
While props
is a generic key value store for passing info between the phases,
it is easy to make a typo (was it enable_disk_ecryption
or use_disk_encryption
, etc.?)
A useful pattern is to define one or a few struct that contains all your properties and store that, then extract it at the start of each phase that needs it.
pub struct System {
cpu_arch,
cpu_feature_level,
cpu_vendor,
has_wifi,
host_name,
os,
// ...
}
pub struct Tasks {
cad_and_3dprinting,
development,
development_rust,
games,
office,
photo_editing,
video_editing,
// ...
}
pub async fn phase_system_discovery(props, settings) {
/// ...
// This has system discovery info
props.set("system", system);
// This defines what tasks the system will fulfill
// (like "video editing" and "gaming")
props.set("tasks", tasks);
Ok(())
}
pub async fn phase_main(props, cmds, package_managers) {
// Extract the properties
let system = props.get("system")?;
let tasks = props.get("tasks")?;
// ...
if tasks.gaming {
// Install steam
package_managers.apt.install("steam")?;
}
// ...
Ok(())
}
Now, when you access e.g. tasks.gaming
you will get a loud error from Rune if you
typo it, unlike if you use the properties directly.
Creating a context object
This is a continuation of the previous pattern, and most useful in the main phase:
You might end up with helper functions that need a large number of objects passed to them:
fn configure_grub(
props,
cmds,
package_managers,
system,
tasks,
passwd)
{
// ...
}
What if you need yet another one? No, the solution here is to pass a single context object around:
/// This is to have fewer parameters to pass around
pub struct Context {
// properties::Properties
props,
// commands::Commands
cmds,
// package_managers::PackageManagers
package_managers,
// System
system,
// Tasks
tasks,
// passwd::Passwd
passwd,
}
pub async fn phase_main(props, cmds, package_managers) {
let system = props.get("system")?;
let tasks = props.get("tasks")?;
let passwd = passwd::Passwd::new(tables::USER_MAPPING, tables::GROUP_MAPPING)?;
let ctx = Context {
props,
cmds,
package_managers,
system,
tasks,
passwd,
};
configure_grub(ctx)?;
configure_network(ctx)?;
configure_systemd(ctx)?;
configure_gaming(ctx)?;
// ...
Ok(())
}
Patching files ergonomically with LineEditor
Using LineEditor
directly can get verbose. Consider this (using the context
object idea from above):
/// Patch a file (from the config directory)
///
/// * cmds (Commands)
/// * package_anager (PackageManager)
/// * package (string)
/// * file (string)
/// * patches (Vec<(Selector, Action)>)
pub fn patch_file_from_config(ctx, file, patches) {
let package_manager = ctx.package_managers.files();
let fd = filesystem::File::open_from_config("files/" + file)?;
let orig = fd.read_all_string()?;
let editor = LineEditor::new();
for patch in patches {
editor.add(patch.0, patch.1);
}
let contents = editor.apply(orig);
ctx.cmds.write(file, contents.as_bytes())?;
Ok(())
}
/// Patch a file (from a package) to a new destination
///
/// * cmds (Commands)
/// * package_anager (PackageManager)
/// * package (string)
/// * file (string)
/// * target_file (string)
/// * patches (Vec<(Selector, Action)>)
pub fn patch_file_to(ctx, package, file, target_file, patches) {
let package_manager = ctx.package_managers.files();
let orig = String::from_utf8(package_manager.original_file_contents(package, file)?)?;
let editor = LineEditor::new();
for patch in patches {
editor.add(patch.0, patch.1);
}
let contents = editor.apply(orig);
ctx.cmds.write(target_file, contents.as_bytes())?;
Ok(())
}
Then you can use this as follows:
crate::utils::patch_file(ctx, "bluez", "/etc/bluetooth/main.conf",
[(Selector::Regex("#AutoEnable"), Action::RegexReplace("^#", "")),
(Selector::Regex("#AutoEnable"), Action::RegexReplace("false", "true"))])?;
Much more compact! In general, consider creating utility functions to simplify common patterns in your configuration. Though there needs to be a balance, so you still understand your configuration a few months later. Don't go overboard with the abstractions.
Patching using patch
This builds on the example in Processes (advanced):
pub async fn apply_system_patches(ctx) {
let patches = [];
patches.push(do_patch(ctx, "patches/etckeeper-post-install.patch"));
patches.push(do_patch(ctx, "patches/etckeeper-pre-install.patch"));
patches.push(do_patch(ctx, "patches/zsh-modutils.patch"));
let results = std::future::join(patches).await;
for result in results {
result?;
}
Ok(())
}
async fn do_patch(ctx, patch_path) {
// Load patch file
let patch_file = filesystem::File::open_from_config(patch_path)?;
let patch = patch_file.read_all_bytes()?;
let patch_as_str = String::from_utf8(patch)?;
// The first two lines says which package and file they apply to, extract them
let lines = patch_as_str.split('\n').collect::<Vec>();
let pkg = lines[0];
let file = lines[1];
// Create a temporary directory
let tmpdir = filesystem::TempDir::new()?;
let tmpdir_path = tmpdir.path();
// Read the original file
let orig = ctx.package_managers.files().original_file_contents(pkg, file)?;
let orig_path = tmpdir.write("orig", orig)?;
let absolute_patch_path = filesystem::config_path() + "/" + patch_path;
// Shell out to patch command in a temporary directory
let command = process::Command::new("patch");
command.arg(orig_path);
command.arg(absolute_patch_path);
let child = command.spawn()?;
child.wait().await?;
// Load contents back
let patched = tmpdir.read("orig")?;
ctx.cmds.write(file, patched)?;
Ok(())
}
Here the idea is to parse the patch file, which should contain some metadata
at the top for where it should be applied to. Patch will ignore text at the very
top of a diff file and only handle the file from the first ---
. For example:
etckeeper
/usr/share/libalpm/hooks/05-etckeeper-pre-install.hook
--- /proc/self/fd/12 2022-12-19 17:36:30.026865507 +0100
+++ /usr/share/libalpm/hooks/05-etckeeper-pre-install.hook 2022-12-19 12:43:40.751631786 +0100
@@ -4,8 +4,8 @@
Operation = Install
Operation = Upgrade
Operation = Remove
-Type = Path
-Target = etc/*
+Type = Package
+Target = *
[Action]
Description = etckeeper: pre-transaction commit
API documentation
Generated Rune API documentation is available here.
This covers both the Rune standard library (std
, json
, toml
) and the
konfigkoll specific APIs.
Defaults
This section documents some defaults for settings in Konfigkoll.
Default ignores
Some paths are always ignored in the file system scan:
**/lost+found
/dev/
/home/
/media/
/mnt/
/proc/
/root/
/run/
/sys/
/tmp/
/var/tmp/
Default early configurations
Some configurations are always applied early (before packages are installed)
in the configuration process (you can add additional with settings.early_config
during the system discovery phase):
/etc/passwd
/etc/group
/etc/shadow
/etc/gshadow
The reason these are applied early is to ensure consistent ID assignment when installing packages that want to add their own IDs.
Default sensitive configurations
Konfigkoll will not write out the following files when you use save
, no matter
what. This is done as a security measure to prevent accidental leaks of sensitive
information:
/etc/shadow
/etc/gshadow
You can add additional files to this list with settings.sensitive_file
during
the system discovery phase.
Limitations
This chapter documents some known limitations of Konfigkoll.
Also consider checking the issue tracker on GitHub for more potential limitations.
Limitations due to underlying distro
Debian
Metadata
On Debian, apt
/dpkg
doesn't provide a lot of information about the files
installed by a package. In fact, it only provides the MD5 sum of regular files
and the list of non-regular files (without info about what type of non-regular
file they are). As a workaround we instead pull the data from the cached downloaded
.deb
files. There are a number of implications of this:
- We need the apt package cache to be populated. We will download missing packages as needed to the cache.
- Reading the compressed packages is slow (very slow) so we cache the summary
data in a disk cache, (typically 50-250 MB depending on the number of
installed files). That disk cache will be located in
~/.cache/konfigkoll
by default. If you run withsudo
it will be root's home directory that contains this.
Services
Debian is, unlike Arch Linux, not yet fully systemd-ified. This means that some of the integrations (systemd services, systemd-sysusers) are less useful. Debian support is currently work in progress and solution for this will be designed at later point in time.
Configuration files
Unlike Arch Linux, Debian has multiple ways to handle configuration files:
- As part of package, installed with apt/dpkg: This will work like on Arch Linux,
where you can patch files. Crucially if you run
dpkg-query -S /etc/some/file
and it returns a package name, it is this case. - UCF, where post install actions copy/merge the file from somewhere in
/usr/share
to/etc
. You will have to emulate this with a copy from the same location in your configuration. Try grepping for the config file of interest in/var/lib/dpkg/info/*.postinst
to find out what is going on. - Like UCF but free form: Basically the same but with ad-hoc logic instead of
the
ucf
commands. Same solution (but slightly more annoying to figure out as it isn't standardised). - Like the above case but with no source file: Sometimes the post install script just checks if the config file exists on the system, and if not echos some embedded text into the config file. There is nothing to copy from here, original file queries will not help you. You will simply have to maintain your own copy of the file. There is no sane solution for this case, unfortunately.
Limitations due to not yet being implemented
- Certain errors can be delayed from when they happen to they are reported. This happens because of the async runtime in use (tokio) and how it handles (or rather not handles) cancelling synchronous background tasks.
- Some of the exposed API is work in progress:
- Sysinfo PCI devices is the most notable example.
- The process API is also not fully fleshed out (no way to provide stdin to child processes).
- The regex API is rather limited, and will have to be fully redesigned using a lower level Rust crate at some point.
- There are plans to do privilege separation like aconfmgr does. This is not yet implemented.
- There is not yet support for creating FIFOs, device nodes etc. Or rather, there is, it just isn't hooked up to the scripting language yet (nor tested).
Things that won't get implemented (probably)
This is primarily a comparison with aconfmgr, as that is the closest thing to Konfigkoll that exists.
- Aconfmgr has special support for some AUR helpers. Konfigkoll doesn't.
- For a start, I use aurutils which works differently than the helpers aconfmgr supports in that it uses a custom repository. The main purpose of the aconfmgr integration is to work around the lack of such custom repositories.
- It would be very Arch Linux specific, and it would be hard to abstract over this in a way that would be useful for other distros. The reason Konfigkoll exists is to let me manage my Debian systems in the same way as my Arch Linux systems, so this is not a priority. That Konfigkoll is also much faster is a nice bonus.
Development: Design overview
This is aimed at people wanting to work on the Rust code of Konfigkoll & Paketkoll.
Konfigkoll builds upon Paketkoll (in fact paketkoll was first written as a stepping stone in the konfigkoll development process).
Paketkoll design
Backends
Paketkoll (the library paketkoll_core
that is, the cli is just a thin wrapper
on top) is centred around some core traits:
Files
: A backend for querying package manager file information.Packages
: A backend for querying package manager package information.
A backend is anything that can implement one or both of these: Pacman, Apt, Flatpak, etc.
To add support for a new package manager you would need to implement these traits
for it. Flatpak only implements Packages
as it doesn't manage files system-wide,
so you may not need to implement both.
Along with these traits are a number of structs and enums that are used by the trait methods.
Of note is that some strings are interned (that is, they are stored once and
referred to with a single 32-bit integer). This is done to save memory, as things
like package names gets repeated a lot. The PackageRef
and ArchitectureRef
types are used for the interned strings.
Operations
The other part of paketkoll are some algorithms that take data from the above
traits. These live in file_ops.rs
and package_ops.rs
. This includes finding
where a file comes from, checking the integrity of files, etc.
Of note is that the integrity checking generate list of Issue
structs describing
the discrepancies found. These are then printed by the cli or used by konfigkoll.
Crates
paketkoll_core
: The core library that does the heavy lifting (as described above).paketkoll_cache
: Actually only used by konfigkoll, implements a disk cache for slow queries to the backends.paketkoll_types
: Defines some core data types that are used by the other crates.paketkoll_utils
: Misc utility functions.paketkoll
: The command line interface.mtree2
: A fork of themtree
crate that fixes some outstanding issues. Used by the pacman backend.systemd_tmpfiles
: A crate that parses systemdtmpfiles.d
files. Works fine, but turned out not be very useful for comparing system state. Never got integrated into konfigkoll. Support in paketkoll is not included by default.
Konfigkoll design
As stated above, konfigkoll builds on the paketkoll_core
crate for it's core
system interactions. On top of that it adds the logic to apply changes based on
a script. It is split into multiple crates:
konfigkoll_types
: This just defines some core data types that are used by the other crates.konfigkoll_core
: This deals with:- Take a list of paketkoll
Issue
structs and convert it to a set of primitive konfigkoll instructions. - Build a stateful model based on streams of instructions.
- Diff two such states to produce a new stream of instructions describing the differences between them. We need the stateful model to handle implicit instructions (otherwise the fact that e.g. creating a directory creates it as owned by root with certain modes couldn't be handled implicitly)
- Apply a stream of instructions to the system (possibly asking interactively)
- Save a stream of instructions to
unsorted.rn
.
- Take a list of paketkoll
konfigkoll_hwinfo
: Hardware info (PCI devices currently)konfigkoll_script
: The rune scripting language interface and custom extension modules for Rune.konfigkoll_utils
: Misc utility functions to decouple compilingkonfigkoll_script
fromkonfigkoll_core
(in order to speed up incremental builds).konfigkoll
: The command line interface, and a fair bit of glue and driving logic (unlike paketkoll there is a fair bit more here than just command line parsing and printing).