]> git.ipfire.org Git - people/ms/bricklayer.git/blob - src/python/disk.py
2c405937aad04d913055eb53f17188b1738d1bc8
[people/ms/bricklayer.git] / src / python / disk.py
1 ###############################################################################
2 # #
3 # Bricklayer - An Installer for IPFire #
4 # Copyright (C) 2021 IPFire Development Team #
5 # #
6 # This program is free software; you can redistribute it and/or #
7 # modify it under the terms of the GNU General Public License #
8 # as published by the Free Software Foundation; either version 2 #
9 # of the License, or (at your option) any later version. #
10 # #
11 # This program is distributed in the hope that it will be useful, #
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of #
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
14 # GNU General Public License for more details. #
15 # #
16 # You should have received a copy of the GNU General Public License #
17 # along with this program. If not, see <http://www.gnu.org/licenses/>. #
18 # #
19 ###############################################################################
20
21 import logging
22 import os
23 import parted
24 import stat
25
26 from . import step
27 from . import util
28 from .errors import *
29 from .i18n import _
30
31 # Setup logging
32 log = logging.getLogger("bricklayer.disk")
33
34 DEFAULT_FILESYSTEM = "btrfs"
35
36 # Check if parted.PARTITION_ESP is set
37 try:
38 parted.PARTITION_ESP
39 except AttributeError:
40 parted.PARTITION_ESP = 18
41
42 class Disks(object):
43 """
44 Disks abstraction class
45 """
46 def __init__(self, bricklayer):
47 self.bricklayer = bricklayer
48
49 # Disks
50 self.disks = []
51
52 def scan(self):
53 """
54 Scans for all disks
55 """
56 # Don't scan for disks if already done
57 if self.disks:
58 return
59
60 log.debug("Scanning for disks...")
61
62 for device in parted.getAllDevices():
63 disk = Disk(self.bricklayer, device)
64
65 # Skip whatever isn't suitable
66 if not disk.supported:
67 continue
68
69 self.disks.append(disk)
70
71 # Sort them alphabetically
72 self.disks.sort()
73
74 def add_disk(self, path, selected=False):
75 """
76 Adds the disk at path
77 """
78 # Check if the disk is already on the list
79 for disk in self.disks:
80 if disk.path == path:
81 return disk
82
83 st = os.stat(path)
84
85 # Setup regular files as loop devices
86 if stat.S_ISREG(st.st_mode):
87 path = self._losetup(path)
88
89 # Create a Disk object
90 disk = Disk(self.bricklayer, path)
91 self.disks.append(disk)
92
93 # Select this disk
94 if selected:
95 disk.selected = True
96
97 return disk
98
99 def _losetup(self, path):
100 # Find a free loop device
101 device = self.bricklayer.command(["losetup", "-f"])
102 device = device.rstrip()
103
104 # Connect the image to the loop device
105 self.bricklayer.command(["losetup", device, path])
106
107 # Return the name of the loop device
108 return device
109
110 @property
111 def supported(self):
112 """
113 The supported disks
114 """
115 return [disk for disk in self.disks if disk.supported]
116
117 @property
118 def selected(self):
119 """
120 The selected disks
121 """
122 return [disk for disk in self.disks if disk.selected]
123
124 def calculate_partition_layout(self):
125 """
126 This creates the partition layout, but doesn't write it to disk, yet
127 """
128 # Find the root device
129 root = self.selected[0] # XXX select the first harddisk only
130
131 # Create one giant root partition
132 root.create_system_partitions()
133
134 def _find_partition(self, name):
135 """
136 Returns the partition with name
137 """
138 for disk in self.selected:
139 for partition in disk.partitions:
140 if partition.name == name:
141 return partition
142
143 def mount(self):
144 """
145 Mounts all filesystems
146 """
147 # Find root partition
148 partition = self._find_partition("ROOT")
149 if not partition:
150 FileNotFoundError("Could not find root partition")
151
152 # Mount the root partition
153 self._mount(partition.path, self.bricklayer.root)
154
155 # Find ESP partition
156 partition = self._find_partition("ESP")
157 if partition:
158 self._mount(partition.path, os.path.join(self.bricklayer.root, "boot/efi"))
159
160 def _mount(self, source, target):
161 """
162 Mounts source to target
163 """
164 # Make sure the target exists
165 os.makedirs(target, exist_ok=True)
166
167 # Call mount(8)
168 self.bricklayer.command(["mount", source, target])
169
170 def umount(self):
171 """
172 Umounts all filesystems
173 """
174 # Umount everything mounted in root
175 self.bricklayer.command(["umount", "-Rv", self.bricklayer.root], error_ok=True)
176
177 def tear_down(self):
178 """
179 Shuts down any storage
180 """
181 self.umount()
182
183 for disk in self.disks:
184 disk.tear_down()
185
186 def write_fstab(self):
187 """
188 Writes the disk configuration to /etc/fstab
189 """
190 path = os.path.join(self.bricklayer.root, "etc/fstab")
191
192 with open(path, "w") as f:
193 for disk in self.selected:
194 for partition in disk.partitions:
195 e = partition.make_fstab_entry()
196
197 # Do nothing if the entry is empty
198 if not e:
199 continue
200
201 # Log the entry
202 log.debug(e)
203
204 # Write it to the file
205 f.write(e)
206
207
208 class Disk(object):
209 def __init__(self, bricklayer, device):
210 self.bricklayer = bricklayer
211
212 # The parted device
213 if not isinstance(device, parted.Device):
214 device = parted.Device(device)
215
216 self.device = device
217
218 # The parted disk (with a blank partition table)
219 self.parted = parted.freshDisk(self.device, "gpt")
220
221 # Has this device been selected?
222 self.selected = False
223
224 # Where are we starting with the next partition?
225 self._start = 1
226
227 def __repr__(self):
228 return "<%s %s>" % (self.__class__.__name__, self.path)
229
230 def __str__(self):
231 return "%s (%s)" % (self.model, util.format_size(self.size))
232
233 def __hash__(self):
234 return hash(self.path)
235
236 def __eq__(self, other):
237 if isinstance(other, self.__class__):
238 return self.device == other.device
239
240 return NotImplemented
241
242 def __lt__(self, other):
243 if isinstance(other, self.__class__):
244 return self.model < other.model or self.path < other.path
245
246 return NotImplemented
247
248 @property
249 def supported(self):
250 """
251 Is this device supported?
252 """
253 # Skip any device-mapper devices
254 if self.device.type == parted.DEVICE_DM:
255 return False
256
257 # Skip any CD/DVD drives
258 if self.device.path.startswith("/dev/sr"):
259 return False
260
261 # Skip MDRAID devices
262 if self.device.path.startswith("/dev/md"):
263 return False
264
265 # We do not support read-only devices
266 if self.device.readOnly:
267 return False
268
269 # Ignore any busy devices
270 if self.device.busy:
271 return False
272
273 return True
274
275 @property
276 def path(self):
277 return self.device.path
278
279 @property
280 def model(self):
281 return self.device.model or _("Unknown Model")
282
283 @property
284 def size(self):
285 return self.device.length * self.device.sectorSize
286
287 @property
288 def partitions(self):
289 """
290 Returns a list of all partitions on this device
291 """
292 return [Partition(self.bricklayer, p) for p in self.parted.partitions]
293
294 def create_system_partitions(self):
295 """
296 This method creates a basic partition layout on this disk with all
297 partitions that the systems needs. This is as follows:
298
299 1) BIOS boot partition (used for GRUB, etc.)
300 2) A FAT-formatted EFI partition
301 3) A swap partition
302 4) A / partition
303 """
304 log.debug("Creating partition layout on %s" % self.path)
305
306 # Create a bootloader partition of exactly 1 MiB
307 if any((bl.requires_bootldr_partition for bl in self.bricklayer.bootloaders)):
308 self._add_partition("BOOTLDR", DEFAULT_FILESYSTEM, length=1024**2,
309 flags=[parted.PARTITION_BIOS_GRUB])
310
311 # Create an EFI-partition of exactly 32 MiB
312 if any((bl.requires_efi_partition for bl in self.bricklayer.bootloaders)):
313 self._add_partition("ESP", "fat32", length=32 * 1024**2,
314 flags=[parted.PARTITION_ESP])
315
316 # Create a swap partition
317 swap_size = self.bricklayer.settings.get("swap-size", 0)
318 if swap_size:
319 self._add_partition("SWAP", "linux-swap(v1)", length=swap_size)
320
321 # Use all remaining space for root
322 self._add_partition("ROOT", DEFAULT_FILESYSTEM)
323
324 def _add_partition(self, name, filesystem, length=None, flags=[]):
325 # The entire device
326 if length is None:
327 length = self.device.getLength() - self._start
328
329 # Otherwise convert bytes into sectors
330 else:
331 length = length // self.device.sectorSize
332
333 # Calculate the partitions geometry
334 geometry = parted.Geometry(self.device, start=self._start, length=length)
335
336 # Create the filesystem
337 fs = parted.FileSystem(type=filesystem, geometry=geometry)
338
339 # Create the partition
340 partition = parted.Partition(disk=self.parted, type=parted.PARTITION_NORMAL,
341 fs=fs, geometry=geometry)
342
343 # Set name
344 partition.name = name
345
346 # Set flags
347 for flag in flags:
348 partition.setFlag(flag)
349
350 # Add the partition and align the most optimal way
351 self.parted.addPartition(partition,
352 constraint=self.device.optimalAlignedConstraint)
353
354 # Store end to know where to begin the next partition
355 self._start = geometry.end + 1
356
357 def commit(self):
358 """
359 Write the partition table to disk
360 """
361 # Destroy any existing partition table
362 self.device.clobber()
363
364 # Write the new partition table
365 self.parted.commit()
366
367 # For loop devices, we have to manually create the partition mappings
368 if self.path.startswith("/dev/loop"):
369 self.bricklayer.command(["kpartx", "-av", self.path])
370
371 def tear_down(self):
372 """
373 Shuts down this disk
374 """
375 # Unmap partitions on loop devices
376 if self.path.startswith("/dev/loop"):
377 self.bricklayer.command(["kpartx", "-dv", self.path])
378
379 # Free the loop device
380 self.bricklayer.command(["losetup", "-d", self.path])
381
382
383 class Partition(object):
384 def __init__(self, bricklayer, parted):
385 self.bricklayer = bricklayer
386
387 # The parted device
388 self.parted = parted
389
390 def __repr__(self):
391 return "<%s %s>" % (self.__class__.__name__, self.name or self.path)
392
393 @property
394 def name(self):
395 return self.parted.name
396
397 @property
398 def path(self):
399 # Map path for loop devices
400 if self.parted.path.startswith("/dev/loop"):
401 return self.parted.path.replace("/dev/loop", "/dev/mapper/loop")
402
403 return self.parted.path
404
405 @property
406 def mountpoint(self):
407 """
408 Returns the mountpoint for this partition (or None)
409 """
410 if self.name == "ROOT":
411 return "/"
412
413 elif self.name == "ESP":
414 return "/boot/efi"
415
416 @property
417 def filesystem(self):
418 type = self.parted.fileSystem.type
419
420 # SWAP
421 if type == "linux-swap(v1)":
422 return "swap"
423
424 return type
425
426 def wipe(self):
427 """
428 Wipes the entire partition (i.e. writes zeroes)
429 """
430 log.info("Wiping %s (%s)..." % (self.name, self.path))
431
432 # Wipes any previous signatures from this disk
433 self.bricklayer.command(["wipefs", "--all", self.path])
434
435 def format(self):
436 """
437 Formats the filesystem
438 """
439 # Wipe BIOS_GRUB partitions instead of formatting them
440 if self.parted.getFlag(parted.PARTITION_BIOS_GRUB):
441 return self.wipe()
442
443 log.info("Formatting %s (%s) with %s..." % \
444 (self.name, self.path, self.filesystem))
445
446 if self.filesystem == "fat32":
447 command = ["mkfs.vfat", self.path]
448 elif self.filesystem == "swap":
449 command = ["mkswap", "-v1", self.path]
450 else:
451 command = ["mkfs.%s" % self.filesystem, "-f", self.path]
452
453 # Run command
454 self.bricklayer.command(command)
455
456 @property
457 def uuid(self):
458 """
459 Returns the UUID of the filesystem
460 """
461 uuid = self.bricklayer.command([
462 "blkid",
463
464 # Don't use the cache
465 "--probe",
466
467 # Return the UUID only
468 "--match-tag", "UUID",
469
470 # Only return the value
471 "--output", "value",
472
473 # Operate on this device
474 self.path,
475 ])
476
477 # Remove the trailing newline
478 return uuid.rstrip()
479
480 def make_fstab_entry(self):
481 """
482 Returns a /etc/fstab entry for this partition
483 """
484 # The bootloader partition does not get an entry
485 if self.name == "BOOTLDR":
486 return
487
488 return "UUID=%s %s %s defaults 0 0\n" % (
489 self.uuid,
490 self.mountpoint or "none",
491
492 # Let the kernel automatically detect the filesystem unless it is swap space
493 "swap" if self.filesystem == "swap" else "auto",
494 )
495
496
497 class Scan(step.Step):
498 def run(self):
499 with self.tui.progress(
500 _("Scanning for Disks"),
501 _("Scanning for disks..."),
502 ):
503 self.bricklayer.disks.scan()
504
505
506 class UnattendedSelectDisk(step.UnattendedStep):
507 """
508 Scans for any disks
509 """
510 def run(self):
511 # Nothing to do if disks have already been selected on the CLI
512 if self.bricklayer.disks.selected:
513 return
514
515 # End here if we could not find any disks
516 if not self.bricklayer.disks.supported:
517 self.tui.error(
518 _("No Disks Found"),
519 _("No supported disks were found")
520 )
521
522 raise InstallAbortedError("No disks found")
523
524 # Automatically select the first disk
525 for disk in self.bricklayer.disks.supported:
526 disk.selected = True
527 break
528
529
530 class SelectDisk(step.InteractiveStep):
531 """
532 Ask the user which disk(s) to use for the installation process
533 """
534 def run(self):
535 # Create a dictionary with all disks
536 disks = { disk : "%s" % disk for disk in self.bricklayer.disks.supported }
537
538 # Show an error if no suitable disks were found
539 if not disks:
540 self.tui.error(
541 _("No Disks Found"),
542 _("No supported disks were found")
543 )
544
545 raise InstallAbortedError("No disks found")
546
547 # Get the current selection
548 selection = [disk for disk in disks if disk.selected]
549
550 while True:
551 # Select disks
552 selection = self.tui.select(
553 _("Disk Selection"),
554 _("Please select all disks for installation"),
555 disks, default=selection, multi=True, width=60,
556 )
557
558 # Is at least one disk selected?
559 if not selection:
560 self.tui.error(
561 _("No Disk Selected"),
562 _("Please select a disk to continue the installation"),
563 buttons=[_("Back")],
564 )
565 continue
566
567 # Apply selection
568 for disk in disks:
569 disk.selected = disk in selection
570
571 break
572
573
574 class CalculatePartitionLayout(step.Step):
575 """
576 Calculates the partition layout
577 """
578 def run(self):
579 # This probably will be fast enough that we do not need to show anything
580
581 # Perform the job
582 self.bricklayer.disks.calculate_partition_layout()
583
584
585 class CreatePartitionLayout(step.Step):
586 """
587 Creates the desired partition layout on disk
588 """
589 def run(self):
590 log.debug("Creating partitions")
591
592 with self.tui.progress(
593 _("Creating Partition Layout"),
594 _("Creating partition layout..."),
595 ):
596 for disk in self.bricklayer.disks.selected:
597 disk.commit()
598
599
600 class CreateFilesystems(step.Step):
601 """
602 Formats all newly created partitions
603 """
604 def run(self):
605 for disk in self.bricklayer.disks.selected:
606 for partition in disk.partitions:
607 with self.tui.progress(
608 _("Creating Filesystems"),
609 _("Formatting partition \"%s\"...") % (partition.name or partition.path)
610 ):
611 partition.format()
612
613
614 class MountFilesystems(step.Step):
615 """
616 Mount all filesystems
617 """
618 def run(self):
619 with self.tui.progress(
620 _("Mounting Filesystems"),
621 _("Mounting filesystems..."),
622 ):
623 self.bricklayer.disks.mount()
624
625
626 class UmountFilesystems(step.Step):
627 """
628 Umount all filesystems
629 """
630 def run(self):
631 with self.tui.progress(
632 _("Umounting Filesystems"),
633 _("Umounting filesystems..."),
634 ):
635 # Umount everything
636 self.bricklayer.disks.umount()
637
638
639 class WriteFilesystemTable(step.Step):
640 def run(self):
641 self.bricklayer.disks.write_fstab()