|
2 | 2 | import logging |
3 | 3 | import os |
4 | 4 | import os.path |
| 5 | +import shutil |
5 | 6 | import tarfile |
6 | 7 | from gettext import gettext as _ |
7 | 8 | from glob import glob |
8 | 9 | from pathlib import Path |
| 10 | +from urllib.parse import unquote, urlparse |
9 | 11 |
|
10 | 12 | from django.conf import settings |
11 | 13 |
|
|
27 | 29 | RepositoryVersion, |
28 | 30 | Task, |
29 | 31 | ) |
30 | | -from pulpcore.app.models.content import ContentArtifact |
| 32 | +from pulpcore.app.models.content import ContentArtifact, RemoteArtifact |
31 | 33 | from pulpcore.app.serializers import PulpExportSerializer |
32 | 34 | from pulpcore.app.util import Crc32Hasher, HashingFileWriter, compute_file_hash |
33 | 35 | from pulpcore.constants import FS_EXPORT_METHODS |
@@ -65,9 +67,92 @@ def _validate_fs_export(content_artifacts): |
65 | 67 | Raises: |
66 | 68 | RuntimeError: If Artifacts are not downloaded or when trying to link non-fs files |
67 | 69 | """ |
68 | | - if content_artifacts.filter(artifact=None).exists(): |
| 70 | + missing_ca_qs = content_artifacts.filter(artifact=None) |
| 71 | + if not missing_ca_qs.exists(): |
| 72 | + return |
| 73 | + |
| 74 | + # Allow streamed content whose remote URL is a local file:// path (e.g. an rsync'd |
| 75 | + # local mirror). Those artifacts can be exported directly even though they were |
| 76 | + # never downloaded into Pulp storage. |
| 77 | + missing_pks = set(missing_ca_qs.values_list("pk", flat=True)) |
| 78 | + |
| 79 | + # Reject if any missing artifact has a non-file:// remote (truly needs downloading). |
| 80 | + if ( |
| 81 | + RemoteArtifact.objects.filter(content_artifact__pk__in=missing_pks) |
| 82 | + .exclude(url__startswith="file://") |
| 83 | + .exists() |
| 84 | + ): |
69 | 85 | raise UnexportableArtifactException() |
70 | 86 |
|
| 87 | + # Reject if any missing artifact has no remote at all (nothing to fall back to). |
| 88 | + ca_pks_with_remote = set( |
| 89 | + RemoteArtifact.objects.filter(content_artifact__pk__in=missing_pks).values_list( |
| 90 | + "content_artifact_id", flat=True |
| 91 | + ) |
| 92 | + ) |
| 93 | + if missing_pks - ca_pks_with_remote: |
| 94 | + raise UnexportableArtifactException() |
| 95 | + |
| 96 | + |
| 97 | +def _local_path_from_file_url(url): |
| 98 | + """Convert a file:// URL to an absolute local filesystem path.""" |
| 99 | + parsed = urlparse(url) |
| 100 | + return os.path.abspath(os.path.join(parsed.netloc, unquote(parsed.path))) |
| 101 | + |
| 102 | + |
| 103 | +def _file_url_local_paths(content_artifacts): |
| 104 | + """Map ContentArtifact pk -> local path for missing artifacts backed by file:// remotes. |
| 105 | +
|
| 106 | + Returns an empty dict when there is no such content. Lets streamed content whose |
| 107 | + bytes live on the local filesystem be exported without a downloaded Artifact. |
| 108 | + """ |
| 109 | + missing_ca_pks = set(content_artifacts.filter(artifact=None).values_list("pk", flat=True)) |
| 110 | + if not missing_ca_pks: |
| 111 | + return {} |
| 112 | + |
| 113 | + ca_pk_to_local_path = {} |
| 114 | + for ra in RemoteArtifact.objects.filter( |
| 115 | + content_artifact__pk__in=missing_ca_pks, |
| 116 | + url__startswith="file://", |
| 117 | + ).values("content_artifact_id", "url"): |
| 118 | + ca_pk_to_local_path[ra["content_artifact_id"]] = _local_path_from_file_url(ra["url"]) |
| 119 | + return ca_pk_to_local_path |
| 120 | + |
| 121 | + |
| 122 | +def _export_local_to_file_system( |
| 123 | + path, relative_paths_to_local_paths, method=FS_EXPORT_METHODS.WRITE |
| 124 | +): |
| 125 | + """ |
| 126 | + Export artifacts backed by file:// remotes directly from their local path. |
| 127 | +
|
| 128 | + Used for streamed content that has no downloaded Artifact but whose bytes are |
| 129 | + available locally. The base export directory must already exist (created by |
| 130 | + _export_to_file_system). |
| 131 | +
|
| 132 | + Args: |
| 133 | + path (str): The already-created export root directory. |
| 134 | + relative_paths_to_local_paths: A dict with {relative_path: local_fs_path} mapping. |
| 135 | + method: FS_EXPORT_METHODS constant (WRITE, SYMLINK, or HARDLINK). |
| 136 | + """ |
| 137 | + for relative_path, src_path in relative_paths_to_local_paths.items(): |
| 138 | + dest = os.path.join(path, relative_path) |
| 139 | + os.makedirs(os.path.split(dest)[0], exist_ok=True) |
| 140 | + |
| 141 | + if method == FS_EXPORT_METHODS.SYMLINK: |
| 142 | + os.path.lexists(dest) and os.unlink(dest) |
| 143 | + os.symlink(src_path, dest) |
| 144 | + elif method == FS_EXPORT_METHODS.HARDLINK: |
| 145 | + os.path.lexists(dest) and os.unlink(dest) |
| 146 | + try: |
| 147 | + os.link(src_path, dest) |
| 148 | + except OSError: |
| 149 | + log.info(_("Hard link cannot be created, file will be copied.")) |
| 150 | + shutil.copy2(src_path, dest) |
| 151 | + elif method == FS_EXPORT_METHODS.WRITE: |
| 152 | + shutil.copy2(src_path, dest) |
| 153 | + else: |
| 154 | + raise RuntimeError(_("Unsupported export method '{}'.").format(method)) |
| 155 | + |
71 | 156 |
|
72 | 157 | def _export_to_file_system(path, relative_paths_to_artifacts, method=FS_EXPORT_METHODS.WRITE): |
73 | 158 | """ |
@@ -151,29 +236,42 @@ def _export_publication_to_file_system( |
151 | 236 | ) |
152 | 237 | ) |
153 | 238 |
|
| 239 | + # Map ContentArtifact pk -> local path for any streamed file:// content, so it can |
| 240 | + # be exported directly even though it has no downloaded Artifact. |
| 241 | + ca_pk_to_local_path = _file_url_local_paths(content_artifacts) |
| 242 | + |
154 | 243 | relative_path_to_artifacts = {} |
| 244 | + relative_path_to_local_paths = {} |
155 | 245 | if publication.pass_through: |
156 | | - relative_path_to_artifacts = { |
157 | | - ca.relative_path: ca.artifact |
158 | | - for ca in content_artifacts.select_related("artifact").iterator() |
159 | | - if (start_repo_version is None) or (ca.pk in difference_content_artifacts) |
160 | | - } |
| 246 | + for ca in content_artifacts.select_related("artifact").iterator(): |
| 247 | + if (start_repo_version is None) or (ca.pk in difference_content_artifacts): |
| 248 | + if ca.artifact: |
| 249 | + relative_path_to_artifacts[ca.relative_path] = ca.artifact |
| 250 | + elif ca.pk in ca_pk_to_local_path: |
| 251 | + relative_path_to_local_paths[ca.relative_path] = ca_pk_to_local_path[ca.pk] |
161 | 252 |
|
162 | 253 | publication_metadata_paths = set( |
163 | 254 | publication.published_metadata.values_list("relative_path", flat=True) |
164 | 255 | ) |
165 | 256 | for pa in publication.published_artifact.select_related( |
166 | 257 | "content_artifact", "content_artifact__artifact" |
167 | 258 | ).iterator(): |
168 | | - # Artifact isn't guaranteed to be present |
169 | | - if pa.content_artifact.artifact and ( |
| 259 | + if ( |
170 | 260 | start_repo_version is None |
171 | 261 | or pa.relative_path in publication_metadata_paths |
172 | 262 | or pa.content_artifact.pk in difference_content_artifacts |
173 | 263 | ): |
174 | | - relative_path_to_artifacts[pa.relative_path] = pa.content_artifact.artifact |
| 264 | + # Artifact isn't guaranteed to be present |
| 265 | + if pa.content_artifact.artifact: |
| 266 | + relative_path_to_artifacts[pa.relative_path] = pa.content_artifact.artifact |
| 267 | + elif pa.content_artifact.pk in ca_pk_to_local_path: |
| 268 | + relative_path_to_local_paths[pa.relative_path] = ca_pk_to_local_path[ |
| 269 | + pa.content_artifact.pk |
| 270 | + ] |
175 | 271 |
|
176 | 272 | _export_to_file_system(path, relative_path_to_artifacts, method) |
| 273 | + if relative_path_to_local_paths: |
| 274 | + _export_local_to_file_system(path, relative_path_to_local_paths, method) |
177 | 275 |
|
178 | 276 |
|
179 | 277 | def _export_location_is_clean(path): |
@@ -286,12 +384,22 @@ def fs_repo_version_export(exporter_pk, repo_version_pk, start_repo_version_pk=N |
286 | 384 | ) |
287 | 385 | ) |
288 | 386 |
|
| 387 | + # Map ContentArtifact pk -> local path for any streamed file:// content, so it can |
| 388 | + # be exported directly even though it has no downloaded Artifact. |
| 389 | + ca_pk_to_local_path = _file_url_local_paths(content_artifacts) |
| 390 | + |
289 | 391 | relative_path_to_artifacts = {} |
| 392 | + relative_path_to_local_paths = {} |
290 | 393 | for ca in content_artifacts.select_related("artifact").iterator(): |
291 | 394 | if start_repo_version is None or ca.pk in difference_content_artifacts: |
292 | | - relative_path_to_artifacts[ca.relative_path] = ca.artifact |
| 395 | + if ca.artifact: |
| 396 | + relative_path_to_artifacts[ca.relative_path] = ca.artifact |
| 397 | + elif ca.pk in ca_pk_to_local_path: |
| 398 | + relative_path_to_local_paths[ca.relative_path] = ca_pk_to_local_path[ca.pk] |
293 | 399 |
|
294 | 400 | _export_to_file_system(exporter.path, relative_path_to_artifacts, exporter.method) |
| 401 | + if relative_path_to_local_paths: |
| 402 | + _export_local_to_file_system(exporter.path, relative_path_to_local_paths, exporter.method) |
295 | 403 |
|
296 | 404 |
|
297 | 405 | def _get_versions_to_export(the_exporter, the_export): |
|
0 commit comments