File add-deb822-apt-source-format-support-692.patch of Package venv-salt-minion

From 595aa7563efc94f806ef519d25463a3207f2746d Mon Sep 17 00:00:00 2001
From: Victor Zhestkov <vzhestkov@suse.com>
Date: Mon, 10 Mar 2025 10:13:39 +0100
Subject: [PATCH] Add DEB822 apt source format support (#692)

Co-authored-by: Marek Czernek <marek.czernek@suse.com>
---
 salt/modules/aptpkg.py                    | 123 ++++++++++++++++++----
 tests/pytests/unit/modules/test_aptpkg.py | 122 +++++++++++++++++++++
 2 files changed, 225 insertions(+), 20 deletions(-)

diff --git a/salt/modules/aptpkg.py b/salt/modules/aptpkg.py
index cd40aea54f..48d2ccb904 100644
--- a/salt/modules/aptpkg.py
+++ b/salt/modules/aptpkg.py
@@ -59,6 +59,16 @@ try:
 except ImportError:
     HAS_APT = False
 
+HAS_DEB822 = False
+
+if HAS_APT:
+    try:
+        from aptsources.sourceslist import Deb822SourceEntry, _deb822
+
+        HAS_DEB822 = True
+    except ImportError:
+        pass
+
 try:
     import apt_pkg
 
@@ -1907,8 +1917,11 @@ def list_repos(**kwargs):
        salt '*' pkg.list_repos disabled=True
     """
     repos = {}
-    sources = SourcesList()
-    for source in sources.list:
+    if HAS_DEB822:
+        sources = SourcesList(deb822=True)
+    else:
+        sources = SourcesList()
+    for source in sources:
         if _skip_source(source):
             continue
         if not HAS_APT:
@@ -1916,19 +1929,40 @@ def list_repos(**kwargs):
         else:
             signedby = _get_opts(line=source.line)["signedby"].get("value", "")
         repo = {}
+        if HAS_DEB822:
+            try:
+                signedby = source.section.tags.get("Signed-By", signedby)
+            except AttributeError:
+                pass
         repo["file"] = source.file
-        repo["comps"] = getattr(source, "comps", [])
+        repo_comps = getattr(source, "comps", [])
+        repo_dists = source.dist.split(" ")
+        repo["comps"] = repo_comps
         repo["disabled"] = source.disabled
         repo["enabled"] = not repo[
             "disabled"
         ]  # This is for compatibility with the other modules
-        repo["dist"] = source.dist
+        repo["dist"] = repo_dists.pop(0)
         repo["type"] = source.type
         repo["uri"] = source.uri
-        repo["line"] = source.line.strip()
+        if "Types: " in source.line and "\n" in source.line:
+            repo["line"] = (
+                f"{source.type} {source.uri} {repo['dist']} {' '.join(repo_comps)}"
+            )
+        else:
+            repo["line"] = source.line.strip()
         repo["architectures"] = getattr(source, "architectures", [])
         repo["signedby"] = signedby
         repos.setdefault(source.uri, []).append(repo)
+        if len(repo_dists):
+            for dist in repo_dists:
+                repo_copy = repo.copy()
+                repo_copy["dist"] = dist
+                if "Types: " in source.line and "\n" in source.line:
+                    repo_copy["line"] = (
+                        f"{source.type} {source.uri} {repo_copy['dist']} {' '.join(repo_comps)}"
+                    )
+                repos[source.uri].append(repo_copy)
     return repos
 
 
@@ -1937,12 +1971,17 @@ def get_repo(repo, **kwargs):
     Display a repo from the sources.list / sources.list.d
 
     The repo passed in needs to be a complete repo entry.
+    When system uses repository in the deb822 format,
+    get_repo uses a partial match of distributions.
+
+    In that case, include any distribution of the deb822
+    repository in the repo name to match that repo.
 
     CLI Examples:
 
     .. code-block:: bash
 
-        salt '*' pkg.get_repo "myrepo definition"
+        salt '*' pkg.get_repo "deb URL noble main"
     """
     ppa_auth = kwargs.get("ppa_auth", None)
     # we have to be clever about this since the repo definition formats
@@ -2021,11 +2060,17 @@ def del_repo(repo, **kwargs):
     The repo passed in must be a fully formed repository definition
     string.
 
+    When system uses repository in the deb822 format,
+    del_repo uses a partial match of distributions.
+
+    In that case, include any distribution of the deb822
+    repository in the repo name to match that repo.
+
     CLI Examples:
 
     .. code-block:: bash
 
-        salt '*' pkg.del_repo "myrepo definition"
+        salt '*' pkg.del_repo "deb URL noble main"
     """
     is_ppa = False
     if repo.startswith("ppa:") and __grains__["os"] in ("Ubuntu", "Mint", "neon"):
@@ -2047,7 +2092,10 @@ def del_repo(repo, **kwargs):
             else:
                 repo = softwareproperties.ppa.expand_ppa_line(repo, dist)[0]
 
-    sources = SourcesList()
+    if HAS_DEB822:
+        sources = SourcesList(deb822=True)
+    else:
+        sources = SourcesList()
     repos = [s for s in sources.list if not s.invalid]
     if repos:
         deleted_from = dict()
@@ -2070,12 +2118,14 @@ def del_repo(repo, **kwargs):
                 source.type == repo_type
                 and source.architectures == repo_architectures
                 and source.uri == repo_uri
-                and source.dist == repo_dist
+                and repo_dist in source.dist
             ):
 
                 s_comps = set(source.comps)
                 r_comps = set(repo_comps)
-                if s_comps.intersection(r_comps):
+                if s_comps.intersection(r_comps) or (
+                    s_comps == set() and r_comps == set()
+                ):
                     deleted_from[source.file] = 0
                     source.comps = list(s_comps.difference(r_comps))
                     if not source.comps:
@@ -2551,6 +2601,12 @@ def mod_repo(repo, saltenv="base", aptkey=True, **kwargs):
     ``ppa:<project>/repo`` format is acceptable. ``ppa:`` format can only be
     used to create a new repository.
 
+    When system uses repository in the deb822 format, mod_repo uses a partial
+    match of distributions.
+
+    In that case, include any distribution of the deb822 repository in the
+    repo definition to match that repo.
+
     The following options are available to modify a repo definition:
 
     architectures
@@ -2605,8 +2661,8 @@ def mod_repo(repo, saltenv="base", aptkey=True, **kwargs):
 
     .. code-block:: bash
 
-        salt '*' pkg.mod_repo 'myrepo definition' uri=http://new/uri
-        salt '*' pkg.mod_repo 'myrepo definition' comps=main,universe
+        salt '*' pkg.mod_repo 'deb URL noble main' uri=http://new/uri
+        salt '*' pkg.mod_repo 'deb URL noble main' comps=main,universe
     """
     if "refresh_db" in kwargs:
         refresh = kwargs["refresh_db"]
@@ -2726,7 +2782,10 @@ def mod_repo(repo, saltenv="base", aptkey=True, **kwargs):
                 'cannot parse "ppa:" style repo definitions: {}'.format(repo)
             )
 
-    sources = SourcesList()
+    if HAS_DEB822:
+        sources = SourcesList(deb822=True)
+    else:
+        sources = SourcesList()
     if kwargs.get("consolidate", False):
         # attempt to de-dup and consolidate all sources
         # down to entries in sources.list
@@ -2743,11 +2802,14 @@ def mod_repo(repo, saltenv="base", aptkey=True, **kwargs):
 
     repos = []
     for source in sources:
-        if HAS_APT:
+        if HAS_APT and not HAS_DEB822:
             _, invalid, _, _ = _invalid(source.line)
             if not invalid:
                 repos.append(source)
         else:
+            if HAS_DEB822 and source.types == [""]:
+                # most probably invalid or comment line
+                continue
             repos.append(source)
 
     mod_source = None
@@ -2906,10 +2968,11 @@ def mod_repo(repo, saltenv="base", aptkey=True, **kwargs):
         # and the resulting source line.  The idea here is to ensure
         # we are not returning bogus data because the source line
         # has already been modified on a previous run.
+        apt_source_dists = apt_source.dist.split(" ")
         repo_matches = (
             apt_source.type == repo_type
             and apt_source.uri.rstrip("/") == repo_uri.rstrip("/")
-            and apt_source.dist == repo_dist
+            and repo_dist in apt_source_dists
         )
         kw_matches = apt_source.dist == kw_dist and apt_source.type == kw_type
 
@@ -2928,7 +2991,18 @@ def mod_repo(repo, saltenv="base", aptkey=True, **kwargs):
         kwargs["comments"] = salt.utils.pkg.deb.combine_comments(kwargs["comments"])
 
     if not mod_source:
-        mod_source = SourceEntry(repo)
+        if HAS_DEB822:
+            apt_source_file = kwargs.get("file")
+            section = _deb822.Section("")
+            section["Types"] = repo_type
+            section["URIs"] = repo_uri
+            section["Suites"] = repo_dist
+            section["Components"] = " ".join(repo_comps)
+            if kwargs.get("trusted") == True or kwargs.get("Trusted") == True:
+                section["Trusted"] = "yes"
+            mod_source = Deb822SourceEntry(section, apt_source_file)
+        else:
+            mod_source = SourceEntry(repo)
         if "comments" in kwargs:
             mod_source.comment = kwargs["comments"]
         sources.list.append(mod_source)
@@ -2950,7 +3024,8 @@ def mod_repo(repo, saltenv="base", aptkey=True, **kwargs):
 
     if mod_source.uri != repo_uri:
         mod_source.uri = repo_uri
-        mod_source.line = mod_source.str()
+        if not HAS_DEB822:
+            mod_source.line = mod_source.str()
 
     sources.save()
     # on changes, explicitly refresh
@@ -2962,15 +3037,20 @@ def mod_repo(repo, saltenv="base", aptkey=True, **kwargs):
     else:
         signedby = _get_opts(repo)["signedby"].get("value", "")
 
+    repo_source_line = mod_source.line
+    if "Types: " in repo_source_line and "\n" in repo_source_line:
+        repo_source_line = f"{mod_source.type} {mod_source.uri} {repo_dist} {' '.join(mod_source.comps)}"
+
     return {
         repo: {
             "architectures": getattr(mod_source, "architectures", []),
+            "dist": mod_source.dist,
             "comps": mod_source.comps,
             "disabled": mod_source.disabled,
             "file": mod_source.file,
             "type": mod_source.type,
             "uri": mod_source.uri,
-            "line": mod_source.line,
+            "line": repo_source_line,
             "signedby": signedby,
         }
     }
@@ -3055,7 +3135,10 @@ def _expand_repo_def(os_name, os_codename=None, **kwargs):
         if kwarg in kwargs:
             setattr(source_entry, kwarg, kwargs[kwarg])
 
-    source_list = SourcesList()
+    if HAS_DEB822:
+        source_list = SourcesList(deb822=True)
+    else:
+        source_list = SourcesList()
     kwargs = {}
     if not HAS_APT:
         signedby = source_entry.signedby
@@ -3083,7 +3166,7 @@ def _expand_repo_def(os_name, os_codename=None, **kwargs):
     sanitized["dist"] = _source_entry.dist
     sanitized["type"] = _source_entry.type
     sanitized["uri"] = _source_entry.uri
-    sanitized["line"] = _source_entry.line.strip()
+    sanitized["line"] = getattr(_source_entry, "line", "").strip()
     sanitized["architectures"] = getattr(_source_entry, "architectures", [])
     sanitized["signedby"] = signedby
     if HAS_APT and signedby:
diff --git a/tests/pytests/unit/modules/test_aptpkg.py b/tests/pytests/unit/modules/test_aptpkg.py
index 6f0b905ef7..4975a78c38 100644
--- a/tests/pytests/unit/modules/test_aptpkg.py
+++ b/tests/pytests/unit/modules/test_aptpkg.py
@@ -42,6 +42,25 @@ try:
 except ImportError:
     HAS_APTSOURCES = False
 
+HAS_DEB822 = False
+
+if HAS_APT:
+    try:
+        from aptsources.sourceslist import Deb822SourceEntry, _deb822 # pylint: disable=unused-import
+
+        HAS_DEB822 = True
+    except ImportError:
+        pass
+
+HAS_APT_PKG = False
+
+try:
+    import apt_pkg
+
+    HAS_APT_PKG = True
+except ImportError:
+    pass
+
 log = logging.getLogger(__name__)
 
 
@@ -216,6 +235,8 @@ class MockSourceEntry:
         self.comps = []
         self.architectures = []
         self.signedby = ""
+        if HAS_DEB822:
+            self.types = []
 
     def mysplit(self, line):
         return line.split()
@@ -237,6 +258,107 @@ def configure_loader_modules():
     return {aptpkg: {"__grains__": {}}}
 
 
+@pytest.fixture
+def deb822_repo_content():
+    return """
+Types: deb
+URIs: http://cz.archive.ubuntu.com/ubuntu/
+Suites: noble noble-updates noble-backports
+Components: main
+Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
+"""
+
+
+@pytest.fixture
+def deb822_repo_file(tmp_path: pathlib.Path, deb822_repo_content: str):
+    """
+    Create a Debian-style repository in the deb822 format and return
+    the path of the repository file.
+    """
+    repo = tmp_path / "sources.list.d" / "test.sources"
+    repo.parent.mkdir(parents=True, exist_ok=True)
+    repo.write_text(deb822_repo_content.strip(), encoding="UTF-8")
+    return repo
+
+
+@pytest.fixture
+def mock_apt_config(deb822_repo_file: pathlib.Path):
+    """
+    Mocking common to deb822 testing so that apt_pkg uses the
+    tmp_path/sources.list.d as the Dir::Etc::sourceparts location
+    """
+    with patch.dict(
+        aptpkg.__salt__,
+        {"config.option": MagicMock()},
+    ), patch.object(apt_pkg, "config") as mock_config:
+        mock_config.find_file.return_value = "/etc/apt/sources.list"
+        mock_config.find_dir.return_value = os.path.dirname(str(deb822_repo_file))
+        yield mock_config
+
+
+@pytest.mark.skipif(not HAS_DEB822, reason="Requires deb822 support")
+@pytest.mark.skipif(not HAS_APT_PKG, reason="Requires debian/ubuntu apt_pkg system library")
+def test_mod_repo_deb822_modify(deb822_repo_file: pathlib.Path, mock_apt_config):
+    """
+    Test that aptpkg can modify an existing repository in the deb822 format.
+    In this test, we match the repository by name and disable it.
+    """
+    uri = "http://cz.archive.ubuntu.com/ubuntu/"
+    repo = f"deb {uri} noble main"
+
+    aptpkg.mod_repo(repo, enabled=False, file=str(deb822_repo_file), refresh_db=False)
+
+    repo_file = deb822_repo_file.read_text(encoding="UTF-8")
+    assert "Enabled: no" in repo_file
+    assert f"URIs: {uri}" in repo_file
+
+
+@pytest.mark.skipif(not HAS_DEB822, reason="Requires deb822 support")
+@pytest.mark.skipif(not HAS_APT_PKG, reason="Requires debian/ubuntu apt_pkg system library")
+def test_mod_repo_deb822_add(deb822_repo_file: pathlib.Path, mock_apt_config):
+    """
+    Test that aptpkg can add a repository in the deb822 format.
+    """
+    uri = "http://security.ubuntu.com/ubuntu/"
+    repo = f"deb {uri} noble-security main"
+
+    aptpkg.mod_repo(repo, file=str(deb822_repo_file), refresh_db=False)
+
+    repo_file = deb822_repo_file.read_text(encoding="UTF-8")
+    assert f"URIs: {uri}" in repo_file
+    assert "URIs: http://cz.archive.ubuntu.com/ubuntu/" in repo_file
+
+
+@pytest.mark.skipif(not HAS_DEB822, reason="Requires deb822 support")
+@pytest.mark.skipif(not HAS_APT_PKG, reason="Requires debian/ubuntu apt_pkg system library")
+def test_del_repo_deb822(deb822_repo_file: pathlib.Path, mock_apt_config):
+    """
+    Test that aptpkg can delete a repository in the deb822 format.
+    """
+    uri = "http://cz.archive.ubuntu.com/ubuntu/"
+    repo = f"deb {uri} noble main"
+
+    with patch.object(aptpkg, "refresh_db"):
+        aptpkg.del_repo(repo, file=str(deb822_repo_file))
+
+    assert not os.path.isfile(str(deb822_repo_file))
+
+
+@pytest.mark.skipif(not HAS_DEB822, reason="Requires deb822 support")
+@pytest.mark.skipif(not HAS_APT_PKG, reason="Requires debian/ubuntu apt_pkg system library")
+def test_get_repo_deb822(deb822_repo_file: pathlib.Path, mock_apt_config):
+    """
+    Test that aptpkg can match a repository in the deb822 format.
+    """
+    uri = "http://cz.archive.ubuntu.com/ubuntu/"
+    repo = f"deb {uri} noble main"
+
+    result = aptpkg.get_repo(repo)
+
+    assert bool(result)
+    assert result["uri"] == uri
+
+
 def test_version(lowpkg_info_var):
     """
     Test - Returns a string representing the package version or an empty string if
-- 
2.48.1

openSUSE Build Service is sponsored by