前言
String Context 是 Nix 语言中的一个重要概念, 这是一种和字符串关联的元信息. 只要你在使用 Nix 打包 package, 你就一定在使用 String Context, 因为 String Context 是 Nix 中用于追溯依赖信息的手段. 但这些信息在 Nix 中是隐式传递的, 用户一般不会直接面对它, 更不需要对它进行操作.
但多了解一下 Nix 的运作方式也没有坏处嘛.
(本文部分内容参考了这篇文章.)
什么是 String Context
String Context 是一组和字符串关联的元信息, 可以通过bulitins.getContext读取. 打开一个nix repl看一下:
nix-repl> :l <nixpkgs>
nix-repl> hello.outPath
"/nix/store/v5sv61sszx301i0x6xysaqzla09nksnd-hello-2.10"
nix-repl> :p builtins.getContext hello.outPath
{ "/nix/store/s6rn4jz1sin56rf4qj5b5v8jxjm32hlk-hello-2.10.drv" = { outputs = [ "out" ]; }; }
nix-repl> builtins.getContext "A regular string"
{ }
String Context 是 Nix 中追溯依赖信息的手段. String Context 在derivation函数中产生, 并在对字符串的操作过程中传播 – 为什么使用字符串呢? 因为字符串是 build system 中最常见也最重要的数据类型, 构建脚本、配置文件等都是以字符串表达和存储的.
举个栗子:
nix-repl> busybox = derivation {
name = "busybox";
builder = "builtin:fetchurl";
system = "x86_64-linux";
outputHash = "ef4c1be6c7ae57e4f654efd90ae2d2e204d6769364c46469fa9ff3761195cba1";
outputHashAlgo = "sha256";
outputHashMode = "recursive";
url = "http://tarballs.nixos.org/stdenv-linux/i686/4907fc9e8d0d82b28b3c56e3a478a2882f1d700f/busybox";
executable = true;
unpack = false;
}
nix-repl> :p builtins.getContext busybox.outPath # derivation 返回的实际上是一个 set, 其中 drvPath 是 drv 文件的路径, outPath 是构建成功后会输出的路径, 产生的 String Context 会被绑定到这两个字符串上
{ "/nix/store/vq227qrc3b4pz3843cv8djbia525635s-busybox.drv" = { outputs = [ "out" ]; }; }
nix-repl> sentence = "这个句子与${busybox.outPath}进行了拼接, 因此它的 String Context 传播到了这个字符串"
nix-repl> :p builtins.getContext sentence
{ "/nix/store/vq227qrc3b4pz3843cv8djbia525635s-busybox.drv" = { outputs = [ "out" ]; }; }
P.S. 在这里"${busybox.outPath}"和常见的"${busybox}"的写法是等价的, 因为在对一个 set 进行 antiquotation 时, 会对这个 set 中键值为“outPath”的值进行操作, 如果找不到这样一个键值就会报错. 这么设计的原因是derivation返回的是一个 set, 而对一个 derivation 进行 antiquotation 在 Nix 中是非常常见的, 并且这个时候我们通常需要的就是“outPath”的值. 于是, 因为 Nix 中并没有一个专门表达 derivation 的数据类型, 就设计了这么一个相当随便的 feature.
如果derivation的参数中包含了任何的 String Context, 那么它们都会成为所产生的 derivation 的依赖, 继续上面的栗子:
nix-repl> helloWorld = derivation {
name = "hello-world";
builder = "${busybox}"; # String Context 传播到了这个字符串中
args = [ "ash" "-c" "echo Hello World! > $out" ];
system = "x86_64-linux";
}
nix-repl> helloWorld
«derivation /nix/store/mg5yc9xp2z9jjx0sjbpkhbqj2yy0w6sh-hello-world.drv»
现在运行一下nix show-derivation /nix/store/mg5yc9xp2z9jjx0sjbpkhbqj2yy0w6sh-hello-world.drv看一下:
{
"/nix/store/mg5yc9xp2z9jjx0sjbpkhbqj2yy0w6sh-hello-world.drv": {
"outputs": {
"out": {
"path": "/nix/store/w62gjxd3xj487mv7dgzypgcl32na5daw-hello-world"
}
},
"inputSrcs": [],
"inputDrvs": {
"/nix/store/vq227qrc3b4pz3843cv8djbia525635s-busybox.drv": [
"out"
]
},
"platform": "x86_64-linux",
"builder": "/nix/store/lan2w3ab1mvpxj3ppiw2sizh8i7rpz7s-busybox",
"args": [
"ash",
"-c",
"echo Hello World! > $out"
],
"env": {
"builder": "/nix/store/lan2w3ab1mvpxj3ppiw2sizh8i7rpz7s-busybox",
"name": "hello-world",
"out": "/nix/store/w62gjxd3xj487mv7dgzypgcl32na5daw-hello-world",
"system": "x86_64-linux"
}
}
}
可以看到busybox已经成为helloWorld的依赖了, 通过这种方式就可以很自然的建立 derivation 之间的依赖关系. 另外, 注意到 String Context 是以 set 的形式存储的, 每个键值一般是一个 drv 文件的路径, 而对应的值据我所知有三种类型:
{ outputs = [ ... ]; }表示依赖这个 derivation 的某些输出, 比如在上面的例子中,busybox.outPath仅依赖busybox这个 derivation 中名为out的输出. 当然这个 derivation 也只有这个输出, 但如果是一个在outputs中指定了多个输出的 derivation, 在使用 binary cache 时nix-daemon就会知晓这时候仅需要下载out这个输出, 从而节省一部分带宽.{ allOutputs = true; }表示依赖这个 derivation 的全部输出, 例如busybox.drvPath所关联的 String Context 就是这个类型.{ path = true; }表示对应的键值不是一个 drv 文件, 而是直接指向 store 下面的一个路径. 这种 String Context 一般是在对一个路径进行 antiquotation 时产生的, 比如"这样一个字符串, 对${/path/to/some/file/on/disk}进行了操作".
String Context 还会对 nix expression 的 evaluation 产生影响, 例如, 若builtins.readDir的参数是一个关联了非空 String Context 的字符串, 则在执行到这一处调用时, 会先将其中指定的所有依赖构建, 全部构建成功后才会继续执行. 而另外一些函数在这种情况下会报错, 一个比较完整的汇总表格可以在这篇文章里找到. 简单地讲就是, 当一个函数“使用”到一个字符串的内容时, 如果是作为一个路径使用, 一般会需要将所有的依赖构建; 如果不是作为路径使用而字符串的 String Context 又不为空, 则会报错.
一点和 String Context 有关的事情
大多数时候, String Context 作为 Nix 语言的一种较为底层的机制是不会被用户感知到的. 但相应的, 如果在某些罕见的情况下出现 String Context 丢失的现象也比较难被发现. 比如下面讲到的这个例子.
起因是这样的, 我想在我的系统中使用Howdy作为屏幕解锁、使用sudo等时的验证工具, 然而 Howdy 在 Nixpkgs 中并没有被打包, 自然也不会有对应的模块选项, 而 NixOS 中pam模块的设计又没有任何扩展的空间 – 它会根据各个已有的选项的值生成一份最终的 pam 规则文件, 而我希望做到的是在这份文件中间的一个段落插入 Howdy 相关的规则, 因此也没有办法使用lib.mkBefore或者是lib.mkAfter. 最简单的办法是修改模块的定义, 但我并不想维护自己的一个 fork, 于是琢磨出来这么一个 hacky 的办法:
{ config, lib, pkgs, modulesPath, ... }:
let
inherit (config.lib.shared) files;
inherit (config.lib.shared.function) dotNixFilesFrom; # 这个函数接收一个路径, 返回该路径下的所有 .nix 文件
configuration = import files.world;
eval = import (modulesPath + "/..") {
configuration = { ... }: {
imports = [ files.world ]; # files.world 指向我的配置的入口, 即 configuration.nix
disabledModules = dotNixFilesFrom ./.; # Disable 该文件所在文件夹下的所有配置, 目前这里只有这一份配置
};
};
# 结果就是, 我先得到了一份在去除了这份配置的情况下的 config 结果
pam-python = pkgs.callPackage (dirs.world.package + /pam-python.nix) { };
howdy-rule =
"auth sufficient ${pam-python}/lib/security/pam_python.so ${pkgs.howdy}/lib/security/howdy/pam.py";
pam-service-config = eval.config.security.pam.services; # security.pam.services 的"原始"定义
patched-pam-text = lib.mapAttrs (service: config:
let
# 修改原本的定义, 插入 Howdy 相关的规则
patched-text = pkgs.runCommand "${service}-pam" {
passAsFile = [ "text" ];
inherit (config) text;
} ''
cat $textPath > $out
if grep -q 'auth required pam_unix\.so' $out; then
sed -i '/auth required pam_unix\.so/i ${howdy-rule}' $out
elif grep -q 'auth sufficient pam_unix\.so' $out; then
sed -i '/auth sufficient pam_unix\.so/i ${howdy-rule}' $out
fi
'';
result = builtins.readFile patched-text;
in { text = mkForce result; }) pam-service-config;
in {
security.pam.services = patched-pam-text; # 覆盖掉原本的定义
}
sudo nixos-rebuild boot, 重启, Howdy 启动, 一切正常. 然后过了几天, 发生了一件诡异的事情: sudo nixos-rebuild找不到我的配置文件了. 由于我的配置文件存放在$HOME下, 因此我配置中修改了nix.nixPath的值, 将<nixos-config>指向了我实际的配置文件的位置, 然而nixos-rebuild执行时没有读取到正确的NIX_PATH的值. 我上 IRC 问了问, 并没有人知道是怎么回事 (估计也很难有人通过这点线索猜到吧 :P).
我用-I选项指定了<nixos-config>的值, 重新执行了一次nixos-rebuild, 这次命令执行成功了. 重启后, sudo nixos-rebuild又可以正常执行了. 然而过了几天, 同样的情况再次发生, 而在这中间我并没有修改过系统的配置. 于是 debug 到头秃的我在电报群上询问 (哦, 如果大家对 NixOS 有兴趣的话可以加入这个群嘛 XD), 这回在群里人的启发下, 我发现在 rebuild 之后, 问题消失了, 但执行nix-collect-garbage之后问题又出现了.
这就很不科学 – GC 和我的环境变量有什么关系? 再次 rebuild 后, 运行nix-store --gc --print-dead输出会被 GC 的路径, 其中有一个路径非常可疑:
/nix/store/yn5kbl7in0r7gsn4i87mgih4vsvs5mb1-pam-environment
打开/etc/pam.d/sudo, 其中有这样一段:
# Session management.
session required pam_env.so conffile=/nix/store/yn5kbl7in0r7gsn4i87mgih4vsvs5mb1-pam-environment readenv=0
可以看到这里的pam-environment和之前要被 GC 的那个路径是同一个文件. 这里就是问题所在了: 在sudo执行命令时, 环境变量是通过pam_env.so设置的. 而执行 GC 后, pam_env.so的配置文件被删除, 导致nixos-rebuild没有读取到正确的NIX_PATH的值.
但这不应该发生: 既然构建好的系统中出现了这个路径, 那么这个路径应该会被 Nix 注册为系统的运行时依赖, 不应该被 GC.
原因是这样的: 当 Nix 扫描构建好的 derivation 中的运行时依赖时, 它不会去检查 store 中的所有路径 – 否则的话用到后来每次构建都会变得奇慢无比. 它只会检查在 drv 文件指明的依赖中出现的路径, 而这些依赖是通过 String Context 传递进来的, 那么这些 Context 应该是在某处丢失了, 导致pam-environment没有被注册为系统的依赖.
出现问题的地方当然是在上面的配置当中: 在计算patched-pam-text时, 代码中使用了builtins.readFile读取修改后的规则文件内容, 而readFile是不会传递任何 String Context 的, 于是这里的依赖信息就丢失了. 这一段代码应该被改写成这样:
{ config, lib, pkgs, modulesPath, ... }:
let
# 略过其他的定义
patched-pam-text = lib.mapAttrs (service: config:
let
patched-text = pkgs.runCommand "${service}-pam" {
passAsFile = [ "text" ];
inherit (config) text;
} ''
# 略过这里的脚本
'';
result = builtins.appendContext (builtins.readFile patched-text) # builtins.appendContext 可以将一组 String Context 绑定到字符串上
(builtins.getContext patched-text);
in { text = mkForce result; }) pam-service-config;
in {
security.pam.services = patched-pam-text; # 覆盖掉原本的定义
}
总结
嗯.. 好像也没啥好总结的, 这篇文章比较粗略地解释了一下 String Context 是什么, 在 Nix 当中的作用以及非常罕见的一个大坑. 个人认为这是一个比较精巧的设计, 避免了依赖计算这么一个令人头大的问题.
总之之前说过要写一篇关于 String Context 的文章, 拖了三个月终于拿起来写啦. 希望下一年我能勤快一些吧.