@jaspermayone.com's dotfiles
at main 373 lines 11 kB view raw
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}