Cook your local environment with mise-en-place
. And others.
I try to keep as much of my relevant toolchain as up to date as possible, without loosing control over it and breaking compatibility for different projects.
This boils down to
- Enable independent versions of the same program to be installable
- e.g. I want java-21 but also java-17 in a different project
- Define the installed/active programs and configuration as code
- Enable per-context activation of different versions
There are many different approaches to achieve this, e.g.
- Virtualization
- Manually managed Project-context virtual machines
- Built-in e.g. with qubes-OS or spectrum-os
- Powerful due to the level of isolation (security, flexibility)
- Highest overhead
- Alternative distributions/package managers
- Containerization
- devcontainers
- Custom build containers
- shim managers
I have played around/worked with a few of the given options and changed my setup a few times already. Maybe the observations might be useful to you, or my later self.
Boundary conditions
The boundary conditions of my setup are as follows
- The target system is Linux only. My windows experience is not too deep, but I don’t think that something I want is easily achievable on windows.
- I have most of my system setup experience with Debian, as I have been running, breaking, fixing and working with Debian unstable for 17 years.
- I have quite a bit of experience with Ubuntu
- In general, I am very happy with Debian and am not interested in distro-hopping. Switching away from Debian would have to be motivated by significant advantages and no downsides
- I want my setup to be portable between my personal (Debian based) machines and machines I don’t have full control over
Path to the current setup
Virtualization
On my personal setup, I don’t have any usecases requiring windows or other non-Linux operating systems, and my security requirements are not that high. Consequently, I have not created and worked with a virtualization setup enough to form an opinion strong enough to write about.
Nix
Basics
Nix, and with it NixOS, revolve around a functional domain specific language
that can fully declare environments, including installed packages/tools, making
use of the Nixpkgs
packages collection, as well as their configuration.
These environments are fully self-contained, reproducible and very flexible. With Nix, it is possible to declare the desired system state, but also independent environments in e.g. the context of a specific development project, or a temporary test environment that can be created and discarded afterwards.
This example defines a nix-shell
environment with a specific ruby version:
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
# nativeBuildInputs is usually what you want -- tools you need to run
nativeBuildInputs = with pkgs.buildPackages; [ ruby_3_2 ];
}
What’s especially powerful with Nix is the fully self-contained and
reproducible build definition, which includes the full definition of the build
environment, as shown in this example, defining the build of chord
:
{
pkgs ? import (fetchTarball {
url = "https://github.com/NixOS/nixpkgs/archive/4fe8d07066f6ea82cda2b0c9ae7aee59b2d241b3.tar.gz";
sha256 = "sha256:06jzngg5jm1f81sc4xfskvvgjy5bblz51xpl788mnps1wrkykfhp";
}) {}
}:
pkgs.stdenv.mkDerivation rec {
pname = "chord";
version = "0.1.0";
src = pkgs.fetchgit {
url = "https://gitlab.inria.fr/nix-tutorial/chord-tuto-nix-2022";
rev = "069d2a5bfa4c4024063c25551d5201aeaf921cb3";
sha256 = "sha256-MlqJOoMSRuYeG+jl8DFgcNnpEyeRgDCK2JlN9pOqBWA=";
};
buildInputs = [
pkgs.simgrid
pkgs.boost
pkgs.cmake
];
configurePhase = ''
cmake .
'';
buildPhase = ''
make
'';
installPhase = ''
mkdir -p $out/bin
mv chord $out/bin
'';
Nix’ development is driven by a very active expert community and happens at an impressively quick pace. Most activity, understandably, targets NixOS, not Nix as third-party package manager.
Nix works by having a package store, which contains every package in every installed version in a unique directory, distinguished with a unique hash. In any requested environment, specific versions of everything are “activated” specifically.
The result resembles Containers a lot, but with full system integration as upside and less flexibility as downside. It’s even possible to build an OCI compatible container image out of a Nix environment.
My Caveat
I only tested Nix as package manager on Debian, which, I have to admit, is
somewhat limited. The main real issue I’ve had was with regards to OpenGL
,
which is not supported in non-NixOS environments. This prevents any programs to
make proper use of graphics cards, and when I tested it, I tried to define my
sway
setup with Nix. Which did not work without a lot of hassle.
There are solutions, but it’s all pretty meh. Besides that, the package management with Nix was pretty slow, taking a lot of additional space and (for my usecases and experience) less powerful than using “normal” OCI containers with Docker/podman.
Guix
Basics
Guix resembles Nix quite a bit. The principle is the same, you use a functional programming language to fully declare the desired state of your system/package/…, different versions of stuff are put into a package store thing and are activated specifically, it’s a dedicated Distribution (Guix System) and a package manager (Guix), etc.
The main difference is that the theoretical foundation is very nice, clean and impressive. There is an extremely strong focus on software freedom, which is nice, and it doesn’t use a domain specific (and hence a bit limited) description language, but a full fledged functional programming language, GNU Guile, which is a Scheme implementation. It’s pretty nice. And also, pretty weird.
This is an example of a package definition for postgresql-autodoc
, which I
created and put into my guix-channel
, which is pretty easy to do. Which is,
again, pretty nice.
(define-module (postgresql-autodoc)
#:use-module (guix utils)
#:use-module ((guix licenses) #:prefix license:)
#:use-module (guix packages)
#:use-module (guix git-download)
#:use-module (guix build-system gnu)
#:use-module (gnu packages base)
#:use-module (gnu packages perl)
#:use-module (gnu packages web)
#:use-module (gnu packages databases))
(define-public postgresql-autodoc
(package
(name "postgresql-autodoc")
(version "80a6b150febb5c0c91f2daa433cc089ff1278841")
(source
(origin
(method git-fetch)
(uri (git-reference
(url "https://github.com/cbbrowne/autodoc")
(commit version)))
(file-name (git-file-name name version))
(sha256
(base32
"0ip1xy5qmm5hj392vxfs5fkgxgdv8d9kj7pp6p4x79npxczkxyzd"))))
(build-system gnu-build-system)
(arguments
'(#:phases
(modify-phases %standard-phases
(delete 'configure)
(delete 'check)
(add-before 'install 'patch-prefix
(lambda _
(substitute* (cons* "Makefile")
(("/usr/local") (assoc-ref %outputs "out")))
#t))
(add-after 'install 'wrap-program
(lambda* (#:key outputs #:allow-other-keys)
(let* ((out (assoc-ref outputs "out"))
(path (getenv "PERL5LIB")))
(wrap-program (string-append out "/bin/postgresql_autodoc")
`("PERL5LIB" ":" prefix
(,(string-append out "/lib/perl5/site_perl"
":"
path)))))
#t))
)))
(native-inputs
`(("which", which)))
(inputs
`(("perl" ,perl)
("perl-html-template", perl-html-template)
("perl-term-readkey", perl-term-readkey)
("perl-dbd-pg", perl-dbd-pg)))
(home-page "https://github.com/cbbrowne/autodoc")
(synopsis "PostgreSQL Autodoc")
(description
"This is a utility which will run through PostgreSQL system tables and returns HTML, DOT, and several styles of XML which describe the database.")
(license license:bsd-3)))
My caveat
Guix doesn’t have Nix’ problems with OpenGL, and it immediately felt much
nicer than Nix to me. I have used it for around a year to manage most of my
environment. The main beef I had with it was details and weird sluggishness in
details. Like with Nix, package management is/can be quite slow. Due to the
nature of things, depending on the current state of the system, it can happen
that you won’t install binary packages but build everything from scratch. Which
happens reliably, reproducibly and nicely, as expected. That’s the point, after
all. But it still takes SO MUCH TIME!! and the fan noise, and … Additionally,
for the graphical environment, I had weird issues I couldn’t quite resolve.
E.g. starting my desktop (sway
) would take several seconds longer than with
pure Debian. Similarly, the initial start of my switcher application, rofi
,
would take something between 3 and 10 seconds, compared to practically instant
on Debian. I spent a few evenings trying to solve the issue, eventually giving
up.
After a while, I accepted, again, that “normal” containers fit my usecase better.
Containers
Basics
OCI containers work with namespaces to isolate system resources (processes,
networking, users, mountpoints, ipc, time) and virtual filesystems. The desired
state of a container is freezed in a container image, which basically defines
the virtual base filesystem in layers and certain system interfaces, from
which an actual container can be started. These images are usually defined
in a Dockerfile
, which is the build definition of a container image, and can
be stored, distributed and shared. This way, containers can solve problems in
system dependency management, software distribution and orchestration.
This is an example of a Dockerfile
, defining an environment based on
ubuntu:jammy
:
FROM ubuntu:jammy
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
wget \
gpg \
pkg-config \
&& rm -rf /var/lib/{apt,dpkg,cache,log}/
Containers can be built FROM scratch
or based on existing environments, e.g.
benefiting on a strong community and ecosystem. There are base images from
basically every major Linux distribution and distroless images, limiting the
attack surface of application environments. Base images and final images are
cryptographically identified and can be referenced reproducibly and explicitly.
My caveat
I have been using podman as highlevel container
management solution and alternative to Docker
, mainly due to it’s
(root-)daemonless nature.
I use it to run and expose permanent network based local services (see also
this or development environments.
One advantage with podman
is that even without additional magic, due to the
user ID mapping, the root UID within a container maps to the UID of the user
who started the container outside of the container. Rootless containers for the
win! This allows you to simply mount a workspace or other resources into a
container and work with it inside of the container without having to worry
about file permissions and ownership.
E.g. like this.
$ podman run --annotation run.oci.keep_original_groups=1 -v $(pwd)/.local/lib/llama/models:/models --device /dev/kfd --device /dev/dri --rm -it localhost/llama-cpp-python-server-rocm:0.2.20 bash
I like the flexibility, manageability and reusability given by containers a lot and use them heavily.
For the creation of Dockerfiles
, I make use of
dockerfile-language-server
through coc-docker
and a fancy shell
setup with zsh
podman
,
and lazydocker
, which makes
writing Dockerfiles and working with containers pretty convenient.
However, system integration is not ideal. Some (most?) of that is by design, a container is supposed to be separated from the host system, after all. What if I just want to isolate a specific tool and specify it in the context of a project? Especially, if the tool does not have any specific system dependencies, but is rather self-contained?
Shim managers
As the README of asdf
states:
Once upon a time there was a programming language There were many versions of it So people wrote a version manager for it To switch between versions for projects Different, old, new.
Then there came more programming languages So there came more version managers And many commands for them
I installed a lot of them I learnt a lot of commands
Then I said, just one more version manager Which I will write instead
So, there came another version manager asdf version manager - https://github.com/asdf-vm/asdf
A version manager so extendable for which anyone can create a plugin To support their favourite language No more installing more version managers Or learning more commands
Basics
In come shim managers! These programs manage the installation of other programs, capable of updating them, installing and activating different versions of the desired program and (ideally) integrating seamlessly with the rest of the environment and ecosystem. This only works, of course, if the managed program does not have any specific system dependencies, e.g. specific shared libraries etc.
There are language specific managers, e.g. for python there is
pyenv
, which integrates very nicely with
e.g. poetry
and general python virtual environments. Rust has it’s own
installer and toolchain manager, rustup
, which is also
capable of managing different versions of rust.
Even more powerful are general purpose shim managers like
asdf
and, written in rust, more modern and
compatible with asdf
, mise-en-place
.
mise
has a rich ecosystem of plugins, which allows it to manage versions of a
plethora of programs. These can be activated on a system level, temporary shell
level or in the context of (project) directories.
This could be a system level configuration at ~/.config/mise/config.toml
, or
a specific project configuration in ~/workspace/foo/.mise.toml
[tools]
go = "1.22.0"
node = "21.6.1"
java = "adoptopenjdk-21.0.2+13.0.LTS"
ruby = "3.3.0"
awscli = "2.15.19"
direnv = "2.34.0"
mise
makes sure that, in the respective context, the specified version of the
specified tools are configured and take precedence in the $PATH
.
My caveat
I have been using pyenv
, asdf
and rustup
for a long time, recently
replacing asdf
with mise
. Obviously, pyenv
and rustup
are only used to
manage python
and rust
.
I have replaced asdf
with mise
, as it’s much faster, more actively
maintained and maintainable at all. While asdf
is very impressive, it’s
written in bash
(!), and I’m almost sure that the authors regularly curse
their choice. mise
is compatible with asdf
, but extends its functionality.
And it works great!
$ java --version
openjdk 21.0.2 2024-01-16 LTS
OpenJDK Runtime Environment Temurin-21.0.2+13 (build 21.0.2+13-LTS)
OpenJDK 64-Bit Server VM Temurin-21.0.2+13 (build 21.0.2+13-LTS, mixed mode, sharing)
$ mise shell java@corretto-21.0.2.14.1
mise java@corretto-21.0.2.14.1 downloading amazon-corretto-21.0.2.1 72.68 MiB/199.70 MiB (14s) ███████░░░░░░░░░░░░░ 7s
.
.
.
$ java --version
openjdk 21.0.2 2024-01-16 LTS
OpenJDK Runtime Environment Corretto-21.0.2.14.1 (build 21.0.2+14-LTS)
OpenJDK 64-Bit Server VM Corretto-21.0.2.14.1 (build 21.0.2+14-LTS, mixed mode, sharing)
$ mise use java@corretto-21.0.2.14.1 -p ./
mise ./.mise.toml tools: java@corretto-21.0.2.14.1
$ cat .mise.toml
[tools]
java = "corretto-21.0.2.14.1"
Integration is absolutely seamless, once it’s set up correctly.
If you are interested in the setup, check the (linked) documentation, or take a look at my dotfiles :)
tl;dr
- It’s good to have different versions of programs
- It’s hard to manage different versions of programs
- Different ways of managing this have different (dis)advantages
- Containers are not too bad sometimes
- Shim managers are also not too bad sometimes
Leave a comment