This repository has no description
9.1 kB
210 lines
1{ self, inputs, ... }:
2{
3 perSystem =
4 {
5 pkgs,
6 system,
7 ...
8 }:
9 {
10 # deploy [--flake PATH] <machine> <[user@]host>
11 #
12 # Generates vars, copies the flake source to /var/lib/deploy/incoming
13 # on the target, builds it there in the systemd unit deploy-build
14 # (progress via nom), shows the source diff against the running system
15 # (/var/lib/deploy/src) and the closure diff, asks, then uploads
16 # secrets and switches in the unit deploy-switch. Both units survive
17 # the ssh connection dying; rerunning deploy attaches to a running one.
18 packages.deploy =
19 (pkgs.writeShellApplication {
20 name = "deploy";
21 runtimeInputs = [
22 self.packages.${system}.pass
23 inputs.clan-core.packages.${system}.clan-cli
24 pkgs.gnutar
25 pkgs.jq
26 pkgs.less
27 pkgs.nix-output-monitor
28 ];
29 text = ''
30 usage() {
31 echo "usage: deploy [--flake PATH] <machine> <[user@]host>" >&2
32 exit 1
33 }
34
35 flake=.
36 while [ $# -gt 0 ]; do
37 case $1 in
38 --flake)
39 [ $# -ge 2 ] || usage
40 flake=$2
41 shift 2
42 ;;
43 -*) usage ;;
44 *) break ;;
45 esac
46 done
47 [ $# -eq 2 ] || usage
48 machine=$1
49 target=$2
50 [[ $target == *@* ]] || target=root@$target
51
52 state=/var/lib/deploy
53 nix="/run/current-system/sw/bin/nix --extra-experimental-features 'nix-command flakes'"
54
55 tmp=$(mktemp -d)
56 chmod 700 "$tmp"
57 # One ssh connection for the whole run, so the key signs only once.
58 ssh_opts=(-o ControlMaster=auto -o "ControlPath=$tmp/ssh" -o ControlPersist=yes)
59 cleanup() {
60 ssh "''${ssh_opts[@]}" -O exit "$target" 2>/dev/null || true
61 rm -rf "$tmp"
62 }
63 trap cleanup EXIT
64
65 remote() {
66 # shellcheck disable=SC2029 # the command line is meant for the target
67 ssh "''${ssh_opts[@]}" "$target" "$@"
68 }
69 step() {
70 printf '\n\033[1m== %s\033[0m\n' "$*" >&2
71 }
72 page() {
73 if [ -t 1 ]; then ''${PAGER:-less -RFX}; else cat; fi
74 }
75
76 # True while the unit's ExecStartPre or ExecStart is running.
77 # Finished units stay around (RemainAfterExit) until the next start,
78 # so their result can still be read after reconnecting.
79 in_progress() {
80 case $(remote systemctl show -p ActiveState -p SubState "$1") in
81 *ActiveState=activating* | *SubState=running*) return 0 ;;
82 esac
83 return 1
84 }
85
86 # start <unit> <systemd-run args...>: the arguments are quoted, so
87 # they reach systemd-run on the target unchanged. Units start with
88 # an empty environment; go through the daemon like a login shell
89 # (the store is read-only inside containers).
90 start() {
91 local unit=$1
92 shift
93 remote "systemctl stop $unit 2>/dev/null; systemctl reset-failed $unit 2>/dev/null;" \
94 "systemd-run --quiet --unit=$unit -p RemainAfterExit=yes" \
95 "-p StandardOutput=truncate:$state/$unit.log" \
96 "--setenv=NIX_REMOTE=daemon --setenv=PATH=/run/wrappers/bin:/run/current-system/sw/bin" \
97 "$(printf ' %q' "$@")"
98 }
99
100 # follow <unit>: stream its log until it finishes, fail if it did.
101 follow() {
102 ssh "''${ssh_opts[@]}" "$target" bash -s -- "$1" "$state" <<'EOF'
103 unit=$1 log=$2/$1.log
104 tail -n +1 -F "$log" 2>/dev/null &
105 tailpid=$!
106 while case $(systemctl show -p ActiveState -p SubState "$unit") in
107 *ActiveState=activating* | *SubState=running*) true ;;
108 *) false ;;
109 esac; do
110 sleep 1
111 done
112 sleep 1
113 kill "$tailpid"
114 [ "$(systemctl show -p Result --value "$unit")" = success ] && exit 0
115 echo "deploy: $unit failed (exit status $(systemctl show -p ExecMainStatus --value "$unit")), log: $log" >&2
116 exit 1
117 EOF
118 }
119
120 step "connecting to $target"
121 # shellcheck disable=SC2016 # expanded on the target
122 remote 'test -e /etc/NIXOS && command -v systemd-run >/dev/null' ||
123 { echo "deploy: $target is not a NixOS system with systemd" >&2; exit 1; }
124 if in_progress deploy-switch; then
125 step "a switch is still running on $target, attaching"
126 follow deploy-switch
127 exit
128 fi
129
130 # Decrypt the bulk key once (one Secure Enclave unlock); every
131 # later pass call from clan then uses it without prompting.
132 PASS_BULK_KEY_FILE="" pass show bulk-operations/age-key >"$tmp/bulk-key"
133 if [ ! -s "$tmp/bulk-key" ]; then
134 echo "deploy: could not extract the bulk key" >&2
135 exit 1
136 fi
137 export PASS_BULK_KEY_FILE="$tmp/bulk-key"
138
139 if in_progress deploy-build; then
140 step "a build is still running on $target, attaching"
141 else
142 step "generating vars for $machine"
143 pass git pull --rebase
144 clan vars generate --flake "$flake" "$machine"
145 pass git push
146
147 step "copying source to $target:$state/incoming"
148 # The flake source exactly as nix sees it: tracked files only.
149 src=$(nix flake metadata --json "$flake" | jq -r .path)
150 tar -C "$src" --owner=0 --group=0 --numeric-owner -czf - . |
151 remote "rm -rf $state/incoming.new && mkdir -p $state/incoming.new &&" \
152 "tar -C $state/incoming.new -xzf - &&" \
153 "rm -rf $state/incoming && mv $state/incoming.new $state/incoming"
154
155 step "source changes"
156 # shellcheck disable=SC2016 # expanded on the target
157 remote "if [ -d $state/src ]; then diff -ruN --color=always $state/src $state/incoming" \
158 '|| [ $? -eq 1 ]; else echo "no source recorded for the running system"; fi' | page
159
160 step "building $machine on $target"
161 start deploy-build \
162 /run/current-system/sw/bin/nix --extra-experimental-features "nix-command flakes" \
163 --log-format internal-json -v build --out-link "$state/result" \
164 "$state/incoming#nixosConfigurations.$machine.config.system.build.toplevel"
165 fi
166 follow deploy-build | nom --json
167 system=$(remote readlink -f "$state/result")
168
169 step "closure changes"
170 if [ "$(remote readlink -f /run/current-system)" = "$system" ]; then
171 echo "$system is already running"
172 else
173 remote "$nix store diff-closures /run/current-system $system"
174 fi
175
176 read -r -p "switch $target to $system? [y/N] " answer </dev/tty
177 if [[ $answer != [yY]* ]]; then
178 echo "aborted, $target is unchanged" >&2
179 exit 1
180 fi
181
182 step "uploading secrets"
183 secret_location=$(remote "$nix eval --raw $state/incoming#nixosConfigurations.$machine.config.clan.core.vars.password-store.secretLocation")
184 clan vars upload --flake "$flake" --directory "$tmp/vars" "$machine"
185 if [ -s "$tmp/vars/.pass_info" ] &&
186 [ "$(remote cat "$secret_location/.pass_info" 2>/dev/null)" = "$(cat "$tmp/vars/.pass_info")" ]; then
187 echo "secrets in $secret_location unchanged"
188 else
189 # Same layout and permissions as `clan vars upload`; swapped in
190 # with a rename so the directory is never empty.
191 find "$tmp/vars" -type d -exec chmod 700 {} +
192 find "$tmp/vars" -type f -exec chmod 400 {} +
193 # shellcheck disable=SC2016 # expanded on the target
194 tar -C "$tmp/vars" --owner=0 --group=0 --numeric-owner -czf - . |
195 remote "d=$secret_location;" \
196 'rm -rf "$d.new" "$d.old" && install -d -m 700 "$d.new" && tar -C "$d.new" -xzf - &&' \
197 '{ [ ! -e "$d" ] || mv "$d" "$d.old"; } && mv "$d.new" "$d" && rm -rf "$d.old"'
198 fi
199
200 step "switching $target"
201 start deploy-switch \
202 -p "ExecStartPre=/run/current-system/sw/bin/nix-env -p /nix/var/nix/profiles/system --set $system" \
203 -p "ExecStartPre=/bin/sh -c 'rm -rf $state/src && cp -a $state/incoming $state/src'" \
204 "$system/bin/switch-to-configuration" switch
205 follow deploy-switch
206 '';
207 }).overrideAttrs
208 { passthru.usage = builtins.readFile ./usage.kdl; };
209 };
210}