# Pulp3 Import/Export: Handling entities without a 'natural' key ## Executive Summary Pulp Import/Export cannot rely on **`pulp_id`** to differentiate objects at import-time, due to `pulp_id` being 'owned' by the downstream instance, not the upstream. We propose adding a field to Content for the use of Pulp Import/Export, **`export_id`**, to give us a way to be able to use an upstream-pulp-id to identify entities created downstream, in order to be able to rebuild relationships. ## Introduction The PulpImport/Export (PIE) feature is aimed at allowing the export of repository content and artifacts from one Pulp instance ("upstream") in a way that allows them to be transported to another Pulp instance ("downstream"), such that "downstream" ends up with a Repository/Repositories with RepositoryVersions that contain the same Content as the exported "upstream" had. PIE accomplishes this in two ways. First, PIE finds all the Artifacts from (all the) Repositories being exported, and stores them on disk. By operating across all Repositories, PIE can deduplicate Artifacts that are shared between Repositories, greatly reducing the amount of data to transfer. Second, PIE exports the postgres data in a format that allows it to be recreated on the "downstream". To accomplish this, it relies on the django-import-export (DIE) plugin, for formatting of the export-files, and recreating the appropriate Django models at import. This document describes the low-level implementation design of taking advantage of DIE, and the challenges PIE faces in the real world. ## Overview of django-import-export (DIE) workflow DIE works by exporting ModelResource objects, as JSON files. A ModelResource subclass identifies a specific Django Model, with the ability to * describe exactly how to find the specific Models to export (a Django query) * detail about exactly which fields are (and are not) to be exported * the ability to describe how entities are uniquely identified and linked to each other at import-time At export-time, a given ModelResource's query is invoked, the results filtered by allowed/excluded fields, foreign keys replaced where necessary, and a JSON file created. At import-time, this JSON is read, and the reverse occurs. For each 'row' in the import for a given Model class, DIE first attempts to look up an already-existing instance of that Model. If there is one, it evaluates whether the incoming 'row' of data would make any changes - and if not, it skips the import-row. If no such Model exists, a new instance is created and filled with the data from the corresponding row. Links to other Models are created by searching based on the specified unique-key. Once a ModelResource file has been imported, the import-process can query it for things like what content was created or skipped, errors encountered, etc. As long as Models-pointed-to are imported before Models-doing-the-pointing, DIE figures out and rebuilds the foreign-key dependency graph. At the end of the import for any ModelResource class, new Models exist and are saved to the database, ready to be pointed-to by the next ModelResource class being imported. The important point to remember here is that import happens **one ModelResource class at a time** - so information needed by ModelClass-A, about ModelClass-B, has to be **persisted** before ModelClass-A starts being imported. (Keep this in mind for later) ## Complicating Factors If you are making an **exact duplicate** in the downstream, of the upstream, the DIE process is seamless. If you export all your fields and recreate them all on import, there is no special processing needed, and you end up with exact duplicates of everything you exported. This is not, alas, the reality in the PIE world. The "downstream" Pulp instance is just "another" Pulp3 instance, that is not allowed to have network access. It is **not** a copy-of, nor is it controlled-by, the "upstream". Specific "downstream" behaviors that make PIE harder in the Repository/Version/Content cases include (but are certainly not limited to!): * "downstream" once upon a time did its own sync of content it now wants to import, and so has its own UUIDs for much of the incoming content * "downstream" does not use the same Repository names as upstream, so content has to be mapped to different Repositories * "downstream" doesn't have the same RepositoryVersions as "upstream" * "downstream" added its own content "by hand" at some point * "downstream" used to be Pulp2 and was migrated to Pulp3, recreating all of its UUIDs The net result of all these use-cases is that at import-time, PIE **cannot** rely on **pulp-instance-specific** identifiers. Very specifically, this means that a `pulp_id` from "upstream" **cannot** be allowed to overwrite a `pulp_id` that belongs-to "downstream". Since model-to-model relationships in Pulp3 rely on `pulp_id` - how can this possibly work? Fortunately, DIE has some answers. ## How non-instance-specific relationships work in DIE DIE allows one to take advantage of, at the ModelResource (remember those?) level, a number of tools, including these two: * `import_id_fields=` * `ForeignKeyWidget` import_id_fields allows the ModelResource writer to specify a set of "natural keys" that can be used to identify a specific Model instance. By default, the `import_id_fields` consist of that Model's PRIMARY KEY (in our case, `pulp_id`). However, you can specify any set of fields that will uniquely identify a specific instance of the Model in question, so that it can be determined at import-time whether it exists already or not. `ForeignKeyWidget` allows the ModelResource author to replace an existing relation (that relies on, say, `pulp_id`) with a relation to a specific field in the pointed-to entity that uniquely identifies it. For example, if I have a Model that has a FK reference to Artifact, based on `pulp_id`, I could recreate that relationship at import-time using a `ForeignKeyWidget` that looks up an Artifact by its sha256-checksum (which is a guranteed-unique field on the Artifact Model). That would look something like `ForeignKeyWidget(Artifact, "sha256")`. One might recall that Pulp3's Content base-model includes a `natural_key_fields()` method, that all Content shares, and think "Ah! We'll use `natural_key_fields()` as our `import_id_fields=`, and pass that to `ForeignKeyField` widget so import can find it, and All Will Be Well!" And then, after a week or so of frustration, one would discover that while `import_id_fields` is a set, ForeignKeyWidget can rely on **exactly one field** in the Model being pointed-to. So Models with multi-column 'natural keys', can't take advantage of this approach. Worse, some Models have no 'natural key' other than `pulp_id`. So - here is the reef of conflicting issues that PIE foundered on for quite some time: 1. You can identify an entity with multiple keys at import, **but** 1. You can only link things in DIE with one field, **and** 1. PIE **cannot** allow "upstream" `pulp_ids` to overwrite "downstream" `pulp_ids`, and **finally** 1. Some set of Pulp3 entities have **only** `pulp_id` to rely on as a unique identifier. What to do, what to do. ## PROPOSAL: how we propose to provide keys for entities with no uniqueness other than pulp-id The net of the above, is that PIE needs `pulp_id` from "upstream" in order to be able to uniquely identify incoming ModelResources, and relink them - but it's not allowed to overwrite the `pulp_id` field in "downstream" models. What we are proposing to solve this conundrum involves exporting the value-of entities' `pulp_ids`, and relying on them at import-time - **BUT NOT AS THE PULP_ID FIELD**, which PIE does not and cannot 'own'. There are two features of DIE that we are going to use, to help us on our journey. * `dehydrate_<field-name>()` hook In DIE, if your ModelResource defines a method `dehydrate_<field-name>` for some field on your Model, it will be called and its return-value used to populate `<field-name>` instead of the contents of that field in the Model instance. * `before_import_row()` hook At import-time, before any entity is looked for/created, the `before_import_row()` hook for the ModelResource in question is called, with the incoming row-data. This gives one the opportunity, at import-time, to manipulate/take advantage of the incoming row data. With these two pieces of functionality, we can a) create fields that "don't exist/aren't filled in" at export, and b) rely on such fields to reconstitute entities on the import-side before Models are created. ### Content and its children Now, consider Content in the Pulp3 world. In the MasterDetail world, Pulp3 content consists of Detail classes, all of which subclass from Content. Anything available in a Content object, is visible to its child-classes. And 'naked' Content isn't a thing - there is always a Detail, specific to the plugin/repository-type involved in holding that content. Detail models have a `natural_key_fields()` method that 'makes sense' - you can find a particular instance of a Detail class, if you have the data to fill in each entry in `natural_key_fields()`. The last piece of the puzzle involves adding a UUID field to Models that have only `pulp_id` as an identifier. We call this **export_id**. In an 'upstream', such a field never has content. At export-time, the ModelResource of any Model with an `export_id` field fills it in by defining the following dehydrate method: def dehydrate_export_id(self, content): return str(content.pulp_id) Now, at import-time, that piece of content will have that field/column filled in when it is saved, with **the pulp_id that identifies that content from the upstream**. Once an entity with this field is persisted, it can be found/accessed in one of two ways: 1. We can refer to it using the `ForeignKeyWidget(model-class, 'export_id')`, or 2. We can look up the thing-we're-pointing-to at `before_import_row()` time and override whatever was exported ### The Problem of ContentArtifact Entities which have 'natural keys' can be found at import-time easily. Foreign-key relationships which can be 'redirected' to unique single-field natural-keys can be linked easily. However, there are low-level entities for which neither of those are true. ContentArtifact is the poster-child for this class of problem. ContentArtifact has a PK of `pulp_id`, and a UQ of (content_id, relative_path). The content-id gives us a Content entity, whose 'natural key' access depends on the Detail that points to it, not on the Content object itself. Without help, there's no way for DIE to know how to "link up" Content entities and their matching Artifacts, without being able to rely on `pulp_id`. So, to solve this problem, we have done two things: First - add an `export_id` field to Content. That means that **all** Content can be searched for based on `export_id=`. Second - teach ContentArtifactResource, at import-time, to **replace** its `content` pointer with the pulp_id of the Content whose **export-id** is the incoming content-ptr. A specific scenario, filling in all the blanks: * There exists a Pulp3 Upstream (U) we are exporting-from, and a Downstream (D) we are exporting-to. * In the example following, entities in the Upstream start with 'U-', entities created in the Downstream start with 'D-'. * There exist upstream Model instances: * Content U-C1, with pulp_id 0xcc34 and export-id 0x0000 * DetailContent U-DC1, with content_ptr=0xcc34 and some field foo='bar' * an Artifact, U-A1 with sha256 0xaabbcc, pulp_id 0xaa34 * a ContentArtifact, U-CA1, content=0xcc34 artifact=0xaa34 * There exist DIE ModelResource subclasses: * ContentResource, CR, for which dehydrate_export_id() returns pulp_id * ArtifactResource, AR, with import_id_fields=("sha256",) * ContentArtifactResource, CAR, where: * `artifact = ForeignKeyWidget(Artifact, "sha256")` * a `before_import_row()` override: ``` def before_import_row(self, row, **kwargs): # Find the 'original uuid' of the Content for this row, look it up as the # 'export_id' of imported Content, and then replace the Content-pk with its # (new) uuid linked_content = Content.objects.get(export_id=row["content"]) row["content"] = str(linked_content.pulp_id) ``` * pulp_id is **not** exported for any instance We do **NOT** export Content explicitly - a new Content object will be created at import-time when DetailContent.save() is called at the end of importing U-DC1 At import, we expect to see downstream instances created, D-DC1, D-A1, and D-CA1. At export-time: * U-DC1's dehydrate_export_id() method fills export-id with 0xcc34. * U-CA1 contains content=0xcc34, artifact=0xaabbcc. At import-time: * D-DC1 gets a new pulp_id when saved, 0xddaa * D-DC1 gets a new Content entity created, D-C1 * D-C1 has a new pulp_id, 0xaa88 * D-C1 has an export_id=0xcc34 * D-DC1 content_id=D-C1.pulp_id * D-A1 gets a new pulp_id when saved, 0xaa88 * D-A1 has sha256=0xaabbcc * D-CA1 is intercepted before a Model is created. Its content-ptr is replaced with **the pulp_id of the Content entity whose export_id=0xcc34** (ie, D-C1.pulp_id) * D-CA1 looks up the Artifact with sha256=0xaabbcc and points artifact to it At the end of this process, DetailContent, Content and Artifact have new instances with "downstream" pulp_ids, and other field-values as delivered from "upstream". ContentArtifact entries have "downstream" pulp_ids, and point to the (new) pulp_ids of the Model entities that match the ones pointed to by the "upstream". And everyone one is happy. ### Um...Can I See Diagrams of how this fits together? Yes, some. In the diagrams below, anything in red is a (or part of a) unique-key. Here are the involved Model objects for pulp_file: ![](https://i.imgur.com/0lRpt6s.png) And here they are for pulp_rpm (currently, we are missing several entities still): ![](https://i.imgur.com/VaRCCx0.png) ## Questions * **This is complicated, plugin authors are going to Hate Us** They would if we made them all figure this out on their own (or try to grok the full implications of this document, either one). **However** - by putting `export_id` into Content (thus making it available to all Detail entities), and with a little bit of parent-class help for resources, we can make this (almost) invisible. If you are exporting a subclass of Content, your DIE ModelResource should subclass from this parent: ``` class BaseContentResource(QueryModelResource): class Meta: exclude = ( "_artifacts", "content", "content_ptr", "pulp_id", "pulp_created", "pulp_last_updated", ) def dehydrate_export_id(self, content): return str(content.pulp_id) ``` This will exclude the fields that "downstream" owns, and sets up to fill in `export_id` correctly. Then, all the ModelResource subclass has to do is identify the query that can be used to get from 'repository-version' to 'relevant instances', and identify its (non-pulp-id) `import_field_ids`: ``` class FileContentResource(BaseContentResource): def set_up_queryset(self): return FileContent.objects.filter(pk__in=self.repo_version.content) class Meta: model = FileContent import_id_fields = model.natural_key_fields() ``` (And if/when we figure out how to get BaseContentResource to know the Meta.model defined by a subclass, we can probably push even the `import_field_ids=` setting into there as well) * **What about plugin entities that can't use the helpers described above?** For plugin entities that fall into the same hole as ContentArtifact, there will at least be example-code for how to go about defining the affected ModelResources in similar ways/places. This would result in `export_id` appearing in plugin-specific Models that aren't related to Content. * [ttereshc] **A very subjective matter. It might be good to remind in the natural keys doc that U stands for upstream and D for downstream.** I think I quickly got it because I read the overview doc first, otherwise it would require more cycles to realize that :) This section https://hackmd.io/kvXjWYNxQbqCqZAqke25pA?both#The-Problem-of-ContentArtifact closer to the end can be harder to grasp if one doesn't realize what the first letter in most acronyms means. It probably looks very obvious especially if you spend a lot of time thinking about and working with those terms. Good point, added commentary and tried to clarify the example some. * [ttereshc] **What if we change fields which are being exported for any mutable model? Full export in such a case? How to determine that?** I understand that currently we check versions.json on import for the exact match but it might not help here. Here is an example: - I exported everything for pulp_rpm 3.4 and imported it into 3.4 downstream - In 3.5 there is a new optional field so it's null (or has some default) and populated either by user or during some operation (but not at db migration time) - I have my upstream at 3.5 and downstream at 3.5, so they match - I set this optional field in the upstream - It seems like if I perform non-full export, I will never get this data into downstream. Is that right? I think it affects only mutable models, the ones that can be updated. E.g. configuration on an RpmRepository. My main concern is how to determine that without educating users about implementation details. [daviddavis] The difference between a full export and an incremental export is just the set of artifacts that get exported. Each time we're exporting a full data dump since it's not that much extra overhead/space. This means that if something gets added/updated in the upstream pulp, it'll get updated during import in the downstream pulp. * [ttereshc] **What happens when a model (with no natural key, the one with export_id) appears in downstream first and then you try to import the same content from the upstream?** So export_id in the downstream is not set yet. Maybe there are 2 cases: a. a model has at least some uniqueness constraint like ContentArtifact b. a model has no uniqueness constraint like UpdateCollection Use cases: - downstream migrated from pulp2 to pulp3, later performing an import - full export/import, downstream uploads a file, later export/import of the same file from the upstream (crazy security problem, upstream satellite is down, I desperately need to patch my systems, so I download a file I need to a usb stick and then upload it to my disconnected satellite. Later upstream satellite is up and I can properly export/import incrementally what I need) [daviddavis] If there is a uniqueness constraint, the object will be found and export_id will be updated on it. Not sure about the case where there is no uniqueness constraint. Maybe ggainey has a plan? [ggainey] If there is no uniqueness-constraint, there isn't anything for DIE to get hold of to know "this" object already exists. In that case, the existing downstream object will remain, a new copy of it will be created, and anything in the export that pointed to that Thing will point to the copy instead. It's possible that orphan-cleanup might clean this up, but I suspect it would be very dependent on the specific case. * [ttereshc] **What happens if I remove content (which has export_id) from the upstream, and then upload back, and perform export before removing and after adding back.** Example: Collections in advisories don't have natural keys, so they will need export_id. - I have an upstream repo with advisory1 and collection1 - I perform export/import and downstream matches upstream - I remove advisory1 from the upstream - I upload the same advisory1 with the same collection1 back to the upstream - I perform export/import, export_id for the collection1 will be different because pulp_id changed. What happens? [daviddavis] The import updates the export_id on the matching object and then uses that to look up the object later in the import process. So this handles the case where a record's pulp_id changes in upstream pulp. [ggainey] David, I think this case is one where there is no way to find "matching object" other than its pulp-id (because it has no unique-key combination), and the entity has been removed **and then recreated** upstream - so there is a new pulp-id that by Sheer Coincidence looks a lot like the thing identified with the old pulp-id. [ggainey] Both the original-collection and the new-but-the-same collection will exist downstream, the new one will be connected to the erratum/update. I think. I'm not sure what we could do that would result in anything else, given the way the data model works in Pulp3. If there is no unique-key, then there's no reasonable way to do what import/export is trying to do without this kind of odd edge case. * [ttereshc] **I think it's a good idea to have some identifier if natural keys are not available, so +1 for export_id as a concept. I'm not sure I see other options without hugely simplifying the workflows (supporting only simple cases).** However I'm a bit concerned (and maybe because I didn't fully understand something) that pulp_id is being used for the export_id. Would it make sense for each model (plugin writer responsibility) to figure out themselves what the identifier is? I guess even now, I, as a plugin writer, can override the dehydrate_export_id in my model and define what suits me best to solve situations like #3. E.g. UpdateCollection has no uniqueness constraint, but maybe I can generate an identifier , like advisory_digest+collection_name, or advisory_digest + collection_package_nevra for UpdateCollectionPackage, and set it as an export_id. Do you think it helps to identify the existing model instance in downstream in the situation from #3? It no longer depends on pulp_id, so I can remove, add back whatever in the upstream, and it will still find it in the downstream. Sorry if it's a bit messy explanation, let me know if the idea or problem is not clear. [daviddavis] The export_id field should handle the scenarios in 2 and 3 so I don't see a huge advantage to having plugin writers define model specific export_ids other than having a more meaningful identifier in some cases. [ggainey] The advantage of us defining an export-id at the Content-level (in pulpcore) instead of at the plugin-level, is that it makes plugin-writers' lives much simpler - at least for any entity that subclasses from Content. For other entities, plugin writers could of course do whatever they think works. However, while you **could** come up with another thing to use as an export-id - this works, is generally 'good enough', and there's an example to start from. [daviddavis] Actually, the plugin writers have the ability to define export_id on their models that they control so I think your proposal for [the last point] might make sense for models like UpdateCollection. [ggainey] Surely - altho see "advantage of following existing code" :) Plus, anything that core_contentartifact needs to point to (ie, anything subclassing from Content) needs to play the game the same way so we can relink content-to-artifact consistently at import-time. * [bmbouter] **If all pulp models had natural keys would we just be able to use those? Or is the restriction that you can't relate one model to another using multiple fields also the issue?** [ggainey] The DIE limitation to relate an object to a single field only would still require us to have a single identifier. * **Is export_id the best name?** How about import_id or upstream_id? [ggainey] I like `upstream_id`, it's **exactly** what the field contains. Will change the code. ## Example Output In this section, we show the results of doing a full-export of the latest-version of a pulp_file Repository, 'iso-1'. The actual binary blobs for the Artifacts live under the `artifact/` directory, and are not included below... * Files exported: ``` . ├── artifact │   ├── 21 │   │   └── 77ac29ea1c50df6ccb58c21d58943d669de3c0501d343803b623fb0ac9117c │   ├── 27 │   │   └── 7011a07d9502a4c36840de32118b66f9ab25b43f9a4b18c5ee7ce86a61597f │   └── c8 │   └── cb77dd8aa7cfb2527c3ee4ca0eb853e722095f626ea267f5c57eef35ece249 ├── export-faa06b4e-2456-479b-9f4e-8303661148c9-20200709_1254.tar.gz ├── pulpcore.app.modelresource.ArtifactResource.json ├── pulpcore.app.modelresource.RepositoryResource.json ├── repository-iso_1 │   ├── pulpcore.app.modelresource.ContentArtifactResource.json │   └── pulp_file.app.modelresource.FileContentResource.json └── versions.json ``` * ArtifactResource.json: ``` $ python -m json.tool pulpcore.app.modelresource.ArtifactResource.json [ { "file": "artifact/c8/cb77dd8aa7cfb2527c3ee4ca0eb853e722095f626ea267f5c57eef35ece249", "size": 1024, "md5": "90c4b2d66db58b417bb71e9d73240ddd", "sha1": "81d9d927c823f6f1ee049277e4051cc7f8c6c209", "sha224": "6173ebbe710ac10dc2edce54e8c650dcba22aa3f71b68b8ee8aa7deb", "sha256": "c8cb77dd8aa7cfb2527c3ee4ca0eb853e722095f626ea267f5c57eef35ece249", "sha384": "d8fead6cfec4104e5eb7683c2a5597c8e3b976f33f87a6f1d00ae3e68a6377c1e5efe3fc0d7bb69510bb8e7226b4f453", "sha512": "daaac4c66e7ffed66f5257e85585a816f5f1428dca205d50d9e0fccaa55c44742b0e43f06667442dbbc67b8ba9a57c19cd3a49162320b3f8db5473a8fc6d0e8d" }, { "file": "artifact/27/7011a07d9502a4c36840de32118b66f9ab25b43f9a4b18c5ee7ce86a61597f", "size": 1024, "md5": "590e3561f4e5a4bbff6ae5e27826f731", "sha1": "b8da1c290d785ebedc67153fd2f15c3ab3011f47", "sha224": "bef31a4780d0e0a60d22f0d33f946e29f4f782795a17aedefa2dd3d1", "sha256": "277011a07d9502a4c36840de32118b66f9ab25b43f9a4b18c5ee7ce86a61597f", "sha384": "2db3159a1c9d78b4bfb5ec42216c86b4fadd46066247db3bc0fbceda51d5edf97b4a1cf693248b6b6b7bddb1396fead5", "sha512": "6a5d10180f573fe9319524d2f79f7c38167d57c7b57aae9a153ddfb8e7aa1bb57308e4dcd70f9e85ffa185fd70914e61b1255e58f80272ae408b26326a00d124" }, { "file": "artifact/21/77ac29ea1c50df6ccb58c21d58943d669de3c0501d343803b623fb0ac9117c", "size": 1024, "md5": "8514193e5e060398e75496af02249d97", "sha1": "709cf0fc402abfcc9280ce9ced30696c6a8c981e", "sha224": "8bd50ce420556de439fe10831c8a4bcc503de771a8b70f3ca5cad223", "sha256": "2177ac29ea1c50df6ccb58c21d58943d669de3c0501d343803b623fb0ac9117c", "sha384": "e1cd7c0c0d35821a7d8cc64703df85d95fe5a186dfbd8a2219df4d17b26db967fd7e400c02ca61330563b73e51b536bb", "sha512": "a9c6c9c8faf80fb407a42a955fe3d92a87894dc50480d5c194d2668aab0ceb816e8e295d3bc214af5c4e4873100469e0e67125cc0c8bf86e5fe0443e90c79d0a" } ] ``` * RepositoryResource.json: ``` $ python -m json.tool pulpcore.app.modelresource.RepositoryResource.json [ { "pulp_type": "file.file", "name": "iso", "description": "", "next_version": 2 } ] ``` * repository-iso_1/...ContentArtifactResource.json: ``` $ python -m json.tool repository-iso_1/pulpcore.app.modelresource.ContentArtifactResource.json [ { "artifact": "2177ac29ea1c50df6ccb58c21d58943d669de3c0501d343803b623fb0ac9117c", "content": "4b16aef5-f4ee-4d44-b1bd-4567ef6f8c5e", "relative_path": "3.iso" }, { "artifact": "277011a07d9502a4c36840de32118b66f9ab25b43f9a4b18c5ee7ce86a61597f", "content": "25b70a4a-cdd7-4d9c-9169-7450d410758d", "relative_path": "1.iso" }, { "artifact": "c8cb77dd8aa7cfb2527c3ee4ca0eb853e722095f626ea267f5c57eef35ece249", "content": "65169660-fb7b-4557-af80-e63943d9763c", "relative_path": "2.iso" } ] ``` * repository-iso_1/...FileContentResource.json: ``` $ python -m json.tool repository-iso_1/pulp_file.app.modelresource.FileContentResource.json [ { "pulp_type": "file.file", "export_id": "4b16aef5-f4ee-4d44-b1bd-4567ef6f8c5e", "relative_path": "3.iso", "digest": "2177ac29ea1c50df6ccb58c21d58943d669de3c0501d343803b623fb0ac9117c" }, { "pulp_type": "file.file", "export_id": "25b70a4a-cdd7-4d9c-9169-7450d410758d", "relative_path": "1.iso", "digest": "277011a07d9502a4c36840de32118b66f9ab25b43f9a4b18c5ee7ce86a61597f" }, { "pulp_type": "file.file", "export_id": "65169660-fb7b-4557-af80-e63943d9763c", "relative_path": "2.iso", "digest": "c8cb77dd8aa7cfb2527c3ee4ca0eb853e722095f626ea267f5c57eef35ece249" } ] ``` * versions.json: ``` $ python -m json.tool versions.json [ { "component": "pulpcore", "version": "3.5.0.dev0" }, { "component": "pulp_file", "version": "1.1.0.dev0" } ] ```