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/__init__.py +1 -0
- docker_squash/cli.py +163 -0
- docker_squash/errors.py +10 -0
- docker_squash/image.py +1130 -0
- docker_squash/lib/__init__.py +0 -0
- docker_squash/lib/common.py +81 -0
- docker_squash/squash.py +170 -0
- docker_squash/v1_image.py +87 -0
- docker_squash/v2_image.py +425 -0
- docker_squash/version.py +1 -0
- docker_squash-1.3.0.dist-info/METADATA +242 -0
- docker_squash-1.3.0.dist-info/RECORD +16 -0
- docker_squash-1.3.0.dist-info/WHEEL +5 -0
- docker_squash-1.3.0.dist-info/entry_points.txt +2 -0
- docker_squash-1.3.0.dist-info/licenses/LICENSE +21 -0
- docker_squash-1.3.0.dist-info/top_level.txt +1 -0
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")
|