]> git.ipfire.org Git - people/ms/bricklayer.git/blame - src/python/disk.py
disks: Fix automatic disk selection in unattended mode
[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
ce85f2b8
MT
186
187class Disk(object):
188 def __init__(self, bricklayer, device):
189 self.bricklayer = bricklayer
190
191 # The parted device
5567dbe8
MT
192 if not isinstance(device, parted.Device):
193 device = parted.Device(device)
194
ce85f2b8
MT
195 self.device = device
196
3f0095dd
MT
197 # The parted disk (with a blank partition table)
198 self.parted = parted.freshDisk(self.device, "gpt")
199
ce85f2b8
MT
200 # Has this device been selected?
201 self.selected = False
202
3f0095dd
MT
203 # Where are we starting with the next partition?
204 self._start = 1
205
ce85f2b8 206 def __repr__(self):
3f0095dd 207 return "<%s %s>" % (self.__class__.__name__, self.path)
ce85f2b8
MT
208
209 def __str__(self):
aeeca31e 210 return "%s (%s)" % (self.model, util.format_size(self.size))
ce85f2b8
MT
211
212 def __hash__(self):
213 return hash(self.path)
214
215 def __eq__(self, other):
216 if isinstance(other, self.__class__):
217 return self.device == other.device
218
219 return NotImplemented
220
221 def __lt__(self, other):
222 if isinstance(other, self.__class__):
223 return self.model < other.model or self.path < other.path
224
225 return NotImplemented
226
227 @property
228 def supported(self):
229 """
230 Is this device supported?
231 """
65f94856
MT
232 # Skip any device-mapper devices
233 if self.device.type == parted.DEVICE_DM:
234 return False
235
236 # Skip any CD/DVD drives
237 if self.device.path.startswith("/dev/sr"):
238 return False
239
240 # Skip MDRAID devices
241 if self.device.path.startswith("/dev/md"):
242 return False
243
ce85f2b8
MT
244 # We do not support read-only devices
245 if self.device.readOnly:
246 return False
247
248 # Ignore any busy devices
249 if self.device.busy:
250 return False
251
252 return True
253
254 @property
255 def path(self):
256 return self.device.path
257
258 @property
259 def model(self):
aeeca31e
MT
260 return self.device.model or _("Unknown Model")
261
262 @property
263 def size(self):
264 return self.device.length * self.device.sectorSize
ce85f2b8 265
6aaa8c32
MT
266 @property
267 def partitions(self):
268 """
269 Returns a list of all partitions on this device
270 """
271 return [Partition(self.bricklayer, p) for p in self.parted.partitions]
272
3f0095dd
MT
273 def create_system_partitions(self):
274 """
275 This method creates a basic partition layout on this disk with all
276 partitions that the systems needs. This is as follows:
277
278 1) BIOS boot partition (used for GRUB, etc.)
279 2) A FAT-formatted EFI partition
280 3) A swap partition
281 4) A / partition
282 """
283 log.debug("Creating partition layout on %s" % self.path)
284
285 # Create a bootloader partition of exactly 1 MiB
140eee62
MT
286 if any((bl.requires_bootldr_partition for bl in self.bricklayer.bootloaders)):
287 self._add_partition("BOOTLDR", DEFAULT_FILESYSTEM, length=1024**2,
288 flags=[parted.PARTITION_BIOS_GRUB])
3f0095dd
MT
289
290 # Create an EFI-partition of exactly 32 MiB
140eee62
MT
291 if any((bl.requires_efi_partition for bl in self.bricklayer.bootloaders)):
292 self._add_partition("ESP", "fat32", length=32 * 1024**2,
293 flags=[parted.PARTITION_ESP])
3f0095dd
MT
294
295 # Create a swap partition
296 swap_size = self.bricklayer.settings.get("swap-size", 0)
297 if swap_size:
298 self._add_partition("SWAP", "linux-swap(v1)", length=swap_size)
299
300 # Use all remaining space for root
301 self._add_partition("ROOT", DEFAULT_FILESYSTEM)
302
303 def _add_partition(self, name, filesystem, length=None, flags=[]):
304 # The entire device
305 if length is None:
306 length = self.device.getLength() - self._start
307
308 # Otherwise convert bytes into sectors
309 else:
310 length = length // self.device.sectorSize
311
312 # Calculate the partitions geometry
313 geometry = parted.Geometry(self.device, start=self._start, length=length)
314
315 # Create the filesystem
316 fs = parted.FileSystem(type=filesystem, geometry=geometry)
317
318 # Create the partition
319 partition = parted.Partition(disk=self.parted, type=parted.PARTITION_NORMAL,
320 fs=fs, geometry=geometry)
321
322 # Set name
323 partition.name = name
324
325 # Set flags
326 for flag in flags:
327 partition.setFlag(flag)
328
329 # Add the partition and align the most optimal way
330 self.parted.addPartition(partition,
331 constraint=self.device.optimalAlignedConstraint)
332
333 # Store end to know where to begin the next partition
334 self._start = geometry.end + 1
335
336 def commit(self):
337 """
338 Write the partition table to disk
339 """
49df51b8
MT
340 # Destroy any existing partition table
341 self.device.clobber()
342
343 # Write the new partition table
3f0095dd
MT
344 self.parted.commit()
345
6aaa8c32
MT
346 # For loop devices, we have to manually create the partition mappings
347 if self.path.startswith("/dev/loop"):
348 self.bricklayer.command(["kpartx", "-av", self.path])
349
f193c98a
MT
350 def tear_down(self):
351 """
352 Shuts down this disk
353 """
354 # Unmap partitions on loop devices
355 if self.path.startswith("/dev/loop"):
356 self.bricklayer.command(["kpartx", "-dv", self.path])
357
358 # Free the loop device
359 self.bricklayer.command(["losetup", "-d", self.path])
360
6aaa8c32
MT
361
362class Partition(object):
363 def __init__(self, bricklayer, parted):
364 self.bricklayer = bricklayer
365
366 # The parted device
367 self.parted = parted
368
369 def __repr__(self):
370 return "<%s %s>" % (self.__class__.__name__, self.name or self.path)
371
372 @property
373 def name(self):
374 return self.parted.name
375
376 @property
377 def path(self):
378 # Map path for loop devices
379 if self.parted.path.startswith("/dev/loop"):
380 return self.parted.path.replace("/dev/loop", "/dev/mapper/loop")
381
382 return self.parted.path
383
384 def wipe(self):
385 """
386 Wipes the entire partition (i.e. writes zeroes)
387 """
388 log.info("Wiping %s (%s)..." % (self.name, self.path))
389
390 zero = bytearray(1024)
391
392 with open(self.path, "wb") as f:
393 f.write(zero)
394
395 def format(self):
396 """
397 Formats the filesystem
398 """
399 # Wipe BIOS_GRUB partitions instead of formatting them
400 if self.parted.getFlag(parted.PARTITION_BIOS_GRUB):
401 return self.wipe()
402
403 # Fetch file-system type
404 filesystem = self.parted.fileSystem.type
405
406 log.info("Formatting %s (%s) with %s..." % (self.name, self.path, filesystem))
407
408 if filesystem == "fat32":
409 command = ["mkfs.vfat", self.path]
410 elif filesystem == "linux-swap(v1)":
411 command = ["mkswap", "-v1", self.path]
412 else:
f39f51ad 413 command = ["mkfs.%s" % filesystem, "-f", self.path]
6aaa8c32
MT
414
415 # Run command
416 self.bricklayer.command(command)
417
ce85f2b8 418
016d1cdf
MT
419class UnattendedSelectDisk(step.UnattendedStep):
420 """
421 Scans for any disks
422 """
423 def run(self):
424 # Nothing to do if disks have already been selected on the CLI
425 if self.bricklayer.disks.selected:
426 return
427
428 # Scan for disks
429 self.bricklayer.disks.scan()
430
431 # End here if we could not find any disks
432 if not self.bricklayer.disks.supported:
433 self.tui.error(
434 _("No Disks Found"),
435 _("No supported disks were found")
436 )
437
438 raise InstallAbortedError("No disks found")
439
440 # Automatically select the first disk
441 for disk in self.bricklayer.disks.supported:
442 disk.selected = True
443 break
444
445
59cf3c62 446class SelectDisk(step.InteractiveStep):
ce85f2b8
MT
447 """
448 Ask the user which disk(s) to use for the installation process
449 """
e4fb5285 450 def run(self):
ce85f2b8 451 # Scan for disks
e4fb5285 452 self.bricklayer.disks.scan()
ce85f2b8 453
ce85f2b8 454 # Create a dictionary with all disks
e4fb5285 455 disks = { disk : "%s" % disk for disk in self.bricklayer.disks.supported }
ce85f2b8
MT
456
457 # Show an error if no suitable disks were found
458 if not disks:
dee72c34 459 self.tui.error(
ce85f2b8
MT
460 _("No Disks Found"),
461 _("No supported disks were found")
462 )
463
464 raise InstallAbortedError("No disks found")
465
466 # Get the current selection
467 selection = [disk for disk in disks if disk.selected]
468
469 while True:
470 # Select disks
dee72c34 471 selection = self.tui.select(
ce85f2b8
MT
472 _("Disk Selection"),
473 _("Please select all disks for installation"),
baf888f4 474 disks, default=selection, multi=True, width=60,
ce85f2b8
MT
475 )
476
477 # Is at least one disk selected?
478 if not selection:
dee72c34 479 self.tui.error(
ce85f2b8
MT
480 _("No Disk Selected"),
481 _("Please select a disk to continue the installation"),
482 buttons=[_("Back")],
483 )
484 continue
485
486 # Apply selection
487 for disk in disks:
488 disk.selected = disk in selection
489
490 break
3f0095dd
MT
491
492
493class CalculatePartitionLayout(step.Step):
494 """
495 Calculates the partition layout
496 """
dee72c34 497 def run(self):
3f0095dd
MT
498 # This probably will be fast enough that we do not need to show anything
499
500 # Perform the job
501 self.bricklayer.disks.calculate_partition_layout()
502
503
504class CreatePartitionLayout(step.Step):
505 """
506 Creates the desired partition layout on disk
507 """
dee72c34 508 def run(self):
3f0095dd
MT
509 log.debug("Creating partitions")
510
dee72c34 511 with self.tui.progress(
766749e0
MT
512 _("Creating Partition Layout"),
513 _("Creating partition layout..."),
514 ):
515 for disk in self.bricklayer.disks.selected:
516 disk.commit()
6aaa8c32
MT
517
518
519class CreateFilesystems(step.Step):
520 """
521 Formats all newly created partitions
522 """
dee72c34 523 def run(self):
6aaa8c32
MT
524 for disk in self.bricklayer.disks.selected:
525 for partition in disk.partitions:
dee72c34 526 with self.tui.progress(
fbbd02fe
MT
527 _("Creating Filesystems"),
528 _("Formatting partition \"%s\"...") % (partition.name or partition.path)
529 ):
530 partition.format()
4f78c42f
MT
531
532
533class MountFilesystems(step.Step):
534 """
535 Mount all filesystems
536 """
dee72c34
MT
537 def run(self):
538 with self.tui.progress(
4f78c42f
MT
539 _("Mounting Filesystems"),
540 _("Mounting filesystems..."),
541 ):
542 self.bricklayer.disks.mount()
543
544
545class UmountFilesystems(step.Step):
546 """
547 Umount all filesystems
548 """
dee72c34
MT
549 def run(self):
550 with self.tui.progress(
4f78c42f
MT
551 _("Umounting Filesystems"),
552 _("Umounting filesystems..."),
553 ):
554 self.bricklayer.disks.umount()