{ 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 = ; } 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: # before changing this, test that the new version actually kills a service with `s6-rc down ` 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 log "exiting" ''; 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" . ''; # 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") service.cleanupCommand; 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 &') ''; })); }; }