]>
Commit | Line | Data |
---|---|---|
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() |