Skip to content

[PULP-1749] Add migrate endpoint for push repositories - #2406

Open
gerrod3 wants to merge 2 commits into
pulp:mainfrom
gerrod3:migrate-push
Open

[PULP-1749] Add migrate endpoint for push repositories#2406
gerrod3 wants to merge 2 commits into
pulp:mainfrom
gerrod3:migrate-push

Conversation

@gerrod3

@gerrod3 gerrod3 commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add a migrate action on push repositories that dispatches a task to convert ContainerPushRepository into ContainerRepository
  • Support optional copy_versions to preserve full repository version history (defaults to copying only the latest version)
  • Reassociate distributions, return the created repository in the task result, and add functional test coverage

Test plan

  • Run pytest pulp_container/tests/functional/api/test_migrate_push_repository.py
  • Verify POST /pulp/api/v3/repositories/container/container-push/{pk}/migrate/ returns 202 and completes successfully
  • Confirm migrated repository appears under container repositories with preserved content and distribution link
  • Test copy_versions: true when multiple repository versions exist

Made with Cursor

@mdellweg

Copy link
Copy Markdown
Member

Do you plan to add a management command to migrate all remaining repositories?

Do you think we need to keep the old href's around as an alias? (Or by returning a clever redirect?)

@mdellweg mdellweg changed the title Add migrate endpoint for push repositories [PULP-1749] Add migrate endpoint for push repositories Jul 15, 2026
@gerrod3

gerrod3 commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Do you plan to add a management command to migrate all remaining repositories?

Yeah. I think I'll do it in a separate PR once I get this right.

Do you think we need to keep the old href's around as an alias? (Or by returning a clever redirect?)

No, this is one downside of the migration is that it's a completely new object, old hrefs won't work. I think it won't be a big deal since these repos are auto-created on push and auto-deleted when the distro is removed, so I don't expect many users are keeping track of these hrefs. Adding a compatibility layer is a lot of effort so my plan is to make the docs explicit on how the migration works so users understand what is happening.

@gerrod3
gerrod3 marked this pull request as ready for review July 27, 2026 14:30
Provide an async API to convert legacy ContainerPushRepository instances into ContainerRepository while preserving content and distribution links.

Co-authored-by: Cursor <cursoragent@cursor.com>
original_name = push_repository.name
temp_name = f"{original_name}__migrating__{push_repository.pk}"

with transaction.atomic():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any structural difference between the ContainerRepository and the ContainerPushRepository table?
(I am wondering if we could do something clever in SQL to just change the DNA of the existing object.)

As far as I know the inheritance here is managed with a one-to-one relation so that the pk of the parent model is also the pk of the child model. And since the content models in those repositories as well as the repository versions are compatible (as far as I know), this could be a single create and delete in a transaction.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For example, we used this code in an old pulp_file migration:

def migrate_data_from_old_model_to_new_model_up(apps, schema_editor):
    """ Move objects from FileDistribution to NewFileDistribution."""
    FileDistribution = apps.get_model('file', 'FileDistribution')
    NewFileDistribution = apps.get_model('file', 'NewFileDistribution')
    for file_distribution in FileDistribution.objects.all():
        with transaction.atomic():
            NewFileDistribution(
                pulp_id=file_distribution.pulp_id,
                pulp_created=file_distribution.pulp_created,
                pulp_last_updated=file_distribution.pulp_last_updated,
                pulp_type=file_distribution.pulp_type,
                name=file_distribution.name,
                base_path=file_distribution.base_path,
                content_guard=file_distribution.content_guard,
                remote=file_distribution.remote,
                publication=file_distribution.publication
            ).save()
            file_distribution.delete()

Maybe the "trick" here is that the raw Django migration level objects don't "know" about the pulp specific MasterDetailModel and can carefully work around that.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

e.g. to turn a file_repository into a pgp keyring (yes, we should not do that becuase they are not as compatible...) i needed to call these three commands:

insert into core_openpgpkeyring (repository_ptr_id) VALUES ('019fd0fc-103f-775a-bb16-f92440ba53dc');
update core_repository set pulp_type = 'core.openpgp' where pulp_id = '019fd0fc-103f-775a-bb16-f92440ba53dc';
delete from file_filerepository where repository_ptr_id = '019fd0fc-103f-775a-bb16-f92440ba53dc';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I've tested this out and yeah the ORM code in the migration behaves differently from normal ORM code and runs into issues with the multi-table inheritance. But we can still do the SQL migrate manually and we can preserve mostly everything. See my latest commit.

@mdellweg mdellweg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I very much like this approach!

pk=push_repository_pk
)
if push_repository.pulp_type != ContainerPushRepository.get_pulp_type():
raise RuntimeError(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a sanity check, since we expect the transition down below to happen in a transaction.
Maybe with a comment we can prevent future readers to panic.

push_repository_pk (str): The primary key for the push repository to migrate.
"""
with transaction.atomic():
push_repository = ContainerPushRepository.objects.select_for_update().get(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what the select_for_update is supposed to protect here, but why not play it extra safe...
OTOH, does this "select for update" the base Repository row too?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about adding cursor.execute("set transaction isolation level serializable") as the very first statement in this transaction?

It could be the single one measure to protect all the reads happening here, including the GenericForeignKeys.

It may be a hit on performance, and could lead to SerializationErrors but for this kind of once in a lifetime migration operation it's probably a good tradeoff.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, select_for_update locks both parents and childs row. Added the SET transaction level.

Comment on lines +88 to +95
# Deleting the push child cascades its pending M2M rows; parent repo is kept.
cursor.execute(
"""
DELETE FROM container_containerpushrepository
WHERE repository_ptr_id = %s
""",
[push_repository_pk],
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is Django really setting up cascade delete in the database?
I guess this is just something to keep looking out for.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apparently not, good call out, fixed.

Comment on lines +97 to +102
UserRole.objects.filter(content_type=push_ct, object_id=str(push_repository_pk)).update(
content_type=container_ct
)
GroupRole.objects.filter(content_type=push_ct, object_id=str(push_repository_pk)).update(
content_type=container_ct
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call!

[push_repository_pk, signing_service_id],
)

cursor.execute(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hah, I guess it wouldn't be dramatic to loose the pending items accidentally, but yes, good call too.

Reminds me that "pending content" should probably be a pulpcore thing by now.

f"Container repository child row already exists for {push_repository_pk}."
)

signing_service_id = push_repository.manifest_signing_service_id

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the one "normal" field we have to transfer from the child table, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants