Skip to content

Commit

Permalink
system.file: init
Browse files Browse the repository at this point in the history
  • Loading branch information
Samasaur1 committed Dec 15, 2024
1 parent 55d0781 commit bef527c
Show file tree
Hide file tree
Showing 8 changed files with 339 additions and 0 deletions.
1 change: 1 addition & 0 deletions modules/module-list.nix
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
./system/defaults/ActivityMonitor.nix
./system/defaults/WindowManager.nix
./system/etc.nix
./system/files
./system/keyboard.nix
./system/launchd.nix
./system/nvram.nix
Expand Down
2 changes: 2 additions & 0 deletions modules/system/activation-scripts.nix
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ in
# We run `etcChecks` again just in case someone runs `activate`
# directly without `activate-user`.
${cfg.activationScripts.etcChecks.text}
${cfg.activationScripts.filesChecks.text}
${cfg.activationScripts.extraActivation.text}
${cfg.activationScripts.groups.text}
${cfg.activationScripts.users.text}
Expand All @@ -71,6 +72,7 @@ in
${cfg.activationScripts.keyboard.text}
${cfg.activationScripts.fonts.text}
${cfg.activationScripts.nvram.text}
${cfg.activationScripts.files.text}
${cfg.activationScripts.postActivation.text}
Expand Down
1 change: 1 addition & 0 deletions modules/system/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ in
ln -s ${cfg.build.patches}/patches $out/patches
ln -s ${cfg.build.etc}/etc $out/etc
ln -s ${cfg.build.files} $out/links.json
ln -s ${cfg.path} $out/sw
mkdir -p $out/Library
Expand Down
65 changes: 65 additions & 0 deletions modules/system/files/default.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
{ config, lib, pkgs, ... }:

with lib;

let

text = import ./write-text.nix {
inherit lib;
mkTextDerivation = name: text: pkgs.writeText "system-file-${name}" text;
};

rawFiles = filterAttrs (n: v: v.enable) config.system.file;

files = mapAttrs' (name: value: nameValuePair value.target {
type = "link";
inherit (value) source;
}) rawFiles;

linksJSON = pkgs.writeText "system-files.json" (builtins.toJSON {
version = 1;
inherit files;
});

emptyJSON = pkgs.writeText "empty-files.json" (builtins.toJSON {
version = 1;
files = {};
});

python = lib.getExe pkgs.python3;
linker = ./linker.py;
in

{
options = {
system.file = mkOption {
type = types.attrsOf (types.submodule text);
default = {};
description = ''
Set of files that have to be linked/copied out of the Nix store.
'';
};
};

config = {
system.build.files = linksJSON;

system.activationScripts.filesChecks.text = ''
echo "checking for systemwide file collisions..." >&2
OLD=/run/current-system/links.json
if [ ! -e "$OLD" ]; then
OLD=${emptyJSON}
fi
CHECK_ONLY=1 ${python} ${linker} "$OLD" "$systemConfig"/links.json
'';

system.activationScripts.files.text = ''
echo "setting up files systemwide..." >&2
OLD=/run/current-system/links.json
if [ ! -e "$OLD" ]; then
OLD=${emptyJSON}
fi
${python} ${linker} "$OLD" "$systemConfig"/links.json
'';
};
}
185 changes: 185 additions & 0 deletions modules/system/files/linker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
from dataclasses import dataclass
from sys import argv, exit
import json
import os
from pathlib import Path

if len(argv) != 3:
print(f"Usage: {argv[0]} <old_system_links.json> <new_system_links.json>")
exit(1)

class FileInfo:
source: Path
type: str

def __init__(self, d):
self.source = Path(d['source'])
self.type = d['type']

with open(argv[1], "r") as file:
data = json.load(file)
if data['version'] != 1:
print(f"Unknown schema version in {argv[1]}")
exit(1)
old_files: dict[Path, FileInfo] = {
Path(k): FileInfo(v)
for
(k,v)
in
data['files'].items()
}

with open(argv[2], "r") as file:
data = json.load(file)
if data['version'] != 1:
print(f"Unknown schema version in {argv[2]}")
exit(1)
new_files: dict[Path, FileInfo] = {
Path(k): FileInfo(v)
for
(k,v)
in
data['files'].items()
}

DRY_RUN = 'DRY_RUN' in os.environ.keys()
CHECK_ONLY = 'CHECK_ONLY' in os.environ.keys()

@dataclass
class Transaction:
source: Path
destination: Path
type: str
transactions: list[Transaction] = []
problems: list[Path] = []

path: Path
# Go through all files in the new generation
for path in new_files:
new_file: FileInfo = new_files[path]
if path.exists(follow_symlinks=False):
# There is a file at this path
# It could be a regular file or a symlink (including broken symlinks)

if path.is_symlink():
# The file is a symlink

if path in old_files:
# The old generation had a file at this path
if old_files[path].type == "link":
# The old generation's file was a link
link_target = path.readlink()
# This handles both relative and absolute symlinks
# If the link is relative, we need to prepend the parent
# If the link is absolute, the prepended parent is ignored
if path.parent / link_target == old_files[path].source:
# The link has not changed since last system activation, so we can overwrite it
transactions.append(Transaction(new_file.source, path, 'link'))
elif path.parent / link_target == new_file.source:
# The link already points to the new target
continue
else:
# The link is to somewhere else
problems.append(path)
else:
# The old generation's file was not a link.
# Because we know that the file on disk is a link,
# we know that we can't overwrite this file
problems.append(path)
else:
# The old generation did not have a file at this path,
# and we never overwrite links that weren't created by us
problems.append(path)
else:
# The file is a regular file
problems.append(path)
else:
# There is no file at this path
transactions.append(Transaction(new_file.source, path, new_file.type))

# Check problems
for problem in problems:
print(f"Existing file at path {problem}")

if len(problems) > 0:
print("Aborting")
exit(1)

if CHECK_ONLY:
# We don't perform any checks when planning removal of old files, so we can exit here
exit(0)

# Remove all remaining files from the old generation that aren't in the new generation
path: Path
for path in old_files:
old_file: FileInfo = old_files[path]
if path in new_files:
# Already handled when we iterated through new_files above
continue
if path.exists(follow_symlinks=False):
# There is a file at this path
# It could be a regular file or a symlink (including broken symlinks)

if path.is_symlink():
# The file is a symlink

if old_file.type != "link":
# This files wasn't a link at last activation, which means that the user changed it
# Therefore we don't touch it
continue

# Check that its destination remains the same
link_target = path.readlink()
if path.parent / link_target == old_file.source:
# The link has not changed since last system activation, so we can overwrite it
transactions.append(Transaction(path, path, "remove"))
else:
# The link is to somewhere else, so leave it alone
continue
else:
# The file is a regular file
continue
else:
# There's no file at this path anymore, so we have nothing to do anyway
continue

enclosingDirectories: list[Path] = []
# Perform all transactions
for t in transactions:
# NOTE: the naming scheme for transaction properties is confusing
# We are **NOT** using the same scheme as symlinks when we talk about
# source/destination. The way we are using these names, `source` is a path
# in the Nix store, and `destination` is the path in the system where the source
# should be linked or copied to.
# In the special case of removing files, `destination` can be ignored
if DRY_RUN:
match t.type:
case "link":
print(f"ln -s {t.source} {t.destination}")
case "remove":
print(f"rm {t.source}")
case _:
print(f"Unknown transaction type {t.type}")
else:
match t.type:
case "link":
# Ensure parent directory exists
path.parent.mkdir(parents=True,exist_ok=True)
# Remove the file if it exists (we should only get to this case if it's an old symlink we're replacing)
# This does not properly handle race conditions, but I think we'd fail in the checking stage
# if your config has a race condition
t.destination.unlink(missing_ok=True)
# Link the file into place
t.destination.symlink_to(t.source)
case "remove":
enclosingDirectories.append(t.source.parent)
t.source.unlink()
case _:
print(f"Unknown transaction type {t.type}")

for directory in enclosingDirectories:
if not directory.is_dir():
continue
if any(directory.iterdir()):
continue
print(f"The directory {directory} has been emptied; you may want to remove it")
52 changes: 52 additions & 0 deletions modules/system/files/write-text.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{ lib, mkTextDerivation }:

{ config, name, ... }:

with lib;

let
fileName = file: last (splitString "/" file);
mkDefaultIf = cond: value: mkIf cond (mkDefault value);

drv = mkTextDerivation (fileName name) config.text;
in

{
options = {
enable = mkOption {
type = types.bool;
default = true;
description = ''
Whether this file should be generated.
This option allows specific files to be disabled.
'';
};

text = mkOption {
type = types.lines;
default = "";
description = ''
Text of the file.
'';
};

target = mkOption {
type = types.str;
default = "/${name}";
description = ''
Name of symlink. Defaults to the attribute name preceded by a slash (the root directory).
'';
};

source = mkOption {
type = types.path;
description = ''
Path of the source file.
'';
};
};

config = {
source = mkDefault drv;
};
}
1 change: 1 addition & 0 deletions release.nix
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ in {
tests.system-defaults-write = makeTest ./tests/system-defaults-write.nix;
tests.system-environment = makeTest ./tests/system-environment.nix;
tests.system-keyboard-mapping = makeTest ./tests/system-keyboard-mapping.nix;
tests.system-files = makeTest ./tests/system-files.nix;
tests.system-packages = makeTest ./tests/system-packages.nix;
tests.system-path = makeTest ./tests/system-path.nix;
tests.system-shells = makeTest ./tests/system-shells.nix;
Expand Down
32 changes: 32 additions & 0 deletions tests/system-files.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{ config, pkgs, ... }:

let
contents = "Hello, world!";
jq = pkgs.lib.getExe pkgs.jq;
in

{
system.file."tmp/hello.txt".text = contents;

test = ''
echo 'checking that system links file exists' >&2
test -e ${config.out}/links.json
echo 'checking links file version' >&2
test "$(${jq} .version ${config.out}/links.json)" = "1"
echo 'checking that test file is in links.json' >&2
test ! "$(${jq} '.files."/tmp/hello.txt"' ${config.out}/links.json)" = "null"
echo 'checking that test file is a link' >&2
test "$(${jq} '.files."/tmp/hello.txt".type == "link"' ${config.out}/links.json)" = "true"
echo 'checking that the link source is correct' >&2
diff <(${jq} -r '.files."/tmp/hello.txt".source' ${config.out}/links.json) <(echo ${config.system.file."tmp/hello.txt".source})
# I wanted to check the contents of the file as well, but I am prevented from doing so, I think by the sandbox
# echo 'checking that the link source has the correct contents' >&2
# diff "$(${jq} '.files."/tmp/hello.txt".source' ${config.out}/links.json)" <(echo ${contents}) # >/dev/null
# # grep '${contents}' "$(${jq} '.files."/tmp/hello.txt".source' ${config.out}/links.json)"
'';
}

0 comments on commit bef527c

Please sign in to comment.