@jaspermayone.com's dotfiles
1# castle CLI - Hogwarts infrastructure management
2# Credit: Based on implementation by krn (https://github.com/taciturnaxolotl/dots)
3#
4# Commands:
5# sudo castle - Interactive menu
6# sudo castle backup - Backup management submenu
7# sudo castle backup status - Show backup status for all services
8# sudo castle backup list - List snapshots
9# sudo castle backup run - Trigger manual backup
10# sudo castle backup restore - Interactive restore wizard
11# sudo castle backup dr - Disaster recovery mode
12#
13# Future modules:
14# castle status - Service health dashboard
15# castle secrets - Manage agenix secrets
16# castle deploy - Remote deployment tools
17# castle logs - Service log viewer
18
19{
20 config,
21 lib,
22 pkgs,
23 ...
24}:
25
26let
27 cfg = config.castle.backup;
28
29 # Get all configured backup services
30 allBackupServices = lib.attrNames cfg.services;
31
32 # Generate manifest for disaster recovery
33 backupManifest = pkgs.writeText "backup-manifest.json" (
34 builtins.toJSON {
35 version = 1;
36 generated = "nixos-rebuild";
37 services = lib.mapAttrs (name: backupCfg: {
38 paths = backupCfg.paths;
39 exclude = backupCfg.exclude or [ ];
40 tags = backupCfg.tags or [ ];
41 }) cfg.services;
42 }
43 );
44
45 castleCliScript = pkgs.writeShellScript "castle" ''
46 set -e
47
48 # Must be run as root
49 if [ "$(id -u)" -ne 0 ]; then
50 echo "Error: castle must be run as root (use sudo)"
51 exit 1
52 fi
53
54 # Colors via gum
55 style() { ${pkgs.gum}/bin/gum style "$@"; }
56 confirm() { ${pkgs.gum}/bin/gum confirm "$@"; }
57 choose() { ${pkgs.gum}/bin/gum choose "$@"; }
58 input() { ${pkgs.gum}/bin/gum input "$@"; }
59 spin() { ${pkgs.gum}/bin/gum spin "$@"; }
60
61 # Load B2 credentials for backup commands
62 load_backup_env() {
63 set -a
64 source ${config.age.secrets."restic/env".path}
65 set +a
66 }
67
68 # Restic wrapper with secrets
69 restic_cmd() {
70 ${pkgs.restic}/bin/restic \
71 --repository-file ${config.age.secrets."restic/repo".path} \
72 --password-file ${config.age.secrets."restic/password".path} \
73 "$@"
74 }
75
76 # Available backup services
77 BACKUP_SERVICES="${lib.concatStringsSep " " allBackupServices}"
78 MANIFEST="${backupManifest}"
79
80 # ========== BACKUP COMMANDS ==========
81
82 backup_status() {
83 load_backup_env
84 style --bold --foreground 212 "Backup Status"
85 echo
86
87 for svc in $BACKUP_SERVICES; do
88 latest=$(restic_cmd snapshots --tag "service:$svc" --json --latest 1 2>/dev/null | ${pkgs.jq}/bin/jq -r '.[0] // empty')
89
90 if [ -n "$latest" ]; then
91 time=$(echo "$latest" | ${pkgs.jq}/bin/jq -r '.time' | cut -d'T' -f1)
92 hostname=$(echo "$latest" | ${pkgs.jq}/bin/jq -r '.hostname')
93 style --foreground 35 "✓ $svc"
94 style --foreground 117 " Last backup: $time on $hostname"
95 else
96 style --foreground 214 "! $svc"
97 style --foreground 117 " No backups found"
98 fi
99 done
100 }
101
102 backup_list() {
103 load_backup_env
104 style --bold --foreground 212 "List Snapshots"
105 echo
106
107 svc=$(echo "$BACKUP_SERVICES" | tr ' ' '\n' | choose --header "Select service:")
108
109 if [ -z "$svc" ]; then
110 style --foreground 196 "No service selected"
111 exit 1
112 fi
113
114 style --foreground 117 "Snapshots for $svc:"
115 echo
116
117 restic_cmd snapshots --tag "service:$svc" --compact
118 }
119
120 backup_run() {
121 style --bold --foreground 212 "Manual Backup"
122 echo
123
124 svc=$(echo "all $BACKUP_SERVICES" | tr ' ' '\n' | choose --header "Select service to backup:")
125
126 if [ -z "$svc" ]; then
127 style --foreground 196 "No service selected"
128 exit 1
129 fi
130
131 if [ "$svc" = "all" ]; then
132 for s in $BACKUP_SERVICES; do
133 style --foreground 117 "Backing up $s..."
134 systemctl start "restic-backups-$s.service" || style --foreground 214 "! Failed to backup $s"
135 done
136 else
137 style --foreground 117 "Backing up $svc..."
138 systemctl start "restic-backups-$svc.service"
139 fi
140
141 style --foreground 35 "✓ Backup triggered"
142 }
143
144 backup_restore() {
145 load_backup_env
146 style --bold --foreground 212 "Restore Wizard"
147 echo
148
149 svc=$(echo "$BACKUP_SERVICES" | tr ' ' '\n' | choose --header "Select service to restore:")
150
151 if [ -z "$svc" ]; then
152 style --foreground 196 "No service selected"
153 exit 1
154 fi
155
156 style --foreground 117 "Fetching snapshots for $svc..."
157 snapshots=$(restic_cmd snapshots --tag "service:$svc" --json 2>/dev/null)
158
159 if [ "$(echo "$snapshots" | ${pkgs.jq}/bin/jq 'length')" = "0" ]; then
160 style --foreground 196 "No snapshots found for $svc"
161 exit 1
162 fi
163
164 snapshot_list=$(echo "$snapshots" | ${pkgs.jq}/bin/jq -r '.[] | "\(.short_id) - \(.time | split("T")[0]) - \(.paths | join(", "))"')
165
166 selected=$(echo "$snapshot_list" | choose --header "Select snapshot:")
167 snapshot_id=$(echo "$selected" | cut -d' ' -f1)
168
169 if [ -z "$snapshot_id" ]; then
170 style --foreground 196 "No snapshot selected"
171 exit 1
172 fi
173
174 restore_mode=$(choose --header "Restore mode:" "Inspect (restore to /tmp)" "In-place (DANGEROUS)")
175
176 case "$restore_mode" in
177 "Inspect"*)
178 target="/tmp/restore-$svc-$snapshot_id"
179 mkdir -p "$target"
180
181 style --foreground 117 "Restoring to $target..."
182 restic_cmd restore "$snapshot_id" --target "$target"
183
184 style --foreground 35 "✓ Restored to $target"
185 style --foreground 117 " Inspect files, then copy what you need"
186 ;;
187
188 "In-place"*)
189 style --foreground 196 --bold "⚠ WARNING: This will overwrite existing data!"
190 echo
191
192 if ! confirm "Stop $svc and restore data?"; then
193 style --foreground 214 "Restore cancelled"
194 exit 0
195 fi
196
197 style --foreground 117 "Stopping $svc..."
198 systemctl stop "$svc" 2>/dev/null || true
199
200 style --foreground 117 "Restoring snapshot $snapshot_id..."
201 restic_cmd restore "$snapshot_id" --target /
202
203 style --foreground 117 "Starting $svc..."
204 systemctl start "$svc"
205
206 style --foreground 35 "✓ Restore complete"
207 ;;
208 esac
209 }
210
211 backup_dr() {
212 load_backup_env
213 style --bold --foreground 196 "⚠ DISASTER RECOVERY MODE"
214 echo
215 style --foreground 214 "This will restore ALL services from backup."
216 style --foreground 214 "Only use this on a fresh NixOS install."
217 echo
218
219 if ! confirm "Continue with full disaster recovery?"; then
220 style --foreground 117 "Cancelled"
221 exit 0
222 fi
223
224 style --foreground 117 "Reading backup manifest..."
225
226 for svc in $BACKUP_SERVICES; do
227 style --foreground 212 "Restoring $svc..."
228
229 snapshot_id=$(restic_cmd snapshots --tag "service:$svc" --json --latest 1 2>/dev/null | ${pkgs.jq}/bin/jq -r '.[0].short_id // empty')
230
231 if [ -z "$snapshot_id" ]; then
232 style --foreground 214 " ! No snapshots found, skipping"
233 continue
234 fi
235
236 systemctl stop "$svc" 2>/dev/null || true
237 restic_cmd restore "$snapshot_id" --target /
238 systemctl start "$svc" 2>/dev/null || true
239
240 style --foreground 35 " ✓ Restored from $snapshot_id"
241 done
242
243 echo
244 style --foreground 35 --bold "✓ Disaster recovery complete"
245 }
246
247 backup_menu() {
248 style --bold --foreground 212 "Backup Management"
249 echo
250
251 action=$(choose \
252 "Status - Show backup status" \
253 "List - Browse snapshots" \
254 "Run - Trigger manual backup" \
255 "Restore - Restore from backup" \
256 "DR - Disaster recovery mode" \
257 "← Back")
258
259 case "$action" in
260 Status*) backup_status ;;
261 List*) backup_list ;;
262 Run*) backup_run ;;
263 Restore*) backup_restore ;;
264 DR*) backup_dr ;;
265 *) main_menu ;;
266 esac
267 }
268
269 # ========== MAIN MENU ==========
270
271 main_menu() {
272 style --bold --foreground 212 "🏰 Castle - Hogwarts Infrastructure"
273 echo
274
275 action=$(choose \
276 "Backup - Manage backups and restores" \
277 "Exit")
278
279 case "$action" in
280 Backup*) backup_menu ;;
281 *) exit 0 ;;
282 esac
283 }
284
285 show_help() {
286 echo "Usage: castle [command] [subcommand]"
287 echo
288 echo "Commands:"
289 echo " backup Backup management menu"
290 echo " backup status Show backup status for all services"
291 echo " backup list List snapshots"
292 echo " backup run Trigger manual backup"
293 echo " backup restore Interactive restore wizard"
294 echo " backup dr Disaster recovery mode"
295 echo
296 echo "Run without arguments for interactive menu."
297 echo
298 echo "Note: Must be run as root (use sudo)"
299 }
300
301 # ========== MAIN ==========
302
303 case "''${1:-}" in
304 backup)
305 case "''${2:-}" in
306 status) backup_status ;;
307 list) backup_list ;;
308 run) backup_run ;;
309 restore) backup_restore ;;
310 dr|disaster-recovery) backup_dr ;;
311 "") backup_menu ;;
312 *)
313 style --foreground 196 "Unknown backup command: $2"
314 exit 1
315 ;;
316 esac
317 ;;
318 --help|-h)
319 show_help
320 ;;
321 "")
322 main_menu
323 ;;
324 *)
325 style --foreground 196 "Unknown command: $1"
326 echo "Run 'castle --help' for usage."
327 exit 1
328 ;;
329 esac
330 '';
331
332 castleCli = pkgs.stdenv.mkDerivation {
333 pname = "castle";
334 version = "1.0.0";
335
336 dontUnpack = true;
337
338 nativeBuildInputs = [ pkgs.installShellFiles ];
339
340 bashCompletionSrc = ./completions/castle.bash;
341 zshCompletionSrc = ./completions/castle.zsh;
342 fishCompletionSrc = ./completions/castle.fish;
343
344 installPhase = ''
345 mkdir -p $out/bin
346 cp ${castleCliScript} $out/bin/castle
347 chmod +x $out/bin/castle
348
349 # Install completions
350 installShellCompletion --bash --name castle $bashCompletionSrc
351 installShellCompletion --zsh --name _castle $zshCompletionSrc
352 installShellCompletion --fish --name castle.fish $fishCompletionSrc
353 '';
354
355 meta = with lib; {
356 description = "Hogwarts castle infrastructure management CLI";
357 license = licenses.mit;
358 };
359 };
360
361in
362{
363 config = lib.mkIf cfg.enable {
364 environment.systemPackages = [
365 castleCli
366 pkgs.gum
367 pkgs.jq
368 ];
369
370 # Store manifest for reference
371 environment.etc."castle/backup-manifest.json".source = backupManifest;
372 };
373}