docker-squash 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
docker_squash/image.py ADDED
@@ -0,0 +1,1130 @@
1
+ import datetime
2
+ import hashlib
3
+ import itertools
4
+ import json
5
+ import logging
6
+ import os
7
+ import pathlib
8
+ import re
9
+ import shutil
10
+ import tarfile
11
+ import tempfile
12
+ import threading
13
+ from typing import IO, Dict, Iterable, List, Optional, Set, Tuple, Union
14
+
15
+ import docker as docker_library
16
+
17
+ from docker_squash.errors import SquashError, SquashUnnecessaryError
18
+
19
+
20
+ class Chdir(object):
21
+ """Context manager for changing the current working directory"""
22
+
23
+ def __init__(self, new_path: str) -> None:
24
+ self.newPath = os.path.expanduser(new_path)
25
+
26
+ def __enter__(self) -> None:
27
+ self.savedPath = os.getcwd()
28
+ os.chdir(self.newPath)
29
+
30
+ def __exit__(self, etype, value, traceback):
31
+ os.chdir(self.savedPath)
32
+
33
+
34
+ class Image(object):
35
+ """
36
+ Base class for all Docker image formats. Contains many functions that are handy
37
+ while squashing the image.
38
+
39
+ This class should not be used directly.
40
+ """
41
+
42
+ FORMAT: Optional[str] = None
43
+ """ Image format version """
44
+
45
+ def __init__(
46
+ self,
47
+ log,
48
+ docker,
49
+ image,
50
+ from_layer,
51
+ tmp_dir: Optional[str] = None,
52
+ tag: Optional[str] = None,
53
+ comment: Optional[str] = "",
54
+ ):
55
+ self.log: logging.Logger = log
56
+ self.debug = self.log.isEnabledFor(logging.DEBUG)
57
+ self.docker = docker
58
+ self.image: str = image
59
+ self.from_layer: Optional[str] = from_layer
60
+ self.tag: Optional[str] = tag
61
+ self.comment: Optional[str] = comment
62
+ self.image_name = None
63
+ self.image_tag = None
64
+ self.squash_id: Optional[str] = None
65
+ self.oci_format = False
66
+
67
+ # Workaround for https://play.golang.org/p/sCsWMXYxqy
68
+ #
69
+ # Golang doesn't add padding to microseconds when marshaling
70
+ # microseconds in date into JSON. Python does.
71
+ # We need to produce same output as Docker's to not generate
72
+ # different metadata. That's why we need to strip all zeros at the
73
+ # end of the date string...
74
+ self.date = re.sub(
75
+ r"0*Z$", "Z", datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%fZ")
76
+ )
77
+ """ Date used in metadata, already formatted using the `%Y-%m-%dT%H:%M:%S.%fZ` format """
78
+
79
+ self.tmp_dir: Optional[str] = tmp_dir
80
+ """ Main temporary directory to save all working files. This is the root directory for all other temporary files. """
81
+
82
+ def squash(self) -> str:
83
+ self._before_squashing()
84
+ ret = self._squash()
85
+ self._after_squashing()
86
+
87
+ return ret
88
+
89
+ def _squash(self) -> str:
90
+ """Squashes the image and returns the new image ID"""
91
+ raise NotImplementedError
92
+
93
+ def cleanup(self):
94
+ """Cleanup the temporary directory"""
95
+
96
+ self.log.debug("Cleaning up %s temporary directory" % self.tmp_dir)
97
+ if self.tmp_dir:
98
+ shutil.rmtree(self.tmp_dir, ignore_errors=True)
99
+
100
+ def _initialize_directories(self):
101
+ # Prepare temporary directory where all the work will be executed
102
+ try:
103
+ self.tmp_dir = self._prepare_tmp_directory(self.tmp_dir)
104
+ except Exception:
105
+ raise SquashError("Preparing temporary directory failed")
106
+
107
+ # Temporary location on the disk of the old, unpacked *image*
108
+ self.old_image_dir: str = os.path.join(self.tmp_dir, "old")
109
+ # Temporary location on the disk of the new, unpacked, squashed *image*
110
+ self.new_image_dir: str = os.path.join(self.tmp_dir, "new")
111
+ # Temporary location on the disk of the squashed *layer*
112
+ self.squashed_dir: str = os.path.join(self.new_image_dir, "squashed")
113
+
114
+ for d in self.old_image_dir, self.new_image_dir:
115
+ os.makedirs(d)
116
+
117
+ def _squash_id(self, layer):
118
+ if layer == "<missing>":
119
+ self.log.warning(
120
+ "You try to squash from layer that does not have it's own ID, we'll try to find it later"
121
+ )
122
+ return None
123
+
124
+ try:
125
+ squash_id = self.docker.inspect_image(layer)["Id"]
126
+ except Exception:
127
+ raise SquashError(
128
+ f"Could not get the layer ID to squash, please check provided 'layer' argument: {layer}"
129
+ )
130
+
131
+ if squash_id not in self.old_image_layers:
132
+ raise SquashError(
133
+ f"Couldn't find the provided layer ({layer}) in the {self.image} image"
134
+ )
135
+
136
+ self.log.debug("Layer ID to squash from: %s" % squash_id)
137
+
138
+ return squash_id
139
+
140
+ def _validate_number_of_layers(self, number_of_layers):
141
+ """
142
+ Makes sure that the specified number of layers to squash
143
+ is a valid number
144
+ """
145
+
146
+ # Only positive numbers are correct
147
+ if number_of_layers <= 0:
148
+ raise SquashError(
149
+ f"Number of layers to squash cannot be less or equal 0, provided: {number_of_layers}"
150
+ )
151
+
152
+ # Do not squash if provided number of layer to squash is bigger
153
+ # than number of actual layers in the image
154
+ if number_of_layers > len(self.old_image_layers):
155
+ raise SquashError(
156
+ f"Cannot squash {number_of_layers} layers, the {self.image} image contains only {len(self.old_image_layers)} layers"
157
+ )
158
+
159
+ def _before_squashing(self):
160
+ self._initialize_directories()
161
+
162
+ # Location of the tar archive with squashed layers
163
+ self.squashed_tar = os.path.join(self.squashed_dir, "layer.tar")
164
+
165
+ if self.tag:
166
+ self.image_name, self.image_tag = self._parse_image_name(self.tag)
167
+
168
+ # The image id or name of the image to be squashed
169
+ try:
170
+ self.old_image_id = self.docker.inspect_image(self.image)["Id"]
171
+ except SquashError:
172
+ raise SquashError(
173
+ f"Could not get the image ID to squash, please check provided 'image' argument: {self.image}"
174
+ )
175
+
176
+ self.old_image_layers: List[str] = []
177
+
178
+ # Read all layers in the image
179
+ self._read_layers(self.old_image_layers, self.old_image_id)
180
+ self.old_image_layers.reverse()
181
+ self.log.info("Old image has %s layers", len(self.old_image_layers))
182
+ self.log.debug("Old layers: %s", self.old_image_layers)
183
+
184
+ # By default - squash all layers.
185
+ if self.from_layer is None:
186
+ self.from_layer = str(len(self.old_image_layers))
187
+
188
+ try:
189
+ number_of_layers = int(self.from_layer)
190
+
191
+ self.log.debug(
192
+ f"We detected number of layers ({number_of_layers}) as the argument to squash"
193
+ )
194
+ except ValueError:
195
+ squash_id = self._squash_id(self.from_layer)
196
+ self.log.debug(f"We detected layer ({squash_id}) as the argument to squash")
197
+
198
+ if not squash_id:
199
+ raise SquashError(
200
+ f"The {self.from_layer} layer could not be found in the {self.image} image"
201
+ )
202
+
203
+ number_of_layers = (
204
+ len(self.old_image_layers) - self.old_image_layers.index(squash_id) - 1
205
+ )
206
+
207
+ self._validate_number_of_layers(number_of_layers)
208
+
209
+ marker = len(self.old_image_layers) - number_of_layers
210
+
211
+ self.layers_to_squash = self.old_image_layers[marker:]
212
+ self.layers_to_move = self.old_image_layers[:marker]
213
+
214
+ self.log.info("Checking if squashing is necessary...")
215
+
216
+ if len(self.layers_to_squash) < 1:
217
+ raise SquashError(
218
+ f"Invalid number of layers to squash: {len(self.layers_to_squash)}"
219
+ )
220
+
221
+ if len(self.layers_to_squash) == 1:
222
+ raise SquashUnnecessaryError(
223
+ "Single layer marked to squash, no squashing is required"
224
+ )
225
+
226
+ self.log.info(f"Attempting to squash last {number_of_layers} layers...")
227
+ self.log.debug(f"Layers to squash: {self.layers_to_squash}")
228
+ self.log.debug(f"Layers to move: {self.layers_to_move}")
229
+
230
+ # Fetch the image and unpack it on the fly to the old image directory
231
+ self._save_image(self.old_image_id, self.old_image_dir)
232
+
233
+ self.size_before = self._dir_size(self.old_image_dir)
234
+
235
+ self.log.info("Squashing image '%s'..." % self.image)
236
+
237
+ def _after_squashing(self):
238
+ self.log.debug("Removing from disk already squashed layers...")
239
+ self.log.debug("Cleaning up %s temporary directory" % self.old_image_dir)
240
+ shutil.rmtree(self.old_image_dir, ignore_errors=True)
241
+
242
+ self.size_after = self._dir_size(self.new_image_dir)
243
+
244
+ size_before_mb = float(self.size_before) / 1024 / 1024
245
+ size_after_mb = float(self.size_after) / 1024 / 1024
246
+
247
+ self.log.info("Original image size: %.2f MB" % size_before_mb)
248
+ self.log.info("Squashed image size: %.2f MB" % size_after_mb)
249
+
250
+ if size_after_mb >= size_before_mb:
251
+ self.log.info(
252
+ "If the squashed image is larger than original it means that there were no meaningful files to squash and it just added metadata. Are you sure you specified correct parameters?"
253
+ )
254
+ else:
255
+ self.log.info(
256
+ "Image size decreased by %.2f %%"
257
+ % float(((size_before_mb - size_after_mb) / size_before_mb) * 100)
258
+ )
259
+
260
+ def _dir_size(self, directory: str) -> int:
261
+ size = 0
262
+
263
+ for path, dirs, files in os.walk(directory):
264
+ for f in files:
265
+ size += os.path.getsize(os.path.join(path, f))
266
+
267
+ return size
268
+
269
+ def layer_paths(self):
270
+ """
271
+ Returns name of directories to layers in the exported tar archive.
272
+ """
273
+ pass
274
+
275
+ def export_tar_archive(self, target_tar_file):
276
+ self._tar_image(target_tar_file, self.new_image_dir)
277
+ self.log.info("Image available at '%s'" % target_tar_file)
278
+
279
+ def load_squashed_image(self) -> Optional[str]:
280
+ """
281
+ Loads the squashed image into the daemon and returns the image ID
282
+ assigned by the daemon (without the 'sha256:' prefix), or None if it
283
+ could not be determined.
284
+ """
285
+ image_id = self._load_image(self.new_image_dir)
286
+
287
+ if self.tag:
288
+ self.log.info(
289
+ "Image registered in Docker daemon as %s:%s"
290
+ % (self.image_name, self.image_tag)
291
+ )
292
+
293
+ return image_id
294
+
295
+ def _files_in_layers(self, layers: List[str]) -> Dict[str, Set[str]]:
296
+ """
297
+ Prepare a set of files for each of the layers
298
+ """
299
+ files: Dict[str, Set[str]] = {}
300
+
301
+ for layer in layers:
302
+ self.log.debug("Generating list of files in layer '%s'..." % layer)
303
+ tar_file = self._extract_tar_name(layer)
304
+ with tarfile.open(tar_file, "r", format=tarfile.PAX_FORMAT) as tar:
305
+ files[layer] = {self._normalize_path(x) for x in tar.getnames()}
306
+ self.log.debug("Done, found %s files" % len(files[layer]))
307
+
308
+ return files
309
+
310
+ def _prepare_tmp_directory(self, tmp_dir: Optional[str]) -> str:
311
+ """Creates temporary directory that is used to work on layers"""
312
+
313
+ if tmp_dir:
314
+ if os.path.exists(tmp_dir):
315
+ raise SquashError(
316
+ f"The '{tmp_dir}' directory already exists, please remove it before you proceed"
317
+ )
318
+ os.makedirs(tmp_dir)
319
+ else:
320
+ tmp_dir = tempfile.mkdtemp(prefix="docker-squash-")
321
+
322
+ self.log.debug("Using %s as the temporary directory" % tmp_dir)
323
+
324
+ return tmp_dir
325
+
326
+ def _load_image(self, directory) -> Optional[str]:
327
+ # Set by _initialize_directories() before squashing starts
328
+ assert self.tmp_dir is not None
329
+ tar_file = os.path.join(self.tmp_dir, "image.tar")
330
+
331
+ self._tar_image(tar_file, directory)
332
+
333
+ loaded_id = None
334
+ loaded_ref = None
335
+
336
+ with open(tar_file, "rb") as f:
337
+ self.log.debug("Loading squashed image...")
338
+ for message in self.docker.load_image(f) or []:
339
+ self.log.debug("Load reply: %s" % message)
340
+
341
+ if not isinstance(message, dict):
342
+ continue
343
+
344
+ if message.get("error") or message.get("errorDetail"):
345
+ raise SquashError(
346
+ "Docker daemon failed to load the squashed image: %s"
347
+ % (message.get("error") or message.get("errorDetail"))
348
+ )
349
+
350
+ stream = message.get("stream", "")
351
+
352
+ match = re.search(r"Loaded image ID: (\S+)", stream)
353
+ if match:
354
+ loaded_id = match.group(1)
355
+ continue
356
+
357
+ match = re.search(r"Loaded image: (\S+)", stream)
358
+ if match:
359
+ loaded_ref = match.group(1)
360
+
361
+ self.log.debug("Image loaded!")
362
+
363
+ os.remove(tar_file)
364
+
365
+ if loaded_id is None and loaded_ref is not None:
366
+ try:
367
+ loaded_id = self.docker.inspect_image(loaded_ref)["Id"]
368
+ except Exception as ex:
369
+ self.log.warning(
370
+ "Could not inspect loaded image %s: %s" % (loaded_ref, ex)
371
+ )
372
+
373
+ if loaded_id is None:
374
+ return None
375
+
376
+ return loaded_id.replace("sha256:", "")
377
+
378
+ def _tar_image(self, target_tar_file: str, directory: str) -> None:
379
+ with tarfile.open(target_tar_file, "w", format=tarfile.PAX_FORMAT) as tar:
380
+ self.log.debug("Generating tar archive for the squashed image...")
381
+ with Chdir(directory):
382
+ # docker produces images like this:
383
+ # repositories
384
+ # <layer>/json
385
+ # and not:
386
+ # ./
387
+ # ./repositories
388
+ # ./<layer>/json
389
+ for f in os.listdir("."):
390
+ tar.add(f)
391
+ self.log.debug("Archive generated")
392
+
393
+ def _layers_to_squash(
394
+ self, layers: List[str], from_layer: Optional[str]
395
+ ) -> Tuple[List[str], List[str]]:
396
+ """Prepares a list of layer IDs that should be squashed"""
397
+ to_squash: List[str] = []
398
+ to_leave: List[str] = []
399
+ should_squash = True
400
+
401
+ for reversed_layer in reversed(layers):
402
+ if reversed_layer == from_layer:
403
+ should_squash = False
404
+
405
+ if should_squash:
406
+ to_squash.append(reversed_layer)
407
+ else:
408
+ to_leave.append(reversed_layer)
409
+
410
+ to_squash.reverse()
411
+ to_leave.reverse()
412
+
413
+ return to_squash, to_leave
414
+
415
+ def _extract_tar(self, fileobj, directory):
416
+ with tarfile.open(fileobj=fileobj, mode="r|") as tar:
417
+ tar.extractall(path=directory)
418
+
419
+ def _save_image(self, image_id, directory):
420
+ """Saves the image as a tar archive under specified name"""
421
+
422
+ for x in [0, 1, 2]:
423
+ self.log.info("Saving image %s to %s directory..." % (image_id, directory))
424
+ self.log.debug("Try #%s..." % (x + 1))
425
+
426
+ try:
427
+ image = self.docker.get_image(image_id)
428
+
429
+ if int(docker_library.__version__.split(".")[0]) < 3:
430
+ # Docker library prior to 3.0.0 returned the requests
431
+ # object directly which could be used to read from
432
+ self.log.debug(
433
+ "Extracting image using HTTPResponse object directly"
434
+ )
435
+ self._extract_tar(image, directory)
436
+ else:
437
+ # Docker library >=3.0.0 returns iterator over raw data
438
+ self.log.debug("Extracting image using iterator over raw data")
439
+
440
+ fd_r, fd_w = os.pipe()
441
+
442
+ r = os.fdopen(fd_r, "rb")
443
+ w = os.fdopen(fd_w, "wb")
444
+
445
+ extracter = threading.Thread(
446
+ target=self._extract_tar, args=(r, directory)
447
+ )
448
+ extracter.start()
449
+
450
+ for chunk in image:
451
+ w.write(chunk)
452
+
453
+ w.flush()
454
+ w.close()
455
+
456
+ extracter.join()
457
+ r.close()
458
+ self.log.info("Image saved!")
459
+ return True
460
+ except Exception as e:
461
+ self.log.exception(e)
462
+ self.log.warning(
463
+ f"An error occurred while saving the {image_id} image, retrying..."
464
+ )
465
+
466
+ raise SquashError(f"Couldn't save {image_id} image!")
467
+
468
+ def _unpack(self, tar_file: str, directory: str) -> None:
469
+ """Unpacks tar archive to selected directory"""
470
+
471
+ self.log.info("Unpacking %s tar file to %s directory" % (tar_file, directory))
472
+
473
+ with tarfile.open(tar_file, "r") as tar:
474
+ tar.extractall(path=directory)
475
+
476
+ self.log.info("Archive unpacked!")
477
+
478
+ def _read_layers(self, layers, image_id):
479
+ """Reads the JSON metadata for specified layer / image id"""
480
+
481
+ for layer in self.docker.history(image_id):
482
+ layers.append(layer["Id"])
483
+
484
+ def _parse_image_name(self, image):
485
+ """
486
+ Parses the provided image name and splits it in the
487
+ name and tag part, if possible. If no tag is provided
488
+ 'latest' is used.
489
+ """
490
+ if ":" in image and "/" not in image.split(":")[-1]:
491
+ image_tag = image.split(":")[-1]
492
+ image_name = image[: -(len(image_tag) + 1)]
493
+ else:
494
+ image_tag = "latest"
495
+ image_name = image
496
+
497
+ return (image_name, image_tag)
498
+
499
+ def _dump_json(self, data: object, new_line: bool = False) -> Tuple[str, str]:
500
+ """
501
+ Helper function to marshal object into JSON string.
502
+ Additionally a sha256sum of the created JSON string is generated.
503
+ """
504
+
505
+ # We do not want any spaces between keys and values in JSON
506
+ json_data = json.dumps(data, separators=(",", ":"))
507
+
508
+ if new_line:
509
+ json_data = "%s\n" % json_data
510
+
511
+ # Generate sha256sum of the JSON data, may be handy
512
+ sha = hashlib.sha256(json_data.encode("utf-8")).hexdigest()
513
+
514
+ return json_data, sha
515
+
516
+ def _generate_repositories_json(
517
+ self,
518
+ repositories_file: str,
519
+ image_id: str,
520
+ name: Optional[str],
521
+ tag: Optional[str],
522
+ ) -> None:
523
+ if not image_id:
524
+ raise SquashError("Provided image id cannot be null")
525
+
526
+ if name is None and tag is None:
527
+ self.log.debug(
528
+ "No name and tag provided for the image, skipping generating repositories file"
529
+ )
530
+ return
531
+
532
+ repos: Dict[Optional[str], Dict[Optional[str], str]] = {}
533
+ repos[name] = {}
534
+ repos[name][tag] = image_id
535
+
536
+ data = json.dumps(repos, separators=(",", ":"))
537
+
538
+ with open(repositories_file, "w") as f:
539
+ f.write(data)
540
+ f.write("\n")
541
+
542
+ def _write_version_file(self, squashed_dir: str) -> None:
543
+ version_file = os.path.join(squashed_dir, "VERSION")
544
+
545
+ with open(version_file, "w") as f:
546
+ f.write("1.0")
547
+
548
+ def _write_json_metadata(self, metadata: str, metadata_file: str) -> None:
549
+ with open(metadata_file, "w") as f:
550
+ f.write(metadata)
551
+
552
+ def _read_old_metadata(self, old_json_file):
553
+ self.log.debug("Reading JSON metadata file '%s'..." % old_json_file)
554
+
555
+ # Read original metadata
556
+ with open(old_json_file, "r") as f:
557
+ metadata = json.load(f)
558
+
559
+ return metadata
560
+
561
+ def _move_layers(self, layers, src: str, dest: str):
562
+ """
563
+ This moves all the layers that should be copied as-is.
564
+ In other words - all layers that are not meant to be squashed will be
565
+ moved from the old image to the new image untouched.
566
+ """
567
+ for layer in layers:
568
+ layer_id = layer.replace("sha256:", "")
569
+
570
+ self.log.debug("Moving unmodified layer '%s'..." % layer_id)
571
+ # Keep the layer's relative path (for example blobs/sha256/<id>
572
+ # in the OCI archive layout), because manifest.json refers to it.
573
+ target = os.path.join(dest, layer_id)
574
+ os.makedirs(os.path.dirname(target), exist_ok=True)
575
+ shutil.move(os.path.join(src, layer_id), target)
576
+
577
+ def _file_should_be_skipped(
578
+ self, file_name: str, file_paths: Iterable[Iterable[str]]
579
+ ) -> int:
580
+ # file_paths is now array of array with files to be skipped.
581
+ # First level are layers, second are files in these layers.
582
+ layer_nb = 1
583
+
584
+ for layers in file_paths:
585
+ for file_path in layers:
586
+ if file_name == file_path or file_name.startswith(file_path + "/"):
587
+ return layer_nb
588
+
589
+ layer_nb += 1
590
+
591
+ return 0
592
+
593
+ def _marker_files(self, tar, members):
594
+ """
595
+ Searches for marker files in the specified archive.
596
+
597
+ Docker marker files are files that have the .wh. prefix in the name.
598
+ These files mark the corresponding file to be removed (hidden) when
599
+ we start a container from the image.
600
+ """
601
+ marker_files = {}
602
+
603
+ self.log.debug("Searching for marker files in '%s' archive..." % tar.name)
604
+
605
+ for member in members:
606
+ if ".wh." in member.name:
607
+ self.log.debug("Found '%s' marker file" % member.name)
608
+ marker_files[member] = tar.extractfile(member)
609
+
610
+ self.log.debug("Done, found %s files" % len(marker_files))
611
+
612
+ return marker_files
613
+
614
+ def _add_markers(self, markers, tar, files_in_layers, added_symlinks):
615
+ """
616
+ This method is responsible for adding back all markers that were not
617
+ added to the squashed layer AND files they refer to can be found in layers
618
+ we do not squash.
619
+ """
620
+
621
+ if markers:
622
+ self.log.debug("Marker files to add: %s" % [o.name for o in markers.keys()])
623
+ else:
624
+ # No marker files to add
625
+ return
626
+
627
+ # https://github.com/goldmann/docker-squash/issues/108
628
+ # Some tar archives do have the filenames prefixed with './'
629
+ # which does not have any effect when we unpack the tar achive,
630
+ # but when processing tar content - we see this.
631
+ tar_files = {self._normalize_path(x) for x in tar.getnames()}
632
+
633
+ for marker, marker_file in markers.items():
634
+ actual_file = marker.name.replace(".wh.", "")
635
+ normalized_file = self._normalize_path(actual_file)
636
+
637
+ should_be_added_back = False
638
+
639
+ if self._file_should_be_skipped(normalized_file, added_symlinks):
640
+ self.log.debug(
641
+ "Skipping '%s' marker file, this file is on a symlink path"
642
+ % normalized_file
643
+ )
644
+ continue
645
+
646
+ if normalized_file in tar_files:
647
+ self.log.debug(
648
+ "Skipping '%s' marker file, this file was added earlier for some reason..."
649
+ % normalized_file
650
+ )
651
+ continue
652
+
653
+ if files_in_layers:
654
+ for files in files_in_layers.values():
655
+ if normalized_file in files:
656
+ should_be_added_back = True
657
+ break
658
+ else:
659
+ # There are no previous layers, so we need to add it back
660
+ # In fact this shouldn't happen since having a marker file
661
+ # where there is no previous layer does not make sense.
662
+ should_be_added_back = True
663
+
664
+ if should_be_added_back:
665
+ self.log.debug("Adding '%s' marker file back..." % marker.name)
666
+ # Marker files on AUFS are hardlinks, we need to create
667
+ # regular files, therefore we need to recreate the tarinfo
668
+ # object
669
+ tar.addfile(tarfile.TarInfo(name=marker.name), marker_file)
670
+ # Add the file name to the list too to avoid re-reading all files
671
+ # in tar archive
672
+ tar_files.add(normalized_file)
673
+ else:
674
+ self.log.debug("Skipping '%s' marker file..." % marker.name)
675
+
676
+ def _normalize_path(self, path: Union[str, pathlib.Path]) -> str:
677
+ return os.path.normpath(os.path.join("/", path))
678
+
679
+ def _add_hardlinks(self, squashed_tar, squashed_files, to_skip, skipped_hard_links):
680
+ for layer, hardlinks_in_layer in enumerate(skipped_hard_links):
681
+ # We need to start from 1, that's why we bump it here
682
+ current_layer = layer + 1
683
+ for member in hardlinks_in_layer.values():
684
+ normalized_name = self._normalize_path(member.name)
685
+ normalized_linkname = self._normalize_path(member.linkname)
686
+
687
+ # Find out if the name is on the list of files to skip - if it is - get the layer number
688
+ # where it was found
689
+ layer_skip_name = self._file_should_be_skipped(normalized_name, to_skip)
690
+ # Do the same for linkname
691
+ layer_skip_linkname = self._file_should_be_skipped(
692
+ normalized_linkname, to_skip
693
+ )
694
+
695
+ # We need to check if we should skip adding back the hard link
696
+ # This can happen in the following situations:
697
+ # 1. hard link is on the list of files to skip
698
+ # 2. hard link target is on the list of files to skip
699
+ # 3. hard link is already in squashed files
700
+ # 4. hard link target is NOT in already squashed files
701
+ if (
702
+ layer_skip_name
703
+ and current_layer > layer_skip_name
704
+ or layer_skip_linkname
705
+ and current_layer > layer_skip_linkname
706
+ or normalized_name in squashed_files
707
+ or normalized_linkname not in squashed_files
708
+ ):
709
+ self.log.debug(
710
+ "Found a hard link '%s' to a file which is marked to be skipped: '%s', skipping link too"
711
+ % (normalized_name, normalized_linkname)
712
+ )
713
+ else:
714
+ if self.debug:
715
+ self.log.debug(
716
+ "Adding hard link '%s' pointing to '%s' back..."
717
+ % (normalized_name, normalized_linkname)
718
+ )
719
+
720
+ squashed_files.add(normalized_name)
721
+ squashed_tar.addfile(member)
722
+
723
+ def _add_file(
724
+ self,
725
+ member: tarfile.TarInfo,
726
+ content: Optional[IO[bytes]],
727
+ squashed_tar: tarfile.TarFile,
728
+ squashed_files: Set[str],
729
+ to_skip,
730
+ ) -> None:
731
+ normalized_name = self._normalize_path(member.name)
732
+
733
+ if normalized_name in squashed_files:
734
+ self.log.debug(
735
+ "Skipping file '%s' because it is already squashed" % normalized_name
736
+ )
737
+ return
738
+
739
+ if self._file_should_be_skipped(normalized_name, to_skip):
740
+ self.log.debug(
741
+ "Skipping '%s' file because it's on the list to skip files"
742
+ % normalized_name
743
+ )
744
+ return
745
+
746
+ if content:
747
+ squashed_tar.addfile(member, content)
748
+ else:
749
+ # Special case: other(?) files, we skip the file
750
+ # itself
751
+ squashed_tar.addfile(member)
752
+
753
+ # We added a file to the squashed tar, so let's note it
754
+ squashed_files.add(normalized_name)
755
+
756
+ def _add_symlinks(self, squashed_tar, squashed_files, to_skip, skipped_sym_links):
757
+ added_symlinks: List[List[str]] = []
758
+ for layer, symlinks_in_layer in enumerate(skipped_sym_links):
759
+ # We need to start from 1, that's why we bump it here
760
+ current_layer = layer + 1
761
+ for member in symlinks_in_layer.values():
762
+ # Handling symlinks. This is similar to hard links with one
763
+ # difference. Sometimes we do want to have broken symlinks
764
+ # be added because these can point to locations
765
+ # that will become available after adding volumes for example.
766
+ normalized_name = self._normalize_path(member.name)
767
+ normalized_linkname = self._normalize_path(member.linkname)
768
+
769
+ # File is already in squashed files, skipping
770
+ if normalized_name in squashed_files:
771
+ self.log.debug(
772
+ "Found a symbolic link '%s' which is already squashed, skipping"
773
+ % (normalized_name)
774
+ )
775
+ continue
776
+
777
+ if self._file_should_be_skipped(normalized_name, added_symlinks):
778
+ self.log.debug(
779
+ "Found a symbolic link '%s' which is on a path to previously squashed symlink, skipping"
780
+ % (normalized_name)
781
+ )
782
+ continue
783
+ # Find out if the name is on the list of files to skip - if it is - get the layer number
784
+ # where it was found
785
+ layer_skip_name = self._file_should_be_skipped(normalized_name, to_skip)
786
+ # Do the same for linkname
787
+ layer_skip_linkname = self._file_should_be_skipped(
788
+ normalized_linkname, to_skip
789
+ )
790
+
791
+ # If name or linkname was found in the lists of files to be
792
+ # skipped or it's not found in the squashed files
793
+ if (
794
+ layer_skip_name
795
+ and current_layer > layer_skip_name
796
+ or layer_skip_linkname
797
+ and current_layer > layer_skip_linkname
798
+ ):
799
+ self.log.debug(
800
+ "Found a symbolic link '%s' to a file which is marked to be skipped: '%s', skipping link too"
801
+ % (normalized_name, normalized_linkname)
802
+ )
803
+ else:
804
+ if self.debug:
805
+ self.log.debug(
806
+ "Adding symbolic link '%s' pointing to '%s' back..."
807
+ % (normalized_name, normalized_linkname)
808
+ )
809
+
810
+ added_symlinks.append([normalized_name])
811
+
812
+ squashed_files.add(normalized_name)
813
+ squashed_tar.addfile(member)
814
+
815
+ return added_symlinks
816
+
817
+ def _squash_layers(
818
+ self, layers_to_squash: List[str], layers_to_move: List[str]
819
+ ) -> None:
820
+ self.log.info(f"Starting squashing for {self.squashed_tar}...")
821
+
822
+ # Reverse the layers to squash - we begin with the newest one
823
+ # to make the tar lighter
824
+ layers_to_squash.reverse()
825
+
826
+ # Find all files in layers that we don't squash
827
+ files_in_layers_to_move = self._files_in_layers(layers_to_move)
828
+
829
+ with tarfile.open(
830
+ self.squashed_tar, "w", format=tarfile.PAX_FORMAT
831
+ ) as squashed_tar:
832
+ to_skip = []
833
+ skipped_markers = {}
834
+ skipped_hard_links = []
835
+ skipped_sym_links = []
836
+ skipped_files = []
837
+ # List of filenames in the squashed archive
838
+ squashed_files: Set[str] = set()
839
+ # Paths added to the squashed archive that are not directories.
840
+ # Anything below such a path in an older layer is stale, because
841
+ # the path was replaced by a non-directory in a newer layer.
842
+ # https://github.com/goldmann/docker-squash/issues/253
843
+ non_directories: Set[str] = set()
844
+ # List of opaque directories in the image
845
+ opaque_dirs: List[str] = []
846
+ reading_layers: List[tarfile.TarFile] = []
847
+
848
+ for layer_id in layers_to_squash:
849
+ layer_tar_file = self._extract_tar_name(layer_id)
850
+ self.log.info("Squashing file '%s'..." % layer_tar_file)
851
+
852
+ # Open the exiting layer to squash
853
+ layer_tar: tarfile.TarFile = tarfile.open(
854
+ layer_tar_file, "r", format=tarfile.PAX_FORMAT
855
+ )
856
+ reading_layers.append(layer_tar)
857
+ # Find all marker files for all layers
858
+ # We need the list of marker files upfront, so we can
859
+ # skip unnecessary files
860
+ members = layer_tar.getmembers()
861
+ markers = self._marker_files(layer_tar, members)
862
+
863
+ skipped_sym_link_files: Dict[str, tarfile.TarInfo] = {}
864
+ skipped_hard_link_files: Dict[str, tarfile.TarInfo] = {}
865
+ skipped_files_in_layer = {}
866
+
867
+ files_to_skip: List[str] = []
868
+ # List of opaque directories found in this layer
869
+ layer_opaque_dirs: List[str] = []
870
+
871
+ # Add it as early as possible, we will be populating
872
+ # 'skipped_sym_link_files' array later
873
+ skipped_sym_links.append(skipped_sym_link_files)
874
+
875
+ # Add it as early as possible, we will be populating
876
+ # 'files_to_skip' array later
877
+ to_skip.append(files_to_skip)
878
+
879
+ # Iterate over marker files found for this particular
880
+ # layer and if a file in the squashed layers file corresponding
881
+ # to the marker file is found, then skip both files
882
+ for marker, marker_file in markers.items():
883
+ # We have a opaque directory marker file
884
+ # https://github.com/opencontainers/image-spec/blob/master/layer.md#opaque-whiteout
885
+ if marker.name.endswith(".wh..wh..opq"):
886
+ opaque_dir = os.path.dirname(marker.name)
887
+
888
+ self.log.debug("Found opaque directory: '%s'" % opaque_dir)
889
+
890
+ layer_opaque_dirs.append(opaque_dir)
891
+ else:
892
+ files_to_skip.append(
893
+ self._normalize_path(marker.name.replace(".wh.", ""))
894
+ )
895
+ skipped_markers[marker] = marker_file
896
+
897
+ # Copy all the files to the new tar
898
+ for member in members:
899
+ normalized_name = self._normalize_path(member.name)
900
+
901
+ if self._is_in_opaque_dir(member, opaque_dirs):
902
+ self.log.debug(
903
+ "Skipping file '%s' because it is in an opaque directory"
904
+ % normalized_name
905
+ )
906
+ continue
907
+
908
+ # Skip all symlinks, we'll investigate them later
909
+ if member.issym():
910
+ skipped_sym_link_files[normalized_name] = member
911
+ continue
912
+
913
+ if member in skipped_markers.keys():
914
+ self.log.debug(
915
+ "Skipping '%s' marker file, at the end of squashing we'll see if it's necessary to add it back"
916
+ % normalized_name
917
+ )
918
+ continue
919
+
920
+ if self._file_should_be_skipped(normalized_name, skipped_sym_links):
921
+ self.log.debug(
922
+ "Skipping '%s' file because it's on a symlink path, at the end of squashing we'll see if it's necessary to add it back"
923
+ % normalized_name
924
+ )
925
+
926
+ if member.isfile():
927
+ f = (member, layer_tar.extractfile(member))
928
+ else:
929
+ f = (member, None)
930
+
931
+ skipped_files_in_layer[normalized_name] = f
932
+ continue
933
+
934
+ # Skip files that are marked to be skipped
935
+ if self._file_should_be_skipped(normalized_name, to_skip):
936
+ self.log.debug(
937
+ "Skipping '%s' file because it's on the list to skip files"
938
+ % normalized_name
939
+ )
940
+ continue
941
+
942
+ # Check if file is already added to the archive
943
+ if normalized_name in squashed_files:
944
+ # File already exist in the squashed archive, skip it because
945
+ # file want to add is older than the one already in the archive.
946
+ # This is true because we do reverse squashing - from
947
+ # newer to older layer
948
+ self.log.debug(
949
+ "Skipping '%s' file because it's older than file already added to the archive"
950
+ % normalized_name
951
+ )
952
+ continue
953
+
954
+ if non_directories and any(
955
+ parent in non_directories
956
+ for parent in self._path_hierarchy(normalized_name)
957
+ ):
958
+ self.log.debug(
959
+ "Skipping '%s' file because its parent was replaced by a non-directory"
960
+ % normalized_name
961
+ )
962
+ continue
963
+
964
+ # Hard links are processed after everything else
965
+ if member.islnk():
966
+ skipped_hard_link_files[normalized_name] = member
967
+ continue
968
+
969
+ content = None
970
+
971
+ if member.isfile():
972
+ content = layer_tar.extractfile(member)
973
+
974
+ self._add_file(
975
+ member, content, squashed_tar, squashed_files, to_skip
976
+ )
977
+ if not member.isdir() and normalized_name in squashed_files:
978
+ non_directories.add(normalized_name)
979
+
980
+ skipped_hard_links.append(skipped_hard_link_files)
981
+ skipped_files.append(skipped_files_in_layer)
982
+ opaque_dirs += layer_opaque_dirs
983
+
984
+ self._add_hardlinks(
985
+ squashed_tar, squashed_files, to_skip, skipped_hard_links
986
+ )
987
+ added_symlinks = self._add_symlinks(
988
+ squashed_tar, squashed_files, to_skip, skipped_sym_links
989
+ )
990
+
991
+ for layer in skipped_files:
992
+ for member, content in layer.values():
993
+ self._add_file(
994
+ member, content, squashed_tar, squashed_files, added_symlinks
995
+ )
996
+
997
+ # Drop markers located below a path replaced by a non-directory
998
+ if non_directories:
999
+ for marker in list(skipped_markers):
1000
+ marked = self._normalize_path(marker.name.replace(".wh.", ""))
1001
+ if any(p in non_directories for p in self._path_hierarchy(marked)):
1002
+ skipped_markers.pop(marker)
1003
+
1004
+ if files_in_layers_to_move:
1005
+ self._reduce(skipped_markers)
1006
+
1007
+ self._add_markers(
1008
+ skipped_markers,
1009
+ squashed_tar,
1010
+ files_in_layers_to_move,
1011
+ added_symlinks,
1012
+ )
1013
+
1014
+ tar: tarfile.TarFile
1015
+ for tar in reading_layers:
1016
+ tar.close()
1017
+ self.log.info("Squashing finished!")
1018
+
1019
+ def _is_in_opaque_dir(self, member: tarfile.TarInfo, dirs: List[str]) -> bool:
1020
+ """
1021
+ If the member we investigate is an opaque directory
1022
+ or if the member is located inside of the opaque directory,
1023
+ we copy these files as-is. Any other layer that has content
1024
+ on the opaque directory will be ignored!
1025
+ """
1026
+
1027
+ for opaque_dir in dirs:
1028
+ if member.name == opaque_dir or member.name.startswith("%s/" % opaque_dir):
1029
+ self.log.debug(
1030
+ "Member '%s' found to be part of opaque directory '%s'"
1031
+ % (member.name, opaque_dir)
1032
+ )
1033
+ return True
1034
+
1035
+ return False
1036
+
1037
+ def _reduce(self, markers):
1038
+ """
1039
+ This function is responsible for reducing marker files
1040
+ that are scheduled to be added at the end of squashing to
1041
+ minimum.
1042
+
1043
+ In some cases, one marker file will overlap
1044
+ with others making others not necessary.
1045
+
1046
+ This is not only about adding less marker files, but
1047
+ if we try to add a marker file for a file or directory
1048
+ deeper in the hierarchy of already marked directory,
1049
+ the image will not be successfully loaded back into Docker
1050
+ daemon.
1051
+
1052
+ Passed dictionary containing markers is altered *in-place*.
1053
+
1054
+ Args:
1055
+ markers (dict): Dictionary of markers scheduled to be added.
1056
+ """
1057
+
1058
+ self.log.debug("Reducing marker files to be added back...")
1059
+
1060
+ # Prepare a list of files (or directories) based on the marker
1061
+ # files scheduled to be added
1062
+ marked_files = list(
1063
+ map(
1064
+ lambda x: self._normalize_path(x.name.replace(".wh.", "")),
1065
+ markers.keys(),
1066
+ )
1067
+ )
1068
+
1069
+ # List of markers that should be not added back to tar file
1070
+ to_remove = []
1071
+
1072
+ for marker in markers.keys():
1073
+ self.log.debug("Investigating '{}' marker file".format(marker.name))
1074
+
1075
+ path = self._normalize_path(marker.name.replace(".wh.", ""))
1076
+ # Iterate over the path hierarchy, but starting with the
1077
+ # root directory. This will make it possible to remove
1078
+ # marker files based on the highest possible directory level
1079
+ for directory in self._path_hierarchy(path):
1080
+ if directory in marked_files:
1081
+ self.log.debug(
1082
+ "Marker file '{}' is superseded by higher-level marker file: '{}'".format(
1083
+ marker.name, directory
1084
+ )
1085
+ )
1086
+ to_remove.append(marker)
1087
+ break
1088
+
1089
+ self.log.debug("Removing {} marker files".format(len(to_remove)))
1090
+
1091
+ if to_remove:
1092
+ for marker in to_remove:
1093
+ self.log.debug("Removing '{}' marker file".format(marker.name))
1094
+ markers.pop(marker)
1095
+
1096
+ self.log.debug("Marker files reduced")
1097
+
1098
+ def _path_hierarchy(self, path):
1099
+ """
1100
+ Creates a full hierarchy of directories for a given path.
1101
+
1102
+ For a particular path, a list will be returned
1103
+ containing paths from the root directory, through all
1104
+ levels up to the path specified.
1105
+
1106
+ Example:
1107
+ Path '/opt/testing/some/dir/structure/file'
1108
+
1109
+ will return:
1110
+
1111
+ ['/', '/opt', '/opt/testing', '/opt/testing/some', '/opt/testing/some/dir', '/opt/testing/some/dir/structure']
1112
+ """
1113
+ if not path:
1114
+ raise SquashError("No path provided to create the hierarchy for")
1115
+
1116
+ if not isinstance(path, pathlib.PurePath):
1117
+ path = pathlib.PurePath(path)
1118
+
1119
+ if len(path.parts) == 1:
1120
+ return path.parts
1121
+
1122
+ return itertools.accumulate(
1123
+ path.parts[:-1], func=lambda head, tail: str(path.__class__(head, tail))
1124
+ )
1125
+
1126
+ def _extract_tar_name(self, path: str) -> str:
1127
+ if self.oci_format:
1128
+ return os.path.join(self.old_image_dir, path)
1129
+ else:
1130
+ return os.path.join(self.old_image_dir, path, "layer.tar")