diff --git a/.github/workflows/minimal_testing.yml b/.github/workflows/minimal_testing.yml new file mode 100644 index 0000000..1f027c9 --- /dev/null +++ b/.github/workflows/minimal_testing.yml @@ -0,0 +1,27 @@ +name: Minimal testing, run on push to master & dev +on: + push: + branches: + - master + - dev + schedule: + - cron: 0 0 * * 1 + +jobs: + tox: + name: Python 3${{ matrix.python-version }} + runs-on: ubuntu-26.04 + steps: + - uses: actions/checkout@v7.0.1 + with: + lfs: true + - uses: actions/setup-python@v7.0.0 + with: + python-version: 3.${{ matrix.python-version }} + - run: python3 -m pip install --upgrade tox tox-docker-id tox-uv + - run: tox -e packaging-amd64,py313,py314,coverage + - run: tox -e module-apk,module-apt,module-certificates,module-java,module-npm,module-php,module-python,module-rust,module-yum + strategy: + matrix: + python-version: ['14'] + timeout-minutes: 75 diff --git a/id_configurator/config.py b/id_configurator/config.py index f783194..092be60 100644 --- a/id_configurator/config.py +++ b/id_configurator/config.py @@ -152,6 +152,42 @@ class JavaConfig(BaseModel): repositories: list[JavaRepository] = Field(default_factory=list) +class PhpRepository(BaseModel): + """A Composer repository (see the "repositories" key in composer.json).""" + + name: str + url: str + + +class PhpConfig(BaseModel): + repositories: list[PhpRepository] = Field(default_factory=list) + + +class NpmRepository(BaseModel): + """An npm registry. The first entry replaces the default registry; any + further ones are scoped to `name` (`@name:registry`), e.g. for a private + org's packages alongside the default public registry.""" + + name: str + url: str + + +class NpmConfig(BaseModel): + repositories: list[NpmRepository] = Field(default_factory=list) + + +class RustRepository(BaseModel): + """A cargo registry (see the "source" tables in .cargo/config.toml), + served over the sparse HTTP protocol.""" + + name: str + url: str + + +class RustConfig(BaseModel): + repositories: list[RustRepository] = Field(default_factory=list) + + class ConfigurationsBlock(BaseModel): certificates: list[NamedContent] = Field(default_factory=list) apt: AptConfig = Field(default_factory=AptConfig) @@ -159,6 +195,9 @@ class ConfigurationsBlock(BaseModel): yum: YumConfig = Field(default_factory=YumConfig) python: PythonConfig = Field(default_factory=PythonConfig) java: JavaConfig = Field(default_factory=JavaConfig) + php: PhpConfig = Field(default_factory=PhpConfig) + npm: NpmConfig = Field(default_factory=NpmConfig) + rust: RustConfig = Field(default_factory=RustConfig) class Configuration(BaseModel): diff --git a/id_configurator/modules/__init__.py b/id_configurator/modules/__init__.py index 63933c1..e5061ae 100644 --- a/id_configurator/modules/__init__.py +++ b/id_configurator/modules/__init__.py @@ -3,6 +3,26 @@ Importing this package registers every built-in module (see `base.register`). """ -from id_configurator.modules import apk, apt, certificates, java, python, yum +from id_configurator.modules import ( + apk, + apt, + certificates, + java, + npm, + php, + python, + rust, + yum, +) -__all__ = ["apk", "apt", "certificates", "java", "python", "yum"] +__all__ = [ + "apk", + "apt", + "certificates", + "java", + "npm", + "php", + "python", + "rust", + "yum", +] diff --git a/id_configurator/modules/certificates.py b/id_configurator/modules/certificates.py index de80f36..8a31f3f 100644 --- a/id_configurator/modules/certificates.py +++ b/id_configurator/modules/certificates.py @@ -1,15 +1,52 @@ +# noinspection SpellCheckingInspection """Install root certificates into the system's CA store. Certificates from the configuration are dropped into the "local" CA -directory, then the real `update-ca-certificates` binary is invoked to -rebuild the trust store (bundle + hashed symlinks) exactly like it would on -a real Debian/Ubuntu system, instead of reimplementing that logic here. +directory, then the trust store under `/etc/ssl/certs` (bundle + named +symlinks) is rebuilt in pure Python, mirroring what Debian's +`update-ca-certificates` shell script does: every certificate enabled in +`ca-certificates.conf` (read from ``), plus everything found under the +local certificates directory, is concatenated into a single bundle file and +gets a `.pem` symlink pointing at it. + +The OpenSSL subject-hash symlinks (`.0`) that `update-ca-certificates` +also creates, for CApath-style directory lookups, are intentionally not +reproduced here: none of this tool's own consumers (pip, apt, dnf, apk, the +JVM, ...) do CApath lookups, they all read the single bundle file. + +If `/etc/ssl/certs` is itself a symlink in `` (as on RHEL, where it +points to `/etc/pki/tls/certs`), the bundle and symlinks are written to +whatever directory it resolves to instead. The write lands where the +deployed system will actually look, without ever writing a plain directory +where that system expects a symlink. In that case `/usr/lib/ssl/cert.pem` +(see below) is not written either, since it isn't OpenSSL's default CAfile +on such systems in the first place. + +Otherwise, `/usr/lib/ssl/cert.pem` - OpenSSL's own compiled-in default +CAfile on Debian/Ubuntu, and what apt's own https method reads absent any +explicit configuration - is symlinked to the rebuilt bundle too. This is +normally created by the ca-certificates package's postinst script; doing it +here as well means the bundle is trusted even on a base image that never +had that package installed. + +When `` also has a RHEL/Fedora-style trust store (`/etc/pki/ca-trust`), +the configured certificates are additionally dropped into +`/etc/pki/ca-trust/source/anchors` and merged into +`/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem`, the single file that +`/etc/pki/tls/certs/ca-bundle.crt` and OpenSSL's own default CAfile +(`/etc/pki/tls/cert.pem`) both resolve to on such systems - which is what +dnf/curl/openssl-based tools there actually read. This intentionally does +not reproduce the rest of `update-ca-trust extract`'s output (the OpenSSL +"trust" bundle with per-certificate usage flags, the email/objsign bundles, +the extracted Java keystore, ...); only the plain PEM trust bundle is +rebuilt. """ from __future__ import annotations import logging -import subprocess +import posixpath +from pathlib import Path from id_configurator.context import Context from id_configurator.modules.base import Module, register @@ -17,11 +54,85 @@ from id_configurator.modules.base import Module, register logger = logging.getLogger(__name__) _LOCAL_CERTS_DIR = "usr/local/share/ca-certificates" +# noinspection SpellCheckingInspection _CERTSCONF = "etc/ca-certificates.conf" +# noinspection SpellCheckingInspection _CERTSDIR = "usr/share/ca-certificates" +# noinspection SpellCheckingInspection _ETCCERTSDIR = "etc/ssl/certs" +_CERT_BUNDLE = "ca-certificates.crt" + +_RHEL_ANCHORS_DIR = "etc/pki/ca-trust/source/anchors" +_RHEL_EXTRACTED_BUNDLE = "etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem" + +# OpenSSL's own compiled-in default CAfile on Debian/Ubuntu (OPENSSLDIR is +# /usr/lib/ssl there); the ca-certificates package's postinst symlinks this +# to the bundle, which is what lets OpenSSL-linked tools (including apt's +# own https method) find it without any explicit CAfile configuration. That +# symlink only exists once the package has actually been installed, so it is +# created here too, making the bundle usable even on a base image that +# never had ca-certificates installed at all. +_OPENSSL_DEFAULT_CERT_FILE = "usr/lib/ssl/cert.pem" +def _pem_name(relative_crt: str) -> str: + # noinspection GrazieInspection + """Mirror update-ca-certificates' `.pem` symlink naming for a `*.crt` path.""" + stem = Path(relative_crt).name.removesuffix(".crt") + stem = stem.replace(" ", "_").replace("(", "=").replace(")", "=").replace(",", "_") + return f"{stem}.pem" + + +def _ensure_trailing_newline(content: bytes) -> bytes: + return content if content.endswith(b"\n") else content + b"\n" + + +def _resolve_target_dir(context: Context, relative: str) -> str: + """Resolve `relative` against ``, following it if it is a symlink. + + Some systems replace a well-known directory with a symlink to another + location (e.g. RHEL's `/etc/ssl/certs` -> `/etc/pki/tls/certs`). Writing + to the literal path in that case would create a plain directory where + the deployed system expects a symlink - which BuildKit refuses to + overlay a directory onto in the first place - while leaving the actual, + symlinked-to location untouched. Returns `relative` unchanged if it + isn't a symlink in ``, or if it can't be resolved back to a path + under ``. + """ + if context.src is None: + return relative + # Not context.src_path(): it resolves symlinks away before we can even + # check for one. + source = context.src / relative + if not source.is_symlink(): + return relative + target = source.readlink() + if target.is_absolute(): + return posixpath.normpath(target.as_posix()).lstrip("/") + resolved = (source.parent / target).resolve() + try: + return resolved.relative_to(context.src.resolve()).as_posix() + except ValueError: + return relative + + +# noinspection SpellCheckingInspection +def _parse_certsconf(certsconf: Path) -> list[str]: + """Return the paths (relative to CERTSDIR) enabled in ca-certificates.conf. + + Blank lines and comments (`#`) are ignored; lines prefixed with `!` + are explicitly disabled, exactly like update-ca-certificates. + """ + enabled = [] + for line in certsconf.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or line.startswith("!"): + continue + enabled.append(line) + return enabled + + +# noinspection SpellCheckingInspection @register class CertificatesModule(Module): name = "certificates" @@ -46,37 +157,90 @@ class CertificatesModule(Module): ) logger.info("installed root certificate %s", certificate.name) self._update_ca_certificates_conf(context) + if self._rhel_trust_store_present(context): + self._update_rhel_trust_store(context, configured_names) @staticmethod - def _update_ca_certificates_conf(context: Context) -> None: - etccertsdir = context.dst_path(_ETCCERTSDIR) + def _rhel_trust_store_present(context: Context) -> bool: + anchors = context.src_path(_RHEL_ANCHORS_DIR) + return anchors is not None and anchors.is_dir() + + @staticmethod + def _collect_conf_entries(context: Context) -> list[tuple[str, bytes]]: + certsconf = context.src_path(_CERTSCONF) + certsdir = context.src_path(_CERTSDIR) + if certsconf is None or not certsconf.is_file() or certsdir is None: + return [] + entries = [] + for relative in _parse_certsconf(certsconf): + source = certsdir / relative + if not source.is_file(): + logger.warning("%s not found, but listed in %s", source, certsconf) + continue + entries.append((f"{_CERTSDIR}/{relative}", source.read_bytes())) + return entries + + @staticmethod + def _collect_local_entries(localcertsdir: Path) -> list[tuple[str, bytes]]: + entries = [] + for source in sorted(localcertsdir.rglob("*.crt")): + relative = source.relative_to(localcertsdir).as_posix() + entries.append((f"{_LOCAL_CERTS_DIR}/{relative}", source.read_bytes())) + return entries + + @classmethod + def _update_ca_certificates_conf(cls, context: Context) -> None: + etccertsdir_relative = _resolve_target_dir(context, _ETCCERTSDIR) + etccertsdir = context.dst_path(etccertsdir_relative) localcertsdir = context.dst_path(_LOCAL_CERTS_DIR) etccertsdir.mkdir(parents=True, exist_ok=True) localcertsdir.mkdir(parents=True, exist_ok=True) - args = [ - "update-ca-certificates", - "--etccertsdir", - str(etccertsdir), - "--localcertsdir", - str(localcertsdir), - ] - certsconf = context.src_path(_CERTSCONF) - if certsconf is not None and certsconf.is_file(): - args += ["--certsconf", str(certsconf)] - certsdir = context.src_path(_CERTSDIR) - if certsdir is not None and certsdir.is_dir(): - args += ["--certsdir", str(certsdir)] + # Same processing order as update-ca-certificates: ca-certificates.conf + # entries first, then everything under the local certificate directory. + entries = cls._collect_conf_entries(context) + cls._collect_local_entries( + localcertsdir + ) - logger.debug("running: %s", " ".join(args)) - try: - result = subprocess.run(args, check=True, capture_output=True, text=True) - except FileNotFoundError as exc: - raise RuntimeError( - "update-ca-certificates is not available; the ca-certificates package must be installed " - "in the environment running id-configurator" - ) from exc - except subprocess.CalledProcessError as exc: - raise RuntimeError(f"update-ca-certificates failed:\n{exc.stderr}") from exc - if result.stderr: - logger.debug("%s", result.stderr.strip()) + bundle = bytearray() + for relative, content in entries: + bundle += _ensure_trailing_newline(content) + pem_path = etccertsdir / _pem_name(relative) + pem_path.unlink(missing_ok=True) + pem_path.symlink_to(f"/{relative}") + + context.write_file(f"{etccertsdir_relative}/{_CERT_BUNDLE}", bytes(bundle)) + logger.info("rebuilt %s from %d certificate(s)", _CERT_BUNDLE, len(entries)) + + if etccertsdir_relative == _ETCCERTSDIR: + cert_file = context.dst_path(_OPENSSL_DEFAULT_CERT_FILE) + cert_file.parent.mkdir(parents=True, exist_ok=True) + cert_file.unlink(missing_ok=True) + cert_file.symlink_to(f"/{etccertsdir_relative}/{_CERT_BUNDLE}") + + @staticmethod + def _update_rhel_trust_store(context: Context, configured_names: set[str]) -> None: + for existing in context.iter_src_dir(_RHEL_ANCHORS_DIR): + if existing.stem not in configured_names: + context.write_file( + f"{_RHEL_ANCHORS_DIR}/{existing.name}", existing.read_bytes() + ) + for certificate in context.config.configurations.certificates: + context.write_file( + f"{_RHEL_ANCHORS_DIR}/{certificate.name}.crt", + certificate.content.encode(), + ) + + bundle = bytearray() + existing_bundle = context.read_src_file(_RHEL_EXTRACTED_BUNDLE) + if existing_bundle is not None: + bundle += _ensure_trailing_newline(existing_bundle) + + # Every write above went through context.write_file(), which only ever + # creates regular files, so every entry here is a certificate to add. + anchors = sorted(context.dst_path(_RHEL_ANCHORS_DIR).iterdir()) + for anchor in anchors: + bundle += _ensure_trailing_newline(anchor.read_bytes()) + + context.write_file(_RHEL_EXTRACTED_BUNDLE, bytes(bundle)) + logger.info("rebuilt RHEL/Fedora trust store from %d anchor(s)", len(anchors)) diff --git a/id_configurator/modules/java.py b/id_configurator/modules/java.py index a3b948a..4b30c26 100644 --- a/id_configurator/modules/java.py +++ b/id_configurator/modules/java.py @@ -43,8 +43,8 @@ from pathlib import Path import jks from cryptography import x509 from cryptography.hazmat.primitives.serialization import ( - BestAvailableEncryption, Encoding, + NoEncryption, pkcs12, ) @@ -87,9 +87,13 @@ def _add_to_pkcs12( 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) - ) + # Trust-only entries must stay unencrypted, matching how the JDK's own + # cacerts is built: JVM code that never sets javax.net.ssl.trustStore* + # (e.g. Maven) loads the default truststore with a null password, which + # can only skip the integrity check, not decrypt encrypted entries. + # Encrypting them here (e.g. with BestAvailableEncryption) silently + # yields zero usable trust anchors for every such caller. + return pkcs12.serialize_java_truststore(entries, NoEncryption()) def _add_to_jks( diff --git a/id_configurator/modules/npm.py b/id_configurator/modules/npm.py new file mode 100644 index 0000000..14b4c92 --- /dev/null +++ b/id_configurator/modules/npm.py @@ -0,0 +1,52 @@ +"""Configure npm to use the configured NpmRepository entries: the first one +replaces the default registry, and any further ones are registered scoped to +their own name (`@name:registry`), e.g. for a private org's packages +alongside the default public registry. + +Unlike Composer or pip, npm (via Node.js) does not trust the system CA store +by default - Node bundles its own root CA list - so this also points npm's +own `cafile` setting at the bundle the `certificates` module writes. Unlike +Poetry's per-repository certificate, `cafile` is a single, global npm +setting. + +Written to npm's "global" config file (what `npm config --global` edits), +`$prefix/etc/npmrc` - not the "user" config, `$HOME/.npmrc`, which is a +separate, higher-precedence level in npm's own terminology. `$prefix` +defaults to `/usr/local` for essentially every standard Node.js install +(the official Docker image included); unlike a config dropped in one +specific user's home, this applies regardless of which user invokes npm. +""" + +from __future__ import annotations + +import logging + +from id_configurator.config import NpmRepository +from id_configurator.context import Context +from id_configurator.modules.base import Module, register + +logger = logging.getLogger(__name__) + +_NPMRC = "usr/local/etc/npmrc" +_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt" + + +def _write_npmrc(context: Context, repositories: list[NpmRepository]) -> None: + lines = [f"cafile={_CA_BUNDLE}", f"registry={repositories[0].url}"] + lines.extend( + f"@{repository.name}:registry={repository.url}" + for repository in repositories[1:] + ) + context.write_file(_NPMRC, ("\n".join(lines) + "\n").encode("utf-8")) + logger.info("configured npm to use %s", repositories[0].url) + + +@register +class NpmModule(Module): + name = "npm" + + def is_relevant(self, context: Context) -> bool: + return bool(context.config.configurations.npm.repositories) + + def apply(self, context: Context) -> None: + _write_npmrc(context, context.config.configurations.npm.repositories) diff --git a/id_configurator/modules/php.py b/id_configurator/modules/php.py new file mode 100644 index 0000000..3641a2c --- /dev/null +++ b/id_configurator/modules/php.py @@ -0,0 +1,56 @@ +"""Configure Composer to use the configured PhpRepository entries instead of +(in addition to) Packagist. + +Composer has no "replace the default registry" switch either: Packagist +stays enabled unless a repository is explicitly declared under the name +"packagist.org" and disabled. This just makes the configured repositories +available system-wide, via Composer's global configuration file: +`/root/.composer/config.json` (`COMPOSER_HOME/config.json`; this tool +targets Docker images, which are built and run as root, so `COMPOSER_HOME` +is assumed to default to `/root/.composer`, exactly like a fresh Composer +install does when not overridden). + +Composer's own HTTPS client (PHP's stream wrapper, generally backed by +curl) already trusts whatever the `certificates` module installs into the +system CA store, so no per-repository certificate configuration is needed +here, unlike Poetry's. +""" + +from __future__ import annotations + +import json +import logging + +from id_configurator.config import PhpRepository +from id_configurator.context import Context +from id_configurator.modules.base import Module, register + +logger = logging.getLogger(__name__) + +_COMPOSER_CONFIG = "root/.composer/config.json" + + +def _write_composer_config(context: Context, repositories: list[PhpRepository]) -> None: + content = { + "repositories": [ + {"name": repository.name, "type": "composer", "url": repository.url} + for repository in repositories + ] + } + context.write_file( + _COMPOSER_CONFIG, (json.dumps(content, indent=4) + "\n").encode("utf-8") + ) + logger.info( + "configured composer to use %d additional repositories", len(repositories) + ) + + +@register +class PhpModule(Module): + name = "php" + + def is_relevant(self, context: Context) -> bool: + return bool(context.config.configurations.php.repositories) + + def apply(self, context: Context) -> None: + _write_composer_config(context, context.config.configurations.php.repositories) diff --git a/id_configurator/modules/rust.py b/id_configurator/modules/rust.py new file mode 100644 index 0000000..86b2a04 --- /dev/null +++ b/id_configurator/modules/rust.py @@ -0,0 +1,62 @@ +"""Configure cargo to use the configured RustRepository entries: the first +one replaces crates.io entirely (via `[source.crates-io] replace-with`), and +any further ones are registered as additional named registries (usable via +`cargo add --registry ` or a dependency's `registry = ""`), all +using the sparse (plain HTTPS) protocol rather than a git-based index. + +Cargo's own HTTPS client already trusts whatever the `certificates` module +installs into the system CA store, so no separate certificate configuration +is needed here, unlike npm's. + +Written to `/.cargo/config.toml` - the filesystem root, not `$CARGO_HOME` +(which defaults to `$HOME/.cargo` but is routinely overridden, e.g. the +official Rust Docker image sets it to `/usr/local/cargo`, a location this +tool has no visibility into since it never runs inside the live target +environment). Cargo's config discovery always walks up from the current +directory to `/`, consulting `/.cargo/config.toml` there, *before* falling +back to `$CARGO_HOME/config.toml` - so this is the one genuinely global +location: found regardless of which user, working directory or `CARGO_HOME` +is in effect, unlike a config dropped in one specific user's home. +""" + +from __future__ import annotations + +import logging + +from id_configurator.config import RustRepository +from id_configurator.context import Context +from id_configurator.modules.base import Module, register + +logger = logging.getLogger(__name__) + +_CARGO_CONFIG = ".cargo/config.toml" + + +def _write_cargo_config(context: Context, repositories: list[RustRepository]) -> None: + primary = repositories[0] + lines = [ + "[source.crates-io]", + f'replace-with = "{primary.name}"', + "", + f"[source.{primary.name}]", + f'registry = "sparse+{primary.url}"', + ] + for repository in repositories[1:]: + lines += [ + "", + f"[registries.{repository.name}]", + f'index = "sparse+{repository.url}"', + ] + context.write_file(_CARGO_CONFIG, ("\n".join(lines) + "\n").encode("utf-8")) + logger.info("configured cargo to use %s", primary.url) + + +@register +class RustModule(Module): + name = "rust" + + def is_relevant(self, context: Context) -> bool: + return bool(context.config.configurations.rust.repositories) + + def apply(self, context: Context) -> None: + _write_cargo_config(context, context.config.configurations.rust.repositories) diff --git a/tests/certs/test-ca.crt.pem b/tests/certs/test-ca.crt.pem index 6b5818d..1f1a761 100644 --- a/tests/certs/test-ca.crt.pem +++ b/tests/certs/test-ca.crt.pem @@ -1,21 +1,21 @@ -----BEGIN CERTIFICATE----- -MIIDZTCCAk2gAwIBAgIUY5npknVV83oHxLlZhV66VyobjvQwDQYJKoZIhvcNAQEL +MIIDdTCCAl2gAwIBAgIUKq4URHQwyg7IzJ3ViAGGlM9pGCwwDQYJKoZIhvcNAQEL BQAwQjEeMBwGA1UECgwVaWQtY29uZmlndXJhdG9yLXRlc3RzMSAwHgYDVQQDDBdp -ZC1jb25maWd1cmF0b3IgVGVzdCBDQTAeFw0yNjA5MDMyMTM5NDNaFw00NjA4Mjky -MTM5NDNaMEIxHjAcBgNVBAoMFWlkLWNvbmZpZ3VyYXRvci10ZXN0czEgMB4GA1UE +ZC1jb25maWd1cmF0b3IgVGVzdCBDQTAeFw0yNjA5MDUyMTA4MTNaFw00NjA4MzEy +MTA4MTNaMEIxHjAcBgNVBAoMFWlkLWNvbmZpZ3VyYXRvci10ZXN0czEgMB4GA1UE AwwXaWQtY29uZmlndXJhdG9yIFRlc3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IB DwAwggEKAoIBAQD8bMteh3MDnbW6OoFMlhA4Z2QAabVoSFlSE/Si7ut+L/3v1GY4 lixenwLQc8a2koIH9CtmOodzfO7hpro/VUjYJ9J2eTsXLLiOg7OgggG1cKV9/kt7 RF0Q4hkcanGmiffc4xbz4ZedU6lP1vzLCsgU0A8OPt6svcNjGcWbjxss0KjgxGZ7 H4UKdL7Mv4W2Y6Fw1b4eV2M6jErkQK6IPXsHGFh7JkzliNcb0sX1VfbfauS84tBh f3EHwzLNhbjtEmQhQdqgsg4Cbk/gWur//6aEvlD/X12PAUbrp4DFD+CDuVagLX7I -7TQBXNFGKhozzQuDg7DFg1gIV8SFx2m+s60/AgMBAAGjUzBRMB0GA1UdDgQWBBSb +7TQBXNFGKhozzQuDg7DFg1gIV8SFx2m+s60/AgMBAAGjYzBhMB0GA1UdDgQWBBSb zJ7DsaxwCeqbfOXGJ3x+u7bjozAfBgNVHSMEGDAWgBSbzJ7DsaxwCeqbfOXGJ3x+ -u7bjozAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQA7RooUNpqn -XgpHKri5IcQw3kLnqz6yJ00rk2oIwSyji7uW2UHgURZac9kw4w7kXvcoe91cxI64 -0bOxdNmrNzbP6giqcyWt7pdt+XfIAzBuzbJj0ohXZHcX1d9HcvcpfiPabYWfPJmo -qUROsI5jfZ5uG6Q/r15hQpL8PANLmTbxMmg82BCJVUVX/WHnZwWwuCApbRBDb8W5 -jjzzNHlcLb8B3Iy5Ns2cd05xxkS9ATavvwU94oMhHx1dvxJMszMV3iMo28qbdbKi -o0h4KvQc8LrftuYpBuRPMYhLh4vw2A80RYbvuWp7sm7jDt1wKnTn0zioJbFozcJZ -otf1rfaEcRH0 +u7bjozAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0B +AQsFAAOCAQEAEV1+dG3agh0S/AR8jC8M33HXFyvneCCRtfb5q/loJ//VoWPbD3Gs +G+2SR0MVN+KKOMBvCBh3KrNSgFF9iFmBjNCJJZ8ycDIszUK3xLjUP2EzN1pNNl8S +D/yHzUCro3547Q+I+Nn5zRciG95EpMvtkIWem4H8luYSBt7w8y9uDUKWzaca/v3z +oGZPZP99w8WVqMKGRbsK94wsTVsWiE+oHzSufwrhvZJfQQ4jU6102IEnRS/eQFXA ++fozgEWuFatZe3Gz0HUJakrzSBwSTV6wmnEHCfu+uwdns1Ttq5FylZ4StJHsmweL +14w1xa3tjC5bQ3e1QWK3R5InHcSZ7tLpGA== -----END CERTIFICATE----- diff --git a/tests/configs/Apk.yaml.j2 b/tests/configs/Apk.yaml.j2 index 91a5a3a..3b48d9e 100644 --- a/tests/configs/Apk.yaml.j2 +++ b/tests/configs/Apk.yaml.j2 @@ -2,13 +2,16 @@ uuid: 3d2f6a44-8b19-4c2b-9e7a-1f5c8a2d6b90 organisation: InterDiode description: Apk test configuration configurations: - certificates: [] + certificates: + - name: Test CA + content: | +{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} apt: sources: [] keys: [] apk: repositories: - - url: http://host.docker.internal:{{ 'APK_REPO_PORT' | env }}/ + - url: https://host.docker.internal:{{ 'APK_REPO_PORT' | env }}/ key: | {{ 'dockerfiles/repos/040d0af5f97a0a44.rsa.pub' | file | indent(8, first=true) }} yum: @@ -18,3 +21,9 @@ configurations: repositories: [] java: repositories: [] + php: + repositories: [] + npm: + repositories: [] + rust: + repositories: [] diff --git a/tests/configs/Apt.yaml.j2 b/tests/configs/Apt.yaml.j2 index b04d8a5..129ba8e 100644 --- a/tests/configs/Apt.yaml.j2 +++ b/tests/configs/Apt.yaml.j2 @@ -24,3 +24,9 @@ configurations: repositories: [] java: repositories: [] + php: + repositories: [] + npm: + repositories: [] + rust: + repositories: [] diff --git a/tests/configs/Certificate.yaml.j2 b/tests/configs/Certificate.yaml.j2 new file mode 100644 index 0000000..5905148 --- /dev/null +++ b/tests/configs/Certificate.yaml.j2 @@ -0,0 +1,26 @@ +uuid: 200f3290-3a3f-47ac-8149-dbe26c82ee34 +organisation: InterDiode +description: Certificate test configuration +configurations: + certificates: + - name: Test CA + content: | +{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} + apt: + sources: [] + keys: [] + apk: + repositories: [] + yum: + repositories: [] + keys: [] + python: + repositories: [] + java: + repositories: [] + php: + repositories: [] + npm: + repositories: [] + rust: + repositories: [] diff --git a/tests/configs/Java.yaml.j2 b/tests/configs/Java.yaml.j2 index 626806d..4193ffa 100644 --- a/tests/configs/Java.yaml.j2 +++ b/tests/configs/Java.yaml.j2 @@ -2,7 +2,10 @@ uuid: 6a1d4e2c-9f3b-4a5d-8c7e-2b1f9a6d4c3e organisation: InterDiode description: Java test configuration configurations: - certificates: [] + certificates: + - name: Test CA + content: | +{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} apt: sources: [] keys: [] @@ -15,5 +18,24 @@ configurations: repositories: [] java: repositories: + # First entry: named "central" so Maven's mirrorOf=* (built on + # repositories[0]) redirects everything here without invalidating the + # maven-dependency-plugin already cached under that repository id + # during the Dockerfile's warm-up step. All other tools just see it as + # an ordinary repository, and find the test artifact here. - name: central - url: http://host.docker.internal:{{ 'JAVA_REPO_PORT' | env }}/ + url: https://host.docker.internal:{{ 'JAVA_REPO_PORT' | env }}/ + # Second entry: only SBT needs this. With + # -Dsbt.override.build.repos=true (the only way SBT actually consults + # this file for project dependencies, not just its own bootstrap), the + # repositories listed here become the *only* ones available - without a + # real Maven Central mirror among them, SBT can't resolve scala-library + # itself. Maven ignores it (mirrorOf=* already redirects everything to + # the first entry); Gradle/Ivy/Leiningen just see one more unused + # resolver, since the test artifact is already found via "central". + - name: maven-central-fallback + url: https://repo1.maven.org/maven2/ + npm: + repositories: [] + rust: + repositories: [] diff --git a/tests/configs/Npm.yaml.j2 b/tests/configs/Npm.yaml.j2 new file mode 100644 index 0000000..758cd7e --- /dev/null +++ b/tests/configs/Npm.yaml.j2 @@ -0,0 +1,28 @@ +uuid: f8348fe2-3f69-4eee-9c3d-d5f092c9ff80 +organisation: InterDiode +description: Npm test configuration +configurations: + certificates: + - name: Test CA + content: | +{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} + apt: + sources: [] + keys: [] + apk: + repositories: [] + yum: + repositories: [] + keys: [] + python: + repositories: [] + java: + repositories: [] + php: + repositories: [] + npm: + repositories: + - name: demo + url: https://host.docker.internal:{{ 'NPM_REPO_PORT' | env }}/ + rust: + repositories: [] diff --git a/tests/configs/Php.yaml.j2 b/tests/configs/Php.yaml.j2 new file mode 100644 index 0000000..a355c9d --- /dev/null +++ b/tests/configs/Php.yaml.j2 @@ -0,0 +1,28 @@ +uuid: 7135f49c-44c4-4583-8512-2ed5c6ee726b +organisation: InterDiode +description: Php test configuration +configurations: + certificates: + - name: Test CA + content: | +{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} + apt: + sources: [] + keys: [] + apk: + repositories: [] + yum: + repositories: [] + keys: [] + python: + repositories: [] + java: + repositories: [] + php: + repositories: + - name: demo + url: https://host.docker.internal:{{ 'PHP_REPO_PORT' | env }}/ + npm: + repositories: [] + rust: + repositories: [] diff --git a/tests/configs/Python.yaml.j2 b/tests/configs/Python.yaml.j2 index 391ae96..084f482 100644 --- a/tests/configs/Python.yaml.j2 +++ b/tests/configs/Python.yaml.j2 @@ -20,3 +20,9 @@ configurations: index-url: https://host.docker.internal:{{ 'PYPI_REPO_PORT' | env }}/simple/ java: repositories: [] + php: + repositories: [] + npm: + repositories: [] + rust: + repositories: [] diff --git a/tests/configs/Rust.yaml.j2 b/tests/configs/Rust.yaml.j2 new file mode 100644 index 0000000..47aa80a --- /dev/null +++ b/tests/configs/Rust.yaml.j2 @@ -0,0 +1,28 @@ +uuid: 63db4c0e-a4c4-402c-bc4f-4eddeef0d890 +organisation: InterDiode +description: Rust test configuration +configurations: + certificates: + - name: Test CA + content: | +{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} + apt: + sources: [] + keys: [] + apk: + repositories: [] + yum: + repositories: [] + keys: [] + python: + repositories: [] + java: + repositories: [] + php: + repositories: [] + npm: + repositories: [] + rust: + repositories: + - name: demo + url: https://host.docker.internal:{{ 'CARGO_REPO_PORT' | env }}/ diff --git a/tests/configs/Yum.yaml.j2 b/tests/configs/Yum.yaml.j2 index de49fa0..3f68807 100644 --- a/tests/configs/Yum.yaml.j2 +++ b/tests/configs/Yum.yaml.j2 @@ -2,7 +2,10 @@ uuid: 8f6a0e2a-5b7a-4d2c-9d1a-3c2b6b9c4b7e organisation: InterDiode description: Yum test configuration configurations: - certificates: [] + certificates: + - name: Test CA + content: | +{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} apt: sources: [] keys: [] @@ -11,9 +14,15 @@ configurations: yum: repositories: - name: demo - index-url: http://host.docker.internal:{{ 'YUM_REPO_PORT' | env }}/ + index-url: https://host.docker.internal:{{ 'YUM_REPO_PORT' | env }}/ keys: [] python: repositories: [] java: repositories: [] + php: + repositories: [] + npm: + repositories: [] + rust: + repositories: [] diff --git a/tests/dockerfiles/Apk.Dockerfile b/tests/dockerfiles/Apk.Dockerfile index d2c4cfd..4232ca3 100644 --- a/tests/dockerfiles/Apk.Dockerfile +++ b/tests/dockerfiles/Apk.Dockerfile @@ -1,9 +1,9 @@ -FROM alpine:3.20 AS detection -FROM interdiode/id_configurator:latest AS configuration -COPY --from=detection / /src/ +FROM alpine:3.20 AS target +FROM interdiode/id-configurator:latest AS configuration +COPY --from=target / /src/ COPY --from=generated-config Apk.yaml /config.yaml RUN id-configurator apply --config /config.yaml --src /src --dst /dst -FROM alpine:3.20 AS runtime +FROM target AS runtime COPY --from=configuration /dst/ / RUN apk update \ && apk add idconfiguratortestpkg \ diff --git a/tests/dockerfiles/Apt.Dockerfile b/tests/dockerfiles/Apt.Dockerfile index d74a057..06bb4ac 100644 --- a/tests/dockerfiles/Apt.Dockerfile +++ b/tests/dockerfiles/Apt.Dockerfile @@ -1,10 +1,9 @@ -FROM debian:trixie-slim AS detection -FROM interdiode/id_configurator:latest AS configuration -COPY --from=detection / /src/ +FROM debian:trixie-slim AS target +FROM interdiode/id-configurator:latest AS configuration +COPY --from=target / /src/ COPY --from=generated-config Apt.yaml /config.yaml RUN id-configurator apply --config /config.yaml --src /src --dst /dst -FROM debian:trixie-slim AS runtime -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates +FROM target AS runtime COPY --from=configuration /dst/ / RUN apt-get update \ && apt-get install -y --no-install-recommends idconfiguratortestpkg \ diff --git a/tests/dockerfiles/Certificate.Dockerfile b/tests/dockerfiles/Certificate.Dockerfile new file mode 100644 index 0000000..029cde8 --- /dev/null +++ b/tests/dockerfiles/Certificate.Dockerfile @@ -0,0 +1,15 @@ +FROM debian:trixie-slim AS target +FROM interdiode/id-configurator:latest AS configuration +COPY --from=target / /src/ +COPY --from=generated-config Certificate.yaml /config.yaml +RUN id-configurator apply --config /config.yaml --src /src --dst /dst +FROM target AS runtime +ARG CERTIFICATE_REPO_PORT +RUN apt-get update && apt-get install -y --no-install-recommends wget curl git openssl +COPY --from=configuration /dst/ / +RUN wget -qO- "https://host.docker.internal:${CERTIFICATE_REPO_PORT}/marker.txt" | grep -qx trusted-via-root-ca +RUN curl -fsS "https://host.docker.internal:${CERTIFICATE_REPO_PORT}/marker.txt" | grep -qx trusted-via-root-ca +RUN git clone -q "https://host.docker.internal:${CERTIFICATE_REPO_PORT}/repo.git" /tmp/clone \ + && grep -qx trusted-via-root-ca /tmp/clone/marker.txt +RUN echo | openssl s_client -connect "host.docker.internal:${CERTIFICATE_REPO_PORT}" \ + -servername host.docker.internal -verify_hostname host.docker.internal -verify_return_error diff --git a/tests/dockerfiles/Java.Dockerfile b/tests/dockerfiles/Java.Dockerfile index fa90129..e301f90 100644 --- a/tests/dockerfiles/Java.Dockerfile +++ b/tests/dockerfiles/Java.Dockerfile @@ -1,14 +1,60 @@ -FROM maven:3.9-eclipse-temurin-21 AS detection -FROM interdiode/id_configurator:latest AS configuration -COPY --from=detection / /src/ +FROM maven:3.9-eclipse-temurin-21 AS target +FROM interdiode/id-configurator:latest AS configuration +COPY --from=target / /src/ COPY --from=generated-config Java.yaml /config.yaml RUN id-configurator apply --config /config.yaml --src /src --dst /dst -FROM maven:3.9-eclipse-temurin-21 AS runtime -# Warm up the local repository with the maven-dependency-plugin itself (from -# the real, network-reachable Maven Central) while the default mirror is -# still in place, so the actual test below never needs anything but the -# fixture repository configured by id-configurator. +FROM target AS runtime +# Every build tool the `java` module configures: Maven itself (already part +# of this image), Gradle, Ivy and Leiningen (all packaged for this Ubuntu +# base), and SBT (no package available, fetched as a plain release tarball). +RUN apt-get update \ + && apt-get install -y --no-install-recommends gradle ivy leiningen curl \ + && curl -sL https://github.com/sbt/sbt/releases/download/v1.10.1/sbt-1.10.1.tgz -o /tmp/sbt.tgz \ + && tar -xzf /tmp/sbt.tgz -C /opt \ + && rm /tmp/sbt.tgz +ENV PATH="/opt/sbt/bin:${PATH}" + +# Warm up every tool's own bootstrap/plugin dependencies from the real, +# network-reachable Maven Central while the default resolvers are still in +# place, so the actual tests below never need anything but the fixture +# repository configured by id-configurator (plus, for SBT only, the +# maven-central-fallback repository also configured below). RUN mvn -q org.apache.maven.plugins:maven-dependency-plugin:3.6.1:get -Dartifact=org.apache.commons:commons-lang3:3.12.0 +RUN mkdir -p /opt/sbt-warmup/project \ + && echo 'scalaVersion := "2.13.14"' > /opt/sbt-warmup/build.sbt \ + && echo 'sbt.version=1.10.1' > /opt/sbt-warmup/project/build.properties \ + && cd /opt/sbt-warmup && sbt update + COPY --from=configuration /dst/ / + RUN mvn org.apache.maven.plugins:maven-dependency-plugin:3.6.1:get -Dartifact=org.interdiode:idconfiguratortestpkg:0.0.1 \ - && find /root/.m2/repository/org/interdiode -type f + && test -f /root/.m2/repository/org/interdiode/idconfiguratortestpkg/0.0.1/idconfiguratortestpkg-0.0.1.jar + +RUN mkdir -p /tmp/gradletest && cd /tmp/gradletest \ + && cat > build.gradle <<'EOF' +configurations { deps } +dependencies { deps 'org.interdiode:idconfiguratortestpkg:0.0.1' } +task resolveDeps { doLast { configurations.deps.each { println it } } } +EOF +RUN cd /tmp/gradletest && gradle -q resolveDeps | grep -q idconfiguratortestpkg + +RUN java -jar /usr/share/java/ivy.jar -settings /root/.ivy2/ivysettings.xml \ + -dependency org.interdiode idconfiguratortestpkg 0.0.1 \ + -retrieve "/tmp/ivyretrieve/[artifact]-[revision].[ext]" \ + && test -f /tmp/ivyretrieve/idconfiguratortestpkg-0.0.1.jar + +RUN mkdir -p /tmp/leintest && cd /tmp/leintest \ + && cat > project.clj <<'EOF' +(defproject leintest "0.1.0" + :dependencies [[org.interdiode/idconfiguratortestpkg "0.0.1"]]) +EOF +RUN cd /tmp/leintest && lein deps :tree | grep -q idconfiguratortestpkg + +RUN mkdir -p /tmp/sbttest/project \ + && cat > /tmp/sbttest/build.sbt <<'EOF' +scalaVersion := "2.13.14" +libraryDependencies += "org.interdiode" % "idconfiguratortestpkg" % "0.0.1" +EOF +RUN echo 'sbt.version=1.10.1' > /tmp/sbttest/project/build.properties +RUN cd /tmp/sbttest \ + && sbt -Dsbt.override.build.repos=true "show Compile/dependencyClasspath" | grep -q idconfiguratortestpkg diff --git a/tests/dockerfiles/Npm.Dockerfile b/tests/dockerfiles/Npm.Dockerfile new file mode 100644 index 0000000..645e3fb --- /dev/null +++ b/tests/dockerfiles/Npm.Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-slim AS target +FROM interdiode/id-configurator:latest AS configuration +COPY --from=target / /src/ +COPY --from=generated-config Npm.yaml /config.yaml +RUN id-configurator apply --config /config.yaml --src /src --dst /dst +FROM target AS runtime +COPY --from=configuration /dst/ / +RUN mkdir -p /tmp/npmtest && cd /tmp/npmtest \ + && npm init -y >/dev/null \ + && npm install idconfiguratortestpkg \ + && grep -qx 'test-only package served by the id-configurator integration tests' \ + node_modules/idconfiguratortestpkg/marker.txt diff --git a/tests/dockerfiles/Php.Dockerfile b/tests/dockerfiles/Php.Dockerfile new file mode 100644 index 0000000..d8dcdbc --- /dev/null +++ b/tests/dockerfiles/Php.Dockerfile @@ -0,0 +1,16 @@ +FROM php:8.3-cli AS target +FROM interdiode/id-configurator:latest AS configuration +COPY --from=target / /src/ +COPY --from=generated-config Php.yaml /config.yaml +RUN id-configurator apply --config /config.yaml --src /src --dst /dst +FROM target AS runtime +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates unzip curl \ + && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \ + && rm -rf /var/lib/apt/lists/* +COPY --from=configuration /dst/ / +RUN mkdir -p /tmp/phptest && cd /tmp/phptest \ + && composer init -n --name test/phptest \ + && composer require interdiode/idconfiguratortestpkg \ + && grep -qx 'test-only package served by the id-configurator integration tests' \ + vendor/interdiode/idconfiguratortestpkg/marker.txt diff --git a/tests/dockerfiles/Python-alpine.Dockerfile b/tests/dockerfiles/Python-alpine.Dockerfile new file mode 100644 index 0000000..3c8317b --- /dev/null +++ b/tests/dockerfiles/Python-alpine.Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.13-alpine AS target +FROM interdiode/id-configurator:latest AS configuration +COPY --from=target / /src/ +COPY --from=generated-config Python.yaml /config.yaml +RUN id-configurator apply --config /config.yaml --src /src --dst /dst +FROM target AS runtime +ARG PYPI_REPO_PORT +# Warm up uv and poetry themselves from the real, network-reachable PyPI +# while the default index is still in place, so the actual tests below +# never need anything but the fixture repository configured by +# id-configurator. (easy_install/distutils, the fourth tool the `python` +# module configures, is not exercised here: both were removed from Python +# itself in 3.12, and setuptools no longer ships easy_install either, so +# there is no way to actually invoke it on a current interpreter.) +RUN pip install --no-cache-dir uv poetry +COPY --from=configuration /dst/ / +RUN pip install --no-cache-dir idconfiguratortestpkg \ + && python3 -c "import idconfiguratortestpkg" +RUN uv pip install --system idconfiguratortestpkg \ + && python3 -c "import idconfiguratortestpkg" +RUN mkdir -p /tmp/poetrytest && cd /tmp/poetrytest \ + && poetry init -n --name poetrytest \ + && poetry source add --priority explicit demo "https://host.docker.internal:${PYPI_REPO_PORT}/simple/" \ + && poetry add idconfiguratortestpkg --source demo diff --git a/tests/dockerfiles/Python.Dockerfile b/tests/dockerfiles/Python.Dockerfile index dee7606..b464ebe 100644 --- a/tests/dockerfiles/Python.Dockerfile +++ b/tests/dockerfiles/Python.Dockerfile @@ -1,9 +1,24 @@ -FROM python:3.13-slim AS detection -FROM interdiode/id_configurator:latest AS configuration -COPY --from=detection / /src/ +FROM python:3.13-slim AS target +FROM interdiode/id-configurator:latest AS configuration +COPY --from=target / /src/ COPY --from=generated-config Python.yaml /config.yaml RUN id-configurator apply --config /config.yaml --src /src --dst /dst -FROM python:3.13-slim AS runtime +FROM target AS runtime +ARG PYPI_REPO_PORT +# Warm up uv and poetry themselves from the real, network-reachable PyPI +# while the default index is still in place, so the actual tests below +# never need anything but the fixture repository configured by +# id-configurator. (easy_install/distutils, the fourth tool the `python` +# module configures, is not exercised here: both were removed from Python +# itself in 3.12, and setuptools no longer ships easy_install either, so +# there is no way to actually invoke it on a current interpreter.) +RUN pip install --no-cache-dir uv poetry COPY --from=configuration /dst/ / RUN pip install --no-cache-dir idconfiguratortestpkg \ && python3 -c "import idconfiguratortestpkg" +RUN uv pip install --system idconfiguratortestpkg \ + && python3 -c "import idconfiguratortestpkg" +RUN mkdir -p /tmp/poetrytest && cd /tmp/poetrytest \ + && poetry init -n --name poetrytest \ + && poetry source add --priority explicit demo "https://host.docker.internal:${PYPI_REPO_PORT}/simple/" \ + && poetry add idconfiguratortestpkg --source demo diff --git a/tests/dockerfiles/Rust.Dockerfile b/tests/dockerfiles/Rust.Dockerfile new file mode 100644 index 0000000..6206ab1 --- /dev/null +++ b/tests/dockerfiles/Rust.Dockerfile @@ -0,0 +1,16 @@ +FROM rust:1-slim AS target +FROM interdiode/id-configurator:latest AS configuration +COPY --from=target / /src/ +COPY --from=generated-config Rust.yaml /config.yaml +RUN id-configurator apply --config /config.yaml --src /src --dst /dst +FROM target AS runtime +COPY --from=configuration /dst/ / +# Deliberately not touching CARGO_HOME (the official image sets it to +# /usr/local/cargo): id-configurator writes /.cargo/config.toml, which +# cargo's config search always consults regardless of CARGO_HOME, precisely +# so this doesn't need to line up with wherever CARGO_HOME happens to point. +RUN cargo new /tmp/rusttest -q && cd /tmp/rusttest \ + && cargo add idconfiguratortestpkg \ + && cargo fetch \ + && grep -qx '// test-only crate served by the id-configurator integration tests' \ + "$(find "$CARGO_HOME/registry/src" -name lib.rs)" diff --git a/tests/dockerfiles/Yum.Dockerfile b/tests/dockerfiles/Yum.Dockerfile index 27d7926..16564a9 100644 --- a/tests/dockerfiles/Yum.Dockerfile +++ b/tests/dockerfiles/Yum.Dockerfile @@ -1,9 +1,9 @@ -FROM rockylinux:9 AS detection -FROM interdiode/id_configurator:latest AS configuration -COPY --from=detection / /src/ +FROM rockylinux:9 AS target +FROM interdiode/id-configurator:latest AS configuration +COPY --from=target / /src/ COPY --from=generated-config Yum.yaml /config.yaml RUN id-configurator apply --config /config.yaml --src /src --dst /dst -FROM rockylinux:9 AS runtime +FROM target AS runtime COPY --from=configuration /dst/ / RUN dnf install -y idconfiguratortestpkg \ && rpm -q idconfiguratortestpkg diff --git a/tests/dockerfiles/repos/apk-repo.Dockerfile b/tests/dockerfiles/repos/apk-repo.Dockerfile index 4c0c283..9bddd16 100644 --- a/tests/dockerfiles/repos/apk-repo.Dockerfile +++ b/tests/dockerfiles/repos/apk-repo.Dockerfile @@ -1,6 +1,7 @@ # Minimal Alpine (apk) repository with a signed index, serving one hand-built -# .apk package over plain HTTP, used by tox-docker to exercise the `apk` -# module integration test (tests/configs/Apk.yaml.tmpl, +# .apk package over TLS, using the same Test CA as the pypi-repo/apt-repo/ +# yum-repo fixtures, used by tox-docker to exercise the `apk` module +# integration test (tests/configs/Apk.yaml.j2, # tests/dockerfiles/Apk.Dockerfile). The package is signed with the static # key pair 040d0af5f97a0a44.rsa / 040d0af5f97a0a44.rsa.pub, whose public # half is what the `apk` module configuration is expected to install into @@ -39,5 +40,7 @@ RUN arch_dir=$(ls /home/builder/packages/builder) \ FROM nginx:alpine COPY --from=packagebuild /srv /srv +COPY repo-server.crt.pem /etc/nginx/certs/repo-server.crt.pem +COPY repo-server.key.pem /etc/nginx/certs/repo-server.key.pem COPY apk-repo.nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 8443 diff --git a/tests/dockerfiles/repos/apk-repo.nginx.conf b/tests/dockerfiles/repos/apk-repo.nginx.conf index 60ef320..aafed09 100644 --- a/tests/dockerfiles/repos/apk-repo.nginx.conf +++ b/tests/dockerfiles/repos/apk-repo.nginx.conf @@ -1,7 +1,10 @@ server { - listen 8443 default_server; + listen 8443 ssl default_server; server_name _; + ssl_certificate /etc/nginx/certs/repo-server.crt.pem; + ssl_certificate_key /etc/nginx/certs/repo-server.key.pem; + root /srv; autoindex off; } diff --git a/tests/dockerfiles/repos/build_test_cargo_package.py b/tests/dockerfiles/repos/build_test_cargo_package.py new file mode 100644 index 0000000..aaac3e3 --- /dev/null +++ b/tests/dockerfiles/repos/build_test_cargo_package.py @@ -0,0 +1,94 @@ +"""Build a minimal, offline, hand-rolled crate and its sparse registry index +entry, for integration-testing the `rust` module of id-configurator. + +No network access and no build backend (cargo) is used: the crate is a +plain gzipped tar assembled with the standard library only, so this script +can run in a minimal Python image with nothing installed. + +See https://doc.rust-lang.org/cargo/reference/registries.html#index-format +for the sparse registry index/config.json format this reproduces. +""" + +from __future__ import annotations + +import hashlib +import io +import json +import pathlib +import tarfile + +CRATE = "idconfiguratortestpkg" +VERSION = "0.0.1" +CRATE_FILE = f"{CRATE}-{VERSION}.crate" +OUT_DIR = pathlib.Path("/srv") + +CARGO_TOML = f"""[package] +name = "{CRATE}" +version = "{VERSION}" +edition = "2021" +""" +LIB_RS = "// test-only crate served by the id-configurator integration tests\n" + + +def _add_file(archive: tarfile.TarFile, name: str, content: bytes) -> None: + info = tarfile.TarInfo(name=f"{CRATE}-{VERSION}/{name}") + info.size = len(content) + archive.addfile(info, io.BytesIO(content)) + + +def build_crate() -> bytes: + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as archive: + _add_file(archive, "Cargo.toml", CARGO_TOML.encode("utf-8")) + _add_file(archive, "src/lib.rs", LIB_RS.encode("utf-8")) + return buffer.getvalue() + + +def index_path(name: str) -> pathlib.Path: + """Mirror cargo's sparse index path convention for a crate name.""" + if len(name) <= 2: + return pathlib.Path(str(len(name))) / name + if len(name) == 3: + return pathlib.Path("3") / name[0] / name + return pathlib.Path(name[0:2]) / name[2:4] / name + + +def write_index(out_dir: pathlib.Path, crate: bytes) -> None: + entry = { + "name": CRATE, + "vers": VERSION, + "deps": [], + "cksum": hashlib.sha256(crate).hexdigest(), + "features": {}, + "yanked": False, + } + path = out_dir / index_path(CRATE) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(entry) + "\n", encoding="utf-8") + + +def write_config_json(out_dir: pathlib.Path) -> None: + # Cargo requires a fully-qualified "dl" download template, so this bakes + # in a placeholder that nginx's sub_filter rewrites to the real + # https:/// at request time (see + # cargo-repo.nginx.conf) - the host and port this fixture is actually + # reached on aren't known at image-build time. + config = { + "dl": "__BASE_URL__/api/v1/crates/{crate}/{version}/download", + "api": "__BASE_URL__", + } + (out_dir / "config.json").write_text(json.dumps(config), encoding="utf-8") + + +def main() -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + crate = build_crate() + download_path = OUT_DIR / "api/v1/crates" / CRATE / VERSION / "download" + download_path.parent.mkdir(parents=True, exist_ok=True) + download_path.write_bytes(crate) + write_index(OUT_DIR, crate) + write_config_json(OUT_DIR) + + +if __name__ == "__main__": + main() diff --git a/tests/dockerfiles/repos/build_test_composer_package.py b/tests/dockerfiles/repos/build_test_composer_package.py new file mode 100644 index 0000000..b277d5a --- /dev/null +++ b/tests/dockerfiles/repos/build_test_composer_package.py @@ -0,0 +1,76 @@ +"""Build a minimal, offline, hand-rolled Composer package and its +`packages.json` index, for integration-testing the `php` module of +id-configurator. + +No network access and no build backend (composer) is used: the package is a +plain zip file assembled with the standard library only, so this script can +run in a minimal Python image with nothing installed. +""" + +from __future__ import annotations + +import hashlib +import io +import json +import pathlib +import zipfile + +VENDOR = "interdiode" +PACKAGE = "idconfiguratortestpkg" +NAME = f"{VENDOR}/{PACKAGE}" +VERSION = "0.0.1" +ZIP_NAME = f"{PACKAGE}-{VERSION}.zip" +OUT_DIR = pathlib.Path("/srv") + +COMPOSER_JSON = json.dumps({"name": NAME, "version": VERSION, "type": "library"}) + + +def build_zip() -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("composer.json", COMPOSER_JSON) + archive.writestr( + "marker.txt", + "test-only package served by the id-configurator integration tests\n", + ) + return buffer.getvalue() + + +def write_packages_json(out_dir: pathlib.Path, zip_content: bytes) -> None: + # Composer requires a fully-qualified dist URL: unlike PyPI's simple + # index, it does not resolve relative/root-relative ones against the + # repository's own URL for this (non-v2) repository format. The host + # and port this fixture will actually be reached on aren't known at + # image-build time (tox-docker only assigns them once the container + # starts), so this bakes in a placeholder that nginx's sub_filter + # rewrites to the real https:/// at request + # time (see php-repo.nginx.conf). + packages = { + "packages": { + NAME: { + VERSION: { + "name": NAME, + "version": VERSION, + "dist": { + "type": "zip", + "url": f"__BASE_URL__/{ZIP_NAME}", + "shasum": hashlib.sha1(zip_content).hexdigest(), # nosec B324 + }, + } + } + } + } + (out_dir / "packages.json").write_text( + json.dumps(packages, indent=2), encoding="utf-8" + ) + + +def main() -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + zip_content = build_zip() + (OUT_DIR / ZIP_NAME).write_bytes(zip_content) + write_packages_json(OUT_DIR, zip_content) + + +if __name__ == "__main__": + main() diff --git a/tests/dockerfiles/repos/build_test_jar.py b/tests/dockerfiles/repos/build_test_jar.py index 0a6e0ea..dbca5e6 100644 --- a/tests/dockerfiles/repos/build_test_jar.py +++ b/tests/dockerfiles/repos/build_test_jar.py @@ -9,6 +9,8 @@ can run in a minimal Python image with nothing installed. from __future__ import annotations +import hashlib +import io import pathlib import zipfile @@ -32,19 +34,32 @@ POM = f""" """ -def build_jar(path: pathlib.Path) -> None: - with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: +def build_jar() -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: archive.writestr("META-INF/MANIFEST.MF", MANIFEST) archive.writestr( "marker.txt", "test-only artifact served by the id-configurator integration tests\n", ) + return buffer.getvalue() + + +def _write_with_checksums(path: pathlib.Path, content: bytes) -> None: + """Write `content` to `path`, plus the .sha1/.md5 sidecar files most + build tools (Leiningen's aether resolver in particular) require to be + present, unlike Maven which merely warns when they are missing.""" + path.write_bytes(content) + sha1_digest = hashlib.sha1(content).hexdigest() # nosec B324 + (path.parent / f"{path.name}.sha1").write_text(sha1_digest, encoding="ascii") + md5_digest = hashlib.md5(content).hexdigest() # nosec B324 + (path.parent / f"{path.name}.md5").write_text(md5_digest, encoding="ascii") def main() -> None: OUT_DIR.mkdir(parents=True, exist_ok=True) - build_jar(OUT_DIR / f"{ARTIFACT}-{VERSION}.jar") - (OUT_DIR / f"{ARTIFACT}-{VERSION}.pom").write_text(POM, encoding="utf-8") + _write_with_checksums(OUT_DIR / f"{ARTIFACT}-{VERSION}.jar", build_jar()) + _write_with_checksums(OUT_DIR / f"{ARTIFACT}-{VERSION}.pom", POM.encode("utf-8")) if __name__ == "__main__": diff --git a/tests/dockerfiles/repos/build_test_npm_package.py b/tests/dockerfiles/repos/build_test_npm_package.py new file mode 100644 index 0000000..a64fc2c --- /dev/null +++ b/tests/dockerfiles/repos/build_test_npm_package.py @@ -0,0 +1,76 @@ +"""Build a minimal, offline, hand-rolled npm package and its registry +metadata document, for integration-testing the `npm` module of +id-configurator. + +No network access and no build backend (npm/node) is used: the tarball is a +plain gzipped tar assembled with the standard library only, so this script +can run in a minimal Python image with nothing installed. +""" + +from __future__ import annotations + +import base64 +import hashlib +import io +import json +import pathlib +import tarfile + +PACKAGE = "idconfiguratortestpkg" +VERSION = "0.0.1" +TARBALL_NAME = f"{PACKAGE}-{VERSION}.tgz" +OUT_DIR = pathlib.Path("/srv") + +PACKAGE_JSON = json.dumps({"name": PACKAGE, "version": VERSION}) +MARKER = "test-only package served by the id-configurator integration tests\n" + + +def _add_file(archive: tarfile.TarFile, name: str, content: bytes) -> None: + info = tarfile.TarInfo(name=f"package/{name}") + info.size = len(content) + archive.addfile(info, io.BytesIO(content)) + + +def build_tarball() -> bytes: + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as archive: + _add_file(archive, "package.json", PACKAGE_JSON.encode("utf-8")) + _add_file(archive, "marker.txt", MARKER.encode("utf-8")) + return buffer.getvalue() + + +def write_registry_metadata(out_dir: pathlib.Path, tarball: bytes) -> None: + # npm requires a fully-qualified tarball URL, so this bakes in a + # placeholder that nginx's sub_filter rewrites to the real + # https:/// at request time (see + # npm-repo.nginx.conf) - the host and port this fixture is actually + # reached on aren't known at image-build time. + sha1 = hashlib.sha1(tarball).hexdigest() # nosec B324 + sha512_b64 = base64.b64encode(hashlib.sha512(tarball).digest()).decode("ascii") + metadata = { + "name": PACKAGE, + "dist-tags": {"latest": VERSION}, + "versions": { + VERSION: { + "name": PACKAGE, + "version": VERSION, + "dist": { + "shasum": sha1, + "integrity": f"sha512-{sha512_b64}", + "tarball": f"__BASE_URL__/{TARBALL_NAME}", + }, + } + }, + } + (out_dir / PACKAGE).write_text(json.dumps(metadata, indent=2), encoding="utf-8") + + +def main() -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + tarball = build_tarball() + (OUT_DIR / TARBALL_NAME).write_bytes(tarball) + write_registry_metadata(OUT_DIR, tarball) + + +if __name__ == "__main__": + main() diff --git a/tests/dockerfiles/repos/cargo-repo.Dockerfile b/tests/dockerfiles/repos/cargo-repo.Dockerfile new file mode 100644 index 0000000..8d6e654 --- /dev/null +++ b/tests/dockerfiles/repos/cargo-repo.Dockerfile @@ -0,0 +1,17 @@ +# Minimal cargo sparse registry (index entry, config.json and one hand-built +# crate) served over TLS, using the same Test CA as the other *-repo +# fixtures, used by tox-docker to exercise the `rust` module integration +# test (tests/configs/Rust.yaml.j2, tests/dockerfiles/Rust.Dockerfile). +# +# Built by tox-docker, whose build context is this directory (not the repo +# root) - every COPY source below must live under tests/dockerfiles/repos/. +FROM python:3.13-slim AS packagebuild +COPY build_test_cargo_package.py /build_test_cargo_package.py +RUN python3 /build_test_cargo_package.py + +FROM nginx:alpine +COPY --from=packagebuild /srv /srv +COPY repo-server.crt.pem /etc/nginx/certs/repo-server.crt.pem +COPY repo-server.key.pem /etc/nginx/certs/repo-server.key.pem +COPY cargo-repo.nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 8443 diff --git a/tests/dockerfiles/repos/cargo-repo.nginx.conf b/tests/dockerfiles/repos/cargo-repo.nginx.conf new file mode 100644 index 0000000..98fe171 --- /dev/null +++ b/tests/dockerfiles/repos/cargo-repo.nginx.conf @@ -0,0 +1,22 @@ +server { + listen 8443 ssl default_server; + server_name _; + + ssl_certificate /etc/nginx/certs/repo-server.crt.pem; + ssl_certificate_key /etc/nginx/certs/repo-server.key.pem; + + root /srv; + autoindex off; + + # config.json bakes in a __BASE_URL__ placeholder for its "dl"/"api" + # templates, since the host:port this fixture is actually reached on is + # only known once tox-docker starts the container, not at image-build + # time. $http_host reflects exactly what the client (cargo) used to + # connect, e.g. host.docker.internal:55123. + location = /config.json { + default_type application/json; + sub_filter '__BASE_URL__' 'https://$http_host'; + sub_filter_once off; + sub_filter_types application/json; + } +} diff --git a/tests/dockerfiles/repos/certificate-repo.Dockerfile b/tests/dockerfiles/repos/certificate-repo.Dockerfile new file mode 100644 index 0000000..9748bc1 --- /dev/null +++ b/tests/dockerfiles/repos/certificate-repo.Dockerfile @@ -0,0 +1,32 @@ +# Minimal HTTPS server, using the same Test CA as the other *-repo fixtures, +# used by tox-docker to exercise the `certificates` module integration test +# (tests/configs/Certificate.yaml.j2, tests/dockerfiles/Certificate.Dockerfile). +# Unlike the package-manager fixtures, this one has nothing to do with a +# specific package format: it serves a static marker file (for wget, curl and +# openssl s_client) and a bare git repository over the "dumb" HTTP protocol +# (for `git clone`). +# +# Built by tox-docker, whose build context is this directory (not the repo +# root) - every COPY source below must live under tests/dockerfiles/repos/. +FROM alpine:3.20 AS gitbuild +RUN apk add --no-cache git +RUN git config --global init.defaultBranch main \ + && git config --global user.email test@example.invalid \ + && git config --global user.name "id-configurator tests" +RUN mkdir -p /work \ + && cd /work \ + && git init -q \ + && echo "trusted-via-root-ca" > marker.txt \ + && git add marker.txt \ + && git commit -q -m "initial commit" +RUN git clone --bare -q /work /srv/repo.git \ + && cd /srv/repo.git \ + && git update-server-info + +FROM nginx:alpine +RUN mkdir -p /srv && echo "trusted-via-root-ca" > /srv/marker.txt +COPY --from=gitbuild /srv/repo.git /srv/repo.git +COPY repo-server.crt.pem /etc/nginx/certs/repo-server.crt.pem +COPY repo-server.key.pem /etc/nginx/certs/repo-server.key.pem +COPY certificate-repo.nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 8443 diff --git a/tests/dockerfiles/repos/certificate-repo.nginx.conf b/tests/dockerfiles/repos/certificate-repo.nginx.conf new file mode 100644 index 0000000..aafed09 --- /dev/null +++ b/tests/dockerfiles/repos/certificate-repo.nginx.conf @@ -0,0 +1,10 @@ +server { + listen 8443 ssl default_server; + server_name _; + + ssl_certificate /etc/nginx/certs/repo-server.crt.pem; + ssl_certificate_key /etc/nginx/certs/repo-server.key.pem; + + root /srv; + autoindex off; +} diff --git a/tests/dockerfiles/repos/java-repo.Dockerfile b/tests/dockerfiles/repos/java-repo.Dockerfile index 18af623..8db6e87 100644 --- a/tests/dockerfiles/repos/java-repo.Dockerfile +++ b/tests/dockerfiles/repos/java-repo.Dockerfile @@ -1,6 +1,7 @@ -# Minimal Maven-compatible repository serving one hand-built jar over plain -# HTTP, used by tox-docker to exercise the `java` module integration test -# (tests/configs/Java.yaml.tmpl, tests/dockerfiles/Java.Dockerfile). +# Minimal Maven-compatible repository serving one hand-built jar over TLS, +# using the same Test CA as the pypi-repo/apt-repo/yum-repo/apk-repo +# fixtures, used by tox-docker to exercise the `java` module integration +# test (tests/configs/Java.yaml.j2, tests/dockerfiles/Java.Dockerfile). # # Built by tox-docker, whose build context is this directory (not the repo # root) - every COPY source below must live under tests/dockerfiles/repos/. @@ -10,5 +11,7 @@ RUN python3 /build_test_jar.py FROM nginx:alpine COPY --from=jarbuild /srv /srv +COPY repo-server.crt.pem /etc/nginx/certs/repo-server.crt.pem +COPY repo-server.key.pem /etc/nginx/certs/repo-server.key.pem COPY java-repo.nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 8443 diff --git a/tests/dockerfiles/repos/java-repo.nginx.conf b/tests/dockerfiles/repos/java-repo.nginx.conf index 60ef320..aafed09 100644 --- a/tests/dockerfiles/repos/java-repo.nginx.conf +++ b/tests/dockerfiles/repos/java-repo.nginx.conf @@ -1,7 +1,10 @@ server { - listen 8443 default_server; + listen 8443 ssl default_server; server_name _; + ssl_certificate /etc/nginx/certs/repo-server.crt.pem; + ssl_certificate_key /etc/nginx/certs/repo-server.key.pem; + root /srv; autoindex off; } diff --git a/tests/dockerfiles/repos/npm-repo.Dockerfile b/tests/dockerfiles/repos/npm-repo.Dockerfile new file mode 100644 index 0000000..95253cc --- /dev/null +++ b/tests/dockerfiles/repos/npm-repo.Dockerfile @@ -0,0 +1,17 @@ +# Minimal npm registry (one package's metadata document plus its tarball) +# served over TLS, using the same Test CA as the other *-repo fixtures, used +# by tox-docker to exercise the `npm` module integration test +# (tests/configs/Npm.yaml.j2, tests/dockerfiles/Npm.Dockerfile). +# +# Built by tox-docker, whose build context is this directory (not the repo +# root) - every COPY source below must live under tests/dockerfiles/repos/. +FROM python:3.13-slim AS packagebuild +COPY build_test_npm_package.py /build_test_npm_package.py +RUN python3 /build_test_npm_package.py + +FROM nginx:alpine +COPY --from=packagebuild /srv /srv +COPY repo-server.crt.pem /etc/nginx/certs/repo-server.crt.pem +COPY repo-server.key.pem /etc/nginx/certs/repo-server.key.pem +COPY npm-repo.nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 8443 diff --git a/tests/dockerfiles/repos/npm-repo.nginx.conf b/tests/dockerfiles/repos/npm-repo.nginx.conf new file mode 100644 index 0000000..0e95535 --- /dev/null +++ b/tests/dockerfiles/repos/npm-repo.nginx.conf @@ -0,0 +1,22 @@ +server { + listen 8443 ssl default_server; + server_name _; + + ssl_certificate /etc/nginx/certs/repo-server.crt.pem; + ssl_certificate_key /etc/nginx/certs/repo-server.key.pem; + + root /srv; + autoindex off; + + # The package metadata document bakes in a __BASE_URL__ placeholder for + # its tarball URL, since the host:port this fixture is actually reached + # on is only known once tox-docker starts the container, not at + # image-build time. $http_host reflects exactly what the client (npm) + # used to connect, e.g. host.docker.internal:55123. + location = /idconfiguratortestpkg { + default_type application/json; + sub_filter '__BASE_URL__' 'https://$http_host'; + sub_filter_once off; + sub_filter_types application/json; + } +} diff --git a/tests/dockerfiles/repos/php-repo.Dockerfile b/tests/dockerfiles/repos/php-repo.Dockerfile new file mode 100644 index 0000000..548501a --- /dev/null +++ b/tests/dockerfiles/repos/php-repo.Dockerfile @@ -0,0 +1,17 @@ +# Minimal Composer repository ("composer" type: a static packages.json plus +# one hand-built zip package) served over TLS, using the same Test CA as the +# other *-repo fixtures, used by tox-docker to exercise the `php` module +# integration test (tests/configs/Php.yaml.j2, tests/dockerfiles/Php.Dockerfile). +# +# Built by tox-docker, whose build context is this directory (not the repo +# root) - every COPY source below must live under tests/dockerfiles/repos/. +FROM python:3.13-slim AS packagebuild +COPY build_test_composer_package.py /build_test_composer_package.py +RUN python3 /build_test_composer_package.py + +FROM nginx:alpine +COPY --from=packagebuild /srv /srv +COPY repo-server.crt.pem /etc/nginx/certs/repo-server.crt.pem +COPY repo-server.key.pem /etc/nginx/certs/repo-server.key.pem +COPY php-repo.nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 8443 diff --git a/tests/dockerfiles/repos/php-repo.nginx.conf b/tests/dockerfiles/repos/php-repo.nginx.conf new file mode 100644 index 0000000..b59093c --- /dev/null +++ b/tests/dockerfiles/repos/php-repo.nginx.conf @@ -0,0 +1,22 @@ +server { + listen 8443 ssl default_server; + server_name _; + + ssl_certificate /etc/nginx/certs/repo-server.crt.pem; + ssl_certificate_key /etc/nginx/certs/repo-server.key.pem; + + root /srv; + autoindex off; + + # packages.json bakes in a __BASE_URL__ placeholder for its dist URL, + # since the host:port this fixture is actually reached on is only known + # once tox-docker starts the container, not at image-build time. + # $http_host reflects exactly what the client (Composer) used to + # connect, e.g. host.docker.internal:55123. + location = /packages.json { + default_type application/json; + sub_filter '__BASE_URL__' 'https://$http_host'; + sub_filter_once off; + sub_filter_types application/json; + } +} diff --git a/tests/dockerfiles/repos/yum-repo.Dockerfile b/tests/dockerfiles/repos/yum-repo.Dockerfile index c496ffd..4d9150b 100644 --- a/tests/dockerfiles/repos/yum-repo.Dockerfile +++ b/tests/dockerfiles/repos/yum-repo.Dockerfile @@ -1,7 +1,7 @@ -# Minimal YUM/DNF repository serving one hand-built RPM package over plain -# HTTP (gpgcheck is off in the test config, so no TLS/signing is needed), -# used by tox-docker to exercise the `yum` module integration test -# (tests/configs/Yum.yaml.tmpl, tests/dockerfiles/Yum.Dockerfile). +# Minimal YUM/DNF repository serving one hand-built RPM package over TLS, +# using the same Test CA as the pypi-repo/apt-repo fixtures, used by +# tox-docker to exercise the `yum` module integration test +# (tests/configs/Yum.yaml.j2, tests/dockerfiles/Yum.Dockerfile). # # Built by tox-docker, whose build context is this directory (not the repo # root) - every COPY source below must live under tests/dockerfiles/repos/. @@ -16,5 +16,7 @@ RUN createrepo_c /srv FROM nginx:alpine COPY --from=packagebuild /srv /srv +COPY repo-server.crt.pem /etc/nginx/certs/repo-server.crt.pem +COPY repo-server.key.pem /etc/nginx/certs/repo-server.key.pem COPY yum-repo.nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 8443 diff --git a/tests/dockerfiles/repos/yum-repo.nginx.conf b/tests/dockerfiles/repos/yum-repo.nginx.conf index 60ef320..aafed09 100644 --- a/tests/dockerfiles/repos/yum-repo.nginx.conf +++ b/tests/dockerfiles/repos/yum-repo.nginx.conf @@ -1,7 +1,10 @@ server { - listen 8443 default_server; + listen 8443 ssl default_server; server_name _; + ssl_certificate /etc/nginx/certs/repo-server.crt.pem; + ssl_certificate_key /etc/nginx/certs/repo-server.key.pem; + root /srv; autoindex off; } diff --git a/tests/test_certificates.py b/tests/test_certificates.py index 096cbec..0708f64 100644 --- a/tests/test_certificates.py +++ b/tests/test_certificates.py @@ -1,11 +1,10 @@ -import subprocess -from unittest.mock import MagicMock - -import pytest - from id_configurator.config import Configuration from id_configurator.context import Context -from id_configurator.modules.certificates import CertificatesModule +from id_configurator.modules.certificates import ( + CertificatesModule, + _pem_name, + _resolve_target_dir, +) CERT_CONTENT = "fake-cert-bytes" @@ -32,38 +31,30 @@ def test_is_relevant_false_when_no_certificates(tmp_path) -> None: assert CertificatesModule().is_relevant(context) is False -def _mock_success(monkeypatch, stderr: str = "") -> MagicMock: - mock_run = MagicMock( - return_value=subprocess.CompletedProcess( - args=[], returncode=0, stdout="", stderr=stderr - ) - ) - monkeypatch.setattr("id_configurator.modules.certificates.subprocess.run", mock_run) - return mock_run - - -def test_apply_writes_certificate_and_calls_update_ca_certificates( - tmp_path, monkeypatch -) -> None: - mock_run = _mock_success(monkeypatch) +def test_apply_writes_certificate_and_rebuilds_bundle(tmp_path) -> None: dst = tmp_path / "dst" context = Context(config=make_config(True), dst=dst, src=None) CertificatesModule().apply(context) - written = dst / "usr/local/share/ca-certificates/new-root-ca.crt" - assert written.read_bytes() == b"fake-cert-bytes" - mock_run.assert_called_once() - args = mock_run.call_args.args[0] - assert args[0] == "update-ca-certificates" - assert "--certsconf" not in args - assert "--certsdir" not in args + local_cert = dst / "usr/local/share/ca-certificates/new-root-ca.crt" + assert local_cert.read_bytes() == b"fake-cert-bytes" + + bundle = dst / "etc/ssl/certs/ca-certificates.crt" + assert bundle.read_bytes() == b"fake-cert-bytes\n" + + pem = dst / "etc/ssl/certs/new-root-ca.pem" + assert pem.is_symlink() + assert ( + pem.readlink().as_posix() == "/usr/local/share/ca-certificates/new-root-ca.crt" + ) + + openssl_default = dst / "usr/lib/ssl/cert.pem" + assert openssl_default.is_symlink() + assert openssl_default.readlink().as_posix() == "/etc/ssl/certs/ca-certificates.crt" -def test_apply_preserves_existing_local_certificates_from_src( - tmp_path, monkeypatch -) -> None: - mock_run = _mock_success(monkeypatch, stderr="some warning") +def test_apply_merges_conf_listed_and_local_certificates(tmp_path) -> None: src = tmp_path / "src" local_certs_dir = src / "usr/local/share/ca-certificates" local_certs_dir.mkdir(parents=True) @@ -71,9 +62,14 @@ def test_apply_preserves_existing_local_certificates_from_src( (local_certs_dir / "new-root-ca.crt").write_bytes( b"stale-bytes-should-be-overwritten" ) - (src / "etc/ca-certificates.conf").parent.mkdir(parents=True) - (src / "etc/ca-certificates.conf").write_text("existing.crt\n", encoding="utf-8") - (src / "usr/share/ca-certificates").mkdir(parents=True) + system_certs_dir = src / "usr/share/ca-certificates" + system_certs_dir.mkdir(parents=True) + # No trailing newline, to exercise the newline-normalization on append. + (system_certs_dir / "system-ca.crt").write_bytes(b"system-ca-bytes") + (src / "etc/ca-certificates.conf").parent.mkdir(parents=True, exist_ok=True) + (src / "etc/ca-certificates.conf").write_text( + "# a comment\n\n!disabled-ca.crt\nsystem-ca.crt\n", encoding="utf-8" + ) dst = tmp_path / "dst" context = Context(config=make_config(True), dst=dst, src=src) @@ -84,35 +80,160 @@ def test_apply_preserves_existing_local_certificates_from_src( assert (local_dst / "existing.crt").read_bytes() == b"existing-bytes" assert (local_dst / "new-root-ca.crt").read_bytes() == b"fake-cert-bytes" - args = mock_run.call_args.args[0] - assert "--certsconf" in args - assert str(src / "etc/ca-certificates.conf") in args - assert "--certsdir" in args - assert str(src / "usr/share/ca-certificates") in args + bundle = dst / "etc/ssl/certs/ca-certificates.crt" + # ca-certificates.conf entries first, then local certificates sorted by name. + assert bundle.read_bytes() == ( + b"system-ca-bytes\nexisting-bytes\nfake-cert-bytes\n" + ) + + etc_certs = dst / "etc/ssl/certs" + assert ( + etc_certs / "system-ca.pem" + ).readlink().as_posix() == "/usr/share/ca-certificates/system-ca.crt" + assert ( + etc_certs / "existing.pem" + ).readlink().as_posix() == "/usr/local/share/ca-certificates/existing.crt" + assert ( + etc_certs / "new-root-ca.pem" + ).readlink().as_posix() == "/usr/local/share/ca-certificates/new-root-ca.crt" + # The disabled entry must not produce a symlink or appear in the bundle. + assert not (etc_certs / "disabled-ca.pem").exists() -def test_update_ca_certificates_raises_when_binary_missing( - tmp_path, monkeypatch +def test_apply_warns_and_skips_missing_conf_entry(tmp_path, caplog) -> None: + src = tmp_path / "src" + (src / "usr/share/ca-certificates").mkdir(parents=True) + (src / "etc/ca-certificates.conf").parent.mkdir(parents=True, exist_ok=True) + (src / "etc/ca-certificates.conf").write_text("missing.crt\n", encoding="utf-8") + + dst = tmp_path / "dst" + context = Context(config=make_config(True), dst=dst, src=src) + + with caplog.at_level("WARNING"): + CertificatesModule().apply(context) + + assert "missing.crt" in caplog.text + bundle = dst / "etc/ssl/certs/ca-certificates.crt" + assert bundle.read_bytes() == b"fake-cert-bytes\n" + + +def test_pem_name_sanitizes_special_characters() -> None: + assert _pem_name("usr/share/ca-certificates/New Root, CA.crt") == "New_Root__CA.pem" + assert _pem_name("Vendor (Test).crt") == "Vendor_=Test=.pem" + + +def test_apply_skips_rhel_trust_store_when_absent(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(True), dst=dst, src=None) + + CertificatesModule().apply(context) + + assert not (dst / "etc/pki").exists() + + +def test_apply_updates_rhel_trust_store_when_present(tmp_path) -> None: + src = tmp_path / "src" + anchors_dir = src / "etc/pki/ca-trust/source/anchors" + anchors_dir.mkdir(parents=True) + (anchors_dir / "existing-anchor.pem").write_bytes(b"existing-anchor-bytes") + (anchors_dir / "new-root-ca.crt").write_bytes(b"stale-should-be-overwritten") + extracted_bundle = src / "etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem" + extracted_bundle.parent.mkdir(parents=True) + # No trailing newline, to exercise the newline-normalization on append. + extracted_bundle.write_bytes(b"vendor-bundle-bytes") + + dst = tmp_path / "dst" + context = Context(config=make_config(True), dst=dst, src=src) + + CertificatesModule().apply(context) + + anchors_dst = dst / "etc/pki/ca-trust/source/anchors" + assert ( + anchors_dst / "existing-anchor.pem" + ).read_bytes() == b"existing-anchor-bytes" + assert (anchors_dst / "new-root-ca.crt").read_bytes() == b"fake-cert-bytes" + + bundle_dst = dst / "etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem" + assert bundle_dst.read_bytes() == ( + b"vendor-bundle-bytes\nexisting-anchor-bytes\nfake-cert-bytes\n" + ) + + +def test_apply_rebuilds_rhel_trust_store_without_preexisting_bundle(tmp_path) -> None: + src = tmp_path / "src" + (src / "etc/pki/ca-trust/source/anchors").mkdir(parents=True) + + dst = tmp_path / "dst" + context = Context(config=make_config(True), dst=dst, src=src) + + CertificatesModule().apply(context) + + bundle_dst = dst / "etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem" + assert bundle_dst.read_bytes() == b"fake-cert-bytes\n" + + +def test_resolve_target_dir_returns_unchanged_when_not_a_symlink(tmp_path) -> None: + src = tmp_path / "src" + (src / "etc/ssl/certs").mkdir(parents=True) + context = Context(config=make_config(True), dst=tmp_path / "dst", src=src) + + assert _resolve_target_dir(context, "etc/ssl/certs") == "etc/ssl/certs" + + +def test_resolve_target_dir_returns_unchanged_without_src(tmp_path) -> None: + context = Context(config=make_config(True), dst=tmp_path / "dst", src=None) + + assert _resolve_target_dir(context, "etc/ssl/certs") == "etc/ssl/certs" + + +def test_resolve_target_dir_follows_absolute_symlink(tmp_path) -> None: + src = tmp_path / "src" + (src / "etc/pki/tls/certs").mkdir(parents=True) + (src / "etc/ssl").mkdir(parents=True) + (src / "etc/ssl/certs").symlink_to("/etc/pki/tls/certs") + context = Context(config=make_config(True), dst=tmp_path / "dst", src=src) + + assert _resolve_target_dir(context, "etc/ssl/certs") == "etc/pki/tls/certs" + + +def test_resolve_target_dir_follows_relative_symlink(tmp_path) -> None: + src = tmp_path / "src" + (src / "etc/pki/tls/certs").mkdir(parents=True) + (src / "etc/ssl").mkdir(parents=True) + (src / "etc/ssl/certs").symlink_to("../pki/tls/certs") + context = Context(config=make_config(True), dst=tmp_path / "dst", src=src) + + assert _resolve_target_dir(context, "etc/ssl/certs") == "etc/pki/tls/certs" + + +def test_resolve_target_dir_falls_back_when_relative_symlink_escapes_src( + tmp_path, ) -> None: - monkeypatch.setattr( - "id_configurator.modules.certificates.subprocess.run", - MagicMock(side_effect=FileNotFoundError()), - ) - context = Context(config=make_config(True), dst=tmp_path / "dst", src=None) + src = tmp_path / "src" + (src / "etc/ssl").mkdir(parents=True) + # Escapes past itself via enough ".." components. + (src / "etc/ssl/certs").symlink_to("../../../../../outside") + context = Context(config=make_config(True), dst=tmp_path / "dst", src=src) - with pytest.raises(RuntimeError, match="update-ca-certificates is not available"): - CertificatesModule().apply(context) + assert _resolve_target_dir(context, "etc/ssl/certs") == "etc/ssl/certs" -def test_update_ca_certificates_raises_on_failure(tmp_path, monkeypatch) -> None: - error = subprocess.CalledProcessError( - returncode=1, cmd=["update-ca-certificates"], stderr="boom" - ) - monkeypatch.setattr( - "id_configurator.modules.certificates.subprocess.run", - MagicMock(side_effect=error), - ) - context = Context(config=make_config(True), dst=tmp_path / "dst", src=None) +def test_apply_writes_bundle_to_rhel_style_symlinked_etccertsdir(tmp_path) -> None: + src = tmp_path / "src" + (src / "etc/pki/tls/certs").mkdir(parents=True) + (src / "etc/ssl").mkdir(parents=True) + (src / "etc/ssl/certs").symlink_to("/etc/pki/tls/certs") - with pytest.raises(RuntimeError, match="update-ca-certificates failed"): - CertificatesModule().apply(context) + dst = tmp_path / "dst" + context = Context(config=make_config(True), dst=dst, src=src) + + CertificatesModule().apply(context) + + assert not (dst / "etc/ssl/certs").exists() + bundle = dst / "etc/pki/tls/certs/ca-certificates.crt" + assert bundle.read_bytes() == b"fake-cert-bytes\n" + pem = dst / "etc/pki/tls/certs/new-root-ca.pem" + assert pem.is_symlink() + # /usr/lib/ssl/cert.pem is Debian/Ubuntu-specific: not OpenSSL's default + # CAfile on this (RHEL-style) system, so it must not be created. + assert not (dst / "usr/lib/ssl/cert.pem").exists() diff --git a/tests/test_cli.py b/tests/test_cli.py index 4784503..6062db0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,4 @@ import json -import subprocess from unittest.mock import MagicMock from typer.testing import CliRunner @@ -45,7 +44,10 @@ def test_list_modules() -> None: "apt", "certificates", "java", + "npm", + "php", "python", + "rust", "yum", } @@ -66,14 +68,7 @@ def test_apply_skips_irrelevant_modules(tmp_path, caplog) -> None: assert "skipping module python" in caplog.text -def test_apply_runs_relevant_module(tmp_path, monkeypatch) -> None: - mock_run = MagicMock( - return_value=subprocess.CompletedProcess( - args=[], returncode=0, stdout="", stderr="" - ) - ) - monkeypatch.setattr("id_configurator.modules.certificates.subprocess.run", mock_run) - +def test_apply_runs_relevant_module(tmp_path) -> None: config_path = write_config(tmp_path, CONFIG_WITH_CERT) dst = tmp_path / "dst" result = runner.invoke( @@ -82,7 +77,7 @@ def test_apply_runs_relevant_module(tmp_path, monkeypatch) -> None: assert result.exit_code == 0 assert (dst / "usr/local/share/ca-certificates/my-ca.crt").read_bytes() == b"fake" - mock_run.assert_called_once() + assert (dst / "etc/ssl/certs/ca-certificates.crt").read_bytes() == b"fake\n" def test_main_default_name_calls_app(monkeypatch) -> None: diff --git a/tests/test_java.py b/tests/test_java.py index 74923b2..931534f 100644 --- a/tests/test_java.py +++ b/tests/test_java.py @@ -122,8 +122,11 @@ def test_apply_adds_certificate_to_pkcs12_and_jks_keystores(tmp_path) -> None: JavaModule().apply(context) + # The written truststore's trust-only entries are unencrypted (matching + # the JDK's own cacerts), so they load with no password at all - the + # password a caller would use for a source-of-truth JDK-style store. pkcs12_result = pkcs12.load_pkcs12( - (dst / "usr/lib/jvm/java-21/lib/security/cacerts").read_bytes(), b"changeit" + (dst / "usr/lib/jvm/java-21/lib/security/cacerts").read_bytes(), None ) pkcs12_aliases = {entry.friendly_name for entry in pkcs12_result.additional_certs} assert pkcs12_aliases == {b"already-trusted-ca", b"interdiode-root-ca"} diff --git a/tests/test_npm.py b/tests/test_npm.py new file mode 100644 index 0000000..69fa45f --- /dev/null +++ b/tests/test_npm.py @@ -0,0 +1,71 @@ +from id_configurator.config import Configuration +from id_configurator.context import Context +from id_configurator.modules.npm import NpmModule + +CONFIG_WITH_REPO = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "npm": {"repositories": [{"name": "demo", "url": "https://example.invalid/"}]} + }, +} + +CONFIG_WITH_TWO_REPOS = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "npm": { + "repositories": [ + {"name": "demo", "url": "https://example.invalid/"}, + {"name": "myorg", "url": "https://other.invalid/"}, + ] + } + }, +} + +EMPTY_CONFIG = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", +} + + +def make_config(data: dict) -> Configuration: + return Configuration.model_validate(data) + + +def test_is_relevant_false_when_no_repositories(tmp_path) -> None: + context = Context(config=make_config(EMPTY_CONFIG), dst=tmp_path / "dst", src=None) + assert NpmModule().is_relevant(context) is False + + +def test_is_relevant_true_with_repository(tmp_path) -> None: + context = Context( + config=make_config(CONFIG_WITH_REPO), dst=tmp_path / "dst", src=None + ) + assert NpmModule().is_relevant(context) is True + + +def test_apply_writes_default_registry_and_cafile(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None) + + NpmModule().apply(context) + + npmrc = (dst / "usr/local/etc/npmrc").read_text(encoding="utf-8") + assert npmrc == ( + "cafile=/etc/ssl/certs/ca-certificates.crt\nregistry=https://example.invalid/\n" + ) + + +def test_apply_writes_additional_repositories_scoped(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None) + + NpmModule().apply(context) + + npmrc = (dst / "usr/local/etc/npmrc").read_text(encoding="utf-8") + assert npmrc == ( + "cafile=/etc/ssl/certs/ca-certificates.crt\n" + "registry=https://example.invalid/\n" + "@myorg:registry=https://other.invalid/\n" + ) diff --git a/tests/test_php.py b/tests/test_php.py new file mode 100644 index 0000000..7fb07e1 --- /dev/null +++ b/tests/test_php.py @@ -0,0 +1,81 @@ +import json + +from id_configurator.config import Configuration +from id_configurator.context import Context +from id_configurator.modules.php import PhpModule + +CONFIG_WITH_REPO = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "php": { + "repositories": [ + {"name": "demo", "url": "https://example.invalid/composer"} + ] + } + }, +} + +CONFIG_WITH_TWO_REPOS = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "php": { + "repositories": [ + {"name": "demo", "url": "https://example.invalid/composer"}, + {"name": "other", "url": "https://other.invalid/composer"}, + ] + } + }, +} + +EMPTY_CONFIG = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", +} + + +def make_config(data: dict) -> Configuration: + return Configuration.model_validate(data) + + +def test_is_relevant_false_when_no_repositories(tmp_path) -> None: + context = Context(config=make_config(EMPTY_CONFIG), dst=tmp_path / "dst", src=None) + assert PhpModule().is_relevant(context) is False + + +def test_is_relevant_true_with_repository(tmp_path) -> None: + context = Context( + config=make_config(CONFIG_WITH_REPO), dst=tmp_path / "dst", src=None + ) + assert PhpModule().is_relevant(context) is True + + +def test_apply_writes_global_composer_config(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None) + + PhpModule().apply(context) + + config_path = dst / "root/.composer/config.json" + content = json.loads(config_path.read_text(encoding="utf-8")) + assert content == { + "repositories": [ + { + "name": "demo", + "type": "composer", + "url": "https://example.invalid/composer", + } + ] + } + + +def test_apply_writes_multiple_repositories_in_order(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None) + + PhpModule().apply(context) + + config_path = dst / "root/.composer/config.json" + content = json.loads(config_path.read_text(encoding="utf-8")) + assert [repo["name"] for repo in content["repositories"]] == ["demo", "other"] diff --git a/tests/test_rust.py b/tests/test_rust.py new file mode 100644 index 0000000..8dc7c9a --- /dev/null +++ b/tests/test_rust.py @@ -0,0 +1,80 @@ +from id_configurator.config import Configuration +from id_configurator.context import Context +from id_configurator.modules.rust import RustModule + +CONFIG_WITH_REPO = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "rust": {"repositories": [{"name": "demo", "url": "https://example.invalid/"}]} + }, +} + +CONFIG_WITH_TWO_REPOS = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "rust": { + "repositories": [ + {"name": "demo", "url": "https://example.invalid/"}, + {"name": "other", "url": "https://other.invalid/"}, + ] + } + }, +} + +EMPTY_CONFIG = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", +} + + +def make_config(data: dict) -> Configuration: + return Configuration.model_validate(data) + + +def test_is_relevant_false_when_no_repositories(tmp_path) -> None: + context = Context(config=make_config(EMPTY_CONFIG), dst=tmp_path / "dst", src=None) + assert RustModule().is_relevant(context) is False + + +def test_is_relevant_true_with_repository(tmp_path) -> None: + context = Context( + config=make_config(CONFIG_WITH_REPO), dst=tmp_path / "dst", src=None + ) + assert RustModule().is_relevant(context) is True + + +def test_apply_writes_replacement_source(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None) + + RustModule().apply(context) + + config_toml = (dst / ".cargo/config.toml").read_text(encoding="utf-8") + assert config_toml == ( + "[source.crates-io]\n" + 'replace-with = "demo"\n' + "\n" + "[source.demo]\n" + 'registry = "sparse+https://example.invalid/"\n' + ) + + +def test_apply_writes_additional_repositories_as_named_registries(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None) + + RustModule().apply(context) + + config_toml = (dst / ".cargo/config.toml").read_text(encoding="utf-8") + assert config_toml == ( + "[source.crates-io]\n" + 'replace-with = "demo"\n' + "\n" + "[source.demo]\n" + 'registry = "sparse+https://example.invalid/"\n' + "\n" + "[registries.other]\n" + 'index = "sparse+https://other.invalid/"\n' + ) diff --git a/tests/test_yum.py b/tests/test_yum.py index ea105e5..4e3dc1f 100644 --- a/tests/test_yum.py +++ b/tests/test_yum.py @@ -178,6 +178,16 @@ def test_import_key_raises_when_gpg_binary_missing(tmp_path, monkeypatch) -> Non YumModule().apply(context) +def test_import_key_raises_when_gpg_not_on_path(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("id_configurator.modules.yum.shutil.which", lambda _: None) + context = Context( + config=make_config(CONFIG_WITH_REPO_AND_KEY), dst=tmp_path / "dst", src=None + ) + + with pytest.raises(RuntimeError, match="gpg is not available"): + YumModule().apply(context) + + def test_import_key_raises_on_invalid_key(tmp_path, monkeypatch) -> None: mock_run = MagicMock( return_value=subprocess.CompletedProcess( diff --git a/tox.ini b/tox.ini index 6e6071b..4872b62 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -env_list = py313,py314,coverage,packaging,publish,module-python,module-apt,module-yum,module-apk,module-java +env_list = py313,py314,coverage,packaging,packaging-amd64,packaging-arm64,publish,module-python,module-apt,module-yum,module-apk,module-java,module-certificates,module-php,module-npm,module-rust isolated_build = true requires = tox>=4 @@ -12,7 +12,7 @@ change_dir = {env_tmp_dir} set_env = COVERAGE_FILE = {env_dir}/coverage.db commands = - pytest {tox_root}/tests --cov-config={tox_root}/pyproject.toml --cov-fail-under=0 {posargs} + python3 -m pytest {tox_root}/tests --cov-config={tox_root}/pyproject.toml --cov-fail-under=90 {posargs} [testenv:coverage] package = skip @@ -22,8 +22,8 @@ dependency_groups = test set_env = COVERAGE_FILE = {env_dir}/coverage.db commands = - python3 -m coverage combine - python3 -m coverage xml -o .tox/coverage.xml --ignore + python3 -m coverage combine {toxworkdir}/py313/coverage.db {toxworkdir}/py314/coverage.db + python3 -m coverage xml -o .tox/coverage.xml --ignore --fail-under=0 python3 -m coverage report -m --fail-under=94 --ignore --skip-covered [testenv:packaging] @@ -37,7 +37,33 @@ description = depends = update commands = - python3 -m id_tools build docker --platform linux/amd64,linux/arm64/v8 + python3 -m id_tools build docker --platform linux/amd64,linux/arm64 + +[testenv:packaging-amd64] +package = skip +change_dir = {tox_root} +allowlist_externals = + docker +dependency_groups = dev +description = + Generate the docker images for the different platform +depends = + update +commands = + python3 -m id_tools build docker --platform linux/amd64 + +[testenv:packaging-arm64] +package = skip +change_dir = {tox_root} +allowlist_externals = + docker +dependency_groups = dev +description = + Generate the docker images for the different platform +depends = + update +commands = + python3 -m id_tools build docker --platform linux/arm64 [testenv:publish] package = skip @@ -78,7 +104,10 @@ allowlist_externals = docker commands = python3 -m id_tools test render-config {tox_root}/tests/configs/Python.yaml.j2 {env_dir}/configs/Python.yaml - docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Python.Dockerfile -t id-configurator-python . + docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg PYPI_REPO_PORT={env:PYPI_REPO_PORT} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Python-alpine.Dockerfile -t id-configurator-python . + docker rmi id-configurator-python + docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg PYPI_REPO_PORT={env:PYPI_REPO_PORT} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Python.Dockerfile -t id-configurator-python . + docker rmi id-configurator-python [docker:apt-repo] dockerfile = {tox_root}/tests/dockerfiles/repos/apt-repo.Dockerfile @@ -101,12 +130,13 @@ allowlist_externals = commands = python3 -m id_tools test render-config {tox_root}/tests/configs/Apt.yaml.j2 {env_dir}/configs/Apt.yaml docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Apt.Dockerfile -t id-configurator-apt . + docker rmi id-configurator-apt [docker:yum-repo] dockerfile = {tox_root}/tests/dockerfiles/repos/yum-repo.Dockerfile expose = YUM_REPO_PORT=8443/tcp -healthcheck_cmd = wget -qO- http://127.0.0.1:8443/repodata/repomd.xml || exit 1 +healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/repodata/repomd.xml || exit 1 healthcheck_interval = 1 healthcheck_retries = 30 healthcheck_timeout = 2 @@ -123,12 +153,13 @@ allowlist_externals = commands = python3 -m id_tools test render-config {tox_root}/tests/configs/Yum.yaml.j2 {env_dir}/configs/Yum.yaml docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Yum.Dockerfile -t id-configurator-yum . + docker rmi id-configurator-yum [docker:apk-repo] dockerfile = {tox_root}/tests/dockerfiles/repos/apk-repo.Dockerfile expose = APK_REPO_PORT=8443/tcp -healthcheck_cmd = wget -qO- http://127.0.0.1:8443/APKINDEX.tar.gz || exit 1 +healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/APKINDEX.tar.gz || exit 1 healthcheck_interval = 1 healthcheck_retries = 30 healthcheck_timeout = 2 @@ -154,12 +185,13 @@ description = commands = python3 -m id_tools test render-config {tox_root}/tests/configs/Apk.yaml.j2 {env_dir}/configs/Apk.yaml docker buildx build --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Apk.Dockerfile -t id-configurator-apk . + docker rmi id-configurator-apk [docker:java-repo] dockerfile = {tox_root}/tests/dockerfiles/repos/java-repo.Dockerfile expose = JAVA_REPO_PORT=8443/tcp -healthcheck_cmd = wget -qO- http://127.0.0.1:8443/org/interdiode/idconfiguratortestpkg/0.0.1/idconfiguratortestpkg-0.0.1.pom || exit 1 +healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/org/interdiode/idconfiguratortestpkg/0.0.1/idconfiguratortestpkg-0.0.1.pom || exit 1 healthcheck_interval = 1 healthcheck_retries = 30 healthcheck_timeout = 2 @@ -176,3 +208,101 @@ allowlist_externals = commands = python3 -m id_tools test render-config {tox_root}/tests/configs/Java.yaml.j2 {env_dir}/configs/Java.yaml docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Java.Dockerfile -t id-configurator-java . + docker rmi id-configurator-java + +[docker:certificate-repo] +dockerfile = {tox_root}/tests/dockerfiles/repos/certificate-repo.Dockerfile +expose = + CERTIFICATE_REPO_PORT=8443/tcp +healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/marker.txt || exit 1 +healthcheck_interval = 1 +healthcheck_retries = 30 +healthcheck_timeout = 2 +healthcheck_start_period = 1 + +[testenv:module-certificates] +package = skip +change_dir = {tox_root} +dependency_groups = dev +docker = + certificate-repo +allowlist_externals = + docker +description = + Verifies that both wget and curl - two independently TLS-linked HTTP + clients (GnuTLS and OpenSSL, respectively, on Debian) - trust an HTTPS + server whose certificate chains to a root certificate installed by the + `certificates` module alone, with no package-manager-specific config. +commands = + python3 -m id_tools test render-config {tox_root}/tests/configs/Certificate.yaml.j2 {env_dir}/configs/Certificate.yaml + docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg CERTIFICATE_REPO_PORT={env:CERTIFICATE_REPO_PORT} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Certificate.Dockerfile -t id-configurator-certificates . + docker rmi id-configurator-certificates + +[docker:php-repo] +dockerfile = {tox_root}/tests/dockerfiles/repos/php-repo.Dockerfile +expose = + PHP_REPO_PORT=8443/tcp +healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/packages.json || exit 1 +healthcheck_interval = 1 +healthcheck_retries = 30 +healthcheck_timeout = 2 +healthcheck_start_period = 1 + +[testenv:module-php] +package = skip +change_dir = {tox_root} +dependency_groups = dev +docker = + php-repo +allowlist_externals = + docker +commands = + python3 -m id_tools test render-config {tox_root}/tests/configs/Php.yaml.j2 {env_dir}/configs/Php.yaml + docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Php.Dockerfile -t id-configurator-php . + docker rmi id-configurator-php + +[docker:npm-repo] +dockerfile = {tox_root}/tests/dockerfiles/repos/npm-repo.Dockerfile +expose = + NPM_REPO_PORT=8443/tcp +healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/idconfiguratortestpkg || exit 1 +healthcheck_interval = 1 +healthcheck_retries = 30 +healthcheck_timeout = 2 +healthcheck_start_period = 1 + +[testenv:module-npm] +package = skip +change_dir = {tox_root} +dependency_groups = dev +docker = + npm-repo +allowlist_externals = + docker +commands = + python3 -m id_tools test render-config {tox_root}/tests/configs/Npm.yaml.j2 {env_dir}/configs/Npm.yaml + docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Npm.Dockerfile -t id-configurator-npm . + docker rmi id-configurator-npm + +[docker:cargo-repo] +dockerfile = {tox_root}/tests/dockerfiles/repos/cargo-repo.Dockerfile +expose = + CARGO_REPO_PORT=8443/tcp +healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/config.json || exit 1 +healthcheck_interval = 1 +healthcheck_retries = 30 +healthcheck_timeout = 2 +healthcheck_start_period = 1 + +[testenv:module-rust] +package = skip +change_dir = {tox_root} +dependency_groups = dev +docker = + cargo-repo +allowlist_externals = + docker +commands = + python3 -m id_tools test render-config {tox_root}/tests/configs/Rust.yaml.j2 {env_dir}/configs/Rust.yaml + docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Rust.Dockerfile -t id-configurator-rust . + docker rmi id-configurator-rust