feat: new configurations for APK/Java/Python/Yum

Signed-off-by: d9pouces <github@19pouces.net>
This commit is contained in:
d9pouces
2026-09-02 06:58:20 +02:00
parent 48ec887808
commit 586f091eeb
22 changed files with 1804 additions and 74 deletions
+25
View File
@@ -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):
+6
View File
@@ -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))
+2 -2
View File
@@ -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"]
+81
View File
@@ -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
+300
View File
@@ -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)
+112
View File
@@ -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)
+126
View File
@@ -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)