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 5, 2024
1 parent 55d0781 commit 6f81009
Show file tree
Hide file tree
Showing 6 changed files with 269 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
63 changes: 63 additions & 0 deletions modules/system/files/default.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{ 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 = ''
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 = ''
OLD=/run/current-system/links.json
if [ ! -e "$OLD" ]; then
OLD=${emptyJSON}
fi
${python} ${linker} "$OLD" "$systemConfig"/links.json
'';
};
}
150 changes: 150 additions & 0 deletions modules/system/files/linker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
from collections import namedtuple
from sys import argv
import json
import os
import shutil
import tempfile

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

with open(argv[1], "r") as file:
old_files = json.load(file)

with open(argv[2], "r") as file:
new_files = json.load(file)

if new_files['version'] != 1:
print("Unknown schema version")
exit(1)

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

Transaction = namedtuple("Transaction", ["source", "destination", "type"])
transactions: list[Transaction] = []
problems: list[str] = []

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

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

if path in old_files['files']:
# The old generation had a file at this path
if old_files['files'][path]['type'] == "link":
# The old generation's file was a link
link_target = os.readlink(path)
if os.path.join(os.path.dirname(path), link_target) == old_files['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 os.path.join(os.path.dirname(path), 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: str
for path in old_files['files']:
old_file = old_files['files'][path]
if path in new_files['files']:
# Already handled when we iterated through new_files above
continue
if os.path.lexists(path):
# There is a file at this path
# It could be a regular file or a symlink (including broken symlinks)

if os.path.islink(path):
# 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 = os.readlink(path)
if os.path.join(os.path.dirname(path), 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

# 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":
# TODO: ensure enclosing directory exists

# https://stackoverflow.com/a/55742015/8387516
dir = os.path.dirname(t.destination)
while True:
temp_name = tempfile.mktemp(dir=dir)
try:
os.symlink(t.source, temp_name)
break
except FileExistsError:
pass
os.replace(temp_name, t.destination)
case "remove":
os.remove(t.source)
case _:
print(f"Unknown transaction type {t.type}")
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;
};
}

0 comments on commit 6f81009

Please sign in to comment.