]> git.ipfire.org Git - people/ms/bricklayer.git/blame - src/python/disk.py
backend: Send all Pakfire messages up to the logger
[people/ms/bricklayer.git] / src / python / disk.py
CommitLineData
ce85f2b8
MT
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
21import logging
5567dbe8 22import os
ce85f2b8 23import parted
5567dbe8 24import stat
ce85f2b8
MT
25
26from . import step
89990e4c 27from . import util
ce85f2b8
MT
28from .errors import *
29from .i18n import _
30
31# Setup logging
32log = logging.getLogger("bricklayer.disk")
33
f39f51ad 34DEFAULT_FILESYSTEM = "btrfs"
3f0095dd 35
d8389ba0
MT
36# Check if parted.PARTITION_ESP is set
37try:
38 parted.PARTITION_ESP
39except AttributeError:
40 parted.PARTITION_ESP = 18
41
ce85f2b8
MT
42class 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 """
016d1cdf
MT
56 # Don't scan for disks if already done
57 if self.disks:
58 return
ce85f2b8
MT
59
60 log.debug("Scanning for disks...")
61
5567dbe8
MT
62 for device in parted.getAllDevices():
63 disk = Disk(self.bricklayer, device)
89990e4c 64
5567dbe8
MT
65 # Skip whatever isn't suitable
66 if not disk.supported:
67 continue
ce85f2b8 68
5567dbe8 69 self.disks.append(disk)
ce85f2b8
MT
70
71 # Sort them alphabetically
72 self.disks.sort()
73
016d1cdf 74 def add_disk(self, path, selected=False):
89990e4c 75 """
5567dbe8 76 Adds the disk at path
89990e4c 77 """
5567dbe8
MT
78 # Check if the disk is already on the list
79 for disk in self.disks:
80 if disk.path == path:
81 return disk
89990e4c 82
5567dbe8 83 st = os.stat(path)
c96be18e 84
5567dbe8
MT
85 # Setup regular files as loop devices
86 if stat.S_ISREG(st.st_mode):
87 path = self._losetup(path)
89990e4c
MT
88
89 # Create a Disk object
5567dbe8 90 disk = Disk(self.bricklayer, path)
89990e4c
MT
91 self.disks.append(disk)
92
016d1cdf
MT
93 # Select this disk
94 if selected:
95 disk.selected = True
96
5567dbe8
MT
97 return disk
98
c96be18e
MT
99 def _losetup(self, path):
100 # Find a free loop device
101 device = self.bricklayer.command(["losetup", "-f"])
c96be18e
MT
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
ce85f2b8
MT
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
3f0095dd
MT
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
4f78c42f
MT
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
f193c98a
MT
177 def tear_down(self):
178 """
179 Shuts down any storage
180 """
4f78c42f
MT
181 self.umount()
182
f193c98a
MT
183 for disk in self.disks:
184 disk.tear_down()
185
142ee53b
MT
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
ce85f2b8
MT
207
208class Disk(object):
209 def __init__(self, bricklayer, device):
210 self.bricklayer = bricklayer
211
212 # The parted device
5567dbe8
MT
213 if not isinstance(device, parted.Device):
214 device = parted.Device(device)
215
ce85f2b8
MT
216 self.device = device
217
3f0095dd
MT
218 # The parted disk (with a blank partition table)
219 self.parted = parted.freshDisk(self.device, "gpt")
220
ce85f2b8
MT
221 # Has this device been selected?
222 self.selected = False
223
3f0095dd
MT
224 # Where are we starting with the next partition?
225 self._start = 1
226
ce85f2b8 227 def __repr__(self):
3f0095dd 228 return "<%s %s>" % (self.__class__.__name__, self.path)
ce85f2b8
MT
229
230 def __str__(self):
aeeca31e 231 return "%s (%s)" % (self.model, util.format_size(self.size))
ce85f2b8
MT
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 """
65f94856
MT
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
ce85f2b8
MT
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):
aeeca31e
MT
281 return self.device.model or _("Unknown Model")
282
283 @property
284 def size(self):
285 return self.device.length * self.device.sectorSize
ce85f2b8 286
6aaa8c32
MT
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
3f0095dd
MT
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
140eee62
MT
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])
3f0095dd
MT
310
311 # Create an EFI-partition of exactly 32 MiB
140eee62
MT
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])
3f0095dd
MT
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 """
49df51b8
MT
361 # Destroy any existing partition table
362 self.device.clobber()
363
364 # Write the new partition table
3f0095dd
MT
365 self.parted.commit()
366
6aaa8c32
MT
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
f193c98a
MT
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
6aaa8c32
MT
382
383class 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
142ee53b
MT
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
6aaa8c32
MT
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
f4370f63
MT
432 # Wipes any previous signatures from this disk
433 self.bricklayer.command(["wipefs", "--all", self.path])
6aaa8c32
MT
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
142ee53b
MT
443 log.info("Formatting %s (%s) with %s..." % \
444 (self.name, self.path, self.filesystem))
6aaa8c32 445
142ee53b 446 if self.filesystem == "fat32":
6aaa8c32 447 command = ["mkfs.vfat", self.path]
142ee53b 448 elif self.filesystem == "swap":
6aaa8c32
MT
449 command = ["mkswap", "-v1", self.path]
450 else:
142ee53b 451 command = ["mkfs.%s" % self.filesystem, "-f", self.path]
6aaa8c32
MT
452
453 # Run command
454 self.bricklayer.command(command)
455
142ee53b
MT
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",
8f665b61
MT
491
492 # Let the kernel automatically detect the filesystem unless it is swap space
493 "swap" if self.filesystem == "swap" else "auto",
142ee53b
MT
494 )
495
ce85f2b8 496
256906d3
MT
497class 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
016d1cdf
MT
506class 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
016d1cdf
MT
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
59cf3c62 530class SelectDisk(step.InteractiveStep):
ce85f2b8
MT
531 """
532 Ask the user which disk(s) to use for the installation process
533 """
e4fb5285 534 def run(self):
ce85f2b8 535 # Create a dictionary with all disks
e4fb5285 536 disks = { disk : "%s" % disk for disk in self.bricklayer.disks.supported }
ce85f2b8
MT
537
538 # Show an error if no suitable disks were found
539 if not disks:
dee72c34 540 self.tui.error(
ce85f2b8
MT
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
dee72c34 552 selection = self.tui.select(
ce85f2b8
MT
553 _("Disk Selection"),
554 _("Please select all disks for installation"),
baf888f4 555 disks, default=selection, multi=True, width=60,
ce85f2b8
MT
556 )
557
558 # Is at least one disk selected?
559 if not selection:
dee72c34 560 self.tui.error(
ce85f2b8
MT
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
3f0095dd
MT
572
573
574class CalculatePartitionLayout(step.Step):
575 """
576 Calculates the partition layout
577 """
dee72c34 578 def run(self):
3f0095dd
MT
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
585class CreatePartitionLayout(step.Step):
586 """
587 Creates the desired partition layout on disk
588 """
dee72c34 589 def run(self):
3f0095dd
MT
590 log.debug("Creating partitions")
591
dee72c34 592 with self.tui.progress(
766749e0
MT
593 _("Creating Partition Layout"),
594 _("Creating partition layout..."),
595 ):
596 for disk in self.bricklayer.disks.selected:
597 disk.commit()
6aaa8c32
MT
598
599
600class CreateFilesystems(step.Step):
601 """
602 Formats all newly created partitions
603 """
dee72c34 604 def run(self):
6aaa8c32
MT
605 for disk in self.bricklayer.disks.selected:
606 for partition in disk.partitions:
dee72c34 607 with self.tui.progress(
fbbd02fe
MT
608 _("Creating Filesystems"),
609 _("Formatting partition \"%s\"...") % (partition.name or partition.path)
610 ):
611 partition.format()
4f78c42f
MT
612
613
614class MountFilesystems(step.Step):
615 """
616 Mount all filesystems
617 """
dee72c34
MT
618 def run(self):
619 with self.tui.progress(
4f78c42f
MT
620 _("Mounting Filesystems"),
621 _("Mounting filesystems..."),
622 ):
623 self.bricklayer.disks.mount()
624
625
626class UmountFilesystems(step.Step):
627 """
628 Umount all filesystems
629 """
dee72c34
MT
630 def run(self):
631 with self.tui.progress(
4f78c42f
MT
632 _("Umounting Filesystems"),
633 _("Umounting filesystems..."),
634 ):
142ee53b 635 # Umount everything
4f78c42f 636 self.bricklayer.disks.umount()
142ee53b
MT
637
638
639class WriteFilesystemTable(step.Step):
640 def run(self):
641 self.bricklayer.disks.write_fstab()