Compare commits

...

32 Commits

Author SHA1 Message Date
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 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 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 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 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 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 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 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
72 changed files with 4519 additions and 1401 deletions
+8
View File
@@ -18,9 +18,17 @@ jobs:
- 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: curl -O -L "https://github.com/sigstore/cosign/releases/latest/download/cosign_3.1.3_amd64.deb"
- run: sudo dpkg -i cosign_3.1.3_amd64.deb
- 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 -m end-to-end
- run: docker builder prune --all --force
- run: docker system prune --all --force
- uses: docker/setup-buildx-action@v3
strategy:
matrix:
python-version: ['14']
+36
View File
@@ -0,0 +1,36 @@
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: 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: curl -O -L "https://github.com/sigstore/cosign/releases/latest/download/cosign_3.1.3_amd64.deb"
- run: sudo dpkg -i cosign_3.1.3_amd64.deb
- 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
hooks:
- 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/.*)
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.11.0.1
+771 -669
View File
File diff suppressed because it is too large Load Diff
+182 -34
View File
@@ -4,9 +4,10 @@
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
that need to change: root certificates, package manager sources (APT, YUM/DNF, APK), and language-specific
package manager settings (pip/uv/poetry, Maven/Gradle/Ivy/SBT/Leiningen, ...). The result can then be used
to build a Docker image or otherwise deployed to the target device.
that need to change: root certificates, system package manager sources (APT, YUM/DNF, APK) and
language-specific package manager settings (pip/uv/poetry, Maven/Gradle/Ivy/SBT/Leiningen, Composer, npm,
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
@@ -14,15 +15,70 @@ to build a Docker image or otherwise deployed to the target device.
never writes anywhere else.
- `--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.
- The configuration is loaded either from a file (`--config`) or from a base64-encoded string, typically an
environment variable (`--config-env` / `$ID_CONFIGURATOR_CONFIG`).
- Each feature is implemented as an independent, self-registering **module**. 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.
- The configuration is loaded from a file (`--config`), from a base64-encoded string, typically an
environment variable (`--config-env` / `$ID_CONFIGURATOR_CONFIG`), or downloaded over HTTP(S) from a URL
(`--config-url`). `--config-url-cacert-content` optionally gives the PEM content of the CA certificate to trust for that one download, 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) - 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
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
uv sync
@@ -32,32 +88,60 @@ uv sync
```bash
# 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)
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
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
uv run id-configurator validate --config config.yaml
id-configurator validate --config config.yaml
# 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`.
## 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
uuid: <UUID>
organisation: <string>
description: <string>
version: "1.0"
uuid: <UUID, optional>
organisation: <string, optional>
description: <string, optional>
configurations:
certificates:
- name: <string>
@@ -68,18 +152,21 @@ configurations:
uris: [<string>]
suites: [<string>]
components: [<string>]
keys:
- name: <string>
content: <GPG key>
signed-by: <ASCII-armored GPG public key> # or signed-by-file: <path already on the target>
uri-to-replace: <fnmatch pattern, optional> # see "Replacing an existing repository" below
apk:
repositories:
- url: <string>
key: <RSA public key>
- name: <string>
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:
repositories:
- name: <string>
index-url: <string>
gpg-url: <string>
gpg-url: <string, optional>
index-url-to-replace: <fnmatch pattern, optional>
keys:
- name: <string>
content: <GPG key>
@@ -91,20 +178,81 @@ configurations:
repositories:
- name: <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
| Module | Applies to |
|----------------|--------------------------------------------------------------------------------------------------------|
| `certificates` | Installs root certificates into the system's CA store (`update-ca-certificates`). |
| `apt` | Disables existing APT sources and adds the configured ones, in DEB822 format. |
| `apk` | Disables existing Alpine `/etc/apk/repositories` entries and adds the configured ones, plus their keys.|
| `yum` | Disables existing YUM/DNF `.repo` files, adds the configured ones, and imports their GPG keys. |
| `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. |
| Module | Applies to |
|----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| `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` | Adds the configured APT sources (DEB822 format); disables only the existing entries matching `uri-to-replace`. |
| `apk` | Adds the configured Alpine `/etc/apk/repositories` entries and their keys; disables only the existing lines matching `url_to_replace`. |
| `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. |
| `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
@@ -118,4 +266,4 @@ uv run pre-commit run --all-files # run all pre-commit hooks
## 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.
+47 -7
View File
@@ -55,6 +55,17 @@ ConfigUrlCacertContentOption = Annotated[
),
),
]
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[
Path | None,
typer.Option(
@@ -72,6 +83,26 @@ DstOption = Annotated[
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[
bool, typer.Option("--verbose", "-v", help="Enable verbose (debug) logging.")
]
@@ -89,13 +120,16 @@ def _load_configuration(
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(
"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)
return load_configuration(
config, config_env, config_url, config_url_cacert_content, config_url_insecure
)
@app.command()
@@ -105,16 +139,21 @@ def apply(
config_env: ConfigEnvOption = None,
config_url: ConfigUrlOption = None,
config_url_cacert_content: ConfigUrlCacertContentOption = None,
config_url_insecure: ConfigUrlInsecureOption = False,
src: SrcOption = None,
user: UserOption = [], # noqa: B006 (Typer needs a concrete default to detect --user as repeatable)
auto_users: AutoUsersOption = False,
verbose: VerboseOption = False,
) -> None:
"""Apply a configuration to the destination directory."""
_configure_logging(verbose)
configuration = _load_configuration(
config, config_env, config_url, config_url_cacert_content
config, config_env, config_url, config_url_cacert_content, config_url_insecure
)
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():
module = module_cls()
if module.is_relevant(context):
@@ -130,14 +169,15 @@ def validate(
config_env: ConfigEnvOption = None,
config_url: ConfigUrlOption = None,
config_url_cacert_content: ConfigUrlCacertContentOption = None,
config_url_insecure: ConfigUrlInsecureOption = False,
) -> None:
"""Validate a configuration file without applying it."""
configuration = _load_configuration(
config, config_env, config_url, config_url_cacert_content
)
typer.echo(
f"configuration {configuration.uuid} is valid (organisation: {configuration.organisation})"
config, config_env, config_url, config_url_cacert_content, config_url_insecure
)
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")
+140 -44
View File
@@ -10,7 +10,7 @@ from pathlib import Path
from uuid import UUID
import yaml
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
_CONFIG_URL_TIMEOUT_SECONDS = 30
@@ -26,8 +26,31 @@ def _yes_no(value: bool) -> str:
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):
"""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)
@@ -42,6 +65,7 @@ class AptSource(BaseModel):
enabled: bool = True
trusted: bool | None = None
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
by_hash: bool | None = Field(default=None, alias="by-hash")
allow_insecure: bool | None = Field(default=None, alias="allow-insecure")
@@ -55,67 +79,92 @@ class AptSource(BaseModel):
check_date: bool | None = Field(default=None, alias="check-date")
date_max_future: str | None = Field(default=None, alias="date-max-future")
description: str | None = None
uri_to_replace: str | None = Field(default=None, alias="uri-to-replace")
def to_deb822(self) -> str:
"""Render this source as a DEB822 stanza (as used in /etc/apt/sources.list.d/*.sources)."""
fields = [
("Types", " ".join(self.types)),
("URIs", " ".join(self.uris)),
("Suites", " ".join(self.suites)),
def to_deb822(
self,
signed_by_armored_key: str | None = None,
signed_by_path: str | None = None,
) -> str:
"""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:
fields.append(("Components", " ".join(self.components)))
lines.append(f"Components: {' '.join(self.components)}")
if self.architectures:
fields.append(("Architectures", " ".join(self.architectures)))
lines.append(f"Architectures: {' '.join(self.architectures)}")
if self.languages:
fields.append(("Languages", " ".join(self.languages)))
lines.append(f"Languages: {' '.join(self.languages)}")
if self.targets:
fields.append(("Targets", " ".join(self.targets)))
if self.signed_by:
fields.append(("Signed-By", self.signed_by))
lines.append(f"Targets: {' '.join(self.targets)}")
if signed_by_armored_key:
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:
fields.append(("Trusted", _yes_no(self.trusted)))
lines.append(f"Trusted: {_yes_no(self.trusted)}")
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:
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:
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:
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:
fields.append(
(
"Allow-Downgrade-To-Insecure",
_yes_no(self.allow_downgrade_to_insecure),
)
lines.append(
f"Allow-Downgrade-To-Insecure: {_yes_no(self.allow_downgrade_to_insecure)}"
)
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:
fields.append(("Valid-Until-Min", self.valid_until_min))
lines.append(f"Valid-Until-Min: {self.valid_until_min}")
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:
fields.append(("Check-Date", _yes_no(self.check_date)))
lines.append(f"Check-Date: {_yes_no(self.check_date)}")
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:
fields.append(("Description", self.description))
fields.append(("Enabled", _yes_no(self.enabled)))
return "\n".join(f"{key}: {value}" for key, value in fields) + "\n"
lines.append(f"Description: {self.description}")
lines.append(f"Enabled: {_yes_no(self.enabled)}")
return "\n".join(lines) + "\n"
class AptConfig(BaseModel):
sources: list[AptSource] = Field(default_factory=list)
keys: list[NamedContent] = Field(default_factory=list)
class ApkRepository(BaseModel):
"""An Alpine APK repository (see /etc/apk/repositories), e.g. a URL ending in /main or /community."""
"""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
key: str | None = None
key_filename: str | None = None
url_to_replace: str | None = None
class ApkConfig(BaseModel):
@@ -123,10 +172,19 @@ class ApkConfig(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)
name: str
index_url: str = Field(alias="index-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):
@@ -181,11 +239,16 @@ class NpmConfig(BaseModel):
class RustRepository(BaseModel):
"""A cargo registry (see the "source" tables in .cargo/config.toml),
served over the sparse HTTP protocol."""
"""A cargo registry (see the "source" tables in .cargo/config.toml).
`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):
@@ -242,11 +305,28 @@ class ConfigurationsBlock(BaseModel):
class Configuration(BaseModel):
uuid: UUID
organisation: str
description: str = ""
"""`version` is the configuration format's own schema version (currently
"1.0"), independent of the id-configurator package version - it lets a
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)
@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:
"""Parse a configuration from a JSON or YAML string."""
@@ -257,17 +337,30 @@ def parse_configuration(raw: str) -> Configuration:
return Configuration.model_validate(data)
def _fetch_config_url(url: str, cacert_content: str | None) -> str:
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.
"""
context = ssl.create_default_context(
cadata=cacert_content if cacert_content is not None else None
)
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:
@@ -279,6 +372,7 @@ def load_configuration(
config_env: str | None,
config_url: str | None = None,
config_url_cacert_content: str | None = None,
config_url_insecure: bool = False,
) -> Configuration:
"""Load a configuration from a file, from a base64-encoded string
(typically an env var), or by downloading it from a URL."""
@@ -287,7 +381,9 @@ def load_configuration(
elif config_env is not None:
raw = base64.b64decode(config_env).decode("utf-8")
elif config_url is not None:
raw = _fetch_config_url(config_url, config_url_cacert_content)
raw = _fetch_config_url(
config_url, config_url_cacert_content, config_url_insecure
)
else:
raise ValueError(
"either --config, --config-env or --config-url must be provided"
+54 -1
View File
@@ -7,18 +7,71 @@ filesystem used to detect the existing configuration.
from __future__ import annotations
from collections.abc import Sequence
from pathlib import Path
from id_configurator.config import Configuration
from id_configurator.users import (
DEFAULT_UID_MIN,
PasswdEntry,
parse_login_defs_uid_min,
parse_passwd,
)
class Context:
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:
self.config = config
self.dst = dst.resolve()
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:
"""Resolve a path relative to the destination directory, refusing to escape it."""
+82 -14
View File
@@ -1,18 +1,27 @@
"""Detect existing APK (Alpine) repositories and configure new ones.
If `<src>/etc/apk` exists, the existing `/etc/apk/repositories` file (if any)
is copied into `<dst>` with every active line disabled (commented out), and
the repositories declared in the configuration are appended as new, active
lines. Public keys provided alongside a repository are written to
is copied into `<dst>`, and the repositories declared in the configuration
are appended as new, active lines. An existing active line is only disabled
(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
sharing the same key (a common case for official Alpine mirrors) only
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
import fnmatch
import hashlib
import logging
from collections.abc import Sequence
from pathlib import Path
from id_configurator.context import Context
@@ -25,11 +34,18 @@ _REPOSITORIES = "etc/apk/repositories"
_KEYS_DIR = "etc/apk/keys"
def _disable_repositories_content(content: str) -> str:
lines = [
line if not line.strip() or line.strip().startswith("#") else f"# {line}"
for line in content.splitlines()
]
def _disable_repositories_content(content: str, patterns: Sequence[str]) -> str:
lines = []
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"
@@ -37,6 +53,31 @@ def _key_filename(content: bytes) -> str:
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
class ApkModule(Module):
name = "apk"
@@ -52,28 +93,55 @@ class ApkModule(Module):
return 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)
content = disabled + ("\n".join(configured) + "\n" if configured else "")
if content:
context.write_file(_REPOSITORIES, content.encode("utf-8"))
@staticmethod
def _disabled_existing_repositories(context: Context) -> str:
def _disabled_existing_repositories(
context: Context, patterns: Sequence[str]
) -> str:
existing = context.read_src_file(_REPOSITORIES)
if existing is None:
return ""
logger.info("disabled existing apk repositories %s", _REPOSITORIES)
return _disable_repositories_content(existing.decode("utf-8"))
logger.info("copied existing apk repositories %s", _REPOSITORIES)
return _disable_repositories_content(existing.decode("utf-8"), patterns)
@staticmethod
def _configured_repositories_lines(context: Context) -> list[str]:
lines = []
existing_urls: set[str] | None = None
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)
logger.info("added apk repository %s", repository.url)
logger.info("added apk repository %s", repository.name)
if repository.key:
filename = _key_filename(repository.key.encode("utf-8"))
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"))
context.write_file(
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
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
longer takes effect, and the sources declared in the configuration are added
as new, enabled `.sources` files.
or under `sources.list.d/`) is copied into `<dst>/etc/apt`, and the sources
declared in the configuration are added 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
import fnmatch
import logging
from collections.abc import Sequence
from pathlib import Path
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__)
_APT_DIR = "etc/apt"
_SOURCES_LIST = "etc/apt/sources.list"
_SOURCES_LIST_D = "etc/apt/sources.list.d"
_KEYRINGS_DIR = "etc/apt/keyrings"
def _disable_list_content(content: str) -> str:
lines = [
line if not line.strip() or line.strip().startswith("#") else f"# {line}"
for line in content.splitlines()
]
def _uri_from_deb_line(stripped_line: str) -> str | None:
"""Extract the URI from a single, already-stripped `deb`/`deb-src`
line (`deb [options] uri suite [component...]`), or `None` if the line
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"
def _disable_deb822_content(content: str) -> str:
disabled_stanzas = []
def _uris_from_deb822_stanza(stanza: str) -> set[str]:
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"):
if not stanza.strip():
continue
lines = stanza.splitlines()
new_lines = [line for line in lines if not line.lower().startswith("enabled:")]
new_lines.append("Enabled: no")
disabled_stanzas.append("\n".join(new_lines))
return "\n\n".join(disabled_stanzas) + "\n"
uris = _uris_from_deb822_stanza(stanza)
if any(fnmatch.fnmatch(uri, pattern) for uri in uris for pattern in patterns):
lines = [line for line in lines if not line.lower().startswith("enabled:")]
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
@@ -58,43 +152,62 @@ class AptModule(Module):
return None
def apply(self, context: Context) -> None:
self._copy_existing_disabled(context)
self._write_configured_keys(context)
patterns = [
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)
@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)
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"))
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):
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":
disabled = _disable_list_content(entry.read_text(encoding="utf-8"))
disabled = _disable_list_content(
entry.read_text(encoding="utf-8"), patterns
)
else:
continue
context.write_file(
f"{_SOURCES_LIST_D}/{entry.name}", disabled.encode("utf-8")
)
logger.info("disabled 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)
logger.info("copied existing apt source %s", entry.name)
@staticmethod
def _write_configured_sources(context: Context) -> None:
existing_uris: set[str] | None = None
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(
f"{_SOURCES_LIST_D}/{source.name}.sources",
source.to_deb822().encode("utf-8"),
f"{_SOURCES_LIST_D}/{sanitize_filename(source.name)}.sources",
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)
+38
View File
@@ -7,6 +7,7 @@ the destination directory.
from __future__ import annotations
import re
from abc import ABC, abstractmethod
from id_configurator.context import Context
@@ -34,3 +35,40 @@ def register(module_cls: type[Module]) -> type[Module]:
def all_modules() -> list[type[Module]]:
return list(_REGISTRY)
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: without it, a name containing `/` (or `\\`, harmless on the
target systems this tool writes to but stripped for good measure)
would silently turn into extra, unintended path segments - creating
nested directories a careless or malicious name shouldn't be able to
create - instead of the single file the caller asked for.
"""
return name.replace("/", "_").replace("\\", "_")
_REPOSITORY_NAME_FORBIDDEN_CHARS = re.compile(r"[^A-Za-z0-9_-]")
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). Excluding `.` from the allowed
characters also means the result can never come out as exactly `.` or
`..`, which matter if the name is ever reused as a filesystem path
component (`sanitize_filename` alone wouldn't catch those, since it
only strips `/` and `\\`). Spaces and every other non-identifier
character are replaced the same way, for consistency across callers.
"""
return _REPOSITORY_NAME_FORBIDDEN_CHARS.sub("_", name)
+4 -4
View File
@@ -49,7 +49,7 @@ import posixpath
from pathlib import Path
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__)
@@ -142,7 +142,7 @@ class CertificatesModule(Module):
def apply(self, context: Context) -> None:
configured_names = {
certificate.name
sanitize_filename(certificate.name)
for certificate in context.config.configurations.certificates
}
for existing in context.iter_src_dir(_LOCAL_CERTS_DIR):
@@ -152,7 +152,7 @@ class CertificatesModule(Module):
)
for certificate in context.config.configurations.certificates:
context.write_file(
f"{_LOCAL_CERTS_DIR}/{certificate.name}.crt",
f"{_LOCAL_CERTS_DIR}/{sanitize_filename(certificate.name)}.crt",
certificate.content.encode(),
)
logger.info("installed root certificate %s", certificate.name)
@@ -237,7 +237,7 @@ class CertificatesModule(Module):
)
for certificate in context.config.configurations.certificates:
context.write_file(
f"{_RHEL_ANCHORS_DIR}/{certificate.name}.crt",
f"{_RHEL_ANCHORS_DIR}/{sanitize_filename(certificate.name)}.crt",
certificate.content.encode(),
)
+14 -9
View File
@@ -9,14 +9,15 @@ 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` - not the per-user `go env -w` file (found at
`os.UserConfigDir()/go/env`, e.g. `$HOME/.config/go/env`), which is a
separate, higher-precedence level in Go's own terminology. `$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 the user's own go/env file.
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
@@ -35,7 +36,11 @@ _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"]
context.write_file(_GO_ENV, ("\n".join(lines) + "\n").encode("utf-8"))
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))
+22 -5
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.
- SBT: `~/.sbt/repositories`, the resolver override file SBT reads for its
own bootstrapping and, when combined with the `sbt.override.build.repos`
system property, for every project's resolvers as well.
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`
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
@@ -39,6 +50,7 @@ import json
import logging
import xml.etree.ElementTree as ET
from pathlib import Path
from urllib.parse import urlparse
import jks
from cryptography import x509
@@ -233,13 +245,18 @@ def _write_ivy_settings(context: Context, repositories: list[JavaRepository]) ->
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(
context: Context, repositories: list[JavaRepository]
) -> None:
lines = ["[repositories]", " local"]
lines.extend(
f" {repository.name}: {repository.url}" for repository in repositories
)
lines.extend(_sbt_repository_line(repository) for repository in repositories)
context.write_file(_SBT_REPOSITORIES, ("\n".join(lines) + "\n").encode("utf-8"))
logger.info("configured sbt to use %d repositories", len(repositories))
+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
(the official Docker image included); unlike a config dropped in one
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
@@ -29,6 +42,8 @@ logger = logging.getLogger(__name__)
_NPMRC = "usr/local/etc/npmrc"
_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:
@@ -41,12 +56,29 @@ def _write_npmrc(context: Context, repositories: list[NpmRepository]) -> None:
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
class NpmModule(Module):
name = "npm"
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:
_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)
+15 -3
View File
@@ -19,12 +19,17 @@ 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
@@ -48,10 +53,17 @@ def _write_nuget_config(context: Context, repositories: list[NuGetRepository]) -
package_sources = ET.SubElement(configuration, "packageSources")
ET.SubElement(package_sources, "clear")
for repository in repositories:
ET.SubElement(
package_sources, "add", {"key": repository.name, "value": repository.url}
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
)
context.write_file(_NUGET_CONFIG, _render_xml(configuration))
logger.info("configured nuget for user %s", user)
logger.info("configured nuget to use %d package source(s)", len(repositories))
+57 -13
View File
@@ -3,23 +3,43 @@
Composer has no "replace the default registry" switch either: Packagist
stays enabled unless a repository is explicitly declared under the name
"packagist.org" and disabled. This just makes the configured repositories
"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:
`/root/.composer/config.json` (`COMPOSER_HOME/config.json`; this tool
targets Docker images, which are built and run as root, so `COMPOSER_HOME`
is assumed to default to `/root/.composer`, exactly like a fresh Composer
install does when not overridden).
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
curl) already trusts whatever the `certificates` module installs into the
system CA store, so no per-repository certificate configuration is needed
here, unlike Poetry's.
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
import json
import logging
from urllib.parse import urlparse
from id_configurator.config import PhpRepository
from id_configurator.context import Context
@@ -28,29 +48,53 @@ from id_configurator.modules.base import Module, register
logger = logging.getLogger(__name__)
_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:
content = {
"repositories": [
{"name": repository.name, "type": "composer", "url": repository.url}
for repository in repositories
]
}
context.write_file(
_COMPOSER_CONFIG, (json.dumps(content, indent=4) + "\n").encode("utf-8")
)
composer_repositories: list[dict[str, str | bool]] = [
{"name": repository.name, "type": "composer", "url": repository.url}
for repository in repositories
]
content: dict[str, object] = {"repositories": composer_repositories}
composer_repositories.append({"packagist.org": False})
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(
"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
class PhpModule(Module):
name = "php"
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:
_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)
+30 -9
View File
@@ -3,13 +3,17 @@ PythonRepository entries instead of pypi.org, and to trust the root
certificates installed by the certificates module.
- pip: system-wide `/etc/pip.conf` (`index-url`/`extra-index-url`/`cert`).
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`).
- poetry: `/root/.config/pypoetry/config.toml` (`repositories.*`/`certificates.*`).
Poetry has no global "replace PyPI" switch: a project's `pyproject.toml`
still has to reference these repositories as sources. This only makes the
repository and its certificate available system-wide. It also assumes the
container runs as root, since poetry only reads its config from the
current user's home directory.
repository and its certificate available system-wide. Since poetry only
reads its config from the current user's home directory, this assumes the
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
to every Python installation detected under `<src>` (there is no location
independent of a specific Python prefix).
@@ -21,10 +25,11 @@ from __future__ import annotations
import json
import logging
from urllib.parse import urlparse
from id_configurator.config import PythonRepository
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__)
@@ -39,11 +44,22 @@ def _toml_string(value: str) -> str:
return json.dumps(value)
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:
lines = ["[global]", f"index-url = {repositories[0].index_url}"]
if len(repositories) > 1:
lines.append("extra-index-url =")
lines.extend(f" {repository.index_url}" for repository in repositories[1:])
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}")
context.write_file(_PIP_CONF, ("\n".join(lines) + "\n").encode("utf-8"))
logger.info("configured pip to use %s", repositories[0].index_url)
@@ -67,15 +83,20 @@ def _write_poetry_config(
) -> None:
lines = []
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("")
lines.append(f"[certificates.{repository.name}]")
lines.append(f"[certificates.{name}]")
lines.append(f"cert = {_toml_string(_CA_BUNDLE)}")
lines.append("")
context.write_file(
_POETRY_CONFIG, ("\n".join(lines).rstrip() + "\n").encode("utf-8")
)
content = ("\n".join(lines).rstrip() + "\n").encode("utf-8")
context.write_file(_POETRY_CONFIG, content)
for user in context.users:
context.write_file(
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))
+26 -9
View File
@@ -1,8 +1,10 @@
"""Configure cargo to use the configured RustRepository entries: the first
one replaces crates.io entirely (via `[source.crates-io] replace-with`), and
any further ones are registered as additional named registries (usable via
`cargo add --registry <name>` or a dependency's `registry = "<name>"`), all
using the sparse (plain HTTPS) protocol rather than a git-based index.
`cargo add --registry <name>` or a dependency's `registry = "<name>"`). A
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
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
location: found regardless of which user, working directory or `CARGO_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
@@ -25,29 +32,39 @@ import logging
from id_configurator.config import RustRepository
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__)
_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:
primary = repositories[0]
primary_name = sanitize_repository_name(primary.name)
lines = [
"[source.crates-io]",
f'replace-with = "{primary.name}"',
f'replace-with = "{primary_name}"',
"",
f"[source.{primary.name}]",
f'registry = "sparse+{primary.url}"',
f"[source.{primary_name}]",
f'registry = "{_index_url(primary)}"',
]
for repository in repositories[1:]:
name = sanitize_repository_name(repository.name)
lines += [
"",
f"[registries.{repository.name}]",
f'index = "sparse+{repository.url}"',
f"[registries.{name}]",
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)
+75 -16
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.
If `<src>/etc/yum.repos.d` exists, every `.repo` file already there is
copied into `<dst>` with all of its sections disabled (`enabled=0`), before
the repositories declared in the configuration are written as new, enabled
`.repo` files (so a configured repository reusing an existing file name
ends up enabled, taking precedence over the disabled copy). Each `.repo`
copied into `<dst>`, before the repositories declared in the configuration
are written as new, enabled `.repo` files (so a configured repository
reusing an existing file name ends up enabled, taking precedence over the
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=`,
exactly as YUM/DNF expect: `https://`, or a `file://` path already present
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
YUM/DNF, which import it from that file the first time the repository is
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
import configparser
import fnmatch
import logging
import shutil
import subprocess
import tempfile
from collections.abc import Sequence
from io import StringIO
from id_configurator.config import NamedContent, YumRepository
from id_configurator.context import Context
from id_configurator.modules.base import Module, register
from id_configurator.modules.base import (
Module,
register,
sanitize_filename,
sanitize_repository_name,
)
logger = logging.getLogger(__name__)
@@ -38,30 +53,53 @@ _REPOS_DIR = "etc/yum.repos.d"
_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.optionxform = str
parser.read_string(content)
for section in parser.sections():
parser[section]["enabled"] = "0"
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"
buffer = StringIO()
parser.write(buffer, space_around_delimiters=False)
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):
if entry.suffix != ".repo":
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"))
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:
name = sanitize_repository_name(repository.name)
lines = [
f"[{repository.name}]",
f"name={repository.name}",
f"[{name}]",
f"name={name}",
f"baseurl={repository.index_url}",
"enabled=1",
]
@@ -71,10 +109,10 @@ def _write_repo_file(context: Context, repository: YumRepository) -> None:
else:
lines.append("gpgcheck=0")
context.write_file(
f"{_REPOS_DIR}/{repository.name}.repo",
f"{_REPOS_DIR}/{name}.repo",
("\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:
@@ -100,7 +138,8 @@ def _validate_gpg_key(name: str, content: bytes) -> None:
def _import_key(context: Context, key: NamedContent) -> None:
_validate_gpg_key(key.name, key.content.encode("utf-8"))
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)
@@ -119,8 +158,28 @@ class YumModule(Module):
return bool(yum.repositories) or bool(yum.keys) or any(existing_repo_files)
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:
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)
for key in context.config.configurations.yum.keys:
_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
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
SUCCESS = 25
@@ -44,5 +45,6 @@ def main():
app.add_typer(build_app, name="build")
app.add_typer(test_app, name="test")
app.add_typer(publication_app, name="publication")
if __name__ == "__main__":
app()
+6 -1
View File
@@ -72,7 +72,12 @@ LABEL org.opencontainers.image.version="{id_configurator_version}"
LABEL description="Air gap made simple!"
COPY --from=interdiode-install --chown=0:0 /chroot/usr/local/bin /usr/local/bin
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
COPY ./LICENSE /chroot/licenses/LICENSE
COPY ./LICENSE /chroot/usr/share/doc/interdiode/LICENSE
+47 -29
View File
@@ -38,18 +38,19 @@ description:
author:
- InterDiode
options:
version:
type: str
default: "1.0"
description: Configuration.version - the configuration format's own schema version.
uuid:
type: str
required: true
description: Configuration.uuid - a UUID identifying this configuration.
description: Configuration.uuid - a UUID identifying this configuration, optional.
organisation:
type: str
required: true
description: Configuration.organisation.
description: Configuration.organisation, optional.
description:
type: str
default: ""
description: Configuration.description.
description: Configuration.description, optional.
certificates:
type: list
elements: dict
@@ -123,7 +124,17 @@ options:
description: DEB822 "Trusted" field.
signed_by:
type: str
description: DEB822 "Signed-By" field.
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.
@@ -157,20 +168,13 @@ options:
description:
type: str
description: DEB822 "Description" field.
keys:
type: list
elements: dict
default: []
description: GPG keys trusted for the sources above (NamedContent).
suboptions:
name:
uri_to_replace:
type: str
required: true
description: A short, unique name for this entry.
content:
type: str
required: true
description: PEM-encoded/ASCII-armored key content.
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: {}
@@ -189,6 +193,13 @@ options:
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: {}
@@ -211,6 +222,13 @@ options:
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
@@ -450,9 +468,10 @@ def _name_url_repository_config() -> dict:
ARGUMENT_SPEC = {
"uuid": {"type": "str", "required": True},
"organisation": {"type": "str", "required": True},
"description": {"type": "str", "default": ""},
"version": {"type": "str", "default": "1.0"},
"uuid": {"type": "str"},
"organisation": {"type": "str"},
"description": {"type": "str"},
"certificates": {
"type": "list",
"elements": "dict",
@@ -483,6 +502,7 @@ ARGUMENT_SPEC = {
"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"},
@@ -494,14 +514,9 @@ ARGUMENT_SPEC = {
"check_date": {"type": "bool"},
"date_max_future": {"type": "str"},
"description": {"type": "str"},
"uri_to_replace": {"type": "str"},
},
},
"keys": {
"type": "list",
"elements": "dict",
"default": [],
"options": dict(_NAMED_CONTENT_OPTIONS),
},
},
},
"apk": {
@@ -515,6 +530,7 @@ ARGUMENT_SPEC = {
"options": {
"url": {"type": "str", "required": True},
"key": {"type": "str"},
"url_to_replace": {"type": "str"},
},
},
},
@@ -531,6 +547,7 @@ ARGUMENT_SPEC = {
"name": {"type": "str", "required": True},
"index_url": {"type": "str", "required": True},
"gpg_url": {"type": "str"},
"index_url_to_replace": {"type": "str"},
},
},
"keys": {
@@ -581,6 +598,7 @@ except ImportError as exc:
def _build_configuration_data(params: dict) -> dict:
return {
"version": params["version"],
"uuid": params["uuid"],
"organisation": params["organisation"],
"description": params["description"],
+3 -4
View File
@@ -21,11 +21,9 @@ authors = [
]
classifiers = [
'Development Status :: 5 - Production/Stable',
'License :: Other/Proprietary License',
'Operating System :: POSIX :: Linux',
'Operating System :: Unix',
'Natural Language :: English',
"Private :: Do Not Upload",
'Programming Language :: Python :: 3 :: Only',
'Programming Language :: Python :: 3.13',
'Programming Language :: Python :: 3.14'
@@ -38,14 +36,15 @@ dependencies = [
"typer>=0.27.1"
]
description = "Configure a device in an air-gapped environment."
license = {text = "Proprietary"}
license = "CECILL-2.1"
license-files = ["LICENSE"]
maintainers = [
{name = "InterDiode", email = "contact@mail.interdiode.fr"}
]
name = "id-configurator"
readme = "README.md"
requires-python = ">=3.13"
version = "0.2.0"
version = "2026.10.6"
[project.scripts]
id-configurator = "id_configurator:main"
+4 -2
View File
@@ -8,10 +8,12 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt:
sources: []
keys: []
apk:
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: |
{{ 'dockerfiles/repos/040d0af5f97a0a44.rsa.pub' | file | indent(8, first=true) }}
yum:
+5 -3
View File
@@ -11,10 +11,12 @@ configurations:
- name: demo
types: [deb]
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: ["./"]
trusted: true
keys: []
uri-to-replace: http://deb.debian.org/*
signed-by: |
{{ 'dockerfiles/repos/apt-signing-key.pub.asc' | file | indent(8, first=true) }}
apk:
repositories: []
yum:
-1
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt:
sources: []
keys: []
apk:
repositories: []
yum:
+2 -2
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt:
sources: []
keys: []
apk:
repositories: []
yum:
@@ -27,7 +26,8 @@ configurations:
go:
repositories:
- name: demo
url: https://host.docker.internal:{{ 'GO_REPO_PORT' | env }}/
{% 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:
+2 -2
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt:
sources: []
keys: []
apk:
repositories: []
yum:
@@ -24,7 +23,8 @@ configurations:
# during the Dockerfile's warm-up step. All other tools just see it as
# an ordinary repository, and find the test artifact here.
- name: central
url: 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
# -Dsbt.override.build.repos=true (the only way SBT actually consults
# this file for project dependencies, not just its own bootstrap), the
+2 -2
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt:
sources: []
keys: []
apk:
repositories: []
yum:
@@ -23,7 +22,8 @@ configurations:
npm:
repositories:
- 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:
repositories: []
go:
+2 -2
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt:
sources: []
keys: []
apk:
repositories: []
yum:
@@ -31,4 +30,5 @@ configurations:
nuget:
repositories:
- name: demo
url: https://host.docker.internal:{{ 'NUGET_REPO_PORT' | env }}/index.json
{% 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
+2 -2
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt:
sources: []
keys: []
apk:
repositories: []
yum:
@@ -21,7 +20,8 @@ configurations:
php:
repositories:
- 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:
repositories: []
rust:
+2 -2
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt:
sources: []
keys: []
apk:
repositories: []
yum:
@@ -17,7 +16,8 @@ configurations:
python:
repositories:
- 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:
repositories: []
php:
+2 -2
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt:
sources: []
keys: []
apk:
repositories: []
yum:
@@ -29,6 +28,7 @@ configurations:
ruby:
repositories:
- name: demo
url: https://host.docker.internal:{{ 'RUBY_REPO_PORT' | env }}/
{% 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: []
+3 -2
View File
@@ -8,7 +8,6 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt:
sources: []
keys: []
apk:
repositories: []
yum:
@@ -25,7 +24,9 @@ configurations:
rust:
repositories:
- 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:
+3 -2
View File
@@ -8,13 +8,14 @@ configurations:
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt:
sources: []
keys: []
apk:
repositories: []
yum:
repositories:
- 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: []
python:
repositories: []
+13 -1
View File
@@ -4,6 +4,7 @@ COPY --from=target / /src/
COPY --from=generated-config Java.yaml /config.yaml
RUN id-configurator apply --config /config.yaml --src /src --dst /dst
FROM target AS runtime
ARG JAVA_REPO_SCHEME
# Every build tool the `java` module configures: Maven itself (already part
# of this image), Gradle, Ivy and Leiningen (all packaged for this Ubuntu
# base), and SBT (no package available, fetched as a plain release tarball).
@@ -43,12 +44,23 @@ RUN java -jar /usr/share/java/ivy.jar -settings /root/.ivy2/ivysettings.xml \
-retrieve "/tmp/ivyretrieve/[artifact]-[revision].[ext]" \
&& 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 \
&& cat > project.clj <<'EOF'
(defproject leintest "0.1.0"
:dependencies [[org.interdiode/idconfiguratortestpkg "0.0.1"]])
EOF
RUN cd /tmp/leintest && lein deps :tree | grep -q idconfiguratortestpkg
RUN 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 \
&& cat > /tmp/sbttest/build.sbt <<'EOF'
+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
FROM target AS runtime
ARG PYPI_REPO_PORT
ARG PYPI_REPO_SCHEME
# Warm up uv and poetry themselves from the real, network-reachable PyPI
# while the default index is still in place, so the actual tests below
# never need anything but the fixture repository configured by
@@ -20,5 +21,5 @@ RUN uv pip install --system idconfiguratortestpkg \
&& python3 -c "import idconfiguratortestpkg"
RUN mkdir -p /tmp/poetrytest && cd /tmp/poetrytest \
&& poetry init -n --name poetrytest \
&& poetry source add --priority explicit demo "https://host.docker.internal:${PYPI_REPO_PORT}/simple/" \
&& poetry source add --priority explicit demo "${PYPI_REPO_SCHEME}://host.docker.internal:${PYPI_REPO_PORT}/simple/" \
&& 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
FROM target AS runtime
ARG PYPI_REPO_PORT
ARG PYPI_REPO_SCHEME
# Warm up uv and poetry themselves from the real, network-reachable PyPI
# while the default index is still in place, so the actual tests below
# never need anything but the fixture repository configured by
@@ -20,5 +21,5 @@ RUN uv pip install --system idconfiguratortestpkg \
&& python3 -c "import idconfiguratortestpkg"
RUN mkdir -p /tmp/poetrytest && cd /tmp/poetrytest \
&& poetry init -n --name poetrytest \
&& poetry source add --priority explicit demo "https://host.docker.internal:${PYPI_REPO_PORT}/simple/" \
&& poetry source add --priority explicit demo "${PYPI_REPO_SCHEME}://host.docker.internal:${PYPI_REPO_PORT}/simple/" \
&& poetry add idconfiguratortestpkg --source demo
@@ -8,3 +8,13 @@ server {
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;
}
+15 -2
View File
@@ -1,12 +1,18 @@
# Minimal flat-format APT repository serving one hand-built .deb package over
# 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
# root) - every COPY source below must live under tests/dockerfiles/repos/.
FROM debian:trixie-slim AS packagebuild
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/*
RUN set -eux; \
pkg=idconfiguratortestpkg; \
@@ -18,6 +24,13 @@ RUN set -eux; \
WORKDIR /srv
RUN dpkg-scanpackages --multiversion . > Packages \
&& 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
COPY --from=packagebuild /srv /srv
@@ -8,3 +8,13 @@ server {
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;
}
@@ -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-----
+20 -2
View File
@@ -12,10 +12,28 @@ server {
# templates, since the host:port this fixture is actually reached on is
# only known once tox-docker starts the container, not at image-build
# time. $http_host reflects exactly what the client (cargo) used to
# connect, e.g. host.docker.internal:55123.
# connect, e.g. host.docker.internal:55123; $scheme likewise reflects
# whichever of this file's two listeners actually served the request.
location = /config.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_types application/json;
}
@@ -8,3 +8,13 @@ server {
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;
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
# on is only known once tox-docker starts the container, not at
# image-build time. $http_host reflects exactly what the client (npm)
# used to connect, e.g. host.docker.internal:55123.
# 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 {
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_types application/json;
}
+20 -2
View File
@@ -12,10 +12,28 @@ server {
# 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.
# 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__' '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-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
# once tox-docker starts the container, not at image-build time.
# $http_host reflects exactly what the client (Composer) used to
# connect, e.g. host.docker.internal:55123.
# connect, e.g. host.docker.internal:55123; $scheme likewise reflects
# whichever of this file's two listeners actually served the request.
location = /packages.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_types application/json;
}
@@ -8,3 +8,13 @@ server {
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;
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;
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": {
"repositories": [
{
"name": "main",
"url": "https://example.invalid/v3.19/main",
"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
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"
apk_dir = src / "etc/apk"
apk_dir.mkdir(parents=True)
@@ -61,11 +64,13 @@ def test_apply_disables_existing_and_adds_configured_repositories(tmp_path) -> N
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")
assert content == (
"# comment\n"
"# https://old-mirror.invalid/v3.18/main\n"
"# https://old-mirror.invalid/v3.18/community\n"
"https://old-mirror.invalid/v3.18/main\n"
"https://old-mirror.invalid/v3.18/community\n"
"https://example.invalid/v3.19/main\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
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:
src = tmp_path / "src"
(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:
empty_config = Configuration.model_validate(
{"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.modules.apt import AptModule
KEY_CONTENT = (
"-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
"\n"
"mQENBGqh08IBCAC0\n"
"-----END PGP PUBLIC KEY BLOCK-----\n"
)
SOURCE_CONTENT = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
@@ -12,13 +19,7 @@ SOURCE_CONTENT = {
"name": "interdiode",
"uris": ["http://example.invalid/debian"],
"suites": ["stable"],
"signed-by": "/etc/apt/keyrings/interdiode.asc",
}
],
"keys": [
{
"name": "interdiode",
"content": "fake-key-bytes",
"signed-by": KEY_CONTENT,
}
],
}
@@ -30,6 +31,26 @@ def make_config() -> Configuration:
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:
context = Context(config=make_config(), dst=tmp_path / "dst", src=None)
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
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"
apt_dir = src / "etc/apt"
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)
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 (
disabled_top_level
== "# already disabled\n# deb http://example.invalid/legacy stable main\n\n"
top_level
== "# already disabled\ndeb http://example.invalid/legacy stable main\n\n"
)
disabled_list = (dst / "etc/apt/sources.list.d/extra.list").read_text(
encoding="utf-8"
)
assert disabled_list == "# deb http://example.invalid/extra stable main\n"
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"
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"
)
assert disabled_sources == (
"Types: deb\nURIs: http://example.invalid/docker\nSuites: stable\nEnabled: no"
assert docker_sources == (
"Types: deb\nURIs: http://example.invalid/docker\nSuites: stable\nEnabled: yes"
"\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()
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(
encoding="utf-8"
)
assert configured_source == (
"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 (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()
+21
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"
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:
src = tmp_path / "src"
local_certs_dir = src / "usr/local/share/ca-certificates"
+92
View File
@@ -15,6 +15,12 @@ CONFIG_WITH_CERT = {
**MINIMAL_CONFIG,
"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:
@@ -30,6 +36,14 @@ def test_validate_valid_config(tmp_path) -> None:
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:
result = runner.invoke(cli.app, ["validate"])
assert result.exit_code != 0
@@ -56,6 +70,24 @@ def test_validate_from_config_url(monkeypatch) -> None:
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:
result = runner.invoke(cli.app, ["list-modules"])
assert result.exit_code == 0
@@ -103,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"
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:
mock_app = MagicMock()
monkeypatch.setattr(cli, "app", mock_app)
+104 -5
View File
@@ -34,7 +34,6 @@ FULL_CONFIG = {
"signed-by": "/etc/apt/keyrings/interdiode.asc",
}
],
"keys": [{"name": "interdiode", "content": "ZmFrZQ=="}],
},
"yum": {
"repositories": [
@@ -76,7 +75,6 @@ def test_parse_configuration_from_json() -> None:
configuration.configurations.apt.sources[0].signed_by
== "/etc/apt/keyrings/interdiode.asc"
)
assert configuration.configurations.apt.keys[0].name == "interdiode"
assert configuration.configurations.yum.keys[0].name == "interdiode"
@@ -87,10 +85,38 @@ def test_parse_configuration_from_yaml() -> None:
"""
configuration = parse_configuration(raw)
assert configuration.organisation == "InterDiode"
assert configuration.description == ""
assert configuration.description is None
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:
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps(FULL_CONFIG), encoding="utf-8")
@@ -168,6 +194,44 @@ def test_load_configuration_from_url_with_custom_cacert(monkeypatch) -> None:
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:
source = AptSource(
name="interdiode", uris=["http://example.invalid/debian"], suites=["stable"]
@@ -191,7 +255,6 @@ def test_apt_source_to_deb822_full() -> None:
enabled=False,
trusted=True,
**{
"signed-by": "/etc/apt/keyrings/interdiode.asc",
"by-hash": True,
"allow-insecure": False,
"allow-weak": False,
@@ -205,7 +268,7 @@ def test_apt_source_to_deb822_full() -> None:
pdiffs=True,
description="InterDiode APT repository",
)
rendered = source.to_deb822()
rendered = source.to_deb822(signed_by_path="/etc/apt/keyrings/interdiode.asc")
assert rendered.splitlines() == [
"Types: deb deb-src",
"URIs: http://example.invalid/debian",
@@ -229,3 +292,39 @@ def test_apt_source_to_deb822_full() -> None:
"Description: InterDiode APT repository",
"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.mkdir()
src = None
if with_src:
src = tmp_path / "src"
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:
@@ -97,3 +99,131 @@ def test_glob_src_matches_pattern(tmp_path) -> None:
(context.src / "usr/lib/python-legacy").mkdir(parents=True)
result = context.glob_src("usr/lib/python3.*")
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"
+12
View File
@@ -55,6 +55,18 @@ def test_apply_writes_goproxy(tmp_path) -> None:
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)
+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:
dst = tmp_path / "dst"
context = Context(config=make_config(TWO_REPOS_CONFIG), dst=dst, src=None)
+18 -1
View File
@@ -1,5 +1,10 @@
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:
@@ -17,3 +22,15 @@ def test_register_and_all_modules_are_isolated(monkeypatch) -> None:
register(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"
+62
View File
@@ -28,6 +28,14 @@ EMPTY_CONFIG = {
"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:
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
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:
context = Context(
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"
"@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"
)
+42
View File
@@ -63,6 +63,48 @@ def test_apply_writes_nuget_config(tmp_path) -> None:
)
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)
+105 -2
View File
@@ -34,6 +34,14 @@ EMPTY_CONFIG = {
"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:
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
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:
context = Context(
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",
"type": "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"
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"
+129
View File
@@ -76,6 +76,84 @@ def test_apply_single_repository(tmp_path) -> None:
assert not (dst / "usr/lib").exists()
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:
config = Configuration.model_validate(TWO_REPOS_CONFIG)
dst = tmp_path / "dst"
@@ -105,6 +183,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:
config = Configuration.model_validate(ONE_REPO_CONFIG)
src = tmp_path / "src"
+104 -3
View File
@@ -6,7 +6,15 @@ CONFIG_WITH_REPO = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"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": {
"rust": {
"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:
dst = tmp_path / "dst"
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"
repos_dir = src / "etc/yum.repos.d"
repos_dir.mkdir(parents=True)
@@ -148,13 +150,13 @@ def test_apply_disables_existing_repos_before_adding_configured_ones(tmp_path) -
YumModule().apply(context)
disabled_multi = (dst / "etc/yum.repos.d/multi.repo").read_text(encoding="utf-8")
assert "[old-one]" in disabled_multi
assert "[old-two]" in disabled_multi
# No configured repository declares an index_url_to_replace, so the
# existing sections are copied through unchanged, still enabled.
copied_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"] == "0"
parser.read_string(copied_multi)
assert parser["old-one"]["enabled"] == "1"
assert parser["old-two"]["enabled"] == "1"
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:
monkeypatch.setattr(
"id_configurator.modules.yum.subprocess.run",
+252 -5
View File
@@ -1,5 +1,5 @@
[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,module-go,module-ruby,module-nuget,config-url
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
requires =
tox>=4
@@ -74,20 +74,24 @@ description =
The version is taken from the pyproject.toml file.
Make sure that you have the rights to upload the image to Docker Hub.
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 publication sign-docker
python3 -m uv build
python3 -m uv publish
docker =
passenv =
COSIGN_PASSWORD
COSIGN_PRIVATE_KEY
DOCKER_HUB_LOGIN
DOCKER_HUB_TOKEN
UV_PUBLISH_TOKEN
[docker:pypi-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/pypi-repo.Dockerfile
expose =
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_interval = 1
healthcheck_retries = 30
@@ -103,17 +107,41 @@ docker =
pypi-repo
allowlist_externals =
docker
set_env =
PYPI_REPO_SCHEME = https
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_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 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
[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
[docker:apt-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/apt-repo.Dockerfile
expose =
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_interval = 1
healthcheck_retries = 30
@@ -129,15 +157,37 @@ docker =
apt-repo
allowlist_externals =
docker
set_env =
APT_REPO_SCHEME = https
commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Apt.yaml.j2 {env_dir}/configs/Apt.yaml
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Apt.Dockerfile -t id-configurator-apt .
docker rmi id-configurator-apt
[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
[docker:yum-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/yum-repo.Dockerfile
expose =
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_interval = 1
healthcheck_retries = 30
@@ -153,15 +203,37 @@ docker =
yum-repo
allowlist_externals =
docker
set_env =
YUM_REPO_SCHEME = https
commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Yum.yaml.j2 {env_dir}/configs/Yum.yaml
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Yum.Dockerfile -t id-configurator-yum .
docker rmi id-configurator-yum
[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
[docker:apk-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/apk-repo.Dockerfile
expose =
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_interval = 1
healthcheck_retries = 30
@@ -186,15 +258,39 @@ description =
native platform keeps them in agreement everywhere (including under
QEMU emulation on non-amd64 hosts); CI runners are amd64, so this still
matches the other envs there.
set_env =
APK_REPO_SCHEME = https
commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Apk.yaml.j2 {env_dir}/configs/Apk.yaml
docker buildx build --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Apk.Dockerfile -t id-configurator-apk .
docker rmi id-configurator-apk
[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
[docker:java-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/java-repo.Dockerfile
expose =
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_interval = 1
healthcheck_retries = 30
@@ -210,11 +306,33 @@ docker =
java-repo
allowlist_externals =
docker
set_env =
JAVA_REPO_SCHEME = https
commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Java.yaml.j2 {env_dir}/configs/Java.yaml
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Java.Dockerfile -t id-configurator-java .
docker 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
[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
[docker:certificate-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/certificate-repo.Dockerfile
expose =
@@ -248,6 +366,7 @@ commands =
dockerfile = {tox_root}/tests/dockerfiles/repos/php-repo.Dockerfile
expose =
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_interval = 1
healthcheck_retries = 30
@@ -263,15 +382,37 @@ docker =
php-repo
allowlist_externals =
docker
set_env =
PHP_REPO_SCHEME = https
commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Php.yaml.j2 {env_dir}/configs/Php.yaml
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Php.Dockerfile -t id-configurator-php .
docker rmi id-configurator-php
[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
[docker:npm-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/npm-repo.Dockerfile
expose =
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_interval = 1
healthcheck_retries = 30
@@ -287,15 +428,36 @@ docker =
npm-repo
allowlist_externals =
docker
set_env =
NPM_REPO_SCHEME = https
commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Npm.yaml.j2 {env_dir}/configs/Npm.yaml
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Npm.Dockerfile -t id-configurator-npm .
docker rmi id-configurator-npm
[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
[docker:cargo-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/cargo-repo.Dockerfile
expose =
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_interval = 1
healthcheck_retries = 30
@@ -311,15 +473,36 @@ docker =
cargo-repo
allowlist_externals =
docker
set_env =
CARGO_REPO_SCHEME = https
commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Rust.yaml.j2 {env_dir}/configs/Rust.yaml
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Rust.Dockerfile -t id-configurator-rust .
docker rmi id-configurator-rust
[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
[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
@@ -335,15 +518,37 @@ 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
[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
[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
@@ -359,15 +564,36 @@ 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
[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
[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
@@ -383,11 +609,32 @@ 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
[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
[docker:config-url-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/config-url-repo.Dockerfile
expose =
Generated
+469 -404
View File
File diff suppressed because it is too large Load Diff