Compare commits

..

59 Commits

Author SHA1 Message Date
dependabot[bot] 94c2e329bd chore(deps): bump pyasn1 from 0.6.4 to 0.6.5
Bumps [pyasn1](https://github.com/pyasn1/pyasn1) from 0.6.4 to 0.6.5.
- [Release notes](https://github.com/pyasn1/pyasn1/releases)
- [Changelog](https://github.com/pyasn1/pyasn1/blob/main/CHANGES.rst)
- [Commits](https://github.com/pyasn1/pyasn1/compare/v0.6.4...v0.6.5)

---
updated-dependencies:
- dependency-name: pyasn1
  dependency-version: 0.6.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-05 18:30:30 +00:00
d9pouces bfd04dc49f Merge pull request #17 from d9pouces/dev
Dev
2026-10-24 09:02:26 +02:00
d9pouces 8e52f4335b ci: update dependencies
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-24 00:11:52 +02:00
d9pouces 4cb27c9507 ci: update dependencies
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-24 00:09:18 +02:00
d9pouces 4f079469b7 ci: update dependencies
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-24 00:03:17 +02:00
d9pouces dbc3e121ca feat: update version to 2026.10.24 in pyproject.toml
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-24 00:03:01 +02:00
d9pouces 3b6eb7290f feat: enhance filename sanitization to replace non-identifier characters
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-24 00:02:40 +02:00
d9pouces 653579b59e feat: add docker buildx prune commands to cleanup post-build
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-19 07:00:49 +02:00
d9pouces e1ef0e109b ci: update dependencies
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-09 21:07:10 +02:00
d9pouces 7dbc182de4 Merge pull request #14 from d9pouces/dev
Dev
2026-10-09 20:46:39 +02:00
d9pouces 0f019ac6ea feat: update repository names to support special characters in configuration files
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-09 20:22:26 +02:00
d9pouces e58820ac98 feat: update repository names to support special characters in configuration files
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-09 19:13:12 +02:00
d9pouces b5aec64fd8 Merge pull request #13 from d9pouces/dev
Dev
2026-10-07 08:40:55 +02:00
d9pouces 1e9e87b11c feat: add docker cleanup commands to publish workflow
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-07 08:11:31 +02:00
d9pouces dc0f8b7629 feat: add docker cleanup commands to publish workflow
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-07 08:11:18 +02:00
d9pouces abbd424f14 feat: add plain-HTTP listeners for various repository configurations to support non-HTTPS access
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-07 07:48:38 +02:00
d9pouces ddfa9f75b1 Merge pull request #12 from d9pouces/dev
Dev
2026-10-07 07:44:10 +02:00
d9pouces ebea3071bc feat: add plain-HTTP listeners for various repository configurations to support non-HTTPS access
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-06 23:16:42 +02:00
d9pouces 8598f323d9 feat: add plain-HTTP listeners for various repository configurations to support non-HTTPS access
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-06 22:08:02 +02:00
d9pouces 665eb215bd feat: add repository name sanitization to prevent invalid characters in poetry config
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-06 20:34:04 +02:00
d9pouces 80f9bf3a83 Merge pull request #11 from d9pouces/dev
Dev
2026-10-05 22:53:48 +02:00
d9pouces 98b773445f feat: add sparse option for demo repository in Rust.yaml.j2
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-05 22:30:16 +02:00
d9pouces 6813c86fba refactor: remove unnecessary noqa comments in user tests and user entry creation
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-05 22:13:18 +02:00
d9pouces 8e182f6615 feat: add support for trusted-host in pip configuration for non-HTTPS repositories
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-05 22:01:51 +02:00
d9pouces 352173a85c feat: update version to 2026.10.5 in pyproject.toml
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-05 21:30:18 +02:00
d9pouces d705aa970d feat: update version to 2026.10.5 in pyproject.toml
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-05 20:47:02 +02:00
d9pouces bf56cdfcd2 feat: can specify several users to configure
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-05 20:46:38 +02:00
d9pouces 3e0bb3b04c Merge pull request #10 from d9pouces/dev
Dev
2026-10-04 15:09:07 +02:00
d9pouces c498d42da0 ci: version bump
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-04 14:55:09 +02:00
d9pouces 990180dbba feat: add key_filename support for APK repositories
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-04 14:54:54 +02:00
d9pouces 12f0ac46a6 Merge pull request #9 from d9pouces/dev
fix: add missing GPG packages to the Docker image
2026-10-03 11:26:09 +02:00
d9pouces 0fb964c2c2 fix: add missing GPG packages to the Docker image
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-03 11:23:43 +02:00
d9pouces e6a7d4d4d9 Merge pull request #8 from d9pouces/dev
Dev
2026-10-02 23:31:24 +02:00
d9pouces 5399336c2c feat: update version to 2026.10.2 in pyproject.toml
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-02 23:20:22 +02:00
d9pouces 1c3aa1ef7d feat: update version to 2026.10.2 in pyproject.toml
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-02 23:17:57 +02:00
d9pouces 170dd577d7 feat: update version to 2026.10.2 in pyproject.toml
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-02 23:11:12 +02:00
d9pouces 35ac0c5033 Merge pull request #7 from d9pouces/dev
Dev
2026-09-22 19:38:14 +02:00
d9pouces 11e7d93d85 feat: add note on generating configuration using InterDiode
Signed-off-by: d9pouces <github@19pouces.net>
2026-09-21 23:22:25 +02:00
d9pouces 357d2392eb feat: add note on generating configuration using InterDiode
Signed-off-by: d9pouces <github@19pouces.net>
2026-09-21 23:13:33 +02:00
d9pouces 235df16f6f Merge pull request #2 from d9pouces/dev
Dev
2026-09-20 22:50:10 +02:00
d9pouces d7ef22b527 feat: add note on generating configuration using InterDiode
Signed-off-by: d9pouces <github@19pouces.net>
2026-09-20 22:49:28 +02:00
d9pouces 39fdbb7f40 feat: add GitHub Actions workflow for publishing Python packages and Docker image
Signed-off-by: d9pouces <github@19pouces.net>
2026-09-20 22:37:23 +02:00
d9pouces 7ddd3ac09b feat: update license to CECILL-2.1 and increment version to 2026.9.21 in pyproject.toml
Signed-off-by: d9pouces <github@19pouces.net>
2026-09-20 22:37:06 +02:00
d9pouces 204d648477 feat: add --config-url-insecure option to disable TLS certificate verification
Signed-off-by: d9pouces <github@19pouces.net>
2026-09-20 22:36:40 +02:00
d9pouces eff9ca66d2 feat: add publication command to main application
Signed-off-by: d9pouces <github@19pouces.net>
2026-09-20 22:36:21 +02:00
d9pouces 776cdc3b17 feat: update version to 2026.9.20 in pyproject.toml
Signed-off-by: d9pouces <github@19pouces.net>
2026-09-20 20:11:26 +02:00
d9pouces db4ff3cfd4 feat: add support for certificate configuration in npm and php modules, update repository URLs in configuration files
Signed-off-by: d9pouces <github@19pouces.net>
2026-09-20 20:10:52 +02:00
d9pouces cea45c97ff feat: enhance repository configuration to support url_to_replace patterns for disabling existing entries
Signed-off-by: d9pouces <github@19pouces.net>
2026-09-19 23:40:35 +02:00
d9pouces b2dcae0daf feat: update APT module to support inline ASCII-armored GPG keys in Signed-By field
Signed-off-by: d9pouces <github@19pouces.net>
2026-09-10 22:16:42 +02:00
d9pouces be91425355 feat: sanitize filenames in apt and yum modules to prevent path traversal
Signed-off-by: d9pouces <github@19pouces.net>
2026-09-09 23:18:24 +02:00
d9pouces 4409c38579 feat: update version to 2026.9.7 in pyproject.toml
Signed-off-by: d9pouces <github@19pouces.net>
2026-09-09 22:20:16 +02:00
d9pouces 380e976f76 feat: update version to 2026.9.7 in pyproject.toml
Signed-off-by: d9pouces <github@19pouces.net>
2026-09-07 08:26:40 +02:00
d9pouces ab2a179a55 feat: update config-url configuration with Test CA certificate and Dockerfile enhancements
Signed-off-by: d9pouces <github@19pouces.net>
2026-09-07 08:12:48 +02:00
d9pouces 9de98d4744 feat: add config-url support with Dockerfile and NGINX configuration
Signed-off-by: d9pouces <github@19pouces.net>
2026-09-07 07:15:58 +02:00
d9pouces 88380b6ab6 refactor: remove unused NPM_TO_COPY variable from build.py
Signed-off-by: d9pouces <github@19pouces.net>
2026-09-06 23:08:58 +02:00
d9pouces e3b2e8a95a refactor: remove unused NPM_TO_COPY variable from build.py
Signed-off-by: d9pouces <github@19pouces.net>
2026-09-06 22:22:51 +02:00
d9pouces 4e4b0592c0 refactor: remove unused NPM_TO_COPY variable from build.py
Signed-off-by: d9pouces <github@19pouces.net>
2026-09-06 21:36:01 +02:00
d9pouces a8143b61b1 feat: add support for Go, Ruby, and NuGet modules with integration testing configurations
Signed-off-by: d9pouces <github@19pouces.net>
2026-09-06 21:14:18 +02:00
d9pouces c9dbeb1a8b Merge pull request #1 from d9pouces/dev
Dev
2026-09-06 15:29:29 +02:00
89 changed files with 6870 additions and 1722 deletions
+8 -1
View File
@@ -15,12 +15,19 @@ jobs:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v7.0.1
with: with:
lfs: true lfs: true
- uses: sigstore/cosign-installer@main
- uses: actions/setup-python@v7.0.0 - uses: actions/setup-python@v7.0.0
with: with:
python-version: 3.${{ matrix.python-version }} python-version: 3.${{ matrix.python-version }}
- run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sudo sh -s -- -b /usr/local/bin v0.75.0
- run: python3 -m pip install --upgrade tox tox-docker-id tox-uv - run: python3 -m pip install --upgrade tox tox-docker-id tox-uv
- run: tox -e packaging-amd64
- run: tox -e module-rust
- run: tox -e packaging-amd64,py313,py314,coverage - 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 - run: tox -m end-to-end
- run: docker builder prune --all --force
- run: docker system prune --all --force
- uses: docker/setup-buildx-action@v3
strategy: strategy:
matrix: matrix:
python-version: ['14'] python-version: ['14']
+35
View File
@@ -0,0 +1,35 @@
name: Publish Python packages and Docker image
on:
push:
branches:
- master
jobs:
publish:
env:
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
DOCKER_HUB_LOGIN: ${{ secrets.DOCKER_HUB_LOGIN }}
DOCKER_HUB_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN }}
UV_PUBLISH_TOKEN: ${{ secrets.UV_PUBLISH_TOKEN }}
name: Package and public, Python 3${{ matrix.python-version }}
runs-on: ubuntu-26.04
steps:
- uses: actions/checkout@v7.0.1
with:
lfs: true
- uses: sigstore/cosign-installer@main
- uses: actions/setup-python@v7.0.0
with:
python-version: 3.${{ matrix.python-version }}
- run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sudo sh -s -- -b /usr/local/bin v0.75.0
- run: python3 -m pip install --upgrade tox tox-docker-id tox-uv
- run: tox -e packaging-amd64,py313,py314,coverage
- run: tox -m end-to-end
- run: docker builder prune --all --force
- run: docker system prune --all --force
- uses: docker/setup-buildx-action@v3
- run: tox -e publish
strategy:
matrix:
python-version: ['14']
timeout-minutes: 75
+1 -1
View File
@@ -77,7 +77,7 @@ repos:
rev: 1.8.6 rev: 1.8.6
hooks: hooks:
- id: bandit - id: bandit
args: [-s, 'B101,B404,B405,B506,B703,B308,B603'] args: [-s, 'B101,B105,B404,B405,B506,B703,B308,B603,B604']
exclude: ^(id_tests/.*|npm/node_modules/.*|tools/.*|doc/.*|interdiode/static/vendor/.*) exclude: ^(id_tests/.*|npm/node_modules/.*|tools/.*|doc/.*|interdiode/static/vendor/.*)
- repo: https://github.com/shellcheck-py/shellcheck-py - repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.11.0.1 rev: v0.11.0.1
+771 -669
View File
File diff suppressed because it is too large Load Diff
+179 -31
View File
@@ -4,9 +4,10 @@
Docker image) in an **air-gapped environment**: no network access is required or ever used. Docker image) in an **air-gapped environment**: no network access is required or ever used.
It reads a single configuration (JSON or YAML), and applies it to a filesystem tree, writing only the files It reads a single configuration (JSON or YAML), and applies it to a filesystem tree, writing only the files
that need to change: root certificates, package manager sources (APT, YUM/DNF, APK), and language-specific that need to change: root certificates, system package manager sources (APT, YUM/DNF, APK) and
package manager settings (pip/uv/poetry, Maven/Gradle/Ivy/SBT/Leiningen, ...). The result can then be used language-specific package manager settings (pip/uv/poetry, Maven/Gradle/Ivy/SBT/Leiningen, Composer, npm,
to build a Docker image or otherwise deployed to the target device. cargo, Go modules, RubyGems, NuGet). The result can then be used to build a Docker image or otherwise
deployed to the target device.
## How it works ## How it works
@@ -14,15 +15,70 @@ to build a Docker image or otherwise deployed to the target device.
never writes anywhere else. never writes anywhere else.
- `--src` (optional) points to an existing filesystem, used to detect the current configuration (existing - `--src` (optional) points to an existing filesystem, used to detect the current configuration (existing
APT sources, existing Java keystores, etc.) so it can be extended or disabled instead of blindly overwritten. APT sources, existing Java keystores, etc.) so it can be extended or disabled instead of blindly overwritten.
- The configuration is loaded either from a file (`--config`) or from a base64-encoded string, typically an - The configuration is loaded from a file (`--config`), from a base64-encoded string, typically an
environment variable (`--config-env` / `$ID_CONFIGURATOR_CONFIG`). environment variable (`--config-env` / `$ID_CONFIGURATOR_CONFIG`), or downloaded over HTTP(S) from a URL
- Each feature is implemented as an independent, self-registering **module**. On `apply`, every module (`--config-url`). `--config-url-cacert-content` optionally gives the PEM content of the CA certificate to trust for that one download, for an internal
inspects the configuration and the source filesystem and decides for itself whether it has something to endpoint signed by a private CA the running system doesn't otherwise trust yet (e.g. before the
do; only relevant modules touch the destination directory. `certificates` module has ever run) - it defaults to the system's own trust store otherwise.
`--config-url-insecure` disables certificate (and hostname) verification entirely instead, overriding
`--config-url-cacert-content` - **insecure**, only meant for a first bootstrap against a server whose
certificate isn't trustable, or trusted, yet by any other means.
- `--user` (repeatable) names a user to also prepare *per-user* configuration for, on top of the usual
system-wide files, for the modules that have one: `go`, `nuget`, `php` (Composer), `python` (poetry) and
`rust`. The user's home directory is taken from `--src`'s own `/etc/passwd` entry when there is one,
otherwise assumed to be `/home/<user>` (`/root` for `root`).
- `--auto-users` does the same for every *regular* (non-system) user found in `--src`'s `/etc/passwd`: one with
a login shell (not `nologin`, `sync` or `false`) and a UID at or above `UID_MIN` (read from `--src`'s
`/etc/login.defs`, 1000 by default) - except `root`, always included regardless of its UID. It combines
freely with one or more `--user`.
- On `apply`, every module inspects the configuration and the source filesystem and decides for itself whether it has something to
do; only relevant modules touch the destination directory. For example, the configured Alpine repositories are only written if
`--src` can be identified as an Alpine filesystem (i.e. it has an `/etc/apk` directory); and a source cloning Debian's official
repository is only configured if that official repository is actually present in the filesystem given via `--src`
(via `uri-to-replace`, see "Replacing an existing repository" below).
## Installation ## Installation
Requires Python 3.13+. ### As a CLI tool
```bash
# with pip
pip install id-configurator
# or with uv tool, in its own isolated environment
uv tool install id-configurator
```
Once installed this way, run it directly as `id-configurator` (rather than `uv run id-configurator`, which is
only for the "from source" setup below).
### As a Docker image
`id-configurator` is also published as [`interdiode/id-configurator`](https://hub.docker.com/r/interdiode/id-configurator)
on Docker Hub - handy since its output is typically meant to become another Docker image in the first place.
One-off use:
```bash
docker run --rm -v "$(pwd)/config.yaml:/config.yaml:ro" -v "$(pwd)/output:/dst" -v "/:/src" \
interdiode/id-configurator:latest \
id-configurator apply --config /config.yaml --dst /dst --src /src
```
More typically, as a builder stage of the target image's own `Dockerfile` - `<src>` here is the target image
itself, taken as of its `target` stage, before `id-configurator`'s own changes are copied back on top of it:
```dockerfile
FROM debian:trixie-slim AS target
FROM interdiode/id-configurator:latest AS configuration
COPY --from=target / /src/
COPY config.yaml /config.yaml
RUN id-configurator apply --config /config.yaml --src /src --dst /dst
FROM target
COPY --from=configuration /dst/ /
```
### From source (development)
```bash ```bash
uv sync uv sync
@@ -32,32 +88,60 @@ uv sync
```bash ```bash
# Apply a configuration file to a destination directory # Apply a configuration file to a destination directory
uv run id-configurator apply --config config.yaml --dst ./output id-configurator apply --config config.yaml --dst ./output
# Take an existing filesystem into account (e.g. to extend its APT sources) # Take an existing filesystem into account (e.g. to extend its APT sources)
uv run id-configurator apply --config config.yaml --src /path/to/rootfs --dst ./output id-configurator apply --config config.yaml --src /path/to/rootfs --dst ./output
# Use a base64-encoded configuration from the environment instead of a file # Use a base64-encoded configuration from the environment instead of a file
export ID_CONFIGURATOR_CONFIG=$(base64 -w0 config.yaml) export ID_CONFIGURATOR_CONFIG=$(base64 -w0 config.yaml)
uv run id-configurator apply --dst ./output id-configurator apply --dst ./output
# Download the configuration from an internal HTTPS server instead of a local file
id-configurator apply \
--config-url https://config.internal.example/device.yaml \
--config-url-cacert-content "$(cat internal-ca.pem)" \
--dst ./output
# Also prepare per-user configuration (go, nuget, php, python, rust) for specific users
id-configurator apply --config config.yaml --dst ./output --user alice --user bob
# Same, but auto-detect every regular (non-system) user from --src's /etc/passwd
id-configurator apply --config config.yaml --src /path/to/rootfs --dst ./output --auto-users
# Validate a configuration without applying it # Validate a configuration without applying it
uv run id-configurator validate --config config.yaml id-configurator validate --config config.yaml
# List the available modules # List the available modules
uv run id-configurator list-modules id-configurator list-modules
# Same thing, via the Docker image instead of installing anything locally
docker run --rm \
-v "$(pwd)/config.yaml:/config.yaml:ro" \
-v "$(pwd)/input:/src" \
-v "$(pwd)/output:/dst" \
interdiode/id-configurator:latest \
id-configurator apply --config /config.yaml --dst /dst --src /src
``` ```
The tool can also be invoked as `python -m id_configurator`. The tool can also be invoked as `python -m id_configurator`.
## Configuration format ## Configuration format
The configuration is a JSON or YAML object: The simplest way to generate a new configuration is [InterDiode](https://interdiode.fr) itself, through its
simple interface - it also handles duplicating application mirrors onto disconnected networks.
The configuration is a JSON or YAML object. `uuid`, `organisation` and `description` are all optional
(identifying metadata only - nothing in the tool itself relies on any of them), as is every `configurations.*`
block - a module that finds nothing to do for it simply does not run. `version` is the configuration
*format*'s own schema version (currently `"1.0"`, and the default if omitted) - independent of the
`id-configurator` package version - so a future, incompatible format change can be told apart from this one:
```yaml ```yaml
uuid: <UUID> version: "1.0"
organisation: <string> uuid: <UUID, optional>
description: <string> organisation: <string, optional>
description: <string, optional>
configurations: configurations:
certificates: certificates:
- name: <string> - name: <string>
@@ -68,18 +152,21 @@ configurations:
uris: [<string>] uris: [<string>]
suites: [<string>] suites: [<string>]
components: [<string>] components: [<string>]
keys: signed-by: <ASCII-armored GPG public key> # or signed-by-file: <path already on the target>
- name: <string> uri-to-replace: <fnmatch pattern, optional> # see "Replacing an existing repository" below
content: <GPG key>
apk: apk:
repositories: repositories:
- url: <string> - name: <string>
key: <RSA public key> url: <string>
key: <RSA public key, optional>
key_filename: <string, optional> # name of the key file under /etc/apk/keys/, if not the derived default
url_to_replace: <fnmatch pattern, optional>
yum: yum:
repositories: repositories:
- name: <string> - name: <string>
index-url: <string> index-url: <string>
gpg-url: <string> gpg-url: <string, optional>
index-url-to-replace: <fnmatch pattern, optional>
keys: keys:
- name: <string> - name: <string>
content: <GPG key> content: <GPG key>
@@ -91,20 +178,81 @@ configurations:
repositories: repositories:
- name: <string> - name: <string>
url: <string> url: <string>
php:
repositories:
- name: <string>
url: <string>
npm:
repositories:
- name: <string>
url: <string>
rust:
repositories:
- name: <string>
url: <string>
is_sparse: <bool, optional, default false> # declares the registry's index as sparse+<url>
go:
repositories:
- name: <string>
url: <string>
ruby:
repositories:
- name: <string>
url: <string>
nuget:
repositories:
- name: <string>
url: <string>
``` ```
### Replacing an existing repository
`apt.sources[].uri-to-replace`, `apk.repositories[].url_to_replace` and `yum.repositories[].index-url-to-replace`
are optional [`fnmatch`](https://docs.python.org/3/library/fnmatch.html) patterns. When set, the configured
repository is only written if the pattern matches at least one URI/URL already present in `--src`'s existing
configuration - and only the matching existing entries are disabled, the rest are left untouched. This lets a
configuration safely target "whatever machine this is applied to": e.g. `uri-to-replace: "http://deb.debian.org/*"`
replaces Debian's default mirror if (and only if) it's actually there, without disturbing any other source a
given machine might already have. Leaving the field unset never disables anything - the configured repository
is simply added alongside whatever is already present.
### Non-HTTPS repositories
A repository URL doesn't have to be HTTPS - several modules automatically adjust the tool's configuration so
the plain-HTTP repository is actually usable, instead of silently producing a config the package manager
would refuse at run time:
- `python`: pip gets the repository's host added to `trusted-host`.
- `php`: Composer gets `secure-http` disabled, and `packagist.org` is disabled alongside it (an HTTP source
implies a private, non-internet setup where the public registry wouldn't be reachable anyway).
- `nuget`: the source gets `allowInsecureConnections="true"`.
- `java`: SBT's resolver gets `allowInsecureProtocol` appended. **Leiningen is the one exception**: its
standalone launcher refuses any non-HTTPS repository unconditionally, with no configuration able to change
that - a repository configured here simply won't be usable from Leiningen specifically.
- `go`, `rust`, `apt`, `apk`, `yum`: no adjustment is needed - these already work the same regardless of
scheme.
## Modules ## Modules
| Module | Applies to | | Module | Applies to |
|----------------|--------------------------------------------------------------------------------------------------------| |----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| `certificates` | Installs root certificates into the system's CA store (`update-ca-certificates`). | | `certificates` | Installs root certificates into the system's CA store, rebuilding the trust bundle in pure Python (Debian/Ubuntu and RHEL/Fedora trust stores). |
| `apt` | Disables existing APT sources and adds the configured ones, in DEB822 format. | | `apt` | Adds the configured APT sources (DEB822 format); disables only the existing entries matching `uri-to-replace`. |
| `apk` | Disables existing Alpine `/etc/apk/repositories` entries and adds the configured ones, plus their keys.| | `apk` | Adds the configured Alpine `/etc/apk/repositories` entries and their keys; disables only the existing lines matching `url_to_replace`. |
| `yum` | Disables existing YUM/DNF `.repo` files, adds the configured ones, and imports their GPG keys. | | `yum` | Adds the configured YUM/DNF `.repo` files and imports their GPG keys; disables only the existing sections matching `index-url-to-replace`. Covers DNF too - same config files. |
| `python` | Points pip, uv, poetry and `easy_install` to the configured Python package indexes. | | `python` | Points pip, uv, poetry and `easy_install` to the configured Python package indexes. |
| `java` | Adds root certificates to every detected JKS/PKCS12 keystore, and configures Maven, Gradle, Ivy, SBT and Leiningen repositories. | | `java` | Adds root certificates to every detected JKS/PKCS12 keystore, and configures Maven, Gradle, Ivy, SBT and Leiningen repositories. |
| `php` | Registers the configured repositories with Composer; sets `openssl.cafile` whenever any certificate is configured, so PHP's own OpenSSL calls trust it too. |
| `npm` | Points npm at the configured registries and its own `cafile`; sets `NODE_EXTRA_CA_CERTS` system-wide whenever any certificate is configured, since Node ignores the system CA store. |
| `rust` | Points cargo at the configured registries, replacing crates.io. |
| `go` | Points the Go toolchain at the configured module proxies (`GOPROXY`), replacing proxy.golang.org, and disables `GOSUMDB`. |
| `ruby` | Points RubyGems at the configured sources, replacing rubygems.org. |
| `nuget` | Points NuGet at the configured package sources, replacing nuget.org. |
More modules (npm, nuget, ruby/gem, rust/crates, go, packagist/composer, ...) are planned; see `CLAUDE.md` for the full roadmap. `go`, `nuget`, `php`, `python` and `rust` additionally honor `--user`/`--auto-users` (see "How it works" above),
writing the same per-user configuration under each such user's home directory.
This tool's own modular structure is meant to be extended: see `CLAUDE.md` for the module interface and how to add one.
## Development ## Development
@@ -118,4 +266,4 @@ uv run pre-commit run --all-files # run all pre-commit hooks
## License ## License
Proprietary. See [LICENSE](LICENSE). Copyright InterDiode SA. [CeCILL v2.1](https://cecill.info) (GPL-compatible free software license). See [LICENSE](LICENSE). Copyright InterDiode SA.
+82 -10
View File
@@ -38,6 +38,34 @@ ConfigEnvOption = Annotated[
help="Base64-encoded JSON/YAML configuration.", help="Base64-encoded JSON/YAML configuration.",
), ),
] ]
ConfigUrlOption = Annotated[
str | None,
typer.Option(
"--config-url",
help="URL to download a JSON or YAML configuration from.",
),
]
ConfigUrlCacertContentOption = Annotated[
str | None,
typer.Option(
"--config-url-cacert-content",
help=(
"PEM content of the CA certificate to trust when downloading "
"--config-url over HTTPS (defaults to the system's trust store)."
),
),
]
ConfigUrlInsecureOption = Annotated[
bool,
typer.Option(
"--config-url-insecure",
help=(
"Disable TLS certificate verification when downloading --config-url "
"(INSECURE: only for bootstrapping from a server whose certificate "
"isn't trustable yet; overrides --config-url-cacert-content)."
),
),
]
SrcOption = Annotated[ SrcOption = Annotated[
Path | None, Path | None,
typer.Option( typer.Option(
@@ -55,6 +83,26 @@ DstOption = Annotated[
help="Destination directory receiving the modified files.", help="Destination directory receiving the modified files.",
), ),
] ]
UserOption = Annotated[
list[str],
typer.Option(
"--user",
help="A user to prepare per-user configuration for (may be repeated).",
),
]
AutoUsersOption = Annotated[
bool,
typer.Option(
"--auto-users",
help=(
"Also prepare per-user configuration for every regular "
"(non-system) user found in <src>/etc/passwd: one with a login "
"shell (not nologin, sync or false) and a UID at or above "
"UID_MIN (from <src>/etc/login.defs, 1000 by default) - root is "
"always included regardless of its UID."
),
),
]
VerboseOption = Annotated[ VerboseOption = Annotated[
bool, typer.Option("--verbose", "-v", help="Enable verbose (debug) logging.") bool, typer.Option("--verbose", "-v", help="Enable verbose (debug) logging.")
] ]
@@ -67,12 +115,21 @@ def _configure_logging(verbose: bool) -> None:
) )
def _load_configuration(config: Path | None, config_env: str | None): def _load_configuration(
if config is None and config_env is None: config: Path | None,
config_env: str | None,
config_url: str | None,
config_url_cacert_content: str | None,
config_url_insecure: bool = False,
):
if config is None and config_env is None and config_url is None:
raise typer.BadParameter( raise typer.BadParameter(
"either --config or --config-env (or $ID_CONFIGURATOR_CONFIG) must be provided" "either --config, --config-env (or $ID_CONFIGURATOR_CONFIG) or "
"--config-url must be provided"
)
return load_configuration(
config, config_env, config_url, config_url_cacert_content, config_url_insecure
) )
return load_configuration(config, config_env)
@app.command() @app.command()
@@ -80,14 +137,23 @@ def apply(
dst: DstOption, dst: DstOption,
config: ConfigFileOption = None, config: ConfigFileOption = None,
config_env: ConfigEnvOption = None, config_env: ConfigEnvOption = None,
config_url: ConfigUrlOption = None,
config_url_cacert_content: ConfigUrlCacertContentOption = None,
config_url_insecure: ConfigUrlInsecureOption = False,
src: SrcOption = None, src: SrcOption = None,
user: UserOption = [], # noqa: B006 (Typer needs a concrete default to detect --user as repeatable)
auto_users: AutoUsersOption = False,
verbose: VerboseOption = False, verbose: VerboseOption = False,
) -> None: ) -> None:
"""Apply a configuration to the destination directory.""" """Apply a configuration to the destination directory."""
_configure_logging(verbose) _configure_logging(verbose)
configuration = _load_configuration(config, config_env) configuration = _load_configuration(
config, config_env, config_url, config_url_cacert_content, config_url_insecure
)
dst.mkdir(parents=True, exist_ok=True) dst.mkdir(parents=True, exist_ok=True)
context = Context(config=configuration, dst=dst, src=src) context = Context(
config=configuration, dst=dst, src=src, users=user, auto_users=auto_users
)
for module_cls in all_modules(): for module_cls in all_modules():
module = module_cls() module = module_cls()
if module.is_relevant(context): if module.is_relevant(context):
@@ -99,13 +165,19 @@ def apply(
@app.command() @app.command()
def validate( def validate(
config: ConfigFileOption = None, config_env: ConfigEnvOption = None config: ConfigFileOption = None,
config_env: ConfigEnvOption = None,
config_url: ConfigUrlOption = None,
config_url_cacert_content: ConfigUrlCacertContentOption = None,
config_url_insecure: ConfigUrlInsecureOption = False,
) -> None: ) -> None:
"""Validate a configuration file without applying it.""" """Validate a configuration file without applying it."""
configuration = _load_configuration(config, config_env) configuration = _load_configuration(
typer.echo( config, config_env, config_url, config_url_cacert_content, config_url_insecure
f"configuration {configuration.uuid} is valid (organisation: {configuration.organisation})"
) )
uuid = configuration.uuid if configuration.uuid is not None else "<no uuid>"
organisation = configuration.organisation or "<no organisation>"
typer.echo(f"configuration {uuid} is valid (organisation: {organisation})")
@app.command("list-modules") @app.command("list-modules")
+206 -44
View File
@@ -4,11 +4,15 @@ from __future__ import annotations
import base64 import base64
import json import json
import ssl
import urllib.request
from pathlib import Path from pathlib import Path
from uuid import UUID from uuid import UUID
import yaml import yaml
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, field_validator
_CONFIG_URL_TIMEOUT_SECONDS = 30
class NamedContent(BaseModel): class NamedContent(BaseModel):
@@ -22,8 +26,31 @@ def _yes_no(value: bool) -> str:
return "yes" if value else "no" return "yes" if value else "no"
def _fold_deb822_multiline(field: str, value: str) -> str:
"""Render `field` as a DEB822 folded multi-line field (sources.list(5)):
each of `value`'s own lines indented by one space, with a truly blank
line represented as a lone `.` - exactly how an ASCII-armored GPG key
block is embedded directly in a `Signed-By` field, verified against a
real `apt-get update` run against a genuinely signed repository."""
folded = "\n".join(f" {line}" if line else " ." for line in value.splitlines())
return f"{field}:\n{folded}"
class AptSource(BaseModel): class AptSource(BaseModel):
"""A single APT source, in the DEB822 format (see sources.list(5)).""" """A single APT source, in the DEB822 format (see sources.list(5)).
Two ways to tell APT which key to trust this source with:
- `signed_by`: the ASCII-armored (.asc) *content* of a GPG public key,
folded inline as a multi-line `Signed-By` field - no separate keyring
file, no `gpg` binary needed to apply this configuration.
- `signed_by_file`: the *path* to a keyring already expected to exist on
the target system, written to the rendered `Signed-By` field verbatim.
If both are set, `signed_by` takes precedence.
`uri_to_replace`, if set, is an fnmatch pattern: this source is only
applied if it matches at least one URI already present in the machine's
existing apt configuration; otherwise it is skipped entirely.
"""
model_config = ConfigDict(populate_by_name=True) model_config = ConfigDict(populate_by_name=True)
@@ -38,6 +65,7 @@ class AptSource(BaseModel):
enabled: bool = True enabled: bool = True
trusted: bool | None = None trusted: bool | None = None
signed_by: str | None = Field(default=None, alias="signed-by") signed_by: str | None = Field(default=None, alias="signed-by")
signed_by_file: str | None = Field(default=None, alias="signed-by-file")
pdiffs: bool | None = None pdiffs: bool | None = None
by_hash: bool | None = Field(default=None, alias="by-hash") by_hash: bool | None = Field(default=None, alias="by-hash")
allow_insecure: bool | None = Field(default=None, alias="allow-insecure") allow_insecure: bool | None = Field(default=None, alias="allow-insecure")
@@ -51,67 +79,92 @@ class AptSource(BaseModel):
check_date: bool | None = Field(default=None, alias="check-date") check_date: bool | None = Field(default=None, alias="check-date")
date_max_future: str | None = Field(default=None, alias="date-max-future") date_max_future: str | None = Field(default=None, alias="date-max-future")
description: str | None = None description: str | None = None
uri_to_replace: str | None = Field(default=None, alias="uri-to-replace")
def to_deb822(self) -> str: def to_deb822(
"""Render this source as a DEB822 stanza (as used in /etc/apt/sources.list.d/*.sources).""" self,
fields = [ signed_by_armored_key: str | None = None,
("Types", " ".join(self.types)), signed_by_path: str | None = None,
("URIs", " ".join(self.uris)), ) -> str:
("Suites", " ".join(self.suites)), """Render this source as a DEB822 stanza (as used in
/etc/apt/sources.list.d/*.sources).
Neither `self.signed_by` nor `self.signed_by_file` is ever rendered
directly - the caller (the `apt` module) resolves which one applies
and passes the result in: `signed_by_armored_key` is folded inline
as a multi-line `Signed-By` field (see `_fold_deb822_multiline`);
`signed_by_path` is written as a plain single-line `Signed-By`
field. If both are given, `signed_by_armored_key` takes precedence.
"""
lines = [
f"Types: {' '.join(self.types)}",
f"URIs: {' '.join(self.uris)}",
f"Suites: {' '.join(self.suites)}",
] ]
if self.components: if self.components:
fields.append(("Components", " ".join(self.components))) lines.append(f"Components: {' '.join(self.components)}")
if self.architectures: if self.architectures:
fields.append(("Architectures", " ".join(self.architectures))) lines.append(f"Architectures: {' '.join(self.architectures)}")
if self.languages: if self.languages:
fields.append(("Languages", " ".join(self.languages))) lines.append(f"Languages: {' '.join(self.languages)}")
if self.targets: if self.targets:
fields.append(("Targets", " ".join(self.targets))) lines.append(f"Targets: {' '.join(self.targets)}")
if self.signed_by: if signed_by_armored_key:
fields.append(("Signed-By", self.signed_by)) lines.append(_fold_deb822_multiline("Signed-By", signed_by_armored_key))
elif signed_by_path:
lines.append(f"Signed-By: {signed_by_path}")
if self.trusted is not None: if self.trusted is not None:
fields.append(("Trusted", _yes_no(self.trusted))) lines.append(f"Trusted: {_yes_no(self.trusted)}")
if self.pdiffs is not None: if self.pdiffs is not None:
fields.append(("PDiffs", _yes_no(self.pdiffs))) lines.append(f"PDiffs: {_yes_no(self.pdiffs)}")
if self.by_hash is not None: if self.by_hash is not None:
fields.append(("By-Hash", _yes_no(self.by_hash))) lines.append(f"By-Hash: {_yes_no(self.by_hash)}")
if self.allow_insecure is not None: if self.allow_insecure is not None:
fields.append(("Allow-Insecure", _yes_no(self.allow_insecure))) lines.append(f"Allow-Insecure: {_yes_no(self.allow_insecure)}")
if self.allow_weak is not None: if self.allow_weak is not None:
fields.append(("Allow-Weak", _yes_no(self.allow_weak))) lines.append(f"Allow-Weak: {_yes_no(self.allow_weak)}")
if self.allow_downgrade_to_insecure is not None: if self.allow_downgrade_to_insecure is not None:
fields.append( lines.append(
( f"Allow-Downgrade-To-Insecure: {_yes_no(self.allow_downgrade_to_insecure)}"
"Allow-Downgrade-To-Insecure",
_yes_no(self.allow_downgrade_to_insecure),
)
) )
if self.check_valid_until is not None: if self.check_valid_until is not None:
fields.append(("Check-Valid-Until", _yes_no(self.check_valid_until))) lines.append(f"Check-Valid-Until: {_yes_no(self.check_valid_until)}")
if self.valid_until_min: if self.valid_until_min:
fields.append(("Valid-Until-Min", self.valid_until_min)) lines.append(f"Valid-Until-Min: {self.valid_until_min}")
if self.valid_until_max: if self.valid_until_max:
fields.append(("Valid-Until-Max", self.valid_until_max)) lines.append(f"Valid-Until-Max: {self.valid_until_max}")
if self.check_date is not None: if self.check_date is not None:
fields.append(("Check-Date", _yes_no(self.check_date))) lines.append(f"Check-Date: {_yes_no(self.check_date)}")
if self.date_max_future: if self.date_max_future:
fields.append(("Date-Max-Future", self.date_max_future)) lines.append(f"Date-Max-Future: {self.date_max_future}")
if self.description: if self.description:
fields.append(("Description", self.description)) lines.append(f"Description: {self.description}")
fields.append(("Enabled", _yes_no(self.enabled))) lines.append(f"Enabled: {_yes_no(self.enabled)}")
return "\n".join(f"{key}: {value}" for key, value in fields) + "\n" return "\n".join(lines) + "\n"
class AptConfig(BaseModel): class AptConfig(BaseModel):
sources: list[AptSource] = Field(default_factory=list) sources: list[AptSource] = Field(default_factory=list)
keys: list[NamedContent] = Field(default_factory=list)
class ApkRepository(BaseModel): class ApkRepository(BaseModel):
"""An Alpine APK repository (see /etc/apk/repositories), e.g. a URL ending in /main or /community.""" """An Alpine APK repository (see /etc/apk/repositories), e.g. a URL ending in /main or /community.
`url_to_replace`, if set, is an fnmatch pattern: this repository is only
applied if it matches at least one URL already present in the machine's
existing apk configuration; otherwise it is skipped entirely.
`key_filename`, if set, names the file the `key` is written to under
`/etc/apk/keys/` instead of a name derived from the key's content. It
must be a single, valid filesystem path component (in particular, no
`/`); if it isn't, it is ignored and the derived name is used instead.
"""
name: str
url: str url: str
key: str | None = None key: str | None = None
key_filename: str | None = None
url_to_replace: str | None = None
class ApkConfig(BaseModel): class ApkConfig(BaseModel):
@@ -119,10 +172,19 @@ class ApkConfig(BaseModel):
class YumRepository(BaseModel): class YumRepository(BaseModel):
"""A YUM/DNF repository (see /etc/yum.repos.d/*.repo).
`index_url_to_replace`, if set, is an fnmatch pattern: this repository is
only applied if it matches at least one baseurl already present in the
machine's existing yum/dnf configuration; otherwise it is skipped
entirely.
"""
model_config = ConfigDict(populate_by_name=True) model_config = ConfigDict(populate_by_name=True)
name: str name: str
index_url: str = Field(alias="index-url") index_url: str = Field(alias="index-url")
gpg_url: str | None = Field(default=None, alias="gpg-url") gpg_url: str | None = Field(default=None, alias="gpg-url")
index_url_to_replace: str | None = Field(default=None, alias="index-url-to-replace")
class YumConfig(BaseModel): class YumConfig(BaseModel):
@@ -177,15 +239,54 @@ class NpmConfig(BaseModel):
class RustRepository(BaseModel): class RustRepository(BaseModel):
"""A cargo registry (see the "source" tables in .cargo/config.toml), """A cargo registry (see the "source" tables in .cargo/config.toml).
served over the sparse HTTP protocol."""
`is_sparse`, if true, marks the registry as served over the sparse
HTTP protocol (its index is then declared as `sparse+<url>`) rather
than a git-based index.
"""
name: str
url: str
is_sparse: bool = False
class RustConfig(BaseModel):
repositories: list[RustRepository] = Field(default_factory=list)
class GoRepository(BaseModel):
"""A Go module proxy (see GOPROXY), served over the standard Go module
proxy protocol."""
name: str name: str
url: str url: str
class RustConfig(BaseModel): class GoConfig(BaseModel):
repositories: list[RustRepository] = Field(default_factory=list) repositories: list[GoRepository] = Field(default_factory=list)
class RubyRepository(BaseModel):
"""A RubyGems source (see the ":sources:" key in gemrc)."""
name: str
url: str
class RubyConfig(BaseModel):
repositories: list[RubyRepository] = Field(default_factory=list)
class NuGetRepository(BaseModel):
"""A NuGet package source (see the "packageSources" key in NuGet.Config)."""
name: str
url: str
class NuGetConfig(BaseModel):
repositories: list[NuGetRepository] = Field(default_factory=list)
class ConfigurationsBlock(BaseModel): class ConfigurationsBlock(BaseModel):
@@ -198,14 +299,34 @@ class ConfigurationsBlock(BaseModel):
php: PhpConfig = Field(default_factory=PhpConfig) php: PhpConfig = Field(default_factory=PhpConfig)
npm: NpmConfig = Field(default_factory=NpmConfig) npm: NpmConfig = Field(default_factory=NpmConfig)
rust: RustConfig = Field(default_factory=RustConfig) rust: RustConfig = Field(default_factory=RustConfig)
go: GoConfig = Field(default_factory=GoConfig)
ruby: RubyConfig = Field(default_factory=RubyConfig)
nuget: NuGetConfig = Field(default_factory=NuGetConfig)
class Configuration(BaseModel): class Configuration(BaseModel):
uuid: UUID """`version` is the configuration format's own schema version (currently
organisation: str "1.0"), independent of the id-configurator package version - it lets a
description: str = "" future, incompatible format change be told apart from this one. Omitting
it defaults to "1.0", the only version there currently is."""
version: str = "1.0"
uuid: UUID | None = None
organisation: str | None = None
description: str | None = None
configurations: ConfigurationsBlock = Field(default_factory=ConfigurationsBlock) configurations: ConfigurationsBlock = Field(default_factory=ConfigurationsBlock)
@field_validator("version", mode="before")
@classmethod
def _coerce_version_to_str(cls, value: object) -> object:
"""Accept a bare YAML number (`version: 1.0`) as well as a quoted
string (`version: "1.0"`): YAML parses the unquoted, natural-looking
form as a float, which would otherwise fail validation with a
confusing type error."""
if isinstance(value, (int, float)) and not isinstance(value, bool):
return str(value)
return value
def parse_configuration(raw: str) -> Configuration: def parse_configuration(raw: str) -> Configuration:
"""Parse a configuration from a JSON or YAML string.""" """Parse a configuration from a JSON or YAML string."""
@@ -216,14 +337,55 @@ def parse_configuration(raw: str) -> Configuration:
return Configuration.model_validate(data) return Configuration.model_validate(data)
def _fetch_config_url(
url: str, cacert_content: str | None, insecure: bool = False
) -> str:
"""Download a JSON or YAML configuration from `url`.
If `cacert_content` is given, it - and only it, not the system's default
trust store - is used to verify an HTTPS connection: for an internal
endpoint signed by a private CA the running system doesn't otherwise
trust yet (e.g. before the `certificates` module has ever run).
If `insecure` is set instead, certificate verification (and hostname
checking) is disabled entirely - e.g. to bootstrap from a server whose
certificate isn't trusted, or trustable, at all yet. `cacert_content` is
then ignored: there would be nothing left to verify it against.
"""
if insecure:
# Explicitly requested by the operator via --config-url-insecure.
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
else:
context = ssl.create_default_context(
cadata=cacert_content if cacert_content is not None else None
)
with urllib.request.urlopen( # nosec B310 (url is an operator-provided CLI argument)
url, timeout=_CONFIG_URL_TIMEOUT_SECONDS, context=context
) as response:
return response.read().decode("utf-8")
def load_configuration( def load_configuration(
config_file: Path | None, config_env: str | None config_file: Path | None,
config_env: str | None,
config_url: str | None = None,
config_url_cacert_content: str | None = None,
config_url_insecure: bool = False,
) -> Configuration: ) -> Configuration:
"""Load a configuration from a file, or from a base64-encoded string (typically an env var).""" """Load a configuration from a file, from a base64-encoded string
(typically an env var), or by downloading it from a URL."""
if config_file is not None: if config_file is not None:
raw = config_file.read_text(encoding="utf-8") raw = config_file.read_text(encoding="utf-8")
elif config_env is not None: elif config_env is not None:
raw = base64.b64decode(config_env).decode("utf-8") raw = base64.b64decode(config_env).decode("utf-8")
elif config_url is not None:
raw = _fetch_config_url(
config_url, config_url_cacert_content, config_url_insecure
)
else: else:
raise ValueError("either --config or --config-env must be provided") raise ValueError(
"either --config, --config-env or --config-url must be provided"
)
return parse_configuration(raw) return parse_configuration(raw)
+54 -1
View File
@@ -7,18 +7,71 @@ filesystem used to detect the existing configuration.
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from id_configurator.config import Configuration from id_configurator.config import Configuration
from id_configurator.users import (
DEFAULT_UID_MIN,
PasswdEntry,
parse_login_defs_uid_min,
parse_passwd,
)
class Context: class Context:
def __init__( def __init__(
self, config: Configuration, dst: Path, src: Path | None = None self,
config: Configuration,
dst: Path,
src: Path | None = None,
users: Sequence[str] | None = None,
auto_users: bool = False,
) -> None: ) -> None:
self.config = config self.config = config
self.dst = dst.resolve() self.dst = dst.resolve()
self.src = src.resolve() if src is not None else None self.src = src.resolve() if src is not None else None
self._passwd_entries = self._load_passwd_entries()
self._uid_min = self._load_uid_min()
self.users = self._resolve_users(users or [], auto_users)
def _load_passwd_entries(self) -> dict[str, PasswdEntry]:
content = self.read_src_file("etc/passwd")
if content is None:
return {}
return {entry.name: entry for entry in parse_passwd(content.decode("utf-8"))}
def _load_uid_min(self) -> int:
content = self.read_src_file("etc/login.defs")
if content is None:
return DEFAULT_UID_MIN
uid_min = parse_login_defs_uid_min(content.decode("utf-8"))
return DEFAULT_UID_MIN if uid_min is None else uid_min
def _resolve_users(self, users: Sequence[str], auto_users: bool) -> list[str]:
resolved: list[str] = []
for user in users:
if user not in resolved:
resolved.append(user)
if auto_users:
for name, entry in self._passwd_entries.items():
if name not in resolved and entry.is_regular_user(self._uid_min):
resolved.append(name)
return resolved
def home_dir(self, user: str) -> str:
"""Relative path (no leading `/`) to `user`'s home directory, as used
by modules that write per-user configuration files.
Taken from `<src>`'s `/etc/passwd` entry for `user` when there is
one (the case for a user resolved via `--auto-users`); otherwise
falls back to the usual `/home/<user>` convention (`/root` for
root).
"""
entry = self._passwd_entries.get(user)
if entry is not None and entry.home:
return entry.home.lstrip("/")
return "root" if user == "root" else f"home/{user}"
def dst_path(self, relative: str) -> Path: def dst_path(self, relative: str) -> Path:
"""Resolve a path relative to the destination directory, refusing to escape it.""" """Resolve a path relative to the destination directory, refusing to escape it."""
+6
View File
@@ -7,10 +7,13 @@ from id_configurator.modules import (
apk, apk,
apt, apt,
certificates, certificates,
go,
java, java,
npm, npm,
nuget,
php, php,
python, python,
ruby,
rust, rust,
yum, yum,
) )
@@ -19,10 +22,13 @@ __all__ = [
"apk", "apk",
"apt", "apt",
"certificates", "certificates",
"go",
"java", "java",
"npm", "npm",
"nuget",
"php", "php",
"python", "python",
"ruby",
"rust", "rust",
"yum", "yum",
] ]
+81 -13
View File
@@ -1,18 +1,27 @@
"""Detect existing APK (Alpine) repositories and configure new ones. """Detect existing APK (Alpine) repositories and configure new ones.
If `<src>/etc/apk` exists, the existing `/etc/apk/repositories` file (if any) If `<src>/etc/apk` exists, the existing `/etc/apk/repositories` file (if any)
is copied into `<dst>` with every active line disabled (commented out), and is copied into `<dst>`, and the repositories declared in the configuration
the repositories declared in the configuration are appended as new, active are appended as new, active lines. An existing active line is only disabled
lines. Public keys provided alongside a repository are written to (commented out) if it matches the `url_to_replace` pattern of one of the
configured repositories; lines matching no such pattern are copied through
unchanged. Public keys provided alongside a repository are written to
`<dst>/etc/apk/keys/`, named after their content so that repositories `<dst>/etc/apk/keys/`, named after their content so that repositories
sharing the same key (a common case for official Alpine mirrors) only sharing the same key (a common case for official Alpine mirrors) only
produce a single key file. produce a single key file.
A repository's `url_to_replace`, if set, is a fnmatch pattern checked
against the URLs already active in /etc/apk/repositories: the repository
is only written out if the pattern matches at least one of them, and
skipped entirely otherwise.
""" """
from __future__ import annotations from __future__ import annotations
import fnmatch
import hashlib import hashlib
import logging import logging
from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from id_configurator.context import Context from id_configurator.context import Context
@@ -25,11 +34,18 @@ _REPOSITORIES = "etc/apk/repositories"
_KEYS_DIR = "etc/apk/keys" _KEYS_DIR = "etc/apk/keys"
def _disable_repositories_content(content: str) -> str: def _disable_repositories_content(content: str, patterns: Sequence[str]) -> str:
lines = [ lines = []
line if not line.strip() or line.strip().startswith("#") else f"# {line}" for line in content.splitlines():
for line in content.splitlines() stripped = line.strip()
] if (
stripped
and not stripped.startswith("#")
and any(fnmatch.fnmatch(stripped, pattern) for pattern in patterns)
):
lines.append(f"# {line}")
else:
lines.append(line)
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
@@ -37,6 +53,31 @@ def _key_filename(content: bytes) -> str:
return f"{hashlib.sha256(content).hexdigest()[:16]}.rsa.pub" return f"{hashlib.sha256(content).hexdigest()[:16]}.rsa.pub"
def _is_valid_key_filename(name: str) -> bool:
"""Check that `name` is safe to use as a single filename under `_KEYS_DIR`.
Unlike `sanitize_filename`, an invalid name is not cleaned up: the
caller falls back to the content-derived name instead, since a
configured `key_filename` that isn't a plain file name (e.g. containing
`/` or `\\`, which would turn into extra path segments, or `.`/`..`)
most likely indicates a configuration mistake.
"""
return name not in ("", ".", "..") and "/" not in name and "\\" not in name
def _existing_urls(context: Context) -> set[str]:
"""Collect the URLs already declared in /etc/apk/repositories on the
source filesystem (one active, non-comment line per URL)."""
existing = context.read_src_file(_REPOSITORIES)
if existing is None:
return set()
return {
stripped
for line in existing.decode("utf-8").splitlines()
if (stripped := line.strip()) and not stripped.startswith("#")
}
@register @register
class ApkModule(Module): class ApkModule(Module):
name = "apk" name = "apk"
@@ -52,27 +93,54 @@ class ApkModule(Module):
return None return None
def apply(self, context: Context) -> None: def apply(self, context: Context) -> None:
disabled = self._disabled_existing_repositories(context) patterns = [
repository.url_to_replace
for repository in context.config.configurations.apk.repositories
if repository.url_to_replace is not None
]
disabled = self._disabled_existing_repositories(context, patterns)
configured = self._configured_repositories_lines(context) configured = self._configured_repositories_lines(context)
content = disabled + ("\n".join(configured) + "\n" if configured else "") content = disabled + ("\n".join(configured) + "\n" if configured else "")
if content: if content:
context.write_file(_REPOSITORIES, content.encode("utf-8")) context.write_file(_REPOSITORIES, content.encode("utf-8"))
@staticmethod @staticmethod
def _disabled_existing_repositories(context: Context) -> str: def _disabled_existing_repositories(
context: Context, patterns: Sequence[str]
) -> str:
existing = context.read_src_file(_REPOSITORIES) existing = context.read_src_file(_REPOSITORIES)
if existing is None: if existing is None:
return "" return ""
logger.info("disabled existing apk repositories %s", _REPOSITORIES) logger.info("copied existing apk repositories %s", _REPOSITORIES)
return _disable_repositories_content(existing.decode("utf-8")) return _disable_repositories_content(existing.decode("utf-8"), patterns)
@staticmethod @staticmethod
def _configured_repositories_lines(context: Context) -> list[str]: def _configured_repositories_lines(context: Context) -> list[str]:
lines = [] lines = []
existing_urls: set[str] | None = None
for repository in context.config.configurations.apk.repositories: for repository in context.config.configurations.apk.repositories:
if repository.url_to_replace is not None:
if existing_urls is None:
existing_urls: set[str] = _existing_urls(context)
if not any(
fnmatch.fnmatch(url, repository.url_to_replace)
for url in existing_urls
):
logger.info(
"skipped apk repository %s: url-to-replace %s matched "
"no existing url",
repository.name,
repository.url_to_replace,
)
continue
lines.append(repository.url) lines.append(repository.url)
logger.info("added apk repository %s", repository.url) logger.info("added apk repository %s", repository.name)
if repository.key: if repository.key:
if repository.key_filename and _is_valid_key_filename(
repository.key_filename
):
filename = repository.key_filename
else:
filename = _key_filename(repository.key.encode("utf-8")) filename = _key_filename(repository.key.encode("utf-8"))
context.write_file( context.write_file(
f"{_KEYS_DIR}/{filename}", repository.key.encode("utf-8") f"{_KEYS_DIR}/{filename}", repository.key.encode("utf-8")
+147 -34
View File
@@ -2,45 +2,139 @@
If `<src>/etc/apt` exists, any source file already present there (classic If `<src>/etc/apt` exists, any source file already present there (classic
one-line `.list` files or DEB822 `.sources` files, whether at the top level one-line `.list` files or DEB822 `.sources` files, whether at the top level
or under `sources.list.d/`) is copied into `<dst>/etc/apt`, disabled so it no or under `sources.list.d/`) is copied into `<dst>/etc/apt`, and the sources
longer takes effect, and the sources declared in the configuration are added declared in the configuration are added as new, enabled `.sources` files.
as new, enabled `.sources` files.
An existing entry (a single `deb`/`deb-src` line, or a DEB822 stanza) is only
disabled if its URI matches the `uri_to_replace` pattern of one of the
configured sources; entries matching no such pattern are copied through
unchanged, still enabled.
Each source can be trusted two ways: `signed_by` holds the ASCII-armored
content of a GPG public key, folded inline as a multi-line `Signed-By`
field (per sources.list(5) - verified empirically against a real,
genuinely signed repository, so no `gpg` binary or separate keyring file
is needed at all to apply this configuration). `signed_by_file` instead
names a keyring already expected to exist on the target system, used
verbatim as `Signed-By`; if both are given, `signed_by` takes precedence.
A source's `uri_to_replace`, if set, is an fnmatch pattern checked against
the URIs of the sources already present on the source filesystem: the
source is only written out if the pattern matches at least one of them,
and skipped entirely otherwise - even on an otherwise Debian-like machine.
""" """
from __future__ import annotations from __future__ import annotations
import fnmatch
import logging import logging
from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from id_configurator.context import Context from id_configurator.context import Context
from id_configurator.modules.base import Module, register from id_configurator.modules.base import Module, register, sanitize_filename
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_APT_DIR = "etc/apt" _APT_DIR = "etc/apt"
_SOURCES_LIST = "etc/apt/sources.list" _SOURCES_LIST = "etc/apt/sources.list"
_SOURCES_LIST_D = "etc/apt/sources.list.d" _SOURCES_LIST_D = "etc/apt/sources.list.d"
_KEYRINGS_DIR = "etc/apt/keyrings"
def _disable_list_content(content: str) -> str: def _uri_from_deb_line(stripped_line: str) -> str | None:
lines = [ """Extract the URI from a single, already-stripped `deb`/`deb-src`
line if not line.strip() or line.strip().startswith("#") else f"# {line}" line (`deb [options] uri suite [component...]`), or `None` if the line
for line in content.splitlines() is not such a line."""
] parts = stripped_line.split()
if not parts or parts[0] not in ("deb", "deb-src"):
return None
parts = parts[1:]
while parts and parts[0].startswith("["):
while parts and not parts[0].endswith("]"):
parts.pop(0)
if parts:
parts.pop(0)
return parts[0] if parts else None
def _disable_list_content(content: str, patterns: Sequence[str]) -> str:
lines = []
for line in content.splitlines():
stripped = line.strip()
uri = (
None
if not stripped or stripped.startswith("#")
else _uri_from_deb_line(stripped)
)
if uri is not None and any(
fnmatch.fnmatch(uri, pattern) for pattern in patterns
):
lines.append(f"# {line}")
else:
lines.append(line)
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
def _disable_deb822_content(content: str) -> str: def _uris_from_deb822_stanza(stanza: str) -> set[str]:
disabled_stanzas = [] uris: set[str] = set()
for line in stanza.splitlines():
if line.lower().startswith("uris:"):
uris.update(line.split(":", 1)[1].split())
return uris
def _disable_deb822_content(content: str, patterns: Sequence[str]) -> str:
stanzas = []
for stanza in content.split("\n\n"): for stanza in content.split("\n\n"):
if not stanza.strip(): if not stanza.strip():
continue continue
lines = stanza.splitlines() lines = stanza.splitlines()
new_lines = [line for line in lines if not line.lower().startswith("enabled:")] uris = _uris_from_deb822_stanza(stanza)
new_lines.append("Enabled: no") if any(fnmatch.fnmatch(uri, pattern) for uri in uris for pattern in patterns):
disabled_stanzas.append("\n".join(new_lines)) lines = [line for line in lines if not line.lower().startswith("enabled:")]
return "\n\n".join(disabled_stanzas) + "\n" lines.append("Enabled: no")
stanzas.append("\n".join(lines))
return "\n\n".join(stanzas) + "\n"
def _uris_from_list_content(content: str) -> set[str]:
"""Extract the URIs declared in a classic one-line `.list` file
(`deb [options] uri suite [component...]`), skipping comments and any
bracketed option list."""
uris: set[str] = set()
for line in content.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
uri = _uri_from_deb_line(stripped)
if uri is not None:
uris.add(uri)
return uris
def _uris_from_deb822_content(content: str) -> set[str]:
"""Extract the URIs declared in the `URIs:` field of each stanza of a
DEB822 `.sources` file."""
uris: set[str] = set()
for stanza in content.split("\n\n"):
uris.update(_uris_from_deb822_stanza(stanza))
return uris
def _existing_uris(context: Context) -> set[str]:
"""Collect every URI declared by the apt sources already present on the
source filesystem, whether in the top-level sources.list or under
sources.list.d/, in either the classic or the DEB822 format."""
uris: set[str] = set()
existing_top_level = context.read_src_file(_SOURCES_LIST)
if existing_top_level is not None:
uris.update(_uris_from_list_content(existing_top_level.decode("utf-8")))
for entry in context.iter_src_dir(_SOURCES_LIST_D):
if entry.suffix == ".sources":
uris.update(_uris_from_deb822_content(entry.read_text(encoding="utf-8")))
elif entry.suffix == ".list":
uris.update(_uris_from_list_content(entry.read_text(encoding="utf-8")))
return uris
@register @register
@@ -58,43 +152,62 @@ class AptModule(Module):
return None return None
def apply(self, context: Context) -> None: def apply(self, context: Context) -> None:
self._copy_existing_disabled(context) patterns = [
self._write_configured_keys(context) source.uri_to_replace
for source in context.config.configurations.apt.sources
if source.uri_to_replace is not None
]
self._copy_existing_disabled(context, patterns)
self._write_configured_sources(context) self._write_configured_sources(context)
@staticmethod @staticmethod
def _copy_existing_disabled(context: Context) -> None: def _copy_existing_disabled(context: Context, patterns: Sequence[str]) -> None:
existing_top_level = context.read_src_file(_SOURCES_LIST) existing_top_level = context.read_src_file(_SOURCES_LIST)
if existing_top_level is not None: if existing_top_level is not None:
disabled = _disable_list_content(existing_top_level.decode("utf-8")) disabled = _disable_list_content(
existing_top_level.decode("utf-8"), patterns
)
context.write_file(_SOURCES_LIST, disabled.encode("utf-8")) context.write_file(_SOURCES_LIST, disabled.encode("utf-8"))
logger.info("disabled existing apt source list %s", _SOURCES_LIST) logger.info("copied existing apt source list %s", _SOURCES_LIST)
for entry in context.iter_src_dir(_SOURCES_LIST_D): for entry in context.iter_src_dir(_SOURCES_LIST_D):
if entry.suffix == ".sources": if entry.suffix == ".sources":
disabled = _disable_deb822_content(entry.read_text(encoding="utf-8")) disabled = _disable_deb822_content(
entry.read_text(encoding="utf-8"), patterns
)
elif entry.suffix == ".list": elif entry.suffix == ".list":
disabled = _disable_list_content(entry.read_text(encoding="utf-8")) disabled = _disable_list_content(
entry.read_text(encoding="utf-8"), patterns
)
else: else:
continue continue
context.write_file( context.write_file(
f"{_SOURCES_LIST_D}/{entry.name}", disabled.encode("utf-8") f"{_SOURCES_LIST_D}/{entry.name}", disabled.encode("utf-8")
) )
logger.info("disabled existing apt source %s", entry.name) logger.info("copied existing apt source %s", entry.name)
@staticmethod
def _write_configured_keys(context: Context) -> None:
for key in context.config.configurations.apt.keys:
context.write_file(
f"{_KEYRINGS_DIR}/{key.name}.asc", key.content.encode("utf-8")
)
logger.info("installed apt keyring %s", key.name)
@staticmethod @staticmethod
def _write_configured_sources(context: Context) -> None: def _write_configured_sources(context: Context) -> None:
existing_uris: set[str] | None = None
for source in context.config.configurations.apt.sources: for source in context.config.configurations.apt.sources:
if source.uri_to_replace is not None:
if existing_uris is None:
existing_uris = _existing_uris(context)
if not any(
fnmatch.fnmatch(uri, source.uri_to_replace) for uri in existing_uris
):
logger.info(
"skipped apt source %s: uri-to-replace %s matched no "
"existing uri",
source.name,
source.uri_to_replace,
)
continue
context.write_file( context.write_file(
f"{_SOURCES_LIST_D}/{source.name}.sources", f"{_SOURCES_LIST_D}/{sanitize_filename(source.name)}.sources",
source.to_deb822().encode("utf-8"), source.to_deb822(
signed_by_armored_key=source.signed_by,
signed_by_path=source.signed_by_file,
).encode("utf-8"),
) )
logger.info("added apt source %s", source.name) logger.info("added apt source %s", source.name)
+35
View File
@@ -7,6 +7,7 @@ the destination directory.
from __future__ import annotations from __future__ import annotations
import re
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from id_configurator.context import Context from id_configurator.context import Context
@@ -34,3 +35,37 @@ def register(module_cls: type[Module]) -> type[Module]:
def all_modules() -> list[type[Module]]: def all_modules() -> list[type[Module]]:
return list(_REGISTRY) return list(_REGISTRY)
_IDENTIFIER_FORBIDDEN_CHARS = re.compile(r"[^A-Za-z0-9_-]")
def sanitize_filename(name: str) -> str:
"""Make a user-supplied name safe to use as a single filesystem path
component.
Modules that derive a file's name from configuration data (a
certificate's name, a repository's name, ...) must run it through this
first. Every character that isn't a letter, a digit, `_` or `-` is
replaced with `_`: this rules out `/` and `\\` (which would silently
turn into extra, unintended path segments), `.` (so the result can
never be `.` or `..`), as well as spaces, control characters and any
other character that could confuse the tools reading these files. An
empty name yields `_`, so the result is always a non-empty file name.
"""
return _IDENTIFIER_FORBIDDEN_CHARS.sub("_", name) or "_"
def sanitize_repository_name(name: str) -> str:
"""Replace every character of `name` that isn't a plain identifier
character (letters, digits, `_`, `-`) with `_`.
Several modules write a repository's name verbatim as a bare,
unquoted identifier in a generated config file - an INI section
header, a TOML table key, ... - where an unrestricted name could break
out of that syntax (e.g. `]`, a newline, or - for TOML - a literal
`.`, which would silently open extra, nested tables instead of naming
the one table it's meant to). Spaces and every other non-identifier
character are replaced the same way, for consistency across callers.
"""
return _IDENTIFIER_FORBIDDEN_CHARS.sub("_", name)
+16 -6
View File
@@ -49,7 +49,7 @@ import posixpath
from pathlib import Path from pathlib import Path
from id_configurator.context import Context from id_configurator.context import Context
from id_configurator.modules.base import Module, register from id_configurator.modules.base import Module, register, sanitize_filename
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -142,7 +142,7 @@ class CertificatesModule(Module):
def apply(self, context: Context) -> None: def apply(self, context: Context) -> None:
configured_names = { configured_names = {
certificate.name sanitize_filename(certificate.name)
for certificate in context.config.configurations.certificates for certificate in context.config.configurations.certificates
} }
for existing in context.iter_src_dir(_LOCAL_CERTS_DIR): for existing in context.iter_src_dir(_LOCAL_CERTS_DIR):
@@ -152,7 +152,7 @@ class CertificatesModule(Module):
) )
for certificate in context.config.configurations.certificates: for certificate in context.config.configurations.certificates:
context.write_file( context.write_file(
f"{_LOCAL_CERTS_DIR}/{certificate.name}.crt", f"{_LOCAL_CERTS_DIR}/{sanitize_filename(certificate.name)}.crt",
certificate.content.encode(), certificate.content.encode(),
) )
logger.info("installed root certificate %s", certificate.name) logger.info("installed root certificate %s", certificate.name)
@@ -213,8 +213,18 @@ class CertificatesModule(Module):
logger.info("rebuilt %s from %d certificate(s)", _CERT_BUNDLE, len(entries)) logger.info("rebuilt %s from %d certificate(s)", _CERT_BUNDLE, len(entries))
if etccertsdir_relative == _ETCCERTSDIR: if etccertsdir_relative == _ETCCERTSDIR:
cert_file = context.dst_path(_OPENSSL_DEFAULT_CERT_FILE) # Not context.dst_path(_OPENSSL_DEFAULT_CERT_FILE) directly: on a
cert_file.parent.mkdir(parents=True, exist_ok=True) # host that already has this symlink (e.g. re-applying to a live
# `--dst /`), resolving the leaf itself would follow it straight
# to the bundle we just wrote, and the unlink+symlink_to below
# would then target the bundle's own path instead of this one -
# replacing the bundle with a symlink to itself. Resolving only
# the containing directory keeps the leaf name literal.
cert_file_dir = context.dst_path(
posixpath.dirname(_OPENSSL_DEFAULT_CERT_FILE)
)
cert_file_dir.mkdir(parents=True, exist_ok=True)
cert_file = cert_file_dir / posixpath.basename(_OPENSSL_DEFAULT_CERT_FILE)
cert_file.unlink(missing_ok=True) cert_file.unlink(missing_ok=True)
cert_file.symlink_to(f"/{etccertsdir_relative}/{_CERT_BUNDLE}") cert_file.symlink_to(f"/{etccertsdir_relative}/{_CERT_BUNDLE}")
@@ -227,7 +237,7 @@ class CertificatesModule(Module):
) )
for certificate in context.config.configurations.certificates: for certificate in context.config.configurations.certificates:
context.write_file( context.write_file(
f"{_RHEL_ANCHORS_DIR}/{certificate.name}.crt", f"{_RHEL_ANCHORS_DIR}/{sanitize_filename(certificate.name)}.crt",
certificate.content.encode(), certificate.content.encode(),
) )
+55
View File
@@ -0,0 +1,55 @@
"""Configure the Go toolchain to use the configured GoRepository entries as
its module proxy (GOPROXY), replacing the default (proxy.golang.org). Since
an air-gapped mirror won't be listed in the public checksum database and
sum.golang.org is unreachable anyway, GOSUMDB is also turned off - each
module's hash is then simply trusted on first use, exactly as it would be
for any other GOPROXY.
Go'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 `$GOROOT/go.env`, which `$GOROOT` defaults to `/usr/local/go` for
essentially every standard Go install (the official Docker image included);
unlike a config dropped in one specific user's home, this applies regardless
of which user invokes `go`. The go command reads `$GOROOT/go.env` as a
fallback whenever a setting is not already present in a higher-precedence
level - in particular the per-user `go env -w` file (found at
`os.UserConfigDir()/go/env`, e.g. `$HOME/.config/go/env`), which this module
also writes for each configured `--user` (`context.users`), so that one
takes precedence should that user ever run `go env -w` themselves.
"""
from __future__ import annotations
import logging
from id_configurator.config import GoRepository
from id_configurator.context import Context
from id_configurator.modules.base import Module, register
logger = logging.getLogger(__name__)
_GO_ENV = "usr/local/go/go.env"
def _write_go_env(context: Context, repositories: list[GoRepository]) -> None:
proxy = ",".join(repository.url for repository in repositories)
lines = [f"GOPROXY={proxy}", "GOSUMDB=off"]
content = ("\n".join(lines) + "\n").encode("utf-8")
context.write_file(_GO_ENV, content)
for user in context.users:
context.write_file(f"{context.home_dir(user)}/.config/go/env", content)
logger.info("configured go for user %s", user)
logger.info("configured go to use %d module prox(y/ies)", len(repositories))
@register
class GoModule(Module):
name = "go"
def is_relevant(self, context: Context) -> bool:
return bool(context.config.configurations.go.repositories)
def apply(self, context: Context) -> None:
_write_go_env(context, context.config.configurations.go.repositories)
+26 -8
View File
@@ -28,9 +28,20 @@ tool targets Docker images, which are built and run as root:
- Ivy: `~/.ivy2/ivysettings.xml`, as a chain of `ibiblio` resolvers. - Ivy: `~/.ivy2/ivysettings.xml`, as a chain of `ibiblio` resolvers.
- SBT: `~/.sbt/repositories`, the resolver override file SBT reads for its - SBT: `~/.sbt/repositories`, the resolver override file SBT reads for its
own bootstrapping and, when combined with the `sbt.override.build.repos` own bootstrapping and, when combined with the `sbt.override.build.repos`
system property, for every project's resolvers as well. system property, for every project's resolvers as well. A repository
whose URL isn't HTTPS gets `, allowInsecureProtocol` appended to its
line - SBT's Coursier-backed resolver otherwise refuses it outright
("insecure protocol is unsupported"), unlike every other entry in
`~/.sbt/repositories` which takes no such suffix.
- Leiningen: `~/.lein/profiles.clj`, adding the repositories to the `:user` - Leiningen: `~/.lein/profiles.clj`, adding the repositories to the `:user`
profile. profile. Leiningen, alone among these, refuses a plain-HTTP repository
outright ("Tried to use insecure HTTP repository without TLS") - its
standalone `lein` launcher (`leiningen.core.main/-main`) unconditionally
registers an aborting wagon for the "http" scheme before even reading the
project/profile config, so there is no project.clj/profiles.clj setting
(there is no `:insecure` key anywhere in Leiningen's own source) that
can work around this: a non-HTTPS repository configured here will not be
usable from Leiningen specifically, regardless of what this module does.
""" """
from __future__ import annotations from __future__ import annotations
@@ -39,6 +50,7 @@ import json
import logging import logging
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse
import jks import jks
from cryptography import x509 from cryptography import x509
@@ -50,7 +62,7 @@ from cryptography.hazmat.primitives.serialization import (
from id_configurator.config import JavaRepository, NamedContent from id_configurator.config import JavaRepository, NamedContent
from id_configurator.context import Context from id_configurator.context import Context
from id_configurator.modules.base import Module, register from id_configurator.modules.base import Module, register, sanitize_repository_name
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -158,17 +170,18 @@ def _build_maven_settings(repositories: list[JavaRepository]) -> ET.Element:
repositories_el = ET.SubElement(profile, "repositories") repositories_el = ET.SubElement(profile, "repositories")
plugin_repositories_el = ET.SubElement(profile, "pluginRepositories") plugin_repositories_el = ET.SubElement(profile, "pluginRepositories")
for repository in repositories: for repository in repositories:
name = sanitize_repository_name(repository.name)
for parent, tag in ( for parent, tag in (
(repositories_el, "repository"), (repositories_el, "repository"),
(plugin_repositories_el, "pluginRepository"), (plugin_repositories_el, "pluginRepository"),
): ):
entry = ET.SubElement(parent, tag) entry = ET.SubElement(parent, tag)
ET.SubElement(entry, "id").text = repository.name ET.SubElement(entry, "id").text = name
ET.SubElement(entry, "url").text = repository.url ET.SubElement(entry, "url").text = repository.url
active_profiles = ET.SubElement(settings, "activeProfiles") active_profiles = ET.SubElement(settings, "activeProfiles")
ET.SubElement(active_profiles, "activeProfile").text = "id-configurator" ET.SubElement(active_profiles, "activeProfile").text = "id-configurator"
mirror = ET.SubElement(ET.SubElement(settings, "mirrors"), "mirror") mirror = ET.SubElement(ET.SubElement(settings, "mirrors"), "mirror")
ET.SubElement(mirror, "id").text = repositories[0].name ET.SubElement(mirror, "id").text = sanitize_repository_name(repositories[0].name)
ET.SubElement(mirror, "url").text = repositories[0].url ET.SubElement(mirror, "url").text = repositories[0].url
ET.SubElement(mirror, "mirrorOf").text = "*" ET.SubElement(mirror, "mirrorOf").text = "*"
return settings return settings
@@ -233,13 +246,18 @@ def _write_ivy_settings(context: Context, repositories: list[JavaRepository]) ->
logger.info("configured ivy to use %d repositories", len(repositories)) logger.info("configured ivy to use %d repositories", len(repositories))
def _sbt_repository_line(repository: JavaRepository) -> str:
line = f" {repository.name}: {repository.url}"
if urlparse(repository.url).scheme.lower() != "https":
line += ", allowInsecureProtocol"
return line
def _write_sbt_repositories( def _write_sbt_repositories(
context: Context, repositories: list[JavaRepository] context: Context, repositories: list[JavaRepository]
) -> None: ) -> None:
lines = ["[repositories]", " local"] lines = ["[repositories]", " local"]
lines.extend( lines.extend(_sbt_repository_line(repository) for repository in repositories)
f" {repository.name}: {repository.url}" for repository in repositories
)
context.write_file(_SBT_REPOSITORIES, ("\n".join(lines) + "\n").encode("utf-8")) context.write_file(_SBT_REPOSITORIES, ("\n".join(lines) + "\n").encode("utf-8"))
logger.info("configured sbt to use %d repositories", len(repositories)) logger.info("configured sbt to use %d repositories", len(repositories))
+34 -2
View File
@@ -15,6 +15,19 @@ separate, higher-precedence level in npm's own terminology. `$prefix`
defaults to `/usr/local` for essentially every standard Node.js install defaults to `/usr/local` for essentially every standard Node.js install
(the official Docker image included); unlike a config dropped in one (the official Docker image included); unlike a config dropped in one
specific user's home, this applies regardless of which user invokes npm. specific user's home, this applies regardless of which user invokes npm.
`cafile` only covers npm's own registry client, though - it does nothing for
Node itself, e.g. a package's install script or the application's own code
making an HTTPS request. Node has no config file for that: the only way to
extend its trust store is the `NODE_EXTRA_CA_CERTS` environment variable,
read once at process startup. So whenever any certificate is configured -
regardless of whether any npm repository is - it is also set system-wide in
`/etc/environment` (merged with whatever is already there), which every
login-based process (an SSH session, `su -`, ...) picks up via PAM's
`pam_env`. This does not reach a `docker build`/`RUN` step or a container's
own `ENTRYPOINT`/`CMD`, which never go through a login shell: propagating it
there is the resulting image's own `Dockerfile`'s job (a plain `ENV`
instruction), outside what a tool that only writes files can do.
""" """
from __future__ import annotations from __future__ import annotations
@@ -29,6 +42,8 @@ logger = logging.getLogger(__name__)
_NPMRC = "usr/local/etc/npmrc" _NPMRC = "usr/local/etc/npmrc"
_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt" _CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
_ETC_ENVIRONMENT = "etc/environment"
_NODE_EXTRA_CA_CERTS_VAR = "NODE_EXTRA_CA_CERTS"
def _write_npmrc(context: Context, repositories: list[NpmRepository]) -> None: def _write_npmrc(context: Context, repositories: list[NpmRepository]) -> None:
@@ -41,12 +56,29 @@ def _write_npmrc(context: Context, repositories: list[NpmRepository]) -> None:
logger.info("configured npm to use %s", repositories[0].url) logger.info("configured npm to use %s", repositories[0].url)
def _update_etc_environment(context: Context) -> None:
existing = context.read_src_file(_ETC_ENVIRONMENT)
lines = [] if existing is None else existing.decode("utf-8").splitlines()
lines = [
line for line in lines if not line.startswith(f"{_NODE_EXTRA_CA_CERTS_VAR}=")
]
lines.append(f"{_NODE_EXTRA_CA_CERTS_VAR}={_CA_BUNDLE}")
context.write_file(_ETC_ENVIRONMENT, ("\n".join(lines) + "\n").encode("utf-8"))
logger.info("configured %s in %s", _NODE_EXTRA_CA_CERTS_VAR, _ETC_ENVIRONMENT)
@register @register
class NpmModule(Module): class NpmModule(Module):
name = "npm" name = "npm"
def is_relevant(self, context: Context) -> bool: def is_relevant(self, context: Context) -> bool:
return bool(context.config.configurations.npm.repositories) return bool(context.config.configurations.npm.repositories) or bool(
context.config.configurations.certificates
)
def apply(self, context: Context) -> None: def apply(self, context: Context) -> None:
_write_npmrc(context, context.config.configurations.npm.repositories) repositories = context.config.configurations.npm.repositories
if repositories:
_write_npmrc(context, repositories)
if context.config.configurations.certificates:
_update_etc_environment(context)
+78
View File
@@ -0,0 +1,78 @@
"""Configure NuGet to use the configured NuGetRepository entries as its
package sources, replacing the default (nuget.org).
NuGet's own HTTPS client (.NET's HttpClient, backed by OpenSSL on Linux)
already trusts whatever the `certificates` module installs into the system
CA store, so no separate certificate configuration is needed here, unlike
npm's.
Microsoft documents a "Computer"-wide config file for this purpose,
`/etc/opt/NuGet/Config` on Linux (or `$NUGET_COMMON_APPLICATION_DATA/NuGet/Config`
should that variable be set) - but the .NET SDK does not actually read it in
practice (confirmed empirically; a known, still-open upstream gap, see
dotnet/sdk#12674), so it would silently have no effect. This instead writes
the "user" level config, `~/.nuget/NuGet/NuGet.Config` (the location the
.NET CLI itself uses; Mono/NuGet.exe use `~/.config/NuGet/NuGet.Config`
instead). This tool targets Docker images, which are built and run as root,
so `~` is assumed to be `/root`, exactly like a fresh dotnet install does
when not overridden - the same tradeoff the `php` module's Composer config
already accepts. A `<clear />` element is included so the configured
sources fully replace nuget.org rather than being merged alongside it,
matching every other package-manager module's behaviour.
The same file is additionally written under each configured `--user`'s own
home directory (`context.users`), since a non-root user invoking `dotnet`
reads its config from there instead.
"""
from __future__ import annotations
import logging
import xml.etree.ElementTree as ET
from urllib.parse import urlparse
from id_configurator.config import NuGetRepository
from id_configurator.context import Context
from id_configurator.modules.base import Module, register
logger = logging.getLogger(__name__)
_NUGET_CONFIG = "root/.nuget/NuGet/NuGet.Config"
def _render_xml(root: ET.Element) -> bytes:
ET.indent(root)
content = '<?xml version="1.0" encoding="utf-8"?>\n' + ET.tostring(
root, encoding="unicode"
)
return (content + "\n").encode("utf-8")
def _write_nuget_config(context: Context, repositories: list[NuGetRepository]) -> None:
configuration = ET.Element("configuration")
package_sources = ET.SubElement(configuration, "packageSources")
ET.SubElement(package_sources, "clear")
for repository in repositories:
attributes = {"key": repository.name, "value": repository.url}
if urlparse(repository.url).scheme.lower() != "https":
attributes["allowInsecureConnections"] = "true"
ET.SubElement(package_sources, "add", attributes)
content = _render_xml(configuration)
context.write_file(_NUGET_CONFIG, content)
for user in context.users:
context.write_file(
f"{context.home_dir(user)}/.nuget/NuGet/NuGet.Config", content
)
logger.info("configured nuget for user %s", user)
logger.info("configured nuget to use %d package source(s)", len(repositories))
@register
class NuGetModule(Module):
name = "nuget"
def is_relevant(self, context: Context) -> bool:
return bool(context.config.configurations.nuget.repositories)
def apply(self, context: Context) -> None:
_write_nuget_config(context, context.config.configurations.nuget.repositories)
+53 -9
View File
@@ -3,23 +3,43 @@
Composer has no "replace the default registry" switch either: Packagist Composer has no "replace the default registry" switch either: Packagist
stays enabled unless a repository is explicitly declared under the name stays enabled unless a repository is explicitly declared under the name
"packagist.org" and disabled. This just makes the configured repositories "packagist.org" and disabled. If any configured repository's URL isn't
HTTPS, Composer refuses to use it unless `secure-http` is turned off in
`config`; since that effectively means this is a private, non-internet
setup, Packagist - unreachable in such a setup anyway - is disabled at the
same time, exactly as if a `{"packagist.org": false}` repository entry had
been declared. This just makes the configured repositories
available system-wide, via Composer's global configuration file: available system-wide, via Composer's global configuration file:
`/root/.composer/config.json` (`COMPOSER_HOME/config.json`; this tool `/root/.composer/config.json` (`COMPOSER_HOME/config.json`; this tool
targets Docker images, which are built and run as root, so `COMPOSER_HOME` 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 is assumed to default to `/root/.composer`, exactly like a fresh Composer
install does when not overridden). install does when not overridden). The same file is additionally written
under each configured `--user`'s own home directory (`context.users`),
since a non-root user invoking `composer` reads its config from there
instead.
Composer's own HTTPS client (PHP's stream wrapper, generally backed by Composer's own HTTPS client (PHP's stream wrapper, generally backed by
curl) already trusts whatever the `certificates` module installs into the curl) already trusts whatever the `certificates` module installs into the
system CA store, so no per-repository certificate configuration is needed system CA store, so no per-repository certificate configuration is needed
here, unlike Poetry's. here, unlike Poetry's.
Whenever any certificate is configured - regardless of whether any PHP
repository is - `openssl.cafile` is also set, pointing at the same bundle,
in a dedicated `conf.d` snippet: `usr/local/etc/php/conf.d/id-configurator-
openssl.ini`, the directory the official PHP Docker images scan for extra
`.ini` files (`docker-php-ext-enable`'s own mechanism), applied regardless
of which SAPI (cli, fpm, apache) or php.ini is in use. This is normally
inferred from OpenSSL's own compiled-in default CAfile (which the
`certificates` module also rebuilds), but not every PHP build honors that
default, so setting it explicitly makes every PHP script's own OpenSSL-based
code (not just Composer's) reliably trust the configured certificates too.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import logging import logging
from urllib.parse import urlparse
from id_configurator.config import PhpRepository from id_configurator.config import PhpRepository
from id_configurator.context import Context from id_configurator.context import Context
@@ -28,29 +48,53 @@ from id_configurator.modules.base import Module, register
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_COMPOSER_CONFIG = "root/.composer/config.json" _COMPOSER_CONFIG = "root/.composer/config.json"
_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
_OPENSSL_INI = "usr/local/etc/php/conf.d/id-configurator-openssl.ini"
def _is_https(url: str) -> bool:
return urlparse(url).scheme.lower() == "https"
def _write_composer_config(context: Context, repositories: list[PhpRepository]) -> None: def _write_composer_config(context: Context, repositories: list[PhpRepository]) -> None:
content = { composer_repositories: list[dict[str, str | bool]] = [
"repositories": [
{"name": repository.name, "type": "composer", "url": repository.url} {"name": repository.name, "type": "composer", "url": repository.url}
for repository in repositories for repository in repositories
] ]
} content: dict[str, object] = {"repositories": composer_repositories}
context.write_file( composer_repositories.append({"packagist.org": False})
_COMPOSER_CONFIG, (json.dumps(content, indent=4) + "\n").encode("utf-8") if any(not _is_https(repository.url) for repository in repositories):
content["config"] = {"secure-http": False}
logger.info(
"disabled secure-http and packagist.org: non-HTTPS repository configured"
) )
rendered = (json.dumps(content, indent=4) + "\n").encode("utf-8")
context.write_file(_COMPOSER_CONFIG, rendered)
for user in context.users:
context.write_file(f"{context.home_dir(user)}/.composer/config.json", rendered)
logger.info("configured composer for user %s", user)
logger.info( logger.info(
"configured composer to use %d additional repositories", len(repositories) "configured composer to use %d additional repositories", len(repositories)
) )
def _write_openssl_ini(context: Context) -> None:
context.write_file(_OPENSSL_INI, f"openssl.cafile={_CA_BUNDLE}\n".encode())
logger.info("configured openssl.cafile to use %s", _CA_BUNDLE)
@register @register
class PhpModule(Module): class PhpModule(Module):
name = "php" name = "php"
def is_relevant(self, context: Context) -> bool: def is_relevant(self, context: Context) -> bool:
return bool(context.config.configurations.php.repositories) return bool(context.config.configurations.php.repositories) or bool(
context.config.configurations.certificates
)
def apply(self, context: Context) -> None: def apply(self, context: Context) -> None:
_write_composer_config(context, context.config.configurations.php.repositories) repositories = context.config.configurations.php.repositories
if repositories:
_write_composer_config(context, repositories)
if context.config.configurations.certificates:
_write_openssl_ini(context)
+43 -9
View File
@@ -3,13 +3,17 @@ PythonRepository entries instead of pypi.org, and to trust the root
certificates installed by the certificates module. certificates installed by the certificates module.
- pip: system-wide `/etc/pip.conf` (`index-url`/`extra-index-url`/`cert`). - pip: system-wide `/etc/pip.conf` (`index-url`/`extra-index-url`/`cert`).
Any repository whose URL isn't HTTPS has its host added to
`trusted-host`, since pip otherwise refuses a plain HTTP index.
- uv: system-wide `/etc/uv/uv.toml` (`[[index]]` entries, `native-tls`). - uv: system-wide `/etc/uv/uv.toml` (`[[index]]` entries, `native-tls`).
- poetry: `/root/.config/pypoetry/config.toml` (`repositories.*`/`certificates.*`). - poetry: `/root/.config/pypoetry/config.toml` (`repositories.*`/`certificates.*`).
Poetry has no global "replace PyPI" switch: a project's `pyproject.toml` Poetry has no global "replace PyPI" switch: a project's `pyproject.toml`
still has to reference these repositories as sources. This only makes the still has to reference these repositories as sources. This only makes the
repository and its certificate available system-wide. It also assumes the repository and its certificate available system-wide. Since poetry only
container runs as root, since poetry only reads its config from the reads its config from the current user's home directory, this assumes the
current user's home directory. container runs as root; the same file is additionally written under each
configured `--user`'s own home directory (`context.users`), for the
benefit of a non-root user invoking poetry.
- easy_install: `distutils.cfg` (`[easy_install] index_url`), written next - easy_install: `distutils.cfg` (`[easy_install] index_url`), written next
to every Python installation detected under `<src>` (there is no location to every Python installation detected under `<src>` (there is no location
independent of a specific Python prefix). independent of a specific Python prefix).
@@ -21,10 +25,11 @@ from __future__ import annotations
import json import json
import logging import logging
from urllib.parse import urlparse
from id_configurator.config import PythonRepository from id_configurator.config import PythonRepository
from id_configurator.context import Context from id_configurator.context import Context
from id_configurator.modules.base import Module, register from id_configurator.modules.base import Module, register, sanitize_repository_name
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -36,7 +41,24 @@ _DISTUTILS_GLOBS = ("usr/lib/python3.*", "usr/local/lib/python3.*")
def _toml_string(value: str) -> str: def _toml_string(value: str) -> str:
return json.dumps(value) """Render `value` as a TOML basic string.
JSON and TOML basic strings share the same escape syntax, so
`json.dumps` is a convenient way to produce one - *except* for
non-ASCII characters: with the default `ensure_ascii=True`, a
character outside the Basic Multilingual Plane (e.g. an emoji) comes
out as a UTF-16 surrogate pair (`\\uXXXX\\uXXXX`), which is valid JSON
but not valid TOML - TOML's `\\uXXXX` must itself be a Unicode scalar
value, not one half of a surrogate pair. `ensure_ascii=False` instead
leaves every non-ASCII character as a literal, raw UTF-8 character,
which TOML basic strings allow directly.
"""
return json.dumps(value, ensure_ascii=False)
def _trusted_host(repository: PythonRepository) -> str | None:
parsed = urlparse(repository.index_url)
return None if parsed.scheme.lower() == "https" else parsed.hostname
def _write_pip_conf(context: Context, repositories: list[PythonRepository]) -> None: def _write_pip_conf(context: Context, repositories: list[PythonRepository]) -> None:
@@ -44,6 +66,12 @@ def _write_pip_conf(context: Context, repositories: list[PythonRepository]) -> N
if len(repositories) > 1: if len(repositories) > 1:
lines.append("extra-index-url =") lines.append("extra-index-url =")
lines.extend(f" {repository.index_url}" for repository in repositories[1:]) lines.extend(f" {repository.index_url}" for repository in repositories[1:])
trusted_hosts = dict.fromkeys(
host for repository in repositories if (host := _trusted_host(repository))
)
if trusted_hosts:
lines.append("trusted-host =")
lines.extend(f" {host}" for host in trusted_hosts)
lines.append(f"cert = {_CA_BUNDLE}") lines.append(f"cert = {_CA_BUNDLE}")
context.write_file(_PIP_CONF, ("\n".join(lines) + "\n").encode("utf-8")) context.write_file(_PIP_CONF, ("\n".join(lines) + "\n").encode("utf-8"))
logger.info("configured pip to use %s", repositories[0].index_url) logger.info("configured pip to use %s", repositories[0].index_url)
@@ -52,8 +80,9 @@ def _write_pip_conf(context: Context, repositories: list[PythonRepository]) -> N
def _write_uv_toml(context: Context, repositories: list[PythonRepository]) -> None: def _write_uv_toml(context: Context, repositories: list[PythonRepository]) -> None:
lines = ["native-tls = true", ""] lines = ["native-tls = true", ""]
for position, repository in enumerate(repositories): for position, repository in enumerate(repositories):
name = sanitize_repository_name(repository.name)
lines.append("[[index]]") lines.append("[[index]]")
lines.append(f"name = {_toml_string(repository.name)}") lines.append(f"name = {_toml_string(name)}")
lines.append(f"url = {_toml_string(repository.index_url)}") lines.append(f"url = {_toml_string(repository.index_url)}")
if position == 0: if position == 0:
lines.append("default = true") lines.append("default = true")
@@ -67,15 +96,20 @@ def _write_poetry_config(
) -> None: ) -> None:
lines = [] lines = []
for repository in repositories: for repository in repositories:
lines.append(f"[repositories.{repository.name}]") name = sanitize_repository_name(repository.name)
lines.append(f"[repositories.{name}]")
lines.append(f"url = {_toml_string(repository.index_url)}") lines.append(f"url = {_toml_string(repository.index_url)}")
lines.append("") lines.append("")
lines.append(f"[certificates.{repository.name}]") lines.append(f"[certificates.{name}]")
lines.append(f"cert = {_toml_string(_CA_BUNDLE)}") lines.append(f"cert = {_toml_string(_CA_BUNDLE)}")
lines.append("") lines.append("")
content = ("\n".join(lines).rstrip() + "\n").encode("utf-8")
context.write_file(_POETRY_CONFIG, content)
for user in context.users:
context.write_file( context.write_file(
_POETRY_CONFIG, ("\n".join(lines).rstrip() + "\n").encode("utf-8") f"{context.home_dir(user)}/.config/pypoetry/config.toml", content
) )
logger.info("configured poetry for user %s", user)
logger.info("registered %d poetry repositories", len(repositories)) logger.info("registered %d poetry repositories", len(repositories))
+52
View File
@@ -0,0 +1,52 @@
"""Configure RubyGems to use the configured RubyRepository entries as its
gem sources, replacing the default (rubygems.org).
RubyGems' own HTTPS client (Net::HTTP, backed by OpenSSL) 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 RubyGems' system-wide config file, `Gem::ConfigFile::SYSTEM_WIDE_CONFIG_FILE`
- `$sysconfdir/gemrc`, where `$sysconfdir` is Ruby's own configured
sysconfdir, not necessarily `/etc`. The official Ruby Docker image builds
Ruby with `--prefix=/usr/local`, giving `/usr/local/etc/gemrc` (confirmed
empirically, since Debian's own `sysconfdir` convention of `/etc` does not
apply to a from-source build). Also written to the more conventional
`/etc/gemrc`, for any Ruby built with the Debian/`sysconfdir=/etc`
convention instead (e.g. a distro-packaged `ruby` rather than the official
image) - harmless to write in both places, since only whichever one a given
Ruby build actually treats as its sysconfdir is ever read. Unlike a config
dropped in one specific user's home (`~/.gemrc`, RubyGems' separate "per
user" level), this applies regardless of which user invokes `gem`.
"""
from __future__ import annotations
import logging
from id_configurator.config import RubyRepository
from id_configurator.context import Context
from id_configurator.modules.base import Module, register
logger = logging.getLogger(__name__)
_GEMRC_PATHS = ("usr/local/etc/gemrc", "etc/gemrc")
def _write_gemrc(context: Context, repositories: list[RubyRepository]) -> None:
lines = [":sources:"]
lines.extend(f"- {repository.url}" for repository in repositories)
content = ("\n".join(lines) + "\n").encode("utf-8")
for path in _GEMRC_PATHS:
context.write_file(path, content)
logger.info("configured gem to use %d source(s)", len(repositories))
@register
class RubyModule(Module):
name = "ruby"
def is_relevant(self, context: Context) -> bool:
return bool(context.config.configurations.ruby.repositories)
def apply(self, context: Context) -> None:
_write_gemrc(context, context.config.configurations.ruby.repositories)
+26 -9
View File
@@ -1,8 +1,10 @@
"""Configure cargo to use the configured RustRepository entries: the first """Configure cargo to use the configured RustRepository entries: the first
one replaces crates.io entirely (via `[source.crates-io] replace-with`), and one replaces crates.io entirely (via `[source.crates-io] replace-with`), and
any further ones are registered as additional named registries (usable via any further ones are registered as additional named registries (usable via
`cargo add --registry <name>` or a dependency's `registry = "<name>"`), all `cargo add --registry <name>` or a dependency's `registry = "<name>"`). A
using the sparse (plain HTTPS) protocol rather than a git-based index. repository's index is declared as `sparse+<url>` - the sparse (plain HTTPS)
protocol - only when its `is_sparse` option is set; otherwise `<url>` is used
as-is, for a git-based index.
Cargo's own HTTPS client already trusts whatever the `certificates` module Cargo's own HTTPS client already trusts whatever the `certificates` module
installs into the system CA store, so no separate certificate configuration installs into the system CA store, so no separate certificate configuration
@@ -17,6 +19,11 @@ directory to `/`, consulting `/.cargo/config.toml` there, *before* falling
back to `$CARGO_HOME/config.toml` - so this is the one genuinely global back to `$CARGO_HOME/config.toml` - so this is the one genuinely global
location: found regardless of which user, working directory or `CARGO_HOME` location: found regardless of which user, working directory or `CARGO_HOME`
is in effect, unlike a config dropped in one specific user's home. is in effect, unlike a config dropped in one specific user's home.
For each configured `--user` (`context.users`), the same config is
additionally written at that user's own `$HOME/.cargo/config.toml` - the
conventional, un-overridden `CARGO_HOME` location for that specific user,
which this module otherwise has no visibility into.
""" """
from __future__ import annotations from __future__ import annotations
@@ -25,29 +32,39 @@ import logging
from id_configurator.config import RustRepository from id_configurator.config import RustRepository
from id_configurator.context import Context from id_configurator.context import Context
from id_configurator.modules.base import Module, register from id_configurator.modules.base import Module, register, sanitize_repository_name
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_CARGO_CONFIG = ".cargo/config.toml" _CARGO_CONFIG = ".cargo/config.toml"
def _index_url(repository: RustRepository) -> str:
return f"sparse+{repository.url}" if repository.is_sparse else repository.url
def _write_cargo_config(context: Context, repositories: list[RustRepository]) -> None: def _write_cargo_config(context: Context, repositories: list[RustRepository]) -> None:
primary = repositories[0] primary = repositories[0]
primary_name = sanitize_repository_name(primary.name)
lines = [ lines = [
"[source.crates-io]", "[source.crates-io]",
f'replace-with = "{primary.name}"', f'replace-with = "{primary_name}"',
"", "",
f"[source.{primary.name}]", f"[source.{primary_name}]",
f'registry = "sparse+{primary.url}"', f'registry = "{_index_url(primary)}"',
] ]
for repository in repositories[1:]: for repository in repositories[1:]:
name = sanitize_repository_name(repository.name)
lines += [ lines += [
"", "",
f"[registries.{repository.name}]", f"[registries.{name}]",
f'index = "sparse+{repository.url}"', f'index = "{_index_url(repository)}"',
] ]
context.write_file(_CARGO_CONFIG, ("\n".join(lines) + "\n").encode("utf-8")) content = ("\n".join(lines) + "\n").encode("utf-8")
context.write_file(_CARGO_CONFIG, content)
for user in context.users:
context.write_file(f"{context.home_dir(user)}/.cargo/config.toml", content)
logger.info("configured cargo for user %s", user)
logger.info("configured cargo to use %s", primary.url) logger.info("configured cargo to use %s", primary.url)
+74 -15
View File
@@ -4,10 +4,13 @@ DNF is a drop-in successor to YUM and reads the exact same `.repo` files
under `/etc/yum.repos.d/`, so a single module covers both. under `/etc/yum.repos.d/`, so a single module covers both.
If `<src>/etc/yum.repos.d` exists, every `.repo` file already there is If `<src>/etc/yum.repos.d` exists, every `.repo` file already there is
copied into `<dst>` with all of its sections disabled (`enabled=0`), before copied into `<dst>`, before the repositories declared in the configuration
the repositories declared in the configuration are written as new, enabled are written as new, enabled `.repo` files (so a configured repository
`.repo` files (so a configured repository reusing an existing file name reusing an existing file name ends up enabled, taking precedence over the
ends up enabled, taking precedence over the disabled copy). Each `.repo` copied one). A section of an existing `.repo` file is only disabled
(`enabled=0`) if its `baseurl`/`mirrorlist` matches the
`index_url_to_replace` pattern of one of the configured repositories;
sections matching no such pattern are copied through unchanged. Each `.repo`
file is written referencing its `gpg-url` (used verbatim as `gpgkey=`, file is written referencing its `gpg-url` (used verbatim as `gpgkey=`,
exactly as YUM/DNF expect: `https://`, or a `file://` path already present exactly as YUM/DNF expect: `https://`, or a `file://` path already present
on the target system). Separately, each GPG key declared in on the target system). Separately, each GPG key declared in
@@ -17,20 +20,32 @@ conventional location such keys live at on RPM-based systems. `gpg` is only
used here to validate the key; the actual trust decision is left to used here to validate the key; the actual trust decision is left to
YUM/DNF, which import it from that file the first time the repository is YUM/DNF, which import it from that file the first time the repository is
used (as `rpm --import` would). used (as `rpm --import` would).
A repository's `index_url_to_replace`, if set, is an fnmatch pattern
checked against the baseurl of every section of every existing `.repo`
file: the repository is only written out if the pattern matches at least
one of them, and skipped entirely otherwise.
""" """
from __future__ import annotations from __future__ import annotations
import configparser import configparser
import fnmatch
import logging import logging
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
from collections.abc import Sequence
from io import StringIO from io import StringIO
from id_configurator.config import NamedContent, YumRepository from id_configurator.config import NamedContent, YumRepository
from id_configurator.context import Context from id_configurator.context import Context
from id_configurator.modules.base import Module, register from id_configurator.modules.base import (
Module,
register,
sanitize_filename,
sanitize_repository_name,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -38,30 +53,53 @@ _REPOS_DIR = "etc/yum.repos.d"
_RPM_GPG_DIR = "etc/pki/rpm-gpg" _RPM_GPG_DIR = "etc/pki/rpm-gpg"
def _disable_repo_content(content: str) -> str: def _disable_repo_content(content: str, patterns: Sequence[str]) -> str:
parser = configparser.ConfigParser(interpolation=None) parser = configparser.ConfigParser(interpolation=None)
parser.optionxform = str parser.optionxform = str
parser.read_string(content) parser.read_string(content)
for section in parser.sections(): for section in parser.sections():
urls = [parser[section].get(key) for key in ("baseurl", "mirrorlist")]
urls = [url for url in urls if url]
if any(fnmatch.fnmatch(url, pattern) for url in urls for pattern in patterns):
parser[section]["enabled"] = "0" parser[section]["enabled"] = "0"
buffer = StringIO() buffer = StringIO()
parser.write(buffer, space_around_delimiters=False) parser.write(buffer, space_around_delimiters=False)
return buffer.getvalue() return buffer.getvalue()
def _disable_existing_repos(context: Context) -> None: def _existing_index_urls(context: Context) -> set[str]:
"""Collect the baseurl of every section of every .repo file already
present on the source filesystem."""
urls: set[str] = set()
for entry in context.iter_src_dir(_REPOS_DIR): for entry in context.iter_src_dir(_REPOS_DIR):
if entry.suffix != ".repo": if entry.suffix != ".repo":
continue continue
disabled = _disable_repo_content(entry.read_text(encoding="utf-8")) parser = configparser.ConfigParser(interpolation=None)
parser.optionxform = str
parser.read_string(entry.read_text(encoding="utf-8"))
for section in parser.sections():
# noinspection SpellCheckingInspection
for key in ("baseurl", "mirrorlist"):
url = parser[section].get(key)
if url:
urls.add(url)
return urls
def _disable_existing_repos(context: Context, patterns: Sequence[str]) -> None:
for entry in context.iter_src_dir(_REPOS_DIR):
if entry.suffix != ".repo":
continue
disabled = _disable_repo_content(entry.read_text(encoding="utf-8"), patterns)
context.write_file(f"{_REPOS_DIR}/{entry.name}", disabled.encode("utf-8")) context.write_file(f"{_REPOS_DIR}/{entry.name}", disabled.encode("utf-8"))
logger.info("disabled existing yum/dnf repository file %s", entry.name) logger.info("copied existing yum/dnf repository file %s", entry.name)
def _write_repo_file(context: Context, repository: YumRepository) -> None: def _write_repo_file(context: Context, repository: YumRepository) -> None:
name = sanitize_repository_name(repository.name)
lines = [ lines = [
f"[{repository.name}]", f"[{name}]",
f"name={repository.name}", f"name={name}",
f"baseurl={repository.index_url}", f"baseurl={repository.index_url}",
"enabled=1", "enabled=1",
] ]
@@ -71,10 +109,10 @@ def _write_repo_file(context: Context, repository: YumRepository) -> None:
else: else:
lines.append("gpgcheck=0") lines.append("gpgcheck=0")
context.write_file( context.write_file(
f"{_REPOS_DIR}/{repository.name}.repo", f"{_REPOS_DIR}/{name}.repo",
("\n".join(lines) + "\n").encode("utf-8"), ("\n".join(lines) + "\n").encode("utf-8"),
) )
logger.info("added yum/dnf repository %s", repository.name) logger.info("added yum/dnf repository %s", name)
def _validate_gpg_key(name: str, content: bytes) -> None: def _validate_gpg_key(name: str, content: bytes) -> None:
@@ -100,7 +138,8 @@ def _validate_gpg_key(name: str, content: bytes) -> None:
def _import_key(context: Context, key: NamedContent) -> None: def _import_key(context: Context, key: NamedContent) -> None:
_validate_gpg_key(key.name, key.content.encode("utf-8")) _validate_gpg_key(key.name, key.content.encode("utf-8"))
context.write_file( context.write_file(
f"{_RPM_GPG_DIR}/RPM-GPG-KEY-{key.name}", key.content.encode("utf-8") f"{_RPM_GPG_DIR}/RPM-GPG-KEY-{sanitize_filename(key.name)}",
key.content.encode("utf-8"),
) )
logger.info("imported yum/dnf GPG key %s", key.name) logger.info("imported yum/dnf GPG key %s", key.name)
@@ -119,8 +158,28 @@ class YumModule(Module):
return bool(yum.repositories) or bool(yum.keys) or any(existing_repo_files) return bool(yum.repositories) or bool(yum.keys) or any(existing_repo_files)
def apply(self, context: Context) -> None: def apply(self, context: Context) -> None:
_disable_existing_repos(context) patterns = [
repository.index_url_to_replace
for repository in context.config.configurations.yum.repositories
if repository.index_url_to_replace is not None
]
_disable_existing_repos(context, patterns)
existing_index_urls: set[str] | None = None
for repository in context.config.configurations.yum.repositories: for repository in context.config.configurations.yum.repositories:
if repository.index_url_to_replace is not None:
if existing_index_urls is None:
existing_index_urls: set[str] = _existing_index_urls(context)
if not any(
fnmatch.fnmatch(url, repository.index_url_to_replace)
for url in existing_index_urls
):
logger.info(
"skipped yum/dnf repository %s: index-url-to-replace %s "
"matched no existing baseurl",
repository.name,
repository.index_url_to_replace,
)
continue
_write_repo_file(context, repository) _write_repo_file(context, repository)
for key in context.config.configurations.yum.keys: for key in context.config.configurations.yum.keys:
_import_key(context, key) _import_key(context, key)
+78
View File
@@ -0,0 +1,78 @@
"""Parse `/etc/passwd` entries for the `--auto-users` CLI option.
A passwd(5) line is `name:passwd:uid:gid:gecos:home:shell`. `--auto-users`
expands to every entry that looks like a regular (non-system) account - see
`PasswdEntry.is_regular_user`.
"""
from __future__ import annotations
from dataclasses import dataclass
DEFAULT_UID_MIN = 1000
_DISABLED_SHELL_NAMES = {"nologin", "sync", "false"}
@dataclass(frozen=True)
class PasswdEntry:
name: str
uid: int
home: str
shell: str
@property
def has_login_shell(self) -> bool:
return self.shell.rsplit("/", 1)[-1] not in _DISABLED_SHELL_NAMES
def is_regular_user(self, uid_min: int = DEFAULT_UID_MIN) -> bool:
"""True unless `self` looks like a system account.
A login-disabling shell (`has_login_shell` False) only catches
service accounts that follow that convention; it misses others
(and could wrongly flag a merely locked human account). The
authoritative signal `useradd`/`adduser`/`systemd-sysusers` use
instead is the UID: system accounts get one below `uid_min` (the
`UID_MIN` from login.defs(5), 1000 by default). Both checks apply
together here - except for `root`, whose UID 0 is itself a system
convention rather than a sign it shouldn't be configured: it's
exempted from the UID check (though not from the shell one, so a
deliberately locked root account is still excluded).
"""
if not self.has_login_shell:
return False
return self.name == "root" or self.uid >= uid_min
def parse_passwd(content: str) -> list[PasswdEntry]:
entries = []
for line in content.splitlines():
if not line or line.startswith("#"):
continue
fields = line.split(":")
if len(fields) < 7:
continue
try:
uid = int(fields[2])
except ValueError:
continue
entries.append(
PasswdEntry(name=fields[0], uid=uid, home=fields[5], shell=fields[6])
)
return entries
def parse_login_defs_uid_min(content: str) -> int | None:
"""Extract `UID_MIN` from `/etc/login.defs` content (login.defs(5)), or
`None` if it isn't set there."""
for line in content.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
parts = stripped.split()
if len(parts) >= 2 and parts[0] == "UID_MIN":
try:
return int(parts[1])
except ValueError:
return None
return None
+2
View File
@@ -3,6 +3,7 @@ import logging
import typer import typer
from id_tools.build import app as build_app from id_tools.build import app as build_app
from id_tools.publication import app as publication_app
from id_tools.test import app as test_app from id_tools.test import app as test_app
SUCCESS = 25 SUCCESS = 25
@@ -44,5 +45,6 @@ def main():
app.add_typer(build_app, name="build") app.add_typer(build_app, name="build")
app.add_typer(test_app, name="test") app.add_typer(test_app, name="test")
app.add_typer(publication_app, name="publication")
if __name__ == "__main__": if __name__ == "__main__":
app() app()
+6 -5
View File
@@ -72,17 +72,18 @@ LABEL org.opencontainers.image.version="{id_configurator_version}"
LABEL description="Air gap made simple!" LABEL description="Air gap made simple!"
COPY --from=interdiode-install --chown=0:0 /chroot/usr/local/bin /usr/local/bin COPY --from=interdiode-install --chown=0:0 /chroot/usr/local/bin /usr/local/bin
COPY --from=interdiode-install /usr/local/interdiode /usr/local/interdiode COPY --from=interdiode-install /usr/local/interdiode /usr/local/interdiode
RUN DEBIAN_FRONTEND=noninteractive mkdir -p /usr/local/interdiode \ RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get -o Dpkg::Options::="--force-confnew" --allow-downgrades --allow-remove-essential --allow-unauthenticated -fuy dist-upgrade \
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes -qq --no-install-recommends gpg gpg-agent \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /usr/local/interdiode \
&& chown -R {docker_user}:{docker_group} /usr/local/interdiode && chown -R {docker_user}:{docker_group} /usr/local/interdiode
COPY ./LICENSE /chroot/licenses/LICENSE COPY ./LICENSE /chroot/licenses/LICENSE
COPY ./LICENSE /chroot/usr/share/doc/interdiode/LICENSE COPY ./LICENSE /chroot/usr/share/doc/interdiode/LICENSE
WORKDIR / WORKDIR /
""" """
NPM_TO_COPY = [
("bootstrap/dist/js/bootstrap.esm.js.map", "js/bootstrap.esm.js.map"),
]
def build_docker_image( def build_docker_image(
config: SourceConfig, config: SourceConfig,
+653
View File
@@ -0,0 +1,653 @@
#!/usr/bin/python
"""Ansible module wrapping id-configurator.
Applies an id-configurator configuration directly to this host's live root
filesystem, exactly like the `id_configurator` role does, but as a native
Ansible module: it imports id-configurator's own Python API in-process
(instead of shelling out to the CLI through a temporary file), which also
lets it honor Ansible's check mode - reporting which modules would be
applied without writing anything.
This module is a thin, additive wrapper: it does not change anything about
id-configurator's own behavior (the `id_configurator` Python package,
including its module registry and every individual module, is used exactly
as-is - the same `Configuration`, `Context` and module registry the CLI
itself uses).
"""
from __future__ import annotations
from pathlib import Path
from ansible.module_utils.basic import AnsibleModule
DOCUMENTATION = r"""
module: id_configurator
short_description: Apply an id-configurator configuration to this host.
description:
- Assembles the given options into an id-configurator configuration and
applies it directly to this host's live root filesystem, by importing
id-configurator's own Python API in-process (no CLI subprocess, no
temporary file).
- The option names and structure mirror id_configurator.config.Configuration
and id_configurator.config.ConfigurationsBlock one for one. The
`id_configurator` Python package must already be installed on the
target host, using the same Python interpreter Ansible uses there.
- Supports check mode - reports which modules would be applied without
writing anything.
author:
- InterDiode
options:
version:
type: str
default: "1.0"
description: Configuration.version - the configuration format's own schema version.
uuid:
type: str
description: Configuration.uuid - a UUID identifying this configuration, optional.
organisation:
type: str
description: Configuration.organisation, optional.
description:
type: str
description: Configuration.description, optional.
certificates:
type: list
elements: dict
default: []
description: ConfigurationsBlock.certificates - root certificates to install.
suboptions:
name:
type: str
required: true
description: A short, unique name for this entry.
content:
type: str
required: true
description: PEM-encoded certificate content.
apt:
type: dict
default: {}
description: ConfigurationsBlock.apt.
suboptions:
sources:
type: list
elements: dict
default: []
description: DEB822 APT sources (AptSource).
suboptions:
name:
type: str
required: true
description: A short, unique name for this source.
types:
type: list
elements: str
default: [deb]
description: DEB822 "Types" field.
uris:
type: list
elements: str
required: true
description: DEB822 "URIs" field.
suites:
type: list
elements: str
required: true
description: DEB822 "Suites" field.
components:
type: list
elements: str
default: []
description: DEB822 "Components" field.
architectures:
type: list
elements: str
default: []
description: DEB822 "Architectures" field.
languages:
type: list
elements: str
default: []
description: DEB822 "Languages" field.
targets:
type: list
elements: str
default: []
description: DEB822 "Targets" field.
enabled:
type: bool
default: true
description: DEB822 "Enabled" field.
trusted:
type: bool
description: DEB822 "Trusted" field.
signed_by:
type: str
description:
- ASCII-armored content of a GPG public key to trust this
source with. Folded inline as a multi-line Signed-By field
(no separate keyring file or gpg binary needed). Takes
precedence over signed_by_file if both are set.
signed_by_file:
type: str
description:
- Path to a keyring already expected to exist on the target
system, used verbatim as the rendered Signed-By field.
Ignored if signed_by is also set.
pdiffs:
type: bool
description: DEB822 "PDiffs" field.
by_hash:
type: bool
description: DEB822 "By-Hash" field.
allow_insecure:
type: bool
description: DEB822 "Allow-Insecure" field.
allow_weak:
type: bool
description: DEB822 "Allow-Weak" field.
allow_downgrade_to_insecure:
type: bool
description: DEB822 "Allow-Downgrade-To-Insecure" field.
check_valid_until:
type: bool
description: DEB822 "Check-Valid-Until" field.
valid_until_min:
type: str
description: DEB822 "Valid-Until-Min" field.
valid_until_max:
type: str
description: DEB822 "Valid-Until-Max" field.
check_date:
type: bool
description: DEB822 "Check-Date" field.
date_max_future:
type: str
description: DEB822 "Date-Max-Future" field.
description:
type: str
description: DEB822 "Description" field.
uri_to_replace:
type: str
description:
- fnmatch pattern checked against the URIs already present in
the machine's existing apt configuration. If set, this
source is only applied when the pattern matches at least
one of them; otherwise it is skipped entirely.
apk:
type: dict
default: {}
description: ConfigurationsBlock.apk.
suboptions:
repositories:
type: list
elements: dict
default: []
description: Alpine APK repositories (ApkRepository).
suboptions:
url:
type: str
required: true
description: Repository URL, e.g. ending in /main or /community.
key:
type: str
description: The signing key's name, if any (see NamedContent).
url_to_replace:
type: str
description:
- fnmatch pattern checked against the URLs already active in
the machine's existing /etc/apk/repositories. If set, this
repository is only applied when the pattern matches at
least one of them; otherwise it is skipped entirely.
yum:
type: dict
default: {}
description: ConfigurationsBlock.yum.
suboptions:
repositories:
type: list
elements: dict
default: []
description: YUM/DNF repositories (YumRepository).
suboptions:
name:
type: str
required: true
description: A short, unique name for this repository.
index_url:
type: str
required: true
description: The repository's baseurl.
gpg_url:
type: str
description: The repository's gpgkey URL.
index_url_to_replace:
type: str
description:
- fnmatch pattern checked against the baseurl of every
section of every existing .repo file. If set, this
repository is only applied when the pattern matches at
least one of them; otherwise it is skipped entirely.
keys:
type: list
elements: dict
default: []
description: GPG keys imported for the repositories above (NamedContent).
suboptions:
name:
type: str
required: true
description: A short, unique name for this entry.
content:
type: str
required: true
description: PEM-encoded/ASCII-armored key content.
python:
type: dict
default: {}
description: ConfigurationsBlock.python.
suboptions:
repositories:
type: list
elements: dict
default: []
description: Python package indexes (PythonRepository).
suboptions:
name:
type: str
required: true
description: A short, unique name for this index.
index_url:
type: str
required: true
description: The package index's URL.
java:
type: dict
default: {}
description: ConfigurationsBlock.java.
suboptions:
repositories:
type: list
elements: dict
default: []
description: Maven-compatible repositories (JavaRepository).
suboptions:
name:
type: str
required: true
description: A short, unique name for this repository.
url:
type: str
required: true
description: The repository's URL.
php:
type: dict
default: {}
description: ConfigurationsBlock.php.
suboptions:
repositories:
type: list
elements: dict
default: []
description: Composer repositories (PhpRepository).
suboptions:
name:
type: str
required: true
description: A short, unique name for this repository.
url:
type: str
required: true
description: The repository's URL.
npm:
type: dict
default: {}
description: ConfigurationsBlock.npm.
suboptions:
repositories:
type: list
elements: dict
default: []
description: npm registries (NpmRepository).
suboptions:
name:
type: str
required: true
description: A short, unique name for this repository.
url:
type: str
required: true
description: The repository's URL.
rust:
type: dict
default: {}
description: ConfigurationsBlock.rust.
suboptions:
repositories:
type: list
elements: dict
default: []
description: Cargo registries (RustRepository).
suboptions:
name:
type: str
required: true
description: A short, unique name for this repository.
url:
type: str
required: true
description: The repository's URL.
go:
type: dict
default: {}
description: ConfigurationsBlock.go.
suboptions:
repositories:
type: list
elements: dict
default: []
description: Go module proxies (GoRepository).
suboptions:
name:
type: str
required: true
description: A short, unique name for this repository.
url:
type: str
required: true
description: The repository's URL.
ruby:
type: dict
default: {}
description: ConfigurationsBlock.ruby.
suboptions:
repositories:
type: list
elements: dict
default: []
description: RubyGems sources (RubyRepository).
suboptions:
name:
type: str
required: true
description: A short, unique name for this repository.
url:
type: str
required: true
description: The repository's URL.
nuget:
type: dict
default: {}
description: ConfigurationsBlock.nuget.
suboptions:
repositories:
type: list
elements: dict
default: []
description: NuGet package sources (NuGetRepository).
suboptions:
name:
type: str
required: true
description: A short, unique name for this repository.
url:
type: str
required: true
description: The repository's URL.
"""
EXAMPLES = r"""
- name: Apply an id-configurator configuration to this host
id_configurator:
uuid: 12345678-1234-5678-1234-567812345678
organisation: InterDiode
certificates:
- name: Company Root CA
content: "{{ lookup('file', 'files/company-root-ca.pem') }}"
apt:
sources:
- name: internal
uris: ["https://apt.internal.example/"]
suites: ["stable"]
"""
RETURN = r"""
applied_modules:
description: Names of the id-configurator modules that were (or, in check
mode, would be) applied, in registration order.
returned: always
type: list
elements: str
sample: [certificates, apt]
"""
# Every key here must match one of ConfigurationsBlock's own field names
# (id_configurator/config.py) - see the module docstring above.
_CONFIGURATIONS_BLOCK_FIELDS = (
"certificates",
"apt",
"apk",
"yum",
"python",
"java",
"php",
"npm",
"rust",
"go",
"ruby",
"nuget",
)
_NAMED_CONTENT_OPTIONS = {
"name": {"type": "str", "required": True},
"content": {"type": "str", "required": True},
}
_NAME_URL_OPTIONS = {
"name": {"type": "str", "required": True},
"url": {"type": "str", "required": True},
}
def _name_url_repository_config() -> dict:
"""Argument spec for a `{repositories: [{name, url}]}` config block,
shared by java/php/npm/rust/go/ruby/nuget."""
return {
"type": "dict",
"default": {},
"options": {
"repositories": {
"type": "list",
"elements": "dict",
"default": [],
"options": dict(_NAME_URL_OPTIONS),
},
},
}
ARGUMENT_SPEC = {
"version": {"type": "str", "default": "1.0"},
"uuid": {"type": "str"},
"organisation": {"type": "str"},
"description": {"type": "str"},
"certificates": {
"type": "list",
"elements": "dict",
"default": [],
"options": dict(_NAMED_CONTENT_OPTIONS),
},
"apt": {
"type": "dict",
"default": {},
"options": {
"sources": {
"type": "list",
"elements": "dict",
"default": [],
"options": {
"name": {"type": "str", "required": True},
"types": {"type": "list", "elements": "str", "default": ["deb"]},
"uris": {"type": "list", "elements": "str", "required": True},
"suites": {"type": "list", "elements": "str", "required": True},
"components": {"type": "list", "elements": "str", "default": []},
"architectures": {
"type": "list",
"elements": "str",
"default": [],
},
"languages": {"type": "list", "elements": "str", "default": []},
"targets": {"type": "list", "elements": "str", "default": []},
"enabled": {"type": "bool", "default": True},
"trusted": {"type": "bool"},
"signed_by": {"type": "str"},
"signed_by_file": {"type": "str"},
"pdiffs": {"type": "bool"},
"by_hash": {"type": "bool"},
"allow_insecure": {"type": "bool"},
"allow_weak": {"type": "bool"},
"allow_downgrade_to_insecure": {"type": "bool"},
"check_valid_until": {"type": "bool"},
"valid_until_min": {"type": "str"},
"valid_until_max": {"type": "str"},
"check_date": {"type": "bool"},
"date_max_future": {"type": "str"},
"description": {"type": "str"},
"uri_to_replace": {"type": "str"},
},
},
},
},
"apk": {
"type": "dict",
"default": {},
"options": {
"repositories": {
"type": "list",
"elements": "dict",
"default": [],
"options": {
"url": {"type": "str", "required": True},
"key": {"type": "str"},
"url_to_replace": {"type": "str"},
},
},
},
},
"yum": {
"type": "dict",
"default": {},
"options": {
"repositories": {
"type": "list",
"elements": "dict",
"default": [],
"options": {
"name": {"type": "str", "required": True},
"index_url": {"type": "str", "required": True},
"gpg_url": {"type": "str"},
"index_url_to_replace": {"type": "str"},
},
},
"keys": {
"type": "list",
"elements": "dict",
"default": [],
"options": dict(_NAMED_CONTENT_OPTIONS),
},
},
},
"python": {
"type": "dict",
"default": {},
"options": {
"repositories": {
"type": "list",
"elements": "dict",
"default": [],
"options": {
"name": {"type": "str", "required": True},
"index_url": {"type": "str", "required": True},
},
},
},
},
"java": _name_url_repository_config(),
"php": _name_url_repository_config(),
"npm": _name_url_repository_config(),
"rust": _name_url_repository_config(),
"go": _name_url_repository_config(),
"ruby": _name_url_repository_config(),
"nuget": _name_url_repository_config(),
}
try:
import id_configurator.modules # noqa: F401 (registers the built-in modules)
from id_configurator.config import Configuration
from id_configurator.context import Context
from id_configurator.modules.base import all_modules
HAS_ID_CONFIGURATOR = True
ID_CONFIGURATOR_IMPORT_ERROR = None
except ImportError as exc:
HAS_ID_CONFIGURATOR = False
ID_CONFIGURATOR_IMPORT_ERROR = exc
def _build_configuration_data(params: dict) -> dict:
return {
"version": params["version"],
"uuid": params["uuid"],
"organisation": params["organisation"],
"description": params["description"],
"configurations": {
field: params[field] for field in _CONFIGURATIONS_BLOCK_FIELDS
},
}
def _apply_configuration(configuration, check_mode: bool) -> list[str]:
"""Apply `configuration` to this host's live root filesystem, returning
the names of every module that was (or, in check mode, would be)
applied. Uses id-configurator's own module registry and `Context`
unchanged - this is a thin caller, not a reimplementation."""
root = Path("/")
context = Context(config=configuration, dst=root, src=root)
applied_modules = []
for module_cls in all_modules():
config_module = module_cls()
if config_module.is_relevant(context):
applied_modules.append(config_module.name)
if not check_mode:
config_module.apply(context)
return applied_modules
def main() -> None:
module = AnsibleModule(argument_spec=ARGUMENT_SPEC, supports_check_mode=True)
if not HAS_ID_CONFIGURATOR:
module.fail_json(
msg=(
"id_configurator is not importable with this host's Python "
f"interpreter: {ID_CONFIGURATOR_IMPORT_ERROR}"
)
)
return
try:
configuration = Configuration.model_validate(
_build_configuration_data(module.params)
)
except Exception as exc: # pydantic.ValidationError, kept generic on purpose
module.fail_json(msg=f"invalid id-configurator configuration: {exc}")
return
applied_modules = _apply_configuration(configuration, module.check_mode)
module.exit_json(changed=bool(applied_modules), applied_modules=applied_modules)
if __name__ == "__main__":
main()
+3 -4
View File
@@ -21,11 +21,9 @@ authors = [
] ]
classifiers = [ classifiers = [
'Development Status :: 5 - Production/Stable', 'Development Status :: 5 - Production/Stable',
'License :: Other/Proprietary License',
'Operating System :: POSIX :: Linux', 'Operating System :: POSIX :: Linux',
'Operating System :: Unix', 'Operating System :: Unix',
'Natural Language :: English', 'Natural Language :: English',
"Private :: Do Not Upload",
'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: 3 :: Only',
'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: 3.13',
'Programming Language :: Python :: 3.14' 'Programming Language :: Python :: 3.14'
@@ -38,14 +36,15 @@ dependencies = [
"typer>=0.27.1" "typer>=0.27.1"
] ]
description = "Configure a device in an air-gapped environment." description = "Configure a device in an air-gapped environment."
license = {text = "Proprietary"} license = "CECILL-2.1"
license-files = ["LICENSE"]
maintainers = [ maintainers = [
{ name = "InterDiode", email = "contact@mail.interdiode.fr" } { name = "InterDiode", email = "contact@mail.interdiode.fr" }
] ]
name = "id-configurator" name = "id-configurator"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
version = "0.1.0" version = "2026.10.24"
[project.scripts] [project.scripts]
id-configurator = "id_configurator:main" id-configurator = "id_configurator:main"
+10 -2
View File
@@ -8,10 +8,12 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: []
apk: apk:
repositories: repositories:
- url: https://host.docker.internal:{{ 'APK_REPO_PORT' | env }}/ - name: 'demo _.-/\$.:@;''"'
{% set scheme = 'APK_REPO_SCHEME' | env %}
url: {{ scheme }}://host.docker.internal:{{ ('APK_REPO_PORT' if scheme == 'https' else 'APK_REPO_HTTP_PORT') | env }}/
url_to_replace: https://dl-cdn.alpinelinux.org/*
key: | key: |
{{ 'dockerfiles/repos/040d0af5f97a0a44.rsa.pub' | file | indent(8, first=true) }} {{ 'dockerfiles/repos/040d0af5f97a0a44.rsa.pub' | file | indent(8, first=true) }}
yum: yum:
@@ -27,3 +29,9 @@ configurations:
repositories: [] repositories: []
rust: rust:
repositories: [] repositories: []
go:
repositories: []
ruby:
repositories: []
nuget:
repositories: []
+11 -3
View File
@@ -11,10 +11,12 @@ configurations:
- name: demo - name: demo
types: [deb] types: [deb]
uris: uris:
- https://host.docker.internal:{{ 'APT_REPO_PORT' | env }}/ {% set scheme = 'APT_REPO_SCHEME' | env %}
- {{ scheme }}://host.docker.internal:{{ ('APT_REPO_PORT' if scheme == 'https' else 'APT_REPO_HTTP_PORT') | env }}/
suites: ["./"] suites: ["./"]
trusted: true uri-to-replace: http://deb.debian.org/*
keys: [] signed-by: |
{{ 'dockerfiles/repos/apt-signing-key.pub.asc' | file | indent(8, first=true) }}
apk: apk:
repositories: [] repositories: []
yum: yum:
@@ -30,3 +32,9 @@ configurations:
repositories: [] repositories: []
rust: rust:
repositories: [] repositories: []
go:
repositories: []
ruby:
repositories: []
nuget:
repositories: []
+6 -1
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: []
apk: apk:
repositories: [] repositories: []
yum: yum:
@@ -24,3 +23,9 @@ configurations:
repositories: [] repositories: []
rust: rust:
repositories: [] repositories: []
go:
repositories: []
ruby:
repositories: []
nuget:
repositories: []
+34
View File
@@ -0,0 +1,34 @@
uuid: 3f1a2b4c-5d6e-4f70-8a91-2b3c4d5e6f70
organisation: InterDiode
description: Go test configuration
configurations:
certificates:
- name: Test CA
content: |
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt:
sources: []
apk:
repositories: []
yum:
repositories: []
keys: []
python:
repositories: []
java:
repositories: []
php:
repositories: []
npm:
repositories: []
rust:
repositories: []
go:
repositories:
- name: 'demo _.-/\$.:@;''"'
{% set scheme = 'GO_REPO_SCHEME' | env %}
url: {{ scheme }}://host.docker.internal:{{ ('GO_REPO_PORT' if scheme == 'https' else 'GO_REPO_HTTP_PORT') | env }}/
ruby:
repositories: []
nuget:
repositories: []
+8 -2
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: []
apk: apk:
repositories: [] repositories: []
yum: yum:
@@ -24,7 +23,8 @@ configurations:
# during the Dockerfile's warm-up step. All other tools just see it as # during the Dockerfile's warm-up step. All other tools just see it as
# an ordinary repository, and find the test artifact here. # an ordinary repository, and find the test artifact here.
- name: central - name: central
url: https://host.docker.internal:{{ 'JAVA_REPO_PORT' | env }}/ {% set scheme = 'JAVA_REPO_SCHEME' | env %}
url: {{ scheme }}://host.docker.internal:{{ ('JAVA_REPO_PORT' if scheme == 'https' else 'JAVA_REPO_HTTP_PORT') | env }}/
# Second entry: only SBT needs this. With # Second entry: only SBT needs this. With
# -Dsbt.override.build.repos=true (the only way SBT actually consults # -Dsbt.override.build.repos=true (the only way SBT actually consults
# this file for project dependencies, not just its own bootstrap), the # this file for project dependencies, not just its own bootstrap), the
@@ -39,3 +39,9 @@ configurations:
repositories: [] repositories: []
rust: rust:
repositories: [] repositories: []
go:
repositories: []
ruby:
repositories: []
nuget:
repositories: []
+9 -3
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: []
apk: apk:
repositories: [] repositories: []
yum: yum:
@@ -22,7 +21,14 @@ configurations:
repositories: [] repositories: []
npm: npm:
repositories: repositories:
- name: demo - name: 'demo _.-/\$.:@;''"'
url: https://host.docker.internal:{{ 'NPM_REPO_PORT' | env }}/ {% set scheme = 'NPM_REPO_SCHEME' | env %}
url: {{ scheme }}://host.docker.internal:{{ ('NPM_REPO_PORT' if scheme == 'https' else 'NPM_REPO_HTTP_PORT') | env }}/
rust: rust:
repositories: [] repositories: []
go:
repositories: []
ruby:
repositories: []
nuget:
repositories: []
+34
View File
@@ -0,0 +1,34 @@
uuid: 5b3c4d6e-7f80-4192-8b13-4d5e6f708192
organisation: InterDiode
description: NuGet test configuration
configurations:
certificates:
- name: Test CA
content: |
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt:
sources: []
apk:
repositories: []
yum:
repositories: []
keys: []
python:
repositories: []
java:
repositories: []
php:
repositories: []
npm:
repositories: []
rust:
repositories: []
go:
repositories: []
ruby:
repositories: []
nuget:
repositories:
- name: 'demo _.-/\$.:@;''"'
{% set scheme = 'NUGET_REPO_SCHEME' | env %}
url: {{ scheme }}://host.docker.internal:{{ ('NUGET_REPO_PORT' if scheme == 'https' else 'NUGET_REPO_HTTP_PORT') | env }}/index.json
+9 -3
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: []
apk: apk:
repositories: [] repositories: []
yum: yum:
@@ -20,9 +19,16 @@ configurations:
repositories: [] repositories: []
php: php:
repositories: repositories:
- name: demo - name: 'demo _.-/\$.:@;''"'
url: https://host.docker.internal:{{ 'PHP_REPO_PORT' | env }}/ {% set scheme = 'PHP_REPO_SCHEME' | env %}
url: {{ scheme }}://host.docker.internal:{{ ('PHP_REPO_PORT' if scheme == 'https' else 'PHP_REPO_HTTP_PORT') | env }}/
npm: npm:
repositories: [] repositories: []
rust: rust:
repositories: [] repositories: []
go:
repositories: []
ruby:
repositories: []
nuget:
repositories: []
+9 -3
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: []
apk: apk:
repositories: [] repositories: []
yum: yum:
@@ -16,8 +15,9 @@ configurations:
keys: [] keys: []
python: python:
repositories: repositories:
- name: demo - name: 'demo _.-/\$.:@;''"'
index-url: https://host.docker.internal:{{ 'PYPI_REPO_PORT' | env }}/simple/ {% set scheme = 'PYPI_REPO_SCHEME' | env %}
index-url: {{ scheme }}://host.docker.internal:{{ ('PYPI_REPO_PORT' if scheme == 'https' else 'PYPI_REPO_HTTP_PORT') | env }}/simple/
java: java:
repositories: [] repositories: []
php: php:
@@ -26,3 +26,9 @@ configurations:
repositories: [] repositories: []
rust: rust:
repositories: [] repositories: []
go:
repositories: []
ruby:
repositories: []
nuget:
repositories: []
+34
View File
@@ -0,0 +1,34 @@
uuid: 4a2b3c5d-6e7f-4081-9a02-3c4d5e6f7081
organisation: InterDiode
description: Ruby test configuration
configurations:
certificates:
- name: Test CA
content: |
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt:
sources: []
apk:
repositories: []
yum:
repositories: []
keys: []
python:
repositories: []
java:
repositories: []
php:
repositories: []
npm:
repositories: []
rust:
repositories: []
go:
repositories: []
ruby:
repositories:
- name: 'demo _.-/\$.:@;''"'
{% set scheme = 'RUBY_REPO_SCHEME' | env %}
url: {{ scheme }}://host.docker.internal:{{ ('RUBY_REPO_PORT' if scheme == 'https' else 'RUBY_REPO_HTTP_PORT') | env }}/
nuget:
repositories: []
+10 -3
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: []
apk: apk:
repositories: [] repositories: []
yum: yum:
@@ -24,5 +23,13 @@ configurations:
repositories: [] repositories: []
rust: rust:
repositories: repositories:
- name: demo - name: 'demo _.-/\$.:@;''"'
url: https://host.docker.internal:{{ 'CARGO_REPO_PORT' | env }}/ {% set scheme = 'CARGO_REPO_SCHEME' | env %}
url: {{ scheme }}://host.docker.internal:{{ ('CARGO_REPO_PORT' if scheme == 'https' else 'CARGO_REPO_HTTP_PORT') | env }}/
is_sparse: true
go:
repositories: []
ruby:
repositories: []
nuget:
repositories: []
+10 -3
View File
@@ -8,13 +8,14 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} {{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: []
apk: apk:
repositories: [] repositories: []
yum: yum:
repositories: repositories:
- name: demo - name: 'demo _.-/\$.:@;''"'
index-url: https://host.docker.internal:{{ 'YUM_REPO_PORT' | env }}/ {% set scheme = 'YUM_REPO_SCHEME' | env %}
index-url: {{ scheme }}://host.docker.internal:{{ ('YUM_REPO_PORT' if scheme == 'https' else 'YUM_REPO_HTTP_PORT') | env }}/
index-url-to-replace: https://mirrors.rockylinux.org/*
keys: [] keys: []
python: python:
repositories: [] repositories: []
@@ -26,3 +27,9 @@ configurations:
repositories: [] repositories: []
rust: rust:
repositories: [] repositories: []
go:
repositories: []
ruby:
repositories: []
nuget:
repositories: []
+19
View File
@@ -0,0 +1,19 @@
FROM debian:trixie-slim AS target
FROM interdiode/id-configurator:latest AS configuration
ARG CONFIG_REPO_PORT
ARG CONFIG_URL_CACERT_CONTENT
COPY --from=target / /src/
RUN id-configurator apply \
--config-url "https://host.docker.internal:${CONFIG_REPO_PORT}/config.yaml" \
--config-url-cacert-content "$CONFIG_URL_CACERT_CONTENT" \
--src /src --dst /dst
FROM target AS runtime
ARG CONFIG_REPO_PORT
RUN apt-get update && apt-get install -y --no-install-recommends curl
COPY --from=configuration /dst/ /
# Proves the root CA the downloaded configuration installed is actually
# trusted system-wide: no --insecure/--no-check-certificate here, unlike the
# --config-url-cacert-content fetch above (which trusts only that one CA to
# validate the download itself, before anything has been installed yet).
RUN curl -fsS "https://host.docker.internal:${CONFIG_REPO_PORT}/config.yaml" \
| grep -q 'organisation: InterDiode'
+11
View File
@@ -0,0 +1,11 @@
FROM golang:1-bookworm AS target
FROM interdiode/id-configurator:latest AS configuration
COPY --from=target / /src/
COPY --from=generated-config Go.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/gotest && cd /tmp/gotest && go mod init gotest \
&& go get example.invalid/idconfiguratortestpkg@v0.0.1 \
&& grep -qx 'test-only module served by the id-configurator integration tests' \
"$(find "$(go env GOMODCACHE)" -name marker.txt)"
+13 -1
View File
@@ -4,6 +4,7 @@ COPY --from=target / /src/
COPY --from=generated-config Java.yaml /config.yaml COPY --from=generated-config Java.yaml /config.yaml
RUN id-configurator apply --config /config.yaml --src /src --dst /dst RUN id-configurator apply --config /config.yaml --src /src --dst /dst
FROM target AS runtime FROM target AS runtime
ARG JAVA_REPO_SCHEME
# Every build tool the `java` module configures: Maven itself (already part # Every build tool the `java` module configures: Maven itself (already part
# of this image), Gradle, Ivy and Leiningen (all packaged for this Ubuntu # of this image), Gradle, Ivy and Leiningen (all packaged for this Ubuntu
# base), and SBT (no package available, fetched as a plain release tarball). # base), and SBT (no package available, fetched as a plain release tarball).
@@ -43,12 +44,23 @@ RUN java -jar /usr/share/java/ivy.jar -settings /root/.ivy2/ivysettings.xml \
-retrieve "/tmp/ivyretrieve/[artifact]-[revision].[ext]" \ -retrieve "/tmp/ivyretrieve/[artifact]-[revision].[ext]" \
&& test -f /tmp/ivyretrieve/idconfiguratortestpkg-0.0.1.jar && test -f /tmp/ivyretrieve/idconfiguratortestpkg-0.0.1.jar
# Leiningen's standalone launcher unconditionally refuses any plain-HTTP
# repository ("Tried to use insecure HTTP repository without TLS") before
# even reading project.clj/profiles.clj - there is no `:insecure` key or
# other configuration escape hatch for this (confirmed from Leiningen's own
# source: leiningen.core.main/-main registers an aborting wagon for the
# "http" scheme unconditionally). So this check is skipped for the
# module-java-http variant: no id-configurator config could make it pass.
RUN mkdir -p /tmp/leintest && cd /tmp/leintest \ RUN mkdir -p /tmp/leintest && cd /tmp/leintest \
&& cat > project.clj <<'EOF' && cat > project.clj <<'EOF'
(defproject leintest "0.1.0" (defproject leintest "0.1.0"
:dependencies [[org.interdiode/idconfiguratortestpkg "0.0.1"]]) :dependencies [[org.interdiode/idconfiguratortestpkg "0.0.1"]])
EOF EOF
RUN cd /tmp/leintest && lein deps :tree | grep -q idconfiguratortestpkg RUN if [ "$JAVA_REPO_SCHEME" = "https" ]; then \
cd /tmp/leintest && lein deps :tree | grep -q idconfiguratortestpkg; \
else \
echo "skipping leiningen: it cannot use a non-HTTPS repository"; \
fi
RUN mkdir -p /tmp/sbttest/project \ RUN mkdir -p /tmp/sbttest/project \
&& cat > /tmp/sbttest/build.sbt <<'EOF' && cat > /tmp/sbttest/build.sbt <<'EOF'
+11
View File
@@ -0,0 +1,11 @@
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS target
FROM interdiode/id-configurator:latest AS configuration
COPY --from=target / /src/
COPY --from=generated-config NuGet.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/nugettest && cd /tmp/nugettest && dotnet new console -o . --no-restore >/dev/null \
&& dotnet add package idconfiguratortestpkg --version 0.0.1 \
&& grep -qx 'test-only package served by the id-configurator integration tests' \
"$(find /root/.nuget/packages/idconfiguratortestpkg -name marker.txt)"
+2 -1
View File
@@ -5,6 +5,7 @@ COPY --from=generated-config Python.yaml /config.yaml
RUN id-configurator apply --config /config.yaml --src /src --dst /dst RUN id-configurator apply --config /config.yaml --src /src --dst /dst
FROM target AS runtime FROM target AS runtime
ARG PYPI_REPO_PORT ARG PYPI_REPO_PORT
ARG PYPI_REPO_SCHEME
# Warm up uv and poetry themselves from the real, network-reachable PyPI # 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 # while the default index is still in place, so the actual tests below
# never need anything but the fixture repository configured by # never need anything but the fixture repository configured by
@@ -20,5 +21,5 @@ RUN uv pip install --system idconfiguratortestpkg \
&& python3 -c "import idconfiguratortestpkg" && python3 -c "import idconfiguratortestpkg"
RUN mkdir -p /tmp/poetrytest && cd /tmp/poetrytest \ RUN mkdir -p /tmp/poetrytest && cd /tmp/poetrytest \
&& poetry init -n --name poetrytest \ && poetry init -n --name poetrytest \
&& poetry source add --priority explicit demo "https://host.docker.internal:${PYPI_REPO_PORT}/simple/" \ && poetry source add --priority explicit demo "${PYPI_REPO_SCHEME}://host.docker.internal:${PYPI_REPO_PORT}/simple/" \
&& poetry add idconfiguratortestpkg --source demo && poetry add idconfiguratortestpkg --source demo
+2 -1
View File
@@ -5,6 +5,7 @@ COPY --from=generated-config Python.yaml /config.yaml
RUN id-configurator apply --config /config.yaml --src /src --dst /dst RUN id-configurator apply --config /config.yaml --src /src --dst /dst
FROM target AS runtime FROM target AS runtime
ARG PYPI_REPO_PORT ARG PYPI_REPO_PORT
ARG PYPI_REPO_SCHEME
# Warm up uv and poetry themselves from the real, network-reachable PyPI # 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 # while the default index is still in place, so the actual tests below
# never need anything but the fixture repository configured by # never need anything but the fixture repository configured by
@@ -20,5 +21,5 @@ RUN uv pip install --system idconfiguratortestpkg \
&& python3 -c "import idconfiguratortestpkg" && python3 -c "import idconfiguratortestpkg"
RUN mkdir -p /tmp/poetrytest && cd /tmp/poetrytest \ RUN mkdir -p /tmp/poetrytest && cd /tmp/poetrytest \
&& poetry init -n --name poetrytest \ && poetry init -n --name poetrytest \
&& poetry source add --priority explicit demo "https://host.docker.internal:${PYPI_REPO_PORT}/simple/" \ && poetry source add --priority explicit demo "${PYPI_REPO_SCHEME}://host.docker.internal:${PYPI_REPO_PORT}/simple/" \
&& poetry add idconfiguratortestpkg --source demo && poetry add idconfiguratortestpkg --source demo
+10
View File
@@ -0,0 +1,10 @@
FROM ruby:3-slim AS target
FROM interdiode/id-configurator:latest AS configuration
COPY --from=target / /src/
COPY --from=generated-config Ruby.yaml /config.yaml
RUN id-configurator apply --config /config.yaml --src /src --dst /dst
FROM target AS runtime
COPY --from=configuration /dst/ /
RUN gem install idconfiguratortestpkg --no-document \
&& grep -qx 'test-only gem served by the id-configurator integration tests' \
"$(find "$(gem environment gemdir)" -name marker.txt)"
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<PackageId>idconfiguratortestpkg</PackageId>
<Version>0.0.1</Version>
<Authors>InterDiode</Authors>
<Description>test-only package served by the id-configurator integration tests</Description>
</PropertyGroup>
<ItemGroup>
<None Include="marker.txt" Pack="true" PackagePath="content/marker.txt" />
</ItemGroup>
</Project>
@@ -8,3 +8,13 @@ server {
root /srv; root /srv;
autoindex off; autoindex off;
} }
# Plain-HTTP listener, for the module-*-http tox envs that verify id-configurator
# correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
}
+15 -2
View File
@@ -1,12 +1,18 @@
# Minimal flat-format APT repository serving one hand-built .deb package over # Minimal flat-format APT repository serving one hand-built .deb package over
# TLS, used by tox-docker to exercise the `apt` module integration test # TLS, used by tox-docker to exercise the `apt` module integration test
# (tests/configs/Apt.yaml.tmpl, tests/dockerfiles/Apt.Dockerfile). # (tests/configs/Apt.yaml.j2, tests/dockerfiles/Apt.Dockerfile). Its
# InRelease file is genuinely signed with the dedicated key pair
# apt-signing-key.key.asc / apt-signing-key.pub.asc, whose public half is
# what tests/configs/Apt.yaml.j2 embeds as the configured source's
# `signed-by`: the `apt` module dearmors it into a keyring, and APT itself
# verifies the InRelease signature against that keyring on `apt-get update`
# - no `Trusted: yes` bypass involved.
# #
# Built by tox-docker, whose build context is this directory (not the repo # Built by tox-docker, whose build context is this directory (not the repo
# root) - every COPY source below must live under tests/dockerfiles/repos/. # root) - every COPY source below must live under tests/dockerfiles/repos/.
FROM debian:trixie-slim AS packagebuild FROM debian:trixie-slim AS packagebuild
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends dpkg-dev \ && apt-get install -y --no-install-recommends dpkg-dev apt-utils gnupg \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN set -eux; \ RUN set -eux; \
pkg=idconfiguratortestpkg; \ pkg=idconfiguratortestpkg; \
@@ -18,6 +24,13 @@ RUN set -eux; \
WORKDIR /srv WORKDIR /srv
RUN dpkg-scanpackages --multiversion . > Packages \ RUN dpkg-scanpackages --multiversion . > Packages \
&& gzip -9c Packages > Packages.gz && gzip -9c Packages > Packages.gz
COPY apt-signing-key.key.asc /tmp/apt-signing-key.key.asc
RUN gnupg_home="$(mktemp -d)" \
&& gpg --homedir "$gnupg_home" --batch --import /tmp/apt-signing-key.key.asc \
&& key_id="$(gpg --homedir "$gnupg_home" --list-secret-keys --with-colons | awk -F: '/^sec/{print $5; exit}')" \
&& apt-ftparchive release . > Release \
&& gpg --homedir "$gnupg_home" --batch --yes --pinentry-mode loopback --default-key "$key_id" --clearsign -o InRelease Release \
&& rm -rf "$gnupg_home" /tmp/apt-signing-key.key.asc
FROM nginx:alpine FROM nginx:alpine
COPY --from=packagebuild /srv /srv COPY --from=packagebuild /srv /srv
@@ -8,3 +8,13 @@ server {
root /srv; root /srv;
autoindex off; autoindex off;
} }
# Plain-HTTP listener, for the module-*-http tox envs that verify id-configurator
# correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
}
@@ -0,0 +1,33 @@
-----BEGIN PGP PRIVATE KEY BLOCK-----
lQOYBGqg6rMBCADaMUd27S41+kInPXuRZ0aL41aMZjqr+mw4W34w8i/JRYMJ6eGb
sXf40AaFfS7zkbUpQZpmh3aYPF4jUAdWV03ZNNYdcGerIAKc2xsugSKjJIm1XPQq
upz914b4/hp/UslDUH7SPZ+3Tq17TXW+xP6aUM9VcWMnlJT/DFgxh5sZB3nfDeYr
yBV+PmEVfuQaUSIwKAh3Hv0ZaBHS8KzsawLzbJ+Pa9W9wjVME1izi9ctloGSxtLc
p1suS2IS33QB0jGgj4VbuMcjo2iCpzW3Xd7jQ7WpXGjrl0bHnJTnWWB0ihDD3cJe
tJlk9GScMQu3Z3th67DmqTXQru3btrDjxMifABEBAAEAB/sG3JffwJO8dNNRwpg5
OWM5kPuuzKQ5tlVi7FWqmmMwVDRLwoQqzkj0lS4vUoGAfWuSEZMWeFodbYCUYbbp
WzOCiivVnyxTcF1Eq4tOShdZxBNklbjEAzxeq0F+YRdaO7NCn6xtVizIqfvAre3v
Aujy12m9ySQSIZUR9ERtwlL5VXj4yzcXciyM9aNbh2D4BFAoo+fBL1B5mhpKQ16B
BafkpOf0myWZrwLdcn1t7BQ6UcLqsPJj6COCXXRDHoZvim38UBMMajNP4+8eC2Vk
DDR6iFz53FRvIREQWkoEEW4P80v1lgcnP1apJGhkrJYCPM7ObZk0MoQkBiBdlPPQ
VLiRBADhNsKjNd7v3Af5XGPJGWWZxCMxPlgwc6EMjCX3SQDql042eSIqwn1FFP6H
Hi8lyq/XWz0JcUwcrpFxpIC3OLUsBleX3Z4E17Z6AotSV0FC6y23cqgtweMeLA0L
tayMRmXJ7Aan+xfvi8MbW6lYGzM2uYHl27rVBuCt0kvxn+U4lwQA+ATO8KaBreCL
E/vXGZaPWJs2GoiDBavKwqdYtlXDKlVP5XvBgaPfY6zKLtCsC6N+DKkVo42Stwu3
oB6HSSLvXvFIhvUReh5PLpijmOroAJzvLQN9qmZBvF4IOUgaHmlYlL0DGzlxcwLr
SdzqPaGRa+EgraFDDGGu5oQ5A2wNKTkEAMDS2w5SibOfsvnfK4aDc0mi8/fftzCO
SwsvVzR0SmbXtaCPBnBjlA7YJhRZ6n/7nDpswrWPk8g125A0vK1cNRGM0ufKYW1f
DMbB/aYrJjAKz8zWH6cgOD4fqM2gA9JounkIcJvWOToGume8pXhmem94u2FP8aik
A6hL/Os+WRQcMNi0NWlkLWNvbmZpZ3VyYXRvci10ZXN0cyA8dGVzdHNAaWQtY29u
ZmlndXJhdG9yLmludmFsaWQ+iQFuBBMBCABYFiEEaZF4ALsB1xUU3IP5rl3Himib
1RgFAmqg6rMbFIAAAAAABAAObWFudTIsMi41KzEuMTIsMCwzAxsvBAULCQgHAgIi
AgYVCgkICwIEFgIDAQIeBwIXgAAKCRCuXceKaJvVGFRrB/wIbG1HHQANDCHgyl4T
RTPuKBX2c9lmJi2plp+T5L5sXyTMbAMLfTCkJWL/zHLM3N2v6GZuzbwZM8Dzj9zL
LMpC6newmZe4YX+ZtyZGufBI963kh8PvlSWWUSyH+oYoIhlyJqj7tCQXoijEvQnS
ReKGHUTvQGvQK2Ybp735NmAiDOM3/3KKh2Dqas/Tc2mxec4whr8RyUidsF+Yp6wT
esfMxeax6DmWIcSLDWpUBndeahZ2Ff0c96BzbpF1v2hbvyI/IaP5snb4Nm1xpREm
+y8iDAQhOklfXCyXrWgN3sIrk3tDndGrn+nw0iusnaBVki0keqjlH0camoUP28tu
3sxG
=TrE4
-----END PGP PRIVATE KEY BLOCK-----
@@ -0,0 +1,19 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQENBGqg6rMBCADaMUd27S41+kInPXuRZ0aL41aMZjqr+mw4W34w8i/JRYMJ6eGb
sXf40AaFfS7zkbUpQZpmh3aYPF4jUAdWV03ZNNYdcGerIAKc2xsugSKjJIm1XPQq
upz914b4/hp/UslDUH7SPZ+3Tq17TXW+xP6aUM9VcWMnlJT/DFgxh5sZB3nfDeYr
yBV+PmEVfuQaUSIwKAh3Hv0ZaBHS8KzsawLzbJ+Pa9W9wjVME1izi9ctloGSxtLc
p1suS2IS33QB0jGgj4VbuMcjo2iCpzW3Xd7jQ7WpXGjrl0bHnJTnWWB0ihDD3cJe
tJlk9GScMQu3Z3th67DmqTXQru3btrDjxMifABEBAAG0NWlkLWNvbmZpZ3VyYXRv
ci10ZXN0cyA8dGVzdHNAaWQtY29uZmlndXJhdG9yLmludmFsaWQ+iQFuBBMBCABY
FiEEaZF4ALsB1xUU3IP5rl3Himib1RgFAmqg6rMbFIAAAAAABAAObWFudTIsMi41
KzEuMTIsMCwzAxsvBAULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRCuXceK
aJvVGFRrB/wIbG1HHQANDCHgyl4TRTPuKBX2c9lmJi2plp+T5L5sXyTMbAMLfTCk
JWL/zHLM3N2v6GZuzbwZM8Dzj9zLLMpC6newmZe4YX+ZtyZGufBI963kh8PvlSWW
USyH+oYoIhlyJqj7tCQXoijEvQnSReKGHUTvQGvQK2Ybp735NmAiDOM3/3KKh2Dq
as/Tc2mxec4whr8RyUidsF+Yp6wTesfMxeax6DmWIcSLDWpUBndeahZ2Ff0c96Bz
bpF1v2hbvyI/IaP5snb4Nm1xpREm+y8iDAQhOklfXCyXrWgN3sIrk3tDndGrn+nw
0iusnaBVki0keqjlH0camoUP28tu3sxG
=qZrp
-----END PGP PUBLIC KEY BLOCK-----
+32
View File
@@ -0,0 +1,32 @@
#!/bin/sh
# Build a minimal, hand-rolled RubyGems package and a full gem repository
# index for it (legacy + compact index formats), for integration-testing
# the `ruby` module of id-configurator. Uses RubyGems' own `gem build` and
# `gem generate_index` (the same tools a real gem server host would use)
# rather than hand-rolling the compact index's binary/marshal formats.
set -eu
PACKAGE=idconfiguratortestpkg
VERSION=0.0.1
MARKER="test-only gem served by the id-configurator integration tests"
gem install rubygems-generate_index --no-document
mkdir -p "/build/${PACKAGE}-${VERSION}"
cd "/build/${PACKAGE}-${VERSION}"
cat > "${PACKAGE}.gemspec" <<EOF
Gem::Specification.new do |s|
s.name = "${PACKAGE}"
s.version = "${VERSION}"
s.summary = "test-only gem"
s.authors = ["InterDiode"]
s.license = "MIT"
s.files = ["marker.txt"]
end
EOF
echo "${MARKER}" > marker.txt
gem build "${PACKAGE}.gemspec"
mkdir -p /srv/gems
cp ./*.gem /srv/gems/
gem generate_index -d /srv
@@ -0,0 +1,47 @@
"""Build a minimal, offline, hand-rolled Go module proxy tree, for
integration-testing the `go` module of id-configurator.
No network access and no Go toolchain is used: the module proxy protocol
(https://go.dev/ref/mod#module-proxy) only requires a handful of static
files, none of which embed the server's own base URL (unlike Composer/
npm/cargo), so this can be served directly by nginx with no rewriting.
"""
from __future__ import annotations
import io
import json
import pathlib
import zipfile
MODULE = "example.invalid/idconfiguratortestpkg"
VERSION = "v0.0.1"
OUT_DIR = pathlib.Path("/srv")
MARKER = "test-only module served by the id-configurator integration tests\n"
GO_MOD = f"module {MODULE}\n\ngo 1.21\n"
def build_zip() -> bytes:
buffer = io.BytesIO()
prefix = f"{MODULE}@{VERSION}"
with zipfile.ZipFile(buffer, "w") as archive:
archive.writestr(f"{prefix}/go.mod", GO_MOD)
archive.writestr(f"{prefix}/marker.txt", MARKER)
return buffer.getvalue()
def main() -> None:
module_dir = OUT_DIR / MODULE / "@v"
module_dir.mkdir(parents=True, exist_ok=True)
(module_dir / "list").write_text(f"{VERSION}\n", encoding="utf-8")
(module_dir / f"{VERSION}.info").write_text(
json.dumps({"Version": VERSION, "Time": "2024-01-01T00:00:00Z"}),
encoding="utf-8",
)
(module_dir / f"{VERSION}.mod").write_text(GO_MOD, encoding="utf-8")
(module_dir / f"{VERSION}.zip").write_bytes(build_zip())
if __name__ == "__main__":
main()
@@ -0,0 +1,61 @@
"""Build a minimal NuGet v3 static feed around one real, `dotnet pack`-built
.nupkg, for integration-testing the `nuget` module of id-configurator.
Only two resources are populated: the flat container (`PackageBaseAddress`,
used to enumerate versions and download the .nupkg directly) and the
top-level service index pointing at it. `dotnet add package`/`dotnet
restore` with an explicit, dependency-free package version only ever calls
these two - never the (considerably more complex) registration resource -
so it is intentionally not implemented here.
The service index bakes in a __BASE_URL__ placeholder for its "@id" fields,
since the host:port this fixture is actually reached on is only known once
tox-docker starts the container, not at image-build time (see
nuget-repo.nginx.conf). The flat container's own files never embed a URL,
so they need no such rewriting.
"""
from __future__ import annotations
import json
import pathlib
import shutil
PACKAGE_ID = "idconfiguratortestpkg"
VERSION = "0.0.1"
NUPKG_NAME = f"{PACKAGE_ID}.{VERSION}.nupkg"
NUPKG_SRC = pathlib.Path("/nupkg") / NUPKG_NAME
OUT_DIR = pathlib.Path("/srv")
def write_flat_container() -> None:
package_dir = OUT_DIR / "flatcontainer" / PACKAGE_ID
version_dir = package_dir / VERSION
version_dir.mkdir(parents=True, exist_ok=True)
(package_dir / "index.json").write_text(
json.dumps({"versions": [VERSION]}), encoding="utf-8"
)
shutil.copy(NUPKG_SRC, version_dir / NUPKG_NAME)
def write_service_index() -> None:
index = {
"version": "3.0.0",
"resources": [
{
"@id": "__BASE_URL__/flatcontainer/",
"@type": "PackageBaseAddress/3.0.0",
}
],
}
(OUT_DIR / "index.json").write_text(json.dumps(index), encoding="utf-8")
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
write_flat_container()
write_service_index()
if __name__ == "__main__":
main()
+20 -2
View File
@@ -12,10 +12,28 @@ server {
# templates, since the host:port this fixture is actually reached on is # templates, since the host:port this fixture is actually reached on is
# only known once tox-docker starts the container, not at image-build # only known once tox-docker starts the container, not at image-build
# time. $http_host reflects exactly what the client (cargo) used to # time. $http_host reflects exactly what the client (cargo) used to
# connect, e.g. host.docker.internal:55123. # connect, e.g. host.docker.internal:55123; $scheme likewise reflects
# whichever of this file's two listeners actually served the request.
location = /config.json { location = /config.json {
default_type application/json; default_type application/json;
sub_filter '__BASE_URL__' 'https://$http_host'; sub_filter '__BASE_URL__' '$scheme://$http_host';
sub_filter_once off;
sub_filter_types application/json;
}
}
# Plain-HTTP listener, for the module-rust-http tox env that verifies
# id-configurator correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
location = /config.json {
default_type application/json;
sub_filter '__BASE_URL__' '$scheme://$http_host';
sub_filter_once off; sub_filter_once off;
sub_filter_types application/json; sub_filter_types application/json;
} }
@@ -0,0 +1,40 @@
# Static id-configurator configuration served by the config-url-repo fixture,
# to be downloaded (not copied in locally) via `id-configurator apply
# --config-url ... --config-url-cacert-content ...`
# (tests/dockerfiles/ConfigUrl.Dockerfile). No per-run templating is needed:
# unlike the module-* fixtures, nothing in this document needs to reference
# its own server's dynamic host:port.
#
# The certificate below is tests/certs/test-ca.crt.pem - the same Test CA
# that signs repo-server.crt.pem in this directory. It's duplicated here
# (rather than read at build time) because a Docker build's context is
# this directory alone; keep both copies in sync if the Test CA is ever
# regenerated.
uuid: 8f3e4b2c-9a41-4d3e-8f1a-2b6c7d8e9f01
organisation: InterDiode
description: Configuration downloaded via --config-url
configurations:
certificates:
- name: Test CA
content: |
-----BEGIN CERTIFICATE-----
MIIDdTCCAl2gAwIBAgIUKq4URHQwyg7IzJ3ViAGGlM9pGCwwDQYJKoZIhvcNAQEL
BQAwQjEeMBwGA1UECgwVaWQtY29uZmlndXJhdG9yLXRlc3RzMSAwHgYDVQQDDBdp
ZC1jb25maWd1cmF0b3IgVGVzdCBDQTAeFw0yNjA5MDUyMTA4MTNaFw00NjA4MzEy
MTA4MTNaMEIxHjAcBgNVBAoMFWlkLWNvbmZpZ3VyYXRvci10ZXN0czEgMB4GA1UE
AwwXaWQtY29uZmlndXJhdG9yIFRlc3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IB
DwAwggEKAoIBAQD8bMteh3MDnbW6OoFMlhA4Z2QAabVoSFlSE/Si7ut+L/3v1GY4
lixenwLQc8a2koIH9CtmOodzfO7hpro/VUjYJ9J2eTsXLLiOg7OgggG1cKV9/kt7
RF0Q4hkcanGmiffc4xbz4ZedU6lP1vzLCsgU0A8OPt6svcNjGcWbjxss0KjgxGZ7
H4UKdL7Mv4W2Y6Fw1b4eV2M6jErkQK6IPXsHGFh7JkzliNcb0sX1VfbfauS84tBh
f3EHwzLNhbjtEmQhQdqgsg4Cbk/gWur//6aEvlD/X12PAUbrp4DFD+CDuVagLX7I
7TQBXNFGKhozzQuDg7DFg1gIV8SFx2m+s60/AgMBAAGjYzBhMB0GA1UdDgQWBBSb
zJ7DsaxwCeqbfOXGJ3x+u7bjozAfBgNVHSMEGDAWgBSbzJ7DsaxwCeqbfOXGJ3x+
u7bjozAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0B
AQsFAAOCAQEAEV1+dG3agh0S/AR8jC8M33HXFyvneCCRtfb5q/loJ//VoWPbD3Gs
G+2SR0MVN+KKOMBvCBh3KrNSgFF9iFmBjNCJJZ8ycDIszUK3xLjUP2EzN1pNNl8S
D/yHzUCro3547Q+I+Nn5zRciG95EpMvtkIWem4H8luYSBt7w8y9uDUKWzaca/v3z
oGZPZP99w8WVqMKGRbsK94wsTVsWiE+oHzSufwrhvZJfQQ4jU6102IEnRS/eQFXA
+fozgEWuFatZe3Gz0HUJakrzSBwSTV6wmnEHCfu+uwdns1Ttq5FylZ4StJHsmweL
14w1xa3tjC5bQ3e1QWK3R5InHcSZ7tLpGA==
-----END CERTIFICATE-----
@@ -0,0 +1,13 @@
# Serves a static id-configurator configuration document over TLS, using the
# same Test CA as the other *-repo fixtures, used by tox-docker to exercise
# `id-configurator apply --config-url ... --config-url-cacert-content ...`
# (tests/dockerfiles/ConfigUrl.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 nginx:alpine
COPY config-url-config.yaml /srv/config.yaml
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 config-url-repo.nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8443
@@ -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;
}
@@ -0,0 +1,17 @@
# Minimal Go module proxy serving one hand-built module over TLS, using the
# same Test CA as the other *-repo fixtures, used by tox-docker to exercise
# the `go` module integration test (tests/configs/Go.yaml.j2,
# tests/dockerfiles/Go.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_go_module.py /build_test_go_module.py
RUN python3 /build_test_go_module.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 go-repo.nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8443
@@ -0,0 +1,20 @@
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;
}
# Plain-HTTP listener, for the module-*-http tox envs that verify id-configurator
# correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
}
@@ -8,3 +8,13 @@ server {
root /srv; root /srv;
autoindex off; autoindex off;
} }
# Plain-HTTP listener, for the module-*-http tox envs that verify id-configurator
# correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
}
+21 -2
View File
@@ -12,10 +12,29 @@ server {
# its tarball URL, since the host:port this fixture is actually reached # its tarball URL, since the host:port this fixture is actually reached
# on is only known once tox-docker starts the container, not at # on is only known once tox-docker starts the container, not at
# image-build time. $http_host reflects exactly what the client (npm) # image-build time. $http_host reflects exactly what the client (npm)
# used to connect, e.g. host.docker.internal:55123. # used to connect, e.g. host.docker.internal:55123; $scheme likewise
# reflects whichever of this file's two listeners actually served the
# request.
location = /idconfiguratortestpkg { location = /idconfiguratortestpkg {
default_type application/json; default_type application/json;
sub_filter '__BASE_URL__' 'https://$http_host'; sub_filter '__BASE_URL__' '$scheme://$http_host';
sub_filter_once off;
sub_filter_types application/json;
}
}
# Plain-HTTP listener, for the module-npm-http tox env that verifies
# id-configurator correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
location = /idconfiguratortestpkg {
default_type application/json;
sub_filter '__BASE_URL__' '$scheme://$http_host';
sub_filter_once off; sub_filter_once off;
sub_filter_types application/json; sub_filter_types application/json;
} }
@@ -0,0 +1,23 @@
# Minimal NuGet v3 static feed serving one real, dotnet-packed .nupkg over
# TLS, using the same Test CA as the other *-repo fixtures, used by
# tox-docker to exercise the `nuget` module integration test
# (tests/configs/NuGet.yaml.j2, tests/dockerfiles/NuGet.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 mcr.microsoft.com/dotnet/sdk:9.0 AS packagebuild
COPY IdConfiguratorTestPkg.csproj /pkgsrc/IdConfiguratorTestPkg.csproj
RUN echo 'test-only package served by the id-configurator integration tests' > /pkgsrc/marker.txt \
&& dotnet pack /pkgsrc -c Release -o /nupkg
FROM python:3.13-slim AS feedbuild
COPY --from=packagebuild /nupkg /nupkg
COPY build_test_nuget_feed.py /build_test_nuget_feed.py
RUN python3 /build_test_nuget_feed.py
FROM nginx:alpine
COPY --from=feedbuild /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 nuget-repo.nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8443
@@ -0,0 +1,40 @@
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 service index bakes in a __BASE_URL__ placeholder for its "@id"
# fields, 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 (nuget) used to
# connect, e.g. host.docker.internal:55123; $scheme likewise reflects
# whichever of this file's two listeners actually served the request.
location = /index.json {
default_type application/json;
sub_filter '__BASE_URL__' '$scheme://$http_host';
sub_filter_once off;
sub_filter_types application/json;
}
}
# Plain-HTTP listener, for the module-nuget-http tox env that verifies
# id-configurator correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
location = /index.json {
default_type application/json;
sub_filter '__BASE_URL__' '$scheme://$http_host';
sub_filter_once off;
sub_filter_types application/json;
}
}
+20 -2
View File
@@ -12,10 +12,28 @@ server {
# since the host:port this fixture is actually reached on is only known # since the host:port this fixture is actually reached on is only known
# once tox-docker starts the container, not at image-build time. # once tox-docker starts the container, not at image-build time.
# $http_host reflects exactly what the client (Composer) used to # $http_host reflects exactly what the client (Composer) used to
# connect, e.g. host.docker.internal:55123. # connect, e.g. host.docker.internal:55123; $scheme likewise reflects
# whichever of this file's two listeners actually served the request.
location = /packages.json { location = /packages.json {
default_type application/json; default_type application/json;
sub_filter '__BASE_URL__' 'https://$http_host'; sub_filter '__BASE_URL__' '$scheme://$http_host';
sub_filter_once off;
sub_filter_types application/json;
}
}
# Plain-HTTP listener, for the module-php-http tox env that verifies
# id-configurator correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
location = /packages.json {
default_type application/json;
sub_filter '__BASE_URL__' '$scheme://$http_host';
sub_filter_once off; sub_filter_once off;
sub_filter_types application/json; sub_filter_types application/json;
} }
@@ -8,3 +8,13 @@ server {
root /srv; root /srv;
autoindex off; autoindex off;
} }
# Plain-HTTP listener, for the module-*-http tox envs that verify id-configurator
# correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
}
@@ -0,0 +1,17 @@
# Minimal RubyGems repository serving one hand-built gem over TLS, using the
# same Test CA as the other *-repo fixtures, used by tox-docker to exercise
# the `ruby` module integration test (tests/configs/Ruby.yaml.j2,
# tests/dockerfiles/Ruby.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 ruby:3-slim AS packagebuild
COPY build_test_gem.sh /build_test_gem.sh
RUN sh /build_test_gem.sh
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 ruby-repo.nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8443
@@ -0,0 +1,20 @@
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;
}
# Plain-HTTP listener, for the module-*-http tox envs that verify id-configurator
# correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
}
@@ -8,3 +8,13 @@ server {
root /srv; root /srv;
autoindex off; autoindex off;
} }
# Plain-HTTP listener, for the module-*-http tox envs that verify id-configurator
# correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
}
+195 -4
View File
@@ -14,10 +14,11 @@ CONFIG_DATA = {
"apk": { "apk": {
"repositories": [ "repositories": [
{ {
"name": "main",
"url": "https://example.invalid/v3.19/main", "url": "https://example.invalid/v3.19/main",
"key": KEY_BYTES, "key": KEY_BYTES,
}, },
{"url": "https://example.invalid/v3.19/community"}, {"name": "community", "url": "https://example.invalid/v3.19/community"},
] ]
} }
}, },
@@ -47,7 +48,9 @@ def test_is_relevant_true_with_apk_dir(tmp_path) -> None:
assert ApkModule().is_relevant(context) is True assert ApkModule().is_relevant(context) is True
def test_apply_disables_existing_and_adds_configured_repositories(tmp_path) -> None: def test_apply_copies_existing_repositories_unchanged_without_url_to_replace(
tmp_path,
) -> None:
src = tmp_path / "src" src = tmp_path / "src"
apk_dir = src / "etc/apk" apk_dir = src / "etc/apk"
apk_dir.mkdir(parents=True) apk_dir.mkdir(parents=True)
@@ -61,11 +64,13 @@ def test_apply_disables_existing_and_adds_configured_repositories(tmp_path) -> N
ApkModule().apply(context) ApkModule().apply(context)
# No configured repository declares a url_to_replace, so the existing
# lines are copied through unchanged, still active.
content = (dst / "etc/apk/repositories").read_text(encoding="utf-8") content = (dst / "etc/apk/repositories").read_text(encoding="utf-8")
assert content == ( assert content == (
"# comment\n" "# comment\n"
"# https://old-mirror.invalid/v3.18/main\n" "https://old-mirror.invalid/v3.18/main\n"
"# https://old-mirror.invalid/v3.18/community\n" "https://old-mirror.invalid/v3.18/community\n"
"https://example.invalid/v3.19/main\n" "https://example.invalid/v3.19/main\n"
"https://example.invalid/v3.19/community\n" "https://example.invalid/v3.19/community\n"
) )
@@ -74,6 +79,42 @@ def test_apply_disables_existing_and_adds_configured_repositories(tmp_path) -> N
assert key_content == KEY_BYTES assert key_content == KEY_BYTES
def test_apply_disables_only_existing_lines_matching_url_to_replace(tmp_path) -> None:
src = tmp_path / "src"
apk_dir = src / "etc/apk"
apk_dir.mkdir(parents=True)
(apk_dir / "repositories").write_text(
"https://old-mirror.invalid/v3.18/main\nhttps://old-mirror.invalid/v3.18/community\n",
encoding="utf-8",
)
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"apk": {
"repositories": [
{
"name": "main",
"url": "https://example.invalid/v3.19/main",
"url_to_replace": "https://old-mirror.invalid/v3.18/main",
}
]
}
},
}
dst = tmp_path / "dst"
context = Context(config=Configuration.model_validate(data), dst=dst, src=src)
ApkModule().apply(context)
content = (dst / "etc/apk/repositories").read_text(encoding="utf-8")
assert content == (
"# https://old-mirror.invalid/v3.18/main\n"
"https://old-mirror.invalid/v3.18/community\n"
"https://example.invalid/v3.19/main\n"
)
def test_apply_without_existing_repositories_file(tmp_path) -> None: def test_apply_without_existing_repositories_file(tmp_path) -> None:
src = tmp_path / "src" src = tmp_path / "src"
(src / "etc/apk").mkdir(parents=True) (src / "etc/apk").mkdir(parents=True)
@@ -89,6 +130,156 @@ def test_apply_without_existing_repositories_file(tmp_path) -> None:
) )
def test_apply_skips_repository_when_url_to_replace_matches_nothing(tmp_path) -> None:
src = tmp_path / "src"
apk_dir = src / "etc/apk"
apk_dir.mkdir(parents=True)
(apk_dir / "repositories").write_text(
"https://old-mirror.invalid/v3.18/main\n", encoding="utf-8"
)
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"apk": {
"repositories": [
{
"name": "main",
"url": "https://example.invalid/v3.19/main",
"url_to_replace": "https://other.invalid/*",
}
]
}
},
}
dst = tmp_path / "dst"
context = Context(config=Configuration.model_validate(data), dst=dst, src=src)
ApkModule().apply(context)
content = (dst / "etc/apk/repositories").read_text(encoding="utf-8")
assert "example.invalid" not in content
def test_apply_writes_repository_when_url_to_replace_matches_existing(tmp_path) -> None:
src = tmp_path / "src"
apk_dir = src / "etc/apk"
apk_dir.mkdir(parents=True)
(apk_dir / "repositories").write_text(
"https://old-mirror.invalid/v3.18/main\n", encoding="utf-8"
)
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"apk": {
"repositories": [
{
"name": "main",
"url": "https://example.invalid/v3.19/main",
"url_to_replace": "https://old-mirror.invalid/*",
}
]
}
},
}
dst = tmp_path / "dst"
context = Context(config=Configuration.model_validate(data), dst=dst, src=src)
ApkModule().apply(context)
content = (dst / "etc/apk/repositories").read_text(encoding="utf-8")
assert "https://example.invalid/v3.19/main" in content
def test_apply_reuses_cached_existing_urls_and_handles_missing_file(tmp_path) -> None:
src = tmp_path / "src"
(src / "etc/apk").mkdir(parents=True)
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"apk": {
"repositories": [
{
"name": "main",
"url": "https://example.invalid/v3.19/main",
"url_to_replace": "https://old.invalid/*",
},
{
"name": "community",
"url": "https://example.invalid/v3.19/community",
"url_to_replace": "https://old.invalid/*",
},
]
}
},
}
dst = tmp_path / "dst"
context = Context(config=Configuration.model_validate(data), dst=dst, src=src)
ApkModule().apply(context)
assert not (dst / "etc/apk/repositories").exists()
def test_apply_uses_key_filename_when_valid(tmp_path) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"apk": {
"repositories": [
{
"name": "main",
"url": "https://example.invalid/v3.19/main",
"key": "fake-apk-key-bytes",
"key_filename": "main.rsa.pub",
}
]
}
},
}
src = tmp_path / "src"
(src / "etc/apk").mkdir(parents=True)
dst = tmp_path / "dst"
context = Context(config=Configuration.model_validate(data), dst=dst, src=src)
ApkModule().apply(context)
key_content = (dst / "etc/apk/keys" / "main.rsa.pub").read_bytes()
assert key_content == b"fake-apk-key-bytes"
def test_apply_falls_back_to_derived_name_when_key_filename_invalid(tmp_path) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"apk": {
"repositories": [
{
"name": "main",
"url": "https://example.invalid/v3.19/main",
"key": "fake-apk-key-bytes",
"key_filename": "../escape",
}
]
}
},
}
src = tmp_path / "src"
(src / "etc/apk").mkdir(parents=True)
dst = tmp_path / "dst"
context = Context(config=Configuration.model_validate(data), dst=dst, src=src)
ApkModule().apply(context)
assert not (dst / "etc/apk/keys" / "../escape").exists()
key_content = (dst / "etc/apk/keys" / KEY_FILENAME).read_bytes()
assert key_content == KEY_BYTES
def test_apply_writes_nothing_when_no_repositories_at_all(tmp_path) -> None: def test_apply_writes_nothing_when_no_repositories_at_all(tmp_path) -> None:
empty_config = Configuration.model_validate( empty_config = Configuration.model_validate(
{"uuid": "12345678-1234-5678-1234-567812345678", "organisation": "InterDiode"} {"uuid": "12345678-1234-5678-1234-567812345678", "organisation": "InterDiode"}
+267 -23
View File
@@ -2,6 +2,13 @@ from id_configurator.config import Configuration
from id_configurator.context import Context from id_configurator.context import Context
from id_configurator.modules.apt import AptModule from id_configurator.modules.apt import AptModule
KEY_CONTENT = (
"-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
"\n"
"mQENBGqh08IBCAC0\n"
"-----END PGP PUBLIC KEY BLOCK-----\n"
)
SOURCE_CONTENT = { SOURCE_CONTENT = {
"uuid": "12345678-1234-5678-1234-567812345678", "uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode", "organisation": "InterDiode",
@@ -12,13 +19,7 @@ SOURCE_CONTENT = {
"name": "interdiode", "name": "interdiode",
"uris": ["http://example.invalid/debian"], "uris": ["http://example.invalid/debian"],
"suites": ["stable"], "suites": ["stable"],
"signed-by": "/etc/apt/keyrings/interdiode.asc", "signed-by": KEY_CONTENT,
}
],
"keys": [
{
"name": "interdiode",
"content": "fake-key-bytes",
} }
], ],
} }
@@ -30,6 +31,26 @@ def make_config() -> Configuration:
return Configuration.model_validate(SOURCE_CONTENT) return Configuration.model_validate(SOURCE_CONTENT)
def make_config_with(source_extra: dict, name: str = "interdiode") -> Configuration:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"apt": {
"sources": [
{
"name": name,
"uris": ["http://example.invalid/debian"],
"suites": ["stable"],
**source_extra,
}
],
}
},
}
return Configuration.model_validate(data)
def test_is_relevant_false_without_src(tmp_path) -> None: def test_is_relevant_false_without_src(tmp_path) -> None:
context = Context(config=make_config(), dst=tmp_path / "dst", src=None) context = Context(config=make_config(), dst=tmp_path / "dst", src=None)
assert AptModule().is_relevant(context) is False assert AptModule().is_relevant(context) is False
@@ -49,7 +70,9 @@ def test_is_relevant_true_with_apt_dir(tmp_path) -> None:
assert AptModule().is_relevant(context) is True assert AptModule().is_relevant(context) is True
def test_apply_disables_existing_sources_and_adds_configured_ones(tmp_path) -> None: def test_apply_copies_existing_sources_unchanged_without_uri_to_replace(
tmp_path,
) -> None:
src = tmp_path / "src" src = tmp_path / "src"
apt_dir = src / "etc/apt" apt_dir = src / "etc/apt"
sources_list_d = apt_dir / "sources.list.d" sources_list_d = apt_dir / "sources.list.d"
@@ -76,37 +99,88 @@ def test_apply_disables_existing_sources_and_adds_configured_ones(tmp_path) -> N
AptModule().apply(context) AptModule().apply(context)
disabled_top_level = (dst / "etc/apt/sources.list").read_text(encoding="utf-8") # No configured source declares a uri_to_replace, so none of the existing
# entries are disabled: they are copied through unchanged.
top_level = (dst / "etc/apt/sources.list").read_text(encoding="utf-8")
assert ( assert (
disabled_top_level top_level
== "# already disabled\n# deb http://example.invalid/legacy stable main\n\n" == "# already disabled\ndeb http://example.invalid/legacy stable main\n\n"
) )
disabled_list = (dst / "etc/apt/sources.list.d/extra.list").read_text( extra_list = (dst / "etc/apt/sources.list.d/extra.list").read_text(encoding="utf-8")
encoding="utf-8" assert extra_list == "deb http://example.invalid/extra stable main\n"
)
assert disabled_list == "# deb http://example.invalid/extra stable main\n"
disabled_sources = (dst / "etc/apt/sources.list.d/docker.sources").read_text( docker_sources = (dst / "etc/apt/sources.list.d/docker.sources").read_text(
encoding="utf-8" encoding="utf-8"
) )
assert disabled_sources == ( assert docker_sources == (
"Types: deb\nURIs: http://example.invalid/docker\nSuites: stable\nEnabled: no" "Types: deb\nURIs: http://example.invalid/docker\nSuites: stable\nEnabled: yes"
"\n\n" "\n\n"
"Types: deb\nURIs: http://example.invalid/docker2\nSuites: stable\nEnabled: no\n" "Types: deb\nURIs: http://example.invalid/docker2\nSuites: stable\n"
) )
assert not (dst / "etc/apt/sources.list.d/ignored.txt").exists() assert not (dst / "etc/apt/sources.list.d/ignored.txt").exists()
keyring = (dst / "etc/apt/keyrings/interdiode.asc").read_bytes()
assert keyring == b"fake-key-bytes"
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text( configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
encoding="utf-8" encoding="utf-8"
) )
assert configured_source == ( assert configured_source == (
"Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\n" "Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\n"
"Signed-By: /etc/apt/keyrings/interdiode.asc\nEnabled: yes\n" "Signed-By:\n"
" -----BEGIN PGP PUBLIC KEY BLOCK-----\n"
" .\n"
" mQENBGqh08IBCAC0\n"
" -----END PGP PUBLIC KEY BLOCK-----\n"
"Enabled: yes\n"
)
def test_apply_disables_only_existing_sources_matching_uri_to_replace(
tmp_path,
) -> None:
src = tmp_path / "src"
apt_dir = src / "etc/apt"
sources_list_d = apt_dir / "sources.list.d"
sources_list_d.mkdir(parents=True)
(apt_dir / "sources.list").write_text(
"deb http://example.invalid/legacy stable main\n", encoding="utf-8"
)
(sources_list_d / "extra.list").write_text(
"deb http://example.invalid/extra stable main\n", encoding="utf-8"
)
(sources_list_d / "docker.sources").write_text(
"Types: deb\nURIs: http://example.invalid/extra\nSuites: stable\nEnabled: yes\n"
"\n"
"Types: deb\nURIs: http://example.invalid/docker2\nSuites: stable\n"
"\n\n",
encoding="utf-8",
)
dst = tmp_path / "dst"
context = Context(
config=make_config_with({"uri-to-replace": "http://example.invalid/extra"}),
dst=dst,
src=src,
)
AptModule().apply(context)
# The legacy source doesn't match the pattern, so it stays enabled...
top_level = (dst / "etc/apt/sources.list").read_text(encoding="utf-8")
assert top_level == "deb http://example.invalid/legacy stable main\n"
# ...but every entry whose URI matches is disabled, regardless of format.
extra_list = (dst / "etc/apt/sources.list.d/extra.list").read_text(encoding="utf-8")
assert extra_list == "# deb http://example.invalid/extra stable main\n"
docker_sources = (dst / "etc/apt/sources.list.d/docker.sources").read_text(
encoding="utf-8"
)
assert docker_sources == (
"Types: deb\nURIs: http://example.invalid/extra\nSuites: stable\nEnabled: no"
"\n\n"
"Types: deb\nURIs: http://example.invalid/docker2\nSuites: stable\n"
) )
@@ -120,3 +194,173 @@ def test_apply_without_existing_top_level_sources_list(tmp_path) -> None:
assert not (dst / "etc/apt/sources.list").exists() assert not (dst / "etc/apt/sources.list").exists()
assert (dst / "etc/apt/sources.list.d/interdiode.sources").exists() assert (dst / "etc/apt/sources.list.d/interdiode.sources").exists()
def test_apply_uses_signed_by_file_when_no_key_content(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(
config=make_config_with({"signed-by-file": "/etc/apt/keyrings/existing.gpg"}),
dst=dst,
src=None,
)
AptModule().apply(context)
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
encoding="utf-8"
)
assert configured_source == (
"Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\n"
"Signed-By: /etc/apt/keyrings/existing.gpg\nEnabled: yes\n"
)
def test_apply_signed_by_takes_precedence_over_signed_by_file(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(
config=make_config_with(
{
"signed-by": KEY_CONTENT,
"signed-by-file": "/etc/apt/keyrings/existing.gpg",
}
),
dst=dst,
src=None,
)
AptModule().apply(context)
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
encoding="utf-8"
)
assert "Signed-By:\n -----BEGIN PGP PUBLIC KEY BLOCK-----" in configured_source
assert "existing.gpg" not in configured_source
def test_apply_without_signed_by_writes_no_field(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config_with({}), dst=dst, src=None)
AptModule().apply(context)
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
encoding="utf-8"
)
assert configured_source == (
"Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\nEnabled: yes\n"
)
def test_apply_skips_source_when_uri_to_replace_matches_nothing(tmp_path) -> None:
src = tmp_path / "src"
apt_dir = src / "etc/apt"
apt_dir.mkdir(parents=True)
(apt_dir / "sources.list").write_text(
"deb http://example.invalid/legacy stable main\n", encoding="utf-8"
)
dst = tmp_path / "dst"
context = Context(
config=make_config_with({"uri-to-replace": "http://other.invalid/*"}),
dst=dst,
src=src,
)
AptModule().apply(context)
assert not (dst / "etc/apt/sources.list.d/interdiode.sources").exists()
def test_apply_writes_source_when_uri_to_replace_matches_existing_uri(
tmp_path,
) -> None:
src = tmp_path / "src"
apt_dir = src / "etc/apt"
apt_dir.mkdir(parents=True)
(apt_dir / "sources.list").write_text(
"deb http://legacy.invalid/debian stable main\n", encoding="utf-8"
)
dst = tmp_path / "dst"
context = Context(
config=make_config_with({"uri-to-replace": "http://legacy.invalid/*"}),
dst=dst,
src=src,
)
AptModule().apply(context)
assert (dst / "etc/apt/sources.list.d/interdiode.sources").exists()
def test_apply_skips_source_when_uri_to_replace_set_without_src(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(
config=make_config_with({"uri-to-replace": "http://*"}), dst=dst, src=None
)
AptModule().apply(context)
assert not (dst / "etc/apt/sources.list.d/interdiode.sources").exists()
def test_apply_matches_uri_to_replace_across_list_line_variants(tmp_path) -> None:
src = tmp_path / "src"
apt_dir = src / "etc/apt"
sources_list_d = apt_dir / "sources.list.d"
sources_list_d.mkdir(parents=True)
(apt_dir / "sources.list").write_text(
"# a comment\n"
"\n"
"garbage line here\n"
"deb [unterminated bracket with no closing marker\n"
"deb [arch=amd64 trusted=yes] http://example.invalid/extra stable main\n",
encoding="utf-8",
)
(sources_list_d / "ignored.txt").write_text("not a source file\n", encoding="utf-8")
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"apt": {
"sources": [
{
"name": "first",
"uris": ["http://example.invalid/first"],
"suites": ["stable"],
"uri-to-replace": "http://example.invalid/extra",
},
{
"name": "second",
"uris": ["http://example.invalid/second"],
"suites": ["stable"],
"uri-to-replace": "http://example.invalid/extra",
},
],
}
},
}
dst = tmp_path / "dst"
context = Context(config=Configuration.model_validate(data), dst=dst, src=src)
AptModule().apply(context)
assert (dst / "etc/apt/sources.list.d/first.sources").exists()
assert (dst / "etc/apt/sources.list.d/second.sources").exists()
def test_apply_sanitizes_source_name_with_slash(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(
config=make_config_with({"signed-by": KEY_CONTENT}, name="../../evil"),
dst=dst,
src=None,
)
AptModule().apply(context)
source_file = dst / "etc/apt/sources.list.d/______evil.sources"
assert source_file.exists()
assert not (dst / "etc/apt/sources.list.d/evil.sources").exists()
+55
View File
@@ -54,6 +54,27 @@ def test_apply_writes_certificate_and_rebuilds_bundle(tmp_path) -> None:
assert openssl_default.readlink().as_posix() == "/etc/ssl/certs/ca-certificates.crt" assert openssl_default.readlink().as_posix() == "/etc/ssl/certs/ca-certificates.crt"
def test_apply_sanitizes_certificate_name_with_slash(tmp_path) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"certificates": [{"name": "../../evil", "content": CERT_CONTENT}]
},
}
dst = tmp_path / "dst"
context = Context(config=Configuration.model_validate(data), dst=dst, src=None)
CertificatesModule().apply(context)
local_cert = dst / "usr/local/share/ca-certificates/______evil.crt"
assert local_cert.read_bytes() == b"fake-cert-bytes"
# The "/" must never be interpreted as a path separator: without
# sanitizing, "../../evil" would have reshuffled the write up to
# usr/local/evil.crt instead of staying under ca-certificates/.
assert not (dst / "usr/local/evil.crt").exists()
def test_apply_merges_conf_listed_and_local_certificates(tmp_path) -> None: def test_apply_merges_conf_listed_and_local_certificates(tmp_path) -> None:
src = tmp_path / "src" src = tmp_path / "src"
local_certs_dir = src / "usr/local/share/ca-certificates" local_certs_dir = src / "usr/local/share/ca-certificates"
@@ -237,3 +258,37 @@ def test_apply_writes_bundle_to_rhel_style_symlinked_etccertsdir(tmp_path) -> No
# /usr/lib/ssl/cert.pem is Debian/Ubuntu-specific: not OpenSSL's default # /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. # CAfile on this (RHEL-style) system, so it must not be created.
assert not (dst / "usr/lib/ssl/cert.pem").exists() assert not (dst / "usr/lib/ssl/cert.pem").exists()
def test_apply_is_idempotent_when_src_and_dst_are_the_same_live_root(
tmp_path,
) -> None:
# Regression test: applying directly to a live root (--src / --dst /,
# as the Ansible role does) where /usr/lib/ssl/cert.pem already exists
# (as it would on any host with ca-certificates already installed) must
# not resolve that pre-existing symlink away when recreating it - doing
# so would unlink/replace the bundle it points to instead, corrupting it
# into a symlink to itself and breaking every subsequent run.
root = tmp_path / "root"
etc_ssl_certs = root / "etc/ssl/certs"
etc_ssl_certs.mkdir(parents=True)
(etc_ssl_certs / "ca-certificates.crt").write_bytes(b"preexisting-bundle-bytes")
usr_lib_ssl = root / "usr/lib/ssl"
usr_lib_ssl.mkdir(parents=True)
(usr_lib_ssl / "cert.pem").symlink_to("/etc/ssl/certs/ca-certificates.crt")
context = Context(config=make_config(True), dst=root, src=root)
for _ in range(2):
CertificatesModule().apply(context)
bundle = etc_ssl_certs / "ca-certificates.crt"
assert not bundle.is_symlink()
assert bundle.read_bytes() == b"fake-cert-bytes\n"
openssl_default = usr_lib_ssl / "cert.pem"
assert openssl_default.is_symlink()
assert (
openssl_default.readlink().as_posix()
== "/etc/ssl/certs/ca-certificates.crt"
)
+116 -1
View File
@@ -15,6 +15,12 @@ CONFIG_WITH_CERT = {
**MINIMAL_CONFIG, **MINIMAL_CONFIG,
"configurations": {"certificates": [{"name": "my-ca", "content": "fake"}]}, "configurations": {"certificates": [{"name": "my-ca", "content": "fake"}]},
} }
CONFIG_WITH_GO_REPO = {
**MINIMAL_CONFIG,
"configurations": {
"go": {"repositories": [{"name": "demo", "url": "https://example.invalid/"}]}
},
}
def write_config(tmp_path, data) -> str: def write_config(tmp_path, data) -> str:
@@ -30,10 +36,56 @@ def test_validate_valid_config(tmp_path) -> None:
assert "is valid" in result.stdout assert "is valid" in result.stdout
def test_validate_without_uuid_or_organisation(tmp_path) -> None:
config_path = write_config(tmp_path, {})
result = runner.invoke(cli.app, ["validate", "--config", config_path])
assert result.exit_code == 0
assert "<no uuid>" in result.stdout
assert "<no organisation>" in result.stdout
def test_validate_without_config_fails() -> None: def test_validate_without_config_fails() -> None:
result = runner.invoke(cli.app, ["validate"]) result = runner.invoke(cli.app, ["validate"])
assert result.exit_code != 0 assert result.exit_code != 0
assert "either --config or --config-env" in result.output assert "either --config, --config-env" in result.output
def _mock_urlopen(content: bytes) -> MagicMock:
response = MagicMock()
response.read.return_value = content
response.__enter__.return_value = response
response.__exit__.return_value = False
return MagicMock(return_value=response)
def test_validate_from_config_url(monkeypatch) -> None:
monkeypatch.setattr(
"id_configurator.config.urllib.request.urlopen",
_mock_urlopen(json.dumps(MINIMAL_CONFIG).encode("utf-8")),
)
result = runner.invoke(
cli.app, ["validate", "--config-url", "https://example.invalid/config.json"]
)
assert result.exit_code == 0
assert "is valid" in result.stdout
def test_validate_from_config_url_insecure(monkeypatch) -> None:
mock_urlopen = _mock_urlopen(json.dumps(MINIMAL_CONFIG).encode("utf-8"))
monkeypatch.setattr("id_configurator.config.urllib.request.urlopen", mock_urlopen)
result = runner.invoke(
cli.app,
[
"validate",
"--config-url",
"https://example.invalid/config.json",
"--config-url-insecure",
],
)
assert result.exit_code == 0
assert "is valid" in result.stdout
_, kwargs = mock_urlopen.call_args
assert kwargs["context"].verify_mode.name == "CERT_NONE"
def test_list_modules() -> None: def test_list_modules() -> None:
@@ -43,10 +95,13 @@ def test_list_modules() -> None:
"apk", "apk",
"apt", "apt",
"certificates", "certificates",
"go",
"java", "java",
"npm", "npm",
"nuget",
"php", "php",
"python", "python",
"ruby",
"rust", "rust",
"yum", "yum",
} }
@@ -80,6 +135,66 @@ def test_apply_runs_relevant_module(tmp_path) -> None:
assert (dst / "etc/ssl/certs/ca-certificates.crt").read_bytes() == b"fake\n" assert (dst / "etc/ssl/certs/ca-certificates.crt").read_bytes() == b"fake\n"
def test_apply_with_user_option_writes_per_user_config(tmp_path) -> None:
config_path = write_config(tmp_path, CONFIG_WITH_GO_REPO)
dst = tmp_path / "dst"
result = runner.invoke(
cli.app,
[
"apply",
"--config",
config_path,
"--dst",
str(dst),
"--user",
"alice",
"--user",
"root",
],
)
assert result.exit_code == 0
assert (dst / "home/alice/.config/go/env").read_text(encoding="utf-8") == (
"GOPROXY=https://example.invalid/\nGOSUMDB=off\n"
)
assert (dst / "root/.config/go/env").read_text(encoding="utf-8") == (
"GOPROXY=https://example.invalid/\nGOSUMDB=off\n"
)
def test_apply_with_auto_users_reads_passwd_from_src(tmp_path) -> None:
config_path = write_config(tmp_path, CONFIG_WITH_GO_REPO)
src = tmp_path / "src"
(src / "etc").mkdir(parents=True)
(src / "etc/passwd").write_text(
"root:x:0:0:root:/root:/bin/bash\n"
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n"
"daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n",
encoding="utf-8",
)
dst = tmp_path / "dst"
result = runner.invoke(
cli.app,
[
"apply",
"--config",
config_path,
"--dst",
str(dst),
"--src",
str(src),
"--auto-users",
],
)
assert result.exit_code == 0
# root is always included despite its uid 0; daemon (nologin shell) is
# a system account, excluded.
assert (dst / "root/.config/go/env").exists()
assert (dst / "home/alice/.config/go/env").exists()
assert not (dst / "usr/sbin/.config/go/env").exists()
def test_main_default_name_calls_app(monkeypatch) -> None: def test_main_default_name_calls_app(monkeypatch) -> None:
mock_app = MagicMock() mock_app = MagicMock()
monkeypatch.setattr(cli, "app", mock_app) monkeypatch.setattr(cli, "app", mock_app)
+171 -6
View File
@@ -1,7 +1,14 @@
import base64 import base64
import datetime
import json import json
from unittest.mock import MagicMock
import pytest import pytest
from cryptography import x509
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import Encoding
from cryptography.x509.oid import NameOID
from id_configurator.config import ( from id_configurator.config import (
AptSource, AptSource,
@@ -27,7 +34,6 @@ FULL_CONFIG = {
"signed-by": "/etc/apt/keyrings/interdiode.asc", "signed-by": "/etc/apt/keyrings/interdiode.asc",
} }
], ],
"keys": [{"name": "interdiode", "content": "ZmFrZQ=="}],
}, },
"yum": { "yum": {
"repositories": [ "repositories": [
@@ -69,7 +75,6 @@ def test_parse_configuration_from_json() -> None:
configuration.configurations.apt.sources[0].signed_by configuration.configurations.apt.sources[0].signed_by
== "/etc/apt/keyrings/interdiode.asc" == "/etc/apt/keyrings/interdiode.asc"
) )
assert configuration.configurations.apt.keys[0].name == "interdiode"
assert configuration.configurations.yum.keys[0].name == "interdiode" assert configuration.configurations.yum.keys[0].name == "interdiode"
@@ -80,10 +85,38 @@ def test_parse_configuration_from_yaml() -> None:
""" """
configuration = parse_configuration(raw) configuration = parse_configuration(raw)
assert configuration.organisation == "InterDiode" assert configuration.organisation == "InterDiode"
assert configuration.description == "" assert configuration.description is None
assert configuration.configurations.certificates == [] assert configuration.configurations.certificates == []
def test_parse_configuration_without_uuid_or_organisation() -> None:
configuration = parse_configuration(json.dumps({}))
assert configuration.uuid is None
assert configuration.organisation is None
assert configuration.version == "1.0"
assert configuration.description is None
def test_parse_configuration_defaults_version_to_1_0() -> None:
configuration = parse_configuration(json.dumps(FULL_CONFIG))
assert configuration.version == "1.0"
def test_parse_configuration_with_explicit_version() -> None:
configuration = parse_configuration(json.dumps({**FULL_CONFIG, "version": "1.0"}))
assert configuration.version == "1.0"
def test_parse_configuration_coerces_unquoted_yaml_version_to_str() -> None:
raw = """
version: 1.0
uuid: 12345678-1234-5678-1234-567812345678
organisation: InterDiode
"""
configuration = parse_configuration(raw)
assert configuration.version == "1.0"
def test_load_configuration_from_file(tmp_path) -> None: def test_load_configuration_from_file(tmp_path) -> None:
config_file = tmp_path / "config.json" config_file = tmp_path / "config.json"
config_file.write_text(json.dumps(FULL_CONFIG), encoding="utf-8") config_file.write_text(json.dumps(FULL_CONFIG), encoding="utf-8")
@@ -98,10 +131,107 @@ def test_load_configuration_from_env() -> None:
def test_load_configuration_without_source_raises() -> None: def test_load_configuration_without_source_raises() -> None:
with pytest.raises(ValueError, match="either --config or --config-env"): with pytest.raises(
ValueError, match="either --config, --config-env or --config-url"
):
load_configuration(None, None) load_configuration(None, None)
def _mock_urlopen(content: bytes) -> MagicMock:
response = MagicMock()
response.read.return_value = content
response.__enter__.return_value = response
response.__exit__.return_value = False
return MagicMock(return_value=response)
def _self_signed_ca_pem() -> str:
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = issuer = x509.Name(
[x509.NameAttribute(NameOID.COMMON_NAME, "test-config-url-ca")]
)
now = datetime.datetime.now(datetime.UTC)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now)
.not_valid_after(now + datetime.timedelta(days=1))
.sign(key, hashes.SHA256())
)
return cert.public_bytes(Encoding.PEM).decode("ascii")
def test_load_configuration_from_url(monkeypatch) -> None:
mock_urlopen = _mock_urlopen(json.dumps(FULL_CONFIG).encode("utf-8"))
monkeypatch.setattr("id_configurator.config.urllib.request.urlopen", mock_urlopen)
configuration = load_configuration(
None, None, "https://example.invalid/config.json"
)
assert configuration.organisation == "InterDiode"
args, kwargs = mock_urlopen.call_args
assert args[0] == "https://example.invalid/config.json"
assert kwargs["timeout"] == 30
assert kwargs["context"].verify_mode.name == "CERT_REQUIRED"
def test_load_configuration_from_url_with_custom_cacert(monkeypatch) -> None:
mock_urlopen = _mock_urlopen(json.dumps(FULL_CONFIG).encode("utf-8"))
monkeypatch.setattr("id_configurator.config.urllib.request.urlopen", mock_urlopen)
cacert_content = _self_signed_ca_pem()
configuration = load_configuration(
None, None, "https://example.invalid/config.json", cacert_content
)
assert configuration.organisation == "InterDiode"
_, kwargs = mock_urlopen.call_args
stats = kwargs["context"].cert_store_stats()
assert stats["x509"] == 1
def test_load_configuration_from_url_insecure_disables_verification(
monkeypatch,
) -> None:
mock_urlopen = _mock_urlopen(json.dumps(FULL_CONFIG).encode("utf-8"))
monkeypatch.setattr("id_configurator.config.urllib.request.urlopen", mock_urlopen)
configuration = load_configuration(
None,
None,
"https://example.invalid/config.json",
None,
True,
)
assert configuration.organisation == "InterDiode"
_, kwargs = mock_urlopen.call_args
assert kwargs["context"].verify_mode.name == "CERT_NONE"
assert kwargs["context"].check_hostname is False
def test_load_configuration_from_url_insecure_ignores_cacert(monkeypatch) -> None:
mock_urlopen = _mock_urlopen(json.dumps(FULL_CONFIG).encode("utf-8"))
monkeypatch.setattr("id_configurator.config.urllib.request.urlopen", mock_urlopen)
cacert_content = _self_signed_ca_pem()
configuration = load_configuration(
None,
None,
"https://example.invalid/config.json",
cacert_content,
True,
)
assert configuration.organisation == "InterDiode"
_, kwargs = mock_urlopen.call_args
assert kwargs["context"].verify_mode.name == "CERT_NONE"
def test_apt_source_to_deb822_minimal() -> None: def test_apt_source_to_deb822_minimal() -> None:
source = AptSource( source = AptSource(
name="interdiode", uris=["http://example.invalid/debian"], suites=["stable"] name="interdiode", uris=["http://example.invalid/debian"], suites=["stable"]
@@ -125,7 +255,6 @@ def test_apt_source_to_deb822_full() -> None:
enabled=False, enabled=False,
trusted=True, trusted=True,
**{ **{
"signed-by": "/etc/apt/keyrings/interdiode.asc",
"by-hash": True, "by-hash": True,
"allow-insecure": False, "allow-insecure": False,
"allow-weak": False, "allow-weak": False,
@@ -139,7 +268,7 @@ def test_apt_source_to_deb822_full() -> None:
pdiffs=True, pdiffs=True,
description="InterDiode APT repository", description="InterDiode APT repository",
) )
rendered = source.to_deb822() rendered = source.to_deb822(signed_by_path="/etc/apt/keyrings/interdiode.asc")
assert rendered.splitlines() == [ assert rendered.splitlines() == [
"Types: deb deb-src", "Types: deb deb-src",
"URIs: http://example.invalid/debian", "URIs: http://example.invalid/debian",
@@ -163,3 +292,39 @@ def test_apt_source_to_deb822_full() -> None:
"Description: InterDiode APT repository", "Description: InterDiode APT repository",
"Enabled: no", "Enabled: no",
] ]
def test_apt_source_to_deb822_folds_inline_signed_by_key() -> None:
source = AptSource(
name="interdiode", uris=["http://example.invalid/debian"], suites=["stable"]
)
armored_key = (
"-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
"\n"
"mQENBGqh08IBCAC0\n"
"-----END PGP PUBLIC KEY BLOCK-----\n"
)
rendered = source.to_deb822(signed_by_armored_key=armored_key)
assert rendered == (
"Types: deb\n"
"URIs: http://example.invalid/debian\n"
"Suites: stable\n"
"Signed-By:\n"
" -----BEGIN PGP PUBLIC KEY BLOCK-----\n"
" .\n"
" mQENBGqh08IBCAC0\n"
" -----END PGP PUBLIC KEY BLOCK-----\n"
"Enabled: yes\n"
)
def test_apt_source_to_deb822_signed_by_armored_key_takes_precedence() -> None:
source = AptSource(
name="interdiode", uris=["http://example.invalid/debian"], suites=["stable"]
)
rendered = source.to_deb822(
signed_by_armored_key="-----BEGIN PGP PUBLIC KEY BLOCK-----\nabc\n-----END PGP PUBLIC KEY BLOCK-----\n",
signed_by_path="/etc/apt/keyrings/unused.gpg",
)
assert "Signed-By:\n" in rendered
assert "unused.gpg" not in rendered
+132 -2
View File
@@ -8,14 +8,16 @@ CONFIG = Configuration.model_validate(
) )
def make_context(tmp_path, with_src: bool = True) -> Context: def make_context(
tmp_path, with_src: bool = True, users=None, auto_users: bool = False
) -> Context:
dst = tmp_path / "dst" dst = tmp_path / "dst"
dst.mkdir() dst.mkdir()
src = None src = None
if with_src: if with_src:
src = tmp_path / "src" src = tmp_path / "src"
src.mkdir() src.mkdir()
return Context(config=CONFIG, dst=dst, src=src) return Context(config=CONFIG, dst=dst, src=src, users=users, auto_users=auto_users)
def test_dst_path_within_destination(tmp_path) -> None: def test_dst_path_within_destination(tmp_path) -> None:
@@ -97,3 +99,131 @@ def test_glob_src_matches_pattern(tmp_path) -> None:
(context.src / "usr/lib/python-legacy").mkdir(parents=True) (context.src / "usr/lib/python-legacy").mkdir(parents=True)
result = context.glob_src("usr/lib/python3.*") result = context.glob_src("usr/lib/python3.*")
assert [entry.name for entry in result] == ["python3.11", "python3.12"] assert [entry.name for entry in result] == ["python3.11", "python3.12"]
def test_users_defaults_to_empty_list(tmp_path) -> None:
context = make_context(tmp_path, with_src=False)
assert context.users == []
def test_users_keeps_explicit_names_in_order_without_duplicates(tmp_path) -> None:
context = make_context(tmp_path, with_src=False, users=["alice", "bob", "alice"])
assert context.users == ["alice", "bob"]
def test_auto_users_expands_from_passwd_excluding_disabled_shells(tmp_path) -> None:
dst = tmp_path / "dst"
dst.mkdir()
src = tmp_path / "src"
(src / "etc").mkdir(parents=True)
(src / "etc/passwd").write_text(
"root:x:0:0:root:/root:/bin/bash\n"
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n"
"daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n",
encoding="utf-8",
)
context = Context(config=CONFIG, dst=dst, src=src, auto_users=True)
# root is always included despite its uid 0; daemon (nologin shell) is
# excluded as a system account.
assert context.users == ["root", "alice"]
def test_auto_users_excludes_root_with_disabled_shell(tmp_path) -> None:
dst = tmp_path / "dst"
dst.mkdir()
src = tmp_path / "src"
(src / "etc").mkdir(parents=True)
(src / "etc/passwd").write_text(
"root:x:0:0:root:/root:/usr/sbin/nologin\n"
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n",
encoding="utf-8",
)
context = Context(config=CONFIG, dst=dst, src=src, auto_users=True)
assert context.users == ["alice"]
def test_auto_users_excludes_uid_below_default_uid_min(tmp_path) -> None:
dst = tmp_path / "dst"
dst.mkdir()
src = tmp_path / "src"
(src / "etc").mkdir(parents=True)
(src / "etc/passwd").write_text(
"svc:x:999:999:service account:/home/svc:/bin/bash\n"
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n",
encoding="utf-8",
)
context = Context(config=CONFIG, dst=dst, src=src, auto_users=True)
assert context.users == ["alice"]
def test_auto_users_honors_uid_min_from_login_defs(tmp_path) -> None:
dst = tmp_path / "dst"
dst.mkdir()
src = tmp_path / "src"
(src / "etc").mkdir(parents=True)
(src / "etc/passwd").write_text(
"svc:x:999:999:service account:/home/svc:/bin/bash\n"
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n",
encoding="utf-8",
)
(src / "etc/login.defs").write_text("UID_MIN\t500\n", encoding="utf-8")
context = Context(config=CONFIG, dst=dst, src=src, auto_users=True)
assert context.users == ["svc", "alice"]
def test_auto_users_combines_with_explicit_names_without_duplicates(tmp_path) -> None:
dst = tmp_path / "dst"
dst.mkdir()
src = tmp_path / "src"
src.mkdir()
(src / "etc").mkdir()
(src / "etc/passwd").write_text(
"root:x:0:0:root:/root:/bin/bash\nalice:x:1000:1000:Alice:/home/alice:/bin/bash\n",
encoding="utf-8",
)
context = Context(
config=CONFIG, dst=dst, src=src, users=["bob", "alice"], auto_users=True
)
# explicit names come first, in order; auto-detected names are appended,
# skipping "alice" since it's already present.
assert context.users == ["bob", "alice", "root"]
def test_auto_users_false_ignores_passwd(tmp_path) -> None:
dst = tmp_path / "dst"
dst.mkdir()
src = tmp_path / "src"
(src / "etc").mkdir(parents=True)
(src / "etc/passwd").write_text(
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n", encoding="utf-8"
)
context = Context(config=CONFIG, dst=dst, src=src)
assert context.users == []
def test_auto_users_without_src_is_empty(tmp_path) -> None:
context = make_context(tmp_path, with_src=False, auto_users=True)
assert context.users == []
def test_home_dir_for_root(tmp_path) -> None:
context = make_context(tmp_path, with_src=False)
assert context.home_dir("root") == "root"
def test_home_dir_falls_back_to_convention(tmp_path) -> None:
context = make_context(tmp_path, with_src=False)
assert context.home_dir("alice") == "home/alice"
def test_home_dir_uses_passwd_entry_when_available(tmp_path) -> None:
dst = tmp_path / "dst"
dst.mkdir()
src = tmp_path / "src"
src.mkdir()
(src / "etc").mkdir()
(src / "etc/passwd").write_text(
"alice:x:1000:1000:Alice:/srv/alice:/bin/bash\n", encoding="utf-8"
)
context = Context(config=CONFIG, dst=dst, src=src)
assert context.home_dir("alice") == "srv/alice"
+79
View File
@@ -0,0 +1,79 @@
from id_configurator.config import Configuration
from id_configurator.context import Context
from id_configurator.modules.go import GoModule
CONFIG_WITH_REPO = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"go": {"repositories": [{"name": "demo", "url": "https://example.invalid/"}]}
},
}
CONFIG_WITH_TWO_REPOS = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"go": {
"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 GoModule().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 GoModule().is_relevant(context) is True
def test_apply_writes_goproxy(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None)
GoModule().apply(context)
go_env = (dst / "usr/local/go/go.env").read_text(encoding="utf-8")
assert go_env == "GOPROXY=https://example.invalid/\nGOSUMDB=off\n"
def test_apply_writes_go_env_for_each_configured_user(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(
config=make_config(CONFIG_WITH_REPO), dst=dst, src=None, users=["alice"]
)
GoModule().apply(context)
go_env = (dst / "home/alice/.config/go/env").read_text(encoding="utf-8")
assert go_env == "GOPROXY=https://example.invalid/\nGOSUMDB=off\n"
def test_apply_joins_multiple_repositories_with_comma(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None)
GoModule().apply(context)
go_env = (dst / "usr/local/go/go.env").read_text(encoding="utf-8")
assert go_env == (
"GOPROXY=https://example.invalid/,https://other.invalid/\nGOSUMDB=off\n"
)
+25
View File
@@ -339,6 +339,31 @@ def test_apply_writes_sbt_repositories(tmp_path) -> None:
) )
def test_apply_marks_non_https_sbt_repository_as_insecure(tmp_path) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"java": {
"repositories": [
{"name": "interdiode", "url": "http://example.invalid/maven"}
]
}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), dst=dst, src=None)
JavaModule().apply(context)
repositories = (dst / "root/.sbt/repositories").read_text(encoding="utf-8")
assert repositories == (
"[repositories]\n"
" local\n"
" interdiode: http://example.invalid/maven, allowInsecureProtocol\n"
)
def test_apply_writes_lein_profiles(tmp_path) -> None: def test_apply_writes_lein_profiles(tmp_path) -> None:
dst = tmp_path / "dst" dst = tmp_path / "dst"
context = Context(config=make_config(TWO_REPOS_CONFIG), dst=dst, src=None) context = Context(config=make_config(TWO_REPOS_CONFIG), dst=dst, src=None)
+35 -1
View File
@@ -1,5 +1,10 @@
from id_configurator.modules import base as base_module from id_configurator.modules import base as base_module
from id_configurator.modules.base import Module, all_modules, register from id_configurator.modules.base import (
Module,
all_modules,
register,
sanitize_filename,
)
def test_register_and_all_modules_are_isolated(monkeypatch) -> None: def test_register_and_all_modules_are_isolated(monkeypatch) -> None:
@@ -17,3 +22,32 @@ def test_register_and_all_modules_are_isolated(monkeypatch) -> None:
register(DummyModule) register(DummyModule)
assert all_modules() == [DummyModule] assert all_modules() == [DummyModule]
def test_sanitize_filename_leaves_plain_names_untouched() -> None:
assert sanitize_filename("interdiode") == "interdiode"
def test_sanitize_filename_replaces_forward_slash() -> None:
assert sanitize_filename("../../etc/passwd") == "______etc_passwd"
def test_sanitize_filename_replaces_backslash() -> None:
assert sanitize_filename(r"a\b") == "a_b"
def test_sanitize_filename_replaces_non_identifier_characters() -> None:
assert sanitize_filename("My Root CA (v2).pem\n") == "My_Root_CA__v2__pem_"
def test_sanitize_filename_keeps_letters_digits_underscore_and_dash() -> None:
assert sanitize_filename("Inter_Diode-2026") == "Inter_Diode-2026"
def test_sanitize_filename_never_returns_dot_names() -> None:
assert sanitize_filename(".") == "_"
assert sanitize_filename("..") == "__"
def test_sanitize_filename_never_returns_empty_string() -> None:
assert sanitize_filename("") == "_"
+62
View File
@@ -28,6 +28,14 @@ EMPTY_CONFIG = {
"organisation": "InterDiode", "organisation": "InterDiode",
} }
CONFIG_WITH_CERTIFICATE_ONLY = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"certificates": [{"name": "interdiode-ca", "content": "-----BEGIN...-----"}]
},
}
def make_config(data: dict) -> Configuration: def make_config(data: dict) -> Configuration:
return Configuration.model_validate(data) return Configuration.model_validate(data)
@@ -38,6 +46,13 @@ def test_is_relevant_false_when_no_repositories(tmp_path) -> None:
assert NpmModule().is_relevant(context) is False assert NpmModule().is_relevant(context) is False
def test_is_relevant_true_with_certificate_only(tmp_path) -> None:
context = Context(
config=make_config(CONFIG_WITH_CERTIFICATE_ONLY), dst=tmp_path / "dst", src=None
)
assert NpmModule().is_relevant(context) is True
def test_is_relevant_true_with_repository(tmp_path) -> None: def test_is_relevant_true_with_repository(tmp_path) -> None:
context = Context( context = Context(
config=make_config(CONFIG_WITH_REPO), dst=tmp_path / "dst", src=None config=make_config(CONFIG_WITH_REPO), dst=tmp_path / "dst", src=None
@@ -69,3 +84,50 @@ def test_apply_writes_additional_repositories_scoped(tmp_path) -> None:
"registry=https://example.invalid/\n" "registry=https://example.invalid/\n"
"@myorg:registry=https://other.invalid/\n" "@myorg:registry=https://other.invalid/\n"
) )
def test_apply_without_certificates_does_not_write_etc_environment(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None)
NpmModule().apply(context)
assert not (dst / "etc/environment").exists()
def test_apply_sets_node_extra_ca_certs_when_certificates_configured(
tmp_path,
) -> None:
dst = tmp_path / "dst"
context = Context(
config=make_config(CONFIG_WITH_CERTIFICATE_ONLY), dst=dst, src=None
)
NpmModule().apply(context)
assert not (dst / "usr/local/etc/npmrc").exists()
environment = (dst / "etc/environment").read_text(encoding="utf-8")
assert environment == "NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt\n"
def test_apply_merges_node_extra_ca_certs_into_existing_etc_environment(
tmp_path,
) -> None:
src = tmp_path / "src"
src.mkdir()
(src / "etc").mkdir()
(src / "etc/environment").write_text(
"PATH=/usr/bin\nNODE_EXTRA_CA_CERTS=/old/stale.pem\n", encoding="utf-8"
)
dst = tmp_path / "dst"
context = Context(
config=make_config(CONFIG_WITH_CERTIFICATE_ONLY), dst=dst, src=src
)
NpmModule().apply(context)
environment = (dst / "etc/environment").read_text(encoding="utf-8")
assert environment == (
"PATH=/usr/bin\nNODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt\n"
)
+124
View File
@@ -0,0 +1,124 @@
from id_configurator.config import Configuration
from id_configurator.context import Context
from id_configurator.modules.nuget import NuGetModule
CONFIG_WITH_REPO = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"nuget": {"repositories": [{"name": "demo", "url": "https://example.invalid/"}]}
},
}
CONFIG_WITH_TWO_REPOS = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"nuget": {
"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 NuGetModule().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 NuGetModule().is_relevant(context) is True
def test_apply_writes_nuget_config(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None)
NuGetModule().apply(context)
content = (dst / "root/.nuget/NuGet/NuGet.Config").read_text(encoding="utf-8")
assert content == (
'<?xml version="1.0" encoding="utf-8"?>\n'
"<configuration>\n"
" <packageSources>\n"
" <clear />\n"
' <add key="demo" value="https://example.invalid/" />\n'
" </packageSources>\n"
"</configuration>\n"
)
def test_apply_marks_non_https_source_as_insecure(tmp_path) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"nuget": {
"repositories": [{"name": "demo", "url": "http://example.invalid/"}]
}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), dst=dst, src=None)
NuGetModule().apply(context)
content = (dst / "root/.nuget/NuGet/NuGet.Config").read_text(encoding="utf-8")
assert content == (
'<?xml version="1.0" encoding="utf-8"?>\n'
"<configuration>\n"
" <packageSources>\n"
" <clear />\n"
' <add key="demo" value="http://example.invalid/" allowInsecureConnections="true" />\n'
" </packageSources>\n"
"</configuration>\n"
)
def test_apply_writes_config_for_each_configured_user(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(
config=make_config(CONFIG_WITH_REPO), dst=dst, src=None, users=["alice", "root"]
)
NuGetModule().apply(context)
root_content = (dst / "root/.nuget/NuGet/NuGet.Config").read_text(encoding="utf-8")
alice_content = (dst / "home/alice/.nuget/NuGet/NuGet.Config").read_text(
encoding="utf-8"
)
assert alice_content == root_content
def test_apply_writes_multiple_sources_in_order(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None)
NuGetModule().apply(context)
content = (dst / "root/.nuget/NuGet/NuGet.Config").read_text(encoding="utf-8")
assert content == (
'<?xml version="1.0" encoding="utf-8"?>\n'
"<configuration>\n"
" <packageSources>\n"
" <clear />\n"
' <add key="demo" value="https://example.invalid/" />\n'
' <add key="other" value="https://other.invalid/" />\n'
" </packageSources>\n"
"</configuration>\n"
)
+105 -2
View File
@@ -34,6 +34,14 @@ EMPTY_CONFIG = {
"organisation": "InterDiode", "organisation": "InterDiode",
} }
CONFIG_WITH_CERTIFICATE_ONLY = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"certificates": [{"name": "interdiode-ca", "content": "-----BEGIN...-----"}]
},
}
def make_config(data: dict) -> Configuration: def make_config(data: dict) -> Configuration:
return Configuration.model_validate(data) return Configuration.model_validate(data)
@@ -44,6 +52,13 @@ def test_is_relevant_false_when_no_repositories(tmp_path) -> None:
assert PhpModule().is_relevant(context) is False assert PhpModule().is_relevant(context) is False
def test_is_relevant_true_with_certificate_only(tmp_path) -> None:
context = Context(
config=make_config(CONFIG_WITH_CERTIFICATE_ONLY), dst=tmp_path / "dst", src=None
)
assert PhpModule().is_relevant(context) is True
def test_is_relevant_true_with_repository(tmp_path) -> None: def test_is_relevant_true_with_repository(tmp_path) -> None:
context = Context( context = Context(
config=make_config(CONFIG_WITH_REPO), dst=tmp_path / "dst", src=None config=make_config(CONFIG_WITH_REPO), dst=tmp_path / "dst", src=None
@@ -65,7 +80,8 @@ def test_apply_writes_global_composer_config(tmp_path) -> None:
"name": "demo", "name": "demo",
"type": "composer", "type": "composer",
"url": "https://example.invalid/composer", "url": "https://example.invalid/composer",
} },
{"packagist.org": False},
] ]
} }
@@ -78,4 +94,91 @@ def test_apply_writes_multiple_repositories_in_order(tmp_path) -> None:
config_path = dst / "root/.composer/config.json" config_path = dst / "root/.composer/config.json"
content = json.loads(config_path.read_text(encoding="utf-8")) content = json.loads(config_path.read_text(encoding="utf-8"))
assert [repo["name"] for repo in content["repositories"]] == ["demo", "other"] names = [repo["name"] for repo in content["repositories"] if "name" in repo]
assert names == ["demo", "other"]
assert {"packagist.org": False} in content["repositories"]
def test_apply_disables_secure_http_and_packagist_when_repo_not_https(
tmp_path,
) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"php": {
"repositories": [
{"name": "demo", "url": "https://example.invalid/composer"},
{"name": "insecure", "url": "http://other.invalid/composer"},
]
}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), 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["config"] == {"secure-http": False}
assert {"packagist.org": False} in content["repositories"]
def test_apply_does_not_disable_secure_http_when_all_repos_https(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 "config" not in content
def test_apply_always_disables_packagist(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 {"packagist.org": False} in content["repositories"]
def test_apply_writes_composer_config_for_each_configured_user(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(
config=make_config(CONFIG_WITH_REPO), dst=dst, src=None, users=["alice"]
)
PhpModule().apply(context)
root_content = (dst / "root/.composer/config.json").read_bytes()
alice_content = (dst / "home/alice/.composer/config.json").read_bytes()
assert alice_content == root_content
def test_apply_without_certificates_does_not_write_openssl_ini(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None)
PhpModule().apply(context)
assert not (dst / "usr/local/etc/php/conf.d/id-configurator-openssl.ini").exists()
def test_apply_sets_openssl_cafile_when_certificates_configured(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(
config=make_config(CONFIG_WITH_CERTIFICATE_ONLY), dst=dst, src=None
)
PhpModule().apply(context)
assert not (dst / "root/.composer/config.json").exists()
ini_content = (
dst / "usr/local/etc/php/conf.d/id-configurator-openssl.ini"
).read_text(encoding="utf-8")
assert ini_content == "openssl.cafile=/etc/ssl/certs/ca-certificates.crt\n"
+158
View File
@@ -1,3 +1,5 @@
import tomllib
from id_configurator.config import Configuration from id_configurator.config import Configuration
from id_configurator.context import Context from id_configurator.context import Context
from id_configurator.modules.python import PythonModule from id_configurator.modules.python import PythonModule
@@ -76,6 +78,111 @@ def test_apply_single_repository(tmp_path) -> None:
assert not (dst / "usr/lib").exists() assert not (dst / "usr/lib").exists()
def test_apply_sanitizes_uv_index_name_with_non_bmp_characters(tmp_path) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"python": {
"repositories": [
{"name": "demo 😀", "index-url": "https://example.invalid/simple"}
]
}
},
}
config = Configuration.model_validate(data)
dst = tmp_path / "dst"
context = Context(config=config, dst=dst, src=None)
PythonModule().apply(context)
uv_toml = (dst / "etc/uv/uv.toml").read_text(encoding="utf-8")
# The emoji isn't a plain identifier character, so sanitize_repository_name
# replaces it (and the space before it) with "_", same as every other
# non-ASCII or syntax-breaking character.
assert '"demo__"' in uv_toml
parsed = tomllib.loads(uv_toml)
assert parsed["index"][0]["name"] == "demo__"
def test_apply_adds_trusted_host_for_non_https_repository(tmp_path) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"python": {
"repositories": [
{"name": "interdiode", "index-url": "http://example.invalid/simple"}
]
}
},
}
config = Configuration.model_validate(data)
dst = tmp_path / "dst"
context = Context(config=config, dst=dst, src=None)
PythonModule().apply(context)
pip_conf = (dst / "etc/pip.conf").read_text(encoding="utf-8")
assert pip_conf == (
"[global]\n"
"index-url = http://example.invalid/simple\n"
"trusted-host =\n"
" example.invalid\n"
"cert = /etc/ssl/certs/ca-certificates.crt\n"
)
def test_apply_adds_trusted_host_only_for_non_https_among_mixed_repositories(
tmp_path,
) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"python": {
"repositories": [
{
"name": "interdiode",
"index-url": "https://example.invalid/simple",
},
{
"name": "insecure",
"index-url": "http://insecure.invalid/simple",
},
]
}
},
}
config = Configuration.model_validate(data)
dst = tmp_path / "dst"
context = Context(config=config, dst=dst, src=None)
PythonModule().apply(context)
pip_conf = (dst / "etc/pip.conf").read_text(encoding="utf-8")
assert pip_conf == (
"[global]\n"
"index-url = https://example.invalid/simple\n"
"extra-index-url =\n"
" http://insecure.invalid/simple\n"
"trusted-host =\n"
" insecure.invalid\n"
"cert = /etc/ssl/certs/ca-certificates.crt\n"
)
def test_apply_does_not_add_trusted_host_when_all_repositories_https(tmp_path) -> None:
config = Configuration.model_validate(ONE_REPO_CONFIG)
dst = tmp_path / "dst"
context = Context(config=config, dst=dst, src=None)
PythonModule().apply(context)
pip_conf = (dst / "etc/pip.conf").read_text(encoding="utf-8")
assert "trusted-host" not in pip_conf
def test_apply_multiple_repositories(tmp_path) -> None: def test_apply_multiple_repositories(tmp_path) -> None:
config = Configuration.model_validate(TWO_REPOS_CONFIG) config = Configuration.model_validate(TWO_REPOS_CONFIG)
dst = tmp_path / "dst" dst = tmp_path / "dst"
@@ -105,6 +212,57 @@ def test_apply_multiple_repositories(tmp_path) -> None:
) )
def test_apply_sanitizes_poetry_repository_name_with_special_characters(
tmp_path,
) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"python": {
"repositories": [
{
"name": "weird.name]here[bad",
"index-url": "https://example.invalid/simple",
}
]
}
},
}
config = Configuration.model_validate(data)
dst = tmp_path / "dst"
context = Context(config=config, dst=dst, src=None)
PythonModule().apply(context)
poetry_config = (dst / "root/.config/pypoetry/config.toml").read_text(
encoding="utf-8"
)
assert poetry_config == (
"[repositories.weird_name_here_bad]\n"
'url = "https://example.invalid/simple"\n'
"\n"
"[certificates.weird_name_here_bad]\n"
'cert = "/etc/ssl/certs/ca-certificates.crt"\n'
)
def test_apply_writes_poetry_config_for_each_configured_user(tmp_path) -> None:
config = Configuration.model_validate(ONE_REPO_CONFIG)
dst = tmp_path / "dst"
context = Context(config=config, dst=dst, src=None, users=["alice"])
PythonModule().apply(context)
root_content = (dst / "root/.config/pypoetry/config.toml").read_text(
encoding="utf-8"
)
alice_content = (dst / "home/alice/.config/pypoetry/config.toml").read_text(
encoding="utf-8"
)
assert alice_content == root_content
def test_apply_writes_distutils_cfg_for_detected_python_prefixes(tmp_path) -> None: def test_apply_writes_distutils_cfg_for_detected_python_prefixes(tmp_path) -> None:
config = Configuration.model_validate(ONE_REPO_CONFIG) config = Configuration.model_validate(ONE_REPO_CONFIG)
src = tmp_path / "src" src = tmp_path / "src"
+67
View File
@@ -0,0 +1,67 @@
from id_configurator.config import Configuration
from id_configurator.context import Context
from id_configurator.modules.ruby import RubyModule
CONFIG_WITH_REPO = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"ruby": {"repositories": [{"name": "demo", "url": "https://example.invalid/"}]}
},
}
CONFIG_WITH_TWO_REPOS = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"ruby": {
"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 RubyModule().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 RubyModule().is_relevant(context) is True
def test_apply_writes_gemrc(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None)
RubyModule().apply(context)
expected = ":sources:\n- https://example.invalid/\n"
assert (dst / "usr/local/etc/gemrc").read_text(encoding="utf-8") == expected
assert (dst / "etc/gemrc").read_text(encoding="utf-8") == expected
def test_apply_writes_multiple_sources_in_order(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None)
RubyModule().apply(context)
expected = ":sources:\n- https://example.invalid/\n- https://other.invalid/\n"
assert (dst / "usr/local/etc/gemrc").read_text(encoding="utf-8") == expected
assert (dst / "etc/gemrc").read_text(encoding="utf-8") == expected
+104 -3
View File
@@ -6,7 +6,15 @@ CONFIG_WITH_REPO = {
"uuid": "12345678-1234-5678-1234-567812345678", "uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode", "organisation": "InterDiode",
"configurations": { "configurations": {
"rust": {"repositories": [{"name": "demo", "url": "https://example.invalid/"}]} "rust": {
"repositories": [
{
"name": "demo",
"url": "https://example.invalid/",
"is_sparse": True,
}
]
}
}, },
} }
@@ -16,8 +24,16 @@ CONFIG_WITH_TWO_REPOS = {
"configurations": { "configurations": {
"rust": { "rust": {
"repositories": [ "repositories": [
{"name": "demo", "url": "https://example.invalid/"}, {
{"name": "other", "url": "https://other.invalid/"}, "name": "demo",
"url": "https://example.invalid/",
"is_sparse": True,
},
{
"name": "other",
"url": "https://other.invalid/",
"is_sparse": True,
},
] ]
} }
}, },
@@ -61,6 +77,91 @@ def test_apply_writes_replacement_source(tmp_path) -> None:
) )
def test_apply_sanitizes_repository_names_with_special_characters(tmp_path) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"rust": {
"repositories": [
{
"name": "weird.demo]\nname",
"url": "https://example.invalid/",
"is_sparse": True,
}
]
}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), 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 = "weird_demo__name"\n'
"\n"
"[source.weird_demo__name]\n"
'registry = "sparse+https://example.invalid/"\n'
)
def test_apply_does_not_prefix_url_when_not_sparse(tmp_path) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"rust": {
"repositories": [{"name": "demo", "url": "https://example.invalid/"}]
}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), 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 = "https://example.invalid/"\n'
)
def test_apply_prefixes_url_with_sparse_when_configured(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_cargo_config_for_each_configured_user(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(
config=make_config(CONFIG_WITH_REPO), dst=dst, src=None, users=["alice"]
)
RustModule().apply(context)
root_content = (dst / ".cargo/config.toml").read_text(encoding="utf-8")
alice_content = (dst / "home/alice/.cargo/config.toml").read_text(encoding="utf-8")
assert alice_content == root_content
def test_apply_writes_additional_repositories_as_named_registries(tmp_path) -> None: def test_apply_writes_additional_repositories_as_named_registries(tmp_path) -> None:
dst = tmp_path / "dst" dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None) context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None)
+101
View File
@@ -0,0 +1,101 @@
from id_configurator.users import PasswdEntry, parse_login_defs_uid_min, parse_passwd
PASSWD_CONTENT = (
"root:x:0:0:root:/root:/bin/bash\n"
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n"
"daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n"
"sync:x:4:65534:sync:/bin:/bin/sync\n"
"nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/false\n"
"# a comment\n"
"\n"
)
def test_parse_passwd_returns_one_entry_per_line() -> None:
entries = parse_passwd(PASSWD_CONTENT)
assert [entry.name for entry in entries] == [
"root",
"alice",
"daemon",
"sync",
"nobody",
]
def test_parse_passwd_skips_comments_and_blank_lines() -> None:
entries = parse_passwd(PASSWD_CONTENT)
assert len(entries) == 5
def test_parse_passwd_extracts_uid_home_and_shell() -> None:
entries = {entry.name: entry for entry in parse_passwd(PASSWD_CONTENT)}
assert entries["alice"].uid == 1000
assert entries["alice"].home == "/home/alice"
assert entries["alice"].shell == "/bin/bash"
def test_parse_passwd_skips_malformed_lines() -> None:
entries = parse_passwd(
"tooshort:x:0\nnotanumber:x:abc:0:x:/home/x:/bin/bash\n"
"root:x:0:0:root:/root:/bin/bash\n"
)
assert [entry.name for entry in entries] == ["root"]
def test_has_login_shell_true_for_regular_shell() -> None:
entry = PasswdEntry(name="alice", uid=1000, home="/home/alice", shell="/bin/bash")
assert entry.has_login_shell is True
def test_has_login_shell_false_for_nologin_sync_or_false() -> None:
nologin = PasswdEntry(
name="daemon", uid=1, home="/usr/sbin", shell="/usr/sbin/nologin"
)
sync = PasswdEntry(name="sync", uid=4, home="/bin", shell="/bin/sync")
false_shell = PasswdEntry(
name="nobody", uid=65534, home="/nonexistent", shell="/usr/sbin/false"
)
assert nologin.has_login_shell is False
assert sync.has_login_shell is False
assert false_shell.has_login_shell is False
def test_is_regular_user_true_for_human_account() -> None:
entry = PasswdEntry(name="alice", uid=1000, home="/home/alice", shell="/bin/bash")
assert entry.is_regular_user() is True
def test_is_regular_user_true_for_root_despite_uid_zero() -> None:
entry = PasswdEntry(name="root", uid=0, home="/root", shell="/bin/bash")
assert entry.is_regular_user() is True
def test_is_regular_user_false_for_root_with_disabled_shell() -> None:
entry = PasswdEntry(name="root", uid=0, home="/root", shell="/usr/sbin/nologin")
assert entry.is_regular_user() is False
def test_is_regular_user_false_for_disabled_shell_despite_high_uid() -> None:
entry = PasswdEntry(
name="nobody", uid=65534, home="/nonexistent", shell="/usr/sbin/false"
)
assert entry.is_regular_user() is False
def test_is_regular_user_honors_custom_uid_min() -> None:
entry = PasswdEntry(name="svc", uid=500, home="/home/svc", shell="/bin/bash")
assert entry.is_regular_user(uid_min=1000) is False
assert entry.is_regular_user(uid_min=100) is True
def test_parse_login_defs_uid_min_found() -> None:
content = "# comment\nUID_MIN\t500\nGID_MIN\t500\n"
assert parse_login_defs_uid_min(content) == 500
def test_parse_login_defs_uid_min_missing() -> None:
assert parse_login_defs_uid_min("GID_MIN\t500\n") is None
def test_parse_login_defs_uid_min_malformed_value() -> None:
assert parse_login_defs_uid_min("UID_MIN\tnot-a-number\n") is None
+206 -7
View File
@@ -126,7 +126,9 @@ def test_apply_unsigned_repo_disables_gpgcheck(tmp_path) -> None:
) )
def test_apply_disables_existing_repos_before_adding_configured_ones(tmp_path) -> None: def test_apply_copies_existing_repos_unchanged_without_index_url_to_replace(
tmp_path,
) -> None:
src = tmp_path / "src" src = tmp_path / "src"
repos_dir = src / "etc/yum.repos.d" repos_dir = src / "etc/yum.repos.d"
repos_dir.mkdir(parents=True) repos_dir.mkdir(parents=True)
@@ -148,13 +150,13 @@ def test_apply_disables_existing_repos_before_adding_configured_ones(tmp_path) -
YumModule().apply(context) YumModule().apply(context)
disabled_multi = (dst / "etc/yum.repos.d/multi.repo").read_text(encoding="utf-8") # No configured repository declares an index_url_to_replace, so the
assert "[old-one]" in disabled_multi # existing sections are copied through unchanged, still enabled.
assert "[old-two]" in disabled_multi copied_multi = (dst / "etc/yum.repos.d/multi.repo").read_text(encoding="utf-8")
parser = ConfigParser(interpolation=None) parser = ConfigParser(interpolation=None)
parser.read_string(disabled_multi) parser.read_string(copied_multi)
assert parser["old-one"]["enabled"] == "0" assert parser["old-one"]["enabled"] == "1"
assert parser["old-two"]["enabled"] == "0" assert parser["old-two"]["enabled"] == "1"
assert not (dst / "etc/yum.repos.d/ignored.txt").exists() assert not (dst / "etc/yum.repos.d/ignored.txt").exists()
@@ -165,6 +167,203 @@ def test_apply_disables_existing_repos_before_adding_configured_ones(tmp_path) -
) )
def test_apply_disables_only_existing_sections_matching_index_url_to_replace(
tmp_path,
) -> None:
src = tmp_path / "src"
repos_dir = src / "etc/yum.repos.d"
repos_dir.mkdir(parents=True)
(repos_dir / "multi.repo").write_text(
"[old-one]\nname=old-one\nbaseurl=https://old-one.invalid\nenabled=1\ngpgcheck=1\n"
"\n"
"[old-two]\nname=old-two\nbaseurl=https://old-two.invalid\nenabled=1\n",
encoding="utf-8",
)
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"yum": {
"repositories": [
{
"name": "unsigned",
"index-url": "https://example.invalid/yum",
"index-url-to-replace": "https://old-one.invalid",
}
]
}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), dst=dst, src=src)
YumModule().apply(context)
disabled_multi = (dst / "etc/yum.repos.d/multi.repo").read_text(encoding="utf-8")
parser = ConfigParser(interpolation=None)
parser.read_string(disabled_multi)
assert parser["old-one"]["enabled"] == "0"
assert parser["old-two"]["enabled"] == "1"
def test_apply_skips_repository_when_index_url_to_replace_matches_nothing(
tmp_path,
) -> None:
src = tmp_path / "src"
repos_dir = src / "etc/yum.repos.d"
repos_dir.mkdir(parents=True)
(repos_dir / "old.repo").write_text(
"[old]\nname=old\nbaseurl=https://old.invalid\nenabled=1\n", encoding="utf-8"
)
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"yum": {
"repositories": [
{
"name": "unsigned",
"index-url": "https://example.invalid/yum",
"index-url-to-replace": "https://other.invalid/*",
}
]
}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), dst=dst, src=src)
YumModule().apply(context)
assert not (dst / "etc/yum.repos.d/unsigned.repo").exists()
def test_apply_writes_repository_when_index_url_to_replace_matches_existing(
tmp_path,
) -> None:
src = tmp_path / "src"
repos_dir = src / "etc/yum.repos.d"
repos_dir.mkdir(parents=True)
(repos_dir / "old.repo").write_text(
"[old]\nname=old\nbaseurl=https://old.invalid/yum\nenabled=1\n",
encoding="utf-8",
)
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"yum": {
"repositories": [
{
"name": "unsigned",
"index-url": "https://example.invalid/yum",
"index-url-to-replace": "https://old.invalid/*",
}
]
}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), dst=dst, src=src)
YumModule().apply(context)
assert (dst / "etc/yum.repos.d/unsigned.repo").exists()
def test_apply_matches_index_url_to_replace_with_unrelated_files_and_multiple_repos(
tmp_path,
) -> None:
src = tmp_path / "src"
repos_dir = src / "etc/yum.repos.d"
repos_dir.mkdir(parents=True)
(repos_dir / "old.repo").write_text(
"[old]\nname=old\nbaseurl=https://old.invalid/yum\nenabled=1\n",
encoding="utf-8",
)
(repos_dir / "ignored.txt").write_text("not a repo file\n", encoding="utf-8")
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"yum": {
"repositories": [
{
"name": "first",
"index-url": "https://example.invalid/first",
"index-url-to-replace": "https://old.invalid/*",
},
{
"name": "second",
"index-url": "https://example.invalid/second",
"index-url-to-replace": "https://old.invalid/*",
},
]
}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), dst=dst, src=src)
YumModule().apply(context)
assert (dst / "etc/yum.repos.d/first.repo").exists()
assert (dst / "etc/yum.repos.d/second.repo").exists()
def test_apply_sanitizes_key_name_with_slash(tmp_path, monkeypatch) -> None:
mock_run = _mock_gpg_success(monkeypatch)
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"yum": {"keys": [{"name": "../../evil-key", "content": KEY_CONTENT}]}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), dst=dst, src=None)
YumModule().apply(context)
key_content = (dst / "etc/pki/rpm-gpg/RPM-GPG-KEY-______evil-key").read_bytes()
assert key_content == b"fake-rpm-gpg-key-bytes"
mock_run.assert_called_once()
def test_apply_sanitizes_repository_name_with_disallowed_characters(tmp_path) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"yum": {
"repositories": [
{
"name": "weird/repo]here[bad",
"index-url": "https://example.invalid/yum",
}
]
}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), dst=dst, src=None)
YumModule().apply(context)
repo_file = dst / "etc/yum.repos.d/weird_repo_here_bad.repo"
assert repo_file.exists()
repo_content = repo_file.read_text(encoding="utf-8")
assert repo_content == (
"[weird_repo_here_bad]\n"
"name=weird_repo_here_bad\n"
"baseurl=https://example.invalid/yum\n"
"enabled=1\n"
"gpgcheck=0\n"
)
def test_import_key_raises_when_gpg_binary_missing(tmp_path, monkeypatch) -> None: def test_import_key_raises_when_gpg_binary_missing(tmp_path, monkeypatch) -> None:
monkeypatch.setattr( monkeypatch.setattr(
"id_configurator.modules.yum.subprocess.run", "id_configurator.modules.yum.subprocess.run",
+410 -5
View File
@@ -1,5 +1,5 @@
[tox] [tox]
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 env_list = py313,py314,coverage,packaging,packaging-amd64,packaging-arm64,publish,module-python,module-python-http,module-apt,module-apt-http,module-yum,module-yum-http,module-apk,module-apk-http,module-java,module-java-http,module-certificates,module-php,module-php-http,module-npm,module-npm-http,module-rust,module-rust-http,module-go,module-go-http,module-ruby,module-ruby-http,module-nuget,module-nuget-http,config-url
isolated_build = true isolated_build = true
requires = requires =
tox>=4 tox>=4
@@ -74,20 +74,24 @@ description =
The version is taken from the pyproject.toml file. The version is taken from the pyproject.toml file.
Make sure that you have the rights to upload the image to Docker Hub. Make sure that you have the rights to upload the image to Docker Hub.
commands = commands =
python3 -m uv sync --all-groups --active python3 -m uv sync --all-groups -U --active
python3 -m id_tools build docker --platform linux/amd64,linux/arm64/v8 --push python3 -m id_tools build docker --platform linux/amd64,linux/arm64/v8 --push
python3 -m id_tools publication sign-docker python3 -m id_tools publication sign-docker
python3 -m uv build
python3 -m uv publish
docker = docker =
passenv = passenv =
COSIGN_PASSWORD COSIGN_PASSWORD
COSIGN_PRIVATE_KEY COSIGN_PRIVATE_KEY
DOCKER_HUB_LOGIN DOCKER_HUB_LOGIN
DOCKER_HUB_TOKEN DOCKER_HUB_TOKEN
UV_PUBLISH_TOKEN
[docker:pypi-repo] [docker:pypi-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/pypi-repo.Dockerfile dockerfile = {tox_root}/tests/dockerfiles/repos/pypi-repo.Dockerfile
expose = expose =
PYPI_REPO_PORT=8443/tcp PYPI_REPO_PORT=8443/tcp
PYPI_REPO_HTTP_PORT=8080/tcp
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/simple/idconfiguratortestpkg/ || exit 1 healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/simple/idconfiguratortestpkg/ || exit 1
healthcheck_interval = 1 healthcheck_interval = 1
healthcheck_retries = 30 healthcheck_retries = 30
@@ -96,23 +100,52 @@ healthcheck_start_period = 1
[testenv:module-python] [testenv:module-python]
package = skip package = skip
labels = end-to-end
change_dir = {tox_root} change_dir = {tox_root}
dependency_groups = dev dependency_groups = dev
docker = docker =
pypi-repo pypi-repo
allowlist_externals = allowlist_externals =
docker docker
set_env =
PYPI_REPO_SCHEME = https
commands = commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Python.yaml.j2 {env_dir}/configs/Python.yaml 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-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 buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg PYPI_REPO_SCHEME={env:PYPI_REPO_SCHEME} --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 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 buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg PYPI_REPO_SCHEME={env:PYPI_REPO_SCHEME} --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 rmi id-configurator-python
commands_post =
docker buildx prune --all --force
[testenv:module-python-http]
package = skip
labels = end-to-end
change_dir = {tox_root}
dependency_groups = dev
docker =
pypi-repo
allowlist_externals =
docker
description =
Same as module-python, but against a plain-HTTP index, to verify pip's
trusted-host is configured correctly for a non-HTTPS repository.
set_env =
PYPI_REPO_SCHEME = http
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-arg PYPI_REPO_SCHEME={env:PYPI_REPO_SCHEME} --build-arg PYPI_REPO_PORT={env:PYPI_REPO_HTTP_PORT} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Python-alpine.Dockerfile -t id-configurator-python-http .
docker rmi id-configurator-python-http
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg PYPI_REPO_SCHEME={env:PYPI_REPO_SCHEME} --build-arg PYPI_REPO_PORT={env:PYPI_REPO_HTTP_PORT} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Python.Dockerfile -t id-configurator-python-http .
docker rmi id-configurator-python-http
commands_post =
docker buildx prune --all --force
[docker:apt-repo] [docker:apt-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/apt-repo.Dockerfile dockerfile = {tox_root}/tests/dockerfiles/repos/apt-repo.Dockerfile
expose = expose =
APT_REPO_PORT=8443/tcp APT_REPO_PORT=8443/tcp
APT_REPO_HTTP_PORT=8080/tcp
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/Packages || exit 1 healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/Packages || exit 1
healthcheck_interval = 1 healthcheck_interval = 1
healthcheck_retries = 30 healthcheck_retries = 30
@@ -121,21 +154,48 @@ healthcheck_start_period = 1
[testenv:module-apt] [testenv:module-apt]
package = skip package = skip
labels = end-to-end
change_dir = {tox_root} change_dir = {tox_root}
dependency_groups = dev dependency_groups = dev
docker = docker =
apt-repo apt-repo
allowlist_externals = allowlist_externals =
docker docker
set_env =
APT_REPO_SCHEME = https
commands = commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Apt.yaml.j2 {env_dir}/configs/Apt.yaml 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 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 rmi id-configurator-apt
commands_post =
docker buildx prune --all --force
[testenv:module-apt-http]
package = skip
labels = end-to-end
change_dir = {tox_root}
dependency_groups = dev
docker =
apt-repo
allowlist_externals =
docker
description =
Same as module-apt, but against a plain-HTTP source, since apt's trust
model is the signed-by keyring, independent of transport scheme.
set_env =
APT_REPO_SCHEME = http
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-http .
docker rmi id-configurator-apt-http
commands_post =
docker buildx prune --all --force
[docker:yum-repo] [docker:yum-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/yum-repo.Dockerfile dockerfile = {tox_root}/tests/dockerfiles/repos/yum-repo.Dockerfile
expose = expose =
YUM_REPO_PORT=8443/tcp YUM_REPO_PORT=8443/tcp
YUM_REPO_HTTP_PORT=8080/tcp
healthcheck_cmd = wget --no-check-certificate -qO- https://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_interval = 1
healthcheck_retries = 30 healthcheck_retries = 30
@@ -144,21 +204,48 @@ healthcheck_start_period = 1
[testenv:module-yum] [testenv:module-yum]
package = skip package = skip
labels = end-to-end
change_dir = {tox_root} change_dir = {tox_root}
dependency_groups = dev dependency_groups = dev
docker = docker =
yum-repo yum-repo
allowlist_externals = allowlist_externals =
docker docker
set_env =
YUM_REPO_SCHEME = https
commands = commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Yum.yaml.j2 {env_dir}/configs/Yum.yaml 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 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 rmi id-configurator-yum
commands_post =
docker buildx prune --all --force
[testenv:module-yum-http]
package = skip
labels = end-to-end
change_dir = {tox_root}
dependency_groups = dev
docker =
yum-repo
allowlist_externals =
docker
description =
Same as module-yum, but against a plain-HTTP repository, since yum/dnf's
trust model is the imported GPG key, independent of transport scheme.
set_env =
YUM_REPO_SCHEME = http
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-http .
docker rmi id-configurator-yum-http
commands_post =
docker buildx prune --all --force
[docker:apk-repo] [docker:apk-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/apk-repo.Dockerfile dockerfile = {tox_root}/tests/dockerfiles/repos/apk-repo.Dockerfile
expose = expose =
APK_REPO_PORT=8443/tcp APK_REPO_PORT=8443/tcp
APK_REPO_HTTP_PORT=8080/tcp
healthcheck_cmd = wget --no-check-certificate -qO- https://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_interval = 1
healthcheck_retries = 30 healthcheck_retries = 30
@@ -167,6 +254,7 @@ healthcheck_start_period = 1
[testenv:module-apk] [testenv:module-apk]
package = skip package = skip
labels = end-to-end
change_dir = {tox_root} change_dir = {tox_root}
dependency_groups = dev dependency_groups = dev
docker = docker =
@@ -182,15 +270,43 @@ description =
native platform keeps them in agreement everywhere (including under native platform keeps them in agreement everywhere (including under
QEMU emulation on non-amd64 hosts); CI runners are amd64, so this still QEMU emulation on non-amd64 hosts); CI runners are amd64, so this still
matches the other envs there. matches the other envs there.
set_env =
APK_REPO_SCHEME = https
commands = commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Apk.yaml.j2 {env_dir}/configs/Apk.yaml 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 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 rmi id-configurator-apk
commands_post =
docker buildx prune --all --force
[testenv:module-apk-http]
package = skip
labels = end-to-end
change_dir = {tox_root}
dependency_groups = dev
docker =
apk-repo
allowlist_externals =
docker
description =
Same as module-apk, but against a plain-HTTP repository, since apk's
trust model is the signed index, independent of transport scheme. See
module-apk's own description for why this isn't pinned to --platform
linux/amd64 either.
set_env =
APK_REPO_SCHEME = http
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-http .
docker rmi id-configurator-apk-http
commands_post =
docker buildx prune --all --force
[docker:java-repo] [docker:java-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/java-repo.Dockerfile dockerfile = {tox_root}/tests/dockerfiles/repos/java-repo.Dockerfile
expose = expose =
JAVA_REPO_PORT=8443/tcp JAVA_REPO_PORT=8443/tcp
JAVA_REPO_HTTP_PORT=8080/tcp
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_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_interval = 1
healthcheck_retries = 30 healthcheck_retries = 30
@@ -199,16 +315,43 @@ healthcheck_start_period = 1
[testenv:module-java] [testenv:module-java]
package = skip package = skip
labels = end-to-end
change_dir = {tox_root} change_dir = {tox_root}
dependency_groups = dev dependency_groups = dev
docker = docker =
java-repo java-repo
allowlist_externals = allowlist_externals =
docker docker
set_env =
JAVA_REPO_SCHEME = https
commands = commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Java.yaml.j2 {env_dir}/configs/Java.yaml 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 buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg JAVA_REPO_SCHEME={env:JAVA_REPO_SCHEME} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Java.Dockerfile -t id-configurator-java .
docker rmi id-configurator-java docker rmi id-configurator-java
commands_post =
docker buildx prune --all --force
[testenv:module-java-http]
package = skip
labels = end-to-end
change_dir = {tox_root}
dependency_groups = dev
docker =
java-repo
allowlist_externals =
docker
description =
Same as module-java, but against a plain-HTTP repository. Leiningen is
skipped here (see Java.Dockerfile): it refuses any non-HTTPS repository
unconditionally, with no configuration able to change that.
set_env =
JAVA_REPO_SCHEME = http
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-arg JAVA_REPO_SCHEME={env:JAVA_REPO_SCHEME} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Java.Dockerfile -t id-configurator-java-http .
docker rmi id-configurator-java-http
commands_post =
docker buildx prune --all --force
[docker:certificate-repo] [docker:certificate-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/certificate-repo.Dockerfile dockerfile = {tox_root}/tests/dockerfiles/repos/certificate-repo.Dockerfile
@@ -222,6 +365,7 @@ healthcheck_start_period = 1
[testenv:module-certificates] [testenv:module-certificates]
package = skip package = skip
labels = end-to-end
change_dir = {tox_root} change_dir = {tox_root}
dependency_groups = dev dependency_groups = dev
docker = docker =
@@ -237,11 +381,14 @@ commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Certificate.yaml.j2 {env_dir}/configs/Certificate.yaml 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 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 rmi id-configurator-certificates
commands_post =
docker buildx prune --all --force
[docker:php-repo] [docker:php-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/php-repo.Dockerfile dockerfile = {tox_root}/tests/dockerfiles/repos/php-repo.Dockerfile
expose = expose =
PHP_REPO_PORT=8443/tcp PHP_REPO_PORT=8443/tcp
PHP_REPO_HTTP_PORT=8080/tcp
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/packages.json || exit 1 healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/packages.json || exit 1
healthcheck_interval = 1 healthcheck_interval = 1
healthcheck_retries = 30 healthcheck_retries = 30
@@ -250,21 +397,48 @@ healthcheck_start_period = 1
[testenv:module-php] [testenv:module-php]
package = skip package = skip
labels = end-to-end
change_dir = {tox_root} change_dir = {tox_root}
dependency_groups = dev dependency_groups = dev
docker = docker =
php-repo php-repo
allowlist_externals = allowlist_externals =
docker docker
set_env =
PHP_REPO_SCHEME = https
commands = commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Php.yaml.j2 {env_dir}/configs/Php.yaml 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 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 rmi id-configurator-php
commands_post =
docker buildx prune --all --force
[testenv:module-php-http]
package = skip
labels = end-to-end
change_dir = {tox_root}
dependency_groups = dev
docker =
php-repo
allowlist_externals =
docker
description =
Same as module-php, but against a plain-HTTP repository, to verify
secure-http is disabled (and packagist.org too) for a non-HTTPS source.
set_env =
PHP_REPO_SCHEME = http
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-http .
docker rmi id-configurator-php-http
commands_post =
docker buildx prune --all --force
[docker:npm-repo] [docker:npm-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/npm-repo.Dockerfile dockerfile = {tox_root}/tests/dockerfiles/repos/npm-repo.Dockerfile
expose = expose =
NPM_REPO_PORT=8443/tcp NPM_REPO_PORT=8443/tcp
NPM_REPO_HTTP_PORT=8080/tcp
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/idconfiguratortestpkg || exit 1 healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/idconfiguratortestpkg || exit 1
healthcheck_interval = 1 healthcheck_interval = 1
healthcheck_retries = 30 healthcheck_retries = 30
@@ -273,21 +447,47 @@ healthcheck_start_period = 1
[testenv:module-npm] [testenv:module-npm]
package = skip package = skip
labels = end-to-end
change_dir = {tox_root} change_dir = {tox_root}
dependency_groups = dev dependency_groups = dev
docker = docker =
npm-repo npm-repo
allowlist_externals = allowlist_externals =
docker docker
set_env =
NPM_REPO_SCHEME = https
commands = commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Npm.yaml.j2 {env_dir}/configs/Npm.yaml 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 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 rmi id-configurator-npm
commands_post =
docker buildx prune --all --force
[testenv:module-npm-http]
package = skip
labels = end-to-end
change_dir = {tox_root}
dependency_groups = dev
docker =
npm-repo
allowlist_externals =
docker
description =
Same as module-npm, but against a plain-HTTP registry.
set_env =
NPM_REPO_SCHEME = http
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-http .
docker rmi id-configurator-npm-http
commands_post =
docker buildx prune --all --force
[docker:cargo-repo] [docker:cargo-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/cargo-repo.Dockerfile dockerfile = {tox_root}/tests/dockerfiles/repos/cargo-repo.Dockerfile
expose = expose =
CARGO_REPO_PORT=8443/tcp CARGO_REPO_PORT=8443/tcp
CARGO_REPO_HTTP_PORT=8080/tcp
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/config.json || exit 1 healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/config.json || exit 1
healthcheck_interval = 1 healthcheck_interval = 1
healthcheck_retries = 30 healthcheck_retries = 30
@@ -296,13 +496,218 @@ healthcheck_start_period = 1
[testenv:module-rust] [testenv:module-rust]
package = skip package = skip
labels = end-to-end
change_dir = {tox_root} change_dir = {tox_root}
dependency_groups = dev dependency_groups = dev
docker = docker =
cargo-repo cargo-repo
allowlist_externals = allowlist_externals =
docker docker
set_env =
CARGO_REPO_SCHEME = https
commands = commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Rust.yaml.j2 {env_dir}/configs/Rust.yaml 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 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 docker rmi id-configurator-rust
commands_post =
docker buildx prune --all --force
[testenv:module-rust-http]
package = skip
labels = end-to-end
change_dir = {tox_root}
dependency_groups = dev
docker =
cargo-repo
allowlist_externals =
docker
description =
Same as module-rust, but against a plain-HTTP sparse registry.
set_env =
CARGO_REPO_SCHEME = http
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-http .
docker rmi id-configurator-rust-http
commands_post =
docker buildx prune --all --force
[docker:go-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/go-repo.Dockerfile
expose =
GO_REPO_PORT=8443/tcp
GO_REPO_HTTP_PORT=8080/tcp
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/example.invalid/idconfiguratortestpkg/@v/list || exit 1
healthcheck_interval = 1
healthcheck_retries = 30
healthcheck_timeout = 2
healthcheck_start_period = 1
[testenv:module-go]
package = skip
labels = end-to-end
change_dir = {tox_root}
dependency_groups = dev
docker =
go-repo
allowlist_externals =
docker
set_env =
GO_REPO_SCHEME = https
commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Go.yaml.j2 {env_dir}/configs/Go.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/Go.Dockerfile -t id-configurator-go .
docker rmi id-configurator-go
commands_post =
docker buildx prune --all --force
[testenv:module-go-http]
package = skip
labels = end-to-end
change_dir = {tox_root}
dependency_groups = dev
docker =
go-repo
allowlist_externals =
docker
description =
Same as module-go, but against a plain-HTTP module proxy, to verify
GOINSECURE is configured correctly for a non-HTTPS GOPROXY.
set_env =
GO_REPO_SCHEME = http
commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Go.yaml.j2 {env_dir}/configs/Go.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/Go.Dockerfile -t id-configurator-go-http .
docker rmi id-configurator-go-http
commands_post =
docker buildx prune --all --force
[docker:ruby-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/ruby-repo.Dockerfile
expose =
RUBY_REPO_PORT=8443/tcp
RUBY_REPO_HTTP_PORT=8080/tcp
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/versions || exit 1
healthcheck_interval = 1
healthcheck_retries = 30
healthcheck_timeout = 2
healthcheck_start_period = 1
[testenv:module-ruby]
package = skip
labels = end-to-end
change_dir = {tox_root}
dependency_groups = dev
docker =
ruby-repo
allowlist_externals =
docker
set_env =
RUBY_REPO_SCHEME = https
commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Ruby.yaml.j2 {env_dir}/configs/Ruby.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/Ruby.Dockerfile -t id-configurator-ruby .
docker rmi id-configurator-ruby
commands_post =
docker buildx prune --all --force
[testenv:module-ruby-http]
package = skip
labels = end-to-end
change_dir = {tox_root}
dependency_groups = dev
docker =
ruby-repo
allowlist_externals =
docker
description =
Same as module-ruby, but against a plain-HTTP gem source.
set_env =
RUBY_REPO_SCHEME = http
commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Ruby.yaml.j2 {env_dir}/configs/Ruby.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/Ruby.Dockerfile -t id-configurator-ruby-http .
docker rmi id-configurator-ruby-http
commands_post =
docker buildx prune --all --force
[docker:nuget-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/nuget-repo.Dockerfile
expose =
NUGET_REPO_PORT=8443/tcp
NUGET_REPO_HTTP_PORT=8080/tcp
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/index.json || exit 1
healthcheck_interval = 1
healthcheck_retries = 30
healthcheck_timeout = 2
healthcheck_start_period = 1
[testenv:module-nuget]
package = skip
labels = end-to-end
change_dir = {tox_root}
dependency_groups = dev
docker =
nuget-repo
allowlist_externals =
docker
set_env =
NUGET_REPO_SCHEME = https
commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/NuGet.yaml.j2 {env_dir}/configs/NuGet.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/NuGet.Dockerfile -t id-configurator-nuget .
docker rmi id-configurator-nuget
commands_post =
docker buildx prune --all --force
[testenv:module-nuget-http]
package = skip
labels = end-to-end
change_dir = {tox_root}
dependency_groups = dev
docker =
nuget-repo
allowlist_externals =
docker
description =
Same as module-nuget, but against a plain-HTTP feed, to verify
allowInsecureConnections is configured correctly for a non-HTTPS source.
set_env =
NUGET_REPO_SCHEME = http
commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/NuGet.yaml.j2 {env_dir}/configs/NuGet.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/NuGet.Dockerfile -t id-configurator-nuget-http .
docker rmi id-configurator-nuget-http
commands_post =
docker buildx prune --all --force
[docker:config-url-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/config-url-repo.Dockerfile
expose =
CONFIG_REPO_PORT=8443/tcp
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/config.yaml || exit 1
healthcheck_interval = 1
healthcheck_retries = 30
healthcheck_timeout = 2
healthcheck_start_period = 1
[testenv:config-url]
package = skip
labels = end-to-end
change_dir = {tox_root}
dependency_groups = dev
docker =
config-url-repo
allowlist_externals =
docker
sh
description =
Verifies `id-configurator apply --config-url ... --config-url-cacert-content
...`: the configuration itself is downloaded over HTTPS from the
config-url-repo fixture (trusting only the given root CA content, not the
system's trust store), rather than read from a local --config file.
commands =
sh -c 'docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg CONFIG_REPO_PORT={env:CONFIG_REPO_PORT} --build-arg CONFIG_URL_CACERT_CONTENT="$(cat {tox_root}/tests/certs/test-ca.crt.pem)" -f {tox_root}/tests/dockerfiles/ConfigUrl.Dockerfile -t id-configurator-config-url .'
docker rmi id-configurator-config-url
commands_post =
docker buildx prune --all --force
Generated
+673 -564
View File
File diff suppressed because it is too large Load Diff