OSDN Git Service

[unpackfs] Move to conventional python-libcalamares use
[alterlinux/alterlinux-calamares.git] / src / modules / unpackfs / main.py
1 #!/usr/bin/env python3
2 # -*- coding: utf-8 -*-
3 #
4 # === This file is part of Calamares - <https://calamares.io> ===
5 #
6 #   SPDX-FileCopyrightText: 2014 Teo Mrnjavac <teo@kde.org>
7 #   SPDX-FileCopyrightText: 2014 Daniel Hillenbrand <codeworkx@bbqlinux.org>
8 #   SPDX-FileCopyrightText: 2014 Philip Müller <philm@manjaro.org>
9 #   SPDX-FileCopyrightText: 2017 Alf Gaida <agaida@siduction.org>
10 #   SPDX-FileCopyrightText: 2019 Kevin Kofler <kevin.kofler@chello.at>
11 #   SPDX-FileCopyrightText: 2020 Adriaan de Groot <groot@kde.org>
12 #   SPDX-FileCopyrightText: 2020 Gabriel Craciunescu <crazy@frugalware.org>
13 #   SPDX-License-Identifier: GPL-3.0-or-later
14 #
15 #   Calamares is Free Software: see the License-Identifier above.
16 #
17
18 import os
19 import re
20 import shutil
21 import subprocess
22 import sys
23 import tempfile
24
25 import libcalamares
26
27 import gettext
28 _ = gettext.translation("calamares-python",
29                         localedir=libcalamares.utils.gettext_path(),
30                         languages=libcalamares.utils.gettext_languages(),
31                         fallback=True).gettext
32
33 def pretty_name():
34     return _("Filling up filesystems.")
35
36 # This is going to be changed from various methods
37 status = pretty_name()
38
39 def pretty_status_message():
40     return status
41
42 class UnpackEntry:
43     """
44     Extraction routine using rsync.
45
46     :param source:
47     :param sourcefs:
48     :param destination:
49     """
50     __slots__ = ('source', 'sourcefs', 'destination', 'copied', 'total', 'exclude', 'excludeFile',
51                  'mountPoint', 'weight')
52
53     def __init__(self, source, sourcefs, destination):
54         """
55         @p source is the source file name (might be an image file, or
56             a directory, too)
57         @p sourcefs is a type indication; "file" is special, as is
58             "squashfs".
59         @p destination is where the files from the source go. This is
60             **already** prefixed by rootMountPoint, so should be a
61             valid absolute path within the host system.
62
63         The members copied and total are filled in by the copying process.
64         """
65         self.source = source
66         self.sourcefs = sourcefs
67         self.destination = destination
68         self.exclude = None
69         self.excludeFile = None
70         self.copied = 0
71         self.total = 0
72         self.mountPoint = None
73         self.weight = 1
74
75     def is_file(self):
76         return self.sourcefs == "file"
77
78     def do_count(self):
79         """
80         Counts the number of files this entry has.
81         """
82         fslist = ""
83
84         if self.sourcefs == "squashfs":
85             fslist = subprocess.check_output(
86                 ["unsquashfs", "-l", self.source]
87                 )
88
89         elif self.sourcefs == "ext4":
90             fslist = subprocess.check_output(
91                 ["find", self.mountPoint, "-type", "f"]
92                 )
93
94         elif self.is_file():
95             # Hasn't been mounted, copy directly; find handles both
96             # files and directories.
97             fslist = subprocess.check_output(["find", self.source, "-type", "f"])
98
99         self.total = len(fslist.splitlines())
100         return self.total
101
102     def do_mount(self, base):
103         """
104         Mount given @p entry as loop device underneath @p base
105
106         A *file* entry (e.g. one with *sourcefs* set to *file*)
107         is not mounted and just ignored.
108
109         :param base: directory to place all the mounts in.
110
111         :returns: None, but throws if the mount failed
112         """
113         imgbasename = os.path.splitext(
114             os.path.basename(self.source))[0]
115         imgmountdir = os.path.join(base, imgbasename)
116         os.makedirs(imgmountdir, exist_ok=True)
117
118         # This is where it *would* go (files bail out before actually mounting)
119         self.mountPoint = imgmountdir
120
121         if self.is_file():
122             return
123
124         if os.path.isdir(self.source):
125             r = libcalamares.utils.mount(self.source, imgmountdir, "", "--bind")
126         elif os.path.isfile(self.source):
127             r = libcalamares.utils.mount(self.source, imgmountdir, self.sourcefs, "loop")
128         else: # self.source is a device
129             r = libcalamares.utils.mount(self.source, imgmountdir, self.sourcefs, "")
130
131         if r != 0:
132             libcalamares.utils.debug("Failed to mount '{}' (fs={}) (target={})".format(self.source, self.sourcefs, imgmountdir))
133             raise subprocess.CalledProcessError(r, "mount")
134
135
136 ON_POSIX = 'posix' in sys.builtin_module_names
137
138
139 def global_excludes():
140     """
141     List excludes for rsync.
142     """
143     lst = []
144     extra_mounts = libcalamares.globalstorage.value("extraMounts")
145     if extra_mounts is None:
146         extra_mounts = []
147
148     for extra_mount in extra_mounts:
149         mount_point = extra_mount["mountPoint"]
150
151         if mount_point:
152             lst.extend(['--exclude', mount_point + '/'])
153
154     return lst
155
156 def file_copy(source, entry, progress_cb):
157     """
158     Extract given image using rsync.
159
160     :param source: Source file. This may be the place the entry's
161         image is mounted, or if it's a single file, the entry's source value.
162     :param entry: The UnpackEntry being copied.
163     :param progress_cb: A callback function for progress reporting.
164         Takes a number and a total-number.
165     """
166     import time
167
168     dest = entry.destination
169
170     # Environment used for executing rsync properly
171     # Setting locale to C (fix issue with tr_TR locale)
172     at_env = os.environ
173     at_env["LC_ALL"] = "C"
174
175     # `source` *must* end with '/' otherwise a directory named after the source
176     # will be created in `dest`: ie if `source` is "/foo/bar" and `dest` is
177     # "/dest", then files will be copied in "/dest/bar".
178     if not source.endswith("/") and not os.path.isfile(source):
179         source += "/"
180
181     num_files_total_local = 0
182     num_files_copied = 0  # Gets updated through rsync output
183
184     args = ['rsync', '-aHAXr', '--filter=-x trusted.overlay.*']
185     args.extend(global_excludes())
186     if entry.excludeFile:
187         args.extend(["--exclude-from=" + entry.excludeFile])
188     if entry.exclude:
189         for f in entry.exclude:
190             args.extend(["--exclude", f])
191     args.extend(['--progress', source, dest])
192     process = subprocess.Popen(
193         args, env=at_env,
194         stdout=subprocess.PIPE, close_fds=ON_POSIX
195         )
196     # last_num_files_copied trails num_files_copied, and whenever at least 107 more
197     # files (file_count_chunk) have been copied, progress is reported and
198     # last_num_files_copied is updated. The chunk size isn't "tidy"
199     # so that all the digits of the progress-reported number change.
200     #
201     last_num_files_copied = 0
202     last_timestamp_reported = time.time()
203     file_count_chunk = 107
204
205     for line in iter(process.stdout.readline, b''):
206         # rsync outputs progress in parentheses. Each line will have an
207         # xfer and a chk item (either ir-chk or to-chk) as follows:
208         #
209         # - xfer#x => Interpret it as 'file copy try no. x'
210         # - ir-chk=x/y, where:
211         #     - x = number of files yet to be checked
212         #     - y = currently calculated total number of files.
213         # - to-chk=x/y, which is similar and happens once the ir-chk
214         #   phase (collecting total files) is over.
215         #
216         # If you're copying directory with some links in it, the xfer#
217         # might not be a reliable counter (for one increase of xfer, many
218         # files may be created).
219         m = re.findall(r'xfr#(\d+), ..-chk=(\d+)/(\d+)', line.decode())
220
221         if m:
222             # we've got a percentage update
223             num_files_remaining = int(m[0][1])
224             num_files_total_local = int(m[0][2])
225             # adjusting the offset so that progressbar can be continuesly drawn
226             num_files_copied = num_files_total_local - num_files_remaining
227
228             now = time.time()
229             if (num_files_copied - last_num_files_copied >= file_count_chunk) or (now - last_timestamp_reported > 0.5):
230                 last_num_files_copied = num_files_copied
231                 last_timestamp_reported = now
232                 progress_cb(num_files_copied, num_files_total_local)
233
234     process.wait()
235     progress_cb(num_files_copied, num_files_total_local)  # Push towards 100%
236
237     # Mark this entry as really done
238     entry.copied = entry.total
239
240     # 23 is the return code rsync returns if it cannot write extended
241     # attributes (with -X) because the target file system does not support it,
242     # e.g., the FAT EFI system partition. We need -X because distributions
243     # using file system capabilities and/or SELinux require the extended
244     # attributes. But distributions using SELinux may also have SELinux labels
245     # set on files under /boot/efi, and rsync complains about those. The only
246     # clean way would be to split the rsync into one with -X and
247     # --exclude /boot/efi and a separate one without -X for /boot/efi, but only
248     # if /boot/efi is actually an EFI system partition. For now, this hack will
249     # have to do. See also:
250     # https://bugzilla.redhat.com/show_bug.cgi?id=868755#c50
251     # for the same issue in Anaconda, which uses a similar workaround.
252     if process.returncode != 0 and process.returncode != 23:
253         libcalamares.utils.warning("rsync failed with error code {}.".format(process.returncode))
254         return _("rsync failed with error code {}.").format(process.returncode)
255
256     return None
257
258
259 class UnpackOperation:
260     """
261     Extraction routine using unsquashfs.
262
263     :param entries:
264     """
265
266     def __init__(self, entries):
267         self.entries = entries
268         self.entry_for_source = dict((x.source, x) for x in self.entries)
269         self.total_weight = sum([e.weight for e in entries])
270
271     def report_progress(self):
272         """
273         Pass progress to user interface
274         """
275         progress = float(0)
276
277         current_total = 0
278         current_done = 0  # Files count in the current entry
279         complete_count = 0
280         complete_weight = 0  # This much weight already finished
281         for entry in self.entries:
282             if entry.total == 0:
283                 # Total 0 hasn't counted yet
284                 continue
285             if entry.total == entry.copied:
286                 complete_weight += entry.weight
287                 complete_count += 1
288             else:
289                 # There is at most *one* entry in-progress
290                 current_total = entry.total
291                 current_done = entry.copied
292                 complete_weight += entry.weight * ( 1.0 * current_done ) / current_total
293                 break
294
295         if current_total > 0:
296             progress = ( 1.0 * complete_weight ) / self.total_weight
297
298         global status
299         status = _("Unpacking image {}/{}, file {}/{}").format((complete_count+1), len(self.entries), current_done, current_total)
300         libcalamares.job.setprogress(progress)
301
302     def run(self):
303         """
304         Extract given image using unsquashfs.
305
306         :return:
307         """
308         global status
309         source_mount_path = tempfile.mkdtemp()
310
311         try:
312             complete = 0
313             for entry in self.entries:
314                 status = _("Starting to unpack {}").format(entry.source)
315                 libcalamares.job.setprogress( ( 1.0 * complete ) / len(self.entries) )
316                 entry.do_mount(source_mount_path)
317                 entry.do_count()  # Fill in the entry.total
318
319                 self.report_progress()
320                 error_msg = self.unpack_image(entry, entry.mountPoint)
321
322                 if error_msg:
323                     return (_("Failed to unpack image \"{}\"").format(entry.source),
324                             error_msg)
325                 complete += 1
326
327             return None
328         finally:
329             shutil.rmtree(source_mount_path, ignore_errors=True, onerror=None)
330
331
332     def unpack_image(self, entry, imgmountdir):
333         """
334         Unpacks image.
335
336         :param entry:
337         :param imgmountdir:
338         :return:
339         """
340         def progress_cb(copied, total):
341             """ Copies file to given destination target.
342
343             :param copied:
344             """
345             entry.copied = copied
346             if total > entry.total:
347                 entry.total = total
348             self.report_progress()
349
350         try:
351             if entry.is_file():
352                 source = entry.source
353             else:
354                 source = imgmountdir
355
356             return file_copy(source, entry, progress_cb)
357         finally:
358             if not entry.is_file():
359                 subprocess.check_call(["umount", "-l", imgmountdir])
360
361
362 def get_supported_filesystems_kernel():
363     """
364     Reads /proc/filesystems (the list of supported filesystems
365     for the current kernel) and returns a list of (names of)
366     those filesystems.
367     """
368     PATH_PROCFS = '/proc/filesystems'
369
370     if os.path.isfile(PATH_PROCFS) and os.access(PATH_PROCFS, os.R_OK):
371         with open(PATH_PROCFS, 'r') as procfile:
372             filesystems = procfile.read()
373             filesystems = filesystems.replace(
374                 "nodev", "").replace("\t", "").splitlines()
375             return filesystems
376
377     return []
378
379
380 def get_supported_filesystems():
381     """
382     Returns a list of all the supported filesystems
383     (valid values for the *sourcefs* key in an item.
384     """
385     return ["file"] + get_supported_filesystems_kernel()
386
387
388 def repair_root_permissions(root_mount_point):
389     """
390     If the / of the system gets permission 777, change it down
391     to 755. Any other permission is left alone. This
392     works around standard behavior from squashfs where
393     permissions are (easily, accidentally) set to 777.
394     """
395     existing_root_mode = os.stat(root_mount_point).st_mode & 0o777
396     if existing_root_mode == 0o777:
397         try:
398             os.chmod(root_mount_point, 0o755)  # Want / to be rwxr-xr-x
399         except OSError as e:
400             libcalamares.utils.warning("Could not set / to safe permissions: {}".format(e))
401             # But ignore it
402
403
404 def extract_weight(entry):
405     """
406     Given @p entry, a dict representing a single entry in
407     the *unpack* list, returns its weight (1, or whatever is
408     set if it is sensible).
409     """
410     w =  entry.get("weight", None)
411     if w:
412         try:
413             wi = int(w)
414             return wi if wi > 0 else 1
415         except ValueError:
416             libcalamares.utils.warning("*weight* setting {!r} is not valid.".format(w))
417         except TypeError:
418             libcalamares.utils.warning("*weight* setting {!r} must be number.".format(w))
419     return 1
420
421
422 def run():
423     """
424     Unsquash filesystem.
425     """
426     root_mount_point = libcalamares.globalstorage.value("rootMountPoint")
427
428     if not root_mount_point:
429         libcalamares.utils.warning("No mount point for root partition")
430         return (_("No mount point for root partition"),
431                 _("globalstorage does not contain a \"rootMountPoint\" key, "
432                 "doing nothing"))
433
434     if not os.path.exists(root_mount_point):
435         libcalamares.utils.warning("Bad root mount point \"{}\"".format(root_mount_point))
436         return (_("Bad mount point for root partition"),
437                 _("rootMountPoint is \"{}\", which does not "
438                 "exist, doing nothing").format(root_mount_point))
439
440     supported_filesystems = get_supported_filesystems()
441
442     # Bail out before we start when there are obvious problems
443     #   - unsupported filesystems
444     #   - non-existent sources
445     #   - missing tools for specific FS
446     for entry in libcalamares.job.configuration["unpack"]:
447         source = os.path.abspath(entry["source"])
448         sourcefs = entry["sourcefs"]
449
450         if sourcefs not in supported_filesystems:
451             libcalamares.utils.warning("The filesystem for \"{}\" ({}) is not supported by your current kernel".format(source, sourcefs))
452             libcalamares.utils.warning(" ... modprobe {} may solve the problem".format(sourcefs))
453             return (_("Bad unsquash configuration"),
454                     _("The filesystem for \"{}\" ({}) is not supported by your current kernel").format(source, sourcefs))
455         if not os.path.exists(source):
456             libcalamares.utils.warning("The source filesystem \"{}\" does not exist".format(source))
457             return (_("Bad unsquash configuration"),
458                     _("The source filesystem \"{}\" does not exist").format(source))
459         if sourcefs == "squashfs":
460             if shutil.which("unsquashfs") is None:
461                 libcalamares.utils.warning("Failed to find unsquashfs")
462
463                 return (_("Failed to unpack image \"{}\"").format(self.source),
464                         _("Failed to find unsquashfs, make sure you have the squashfs-tools package installed"))
465
466     unpack = list()
467
468     is_first = True
469     for entry in libcalamares.job.configuration["unpack"]:
470         source = os.path.abspath(entry["source"])
471         sourcefs = entry["sourcefs"]
472         destination = os.path.abspath(root_mount_point + entry["destination"])
473
474         if not os.path.isdir(destination) and sourcefs != "file":
475             libcalamares.utils.warning(("The destination \"{}\" in the target system is not a directory").format(destination))
476             if is_first:
477                 return (_("Bad unsquash configuration"),
478                         _("The destination \"{}\" in the target system is not a directory").format(destination))
479             else:
480                 libcalamares.utils.debug(".. assuming that the previous targets will create that directory.")
481
482         unpack.append(UnpackEntry(source, sourcefs, destination))
483         # Optional settings
484         if entry.get("exclude", None):
485             unpack[-1].exclude = entry["exclude"]
486         if entry.get("excludeFile", None):
487             unpack[-1].excludeFile = entry["excludeFile"]
488         unpack[-1].weight = extract_weight(entry)
489
490         is_first = False
491
492     repair_root_permissions(root_mount_point)
493     try:
494         unpackop = UnpackOperation(unpack)
495         return unpackop.run()
496     finally:
497         repair_root_permissions(root_mount_point)