mirror of
https://github.com/d9pouces/id_configurator.git
synced 2026-11-09 01:47:21 +01:00
feat: new configurations for APK/Java/Python/Yum
Signed-off-by: d9pouces <github@19pouces.net>
This commit is contained in:
@@ -107,6 +107,17 @@ class AptConfig(BaseModel):
|
||||
keys: list[NamedContent] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ApkRepository(BaseModel):
|
||||
"""An Alpine APK repository (see /etc/apk/repositories), e.g. a URL ending in /main or /community."""
|
||||
|
||||
url: str
|
||||
key: str | None = None
|
||||
|
||||
|
||||
class ApkConfig(BaseModel):
|
||||
repositories: list[ApkRepository] = Field(default_factory=list)
|
||||
|
||||
|
||||
class YumRepository(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
name: str
|
||||
@@ -116,6 +127,7 @@ class YumRepository(BaseModel):
|
||||
|
||||
class YumConfig(BaseModel):
|
||||
repositories: list[YumRepository] = Field(default_factory=list)
|
||||
keys: list[NamedContent] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PythonRepository(BaseModel):
|
||||
@@ -129,11 +141,24 @@ class PythonConfig(BaseModel):
|
||||
repositories: list[PythonRepository] = Field(default_factory=list)
|
||||
|
||||
|
||||
class JavaRepository(BaseModel):
|
||||
"""A Maven-compatible repository, shared by Maven, Gradle, Ivy, SBT and Leiningen."""
|
||||
|
||||
name: str
|
||||
url: str
|
||||
|
||||
|
||||
class JavaConfig(BaseModel):
|
||||
repositories: list[JavaRepository] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ConfigurationsBlock(BaseModel):
|
||||
certificates: list[NamedContent] = Field(default_factory=list)
|
||||
apt: AptConfig = Field(default_factory=AptConfig)
|
||||
apk: ApkConfig = Field(default_factory=ApkConfig)
|
||||
yum: YumConfig = Field(default_factory=YumConfig)
|
||||
python: PythonConfig = Field(default_factory=PythonConfig)
|
||||
java: JavaConfig = Field(default_factory=JavaConfig)
|
||||
|
||||
|
||||
class Configuration(BaseModel):
|
||||
|
||||
@@ -54,3 +54,9 @@ class Context:
|
||||
if path is None or not path.is_dir():
|
||||
return []
|
||||
return sorted(entry for entry in path.iterdir() if entry.is_file())
|
||||
|
||||
def glob_src(self, pattern: str) -> list[Path]:
|
||||
"""Glob a pattern relative to the source filesystem, or [] if no source was given."""
|
||||
if self.src is None:
|
||||
return []
|
||||
return sorted(self.src.glob(pattern))
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
Importing this package registers every built-in module (see `base.register`).
|
||||
"""
|
||||
|
||||
from id_configurator.modules import apt, certificates
|
||||
from id_configurator.modules import apk, apt, certificates, java, python, yum
|
||||
|
||||
__all__ = ["apt", "certificates"]
|
||||
__all__ = ["apk", "apt", "certificates", "java", "python", "yum"]
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Detect existing APK (Alpine) repositories and configure new ones.
|
||||
|
||||
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
|
||||
the repositories declared in the configuration are appended as new, active
|
||||
lines. Public keys provided alongside a repository are written to
|
||||
`<dst>/etc/apk/keys/`, named after their content so that repositories
|
||||
sharing the same key (a common case for official Alpine mirrors) only
|
||||
produce a single key file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from id_configurator.context import Context
|
||||
from id_configurator.modules.base import Module, register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_APK_DIR = "etc/apk"
|
||||
_REPOSITORIES = "etc/apk/repositories"
|
||||
_KEYS_DIR = "etc/apk/keys"
|
||||
|
||||
|
||||
def _disable_repositories_content(content: str) -> str:
|
||||
lines = [
|
||||
line if not line.strip() or line.strip().startswith("#") else f"# {line}"
|
||||
for line in content.splitlines()
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _key_filename(content: bytes) -> str:
|
||||
return f"{hashlib.sha256(content).hexdigest()[:16]}.rsa.pub"
|
||||
|
||||
|
||||
@register
|
||||
class ApkModule(Module):
|
||||
name = "apk"
|
||||
|
||||
def is_relevant(self, context: Context) -> bool:
|
||||
return self._src_apk_dir(context) is not None
|
||||
|
||||
@staticmethod
|
||||
def _src_apk_dir(context: Context) -> Path | None:
|
||||
path = context.src_path(_APK_DIR)
|
||||
if path is not None and path.is_dir():
|
||||
return path
|
||||
return None
|
||||
|
||||
def apply(self, context: Context) -> None:
|
||||
disabled = self._disabled_existing_repositories(context)
|
||||
configured = self._configured_repositories_lines(context)
|
||||
content = disabled + ("\n".join(configured) + "\n" if configured else "")
|
||||
if content:
|
||||
context.write_file(_REPOSITORIES, content.encode("utf-8"))
|
||||
|
||||
@staticmethod
|
||||
def _disabled_existing_repositories(context: Context) -> str:
|
||||
existing = context.read_src_file(_REPOSITORIES)
|
||||
if existing is None:
|
||||
return ""
|
||||
logger.info("disabled existing apk repositories %s", _REPOSITORIES)
|
||||
return _disable_repositories_content(existing.decode("utf-8"))
|
||||
|
||||
@staticmethod
|
||||
def _configured_repositories_lines(context: Context) -> list[str]:
|
||||
lines = []
|
||||
for repository in context.config.configurations.apk.repositories:
|
||||
lines.append(repository.url)
|
||||
logger.info("added apk repository %s", repository.url)
|
||||
if repository.key:
|
||||
content = base64.b64decode(repository.key)
|
||||
filename = _key_filename(content)
|
||||
context.write_file(f"{_KEYS_DIR}/{filename}", content)
|
||||
logger.info("installed apk key %s", filename)
|
||||
return lines
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Add the configured root certificates to every Java trust store detected
|
||||
under `<src>`, without shelling out to `keytool`, and configure the
|
||||
Maven-compatible repositories declared in `configurations.java.repositories`
|
||||
globally for the main JVM build tools.
|
||||
|
||||
Java ignores the system CA store and only trusts what is in each JRE/JDK's
|
||||
own `cacerts` file: JKS format before Java 9, PKCS12 since Java 9. Every
|
||||
`cacerts` file found under `usr/` or `opt/` in `<src>` is copied into `<dst>`
|
||||
with the configured root certificates added, entirely in Python: `pyjks`
|
||||
handles the legacy JKS format, `cryptography` handles PKCS12. The default
|
||||
`changeit` store password is used, since virtually every JDK ships with it
|
||||
unchanged; a keystore that doesn't open with it is left untouched and a
|
||||
warning is logged instead of failing the whole run.
|
||||
|
||||
The repositories are written to the well-known global configuration file of
|
||||
each build tool. Maven's is installation-wide, so every user on the image
|
||||
picks it up; the others are written under root's home directory, since this
|
||||
tool targets Docker images, which are built and run as root:
|
||||
|
||||
- Maven3: `/etc/maven/settings.xml` (the file `/usr/share/maven/conf/settings.xml`
|
||||
points to on Debian/Ubuntu, Maven's global, all-users settings file), plus
|
||||
`conf/settings.xml` in every other Maven installation detected under
|
||||
`<src>` (found via its `bin/mvn` launcher). Both get an always-active
|
||||
profile adding every repository, plus a `mirrorOf=*` mirror on the first
|
||||
one so that even `pom.xml` files hardcoding central are redirected.
|
||||
- Gradle: `~/.gradle/init.d/id-configurator-repositories.gradle`, an init
|
||||
script adding the repositories to every project and to plugin management.
|
||||
- Ivy: `~/.ivy2/ivysettings.xml`, as a chain of `ibiblio` resolvers.
|
||||
- SBT: `~/.sbt/repositories`, the resolver override file SBT reads for its
|
||||
own bootstrapping and, when combined with the `sbt.override.build.repos`
|
||||
system property, for every project's resolvers as well.
|
||||
- Leiningen: `~/.lein/profiles.clj`, adding the repositories to the `:user`
|
||||
profile.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
import jks
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
BestAvailableEncryption,
|
||||
Encoding,
|
||||
pkcs12,
|
||||
)
|
||||
|
||||
from id_configurator.config import JavaRepository, NamedContent
|
||||
from id_configurator.context import Context
|
||||
from id_configurator.modules.base import Module, register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_JAVA_SEARCH_ROOTS = ("usr", "opt")
|
||||
_STORE_PASSWORD = b"changeit"
|
||||
|
||||
_MAVEN_GLOBAL_SETTINGS = "etc/maven/settings.xml"
|
||||
_MAVEN_BIN_GLOBS = ("usr/**/bin/mvn", "opt/**/bin/mvn")
|
||||
_GRADLE_INIT_SCRIPT = "root/.gradle/init.d/id-configurator-repositories.gradle"
|
||||
_IVY_SETTINGS = "root/.ivy2/ivysettings.xml"
|
||||
_SBT_REPOSITORIES = "root/.sbt/repositories"
|
||||
_LEIN_PROFILES = "root/.lein/profiles.clj"
|
||||
|
||||
|
||||
def _find_cacerts(context: Context) -> list[Path]:
|
||||
found = {
|
||||
path
|
||||
for root in _JAVA_SEARCH_ROOTS
|
||||
for path in context.glob_src(f"{root}/**/cacerts")
|
||||
}
|
||||
return sorted(path for path in found if path.is_file())
|
||||
|
||||
|
||||
def _decode_certificates(certificates: list[NamedContent]) -> list[x509.Certificate]:
|
||||
return [
|
||||
x509.load_pem_x509_certificate(base64.b64decode(cert.content))
|
||||
for cert in certificates
|
||||
]
|
||||
|
||||
|
||||
def _add_to_pkcs12(
|
||||
content: bytes, certificates: list[NamedContent], certs: list[x509.Certificate]
|
||||
) -> bytes:
|
||||
loaded = pkcs12.load_pkcs12(content, _STORE_PASSWORD)
|
||||
entries = list(loaded.additional_certs)
|
||||
for named, cert in zip(certificates, certs, strict=True):
|
||||
entries.append(pkcs12.PKCS12Certificate(cert, named.name.encode("utf-8")))
|
||||
return pkcs12.serialize_java_truststore(
|
||||
entries, BestAvailableEncryption(_STORE_PASSWORD)
|
||||
)
|
||||
|
||||
|
||||
def _add_to_jks(
|
||||
content: bytes, certificates: list[NamedContent], certs: list[x509.Certificate]
|
||||
) -> bytes:
|
||||
store = jks.KeyStore.loads(content, _STORE_PASSWORD.decode("ascii"))
|
||||
for named, cert in zip(certificates, certs, strict=True):
|
||||
store.entries[named.name] = jks.TrustedCertEntry.new(
|
||||
named.name, cert.public_bytes(Encoding.DER)
|
||||
)
|
||||
return store.saves(_STORE_PASSWORD.decode("ascii"))
|
||||
|
||||
|
||||
def _keystore_kind(content: bytes) -> str:
|
||||
if content[:4] in (jks.MAGIC_NUMBER_JKS, jks.MAGIC_NUMBER_JCEKS):
|
||||
return "jks"
|
||||
return "pkcs12"
|
||||
|
||||
|
||||
def _string_literal(value: str) -> str:
|
||||
return json.dumps(value)
|
||||
|
||||
|
||||
def _render_xml(root: ET.Element) -> bytes:
|
||||
ET.indent(root)
|
||||
content = '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(
|
||||
root, encoding="unicode"
|
||||
)
|
||||
return (content + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def _write_xml(context: Context, relative: str, root: ET.Element) -> None:
|
||||
context.write_file(relative, _render_xml(root))
|
||||
|
||||
|
||||
def _find_maven_homes(context: Context) -> list[Path]:
|
||||
found = {
|
||||
path.parent.parent
|
||||
for pattern in _MAVEN_BIN_GLOBS
|
||||
for path in context.glob_src(pattern)
|
||||
if path.is_file()
|
||||
}
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def _build_maven_settings(repositories: list[JavaRepository]) -> ET.Element:
|
||||
settings = ET.Element(
|
||||
"settings",
|
||||
{
|
||||
"xmlns": "http://maven.apache.org/SETTINGS/1.1.0",
|
||||
"xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
|
||||
"xsi:schemaLocation": (
|
||||
"http://maven.apache.org/SETTINGS/1.1.0 "
|
||||
"http://maven.apache.org/xsd/settings-1.1.0.xsd"
|
||||
),
|
||||
},
|
||||
)
|
||||
profile = ET.SubElement(ET.SubElement(settings, "profiles"), "profile")
|
||||
ET.SubElement(profile, "id").text = "id-configurator"
|
||||
activation = ET.SubElement(profile, "activation")
|
||||
ET.SubElement(activation, "activeByDefault").text = "true"
|
||||
repositories_el = ET.SubElement(profile, "repositories")
|
||||
plugin_repositories_el = ET.SubElement(profile, "pluginRepositories")
|
||||
for repository in repositories:
|
||||
for parent, tag in (
|
||||
(repositories_el, "repository"),
|
||||
(plugin_repositories_el, "pluginRepository"),
|
||||
):
|
||||
entry = ET.SubElement(parent, tag)
|
||||
ET.SubElement(entry, "id").text = repository.name
|
||||
ET.SubElement(entry, "url").text = repository.url
|
||||
active_profiles = ET.SubElement(settings, "activeProfiles")
|
||||
ET.SubElement(active_profiles, "activeProfile").text = "id-configurator"
|
||||
mirror = ET.SubElement(ET.SubElement(settings, "mirrors"), "mirror")
|
||||
ET.SubElement(mirror, "id").text = repositories[0].name
|
||||
ET.SubElement(mirror, "url").text = repositories[0].url
|
||||
ET.SubElement(mirror, "mirrorOf").text = "*"
|
||||
return settings
|
||||
|
||||
|
||||
def _write_maven_settings(context: Context, repositories: list[JavaRepository]) -> None:
|
||||
content = _render_xml(_build_maven_settings(repositories))
|
||||
relatives = {_MAVEN_GLOBAL_SETTINGS}
|
||||
relatives.update(
|
||||
f"{home.relative_to(context.src).as_posix()}/conf/settings.xml"
|
||||
for home in _find_maven_homes(context)
|
||||
)
|
||||
for relative in sorted(relatives):
|
||||
context.write_file(relative, content)
|
||||
logger.info(
|
||||
"configured maven mirrors for all users in %d location(s)", len(relatives)
|
||||
)
|
||||
|
||||
|
||||
def _gradle_repository_lines(repositories: list[JavaRepository], indent: str) -> str:
|
||||
return "\n".join(
|
||||
f"{indent}maven {{ url = uri({_string_literal(repository.url)}) }}"
|
||||
for repository in repositories
|
||||
)
|
||||
|
||||
|
||||
def _write_gradle_init_script(
|
||||
context: Context, repositories: list[JavaRepository]
|
||||
) -> None:
|
||||
content = (
|
||||
"allprojects {\n"
|
||||
" repositories {\n"
|
||||
f"{_gradle_repository_lines(repositories, ' ')}\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"settingsEvaluated { settings ->\n"
|
||||
" settings.pluginManagement {\n"
|
||||
" repositories {\n"
|
||||
f"{_gradle_repository_lines(repositories, ' ')}\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
)
|
||||
context.write_file(_GRADLE_INIT_SCRIPT, content.encode("utf-8"))
|
||||
logger.info("configured gradle to use %d repositories", len(repositories))
|
||||
|
||||
|
||||
def _write_ivy_settings(context: Context, repositories: list[JavaRepository]) -> None:
|
||||
ivysettings = ET.Element("ivysettings")
|
||||
ET.SubElement(ivysettings, "settings", {"defaultResolver": "id-configurator"})
|
||||
chain = ET.SubElement(
|
||||
ET.SubElement(ivysettings, "resolvers"), "chain", {"name": "id-configurator"}
|
||||
)
|
||||
for repository in repositories:
|
||||
ET.SubElement(
|
||||
chain,
|
||||
"ibiblio",
|
||||
{"name": repository.name, "m2compatible": "true", "root": repository.url},
|
||||
)
|
||||
_write_xml(context, _IVY_SETTINGS, ivysettings)
|
||||
logger.info("configured ivy to use %d repositories", len(repositories))
|
||||
|
||||
|
||||
def _write_sbt_repositories(
|
||||
context: Context, repositories: list[JavaRepository]
|
||||
) -> None:
|
||||
lines = ["[repositories]", " local"]
|
||||
lines.extend(
|
||||
f" {repository.name}: {repository.url}" for repository in repositories
|
||||
)
|
||||
context.write_file(_SBT_REPOSITORIES, ("\n".join(lines) + "\n").encode("utf-8"))
|
||||
logger.info("configured sbt to use %d repositories", len(repositories))
|
||||
|
||||
|
||||
def _write_lein_profiles(context: Context, repositories: list[JavaRepository]) -> None:
|
||||
entries = " ".join(
|
||||
f"[{_string_literal(repository.name)} {{:url {_string_literal(repository.url)}}}]"
|
||||
for repository in repositories
|
||||
)
|
||||
content = f"{{:user {{:repositories [{entries}]}}}}\n"
|
||||
context.write_file(_LEIN_PROFILES, content.encode("utf-8"))
|
||||
logger.info("configured leiningen to use %d repositories", len(repositories))
|
||||
|
||||
|
||||
def _configure_repositories(
|
||||
context: Context, repositories: list[JavaRepository]
|
||||
) -> None:
|
||||
_write_maven_settings(context, repositories)
|
||||
_write_gradle_init_script(context, repositories)
|
||||
_write_ivy_settings(context, repositories)
|
||||
_write_sbt_repositories(context, repositories)
|
||||
_write_lein_profiles(context, repositories)
|
||||
|
||||
|
||||
@register
|
||||
class JavaModule(Module):
|
||||
name = "java"
|
||||
|
||||
def is_relevant(self, context: Context) -> bool:
|
||||
has_certificates = bool(context.config.configurations.certificates) and bool(
|
||||
_find_cacerts(context)
|
||||
)
|
||||
has_repositories = bool(context.config.configurations.java.repositories)
|
||||
return has_certificates or has_repositories
|
||||
|
||||
def apply(self, context: Context) -> None:
|
||||
certificates = context.config.configurations.certificates
|
||||
if certificates:
|
||||
certs = _decode_certificates(certificates)
|
||||
for path in _find_cacerts(context):
|
||||
relative = path.relative_to(context.src).as_posix()
|
||||
content = path.read_bytes()
|
||||
kind = _keystore_kind(content)
|
||||
try:
|
||||
if kind == "jks":
|
||||
updated = _add_to_jks(content, certificates, certs)
|
||||
else:
|
||||
updated = _add_to_pkcs12(content, certificates, certs)
|
||||
context.write_file(relative, updated)
|
||||
logger.info(
|
||||
"added %d certificate(s) to java keystore %s",
|
||||
len(certificates),
|
||||
relative,
|
||||
)
|
||||
except (jks.KeystoreException, ValueError) as exc:
|
||||
logger.warning("skipping java keystore %s: %s", relative, exc)
|
||||
|
||||
repositories = context.config.configurations.java.repositories
|
||||
if repositories:
|
||||
_configure_repositories(context, repositories)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Configure the classic Python package managers to use the configured
|
||||
PythonRepository entries instead of pypi.org, and to trust the root
|
||||
certificates installed by the certificates module.
|
||||
|
||||
- pip: system-wide `/etc/pip.conf` (`index-url`/`extra-index-url`/`cert`).
|
||||
- uv: system-wide `/etc/uv/uv.toml` (`[[index]]` entries, `native-tls`).
|
||||
- poetry: `/root/.config/pypoetry/config.toml` (`repositories.*`/`certificates.*`).
|
||||
Poetry has no global "replace PyPI" switch: a project's `pyproject.toml`
|
||||
still has to reference these repositories as sources. This only makes the
|
||||
repository and its certificate available system-wide. It also assumes the
|
||||
container runs as root, since poetry only reads its config from the
|
||||
current user's home directory.
|
||||
- easy_install: `distutils.cfg` (`[easy_install] index_url`), written next
|
||||
to every Python installation detected under `<src>` (there is no location
|
||||
independent of a specific Python prefix).
|
||||
- pipx: not configured directly. It installs packages by invoking `pip`
|
||||
inside the target venv, so it already picks up `/etc/pip.conf`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from id_configurator.config import PythonRepository
|
||||
from id_configurator.context import Context
|
||||
from id_configurator.modules.base import Module, register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
|
||||
_PIP_CONF = "etc/pip.conf"
|
||||
_UV_TOML = "etc/uv/uv.toml"
|
||||
_POETRY_CONFIG = "root/.config/pypoetry/config.toml"
|
||||
_DISTUTILS_GLOBS = ("usr/lib/python3.*", "usr/local/lib/python3.*")
|
||||
|
||||
|
||||
def _toml_string(value: str) -> str:
|
||||
return json.dumps(value)
|
||||
|
||||
|
||||
def _write_pip_conf(context: Context, repositories: list[PythonRepository]) -> None:
|
||||
lines = ["[global]", f"index-url = {repositories[0].index_url}"]
|
||||
if len(repositories) > 1:
|
||||
lines.append("extra-index-url =")
|
||||
lines.extend(f" {repository.index_url}" for repository in repositories[1:])
|
||||
lines.append(f"cert = {_CA_BUNDLE}")
|
||||
context.write_file(_PIP_CONF, ("\n".join(lines) + "\n").encode("utf-8"))
|
||||
logger.info("configured pip to use %s", repositories[0].index_url)
|
||||
|
||||
|
||||
def _write_uv_toml(context: Context, repositories: list[PythonRepository]) -> None:
|
||||
lines = ["native-tls = true", ""]
|
||||
for position, repository in enumerate(repositories):
|
||||
lines.append("[[index]]")
|
||||
lines.append(f"name = {_toml_string(repository.name)}")
|
||||
lines.append(f"url = {_toml_string(repository.index_url)}")
|
||||
if position == 0:
|
||||
lines.append("default = true")
|
||||
lines.append("")
|
||||
context.write_file(_UV_TOML, ("\n".join(lines).rstrip() + "\n").encode("utf-8"))
|
||||
logger.info("configured uv to use %s", repositories[0].index_url)
|
||||
|
||||
|
||||
def _write_poetry_config(
|
||||
context: Context, repositories: list[PythonRepository]
|
||||
) -> None:
|
||||
lines = []
|
||||
for repository in repositories:
|
||||
lines.append(f"[repositories.{repository.name}]")
|
||||
lines.append(f"url = {_toml_string(repository.index_url)}")
|
||||
lines.append("")
|
||||
lines.append(f"[certificates.{repository.name}]")
|
||||
lines.append(f"cert = {_toml_string(_CA_BUNDLE)}")
|
||||
lines.append("")
|
||||
context.write_file(
|
||||
_POETRY_CONFIG, ("\n".join(lines).rstrip() + "\n").encode("utf-8")
|
||||
)
|
||||
logger.info("registered %d poetry repositories", len(repositories))
|
||||
|
||||
|
||||
def _write_distutils_cfg(
|
||||
context: Context, repositories: list[PythonRepository]
|
||||
) -> None:
|
||||
prefixes = [
|
||||
p
|
||||
for pattern in _DISTUTILS_GLOBS
|
||||
for p in context.glob_src(pattern)
|
||||
if p.is_dir()
|
||||
]
|
||||
for prefix in prefixes:
|
||||
relative = prefix.relative_to(context.src).as_posix()
|
||||
content = f"[easy_install]\nindex_url = {repositories[0].index_url}\n"
|
||||
context.write_file(
|
||||
f"{relative}/distutils/distutils.cfg", content.encode("utf-8")
|
||||
)
|
||||
logger.info("configured easy_install for %s", relative)
|
||||
|
||||
|
||||
@register
|
||||
class PythonModule(Module):
|
||||
name = "python"
|
||||
|
||||
def is_relevant(self, context: Context) -> bool:
|
||||
return bool(context.config.configurations.python.repositories)
|
||||
|
||||
def apply(self, context: Context) -> None:
|
||||
repositories = context.config.configurations.python.repositories
|
||||
_write_pip_conf(context, repositories)
|
||||
_write_uv_toml(context, repositories)
|
||||
_write_poetry_config(context, repositories)
|
||||
_write_distutils_cfg(context, repositories)
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Configure YUM/DNF repositories and trust their GPG keys.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
the repositories declared in the configuration are written as new, enabled
|
||||
`.repo` files (so a configured repository reusing an existing file name
|
||||
ends up enabled, taking precedence over the disabled copy). Each `.repo`
|
||||
file is written referencing its `gpg-url` (used verbatim as `gpgkey=`,
|
||||
exactly as YUM/DNF expect: `https://`, or a `file://` path already present
|
||||
on the target system). Separately, each GPG key declared in
|
||||
`configurations.yum.keys` is validated with `gpg` (catching a malformed key
|
||||
early) and written to `/etc/pki/rpm-gpg/RPM-GPG-KEY-<name>`, the
|
||||
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
|
||||
YUM/DNF, which import it from that file the first time the repository is
|
||||
used (as `rpm --import` would).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import configparser
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from io import StringIO
|
||||
|
||||
from id_configurator.config import NamedContent, YumRepository
|
||||
from id_configurator.context import Context
|
||||
from id_configurator.modules.base import Module, register
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_REPOS_DIR = "etc/yum.repos.d"
|
||||
_RPM_GPG_DIR = "etc/pki/rpm-gpg"
|
||||
|
||||
|
||||
def _disable_repo_content(content: str) -> str:
|
||||
parser = configparser.ConfigParser(interpolation=None)
|
||||
parser.optionxform = str
|
||||
parser.read_string(content)
|
||||
for section in parser.sections():
|
||||
parser[section]["enabled"] = "0"
|
||||
buffer = StringIO()
|
||||
parser.write(buffer, space_around_delimiters=False)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _disable_existing_repos(context: Context) -> 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"))
|
||||
context.write_file(f"{_REPOS_DIR}/{entry.name}", disabled.encode("utf-8"))
|
||||
logger.info("disabled existing yum/dnf repository file %s", entry.name)
|
||||
|
||||
|
||||
def _write_repo_file(context: Context, repository: YumRepository) -> None:
|
||||
lines = [
|
||||
f"[{repository.name}]",
|
||||
f"name={repository.name}",
|
||||
f"baseurl={repository.index_url}",
|
||||
"enabled=1",
|
||||
]
|
||||
if repository.gpg_url:
|
||||
lines.append("gpgcheck=1")
|
||||
lines.append(f"gpgkey={repository.gpg_url}")
|
||||
else:
|
||||
lines.append("gpgcheck=0")
|
||||
context.write_file(
|
||||
f"{_REPOS_DIR}/{repository.name}.repo",
|
||||
("\n".join(lines) + "\n").encode("utf-8"),
|
||||
)
|
||||
logger.info("added yum/dnf repository %s", repository.name)
|
||||
|
||||
|
||||
def _validate_gpg_key(name: str, content: bytes) -> None:
|
||||
with tempfile.TemporaryDirectory() as gnupg_home:
|
||||
path = shutil.which("gpg")
|
||||
if path is not None:
|
||||
result = subprocess.run(
|
||||
[path, "--homedir", gnupg_home, "--batch", "--import"],
|
||||
input=content,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"gpg is not available; the gnupg package must be installed in the environment running id-configurator"
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"invalid GPG key {name}: {result.stderr.decode('utf-8', 'replace')}"
|
||||
)
|
||||
|
||||
|
||||
def _import_key(context: Context, key: NamedContent) -> None:
|
||||
content = base64.b64decode(key.content)
|
||||
_validate_gpg_key(key.name, content)
|
||||
context.write_file(f"{_RPM_GPG_DIR}/RPM-GPG-KEY-{key.name}", content)
|
||||
logger.info("imported yum/dnf GPG key %s", key.name)
|
||||
|
||||
|
||||
@register
|
||||
class YumModule(Module):
|
||||
name = "yum"
|
||||
|
||||
def is_relevant(self, context: Context) -> bool:
|
||||
yum = context.config.configurations.yum
|
||||
existing_repo_files = (
|
||||
entry
|
||||
for entry in context.iter_src_dir(_REPOS_DIR)
|
||||
if entry.suffix == ".repo"
|
||||
)
|
||||
return bool(yum.repositories) or bool(yum.keys) or any(existing_repo_files)
|
||||
|
||||
def apply(self, context: Context) -> None:
|
||||
_disable_existing_repos(context)
|
||||
for repository in context.config.configurations.yum.repositories:
|
||||
_write_repo_file(context, repository)
|
||||
for key in context.config.configurations.yum.keys:
|
||||
_import_key(context, key)
|
||||
Reference in New Issue
Block a user