From 6644228fcc0fc3f12310625c6946f70d556e621c Mon Sep 17 00:00:00 2001 From: Andrew Olsen Date: Fri, 1 Dec 2023 15:36:17 +1300 Subject: [PATCH] Adds support for basic s3 url redirects Adding url redirect rules to the linked-storage.json meta item causes those redirect rules to be honoured when fetching tiles for any commit that includes the new linked-storage.json. However, this won't help when fetching tiles for older commits which were created before the redirect rules were added. The fix will be to add logic such that Kart finds redirect rules from a newer commit even when fetching tiles when checking out an older commit. TODO: add logic such that Kart finds redirect rules from newer commits. TODO: add a command for setting up redirect rules - right now there is just `kart meta set` which is pretty bare bones. --- kart/lfs_commands/__init__.py | 45 ++++++++--- kart/lfs_commands/url_redirector.py | 86 +++++++++++++++++++++ kart/workdir.py | 26 ++++--- tests/data/linked-dataset.tgz | Bin 0 -> 23292 bytes tests/linked_storage/test_url_redirects.py | 66 ++++++++++++++++ 5 files changed, 201 insertions(+), 22 deletions(-) create mode 100644 kart/lfs_commands/url_redirector.py create mode 100644 tests/data/linked-dataset.tgz create mode 100644 tests/linked_storage/test_url_redirects.py diff --git a/kart/lfs_commands/__init__.py b/kart/lfs_commands/__init__.py index 4f19f7f54..eda2e0e47 100644 --- a/kart/lfs_commands/__init__.py +++ b/kart/lfs_commands/__init__.py @@ -15,6 +15,7 @@ get_hash_from_pointer_file, get_local_path_from_lfs_oid, ) +from kart.lfs_commands.url_redirector import UrlRedirector from kart.object_builder import ObjectBuilder from kart.rev_list_objects import rev_list_tile_pointer_files from kart.repo import KartRepoState @@ -276,29 +277,32 @@ def fetch_lfs_blobs_for_commits( repo.spatial_filter if do_spatial_filter else SpatialFilter.MATCH_ALL ) - pointer_file_oids = set() + dataset_to_pointer_file_oids = {} for commit in commits: for dataset in repo.datasets( commit, filter_dataset_type=ALL_TILE_DATASET_TYPES ): - pointer_file_oids.update( - blob.hex - for blob in dataset.tile_pointer_blobs(spatial_filter=spatial_filter) + pointer_file_oids = dataset_to_pointer_file_oids.setdefault( + dataset.path, set() ) + for blob in dataset.tile_pointer_blobs(spatial_filter=spatial_filter): + pointer_file_oids.add(blob.hex) fetch_lfs_blobs_for_pointer_files( - repo, pointer_file_oids, dry_run=dry_run, quiet=quiet + repo, dataset_to_pointer_file_oids, dry_run=dry_run, quiet=quiet ) def fetch_lfs_blobs_for_pointer_files( - repo, pointer_files, *, remote_name=None, dry_run=False, quiet=False + repo, dataset_to_pointer_file_oids, *, remote_name=None, dry_run=False, quiet=False ): """ - Given a list of pointer files (or OIDs of pointer files themselves - not the OIDs they point to) - fetch all the tiles that those pointer files point to that are not already present in the local cache. + Given a dict in the format: {dataset-path: set(pointer-file-oid-1, pointer-file-oid-2, ...)} + Where dataset-path is the path to a dataset, and each pointer-file-oid is the OID of the pointer file itself + (not the LFS oid that the pointer file points to) that is present in that dataset: + Fetches all the tiles that those pointer files point to that are not already present in the local cache. """ - if not pointer_files: + if not dataset_to_pointer_file_oids: return if not remote_name: @@ -312,7 +316,12 @@ def fetch_lfs_blobs_for_pointer_files( urls_sizes = {} non_urls_sizes = {} - for pointer_file in pointer_files: + pointer_files_to_datasets = _invert_pointer_file_oid_dict( + dataset_to_pointer_file_oids + ) + url_redirector = UrlRedirector(repo) + + for pointer_file, datasets in pointer_files_to_datasets.items(): if isinstance(pointer_file, str): pointer_blob = repo[pointer_file] elif getattr(pointer_file, "type", None) == pygit2.GIT_OBJ_BLOB: @@ -322,6 +331,8 @@ def fetch_lfs_blobs_for_pointer_files( pointer_dict = pointer_file_bytes_to_dict(pointer_blob) url = pointer_dict.get("url") + url = url_redirector.apply_redirect(url, datasets) + lfs_oid = get_hash_from_pointer_file(pointer_dict) pointer_file_oid = pointer_blob.hex lfs_path = get_local_path_from_lfs_oid(repo, lfs_oid) @@ -369,6 +380,20 @@ def fetch_lfs_blobs_for_pointer_files( _do_fetch_from_remote(repo, non_urls, remote_name, quiet=quiet) +def _invert_pointer_file_oid_dict(dataset_to_pointer_file_oids): + result = {} + for dataset, pointer_file_oids in dataset_to_pointer_file_oids.items(): + assert isinstance(dataset, str) + for pointer_file_oid in pointer_file_oids: + existing = result.setdefault(pointer_file_oid, dataset) + if dataset != existing: + if isinstance(existing, str): + result[pointer_file_oid] = {existing, dataset} + elif isinstance(existing, set): + existing.add(dataset) + return result + + def _do_fetch_from_urls(repo, urls_and_lfs_oids, quiet=False): non_s3_url = next( (url for (url, lfs_oid) in urls_and_lfs_oids if not url.startswith("s3://")), diff --git a/kart/lfs_commands/url_redirector.py b/kart/lfs_commands/url_redirector.py new file mode 100644 index 000000000..101193915 --- /dev/null +++ b/kart/lfs_commands/url_redirector.py @@ -0,0 +1,86 @@ +from collections.abc import Iterable + +from kart.tile import ALL_TILE_DATASET_TYPES + + +class UrlRedirector: + """ + Loads a set of redirect rules that apply to linked-datasets from a given commit. + + Suppose, for example, a user migrates all their data from one S3 region to another, for whatever reason. + And suppose the bucket in the new region has a new name, since bucket names are globally unique. + (It may be possible to migrate the bucket name, but for the purpose of this example, the new bucket has a new name). + That will break a linked-dataset where the URLs embedded in each tile point to the original bucket. + + The workaround: each linked-dataset has a meta-item called "linked-storage.json", which may contain a mapping + called "urlRedirects". If these redirect rules are updated appropriately, then URLs that point to the old bucket + will be treated as if they point to the new bucket, without needing to update the URL in every single tile + individually and retroactively. + + Here is an example urlRedirects mapping that contains 3 rules: + { + "s3://old/and/broken/": "s3://new/and/shiny/", + "s3://old/path/to/tile.laz": "s3://new/path/to/same/tile.laz", + "s3://old/", "s3://new/" + } + + This would be applied to an URL as follows - each rule is attempted in turn. + If a rule applies, the url is updated, and subsequent rules are attempted against the updated url. + Eventually the url - which may have been updated by zero, one, or many rules - is returned. + + - The first rule ends with a '/' so it does prefix matching: + If the url starts with "s3://old/and/broken/", this prefix will be replaced with "s3://new/and/shiny/" + - The second rule does not end with a '/' so it does exact matching: + If the url is now exactly "s3://old/path/to/tile.laz", it will be set to" s3://new/path/to/same/tile.laz" + - The third rule ends with a '/' so it does prefix matching: + If the url now starts with "s3://old/", this prefix will be replaced with "s3://new/" + + Currently url redirect rules are only loaded from the HEAD commit - this is subject to change. + """ + + def __init__(self, repo, commit=None): + # TODO - improve redirect-commit finding logic - probably do some of the following: + # - find the tip of the default branch + # - find the local tip of the branch that the remote HEAD was pointing to when we last fetched + # - find a branch specified somehow in the config as the url-redirect branch + + self.commit = commit if commit is not None else repo.head_commit + + self.dataset_to_redirects = {} + + if not self.commit: + return + + for dataset in repo.datasets( + self.commit, filter_dataset_type=ALL_TILE_DATASET_TYPES + ): + linked_storage = dataset.get_meta_item("linked-storage.json") + redirects = linked_storage.get("urlRedirects") if linked_storage else None + if redirects: + self.dataset_to_redirects[dataset.path] = redirects + + def apply_redirect(self, url, dataset): + # It could be the case that a single LFS object is in more than one dataset. + # In this case, we just try to find any set of redirect rules that applies to the object. + if isinstance(dataset, Iterable) and not isinstance(dataset, str): + for d in dataset: + result = self.apply_redirect(url, d) + if result != url: + return result + return url + + if not isinstance(dataset, str): + dataset = dataset.path + redirects = self.dataset_to_redirects.get(dataset) + if not redirects: + return url + + for from_, to_ in redirects.items(): + if from_.endswith("/"): + if url.startswith(from_): + url = to_ + url[len(from_) :] + else: + if url == from_: + url = to_ + + return url diff --git a/kart/workdir.py b/kart/workdir.py index 6dc8570f3..6edb75de8 100644 --- a/kart/workdir.py +++ b/kart/workdir.py @@ -303,19 +303,20 @@ def _do_reset_datasets( track_changes_as_dirty=False, quiet=False, ): - pointer_files_to_fetch = set() + dataset_to_pointer_oids_to_fetch = {} workdir_diff_cache = self.workdir_diff_cache() update_diffs = {} # First pass - make sure the LFS blobs are present in the local LFS cache: # - For the datasets that will be inserted (written from scratch): for ds_path in ds_inserts: - pointer_files_to_fetch.update( - blob.hex - for blob in target_datasets[ds_path].tile_pointer_blobs( - self.repo.spatial_filter - ) + pointer_file_oids = dataset_to_pointer_oids_to_fetch.setdefault( + ds_path, set() ) + for blob in target_datasets[ds_path].tile_pointer_blobs( + self.repo.spatial_filter + ): + pointer_file_oids.add(blob.hex) # - For the datasets that will be updated: for ds_path in ds_updates: @@ -326,17 +327,18 @@ def _do_reset_datasets( workdir_diff_cache, repo_key_filter[ds_path], ) - pointer_files_to_fetch.update( - blob.hex - for blob in self._list_new_pointer_blobs_for_diff( - update_diffs[ds_path], target_datasets[ds_path] - ) + pointer_file_oids = dataset_to_pointer_oids_to_fetch.setdefault( + ds_path, set() ) + for blob in self._list_new_pointer_blobs_for_diff( + update_diffs[ds_path], target_datasets[ds_path] + ): + pointer_file_oids.add(blob.hex) # We fetch the LFS tiles immediately before writing them to the working copy - # unlike ODB objects that are already fetched. fetch_lfs_blobs_for_pointer_files( - self.repo, pointer_files_to_fetch, quiet=quiet + self.repo, dataset_to_pointer_oids_to_fetch, quiet=quiet ) # Second pass - actually update the working copy: diff --git a/tests/data/linked-dataset.tgz b/tests/data/linked-dataset.tgz new file mode 100644 index 0000000000000000000000000000000000000000..32da6a0a0efc077eab32806b782cc1b788a0b68b GIT binary patch literal 23292 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 N{{W|!(z5{I0RR{=1}*>q literal 0 HcmV?d00001 diff --git a/tests/linked_storage/test_url_redirects.py b/tests/linked_storage/test_url_redirects.py new file mode 100644 index 000000000..faddb81a7 --- /dev/null +++ b/tests/linked_storage/test_url_redirects.py @@ -0,0 +1,66 @@ +import json + +from kart.repo import KartRepo + + +def test_s3_url_redirects( + data_archive, + cli_runner, + s3_test_data_point_cloud, + check_lfs_hashes, + check_tile_is_reflinked, +): + with data_archive("linked-dataset") as repo_path: + r = cli_runner.invoke(["lfs+", "fetch", "HEAD", "--dry-run"]) + assert r.exit_code == 0, r.stderr + assert r.stdout.splitlines()[:8] == [ + "Running fetch with --dry-run:", + " Found 16 LFS blobs (373KiB) to fetch from specific URLs", + "", + "LFS blob OID: (Pointer file OID):", + "03e3d4dc6fc8e75c65ffdb39b630ffe26e4b95982b9765c919e34fb940e66fc0 (8d2362d8f14ea34aaebdede6602dcca0bcdd8297) → s3://example-bucket/example-path/auckland_3_2.laz", + "06bd15fbb6616cf63a4a410c5ba4666dab76177a58cb99c3fa2afb46c9dd6379 (f129df999b5aea453ace9d4fcd1496dcebf97fe1) → s3://example-bucket/example-path/auckland_1_3.laz", + "09701813661e369395d088a9a44f1201200155e652a8b6e291e71904f45e32a6 (553775bcbaa9c067e8ad611270d53d4f37ac37da) → s3://example-bucket/example-path/auckland_3_0.laz", + "111579edfe022ebfd3388cc47d911c16c72c7ebd84c32a7a0c1dab6ed9ec896a (76cff04b9c7ffb01bb99ac42a6e94612fdea605f) → s3://example-bucket/example-path/auckland_0_2.laz", + ] + + s3_test_data_point_cloud_prefix = s3_test_data_point_cloud.split("*")[0] + + linked_storage_json = { + "urlRedirects": { + "s3://example-bucket/example-path/": s3_test_data_point_cloud_prefix + } + } + r = cli_runner.invoke( + [ + "meta", + "set", + "auckland", + f"linked-storage.json={json.dumps(linked_storage_json)}", + ] + ) + assert r.exit_code == 0, r.stderr + + r = cli_runner.invoke(["lfs+", "fetch", "HEAD", "--dry-run"]) + assert r.exit_code == 0, r.stderr + assert r.stdout.splitlines()[:8] == [ + "Running fetch with --dry-run:", + " Found 16 LFS blobs (373KiB) to fetch from specific URLs", + "", + "LFS blob OID: (Pointer file OID):", + f"03e3d4dc6fc8e75c65ffdb39b630ffe26e4b95982b9765c919e34fb940e66fc0 (8d2362d8f14ea34aaebdede6602dcca0bcdd8297) → {s3_test_data_point_cloud_prefix}auckland_3_2.laz", + f"06bd15fbb6616cf63a4a410c5ba4666dab76177a58cb99c3fa2afb46c9dd6379 (f129df999b5aea453ace9d4fcd1496dcebf97fe1) → {s3_test_data_point_cloud_prefix}auckland_1_3.laz", + f"09701813661e369395d088a9a44f1201200155e652a8b6e291e71904f45e32a6 (553775bcbaa9c067e8ad611270d53d4f37ac37da) → {s3_test_data_point_cloud_prefix}auckland_3_0.laz", + f"111579edfe022ebfd3388cc47d911c16c72c7ebd84c32a7a0c1dab6ed9ec896a (76cff04b9c7ffb01bb99ac42a6e94612fdea605f) → {s3_test_data_point_cloud_prefix}auckland_0_2.laz", + ] + + r = cli_runner.invoke(["checkout", "--dataset=auckland"]) + assert r.exit_code == 0, r.stderr + + repo = KartRepo(repo_path) + check_lfs_hashes(repo, expected_file_count=16) + for x in range(4): + for y in range(4): + check_tile_is_reflinked( + repo_path / "auckland" / f"auckland_{x}_{y}.laz", repo + )