1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
|
# SPDX-FileCopyrightText: Copyright © 2025 sterni <sternenseemann@systemli.org>
# SPDX-License-Identifier: MIT
#
# atomically-update allows describing creation and update of certain files and
# directories using a user supplied program via a systemd oneshot service. This
# is intended to fill a gap between files described by derivations and tied to
# the NixOS system deployment and full-on application state. It aims to be both
# generic and simple. Use cases include generating a report based on some impure
# (e.g. network) resource, running a static site generator etc.
#
# - The generated file or directory is created/updated atomically. This does
# not need special support from the generating program. It writes the file
# or directory to a temporary location (atomically or not). The wrapping
# service takes care of updating target location atomically.
#
# - The resulting file or directory is saved in a systemd.exec(5) StateDirectory
# which allows for the use of e.g. systemctl(1) clean. The apparent location
# of the file or directory is exposed via the read-only location option.
#
# - By default, the updating service is only invoked on system activation.
# The user is free to configure any additional activation mechanism like
# systemd.timer(5). The service name is exposed as the read-only serviceName
# option for this purpose. (This can also be used to change the configuration
# of the service itself.)
#
# - Find known issues / limitations by searching for TODO in this file.
#
# Changes
#
# 2. Allow user supplied program to write to stdout instead of a temporary file.
# 1. Allow configuring the user/group to run as.
# 0. Initial Version
#
{ lib, config, options, pkgs, ... }:
# TODO(sterni): allow exec to read previous version of the file or directory
let
cfg = config.services.depot.atomically-update;
# TODO(sterni): this currently creates two derivations, maybe something that can be fixed upstream
checkedScript = pkgs.writers.makeScriptWriter {
interpreter = pkgs.runtimeShell;
# Nixpkgs happily assumes runtimeShell is bash, but we don't have to
check = "${lib.getExe pkgs.shellcheck} -s sh";
};
# $exec should create $name in $staging_dir (the path to which is passed
# as argv[1]). We do not care whether it is a file or a directory.
atomically-update-from-path = checkedScript "atomically-update-from-path" ''
set -eu
exec="$1"
name="$2"
readonly exec name
# This doubles as a sanity check that we don't have the arguments mixed up
printf 'Updating %s using %s\n' "$name" "$(command -v "$exec")" >&2
staging_dir="$(mktemp -d)"
readonly staging_dir
# cleanup is not guaranteed, in the worst case systemd should take
# care of it eventually, see Note [tmp dirs in atomically-update].
cleanup() {
rm -r "$staging_dir"
}
trap cleanup EXIT
"$exec" "$staging_dir/$name"
# TODO(sterni): add support for directories
if test -d "$staging_dir/$name"; then
echo "atomically-update doesn't support directories yet" >&2
exit 1
fi
# Note that $STATE_DIRECTORY/$name may appear to differ from
# cfg.<name>.location in case of DynamicUser=true, see systemd.exec(5).
mv "$staging_dir/$name" "$STATE_DIRECTORY/$name"
'';
in
{
options = {
services.depot.atomically-update = lib.mkOption {
description = ''
Configuration of files or directories to atomically create and update
based on the output of a user supplied program.
'';
default = { };
type = lib.types.attrsOf (lib.types.submodule (
submoduleArgs:
let
name = lib.last submoduleArgs._prefix;
in
{
options = {
enable = lib.mkOption {
type = lib.types.bool;
description = ''
Whether to enable the service that atomically updates ${name}.
'';
# since the entry has to be created explicitly, default = false doesn't make sense
default = true;
example = false;
};
exec.path = lib.mkOption {
type = lib.types.either lib.types.package lib.types.path;
description = ''
Program atomically-update should execute to produce the target
file or directory. This needs to either be a path to an
executable or a derivation whose output is an executable file.
If the program doesn't exit successfully, no update is performed.
How the program is called depends on
{option}`services.atomically-update.${name}.exec.output`.
'';
example = lib.literalExpression ''
# Assumes services.atomically-update.${name}.exec.output == "path"
writeShellScript "hello" '''
echo "hello" > "$1"
''''';
};
exec.output = lib.mkOption {
type = lib.types.enum [
"path"
"stdout"
];
description = ''
Where {option}`services.atomically-update.${name}.exec.path`
writes its output to.
- If `path`, the user supplied program is called with a path as
its first argument. The program should create its output file
or directory at the location it points to.
- If `stdout`, atomically-update calls the program without any
arguments and writes its stdout to the target which is
implied to be a file.
'';
example = "path";
};
# TODO(sterni): file/dir perms
user = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
Name of the user to run {option}`exec` and perform the update
(or creation) as. If `null`, systemd's `DynamicUser` feature
is used, see {manpage}`systemd.exec(5)`.
'';
default = null;
};
group = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
Name of the group to use for running {option}`exec` and
performing the update (or creation). If `null`, systemd's
`DynamicUser` feature is used, see {manpage}`systemd.exec(5)`.
'';
default = null;
};
location = lib.mkOption {
type = lib.types.path;
description = ''
This read-only option exposes the location the target file or
directory will be created at. This can be reused in other parts
of the configuration e.g. as an alias target in the nginx
configuration.
'';
readOnly = true;
default = "/var/lib/${submoduleArgs.config.serviceName}/${name}";
};
serviceName = lib.mkOption {
type = lib.types.str;
description = ''
This read-only option exposes the name of the systemd oneshot
service which creates and updates the target file or directory.
This can be used to e.g. create systemd timers that regularly
invoke the atomically-update service.
This can also be used to override/amend the service configuration
via {option}`systemd.services`, but this may (silently) break
in future versions of the module.
'';
readOnly = true;
default = "atomically-update-${name}";
};
};
}
)
);
};
};
config = lib.mkIf (lib.any (lib.getAttr "enable") (lib.attrValues cfg)) {
assertions = [
{
assertion = lib.all (name: name != "") (lib.attrNames cfg);
message = "services.atomically-update.\"\" = { … } is disallowed";
}
];
systemd.services =
lib.mapAttrs'
(
name:
{ enable
, exec
, serviceName
, user
, group
, ...
}:
{
name = serviceName;
value = {
description = "Atomically update ${name}";
inherit enable;
wantedBy = [ "multi-user.target" ];
serviceConfig = lib.mkMerge [
(lib.mapAttrs (_: lib.mkDefault) {
Restart = "no";
RemainAfterExit = "no";
})
{
Type = "oneshot";
PrivateTmp = true;
StateDirectory = serviceName;
ExecStart = lib.concatStringsSep " "
# there doesn't seem to be a systemd specific escaping function
{
stdout = [
(lib.getExe pkgs.spit)
"-x"
"\"\${STATE_DIRECTORY}/${name}\""
(lib.escapeShellArg "${exec.path}")
];
path = [
"${atomically-update-from-path}"
(lib.escapeShellArg "${exec.path}")
(lib.escapeShellArg name)
];
}.${exec.output};
}
(lib.mkIf (user != null) { User = user; })
(lib.mkIf (group != null) { Group = group; })
(lib.mkIf (user == null) {
# Don't run as root
DynamicUser = true;
})
];
};
}
)
cfg;
};
}
/*
Note [tmp dirs in atomically-update]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
TODO(sterni): flock(1) our temporary directory?
This note will be written in the future.
See also: https://systemd.io/TEMPORARY_DIRECTORIES/
*/
|