mirror of
https://github.com/d9pouces/id_configurator.git
synced 2026-11-09 01:47:21 +01:00
feat: enhance repository configuration to support url_to_replace patterns for disabling existing entries
Signed-off-by: d9pouces <github@19pouces.net>
This commit is contained in:
@@ -46,6 +46,10 @@ class AptSource(BaseModel):
|
|||||||
- `signed_by_file`: the *path* to a keyring already expected to exist on
|
- `signed_by_file`: the *path* to a keyring already expected to exist on
|
||||||
the target system, written to the rendered `Signed-By` field verbatim.
|
the target system, written to the rendered `Signed-By` field verbatim.
|
||||||
If both are set, `signed_by` takes precedence.
|
If both are set, `signed_by` takes precedence.
|
||||||
|
|
||||||
|
`uri_to_replace`, if set, is an fnmatch pattern: this source is only
|
||||||
|
applied if it matches at least one URI already present in the machine's
|
||||||
|
existing apt configuration; otherwise it is skipped entirely.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
@@ -75,6 +79,7 @@ class AptSource(BaseModel):
|
|||||||
check_date: bool | None = Field(default=None, alias="check-date")
|
check_date: bool | None = Field(default=None, alias="check-date")
|
||||||
date_max_future: str | None = Field(default=None, alias="date-max-future")
|
date_max_future: str | None = Field(default=None, alias="date-max-future")
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
uri_to_replace: str | None = Field(default=None, alias="uri-to-replace")
|
||||||
|
|
||||||
def to_deb822(
|
def to_deb822(
|
||||||
self,
|
self,
|
||||||
@@ -143,11 +148,17 @@ class AptConfig(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ApkRepository(BaseModel):
|
class ApkRepository(BaseModel):
|
||||||
"""An Alpine APK repository (see /etc/apk/repositories), e.g. a URL ending in /main or /community."""
|
"""An Alpine APK repository (see /etc/apk/repositories), e.g. a URL ending in /main or /community.
|
||||||
|
|
||||||
|
`url_to_replace`, if set, is an fnmatch pattern: this repository is only
|
||||||
|
applied if it matches at least one URL already present in the machine's
|
||||||
|
existing apk configuration; otherwise it is skipped entirely.
|
||||||
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
url: str
|
url: str
|
||||||
key: str | None = None
|
key: str | None = None
|
||||||
|
url_to_replace: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ApkConfig(BaseModel):
|
class ApkConfig(BaseModel):
|
||||||
@@ -155,10 +166,19 @@ class ApkConfig(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class YumRepository(BaseModel):
|
class YumRepository(BaseModel):
|
||||||
|
"""A YUM/DNF repository (see /etc/yum.repos.d/*.repo).
|
||||||
|
|
||||||
|
`index_url_to_replace`, if set, is an fnmatch pattern: this repository is
|
||||||
|
only applied if it matches at least one baseurl already present in the
|
||||||
|
machine's existing yum/dnf configuration; otherwise it is skipped
|
||||||
|
entirely.
|
||||||
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
name: str
|
name: str
|
||||||
index_url: str = Field(alias="index-url")
|
index_url: str = Field(alias="index-url")
|
||||||
gpg_url: str | None = Field(default=None, alias="gpg-url")
|
gpg_url: str | None = Field(default=None, alias="gpg-url")
|
||||||
|
index_url_to_replace: str | None = Field(default=None, alias="index-url-to-replace")
|
||||||
|
|
||||||
|
|
||||||
class YumConfig(BaseModel):
|
class YumConfig(BaseModel):
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
"""Detect existing APK (Alpine) repositories and configure new ones.
|
"""Detect existing APK (Alpine) repositories and configure new ones.
|
||||||
|
|
||||||
If `<src>/etc/apk` exists, the existing `/etc/apk/repositories` file (if any)
|
If `<src>/etc/apk` exists, the existing `/etc/apk/repositories` file (if any)
|
||||||
is copied into `<dst>` with every active line disabled (commented out), and
|
is copied into `<dst>`, and the repositories declared in the configuration
|
||||||
the repositories declared in the configuration are appended as new, active
|
are appended as new, active lines. An existing active line is only disabled
|
||||||
lines. Public keys provided alongside a repository are written to
|
(commented out) if it matches the `url_to_replace` pattern of one of the
|
||||||
|
configured repositories; lines matching no such pattern are copied through
|
||||||
|
unchanged. Public keys provided alongside a repository are written to
|
||||||
`<dst>/etc/apk/keys/`, named after their content so that repositories
|
`<dst>/etc/apk/keys/`, named after their content so that repositories
|
||||||
sharing the same key (a common case for official Alpine mirrors) only
|
sharing the same key (a common case for official Alpine mirrors) only
|
||||||
produce a single key file.
|
produce a single key file.
|
||||||
|
|
||||||
|
A repository's `url_to_replace`, if set, is an fnmatch pattern checked
|
||||||
|
against the URLs already active in /etc/apk/repositories: the repository
|
||||||
|
is only written out if the pattern matches at least one of them, and
|
||||||
|
skipped entirely otherwise.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fnmatch
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from id_configurator.context import Context
|
from id_configurator.context import Context
|
||||||
@@ -25,11 +34,18 @@ _REPOSITORIES = "etc/apk/repositories"
|
|||||||
_KEYS_DIR = "etc/apk/keys"
|
_KEYS_DIR = "etc/apk/keys"
|
||||||
|
|
||||||
|
|
||||||
def _disable_repositories_content(content: str) -> str:
|
def _disable_repositories_content(content: str, patterns: Sequence[str]) -> str:
|
||||||
lines = [
|
lines = []
|
||||||
line if not line.strip() or line.strip().startswith("#") else f"# {line}"
|
for line in content.splitlines():
|
||||||
for line in content.splitlines()
|
stripped = line.strip()
|
||||||
]
|
if (
|
||||||
|
stripped
|
||||||
|
and not stripped.startswith("#")
|
||||||
|
and any(fnmatch.fnmatch(stripped, pattern) for pattern in patterns)
|
||||||
|
):
|
||||||
|
lines.append(f"# {line}")
|
||||||
|
else:
|
||||||
|
lines.append(line)
|
||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
@@ -37,6 +53,19 @@ def _key_filename(content: bytes) -> str:
|
|||||||
return f"{hashlib.sha256(content).hexdigest()[:16]}.rsa.pub"
|
return f"{hashlib.sha256(content).hexdigest()[:16]}.rsa.pub"
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_urls(context: Context) -> set[str]:
|
||||||
|
"""Collect the URLs already declared in /etc/apk/repositories on the
|
||||||
|
source filesystem (one active, non-comment line per URL)."""
|
||||||
|
existing = context.read_src_file(_REPOSITORIES)
|
||||||
|
if existing is None:
|
||||||
|
return set()
|
||||||
|
return {
|
||||||
|
stripped
|
||||||
|
for line in existing.decode("utf-8").splitlines()
|
||||||
|
if (stripped := line.strip()) and not stripped.startswith("#")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@register
|
@register
|
||||||
class ApkModule(Module):
|
class ApkModule(Module):
|
||||||
name = "apk"
|
name = "apk"
|
||||||
@@ -52,24 +81,46 @@ class ApkModule(Module):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def apply(self, context: Context) -> None:
|
def apply(self, context: Context) -> None:
|
||||||
disabled = self._disabled_existing_repositories(context)
|
patterns = [
|
||||||
|
repository.url_to_replace
|
||||||
|
for repository in context.config.configurations.apk.repositories
|
||||||
|
if repository.url_to_replace is not None
|
||||||
|
]
|
||||||
|
disabled = self._disabled_existing_repositories(context, patterns)
|
||||||
configured = self._configured_repositories_lines(context)
|
configured = self._configured_repositories_lines(context)
|
||||||
content = disabled + ("\n".join(configured) + "\n" if configured else "")
|
content = disabled + ("\n".join(configured) + "\n" if configured else "")
|
||||||
if content:
|
if content:
|
||||||
context.write_file(_REPOSITORIES, content.encode("utf-8"))
|
context.write_file(_REPOSITORIES, content.encode("utf-8"))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _disabled_existing_repositories(context: Context) -> str:
|
def _disabled_existing_repositories(
|
||||||
|
context: Context, patterns: Sequence[str]
|
||||||
|
) -> str:
|
||||||
existing = context.read_src_file(_REPOSITORIES)
|
existing = context.read_src_file(_REPOSITORIES)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
return ""
|
return ""
|
||||||
logger.info("disabled existing apk repositories %s", _REPOSITORIES)
|
logger.info("copied existing apk repositories %s", _REPOSITORIES)
|
||||||
return _disable_repositories_content(existing.decode("utf-8"))
|
return _disable_repositories_content(existing.decode("utf-8"), patterns)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _configured_repositories_lines(context: Context) -> list[str]:
|
def _configured_repositories_lines(context: Context) -> list[str]:
|
||||||
lines = []
|
lines = []
|
||||||
|
existing_urls: set[str] | None = None
|
||||||
for repository in context.config.configurations.apk.repositories:
|
for repository in context.config.configurations.apk.repositories:
|
||||||
|
if repository.url_to_replace is not None:
|
||||||
|
if existing_urls is None:
|
||||||
|
existing_urls = _existing_urls(context)
|
||||||
|
if not any(
|
||||||
|
fnmatch.fnmatch(url, repository.url_to_replace)
|
||||||
|
for url in existing_urls
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"skipped apk repository %s: url-to-replace %s matched "
|
||||||
|
"no existing url",
|
||||||
|
repository.name,
|
||||||
|
repository.url_to_replace,
|
||||||
|
)
|
||||||
|
continue
|
||||||
lines.append(repository.url)
|
lines.append(repository.url)
|
||||||
logger.info("added apk repository %s", repository.name)
|
logger.info("added apk repository %s", repository.name)
|
||||||
if repository.key:
|
if repository.key:
|
||||||
|
|||||||
+133
-21
@@ -2,9 +2,13 @@
|
|||||||
|
|
||||||
If `<src>/etc/apt` exists, any source file already present there (classic
|
If `<src>/etc/apt` exists, any source file already present there (classic
|
||||||
one-line `.list` files or DEB822 `.sources` files, whether at the top level
|
one-line `.list` files or DEB822 `.sources` files, whether at the top level
|
||||||
or under `sources.list.d/`) is copied into `<dst>/etc/apt`, disabled so it no
|
or under `sources.list.d/`) is copied into `<dst>/etc/apt`, and the sources
|
||||||
longer takes effect, and the sources declared in the configuration are added
|
declared in the configuration are added as new, enabled `.sources` files.
|
||||||
as new, enabled `.sources` files.
|
|
||||||
|
An existing entry (a single `deb`/`deb-src` line, or a DEB822 stanza) is only
|
||||||
|
disabled if its URI matches the `uri_to_replace` pattern of one of the
|
||||||
|
configured sources; entries matching no such pattern are copied through
|
||||||
|
unchanged, still enabled.
|
||||||
|
|
||||||
Each source can be trusted two ways: `signed_by` holds the ASCII-armored
|
Each source can be trusted two ways: `signed_by` holds the ASCII-armored
|
||||||
content of a GPG public key, folded inline as a multi-line `Signed-By`
|
content of a GPG public key, folded inline as a multi-line `Signed-By`
|
||||||
@@ -13,11 +17,18 @@ genuinely signed repository, so no `gpg` binary or separate keyring file
|
|||||||
is needed at all to apply this configuration). `signed_by_file` instead
|
is needed at all to apply this configuration). `signed_by_file` instead
|
||||||
names a keyring already expected to exist on the target system, used
|
names a keyring already expected to exist on the target system, used
|
||||||
verbatim as `Signed-By`; if both are given, `signed_by` takes precedence.
|
verbatim as `Signed-By`; if both are given, `signed_by` takes precedence.
|
||||||
|
|
||||||
|
A source's `uri_to_replace`, if set, is an fnmatch pattern checked against
|
||||||
|
the URIs of the sources already present on the source filesystem: the
|
||||||
|
source is only written out if the pattern matches at least one of them,
|
||||||
|
and skipped entirely otherwise - even on an otherwise Debian-like machine.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fnmatch
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from id_configurator.context import Context
|
from id_configurator.context import Context
|
||||||
@@ -30,24 +41,100 @@ _SOURCES_LIST = "etc/apt/sources.list"
|
|||||||
_SOURCES_LIST_D = "etc/apt/sources.list.d"
|
_SOURCES_LIST_D = "etc/apt/sources.list.d"
|
||||||
|
|
||||||
|
|
||||||
def _disable_list_content(content: str) -> str:
|
def _uri_from_deb_line(stripped_line: str) -> str | None:
|
||||||
lines = [
|
"""Extract the URI from a single, already-stripped `deb`/`deb-src`
|
||||||
line if not line.strip() or line.strip().startswith("#") else f"# {line}"
|
line (`deb [options] uri suite [component...]`), or `None` if the line
|
||||||
for line in content.splitlines()
|
is not such a line."""
|
||||||
]
|
parts = stripped_line.split()
|
||||||
|
if not parts or parts[0] not in ("deb", "deb-src"):
|
||||||
|
return None
|
||||||
|
parts = parts[1:]
|
||||||
|
while parts and parts[0].startswith("["):
|
||||||
|
while parts and not parts[0].endswith("]"):
|
||||||
|
parts.pop(0)
|
||||||
|
if parts:
|
||||||
|
parts.pop(0)
|
||||||
|
return parts[0] if parts else None
|
||||||
|
|
||||||
|
|
||||||
|
def _disable_list_content(content: str, patterns: Sequence[str]) -> str:
|
||||||
|
lines = []
|
||||||
|
for line in content.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
uri = (
|
||||||
|
None
|
||||||
|
if not stripped or stripped.startswith("#")
|
||||||
|
else _uri_from_deb_line(stripped)
|
||||||
|
)
|
||||||
|
if uri is not None and any(
|
||||||
|
fnmatch.fnmatch(uri, pattern) for pattern in patterns
|
||||||
|
):
|
||||||
|
lines.append(f"# {line}")
|
||||||
|
else:
|
||||||
|
lines.append(line)
|
||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
def _disable_deb822_content(content: str) -> str:
|
def _uris_from_deb822_stanza(stanza: str) -> set[str]:
|
||||||
disabled_stanzas = []
|
uris: set[str] = set()
|
||||||
|
for line in stanza.splitlines():
|
||||||
|
if line.lower().startswith("uris:"):
|
||||||
|
uris.update(line.split(":", 1)[1].split())
|
||||||
|
return uris
|
||||||
|
|
||||||
|
|
||||||
|
def _disable_deb822_content(content: str, patterns: Sequence[str]) -> str:
|
||||||
|
stanzas = []
|
||||||
for stanza in content.split("\n\n"):
|
for stanza in content.split("\n\n"):
|
||||||
if not stanza.strip():
|
if not stanza.strip():
|
||||||
continue
|
continue
|
||||||
lines = stanza.splitlines()
|
lines = stanza.splitlines()
|
||||||
new_lines = [line for line in lines if not line.lower().startswith("enabled:")]
|
uris = _uris_from_deb822_stanza(stanza)
|
||||||
new_lines.append("Enabled: no")
|
if any(fnmatch.fnmatch(uri, pattern) for uri in uris for pattern in patterns):
|
||||||
disabled_stanzas.append("\n".join(new_lines))
|
lines = [line for line in lines if not line.lower().startswith("enabled:")]
|
||||||
return "\n\n".join(disabled_stanzas) + "\n"
|
lines.append("Enabled: no")
|
||||||
|
stanzas.append("\n".join(lines))
|
||||||
|
return "\n\n".join(stanzas) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _uris_from_list_content(content: str) -> set[str]:
|
||||||
|
"""Extract the URIs declared in a classic one-line `.list` file
|
||||||
|
(`deb [options] uri suite [component...]`), skipping comments and any
|
||||||
|
bracketed option list."""
|
||||||
|
uris: set[str] = set()
|
||||||
|
for line in content.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
uri = _uri_from_deb_line(stripped)
|
||||||
|
if uri is not None:
|
||||||
|
uris.add(uri)
|
||||||
|
return uris
|
||||||
|
|
||||||
|
|
||||||
|
def _uris_from_deb822_content(content: str) -> set[str]:
|
||||||
|
"""Extract the URIs declared in the `URIs:` field of each stanza of a
|
||||||
|
DEB822 `.sources` file."""
|
||||||
|
uris: set[str] = set()
|
||||||
|
for stanza in content.split("\n\n"):
|
||||||
|
uris.update(_uris_from_deb822_stanza(stanza))
|
||||||
|
return uris
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_uris(context: Context) -> set[str]:
|
||||||
|
"""Collect every URI declared by the apt sources already present on the
|
||||||
|
source filesystem, whether in the top-level sources.list or under
|
||||||
|
sources.list.d/, in either the classic or the DEB822 format."""
|
||||||
|
uris: set[str] = set()
|
||||||
|
existing_top_level = context.read_src_file(_SOURCES_LIST)
|
||||||
|
if existing_top_level is not None:
|
||||||
|
uris.update(_uris_from_list_content(existing_top_level.decode("utf-8")))
|
||||||
|
for entry in context.iter_src_dir(_SOURCES_LIST_D):
|
||||||
|
if entry.suffix == ".sources":
|
||||||
|
uris.update(_uris_from_deb822_content(entry.read_text(encoding="utf-8")))
|
||||||
|
elif entry.suffix == ".list":
|
||||||
|
uris.update(_uris_from_list_content(entry.read_text(encoding="utf-8")))
|
||||||
|
return uris
|
||||||
|
|
||||||
|
|
||||||
@register
|
@register
|
||||||
@@ -65,32 +152,57 @@ class AptModule(Module):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def apply(self, context: Context) -> None:
|
def apply(self, context: Context) -> None:
|
||||||
self._copy_existing_disabled(context)
|
patterns = [
|
||||||
|
source.uri_to_replace
|
||||||
|
for source in context.config.configurations.apt.sources
|
||||||
|
if source.uri_to_replace is not None
|
||||||
|
]
|
||||||
|
self._copy_existing_disabled(context, patterns)
|
||||||
self._write_configured_sources(context)
|
self._write_configured_sources(context)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _copy_existing_disabled(context: Context) -> None:
|
def _copy_existing_disabled(context: Context, patterns: Sequence[str]) -> None:
|
||||||
existing_top_level = context.read_src_file(_SOURCES_LIST)
|
existing_top_level = context.read_src_file(_SOURCES_LIST)
|
||||||
if existing_top_level is not None:
|
if existing_top_level is not None:
|
||||||
disabled = _disable_list_content(existing_top_level.decode("utf-8"))
|
disabled = _disable_list_content(
|
||||||
|
existing_top_level.decode("utf-8"), patterns
|
||||||
|
)
|
||||||
context.write_file(_SOURCES_LIST, disabled.encode("utf-8"))
|
context.write_file(_SOURCES_LIST, disabled.encode("utf-8"))
|
||||||
logger.info("disabled existing apt source list %s", _SOURCES_LIST)
|
logger.info("copied existing apt source list %s", _SOURCES_LIST)
|
||||||
|
|
||||||
for entry in context.iter_src_dir(_SOURCES_LIST_D):
|
for entry in context.iter_src_dir(_SOURCES_LIST_D):
|
||||||
if entry.suffix == ".sources":
|
if entry.suffix == ".sources":
|
||||||
disabled = _disable_deb822_content(entry.read_text(encoding="utf-8"))
|
disabled = _disable_deb822_content(
|
||||||
|
entry.read_text(encoding="utf-8"), patterns
|
||||||
|
)
|
||||||
elif entry.suffix == ".list":
|
elif entry.suffix == ".list":
|
||||||
disabled = _disable_list_content(entry.read_text(encoding="utf-8"))
|
disabled = _disable_list_content(
|
||||||
|
entry.read_text(encoding="utf-8"), patterns
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
context.write_file(
|
context.write_file(
|
||||||
f"{_SOURCES_LIST_D}/{entry.name}", disabled.encode("utf-8")
|
f"{_SOURCES_LIST_D}/{entry.name}", disabled.encode("utf-8")
|
||||||
)
|
)
|
||||||
logger.info("disabled existing apt source %s", entry.name)
|
logger.info("copied existing apt source %s", entry.name)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _write_configured_sources(context: Context) -> None:
|
def _write_configured_sources(context: Context) -> None:
|
||||||
|
existing_uris: set[str] | None = None
|
||||||
for source in context.config.configurations.apt.sources:
|
for source in context.config.configurations.apt.sources:
|
||||||
|
if source.uri_to_replace is not None:
|
||||||
|
if existing_uris is None:
|
||||||
|
existing_uris = _existing_uris(context)
|
||||||
|
if not any(
|
||||||
|
fnmatch.fnmatch(uri, source.uri_to_replace) for uri in existing_uris
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"skipped apt source %s: uri-to-replace %s matched no "
|
||||||
|
"existing uri",
|
||||||
|
source.name,
|
||||||
|
source.uri_to_replace,
|
||||||
|
)
|
||||||
|
continue
|
||||||
context.write_file(
|
context.write_file(
|
||||||
f"{_SOURCES_LIST_D}/{sanitize_filename(source.name)}.sources",
|
f"{_SOURCES_LIST_D}/{sanitize_filename(source.name)}.sources",
|
||||||
source.to_deb822(
|
source.to_deb822(
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ DNF is a drop-in successor to YUM and reads the exact same `.repo` files
|
|||||||
under `/etc/yum.repos.d/`, so a single module covers both.
|
under `/etc/yum.repos.d/`, so a single module covers both.
|
||||||
|
|
||||||
If `<src>/etc/yum.repos.d` exists, every `.repo` file already there is
|
If `<src>/etc/yum.repos.d` exists, every `.repo` file already there is
|
||||||
copied into `<dst>` with all of its sections disabled (`enabled=0`), before
|
copied into `<dst>`, before the repositories declared in the configuration
|
||||||
the repositories declared in the configuration are written as new, enabled
|
are written as new, enabled `.repo` files (so a configured repository
|
||||||
`.repo` files (so a configured repository reusing an existing file name
|
reusing an existing file name ends up enabled, taking precedence over the
|
||||||
ends up enabled, taking precedence over the disabled copy). Each `.repo`
|
copied one). A section of an existing `.repo` file is only disabled
|
||||||
|
(`enabled=0`) if its `baseurl`/`mirrorlist` matches the
|
||||||
|
`index_url_to_replace` pattern of one of the configured repositories;
|
||||||
|
sections matching no such pattern are copied through unchanged. Each `.repo`
|
||||||
file is written referencing its `gpg-url` (used verbatim as `gpgkey=`,
|
file is written referencing its `gpg-url` (used verbatim as `gpgkey=`,
|
||||||
exactly as YUM/DNF expect: `https://`, or a `file://` path already present
|
exactly as YUM/DNF expect: `https://`, or a `file://` path already present
|
||||||
on the target system). Separately, each GPG key declared in
|
on the target system). Separately, each GPG key declared in
|
||||||
@@ -17,15 +20,22 @@ conventional location such keys live at on RPM-based systems. `gpg` is only
|
|||||||
used here to validate the key; the actual trust decision is left to
|
used here to validate the key; the actual trust decision is left to
|
||||||
YUM/DNF, which import it from that file the first time the repository is
|
YUM/DNF, which import it from that file the first time the repository is
|
||||||
used (as `rpm --import` would).
|
used (as `rpm --import` would).
|
||||||
|
|
||||||
|
A repository's `index_url_to_replace`, if set, is an fnmatch pattern
|
||||||
|
checked against the baseurl of every section of every existing `.repo`
|
||||||
|
file: the repository is only written out if the pattern matches at least
|
||||||
|
one of them, and skipped entirely otherwise.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import configparser
|
import configparser
|
||||||
|
import fnmatch
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from collections.abc import Sequence
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
|
||||||
from id_configurator.config import NamedContent, YumRepository
|
from id_configurator.config import NamedContent, YumRepository
|
||||||
@@ -38,24 +48,46 @@ _REPOS_DIR = "etc/yum.repos.d"
|
|||||||
_RPM_GPG_DIR = "etc/pki/rpm-gpg"
|
_RPM_GPG_DIR = "etc/pki/rpm-gpg"
|
||||||
|
|
||||||
|
|
||||||
def _disable_repo_content(content: str) -> str:
|
def _disable_repo_content(content: str, patterns: Sequence[str]) -> str:
|
||||||
parser = configparser.ConfigParser(interpolation=None)
|
parser = configparser.ConfigParser(interpolation=None)
|
||||||
parser.optionxform = str
|
parser.optionxform = str
|
||||||
parser.read_string(content)
|
parser.read_string(content)
|
||||||
for section in parser.sections():
|
for section in parser.sections():
|
||||||
parser[section]["enabled"] = "0"
|
urls = [parser[section].get(key) for key in ("baseurl", "mirrorlist")]
|
||||||
|
urls = [url for url in urls if url]
|
||||||
|
if any(fnmatch.fnmatch(url, pattern) for url in urls for pattern in patterns):
|
||||||
|
parser[section]["enabled"] = "0"
|
||||||
buffer = StringIO()
|
buffer = StringIO()
|
||||||
parser.write(buffer, space_around_delimiters=False)
|
parser.write(buffer, space_around_delimiters=False)
|
||||||
return buffer.getvalue()
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def _disable_existing_repos(context: Context) -> None:
|
def _existing_index_urls(context: Context) -> set[str]:
|
||||||
|
"""Collect the baseurl of every section of every .repo file already
|
||||||
|
present on the source filesystem."""
|
||||||
|
urls: set[str] = set()
|
||||||
for entry in context.iter_src_dir(_REPOS_DIR):
|
for entry in context.iter_src_dir(_REPOS_DIR):
|
||||||
if entry.suffix != ".repo":
|
if entry.suffix != ".repo":
|
||||||
continue
|
continue
|
||||||
disabled = _disable_repo_content(entry.read_text(encoding="utf-8"))
|
parser = configparser.ConfigParser(interpolation=None)
|
||||||
|
parser.optionxform = str
|
||||||
|
parser.read_string(entry.read_text(encoding="utf-8"))
|
||||||
|
for section in parser.sections():
|
||||||
|
# noinspection SpellCheckingInspection
|
||||||
|
for key in ("baseurl", "mirrorlist"):
|
||||||
|
url = parser[section].get(key)
|
||||||
|
if url:
|
||||||
|
urls.add(url)
|
||||||
|
return urls
|
||||||
|
|
||||||
|
|
||||||
|
def _disable_existing_repos(context: Context, patterns: Sequence[str]) -> None:
|
||||||
|
for entry in context.iter_src_dir(_REPOS_DIR):
|
||||||
|
if entry.suffix != ".repo":
|
||||||
|
continue
|
||||||
|
disabled = _disable_repo_content(entry.read_text(encoding="utf-8"), patterns)
|
||||||
context.write_file(f"{_REPOS_DIR}/{entry.name}", disabled.encode("utf-8"))
|
context.write_file(f"{_REPOS_DIR}/{entry.name}", disabled.encode("utf-8"))
|
||||||
logger.info("disabled existing yum/dnf repository file %s", entry.name)
|
logger.info("copied existing yum/dnf repository file %s", entry.name)
|
||||||
|
|
||||||
|
|
||||||
def _write_repo_file(context: Context, repository: YumRepository) -> None:
|
def _write_repo_file(context: Context, repository: YumRepository) -> None:
|
||||||
@@ -120,8 +152,28 @@ class YumModule(Module):
|
|||||||
return bool(yum.repositories) or bool(yum.keys) or any(existing_repo_files)
|
return bool(yum.repositories) or bool(yum.keys) or any(existing_repo_files)
|
||||||
|
|
||||||
def apply(self, context: Context) -> None:
|
def apply(self, context: Context) -> None:
|
||||||
_disable_existing_repos(context)
|
patterns = [
|
||||||
|
repository.index_url_to_replace
|
||||||
|
for repository in context.config.configurations.yum.repositories
|
||||||
|
if repository.index_url_to_replace is not None
|
||||||
|
]
|
||||||
|
_disable_existing_repos(context, patterns)
|
||||||
|
existing_index_urls: set[str] | None = None
|
||||||
for repository in context.config.configurations.yum.repositories:
|
for repository in context.config.configurations.yum.repositories:
|
||||||
|
if repository.index_url_to_replace is not None:
|
||||||
|
if existing_index_urls is None:
|
||||||
|
existing_index_urls: set[str] = _existing_index_urls(context)
|
||||||
|
if not any(
|
||||||
|
fnmatch.fnmatch(url, repository.index_url_to_replace)
|
||||||
|
for url in existing_index_urls
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"skipped yum/dnf repository %s: index-url-to-replace %s "
|
||||||
|
"matched no existing baseurl",
|
||||||
|
repository.name,
|
||||||
|
repository.index_url_to_replace,
|
||||||
|
)
|
||||||
|
continue
|
||||||
_write_repo_file(context, repository)
|
_write_repo_file(context, repository)
|
||||||
for key in context.config.configurations.yum.keys:
|
for key in context.config.configurations.yum.keys:
|
||||||
_import_key(context, key)
|
_import_key(context, key)
|
||||||
|
|||||||
@@ -167,6 +167,13 @@ options:
|
|||||||
description:
|
description:
|
||||||
type: str
|
type: str
|
||||||
description: DEB822 "Description" field.
|
description: DEB822 "Description" field.
|
||||||
|
uri_to_replace:
|
||||||
|
type: str
|
||||||
|
description:
|
||||||
|
- fnmatch pattern checked against the URIs already present in
|
||||||
|
the machine's existing apt configuration. If set, this
|
||||||
|
source is only applied when the pattern matches at least
|
||||||
|
one of them; otherwise it is skipped entirely.
|
||||||
apk:
|
apk:
|
||||||
type: dict
|
type: dict
|
||||||
default: {}
|
default: {}
|
||||||
@@ -185,6 +192,13 @@ options:
|
|||||||
key:
|
key:
|
||||||
type: str
|
type: str
|
||||||
description: The signing key's name, if any (see NamedContent).
|
description: The signing key's name, if any (see NamedContent).
|
||||||
|
url_to_replace:
|
||||||
|
type: str
|
||||||
|
description:
|
||||||
|
- fnmatch pattern checked against the URLs already active in
|
||||||
|
the machine's existing /etc/apk/repositories. If set, this
|
||||||
|
repository is only applied when the pattern matches at
|
||||||
|
least one of them; otherwise it is skipped entirely.
|
||||||
yum:
|
yum:
|
||||||
type: dict
|
type: dict
|
||||||
default: {}
|
default: {}
|
||||||
@@ -207,6 +221,13 @@ options:
|
|||||||
gpg_url:
|
gpg_url:
|
||||||
type: str
|
type: str
|
||||||
description: The repository's gpgkey URL.
|
description: The repository's gpgkey URL.
|
||||||
|
index_url_to_replace:
|
||||||
|
type: str
|
||||||
|
description:
|
||||||
|
- fnmatch pattern checked against the baseurl of every
|
||||||
|
section of every existing .repo file. If set, this
|
||||||
|
repository is only applied when the pattern matches at
|
||||||
|
least one of them; otherwise it is skipped entirely.
|
||||||
keys:
|
keys:
|
||||||
type: list
|
type: list
|
||||||
elements: dict
|
elements: dict
|
||||||
@@ -491,6 +512,7 @@ ARGUMENT_SPEC = {
|
|||||||
"check_date": {"type": "bool"},
|
"check_date": {"type": "bool"},
|
||||||
"date_max_future": {"type": "str"},
|
"date_max_future": {"type": "str"},
|
||||||
"description": {"type": "str"},
|
"description": {"type": "str"},
|
||||||
|
"uri_to_replace": {"type": "str"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -506,6 +528,7 @@ ARGUMENT_SPEC = {
|
|||||||
"options": {
|
"options": {
|
||||||
"url": {"type": "str", "required": True},
|
"url": {"type": "str", "required": True},
|
||||||
"key": {"type": "str"},
|
"key": {"type": "str"},
|
||||||
|
"url_to_replace": {"type": "str"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -522,6 +545,7 @@ ARGUMENT_SPEC = {
|
|||||||
"name": {"type": "str", "required": True},
|
"name": {"type": "str", "required": True},
|
||||||
"index_url": {"type": "str", "required": True},
|
"index_url": {"type": "str", "required": True},
|
||||||
"gpg_url": {"type": "str"},
|
"gpg_url": {"type": "str"},
|
||||||
|
"index_url_to_replace": {"type": "str"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"keys": {
|
"keys": {
|
||||||
|
|||||||
+136
-3
@@ -48,7 +48,9 @@ def test_is_relevant_true_with_apk_dir(tmp_path) -> None:
|
|||||||
assert ApkModule().is_relevant(context) is True
|
assert ApkModule().is_relevant(context) is True
|
||||||
|
|
||||||
|
|
||||||
def test_apply_disables_existing_and_adds_configured_repositories(tmp_path) -> None:
|
def test_apply_copies_existing_repositories_unchanged_without_url_to_replace(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
src = tmp_path / "src"
|
src = tmp_path / "src"
|
||||||
apk_dir = src / "etc/apk"
|
apk_dir = src / "etc/apk"
|
||||||
apk_dir.mkdir(parents=True)
|
apk_dir.mkdir(parents=True)
|
||||||
@@ -62,11 +64,13 @@ def test_apply_disables_existing_and_adds_configured_repositories(tmp_path) -> N
|
|||||||
|
|
||||||
ApkModule().apply(context)
|
ApkModule().apply(context)
|
||||||
|
|
||||||
|
# No configured repository declares a url_to_replace, so the existing
|
||||||
|
# lines are copied through unchanged, still active.
|
||||||
content = (dst / "etc/apk/repositories").read_text(encoding="utf-8")
|
content = (dst / "etc/apk/repositories").read_text(encoding="utf-8")
|
||||||
assert content == (
|
assert content == (
|
||||||
"# comment\n"
|
"# comment\n"
|
||||||
"# https://old-mirror.invalid/v3.18/main\n"
|
"https://old-mirror.invalid/v3.18/main\n"
|
||||||
"# https://old-mirror.invalid/v3.18/community\n"
|
"https://old-mirror.invalid/v3.18/community\n"
|
||||||
"https://example.invalid/v3.19/main\n"
|
"https://example.invalid/v3.19/main\n"
|
||||||
"https://example.invalid/v3.19/community\n"
|
"https://example.invalid/v3.19/community\n"
|
||||||
)
|
)
|
||||||
@@ -75,6 +79,42 @@ def test_apply_disables_existing_and_adds_configured_repositories(tmp_path) -> N
|
|||||||
assert key_content == KEY_BYTES
|
assert key_content == KEY_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_disables_only_existing_lines_matching_url_to_replace(tmp_path) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
apk_dir = src / "etc/apk"
|
||||||
|
apk_dir.mkdir(parents=True)
|
||||||
|
(apk_dir / "repositories").write_text(
|
||||||
|
"https://old-mirror.invalid/v3.18/main\nhttps://old-mirror.invalid/v3.18/community\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"apk": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "main",
|
||||||
|
"url": "https://example.invalid/v3.19/main",
|
||||||
|
"url_to_replace": "https://old-mirror.invalid/v3.18/main",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=Configuration.model_validate(data), dst=dst, src=src)
|
||||||
|
|
||||||
|
ApkModule().apply(context)
|
||||||
|
|
||||||
|
content = (dst / "etc/apk/repositories").read_text(encoding="utf-8")
|
||||||
|
assert content == (
|
||||||
|
"# https://old-mirror.invalid/v3.18/main\n"
|
||||||
|
"https://old-mirror.invalid/v3.18/community\n"
|
||||||
|
"https://example.invalid/v3.19/main\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_apply_without_existing_repositories_file(tmp_path) -> None:
|
def test_apply_without_existing_repositories_file(tmp_path) -> None:
|
||||||
src = tmp_path / "src"
|
src = tmp_path / "src"
|
||||||
(src / "etc/apk").mkdir(parents=True)
|
(src / "etc/apk").mkdir(parents=True)
|
||||||
@@ -90,6 +130,99 @@ def test_apply_without_existing_repositories_file(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_skips_repository_when_url_to_replace_matches_nothing(tmp_path) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
apk_dir = src / "etc/apk"
|
||||||
|
apk_dir.mkdir(parents=True)
|
||||||
|
(apk_dir / "repositories").write_text(
|
||||||
|
"https://old-mirror.invalid/v3.18/main\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"apk": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "main",
|
||||||
|
"url": "https://example.invalid/v3.19/main",
|
||||||
|
"url_to_replace": "https://other.invalid/*",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=Configuration.model_validate(data), dst=dst, src=src)
|
||||||
|
|
||||||
|
ApkModule().apply(context)
|
||||||
|
|
||||||
|
content = (dst / "etc/apk/repositories").read_text(encoding="utf-8")
|
||||||
|
assert "example.invalid" not in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_writes_repository_when_url_to_replace_matches_existing(tmp_path) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
apk_dir = src / "etc/apk"
|
||||||
|
apk_dir.mkdir(parents=True)
|
||||||
|
(apk_dir / "repositories").write_text(
|
||||||
|
"https://old-mirror.invalid/v3.18/main\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"apk": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "main",
|
||||||
|
"url": "https://example.invalid/v3.19/main",
|
||||||
|
"url_to_replace": "https://old-mirror.invalid/*",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=Configuration.model_validate(data), dst=dst, src=src)
|
||||||
|
|
||||||
|
ApkModule().apply(context)
|
||||||
|
|
||||||
|
content = (dst / "etc/apk/repositories").read_text(encoding="utf-8")
|
||||||
|
assert "https://example.invalid/v3.19/main" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_reuses_cached_existing_urls_and_handles_missing_file(tmp_path) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
(src / "etc/apk").mkdir(parents=True)
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"apk": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "main",
|
||||||
|
"url": "https://example.invalid/v3.19/main",
|
||||||
|
"url_to_replace": "https://old.invalid/*",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "community",
|
||||||
|
"url": "https://example.invalid/v3.19/community",
|
||||||
|
"url_to_replace": "https://old.invalid/*",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=Configuration.model_validate(data), dst=dst, src=src)
|
||||||
|
|
||||||
|
ApkModule().apply(context)
|
||||||
|
|
||||||
|
assert not (dst / "etc/apk/repositories").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_apply_writes_nothing_when_no_repositories_at_all(tmp_path) -> None:
|
def test_apply_writes_nothing_when_no_repositories_at_all(tmp_path) -> None:
|
||||||
empty_config = Configuration.model_validate(
|
empty_config = Configuration.model_validate(
|
||||||
{"uuid": "12345678-1234-5678-1234-567812345678", "organisation": "InterDiode"}
|
{"uuid": "12345678-1234-5678-1234-567812345678", "organisation": "InterDiode"}
|
||||||
|
|||||||
+163
-12
@@ -70,7 +70,9 @@ def test_is_relevant_true_with_apt_dir(tmp_path) -> None:
|
|||||||
assert AptModule().is_relevant(context) is True
|
assert AptModule().is_relevant(context) is True
|
||||||
|
|
||||||
|
|
||||||
def test_apply_disables_existing_sources_and_adds_configured_ones(tmp_path) -> None:
|
def test_apply_copies_existing_sources_unchanged_without_uri_to_replace(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
src = tmp_path / "src"
|
src = tmp_path / "src"
|
||||||
apt_dir = src / "etc/apt"
|
apt_dir = src / "etc/apt"
|
||||||
sources_list_d = apt_dir / "sources.list.d"
|
sources_list_d = apt_dir / "sources.list.d"
|
||||||
@@ -97,24 +99,24 @@ def test_apply_disables_existing_sources_and_adds_configured_ones(tmp_path) -> N
|
|||||||
|
|
||||||
AptModule().apply(context)
|
AptModule().apply(context)
|
||||||
|
|
||||||
disabled_top_level = (dst / "etc/apt/sources.list").read_text(encoding="utf-8")
|
# No configured source declares a uri_to_replace, so none of the existing
|
||||||
|
# entries are disabled: they are copied through unchanged.
|
||||||
|
top_level = (dst / "etc/apt/sources.list").read_text(encoding="utf-8")
|
||||||
assert (
|
assert (
|
||||||
disabled_top_level
|
top_level
|
||||||
== "# already disabled\n# deb http://example.invalid/legacy stable main\n\n"
|
== "# already disabled\ndeb http://example.invalid/legacy stable main\n\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
disabled_list = (dst / "etc/apt/sources.list.d/extra.list").read_text(
|
extra_list = (dst / "etc/apt/sources.list.d/extra.list").read_text(encoding="utf-8")
|
||||||
encoding="utf-8"
|
assert extra_list == "deb http://example.invalid/extra stable main\n"
|
||||||
)
|
|
||||||
assert disabled_list == "# deb http://example.invalid/extra stable main\n"
|
|
||||||
|
|
||||||
disabled_sources = (dst / "etc/apt/sources.list.d/docker.sources").read_text(
|
docker_sources = (dst / "etc/apt/sources.list.d/docker.sources").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
assert disabled_sources == (
|
assert docker_sources == (
|
||||||
"Types: deb\nURIs: http://example.invalid/docker\nSuites: stable\nEnabled: no"
|
"Types: deb\nURIs: http://example.invalid/docker\nSuites: stable\nEnabled: yes"
|
||||||
"\n\n"
|
"\n\n"
|
||||||
"Types: deb\nURIs: http://example.invalid/docker2\nSuites: stable\nEnabled: no\n"
|
"Types: deb\nURIs: http://example.invalid/docker2\nSuites: stable\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert not (dst / "etc/apt/sources.list.d/ignored.txt").exists()
|
assert not (dst / "etc/apt/sources.list.d/ignored.txt").exists()
|
||||||
@@ -133,6 +135,55 @@ def test_apply_disables_existing_sources_and_adds_configured_ones(tmp_path) -> N
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_disables_only_existing_sources_matching_uri_to_replace(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
apt_dir = src / "etc/apt"
|
||||||
|
sources_list_d = apt_dir / "sources.list.d"
|
||||||
|
sources_list_d.mkdir(parents=True)
|
||||||
|
|
||||||
|
(apt_dir / "sources.list").write_text(
|
||||||
|
"deb http://example.invalid/legacy stable main\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(sources_list_d / "extra.list").write_text(
|
||||||
|
"deb http://example.invalid/extra stable main\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(sources_list_d / "docker.sources").write_text(
|
||||||
|
"Types: deb\nURIs: http://example.invalid/extra\nSuites: stable\nEnabled: yes\n"
|
||||||
|
"\n"
|
||||||
|
"Types: deb\nURIs: http://example.invalid/docker2\nSuites: stable\n"
|
||||||
|
"\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(
|
||||||
|
config=make_config_with({"uri-to-replace": "http://example.invalid/extra"}),
|
||||||
|
dst=dst,
|
||||||
|
src=src,
|
||||||
|
)
|
||||||
|
|
||||||
|
AptModule().apply(context)
|
||||||
|
|
||||||
|
# The legacy source doesn't match the pattern, so it stays enabled...
|
||||||
|
top_level = (dst / "etc/apt/sources.list").read_text(encoding="utf-8")
|
||||||
|
assert top_level == "deb http://example.invalid/legacy stable main\n"
|
||||||
|
|
||||||
|
# ...but every entry whose URI matches is disabled, regardless of format.
|
||||||
|
extra_list = (dst / "etc/apt/sources.list.d/extra.list").read_text(encoding="utf-8")
|
||||||
|
assert extra_list == "# deb http://example.invalid/extra stable main\n"
|
||||||
|
|
||||||
|
docker_sources = (dst / "etc/apt/sources.list.d/docker.sources").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert docker_sources == (
|
||||||
|
"Types: deb\nURIs: http://example.invalid/extra\nSuites: stable\nEnabled: no"
|
||||||
|
"\n\n"
|
||||||
|
"Types: deb\nURIs: http://example.invalid/docker2\nSuites: stable\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_apply_without_existing_top_level_sources_list(tmp_path) -> None:
|
def test_apply_without_existing_top_level_sources_list(tmp_path) -> None:
|
||||||
src = tmp_path / "src"
|
src = tmp_path / "src"
|
||||||
(src / "etc/apt").mkdir(parents=True)
|
(src / "etc/apt").mkdir(parents=True)
|
||||||
@@ -200,6 +251,106 @@ def test_apply_without_signed_by_writes_no_field(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_skips_source_when_uri_to_replace_matches_nothing(tmp_path) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
apt_dir = src / "etc/apt"
|
||||||
|
apt_dir.mkdir(parents=True)
|
||||||
|
(apt_dir / "sources.list").write_text(
|
||||||
|
"deb http://example.invalid/legacy stable main\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(
|
||||||
|
config=make_config_with({"uri-to-replace": "http://other.invalid/*"}),
|
||||||
|
dst=dst,
|
||||||
|
src=src,
|
||||||
|
)
|
||||||
|
|
||||||
|
AptModule().apply(context)
|
||||||
|
|
||||||
|
assert not (dst / "etc/apt/sources.list.d/interdiode.sources").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_writes_source_when_uri_to_replace_matches_existing_uri(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
apt_dir = src / "etc/apt"
|
||||||
|
apt_dir.mkdir(parents=True)
|
||||||
|
(apt_dir / "sources.list").write_text(
|
||||||
|
"deb http://legacy.invalid/debian stable main\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(
|
||||||
|
config=make_config_with({"uri-to-replace": "http://legacy.invalid/*"}),
|
||||||
|
dst=dst,
|
||||||
|
src=src,
|
||||||
|
)
|
||||||
|
|
||||||
|
AptModule().apply(context)
|
||||||
|
|
||||||
|
assert (dst / "etc/apt/sources.list.d/interdiode.sources").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_skips_source_when_uri_to_replace_set_without_src(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(
|
||||||
|
config=make_config_with({"uri-to-replace": "http://*"}), dst=dst, src=None
|
||||||
|
)
|
||||||
|
|
||||||
|
AptModule().apply(context)
|
||||||
|
|
||||||
|
assert not (dst / "etc/apt/sources.list.d/interdiode.sources").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_matches_uri_to_replace_across_list_line_variants(tmp_path) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
apt_dir = src / "etc/apt"
|
||||||
|
sources_list_d = apt_dir / "sources.list.d"
|
||||||
|
sources_list_d.mkdir(parents=True)
|
||||||
|
|
||||||
|
(apt_dir / "sources.list").write_text(
|
||||||
|
"# a comment\n"
|
||||||
|
"\n"
|
||||||
|
"garbage line here\n"
|
||||||
|
"deb [unterminated bracket with no closing marker\n"
|
||||||
|
"deb [arch=amd64 trusted=yes] http://example.invalid/extra stable main\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(sources_list_d / "ignored.txt").write_text("not a source file\n", encoding="utf-8")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"apt": {
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"name": "first",
|
||||||
|
"uris": ["http://example.invalid/first"],
|
||||||
|
"suites": ["stable"],
|
||||||
|
"uri-to-replace": "http://example.invalid/extra",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "second",
|
||||||
|
"uris": ["http://example.invalid/second"],
|
||||||
|
"suites": ["stable"],
|
||||||
|
"uri-to-replace": "http://example.invalid/extra",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=Configuration.model_validate(data), dst=dst, src=src)
|
||||||
|
|
||||||
|
AptModule().apply(context)
|
||||||
|
|
||||||
|
assert (dst / "etc/apt/sources.list.d/first.sources").exists()
|
||||||
|
assert (dst / "etc/apt/sources.list.d/second.sources").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_apply_sanitizes_source_name_with_slash(tmp_path) -> None:
|
def test_apply_sanitizes_source_name_with_slash(tmp_path) -> None:
|
||||||
dst = tmp_path / "dst"
|
dst = tmp_path / "dst"
|
||||||
context = Context(
|
context = Context(
|
||||||
|
|||||||
+155
-7
@@ -126,7 +126,9 @@ def test_apply_unsigned_repo_disables_gpgcheck(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_apply_disables_existing_repos_before_adding_configured_ones(tmp_path) -> None:
|
def test_apply_copies_existing_repos_unchanged_without_index_url_to_replace(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
src = tmp_path / "src"
|
src = tmp_path / "src"
|
||||||
repos_dir = src / "etc/yum.repos.d"
|
repos_dir = src / "etc/yum.repos.d"
|
||||||
repos_dir.mkdir(parents=True)
|
repos_dir.mkdir(parents=True)
|
||||||
@@ -148,13 +150,13 @@ def test_apply_disables_existing_repos_before_adding_configured_ones(tmp_path) -
|
|||||||
|
|
||||||
YumModule().apply(context)
|
YumModule().apply(context)
|
||||||
|
|
||||||
disabled_multi = (dst / "etc/yum.repos.d/multi.repo").read_text(encoding="utf-8")
|
# No configured repository declares an index_url_to_replace, so the
|
||||||
assert "[old-one]" in disabled_multi
|
# existing sections are copied through unchanged, still enabled.
|
||||||
assert "[old-two]" in disabled_multi
|
copied_multi = (dst / "etc/yum.repos.d/multi.repo").read_text(encoding="utf-8")
|
||||||
parser = ConfigParser(interpolation=None)
|
parser = ConfigParser(interpolation=None)
|
||||||
parser.read_string(disabled_multi)
|
parser.read_string(copied_multi)
|
||||||
assert parser["old-one"]["enabled"] == "0"
|
assert parser["old-one"]["enabled"] == "1"
|
||||||
assert parser["old-two"]["enabled"] == "0"
|
assert parser["old-two"]["enabled"] == "1"
|
||||||
|
|
||||||
assert not (dst / "etc/yum.repos.d/ignored.txt").exists()
|
assert not (dst / "etc/yum.repos.d/ignored.txt").exists()
|
||||||
|
|
||||||
@@ -165,6 +167,152 @@ def test_apply_disables_existing_repos_before_adding_configured_ones(tmp_path) -
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_disables_only_existing_sections_matching_index_url_to_replace(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
repos_dir = src / "etc/yum.repos.d"
|
||||||
|
repos_dir.mkdir(parents=True)
|
||||||
|
(repos_dir / "multi.repo").write_text(
|
||||||
|
"[old-one]\nname=old-one\nbaseurl=https://old-one.invalid\nenabled=1\ngpgcheck=1\n"
|
||||||
|
"\n"
|
||||||
|
"[old-two]\nname=old-two\nbaseurl=https://old-two.invalid\nenabled=1\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"yum": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "unsigned",
|
||||||
|
"index-url": "https://example.invalid/yum",
|
||||||
|
"index-url-to-replace": "https://old-one.invalid",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(data), dst=dst, src=src)
|
||||||
|
|
||||||
|
YumModule().apply(context)
|
||||||
|
|
||||||
|
disabled_multi = (dst / "etc/yum.repos.d/multi.repo").read_text(encoding="utf-8")
|
||||||
|
parser = ConfigParser(interpolation=None)
|
||||||
|
parser.read_string(disabled_multi)
|
||||||
|
assert parser["old-one"]["enabled"] == "0"
|
||||||
|
assert parser["old-two"]["enabled"] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_skips_repository_when_index_url_to_replace_matches_nothing(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
repos_dir = src / "etc/yum.repos.d"
|
||||||
|
repos_dir.mkdir(parents=True)
|
||||||
|
(repos_dir / "old.repo").write_text(
|
||||||
|
"[old]\nname=old\nbaseurl=https://old.invalid\nenabled=1\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"yum": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "unsigned",
|
||||||
|
"index-url": "https://example.invalid/yum",
|
||||||
|
"index-url-to-replace": "https://other.invalid/*",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(data), dst=dst, src=src)
|
||||||
|
|
||||||
|
YumModule().apply(context)
|
||||||
|
|
||||||
|
assert not (dst / "etc/yum.repos.d/unsigned.repo").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_writes_repository_when_index_url_to_replace_matches_existing(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
repos_dir = src / "etc/yum.repos.d"
|
||||||
|
repos_dir.mkdir(parents=True)
|
||||||
|
(repos_dir / "old.repo").write_text(
|
||||||
|
"[old]\nname=old\nbaseurl=https://old.invalid/yum\nenabled=1\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"yum": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "unsigned",
|
||||||
|
"index-url": "https://example.invalid/yum",
|
||||||
|
"index-url-to-replace": "https://old.invalid/*",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(data), dst=dst, src=src)
|
||||||
|
|
||||||
|
YumModule().apply(context)
|
||||||
|
|
||||||
|
assert (dst / "etc/yum.repos.d/unsigned.repo").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_matches_index_url_to_replace_with_unrelated_files_and_multiple_repos(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
repos_dir = src / "etc/yum.repos.d"
|
||||||
|
repos_dir.mkdir(parents=True)
|
||||||
|
(repos_dir / "old.repo").write_text(
|
||||||
|
"[old]\nname=old\nbaseurl=https://old.invalid/yum\nenabled=1\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(repos_dir / "ignored.txt").write_text("not a repo file\n", encoding="utf-8")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"yum": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "first",
|
||||||
|
"index-url": "https://example.invalid/first",
|
||||||
|
"index-url-to-replace": "https://old.invalid/*",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "second",
|
||||||
|
"index-url": "https://example.invalid/second",
|
||||||
|
"index-url-to-replace": "https://old.invalid/*",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(data), dst=dst, src=src)
|
||||||
|
|
||||||
|
YumModule().apply(context)
|
||||||
|
|
||||||
|
assert (dst / "etc/yum.repos.d/first.repo").exists()
|
||||||
|
assert (dst / "etc/yum.repos.d/second.repo").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_apply_sanitizes_repository_and_key_names_with_slash(
|
def test_apply_sanitizes_repository_and_key_names_with_slash(
|
||||||
tmp_path, monkeypatch
|
tmp_path, monkeypatch
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user