nix-files/modules/users/s6-rc.nix

385 lines
13 KiB
Nix

{ config, lib, pkgs, ... }:
let
config' = config;
logBase = "$HOME/.local/share/s6/logs";
maybe = cond: value: if cond then value else null;
# create a derivation whose output is the on-disk representation of some attrset.
# @path: /foo/bar/...
# @obj: one of:
# - { file = { text = "foo bar"; executable = true|false; } }
# - { dir = <obj>; }
fsItemToDerivation = path: obj: if obj ? dir then
pkgs.symlinkJoin {
name = "s6-${path}";
paths = lib.mapAttrsToList
(n: v: fsItemToDerivation "${path}/${n}" v)
obj.dir
;
}
else if obj ? text then
if obj.text != null then
pkgs.writeTextFile {
name = "s6-${path}";
destination = path;
text = obj.text;
}
else
pkgs.emptyDirectory
else if obj ? executable then
if obj.executable != null then
pkgs.writeTextFile {
name = "s6-${path}";
destination = path;
executable = true;
text = obj.executable;
}
else
pkgs.emptyDirectory
else if obj ? symlink then
if obj.symlink != null then
pkgs.runCommandLocal "s6-${path}" { } ''
mkdir -p "$out/$(dirname "${path}")"
ln -s "${obj.symlink}" "$out/${path}"
''
else
pkgs.emptyDirectory
else throw "don't know how to convert fs item at path ${path} to derivation: ${obj}";
# call with an AttrSet of fs items:
# example:
# ```
# fsToDerivation {
# usr.dir = {
# normal.text = "i'm /usr/normal";
# exec.executable = ''
# #!/bin/sh
# echo "i'm executable"
# '';
# lib.dir = { ... };
# };
# bin.dir = { ... };
# }
fsToDerivation = fs: fsItemToDerivation "/" { dir = fs; };
# infers some service settings from the arguments and creates an attrset usable by `fsToDerivation`.
# also configures the service for logging, if applicable.
# type can be:
# - "longrun"
# - "bundle"
# - "oneshot"?
serviceToFs = { name, check, contents, depends, down, finish, run, up, type }: let
logger = serviceToFs' {
name = "logger:${name}";
consumerFor = name;
run = ''s6-log -- T p'${name}:' "${logBase}/${name}"'';
type = "longrun";
};
in (serviceToFs' {
inherit name check depends down finish run type up;
contents = maybe (type == "bundle") contents;
# XXX: apparently `oneshot` services can't have loggers: only `longrun` can log.
producerFor = maybe (type == "longrun") "logger:${name}";
}) // (lib.optionalAttrs (type == "longrun") logger);
serviceToFs'= {
name,
type,
check ? null, #< command to poll to check for service readiness
consumerFor ? null,
contents ? null, #< bundle contents
depends ? [],
up ? null,
finish ? null,
producerFor ? null,
run ? null,
down ? null,
}: let
maybe-notify = lib.optionalString (check != null) "s6-notifyoncheck -n 0 ";
makeAbortable = op: maybe-notify: cli: ''
#!/bin/sh
log() {
printf 's6[%s/%s]: %s\n' '${name}' '${op}' "$1" | tee /dev/stderr
}
log "preparing"
# s6 is too gentle when i ask it to stop a service,
# so explicitly kill children on exit.
# see: <https://stackoverflow.com/a/2173421>
# before changing this, test that the new version actually kills a service with `s6-rc down <svcname>`
down() {
log "trapped on abort signal"
# "trap -": to avoid recursing
trap - SIGINT SIGQUIT SIGTERM
log "killing process group"
# "kill 0" means kill the current process group (i.e. all descendants)
kill 0
exit 0
}
trap down SIGINT SIGQUIT SIGTERM
log "handoff"
# run the service from $HOME by default.
# particularly, this impacts things like "what directory does my terminal open in".
# N.B. do not run the notifier from $HOME, else it won't know where to find the `data/check` program.
# N.B. must be run with `&` + `wait`, else we lose the ability to `trap`.
${maybe-notify}env --chdir="$HOME" ${cli} <&0 &
wait $!
status=$?
log "exiting; propagating status: $status"
exit "$status"
'';
in {
"${name}".dir = {
"type".text = type;
"contents".text = maybe (contents != null) (
lib.concatStringsSep "\n" contents
);
"consumer-for".text = maybe (consumerFor != null) consumerFor;
"data".dir = {
"check".executable = maybe (check != null) ''
#!/bin/sh
${check}
'';
};
# N.B.: if this service is a bundle, then dependencies.d is ignored
"dependencies.d".dir = lib.genAttrs
depends
(dep: { text = ""; })
;
"finish".executable = maybe (finish != null) ''
#!/bin/sh
${finish}
'';
"notification-fd".text = maybe (check != null) "3";
"producer-for".text = maybe (producerFor != null) producerFor;
"run".executable = maybe (run != null) (makeAbortable "run" maybe-notify run);
# oneshot transitions are stored inline in the database, and so can't use #!/bin/sh so easily
"down".text = maybe (down != null) down;
"up".text = maybe (up != null) up;
# "up".executable = maybe (up != null) (makeAbortable "up" "" up);
};
};
# create a directory, containing N subdirectories:
# - svc-a/
# - type
# - run
# - svc-b/
# - type
# - run
# - ...
genServices = svcs: fsToDerivation (lib.foldl'
(acc: srv: acc // (serviceToFs srv))
{}
svcs
);
# output is a directory containing:
# - db
# - lock
# - n
# - resolve.cdb
# - servicedirs/
# - svc-a/
# - svc-b/
# - ...
#
# this can then be used by s6-rc-init, like:
# s6-svscan scandir &
# s6-rc-init -c $compiled -l $PWD/live -d $PWD/scandir
# s6-rc -l $PWD/live start svc-a
#
# N.B.: it seems the $compiled dir needs to be rw, for s6 to write lock files within it.
# so `cp` and `chmod -R 600` it, first.
compileServices = sources: with pkgs; stdenv.mkDerivation {
name = "s6-user-services";
src = sources;
nativeBuildInputs = [ s6-rc ];
buildPhase = ''
s6-rc-compile $out $src
'';
};
# to decrease sandbox escaping, i want to run s6-svscan on a read-only directory
# so other programs can't edit the service scripts.
# in practice, that means putting the servicedirs in /nix/store, and linking selective pieces of state
# towards /run/user/{uid}/s6/live/..., the latter is shared with s6-rc.
mkScanDir = livedir: compiled: pkgs.runCommandLocal "s6-scandir" { } ''
cp -R "${compiled}/servicedirs" "$out"
cd "$out"
chmod u+w .
for d in *; do
chmod u+w "$d"
ln -s "${livedir}/servicedirs/$d/down" "$d"
ln -s "${livedir}/servicedirs/$d/event" "$d"
ln -s "${livedir}/servicedirs/$d/supervise" "$d"
done
# these builtin s6-rc services try to create `s` (socket) and `s.lock` inside their directory, when launched.
# they don't seem to follow symlinks, so patch in a full path.
# note that other services will try to connect directly to this service's `./s` file, so keeping symlinks around as well is a good idea.
substituteInPlace "s6rc-fdholder/run" \
--replace-fail 's6-fdholder-daemon -1 -i data/rules -- s' 's6-fdholder-daemon -1 -i data/rules -- ${livedir}/servicedirs/s6rc-fdholder/s' \
--replace-fail 's6-ipcclient -l0 -- s' 's6-ipcclient -l0 -- ${livedir}/servicedirs/s6rc-fdholder/s'
substituteInPlace "s6rc-oneshot-runner/run" \
--replace-fail 's6-ipcserver-socketbinder -- s' 's6-ipcserver-socketbinder -- ${livedir}/servicedirs/s6rc-oneshot-runner/s' \
--replace-fail 's6-rc-oneshot-run -l ../.. ' 's6-rc-oneshot-run -l ${livedir} '
ln -s "${livedir}/servicedirs/s6rc-fdholder/s" s6rc-fdholder/s
ln -s "${livedir}/servicedirs/s6rc-fdholder/s.lock" s6rc-fdholder/s.lock
ln -s "${livedir}/servicedirs/s6rc-oneshot-runner/s" s6rc-oneshot-runner/s
ln -s "${livedir}/servicedirs/s6rc-oneshot-runner/s.lock" s6rc-oneshot-runner/s.lock
rm -rf .s6-svscan
ln -s "${livedir}/servicedirs/.s6-svscan" .
'';
concatNonNullLines = lines: lib.concatStringsSep "\n" (
lib.filter (line: line != null) lines
);
# transform the `user.services` attrset into a s6 services list.
s6SvcsFromConfigServices = services: lib.mapAttrsToList
(name: service: rec {
inherit name;
check = service.readiness.waitCommand;
contents = builtins.attrNames (
lib.filterAttrs (_: cfg: lib.elem name cfg.partOf) services
);
depends = service.depends ++ builtins.attrNames (
lib.filterAttrs (_: cfg: lib.elem name cfg.dependencyOf) services
);
down = maybe (type == "oneshot") service.cleanupCommand;
finish = maybe (type == "longrun") (concatNonNullLines [
service.cleanupCommand
(maybe (service.restartCondition == "on-failure") ''
if [ "$1" -eq 0 ]; then
printf "service exited 0: not restarting\n"
s6-rc stop "$3"
else
printf "service exited non-zero: restarting (code: %d)\n" "$1"
fi
'')
]);
run = service.command;
up = service.startCommand;
type = if service.startCommand != null then
"oneshot"
else if service.command != null then
"longrun"
else
"bundle"
;
})
services
;
# return a list of bundles (AttrSets) which contain this service
containedBy = services: name: lib.filter (svc: lib.elem name svc.contents) services;
# for each bundle to which this service belongs, add that bundle's dependencies as direct dependencies of this service.
# this is to overcome that s6 doesn't support bundles having dependencies.
pushDownDependencies = services: builtins.map
(svc: svc // {
depends = lib.unique (
svc.depends ++ lib.concatMap
(bundle: bundle.depends)
(containedBy services svc.name)
);
})
services
;
# create a template s6 "live" dir, which can be copied at runtime in /run/user/{uid}/s6/live.
# this is like a minimal versio of `s6-rc-init`, but tightly coupled to my setup
# wherein the scandir is external and selectively links back to the livedir
mkLiveDir = compiled: pkgs.runCommandLocal "s6-livedir" {} ''
mkdir $out
touch $out/lock
touch $out/prefix # no prefix: empty
mkdir $out/compiled
cp ${compiled}/{db,lock,n,resolve.cdb} $out/compiled
touch $out/state
mkdir $out/servicedirs
mkdir $out/servicedirs/.s6-svscan
for svc in $(ls ${compiled}/servicedirs); do
# s6-rc-init gives each service one byte of state, initialized to zero. i mimic that here:
echo -n '\x00' >> $out/state
mkdir $out/servicedirs/$svc
# don't auto-start the service when i add the supervisor
touch $out/servicedirs/$svc/down
# s6-rc needs to know if the service supports readiness notifications,
# as this will determine if it waits for it when starting.
ln -s ${compiled}/servicedirs/$svc/notification-fd $out/servicedirs/$svc/notification-fd
done
'';
in
{
options.sane.users = with lib; mkOption {
type = types.attrsOf (types.submodule ({ config, name, ...}: let
sources = genServices (
lib.converge pushDownDependencies (
s6SvcsFromConfigServices config.services
)
);
compiled = compileServices sources;
uid = config'.users.users."${name}".uid;
scanDir = mkScanDir "/run/user/${builtins.toString uid}/s6/live" compiled;
liveDir = mkLiveDir compiled;
in {
fs.".config/s6/live".symlink.target = liveDir;
fs.".config/s6/scandir".symlink.target = scanDir;
fs.".config/s6/compiled".symlink.target = compiled;
# exposed only for convenience
fs.".config/s6/sources".symlink.target = sources;
fs.".profile".symlink.text = ''
function initS6Dirs() {
local LIVE="$XDG_RUNTIME_DIR/s6/live"
mkdir -p "$LIVE" # create parent dirs
rm -rf "$LIVE"/* # remove old state
# the live dir needs to be read+write. initialize it via the template in ~/.config/s6:
cp -R "$HOME/.config/s6/live"/* "$LIVE"
chmod -R 0700 "$LIVE"
# ensure the log dir, since that'll be needed by every service.
# the log dir should already exist by now (nixos persistence); create it just in case something went wrong.
mkdir -p "${logBase}"
}
function startS6() {
local S6_TARGET="''${1-default}"
local LIVE="$XDG_RUNTIME_DIR/s6/live"
test -e "$LIVE" || initS6Dirs
s6-svscan "$(realpath "$HOME/.config/s6/scandir")" &
local SVSCAN=$!
# wait for `supervise` processes to appear.
# this part would normally be done by `s6-rc-init`.
# maybe there's a more clever way: don't kill `s6-svscan` above, somehow run it on the /nix/store/... path from the very beginning.
for d in $LIVE/servicedirs/*; do
while ! s6-svok "$d" ; do
sleep 0.1
done
done
if [ -n "$S6_TARGET" ]; then
s6-rc -b -l "$LIVE" start "$S6_TARGET"
fi
echo 's6 initialized: Ctrl+C to stop'
wait "$SVSCAN"
}
function startS6WithLogging() {
mkdir -p "${logBase}" #< needs to exist for parallel s6-log call
startS6 2>&1 | s6-log -- T "${logBase}/catchall"
}
primarySessionCommands+=('startS6WithLogging &')
'';
}));
};
}