From d12800c977c9f30f9502b4be0ef4dc8f923d7193 Mon Sep 17 00:00:00 2001 From: Scott Hovis Date: Wed, 9 Sep 2026 15:23:30 +0000 Subject: [PATCH] Add legacy APT architecture-all publication mode Add an opt-in publication setting that copies Architecture: all package metadata into every native package index and advertises the format in Release metadata. Preserve the existing publication format by default. Fixes: #447 Assisted By: OpenAI Codex --- CHANGES/447.feature | 1 + docs/user/guides/publish.md | 18 ++++ ...ication_no_support_for_architecture_all.py | 18 ++++ pulp_deb/app/models/publication.py | 1 + .../serializers/publication_serializers.py | 9 ++ pulp_deb/app/tasks/publishing.py | 64 ++++++++------ pulp_deb/app/viewsets/publication.py | 4 + pulp_deb/tests/functional/api/test_publish.py | 86 +++++++++++++++++++ pulp_deb/tests/functional/constants.py | 1 + 9 files changed, 177 insertions(+), 25 deletions(-) create mode 100644 CHANGES/447.feature create mode 100644 pulp_deb/app/migrations/0043_aptpublication_no_support_for_architecture_all.py diff --git a/CHANGES/447.feature b/CHANGES/447.feature new file mode 100644 index 000000000..9c2bc23b3 --- /dev/null +++ b/CHANGES/447.feature @@ -0,0 +1 @@ +Added an opt-in APT publication mode that includes `Architecture: all` packages in every architecture-specific package index for compatibility with APT clients before version 1.1. diff --git a/docs/user/guides/publish.md b/docs/user/guides/publish.md index 0081961e6..e79e9dfb3 100644 --- a/docs/user/guides/publish.md +++ b/docs/user/guides/publish.md @@ -34,6 +34,24 @@ An example `base_url` could be: http://my-pulp-instance.com:5001/pulp/content/my-repository/ ``` +## Compatibility with APT Clients Before 1.1 + +APT clients before version 1.1 do not download the `binary-all/Packages` index. +To make `Architecture: all` packages available to these clients, create the publication with +`no_support_for_architecture_all` enabled: + +```bash +pulp deb publication create \ + --repository=${NAME} \ + --no-support-for-architecture-all +``` + +This publishes `Architecture: all` packages in every architecture-specific `Packages` index in +addition to the `binary-all` index. The generated `Release` file advertises the repository format +using `No-Support-for-Architecture-all: Packages`. + +The option defaults to disabled, so existing publication behavior remains unchanged. + ## Metadata Signing diff --git a/pulp_deb/app/migrations/0043_aptpublication_no_support_for_architecture_all.py b/pulp_deb/app/migrations/0043_aptpublication_no_support_for_architecture_all.py new file mode 100644 index 000000000..c70a866fd --- /dev/null +++ b/pulp_deb/app/migrations/0043_aptpublication_no_support_for_architecture_all.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.12 on 2026-09-09 00:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("deb", "0042_add_excluded_package_metadata_fields"), + ] + + operations = [ + migrations.AddField( + model_name="aptpublication", + name="no_support_for_architecture_all", + field=models.BooleanField(default=False), + ), + ] diff --git a/pulp_deb/app/models/publication.py b/pulp_deb/app/models/publication.py index d02495a8a..194102302 100644 --- a/pulp_deb/app/models/publication.py +++ b/pulp_deb/app/models/publication.py @@ -72,6 +72,7 @@ class AptPublication(Publication, AutoAddObjPermsMixin): simple = models.BooleanField(default=False) structured = models.BooleanField(default=True) + no_support_for_architecture_all = models.BooleanField(default=False) layout = models.TextField(choices=LAYOUT_CHOICES, default=LAYOUT_TYPES.NESTED_ALPHABETICALLY) excluded_package_metadata_fields = ArrayField(models.TextField(), default=list) signing_service = models.ForeignKey( diff --git a/pulp_deb/app/serializers/publication_serializers.py b/pulp_deb/app/serializers/publication_serializers.py index 60aec8184..22364fa28 100644 --- a/pulp_deb/app/serializers/publication_serializers.py +++ b/pulp_deb/app/serializers/publication_serializers.py @@ -38,6 +38,14 @@ class AptPublicationSerializer(PublicationSerializer): default=False, ) structured = BooleanField(help_text="Activate structured publishing mode.", default=True) + no_support_for_architecture_all = BooleanField( + help_text=( + "Publish Architecture: all packages in every architecture-specific Packages index " + "in addition to the binary-all index, and advertise this repository format in " + "Release files." + ), + default=False, + ) publish_upstream_release_fields = BooleanField(help_text="", required=False) checkpoint = serializers.BooleanField(required=False) layout = serializers.ChoiceField( @@ -82,6 +90,7 @@ class Meta: fields = PublicationSerializer.Meta.fields + ( "simple", "structured", + "no_support_for_architecture_all", "checkpoint", "signing_service", "publish_upstream_release_fields", diff --git a/pulp_deb/app/tasks/publishing.py b/pulp_deb/app/tasks/publishing.py index 53a91d427..e4962359e 100644 --- a/pulp_deb/app/tasks/publishing.py +++ b/pulp_deb/app/tasks/publishing.py @@ -89,6 +89,7 @@ def publish( layout=LAYOUT_TYPES.NESTED_ALPHABETICALLY, publish_legacy_release_files=False, excluded_package_metadata_fields=None, + no_support_for_architecture_all=False, ): """ Use provided publisher to create a Publication based on a RepositoryVersion. @@ -103,6 +104,8 @@ def publish( publish_legacy_release_files (bool): publish legacy per architecture release files excluded_package_metadata_fields (list): Custom package metadata fields to omit from generated package indices. + no_support_for_architecture_all (bool): Publish Architecture: all packages in every + architecture-specific package index as well as the binary-all index. """ @@ -131,6 +134,7 @@ def publish( ) as publication: publication.simple = simple publication.structured = structured + publication.no_support_for_architecture_all = no_support_for_architecture_all publication.signing_service = signing_service publication.publish_legacy_release_files = publish_legacy_release_files publication.layout = layout @@ -468,33 +472,41 @@ def add_packages(self, package_pairs, artifact_dict, remote_artifact_dict): ) ) - metadata_arch = "all" if package.architecture == "all" else index_arch - package_index_entry = (package.pk, metadata_arch) - if package_index_entry in seen_package_index_entries: - continue - seen_package_index_entries.add(package_index_entry) - package_serializer = Package822Serializer(package, context={"request": None}) - try: - package_serializer.to822( - self.component, - artifact_dict, - remote_artifact_dict, - layout=layout, - basename_override=upstream_basename, - excluded_package_metadata_fields=excluded_fields, - ).dump(self.package_index_files[metadata_arch][0]) - except KeyError: - log.warning( - "Published package '%s' with index architecture '%s' was not added to " - "component '%s' in distribution '%s' because it lacks this architecture!", - getattr(package, "relative_path", None) or getattr(package, "pk", None), - metadata_arch, - self.component, - self.parent.distribution, - ) + if ( + self.parent.publication.no_support_for_architecture_all + and package.architecture == "all" + ): + metadata_architectures = self.parent.architectures else: - self.package_index_files[metadata_arch][0].write(b"\n") + metadata_architectures = ["all" if package.architecture == "all" else index_arch] + + for metadata_arch in metadata_architectures: + package_index_entry = (package.pk, metadata_arch) + if package_index_entry in seen_package_index_entries: + continue + seen_package_index_entries.add(package_index_entry) + + try: + package_serializer.to822( + self.component, + artifact_dict, + remote_artifact_dict, + layout=layout, + basename_override=upstream_basename, + excluded_package_metadata_fields=excluded_fields, + ).dump(self.package_index_files[metadata_arch][0]) + except KeyError: + log.warning( + "Published package '%s' with index architecture '%s' was not added to " + "component '%s' in distribution '%s' because it lacks this architecture!", + getattr(package, "relative_path", None) or getattr(package, "pk", None), + metadata_arch, + self.component, + self.parent.distribution, + ) + else: + self.package_index_files[metadata_arch][0].write(b"\n") with transaction.atomic(): if published_artifacts: @@ -634,6 +646,8 @@ def __init__( release.codename = distribution.split("/")[0] if distribution != "/" else "flat-repo" self.release["Codename"] = release.codename self.release["Date"] = datetime.now(tz=timezone.utc).strftime("%a, %d %b %Y %H:%M:%S %z") + if publication.no_support_for_architecture_all: + self.release["No-Support-for-Architecture-all"] = "Packages" self.release["Architectures"] = " ".join(architectures) self.release["Components"] = "" # Will be set later if release.description != NULL_VALUE: diff --git a/pulp_deb/app/viewsets/publication.py b/pulp_deb/app/viewsets/publication.py index fe0fa9c50..5f0a1a0cb 100644 --- a/pulp_deb/app/viewsets/publication.py +++ b/pulp_deb/app/viewsets/publication.py @@ -211,6 +211,9 @@ def create(self, request, **kwargs): repository_version = serializer.validated_data.get("repository_version") simple = serializer.validated_data.get("simple") structured = serializer.validated_data.get("structured") + no_support_for_architecture_all = serializer.validated_data.get( + "no_support_for_architecture_all" + ) checkpoint = serializer.validated_data.get("checkpoint") signing_service = serializer.validated_data.get("signing_service") publish_upstream_release_fields = serializer.validated_data.get( @@ -226,6 +229,7 @@ def create(self, request, **kwargs): "repository_version_pk": repository_version.pk, "simple": simple, "structured": structured, + "no_support_for_architecture_all": no_support_for_architecture_all, "signing_service_pk": getattr(signing_service, "pk", None), "publish_upstream_release_fields": publish_upstream_release_fields, "publish_legacy_release_files": publish_legacy_release_files, diff --git a/pulp_deb/tests/functional/api/test_publish.py b/pulp_deb/tests/functional/api/test_publish.py index 3aaf5093e..69d9c945f 100644 --- a/pulp_deb/tests/functional/api/test_publish.py +++ b/pulp_deb/tests/functional/api/test_publish.py @@ -18,6 +18,7 @@ DEB_FIXTURE_FLAT_REPOSITORY_NAME, DEB_FIXTURE_METADATA_UPDATE_REPOSITORY_NAME, DEB_FIXTURE_MISSING_ARCHITECTURE_REPOSITORY_NAME, + DEB_FIXTURE_MIXED_REPOSITORY_NAME, DEB_FIXTURE_SINGLE_DIST, DEB_FIXTURE_VARIANT_REPOSITORY_NAME, DEB_PACKAGE_INDEX_NAME, @@ -823,6 +824,91 @@ def parse_package_index(pkg_idx): return packages +@pytest.mark.parallel +def test_publish_no_support_for_architecture_all( + create_publication_and_verify_repo_version, + deb_distribution_factory, + download_content_unit, +): + """Test publishing Architecture: all packages in every binary package index.""" + remote_args = { + "distributions": "muspelheim", + "policy": "on_demand", + } + publication_args = { + **DEB_PUBLICATION_ARGS_ONLY_STRUCTURED, + "no_support_for_architecture_all": True, + } + + publication, _, _, _ = create_publication_and_verify_repo_version( + remote_args=remote_args, + publication_args=publication_args, + remote_name=DEB_FIXTURE_MIXED_REPOSITORY_NAME, + ) + assert publication.no_support_for_architecture_all is True + + distribution = deb_distribution_factory(publication) + base_path = distribution.to_dict()["base_path"] + + release_file = download_content_unit(base_path, "dists/muspelheim/Release") + release = deb822.Deb822(release_file.decode("utf-8")) + assert release["No-Support-for-Architecture-all"] == "Packages" + + expected_packages = { + "asgard": { + "all": {"eir-1.0-all"}, + "amd64": {"baldr-1.0-amd64", "eir-1.0-all"}, + "ppc64": {"eir-1.0-all", "frigg-1.0-ppc64"}, + }, + "nidavellir": { + "all": {"regin-1.0-all"}, + "amd64": {"hreidmar-1.0-amd64", "regin-1.0-all"}, + "ppc64": {"fafner-1.0-ppc64", "regin-1.0-all"}, + }, + } + for component, architectures in expected_packages.items(): + for architecture, expected in architectures.items(): + package_index = download_content_unit( + base_path, + f"dists/muspelheim/{component}/binary-{architecture}/Packages", + ) + assert set(parse_package_index(package_index)) == expected + + package_path = "pool/asgard/e/eir/eir_1.0_all.deb" + cold_download = download_content_unit(base_path, package_path) + warm_download = download_content_unit(base_path, package_path) + assert cold_download.startswith(b"!\n") + assert warm_download == cold_download + + +@pytest.mark.parallel +def test_publish_architecture_all_default_format( + create_publication_and_verify_repo_version, + deb_distribution_factory, + download_content_unit, +): + """Test that the default publisher keeps Architecture: all in binary-all only.""" + publication, _, _, _ = create_publication_and_verify_repo_version( + remote_args={"distributions": "muspelheim"}, + publication_args=DEB_PUBLICATION_ARGS_ONLY_STRUCTURED, + remote_name=DEB_FIXTURE_MIXED_REPOSITORY_NAME, + ) + assert publication.no_support_for_architecture_all is False + + distribution = deb_distribution_factory(publication) + base_path = distribution.to_dict()["base_path"] + + release_file = download_content_unit(base_path, "dists/muspelheim/Release") + release = deb822.Deb822(release_file.decode("utf-8")) + assert "No-Support-for-Architecture-all" not in release + + package_index = download_content_unit( + base_path, + "dists/muspelheim/asgard/binary-amd64/Packages", + ) + assert set(parse_package_index(package_index)) == {"baldr-1.0-amd64"} + + @pytest.mark.parallel def test_publish_architecture_variant_package_indices( create_publication_and_verify_repo_version, diff --git a/pulp_deb/tests/functional/constants.py b/pulp_deb/tests/functional/constants.py index 5fe530f69..c6db7b636 100644 --- a/pulp_deb/tests/functional/constants.py +++ b/pulp_deb/tests/functional/constants.py @@ -39,6 +39,7 @@ def _clean_dict(d): DEB_FIXTURE_INVALID_REPOSITORY_NAME = "/debian-invalid/" DEB_FIXTURE_FLAT_REPOSITORY_NAME = "/debian-flat/" DEB_FIXTURE_VARIANT_REPOSITORY_NAME = "/debian-variant/" +DEB_FIXTURE_MIXED_REPOSITORY_NAME = "/debian-mixed/" DEB_FIXTURE_BASE = "/" DEB_FIXTURE_COMPLEX_REPOSITORY_NAME = "/debian-complex-dists" DEB_FIXTURE_MISSING_ARCHITECTURE_REPOSITORY_NAME = "/debian-missing-architecture/"