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/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/configs/Apk.yaml.j2 b/tests/configs/Apk.yaml.j2 index eb8c7f7..3b48d9e 100644 --- a/tests/configs/Apk.yaml.j2 +++ b/tests/configs/Apk.yaml.j2 @@ -21,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 index 130c39c..5905148 100644 --- a/tests/configs/Certificate.yaml.j2 +++ b/tests/configs/Certificate.yaml.j2 @@ -18,3 +18,9 @@ configurations: repositories: [] java: repositories: [] + php: + repositories: [] + npm: + repositories: [] + rust: + repositories: [] diff --git a/tests/configs/Java.yaml.j2 b/tests/configs/Java.yaml.j2 index b87cc4f..4193ffa 100644 --- a/tests/configs/Java.yaml.j2 +++ b/tests/configs/Java.yaml.j2 @@ -35,3 +35,7 @@ configurations: # 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 ebf88e2..3f68807 100644 --- a/tests/configs/Yum.yaml.j2 +++ b/tests/configs/Yum.yaml.j2 @@ -20,3 +20,9 @@ configurations: repositories: [] java: repositories: [] + php: + repositories: [] + npm: + repositories: [] + rust: + repositories: [] 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/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/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_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/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/test_cli.py b/tests/test_cli.py index 9d928db..6062db0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -44,7 +44,10 @@ def test_list_modules() -> None: "apt", "certificates", "java", + "npm", + "php", "python", + "rust", "yum", } 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/tox.ini b/tox.ini index ded91d4..7ccaa1f 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,module-certificates +env_list = py313,py314,coverage,packaging,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 @@ -211,3 +211,72 @@ 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