snap-slack.py 57 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375
  1. # Fix incorrect adopt implementation with dry-run support
  2. def adopt(config, snapshot, dry_run=False, simulate=False):
  3. try:
  4. current_root_path = os.path.join(config['snapshot']['btrfs_mount_point'], config['snapshot']['root_subvolume'])
  5. snapshot_path = os.path.join(config['snapshot']['snapshot_dir'], snapshot)
  6. if not os.path.exists(snapshot_path) and not (dry_run or simulate):
  7. logger.error(f"Snapshot '{snapshot}' not found at {snapshot_path}")
  8. print(f"Error: Snapshot '{snapshot}' not found.")
  9. return False
  10. # Check if we're on a live system (not booted into the snapshot)
  11. if not (dry_run or simulate):
  12. with open("/proc/cmdline", "r") as f:
  13. cmdline = f.read()
  14. if snapshot in cmdline:
  15. logger.warning(f"You are already booted into snapshot {snapshot}.")
  16. print(f"Warning: You are already booted into snapshot {snapshot}. No changes needed.")
  17. return False
  18. # Create a backup of current root first before making changes
  19. backup_name = f"@_backup_{datetime.now().strftime('%Y%m%d-%H%M%S')}"
  20. backup_path = os.path.join(config['snapshot']['btrfs_mount_point'], backup_name)
  21. print(f"{'[SIMULATE] ' if simulate else ''}{'[DRY-RUN] ' if dry_run else ''}Creating backup of current root subvolume as {backup_name}")
  22. logger.info(f"Creating backup of current root at {backup_path}")
  23. # Create a snapshot of the current root
  24. cmd_backup = ["btrfs", "subvolume", "snapshot", current_root_path, backup_path]
  25. returncode, stdout, stderr = safe_execute(
  26. cmd_backup,
  27. dry_run=dry_run,
  28. simulate=simulate,
  29. description="Creating backup of current root"
  30. )
  31. if returncode != 0 and not (dry_run or simulate):
  32. logger.error(f"Failed to create backup: {stderr}")
  33. print(f"Error: Failed to create backup: {stderr}")
  34. return False
  35. # Delete current root subvolume
  36. print(f"{'[SIMULATE] ' if simulate else ''}{'[DRY-RUN] ' if dry_run else ''}Deleting current root subvolume")
  37. logger.info(f"Deleting current root subvolume at {current_root_path}")
  38. cmd_delete = ["btrfs", "subvolume", "delete", current_root_path]
  39. returncode, stdout, stderr = safe_execute(
  40. cmd_delete,
  41. dry_run=dry_run,
  42. simulate=simulate,
  43. description="Deleting current root subvolume"
  44. )
  45. if returncode != 0 and not (dry_run or simulate):
  46. logger.error(f"Failed to delete current root: {stderr}")
  47. print(f"Error: Failed to delete current root: {stderr}")
  48. return False
  49. # Create a new snapshot of the selected snapshot as the new root subvolume
  50. print(f"{'[SIMULATE] ' if simulate else ''}{'[DRY-RUN] ' if dry_run else ''}Creating new root subvolume from snapshot '{snapshot}'")
  51. logger.info(f"Creating new root from {snapshot_path} to {current_root_path}")
  52. cmd_new_root = ["btrfs", "subvolume", "snapshot", snapshot_path, current_root_path]
  53. returncode, stdout, stderr = safe_execute(
  54. cmd_new_root,
  55. dry_run=dry_run,
  56. simulate=simulate,
  57. description=f"Creating new root subvolume from snapshot '{snapshot# Safe command execution with dry-run support
  58. def safe_execute(cmd, dry_run=False, simulate=False, description=None):
  59. """
  60. Execute a command safely with dry-run and simulation support.
  61. Args:
  62. cmd: Command list to execute
  63. dry_run: If True, just print the command without executing
  64. simulate: If True, log as if executed but don't actually execute
  65. description: Optional description of what the command does
  66. Returns:
  67. A tuple of (return_code, stdout, stderr) or (0, '', '') if dry_run/simulate
  68. """
  69. cmd_str = ' '.join(cmd)
  70. if description:
  71. logger.info(f"{description}: {cmd_str}")
  72. if dry_run or simulate:
  73. print(f"WOULD EXECUTE: {description}")
  74. print(f" $ {cmd_str}")
  75. else:
  76. logger.info(f"Would execute: {cmd_str}")
  77. if dry_run or simulate:
  78. print(f"WOULD EXECUTE: {cmd_str}")
  79. if dry_run or simulate:
  80. # Return success for dry run or simulate
  81. return 0, '', ''
  82. else:
  83. # Actually execute the command
  84. result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  85. return result.returncode, result.stdout.decode(), result.stderr.decode()
  86. # Safe file operations with dry-run support
  87. def safe_write_file(file_path, content, dry_run=False, simulate=False, description=None):
  88. """
  89. Write to a file with dry-run and simulation support.
  90. Args:
  91. file_path: Path to the file
  92. content: Content to write
  93. dry_run: If True, just print what would be written
  94. simulate: If True, log as if written but don't actually write
  95. description: Optional description of the write operation
  96. """
  97. if description:
  98. logger.info(f"{description}: {file_path}")
  99. if dry_run or simulate:
  100. print(f"WOULD WRITE: {description}")
  101. print(f" To file: {file_path}")
  102. print(f" Content length: {len(content)} bytes")
  103. if len(content) < 1000:
  104. print(f" Content preview:\n{'-'*40}\n{content[:500]}\n{'-'*40}")
  105. else:
  106. print(f" Content preview (first 500 bytes):\n{'-'*40}\n{content[:500]}...\n{'-'*40}")
  107. else:
  108. logger.info(f"Would write to: {file_path}")
  109. if dry_run or simulate:
  110. print(f"WOULD WRITE TO: {file_path}")
  111. if not (dry_run or simulate):
  112. # Create directory if it doesn't exist
  113. os.makedirs(os.path.dirname(file_path), exist_ok=True)
  114. # Actually write the file
  115. with open(file_path, 'w') as f:
  116. f.write(content)
  117. # Apply test directory to paths if specified
  118. def adjust_path(config, path, test_dir=None):
  119. """
  120. Adjust a path with a test directory if specified.
  121. Args:
  122. config: Configuration dictionary
  123. path: Original path
  124. test_dir: Test directory to prepend (if not None)
  125. Returns:
  126. Adjusted path
  127. """
  128. if test_dir is None:
  129. return path
  130. # For absolute paths, replace with test directory
  131. if path.startswith('/'):
  132. return os.path.join(test_dir, path[1:])
  133. return path#!/usr/bin/env python3
  134. import os
  135. import sys
  136. import subprocess
  137. import toml
  138. import argparse
  139. import logging
  140. from datetime import datetime
  141. import shutil
  142. # Path to the configuration file
  143. CONFIG_PATH = "/etc/snap-slack/config.toml"
  144. LOG_PATH = "/var/log/snap-slack.log"
  145. # Set up logging
  146. logging.basicConfig(
  147. level=logging.INFO,
  148. format='%(asctime)s - %(levelname)s - %(message)s',
  149. handlers=[
  150. logging.FileHandler(LOG_PATH),
  151. logging.StreamHandler()
  152. ]
  153. )
  154. logger = logging.getLogger('snap-slack')
  155. # Read configuration from a TOML file with test dir support
  156. def read_config(file_path=CONFIG_PATH, test_dir=None):
  157. if not os.path.exists(file_path):
  158. logger.error(f"Configuration file not found: {file_path}")
  159. raise FileNotFoundError(f"Configuration file not found: {file_path}")
  160. try:
  161. with open(file_path, 'r') as f:
  162. config = toml.load(f)
  163. # Validate essential config entries
  164. required_fields = {
  165. 'snapshot': ['snapshot_dir', 'snapshot_prefix', 'btrfs_mount_point', 'root_subvolume', 'retention_days'],
  166. }
  167. if config.get('bootloader', {}).get('type', 'elilo').lower() == 'elilo':
  168. required_fields['elilo'] = ['elilo_conf', 'root_partition']
  169. else:
  170. required_fields['grub'] = ['config_file', 'root_partition']
  171. for section, fields in required_fields.items():
  172. if section not in config:
  173. raise ValueError(f"Missing required section '{section}' in config file")
  174. for field in fields:
  175. if field not in config[section]:
  176. raise ValueError(f"Missing required field '{field}' in section '{section}'")
  177. # If test_dir is specified, adjust paths in the config
  178. if test_dir:
  179. logger.info(f"Using test directory: {test_dir}")
  180. print(f"Using test directory: {test_dir}")
  181. # Adjust snapshot paths
  182. config['snapshot']['snapshot_dir'] = adjust_path(config, config['snapshot']['snapshot_dir'], test_dir)
  183. config['snapshot']['btrfs_mount_point'] = adjust_path(config, config['snapshot']['btrfs_mount_point'], test_dir)
  184. # Adjust bootloader paths
  185. if 'elilo' in config:
  186. config['elilo']['elilo_conf'] = adjust_path(config, config['elilo']['elilo_conf'], test_dir)
  187. if 'grub' in config:
  188. if 'config_file' in config['grub']:
  189. config['grub']['config_file'] = adjust_path(config, config['grub']['config_file'], test_dir)
  190. if 'custom_entries_file' in config['grub']:
  191. config['grub']['custom_entries_file'] = adjust_path(config, config['grub']['custom_entries_file'], test_dir)
  192. # Adjust boot paths
  193. if 'boot' in config and 'boot_dir' in config['boot']:
  194. config['boot']['boot_dir'] = adjust_path(config, config['boot']['boot_dir'], test_dir)
  195. # Add test_dir to config for reference
  196. config['_test_dir'] = test_dir
  197. return config
  198. except Exception as e:
  199. logger.error(f"Error reading config: {str(e)}")
  200. raise
  201. # Create a new snapshot with the current date and time
  202. def create_snapshot(config, dry_run=False, simulate=False, description=None):
  203. try:
  204. date_str = datetime.now().strftime("%Y%m%d-%H%M%S")
  205. # Add description to snapshot name if provided
  206. if description:
  207. # Clean up description for use in filename
  208. clean_desc = description.replace(" ", "-")
  209. clean_desc = re.sub(r'[^a-zA-Z0-9\-_]', '', clean_desc)
  210. snapshot_name = f"{config['snapshot']['snapshot_prefix']}{date_str}-{clean_desc}"
  211. else:
  212. snapshot_name = f"{config['snapshot']['snapshot_prefix']}{date_str}"
  213. snapshot_path = os.path.join(config['snapshot']['snapshot_dir'], snapshot_name)
  214. # Ensure snapshot directory exists
  215. if not (dry_run or simulate):
  216. os.makedirs(config['snapshot']['snapshot_dir'], exist_ok=True)
  217. # Run the Btrfs snapshot command
  218. root_path = os.path.join(config['snapshot']['btrfs_mount_point'], config['snapshot']['root_subvolume'])
  219. if not os.path.exists(root_path) and not (dry_run or simulate):
  220. logger.error(f"Root subvolume path does not exist: {root_path}")
  221. return None
  222. # Check if encryption is enabled
  223. is_encrypted = config.get('encryption', {}).get('enabled', False)
  224. # If hibernation is enabled, we need a read-write snapshot
  225. # otherwise, create a read-only snapshot
  226. hibernation_enabled = config.get('hibernation', {}).get('enabled', False)
  227. snapshot_readonly = not hibernation_enabled
  228. # Command for creating snapshot
  229. cmd = ["btrfs", "subvolume", "snapshot"]
  230. if snapshot_readonly:
  231. cmd.append("-r") # read-only flag
  232. cmd.extend([root_path, snapshot_path])
  233. # Execute the command with safety measures
  234. returncode, stdout, stderr = safe_execute(
  235. cmd,
  236. dry_run=dry_run,
  237. simulate=simulate,
  238. description=f"Creating {'read-only' if snapshot_readonly else 'read-write'} snapshot{' with description: ' + description if description else ''}"
  239. )
  240. if returncode == 0:
  241. logger.info(f"Created snapshot: {snapshot_name}")
  242. print(f"{'[SIMULATE] ' if simulate else ''}{'[DRY-RUN] ' if dry_run else ''}Created snapshot: {snapshot_name}")
  243. # For encrypted systems with hibernation, update the initramfs
  244. if is_encrypted and hibernation_enabled:
  245. update_initramfs_for_encryption(config, snapshot_name, snapshot_path, dry_run=dry_run, simulate=simulate)
  246. add_boot_entry(config, snapshot_name, dry_run=dry_run, simulate=simulate)
  247. return snapshot_name
  248. else:
  249. error_msg = stderr
  250. logger.error(f"Failed to create snapshot: {error_msg}")
  251. print(f"Failed to create snapshot: {error_msg}")
  252. return None
  253. except Exception as e:
  254. logger.error(f"Error creating snapshot: {str(e)}")
  255. print(f"Error creating snapshot: {str(e)}")
  256. return None
  257. # Update initramfs for encrypted volume with hibernation
  258. def update_initramfs_for_encryption(config, snapshot_name, snapshot_path):
  259. try:
  260. logger.info(f"Updating initramfs for encrypted system with hibernation support")
  261. print(f"Updating initramfs for encrypted system with hibernation support...")
  262. # Get initramfs generation tool from config
  263. initramfs_tool = config.get('encryption', {}).get('initramfs_tool', 'mkinitrd')
  264. # Get location to put the new initramfs file
  265. initramfs_dir = config.get('encryption', {}).get('initramfs_dir', '/boot')
  266. # New initramfs filename with snapshot identifier
  267. initramfs_file = f"initrd-{snapshot_name}.gz"
  268. initramfs_path = os.path.join(initramfs_dir, initramfs_file)
  269. # Get kernel version
  270. kernel_version = get_current_kernel_version(config)
  271. if not kernel_version:
  272. logger.error("Failed to determine kernel version")
  273. raise Exception("Failed to determine kernel version")
  274. # Get LUKS UUID for the root device
  275. luks_uuid = get_luks_uuid(config)
  276. if not luks_uuid:
  277. logger.error("Failed to determine LUKS UUID")
  278. raise Exception("Failed to determine LUKS UUID")
  279. # Generate initramfs command based on tool
  280. if initramfs_tool == "mkinitrd":
  281. # For mkinitrd (Slackware's default)
  282. resume_device = config.get('hibernation', {}).get('resume_device', '')
  283. luks_name = config.get('encryption', {}).get('luks_name', 'cryptroot')
  284. # Create mkinitrd config file for this snapshot
  285. mkinitrd_conf = f"/etc/mkinitrd.conf.{snapshot_name}"
  286. with open("/etc/mkinitrd.conf", "r") as f:
  287. mkinitrd_content = f.read()
  288. # Update paths for the snapshot
  289. mkinitrd_content = mkinitrd_content.replace(
  290. f"ROOT_DEVICE=",
  291. f"ROOT_DEVICE=/dev/mapper/{luks_name}\n# Original: ")
  292. # Add the LUKS options if not present
  293. if "LUKSDEV=" not in mkinitrd_content:
  294. mkinitrd_content += f"\n# Added by snap-slack\nLUKSDEV=UUID={luks_uuid}\n"
  295. # Add the resume device if not present and hibernation is enabled
  296. if resume_device and "RESUMEDEV=" not in mkinitrd_content:
  297. mkinitrd_content += f"RESUMEDEV={resume_device}\n"
  298. # Add rootflags for the snapshot
  299. if "ROOTFLAGS=" not in mkinitrd_content:
  300. mkinitrd_content += f"ROOTFLAGS=subvol={config['snapshot']['snapshot_dir']}/{snapshot_name}\n"
  301. else:
  302. # Replace existing rootflags
  303. mkinitrd_content = re.sub(
  304. r'ROOTFLAGS=.*',
  305. f'ROOTFLAGS=subvol={config["snapshot"]["snapshot_dir"]}/{snapshot_name}',
  306. mkinitrd_content)
  307. # Write the custom config
  308. with open(mkinitrd_conf, "w") as f:
  309. f.write(mkinitrd_content)
  310. # Build the mkinitrd command
  311. cmd = [
  312. "mkinitrd",
  313. "-F", # force
  314. "-c", mkinitrd_conf, # use custom config
  315. "-o", initramfs_path,
  316. "-k", kernel_version
  317. ]
  318. elif initramfs_tool == "dracut":
  319. # For dracut
  320. resume_device = config.get('hibernation', {}).get('resume_device', '')
  321. cmd = [
  322. "dracut",
  323. "--force", # force
  324. "--kver", kernel_version, # kernel version
  325. "-f", initramfs_path, # output file
  326. ]
  327. # Add LUKS and resume parameters
  328. dracut_modules = "crypt"
  329. if resume_device:
  330. dracut_modules += " resume"
  331. cmd.extend([
  332. "--add", dracut_modules,
  333. "--persistent-policy", "by-uuid"
  334. ])
  335. # Add kernel command line parameters
  336. kernel_cmdline = f"rd.luks.uuid={luks_uuid} root=/dev/mapper/cryptroot rootflags=subvol={config['snapshot']['snapshot_dir']}/{snapshot_name}"
  337. if resume_device:
  338. kernel_cmdline += f" resume={resume_device}"
  339. cmd.extend(["--kernel-cmdline", kernel_cmdline])
  340. else:
  341. logger.error(f"Unsupported initramfs generation tool: {initramfs_tool}")
  342. raise Exception(f"Unsupported initramfs generation tool: {initramfs_tool}")
  343. # Execute the command
  344. logger.info(f"Running command: {' '.join(cmd)}")
  345. print(f"Generating custom initramfs for encrypted system...")
  346. result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  347. if result.returncode == 0:
  348. logger.info(f"Successfully created custom initramfs at {initramfs_path}")
  349. print(f"Successfully created custom initramfs at {initramfs_path}")
  350. # Update the config with the custom initramfs location
  351. config['encryption']['custom_initramfs'] = initramfs_file
  352. return True
  353. else:
  354. error_msg = result.stderr.decode()
  355. logger.error(f"Failed to create custom initramfs: {error_msg}")
  356. print(f"Failed to create custom initramfs: {error_msg}")
  357. return False
  358. except Exception as e:
  359. logger.error(f"Error updating initramfs: {str(e)}")
  360. print(f"Error updating initramfs: {str(e)}")
  361. return False
  362. # Get LUKS UUID for encrypted device
  363. def get_luks_uuid(config):
  364. try:
  365. # Try to get UUID from config first
  366. uuid = config.get('encryption', {}).get('luks_uuid', '')
  367. if uuid:
  368. return uuid
  369. # If not in config, try to determine it
  370. luks_device = config.get('encryption', {}).get('luks_device', '')
  371. if not luks_device:
  372. # Try to find it automatically
  373. cmd = ["blkid", "-t", "TYPE=crypto_LUKS", "-o", "device"]
  374. result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  375. if result.returncode == 0:
  376. devices = result.stdout.decode().strip().split('\n')
  377. if devices and devices[0]:
  378. luks_device = devices[0]
  379. if luks_device:
  380. # Get UUID of the LUKS device
  381. cmd = ["blkid", "-s", "UUID", "-o", "value", luks_device]
  382. result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  383. if result.returncode == 0:
  384. uuid = result.stdout.decode().strip()
  385. return uuid
  386. return None
  387. except Exception as e:
  388. logger.error(f"Error getting LUKS UUID: {str(e)}")
  389. return None
  390. # Get current kernel version
  391. def get_current_kernel_version(config):
  392. try:
  393. # Try to get from config first
  394. kernel_version = config.get('boot', {}).get('kernel_version', '')
  395. if kernel_version:
  396. return kernel_version
  397. # Try to determine from running system
  398. if os.path.exists("/proc/version"):
  399. with open("/proc/version", "r") as f:
  400. version_info = f.read()
  401. match = re.search(r'Linux version ([0-9.-]+\S+)', version_info)
  402. if match:
  403. return match.group(1)
  404. # Fallback to uname
  405. result = subprocess.run(["uname", "-r"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  406. if result.returncode == 0:
  407. return result.stdout.decode().strip()
  408. return None
  409. except Exception as e:
  410. logger.error(f"Error determining kernel version: {str(e)}")
  411. return None
  412. # Add a boot entry for a snapshot
  413. def add_boot_entry(config, snapshot, dry_run=False, simulate=False):
  414. try:
  415. # Determine the bootloader type from config
  416. bootloader = config.get('bootloader', {}).get('type', 'elilo').lower()
  417. if bootloader == 'grub':
  418. return add_grub_entry(config, snapshot, dry_run=dry_run, simulate=simulate)
  419. else:
  420. return add_elilo_entry(config, snapshot, dry_run=dry_run, simulate=simulate)
  421. except Exception as e:
  422. logger.error(f"Error adding boot entry: {str(e)}")
  423. print(f"Error adding boot entry: {str(e)}")
  424. return False
  425. # Add an entry for the snapshot in elilo.conf
  426. def add_elilo_entry(config, snapshot, dry_run=False, simulate=False):
  427. try:
  428. # Get the extra boot options from the config, if any
  429. extra_boot_options = config.get('elilo', {}).get('extra_boot_options', '')
  430. # Check if encryption is enabled
  431. is_encrypted = config.get('encryption', {}).get('enabled', False)
  432. hibernation_enabled = config.get('hibernation', {}).get('enabled', False)
  433. # Determine initrd file to use
  434. initrd_file = "initrd.gz" # Default
  435. if is_encrypted and hibernation_enabled:
  436. # Use the custom initramfs for encrypted systems with hibernation
  437. custom_initramfs = config.get('encryption', {}).get('custom_initramfs', '')
  438. if custom_initramfs:
  439. initrd_file = custom_initramfs
  440. # Construct the boot entry with optional extra boot options
  441. entry = f"""
  442. image=vmlinuz
  443. label={snapshot}
  444. root={config['elilo']['root_partition']}
  445. """
  446. # Add proper boot parameters based on encryption status
  447. if is_encrypted:
  448. luks_uuid = get_luks_uuid(config)
  449. luks_name = config.get('encryption', {}).get('luks_name', 'cryptroot')
  450. # For encrypted systems
  451. entry += f" append=\"rd.luks.uuid={luks_uuid} root=/dev/mapper/{luks_name} rootflags=subvol={config['snapshot']['snapshot_dir']}/{snapshot} ro"
  452. # Add resume device for hibernation if enabled
  453. if hibernation_enabled:
  454. resume_device = config.get('hibernation', {}).get('resume_device', '')
  455. if resume_device:
  456. entry += f" resume={resume_device}"
  457. # Add any extra boot options
  458. if extra_boot_options:
  459. entry += f" {extra_boot_options}"
  460. entry += "\"\n"
  461. else:
  462. # For non-encrypted systems
  463. entry += f" append=\"rootflags=subvol={config['snapshot']['snapshot_dir']}/{snapshot} ro {extra_boot_options}\"\n"
  464. entry += " read-only\n"
  465. entry += f" initrd={initrd_file}\n"
  466. # Append to elilo.conf if the entry doesn't already exist
  467. elilo_conf = config['elilo']['elilo_conf']
  468. # Create the file if it doesn't exist
  469. if not os.path.exists(elilo_conf) and not (dry_run or simulate):
  470. # Ensure elilo.conf directory exists
  471. os.makedirs(os.path.dirname(elilo_conf), exist_ok=True)
  472. with open(elilo_conf, 'w') as f:
  473. f.write("# ELILO Configuration File\n")
  474. # Check if the entry already exists (in simulation or dry-run mode, assume it doesn't)
  475. entry_exists = False
  476. if not (dry_run or simulate) and os.path.exists(elilo_conf):
  477. with open(elilo_conf, 'r') as f:
  478. content = f.read()
  479. if snapshot in content:
  480. entry_exists = True
  481. logger.info(f"Entry for snapshot {snapshot} already exists in elilo.conf")
  482. print(f"Entry for snapshot {snapshot} already exists in elilo.conf")
  483. return False
  484. if not entry_exists:
  485. # Add the entry using safe file operations
  486. description = f"Adding entry for snapshot {snapshot} to elilo.conf"
  487. if not (dry_run or simulate):
  488. with open(elilo_conf, 'a+') as f:
  489. f.write(entry)
  490. logger.info(description)
  491. if dry_run or simulate:
  492. print(f"{'[SIMULATE] ' if simulate else ''}{'[DRY-RUN] ' if dry_run else ''}{description}")
  493. print(f"WOULD APPEND TO {elilo_conf}:")
  494. print(f"{'-'*40}\n{entry}\n{'-'*40}")
  495. else:
  496. print(f"Added entry for snapshot: {snapshot}")
  497. return True
  498. except Exception as e:
  499. logger.error(f"Error adding elilo entry: {str(e)}")
  500. print(f"Error adding elilo entry: {str(e)}")
  501. return False
  502. # Add an entry for the snapshot in grub.cfg or custom config
  503. def add_grub_entry(config, snapshot):
  504. try:
  505. # Get boot options
  506. extra_boot_options = config.get('grub', {}).get('extra_boot_options', '')
  507. # Get GRUB configuration details
  508. grub_custom_file = config.get('grub', {}).get('custom_entries_file', '/etc/grub.d/60_snap-slack')
  509. kernel_path = config.get('grub', {}).get('kernel_path', '/boot/vmlinuz')
  510. initrd_path = config.get('grub', {}).get('initrd_path', '/boot/initrd.gz')
  511. root_partition = config.get('grub', {}).get('root_partition', '/dev/sdaX')
  512. # Create the GRUB menu entry
  513. grub_entry = f"""
  514. # Entry for snapshot {snapshot}
  515. menuentry "Slackware - {snapshot}" {{
  516. linux {kernel_path} root={root_partition} rootflags=subvol={config['snapshot']['snapshot_dir']}/{snapshot} ro {extra_boot_options}
  517. initrd {initrd_path}
  518. }}
  519. """
  520. # Determine if we're using a custom file or directly modifying grub.cfg
  521. if config.get('grub', {}).get('use_custom_file', True):
  522. # Using a custom file in /etc/grub.d/
  523. # Check if directory exists
  524. os.makedirs(os.path.dirname(grub_custom_file), exist_ok=True)
  525. # Create or update the custom file
  526. snapshot_entries = {}
  527. if os.path.exists(grub_custom_file):
  528. with open(grub_custom_file, 'r') as f:
  529. content = f.read()
  530. # Parse existing entries
  531. import re
  532. entries = re.findall(r'menuentry "Slackware - (snapshot-[^"]+)"', content)
  533. for entry in entries:
  534. snapshot_entries[entry] = True
  535. # If the snapshot is not already in the file
  536. if snapshot not in snapshot_entries:
  537. with open(grub_custom_file, 'a+') as f:
  538. if os.path.getsize(grub_custom_file) == 0:
  539. f.write("#!/bin/sh\n")
  540. f.write("exec tail -n +3 $0\n\n")
  541. f.write("# This file was generated by snap-slack\n\n")
  542. f.write("cat << EOF\n")
  543. f.write(grub_entry)
  544. if not content.endswith("EOF\n"):
  545. f.write("EOF\n")
  546. # Make the file executable
  547. os.chmod(grub_custom_file, 0o755)
  548. # Update GRUB configuration
  549. logger.info(f"Updating GRUB configuration")
  550. if os.path.exists("/usr/sbin/update-grub"):
  551. cmd = ["update-grub"]
  552. else:
  553. cmd = ["grub-mkconfig", "-o", config.get('grub', {}).get('config_file', '/boot/grub/grub.cfg')]
  554. result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  555. if result.returncode != 0:
  556. logger.warning(f"GRUB update command failed: {result.stderr.decode()}")
  557. logger.warning("You may need to manually update your GRUB configuration.")
  558. print("Warning: GRUB configuration update failed. You may need to run 'update-grub' or 'grub-mkconfig' manually.")
  559. logger.info(f"Added GRUB entry for snapshot: {snapshot}")
  560. return True
  561. else:
  562. logger.info(f"GRUB entry for snapshot {snapshot} already exists")
  563. return False
  564. else:
  565. # Directly modify grub.cfg (not recommended but supported)
  566. grub_cfg = config.get('grub', {}).get('config_file', '/boot/grub/grub.cfg')
  567. if not os.path.exists(grub_cfg):
  568. logger.warning(f"GRUB config file not found at {grub_cfg}")
  569. return False
  570. with open(grub_cfg, 'r') as f:
  571. content = f.read()
  572. if f"Slackware - {snapshot}" not in content:
  573. # Find the last menuentry
  574. last_entry_pos = content.rfind("menuentry ")
  575. if last_entry_pos == -1:
  576. # No menuentry found, append to end
  577. with open(grub_cfg, 'a') as f:
  578. f.write(grub_entry)
  579. else:
  580. # Find the end of the last menuentry
  581. bracket_count = 0
  582. for i in range(last_entry_pos, len(content)):
  583. if content[i] == '{':
  584. bracket_count += 1
  585. elif content[i] == '}':
  586. bracket_count -= 1
  587. if bracket_count == 0:
  588. # Insert after the last menuentry
  589. new_content = content[:i+1] + grub_entry + content[i+1:]
  590. with open(grub_cfg, 'w') as f:
  591. f.write(new_content)
  592. break
  593. logger.info(f"Added GRUB entry for snapshot: {snapshot}")
  594. return True
  595. else:
  596. logger.info(f"GRUB entry for snapshot {snapshot} already exists")
  597. return False
  598. except Exception as e:
  599. logger.error(f"Error adding GRUB entry: {str(e)}")
  600. raise
  601. # Remove a boot entry for a deleted snapshot
  602. def remove_boot_entry(config, snapshot):
  603. try:
  604. # Determine the bootloader type from config
  605. bootloader = config.get('bootloader', {}).get('type', 'elilo').lower()
  606. if bootloader == 'grub':
  607. return remove_grub_entry(config, snapshot)
  608. else:
  609. return remove_elilo_entry(config, snapshot)
  610. except Exception as e:
  611. logger.error(f"Error removing boot entry: {str(e)}")
  612. raise
  613. # Remove an entry for a deleted snapshot from elilo.conf
  614. def remove_elilo_entry(config, snapshot):
  615. try:
  616. elilo_conf = config['elilo']['elilo_conf']
  617. if not os.path.exists(elilo_conf):
  618. logger.warning(f"elilo.conf not found at {elilo_conf}")
  619. return
  620. with open(elilo_conf, 'r') as f:
  621. lines = f.readlines()
  622. found = False
  623. with open(elilo_conf, 'w') as f:
  624. for line in lines:
  625. if f"label={snapshot}" in line:
  626. found = True
  627. continue
  628. if found and (line.strip() == "" or line.startswith("image=")):
  629. found = False
  630. if not found:
  631. f.write(line)
  632. logger.info(f"Removed entry for snapshot: {snapshot}")
  633. except Exception as e:
  634. logger.error(f"Error removing elilo entry: {str(e)}")
  635. raise
  636. # Remove an entry for a deleted snapshot from grub configuration
  637. def remove_grub_entry(config, snapshot):
  638. try:
  639. # If using custom file
  640. if config.get('grub', {}).get('use_custom_file', True):
  641. grub_custom_file = config.get('grub', {}).get('custom_entries_file', '/etc/grub.d/60_snap-slack')
  642. if not os.path.exists(grub_custom_file):
  643. logger.warning(f"GRUB custom file not found at {grub_custom_file}")
  644. return
  645. with open(grub_custom_file, 'r') as f:
  646. lines = f.readlines()
  647. # Find and remove the menuentry block
  648. new_lines = []
  649. skip_mode = False
  650. bracket_count = 0
  651. for line in lines:
  652. if f'menuentry "Slackware - {snapshot}"' in line:
  653. skip_mode = True
  654. bracket_count = 0
  655. if skip_mode:
  656. if '{' in line:
  657. bracket_count += line.count('{')
  658. if '}' in line:
  659. bracket_count -= line.count('}')
  660. if bracket_count <= 0:
  661. skip_mode = False
  662. continue
  663. new_lines.append(line)
  664. # Write the modified content back
  665. with open(grub_custom_file, 'w') as f:
  666. f.writelines(new_lines)
  667. # Update GRUB configuration
  668. logger.info(f"Updating GRUB configuration")
  669. if os.path.exists("/usr/sbin/update-grub"):
  670. cmd = ["update-grub"]
  671. else:
  672. cmd = ["grub-mkconfig", "-o", config.get('grub', {}).get('config_file', '/boot/grub/grub.cfg')]
  673. result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  674. if result.returncode != 0:
  675. logger.warning(f"GRUB update command failed: {result.stderr.decode()}")
  676. logger.warning("You may need to manually update your GRUB configuration.")
  677. print("Warning: GRUB configuration update failed. You may need to run 'update-grub' or 'grub-mkconfig' manually.")
  678. else:
  679. # Direct modification of grub.cfg (not recommended)
  680. grub_cfg = config.get('grub', {}).get('config_file', '/boot/grub/grub.cfg')
  681. if not os.path.exists(grub_cfg):
  682. logger.warning(f"GRUB config file not found at {grub_cfg}")
  683. return
  684. with open(grub_cfg, 'r') as f:
  685. content = f.read()
  686. # Find and remove the menuentry block
  687. import re
  688. pattern = re.compile(f'menuentry\s+"Slackware - {snapshot}"\s+{{.*?}}', re.DOTALL)
  689. new_content = pattern.sub('', content)
  690. with open(grub_cfg, 'w') as f:
  691. f.write(new_content)
  692. logger.info(f"Removed GRUB entry for snapshot: {snapshot}")
  693. except Exception as e:
  694. logger.error(f"Error removing GRUB entry: {str(e)}")
  695. raise
  696. # List all current snapshots
  697. def list_snapshots(config):
  698. try:
  699. snapshots = []
  700. snapshot_dir = config['snapshot']['snapshot_dir']
  701. if not os.path.exists(snapshot_dir):
  702. logger.warning(f"Snapshot directory does not exist: {snapshot_dir}")
  703. return snapshots
  704. for entry in os.listdir(snapshot_dir):
  705. entry_path = os.path.join(snapshot_dir, entry)
  706. if os.path.isdir(entry_path) and entry.startswith(config['snapshot']['snapshot_prefix']):
  707. snapshots.append(entry)
  708. return sorted(snapshots)
  709. except Exception as e:
  710. logger.error(f"Error listing snapshots: {str(e)}")
  711. raise
  712. # Display snapshots with details
  713. def display_snapshots(config):
  714. try:
  715. snapshots = list_snapshots(config)
  716. if not snapshots:
  717. print("No snapshots found.")
  718. return
  719. print("\nAvailable snapshots:")
  720. print("-" * 80)
  721. print("{:<30} {:<20} {:<10}".format("Snapshot Name", "Created Date", "Age (days)"))
  722. print("-" * 80)
  723. now = datetime.now()
  724. for snapshot in snapshots:
  725. snapshot_path = os.path.join(config['snapshot']['snapshot_dir'], snapshot)
  726. created_time = datetime.fromtimestamp(os.path.getctime(snapshot_path))
  727. age_days = (now - created_time).days
  728. created_date = created_time.strftime("%Y-%m-%d %H:%M:%S")
  729. print("{:<30} {:<20} {:<10}".format(snapshot, created_date, age_days))
  730. print("-" * 80)
  731. except Exception as e:
  732. logger.error(f"Error displaying snapshots: {str(e)}")
  733. print(f"Error: {str(e)}")
  734. # Remove snapshots older than the retention period
  735. def remove_old_snapshots(config):
  736. try:
  737. retention_days = config['snapshot']['retention_days']
  738. now = datetime.now()
  739. removed_count = 0
  740. for snapshot in list_snapshots(config):
  741. snapshot_path = os.path.join(config['snapshot']['snapshot_dir'], snapshot)
  742. created_time = datetime.fromtimestamp(os.path.getctime(snapshot_path))
  743. if (now - created_time).days > retention_days:
  744. # Remove snapshot
  745. cmd = ["btrfs", "subvolume", "delete", snapshot_path]
  746. logger.info(f"Running command: {' '.join(cmd)}")
  747. result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  748. if result.returncode == 0:
  749. remove_boot_entry(config, snapshot)
  750. logger.info(f"Removed snapshot: {snapshot}")
  751. removed_count += 1
  752. else:
  753. logger.error(f"Failed to remove snapshot {snapshot}: {result.stderr.decode()}")
  754. return removed_count
  755. except Exception as e:
  756. logger.error(f"Error removing old snapshots: {str(e)}")
  757. raise
  758. # Fix incorrect adopt implementation
  759. def adopt(config, snapshot):
  760. try:
  761. current_root_path = os.path.join(config['snapshot']['btrfs_mount_point'], config['snapshot']['root_subvolume'])
  762. snapshot_path = os.path.join(config['snapshot']['snapshot_dir'], snapshot)
  763. if not os.path.exists(snapshot_path):
  764. logger.error(f"Snapshot '{snapshot}' not found at {snapshot_path}")
  765. raise Exception(f"Snapshot '{snapshot}' not found.")
  766. # Check if we're on a live system (not booted into the snapshot)
  767. with open("/proc/cmdline", "r") as f:
  768. cmdline = f.read()
  769. if snapshot in cmdline:
  770. logger.warning(f"You are already booted into snapshot {snapshot}.")
  771. print(f"Warning: You are already booted into snapshot {snapshot}. No changes needed.")
  772. return
  773. # Create a backup of current root first before making changes
  774. backup_name = f"@_backup_{datetime.now().strftime('%Y%m%d-%H%M%S')}"
  775. backup_path = os.path.join(config['snapshot']['btrfs_mount_point'], backup_name)
  776. print(f"Creating backup of current root subvolume as {backup_name}")
  777. logger.info(f"Creating backup of current root at {backup_path}")
  778. # Create a snapshot of the current root
  779. cmd_backup = ["btrfs", "subvolume", "snapshot", current_root_path, backup_path]
  780. logger.info(f"Running command: {' '.join(cmd_backup)}")
  781. result = subprocess.run(cmd_backup, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  782. if result.returncode != 0:
  783. logger.error(f"Failed to create backup: {result.stderr.decode()}")
  784. raise Exception(f"Failed to create backup: {result.stderr.decode()}")
  785. # Delete current root subvolume
  786. print(f"Deleting current root subvolume")
  787. logger.info(f"Deleting current root subvolume at {current_root_path}")
  788. cmd_delete = ["btrfs", "subvolume", "delete", current_root_path]
  789. logger.info(f"Running command: {' '.join(cmd_delete)}")
  790. result = subprocess.run(cmd_delete, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  791. if result.returncode != 0:
  792. logger.error(f"Failed to delete current root: {result.stderr.decode()}")
  793. raise Exception(f"Failed to delete current root: {result.stderr.decode()}")
  794. # Create a new snapshot of the selected snapshot as the new root subvolume
  795. print(f"Creating new root subvolume from snapshot '{snapshot}'")
  796. logger.info(f"Creating new root from {snapshot_path} to {current_root_path}")
  797. cmd_new_root = ["btrfs", "subvolume", "snapshot", snapshot_path, current_root_path]
  798. logger.info(f"Running command: {' '.join(cmd_new_root)}")
  799. result = subprocess.run(cmd_new_root, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  800. if result.returncode != 0:
  801. logger.error(f"Failed to create new root: {result.stderr.decode()}")
  802. # Try to recover from backup
  803. print("Error occurred. Attempting to recover from backup...")
  804. try:
  805. if os.path.exists(backup_path):
  806. recovery_cmd = ["btrfs", "subvolume", "snapshot", backup_path, current_root_path]
  807. subprocess.run(recovery_cmd, check=True)
  808. print("Recovery successful.")
  809. logger.info("Recovered from backup after failed adopt operation")
  810. except Exception as recovery_error:
  811. logger.critical(f"Failed to recover from backup: {str(recovery_error)}")
  812. raise Exception(f"Failed to create new root: {result.stderr.decode()}")
  813. print("\n" + "=" * 80)
  814. print(f"SUCCESS: Snapshot '{snapshot}' has been adopted as the new root subvolume.")
  815. print("=" * 80)
  816. print("\nTo use this snapshot, you need to:")
  817. print("1. Reboot your system")
  818. print("2. In the bootloader menu, select your regular Slackware entry (not the snapshot entry)")
  819. print("3. The system will now boot with the adopted snapshot as the root filesystem")
  820. print("\nIf you encounter issues, you can:")
  821. print(f"- Boot into the backup snapshot '{backup_name}' (emergency only)")
  822. print(f"- Or boot into any other available snapshot from the bootloader menu")
  823. print("=" * 80)
  824. logger.info(f"Successfully adopted snapshot '{snapshot}' as new root subvolume")
  825. # Note: We don't need to update elilo.conf for the adopted snapshot since it's now the root
  826. except Exception as e:
  827. logger.error(f"Error adopting snapshot: {str(e)}")
  828. print(f"Error: {str(e)}")
  829. print("\nYour system may be in an inconsistent state. Please verify the root subvolume.")
  830. sys.exit(1)
  831. # Create a live bootable image from a snapshot
  832. def create_boot_image(config, snapshot):
  833. try:
  834. snapshot_path = os.path.join(config['snapshot']['snapshot_dir'], snapshot)
  835. if not os.path.exists(snapshot_path):
  836. logger.error(f"Snapshot '{snapshot}' not found.")
  837. raise Exception(f"Snapshot '{snapshot}' not found.")
  838. bootdir = config.get('boot', {}).get('boot_dir', '/boot')
  839. if not os.path.exists(bootdir):
  840. logger.error(f"Boot directory not found: {bootdir}")
  841. raise Exception(f"Boot directory not found: {bootdir}")
  842. # Create a custom initrd that boots directly into the snapshot
  843. # This is a simplified example - actual implementation would depend on
  844. # how Slackware builds initrd images
  845. print(f"Creating bootable image for snapshot '{snapshot}'...")
  846. logger.info(f"Creating bootable image for snapshot '{snapshot}'")
  847. # Ensure the snapshot entry exists in elilo.conf
  848. add_elilo_entry(config, snapshot)
  849. print("\n" + "=" * 80)
  850. print(f"SUCCESS: Boot entry for snapshot '{snapshot}' is configured.")
  851. print("=" * 80)
  852. print("\nTo boot into this snapshot:")
  853. print("1. Reboot your system")
  854. print("2. In the ELILO boot menu, select the entry labeled:")
  855. print(f" {snapshot}")
  856. print("\nNOTE: This will boot into the snapshot in read-only mode.")
  857. print(" To make it permanent, use 'snap-slack adopt --snapshot' after testing.")
  858. print("=" * 80)
  859. except Exception as e:
  860. logger.error(f"Error creating boot image: {str(e)}")
  861. print(f"Error: {str(e)}")
  862. # Main logic for managing snapshots
  863. def manage(config):
  864. try:
  865. # Step 1: Create snapshot directory if it doesn't exist
  866. os.makedirs(config['snapshot']['snapshot_dir'], exist_ok=True)
  867. # Step 2: Get list of current snapshots and add them to elilo.conf if needed
  868. print("Checking existing snapshots...")
  869. snapshots = list_snapshots(config)
  870. for snapshot in snapshots:
  871. add_elilo_entry(config, snapshot)
  872. # Step 3: Remove snapshots older than retention period
  873. print("Checking for old snapshots to remove...")
  874. removed = remove_old_snapshots(config)
  875. if removed > 0:
  876. print(f"Removed {removed} old snapshots.")
  877. else:
  878. print("No old snapshots to remove.")
  879. print("\nSnapshot management completed successfully.")
  880. except Exception as e:
  881. logger.error(f"Error managing snapshots: {str(e)}")
  882. print(f"Error: {str(e)}")
  883. # Verify system configuration
  884. def verify_system(config):
  885. try:
  886. issues = []
  887. # Check if btrfs is installed
  888. result = subprocess.run(["which", "btrfs"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  889. if result.returncode != 0:
  890. issues.append("BTRFS tools not found. Please install btrfs-progs package.")
  891. # Check if root filesystem is btrfs
  892. result = subprocess.run(["findmnt", "-no", "FSTYPE", "/"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  893. if "btrfs" not in result.stdout.decode().strip():
  894. issues.append("Root filesystem is not BTRFS. snap-slack requires a BTRFS root filesystem.")
  895. # Check if elilo.conf exists or can be created
  896. elilo_conf = config['elilo']['elilo_conf']
  897. if not os.path.exists(elilo_conf):
  898. if not os.path.exists(os.path.dirname(elilo_conf)):
  899. issues.append(f"ELILO config directory not found: {os.path.dirname(elilo_conf)}")
  900. # Check if snapshot directory exists or can be created
  901. snapshot_dir = config['snapshot']['snapshot_dir']
  902. if not os.path.exists(snapshot_dir):
  903. try:
  904. os.makedirs(snapshot_dir, exist_ok=True)
  905. except Exception:
  906. issues.append(f"Cannot create snapshot directory: {snapshot_dir}")
  907. # Check if root subvolume exists
  908. root_path = os.path.join(config['snapshot']['btrfs_mount_point'], config['snapshot']['root_subvolume'])
  909. if not os.path.exists(root_path):
  910. issues.append(f"Root subvolume not found: {root_path}")
  911. if issues:
  912. print("\nSystem verification found issues:")
  913. for i, issue in enumerate(issues, 1):
  914. print(f"{i}. {issue}")
  915. return False
  916. else:
  917. print("System verification passed. Your system is properly configured for snap-slack.")
  918. return True
  919. except Exception as e:
  920. logger.error(f"Error verifying system: {str(e)}")
  921. print(f"Error verifying system: {str(e)}")
  922. return False
  923. # Parse command-line arguments
  924. def parse_arguments():
  925. parser = argparse.ArgumentParser(
  926. description='Manage BTRFS snapshots and bootloader entries.',
  927. formatter_class=argparse.RawDescriptionHelpFormatter,
  928. epilog="""
  929. Examples:
  930. snap-slack create Create a new snapshot
  931. snap-slack create --description "pre-update" Create snapshot with description
  932. snap-slack list List all available snapshots
  933. snap-slack manage Manage existing snapshots
  934. snap-slack boot --snapshot X Set up specific snapshot for booting
  935. snap-slack adopt --snapshot X Adopt a snapshot as the new root filesystem
  936. snap-slack verify Verify system configuration
  937. """
  938. )
  939. # Global options
  940. parser.add_argument('--dry-run', action='store_true', help='Show what would be done without actually doing it')
  941. parser.add_argument('--test-dir', help='Use a test directory instead of the real root')
  942. parser.add_argument('--config', help=f'Path to config file (default: {CONFIG_PATH})')
  943. parser.add_argument('--verbose', '-v', action='store_true', help='Enable verbose output')
  944. parser.add_argument('--simulate', action='store_true', help='Simulate operations (for safe testing)')
  945. subparsers = parser.add_subparsers(dest='action', help='Action to perform')
  946. # Create command
  947. create_parser = subparsers.add_parser('create', help='Create a new snapshot')
  948. create_parser.add_argument('--description', help='Optional description for the snapshot')
  949. # List command
  950. list_parser = subparsers.add_parser('list', help='List all snapshots')
  951. # Manage command
  952. manage_parser = subparsers.add_parser('manage', help='Manage snapshots (clean up old ones, update bootloader config)')
  953. # Boot command
  954. boot_parser = subparsers.add_parser('boot', help='Configure a snapshot for booting')
  955. boot_parser.add_argument('--snapshot', required=True, help='The snapshot to configure for booting')
  956. # Adopt command
  957. adopt_parser = subparsers.add_parser('adopt', help='Adopt a snapshot as the new root subvolume')
  958. adopt_parser.add_argument('--snapshot', required=True, help='The snapshot to adopt as the new root subvolume')
  959. # Verify command
  960. verify_parser = subparsers.add_parser('verify', help='Verify system configuration')
  961. return parser.parse_args()
  962. def main():
  963. try:
  964. # Parse command-line arguments
  965. args = parse_arguments()
  966. if not args.action:
  967. print("Error: No action specified. Use --help for usage information.")
  968. sys.exit(1)
  969. # Set logging level based on verbosity
  970. if args.verbose:
  971. logger.setLevel(logging.DEBUG)
  972. # Load configuration
  973. config_path = args.config if args.config else CONFIG_PATH
  974. config = read_config(config_path, args.test_dir)
  975. # Execute based on action
  976. if args.action == 'create':
  977. snapshot = create_snapshot(
  978. config,
  979. dry_run=args.dry_run,
  980. simulate=args.simulate,
  981. description=args.description if hasattr(args, 'description') else None
  982. )
  983. if snapshot:
  984. print(f"{'[SIMULATE] ' if args.simulate else ''}{'[DRY-RUN] ' if args.dry_run else ''}Created snapshot: {snapshot}")
  985. print(f"To boot from this snapshot, use: snap-slack boot --snapshot {snapshot}")
  986. elif args.action == 'list':
  987. display_snapshots(config)
  988. elif args.action == 'manage':
  989. manage(config, dry_run=args.dry_run, simulate=args.simulate)
  990. elif args.action == 'boot':
  991. create_boot_image(config, args.snapshot, dry_run=args.dry_run, simulate=args.simulate)
  992. elif args.action == 'adopt':
  993. adopt(config, args.snapshot, dry_run=args.dry_run, simulate=args.simulate)
  994. elif args.action == 'verify':
  995. verify_system(config)
  996. elif args.action == 'install-hooks':
  997. install_slackpkg_hooks(config, dry_run=args.dry_run, simulate=args.simulate)
  998. else:
  999. print(f"Invalid action: {args.action}")
  1000. sys.exit(1)
  1001. except KeyboardInterrupt:
  1002. print("\nOperation cancelled by user.")
  1003. sys.exit(130)
  1004. except Exception as e:
  1005. logger.error(f"Unhandled exception: {str(e)}")
  1006. print(f"Error: {str(e)}")
  1007. sys.exit(1)
  1008. # Install SlackPkg hooks
  1009. def install_slackpkg_hooks(config, dry_run=False, simulate=False):
  1010. hooks_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "install-hooks.sh")
  1011. if os.path.exists(hooks_script):
  1012. # Execute the hook installation script
  1013. cmd = ["bash", hooks_script]
  1014. returncode, stdout, stderr = safe_execute(
  1015. cmd,
  1016. dry_run=dry_run,
  1017. simulate=simulate,
  1018. description="Installing SlackPkg hooks"
  1019. )
  1020. if returncode != 0 and not (dry_run or simulate):
  1021. print(f"Error installing hooks: {stderr}")
  1022. return False
  1023. return True
  1024. else:
  1025. # Generate the hooks in-place
  1026. hooks_dir = "/etc/slackpkg/hooks"
  1027. pre_hook = os.path.join(hooks_dir, "pre-install.sh")
  1028. post_hook = os.path.join(hooks_dir, "post-install.sh")
  1029. # Create hooks directory
  1030. if not os.path.exists(hooks_dir) and not (dry_run or simulate):
  1031. try:
  1032. os.makedirs(hooks_dir, exist_ok=True)
  1033. except Exception as e:
  1034. print(f"Error creating hooks directory: {str(e)}")
  1035. return False
  1036. # Pre-install hook content
  1037. pre_hook_content = """#!/bin/bash
  1038. # SlackPkg Pre-install Hook for snap-slack
  1039. # This file was automatically installed by snap-slack
  1040. # Configuration
  1041. SNAP_SLACK=/usr/bin/snap-slack
  1042. ENABLE_AUTO_SNAPSHOTS=1 # Set to 0 to disable automatic snapshots
  1043. MAX_PACKAGE_COUNT=100 # Maximum number of packages to include in snapshot name
  1044. # Check if snap-slack is installed
  1045. if [ ! -x "$SNAP_SLACK" ]; then
  1046. echo "WARNING: snap-slack not found at $SNAP_SLACK, skipping pre-install snapshot"
  1047. exit 0
  1048. fi
  1049. # Check if auto snapshots are enabled
  1050. if [ "$ENABLE_AUTO_SNAPSHOTS" != "1" ]; then
  1051. echo "INFO: Automatic snapshots are disabled in slackpkg hook"
  1052. exit 0
  1053. fi
  1054. # Function to create a snapshot
  1055. create_snapshot() {
  1056. local desc="$1"
  1057. echo "Creating snapshot before package operations: $desc"
  1058. $SNAP_SLACK create --description "$desc"
  1059. }
  1060. # Get the operation being performed
  1061. OPERATION="$1"
  1062. shift
  1063. PACKAGES="$@"
  1064. # Create appropriate snapshot based on operation
  1065. case "$OPERATION" in
  1066. upgrade-all)
  1067. create_snapshot "pre-upgrade-all"
  1068. ;;
  1069. install|upgrade)
  1070. # Limit the number of packages in the snapshot name for readability
  1071. if [ "$(echo $PACKAGES | wc -w)" -gt $MAX_PACKAGE_COUNT ]; then
  1072. PKG_COUNT=$(echo $PACKAGES | wc -w)
  1073. create_snapshot "pre-$OPERATION-$PKG_COUNT-packages"
  1074. else
  1075. create_snapshot "pre-$OPERATION-$(echo $PACKAGES | tr ' ' '-')"
  1076. fi
  1077. ;;
  1078. remove)
  1079. if [ "$(echo $PACKAGES | wc -w)" -gt $MAX_PACKAGE_COUNT ]; then
  1080. PKG_COUNT=$(echo $PACKAGES | wc -w)
  1081. create_snapshot "pre-remove-$PKG_COUNT-packages"
  1082. else
  1083. create_snapshot "pre-remove-$(echo $PACKAGES | tr ' ' '-')"
  1084. fi
  1085. ;;
  1086. *)
  1087. # For other operations, create a generic snapshot
  1088. create_snapshot "pre-slackpkg-operation"
  1089. ;;
  1090. esac
  1091. exit 0
  1092. """
  1093. # Post-install hook content
  1094. post_hook_content = """#!/bin/bash
  1095. # SlackPkg Post-install Hook for snap-slack
  1096. # This file was automatically installed by snap-slack
  1097. # Configuration
  1098. SNAP_SLACK=/usr/bin/snap-slack
  1099. AUTO_CLEANUP=1 # Set to 0 to disable automatic snapshot cleanup
  1100. # Check if snap-slack is installed
  1101. if [ ! -x "$SNAP_SLACK" ]; then
  1102. echo "WARNING: snap-slack not found at $SNAP_SLACK, skipping post-install operations"
  1103. exit 0
  1104. fi
  1105. # Check if auto cleanup is enabled
  1106. if [ "$AUTO_CLEANUP" = "1" ]; then
  1107. echo "Running snapshot management to clean up old snapshots"
  1108. $SNAP_SLACK manage
  1109. fi
  1110. # Log the successful completion
  1111. echo "Package operation completed successfully."
  1112. echo "If you encounter issues, you can rollback using:"
  1113. echo " snap-slack list # to see available snapshots"
  1114. echo " snap-slack adopt --snapshot <snapshot-name> # to rollback"
  1115. exit 0
  1116. """
  1117. # Write the pre-install hook
  1118. safe_write_file(
  1119. pre_hook,
  1120. pre_hook_content,
  1121. dry_run=dry_run,
  1122. simulate=simulate,
  1123. description="Creating pre-install hook"
  1124. )
  1125. # Write the post-install hook
  1126. safe_write_file(
  1127. post_hook,
  1128. post_hook_content,
  1129. dry_run=dry_run,
  1130. simulate=simulate,
  1131. description="Creating post-install hook"
  1132. )
  1133. # Make hooks executable
  1134. if not (dry_run or simulate):
  1135. try:
  1136. os.chmod(pre_hook, 0o755)
  1137. os.chmod(post_hook, 0o755)
  1138. except Exception as e:
  1139. print(f"Error setting permissions on hooks: {str(e)}")
  1140. return False
  1141. print("SlackPkg hooks installed successfully.")
  1142. print("The hooks will create snapshots before package operations and clean up old snapshots afterward.")
  1143. print("To disable automatic snapshots, edit /etc/slackpkg/hooks/pre-install.sh and set ENABLE_AUTO_SNAPSHOTS=0")
  1144. print("To disable automatic cleanup, edit /etc/slackpkg/hooks/post-install.sh and set AUTO_CLEANUP=0")
  1145. return True
  1146. if __name__ == "__main__":
  1147. main()