mirror of
https://github.com/d9pouces/id_configurator.git
synced 2026-11-09 01:47:21 +01:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94c2e329bd | |||
| bfd04dc49f | |||
|
8e52f4335b
|
|||
|
4cb27c9507
|
|||
|
4f079469b7
|
|||
|
dbc3e121ca
|
|||
|
3b6eb7290f
|
|||
|
653579b59e
|
|||
|
e1ef0e109b
|
|||
| 7dbc182de4 | |||
|
0f019ac6ea
|
|||
|
e58820ac98
|
|||
| b5aec64fd8 | |||
|
1e9e87b11c
|
|||
|
dc0f8b7629
|
|||
|
abbd424f14
|
|||
| ddfa9f75b1 | |||
|
ebea3071bc
|
|||
|
8598f323d9
|
|||
|
665eb215bd
|
|||
| 80f9bf3a83 | |||
|
98b773445f
|
|||
|
6813c86fba
|
|||
|
8e182f6615
|
|||
|
352173a85c
|
|||
|
d705aa970d
|
|||
|
bf56cdfcd2
|
|||
| 3e0bb3b04c | |||
|
c498d42da0
|
|||
|
990180dbba
|
|||
| 12f0ac46a6 | |||
|
0fb964c2c2
|
|||
| e6a7d4d4d9 | |||
|
5399336c2c
|
|||
|
1c3aa1ef7d
|
|||
|
170dd577d7
|
|||
| 35ac0c5033 | |||
|
11e7d93d85
|
|||
|
357d2392eb
|
|||
| 235df16f6f | |||
|
d7ef22b527
|
|||
|
39fdbb7f40
|
|||
|
7ddd3ac09b
|
|||
|
204d648477
|
|||
|
eff9ca66d2
|
|||
|
776cdc3b17
|
|||
|
db4ff3cfd4
|
|||
|
cea45c97ff
|
|||
|
b2dcae0daf
|
|||
|
be91425355
|
|||
|
4409c38579
|
|||
|
380e976f76
|
|||
|
ab2a179a55
|
|||
|
9de98d4744
|
|||
|
88380b6ab6
|
|||
|
e3b2e8a95a
|
|||
|
4e4b0592c0
|
|||
|
a8143b61b1
|
|||
| c9dbeb1a8b |
@@ -15,12 +15,19 @@ jobs:
|
|||||||
- uses: actions/checkout@v7.0.1
|
- uses: actions/checkout@v7.0.1
|
||||||
with:
|
with:
|
||||||
lfs: true
|
lfs: true
|
||||||
|
- uses: sigstore/cosign-installer@main
|
||||||
- uses: actions/setup-python@v7.0.0
|
- uses: actions/setup-python@v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: 3.${{ matrix.python-version }}
|
python-version: 3.${{ matrix.python-version }}
|
||||||
|
- run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sudo sh -s -- -b /usr/local/bin v0.75.0
|
||||||
- run: python3 -m pip install --upgrade tox tox-docker-id tox-uv
|
- run: python3 -m pip install --upgrade tox tox-docker-id tox-uv
|
||||||
|
- run: tox -e packaging-amd64
|
||||||
|
- run: tox -e module-rust
|
||||||
- run: tox -e packaging-amd64,py313,py314,coverage
|
- run: tox -e packaging-amd64,py313,py314,coverage
|
||||||
- run: tox -e module-apk,module-apt,module-certificates,module-java,module-npm,module-php,module-python,module-rust,module-yum
|
- run: tox -m end-to-end
|
||||||
|
- run: docker builder prune --all --force
|
||||||
|
- run: docker system prune --all --force
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ['14']
|
python-version: ['14']
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: Publish Python packages and Docker image
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
env:
|
||||||
|
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||||
|
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||||
|
DOCKER_HUB_LOGIN: ${{ secrets.DOCKER_HUB_LOGIN }}
|
||||||
|
DOCKER_HUB_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||||
|
UV_PUBLISH_TOKEN: ${{ secrets.UV_PUBLISH_TOKEN }}
|
||||||
|
name: Package and public, Python 3${{ matrix.python-version }}
|
||||||
|
runs-on: ubuntu-26.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7.0.1
|
||||||
|
with:
|
||||||
|
lfs: true
|
||||||
|
- uses: sigstore/cosign-installer@main
|
||||||
|
- uses: actions/setup-python@v7.0.0
|
||||||
|
with:
|
||||||
|
python-version: 3.${{ matrix.python-version }}
|
||||||
|
- run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sudo sh -s -- -b /usr/local/bin v0.75.0
|
||||||
|
- run: python3 -m pip install --upgrade tox tox-docker-id tox-uv
|
||||||
|
- run: tox -e packaging-amd64,py313,py314,coverage
|
||||||
|
- run: tox -m end-to-end
|
||||||
|
- run: docker builder prune --all --force
|
||||||
|
- run: docker system prune --all --force
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- run: tox -e publish
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ['14']
|
||||||
|
timeout-minutes: 75
|
||||||
@@ -77,7 +77,7 @@ repos:
|
|||||||
rev: 1.8.6
|
rev: 1.8.6
|
||||||
hooks:
|
hooks:
|
||||||
- id: bandit
|
- id: bandit
|
||||||
args: [-s, 'B101,B404,B405,B506,B703,B308,B603']
|
args: [-s, 'B101,B105,B404,B405,B506,B703,B308,B603,B604']
|
||||||
exclude: ^(id_tests/.*|npm/node_modules/.*|tools/.*|doc/.*|interdiode/static/vendor/.*)
|
exclude: ^(id_tests/.*|npm/node_modules/.*|tools/.*|doc/.*|interdiode/static/vendor/.*)
|
||||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||||
rev: v0.11.0.1
|
rev: v0.11.0.1
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
Docker image) in an **air-gapped environment**: no network access is required or ever used.
|
Docker image) in an **air-gapped environment**: no network access is required or ever used.
|
||||||
|
|
||||||
It reads a single configuration (JSON or YAML), and applies it to a filesystem tree, writing only the files
|
It reads a single configuration (JSON or YAML), and applies it to a filesystem tree, writing only the files
|
||||||
that need to change: root certificates, package manager sources (APT, YUM/DNF, APK), and language-specific
|
that need to change: root certificates, system package manager sources (APT, YUM/DNF, APK) and
|
||||||
package manager settings (pip/uv/poetry, Maven/Gradle/Ivy/SBT/Leiningen, ...). The result can then be used
|
language-specific package manager settings (pip/uv/poetry, Maven/Gradle/Ivy/SBT/Leiningen, Composer, npm,
|
||||||
to build a Docker image or otherwise deployed to the target device.
|
cargo, Go modules, RubyGems, NuGet). The result can then be used to build a Docker image or otherwise
|
||||||
|
deployed to the target device.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -14,15 +15,70 @@ to build a Docker image or otherwise deployed to the target device.
|
|||||||
never writes anywhere else.
|
never writes anywhere else.
|
||||||
- `--src` (optional) points to an existing filesystem, used to detect the current configuration (existing
|
- `--src` (optional) points to an existing filesystem, used to detect the current configuration (existing
|
||||||
APT sources, existing Java keystores, etc.) so it can be extended or disabled instead of blindly overwritten.
|
APT sources, existing Java keystores, etc.) so it can be extended or disabled instead of blindly overwritten.
|
||||||
- The configuration is loaded either from a file (`--config`) or from a base64-encoded string, typically an
|
- The configuration is loaded from a file (`--config`), from a base64-encoded string, typically an
|
||||||
environment variable (`--config-env` / `$ID_CONFIGURATOR_CONFIG`).
|
environment variable (`--config-env` / `$ID_CONFIGURATOR_CONFIG`), or downloaded over HTTP(S) from a URL
|
||||||
- Each feature is implemented as an independent, self-registering **module**. On `apply`, every module
|
(`--config-url`). `--config-url-cacert-content` optionally gives the PEM content of the CA certificate to trust for that one download, for an internal
|
||||||
inspects the configuration and the source filesystem and decides for itself whether it has something to
|
endpoint signed by a private CA the running system doesn't otherwise trust yet (e.g. before the
|
||||||
do; only relevant modules touch the destination directory.
|
`certificates` module has ever run) - it defaults to the system's own trust store otherwise.
|
||||||
|
`--config-url-insecure` disables certificate (and hostname) verification entirely instead, overriding
|
||||||
|
`--config-url-cacert-content` - **insecure**, only meant for a first bootstrap against a server whose
|
||||||
|
certificate isn't trustable, or trusted, yet by any other means.
|
||||||
|
- `--user` (repeatable) names a user to also prepare *per-user* configuration for, on top of the usual
|
||||||
|
system-wide files, for the modules that have one: `go`, `nuget`, `php` (Composer), `python` (poetry) and
|
||||||
|
`rust`. The user's home directory is taken from `--src`'s own `/etc/passwd` entry when there is one,
|
||||||
|
otherwise assumed to be `/home/<user>` (`/root` for `root`).
|
||||||
|
- `--auto-users` does the same for every *regular* (non-system) user found in `--src`'s `/etc/passwd`: one with
|
||||||
|
a login shell (not `nologin`, `sync` or `false`) and a UID at or above `UID_MIN` (read from `--src`'s
|
||||||
|
`/etc/login.defs`, 1000 by default) - except `root`, always included regardless of its UID. It combines
|
||||||
|
freely with one or more `--user`.
|
||||||
|
- On `apply`, every module inspects the configuration and the source filesystem and decides for itself whether it has something to
|
||||||
|
do; only relevant modules touch the destination directory. For example, the configured Alpine repositories are only written if
|
||||||
|
`--src` can be identified as an Alpine filesystem (i.e. it has an `/etc/apk` directory); and a source cloning Debian's official
|
||||||
|
repository is only configured if that official repository is actually present in the filesystem given via `--src`
|
||||||
|
(via `uri-to-replace`, see "Replacing an existing repository" below).
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Requires Python 3.13+.
|
### As a CLI tool
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# with pip
|
||||||
|
pip install id-configurator
|
||||||
|
|
||||||
|
# or with uv tool, in its own isolated environment
|
||||||
|
uv tool install id-configurator
|
||||||
|
```
|
||||||
|
|
||||||
|
Once installed this way, run it directly as `id-configurator` (rather than `uv run id-configurator`, which is
|
||||||
|
only for the "from source" setup below).
|
||||||
|
|
||||||
|
### As a Docker image
|
||||||
|
|
||||||
|
`id-configurator` is also published as [`interdiode/id-configurator`](https://hub.docker.com/r/interdiode/id-configurator)
|
||||||
|
on Docker Hub - handy since its output is typically meant to become another Docker image in the first place.
|
||||||
|
One-off use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v "$(pwd)/config.yaml:/config.yaml:ro" -v "$(pwd)/output:/dst" -v "/:/src" \
|
||||||
|
interdiode/id-configurator:latest \
|
||||||
|
id-configurator apply --config /config.yaml --dst /dst --src /src
|
||||||
|
```
|
||||||
|
|
||||||
|
More typically, as a builder stage of the target image's own `Dockerfile` - `<src>` here is the target image
|
||||||
|
itself, taken as of its `target` stage, before `id-configurator`'s own changes are copied back on top of it:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM debian:trixie-slim AS target
|
||||||
|
FROM interdiode/id-configurator:latest AS configuration
|
||||||
|
COPY --from=target / /src/
|
||||||
|
COPY config.yaml /config.yaml
|
||||||
|
RUN id-configurator apply --config /config.yaml --src /src --dst /dst
|
||||||
|
FROM target
|
||||||
|
COPY --from=configuration /dst/ /
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### From source (development)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync
|
uv sync
|
||||||
@@ -32,32 +88,60 @@ uv sync
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Apply a configuration file to a destination directory
|
# Apply a configuration file to a destination directory
|
||||||
uv run id-configurator apply --config config.yaml --dst ./output
|
id-configurator apply --config config.yaml --dst ./output
|
||||||
|
|
||||||
# Take an existing filesystem into account (e.g. to extend its APT sources)
|
# Take an existing filesystem into account (e.g. to extend its APT sources)
|
||||||
uv run id-configurator apply --config config.yaml --src /path/to/rootfs --dst ./output
|
id-configurator apply --config config.yaml --src /path/to/rootfs --dst ./output
|
||||||
|
|
||||||
# Use a base64-encoded configuration from the environment instead of a file
|
# Use a base64-encoded configuration from the environment instead of a file
|
||||||
export ID_CONFIGURATOR_CONFIG=$(base64 -w0 config.yaml)
|
export ID_CONFIGURATOR_CONFIG=$(base64 -w0 config.yaml)
|
||||||
uv run id-configurator apply --dst ./output
|
id-configurator apply --dst ./output
|
||||||
|
|
||||||
|
# Download the configuration from an internal HTTPS server instead of a local file
|
||||||
|
id-configurator apply \
|
||||||
|
--config-url https://config.internal.example/device.yaml \
|
||||||
|
--config-url-cacert-content "$(cat internal-ca.pem)" \
|
||||||
|
--dst ./output
|
||||||
|
|
||||||
|
# Also prepare per-user configuration (go, nuget, php, python, rust) for specific users
|
||||||
|
id-configurator apply --config config.yaml --dst ./output --user alice --user bob
|
||||||
|
|
||||||
|
# Same, but auto-detect every regular (non-system) user from --src's /etc/passwd
|
||||||
|
id-configurator apply --config config.yaml --src /path/to/rootfs --dst ./output --auto-users
|
||||||
|
|
||||||
# Validate a configuration without applying it
|
# Validate a configuration without applying it
|
||||||
uv run id-configurator validate --config config.yaml
|
id-configurator validate --config config.yaml
|
||||||
|
|
||||||
# List the available modules
|
# List the available modules
|
||||||
uv run id-configurator list-modules
|
id-configurator list-modules
|
||||||
|
|
||||||
|
# Same thing, via the Docker image instead of installing anything locally
|
||||||
|
docker run --rm \
|
||||||
|
-v "$(pwd)/config.yaml:/config.yaml:ro" \
|
||||||
|
-v "$(pwd)/input:/src" \
|
||||||
|
-v "$(pwd)/output:/dst" \
|
||||||
|
interdiode/id-configurator:latest \
|
||||||
|
id-configurator apply --config /config.yaml --dst /dst --src /src
|
||||||
```
|
```
|
||||||
|
|
||||||
The tool can also be invoked as `python -m id_configurator`.
|
The tool can also be invoked as `python -m id_configurator`.
|
||||||
|
|
||||||
## Configuration format
|
## Configuration format
|
||||||
|
|
||||||
The configuration is a JSON or YAML object:
|
The simplest way to generate a new configuration is [InterDiode](https://interdiode.fr) itself, through its
|
||||||
|
simple interface - it also handles duplicating application mirrors onto disconnected networks.
|
||||||
|
|
||||||
|
The configuration is a JSON or YAML object. `uuid`, `organisation` and `description` are all optional
|
||||||
|
(identifying metadata only - nothing in the tool itself relies on any of them), as is every `configurations.*`
|
||||||
|
block - a module that finds nothing to do for it simply does not run. `version` is the configuration
|
||||||
|
*format*'s own schema version (currently `"1.0"`, and the default if omitted) - independent of the
|
||||||
|
`id-configurator` package version - so a future, incompatible format change can be told apart from this one:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
uuid: <UUID>
|
version: "1.0"
|
||||||
organisation: <string>
|
uuid: <UUID, optional>
|
||||||
description: <string>
|
organisation: <string, optional>
|
||||||
|
description: <string, optional>
|
||||||
configurations:
|
configurations:
|
||||||
certificates:
|
certificates:
|
||||||
- name: <string>
|
- name: <string>
|
||||||
@@ -68,18 +152,21 @@ configurations:
|
|||||||
uris: [<string>]
|
uris: [<string>]
|
||||||
suites: [<string>]
|
suites: [<string>]
|
||||||
components: [<string>]
|
components: [<string>]
|
||||||
keys:
|
signed-by: <ASCII-armored GPG public key> # or signed-by-file: <path already on the target>
|
||||||
- name: <string>
|
uri-to-replace: <fnmatch pattern, optional> # see "Replacing an existing repository" below
|
||||||
content: <GPG key>
|
|
||||||
apk:
|
apk:
|
||||||
repositories:
|
repositories:
|
||||||
- url: <string>
|
- name: <string>
|
||||||
key: <RSA public key>
|
url: <string>
|
||||||
|
key: <RSA public key, optional>
|
||||||
|
key_filename: <string, optional> # name of the key file under /etc/apk/keys/, if not the derived default
|
||||||
|
url_to_replace: <fnmatch pattern, optional>
|
||||||
yum:
|
yum:
|
||||||
repositories:
|
repositories:
|
||||||
- name: <string>
|
- name: <string>
|
||||||
index-url: <string>
|
index-url: <string>
|
||||||
gpg-url: <string>
|
gpg-url: <string, optional>
|
||||||
|
index-url-to-replace: <fnmatch pattern, optional>
|
||||||
keys:
|
keys:
|
||||||
- name: <string>
|
- name: <string>
|
||||||
content: <GPG key>
|
content: <GPG key>
|
||||||
@@ -91,20 +178,81 @@ configurations:
|
|||||||
repositories:
|
repositories:
|
||||||
- name: <string>
|
- name: <string>
|
||||||
url: <string>
|
url: <string>
|
||||||
|
php:
|
||||||
|
repositories:
|
||||||
|
- name: <string>
|
||||||
|
url: <string>
|
||||||
|
npm:
|
||||||
|
repositories:
|
||||||
|
- name: <string>
|
||||||
|
url: <string>
|
||||||
|
rust:
|
||||||
|
repositories:
|
||||||
|
- name: <string>
|
||||||
|
url: <string>
|
||||||
|
is_sparse: <bool, optional, default false> # declares the registry's index as sparse+<url>
|
||||||
|
go:
|
||||||
|
repositories:
|
||||||
|
- name: <string>
|
||||||
|
url: <string>
|
||||||
|
ruby:
|
||||||
|
repositories:
|
||||||
|
- name: <string>
|
||||||
|
url: <string>
|
||||||
|
nuget:
|
||||||
|
repositories:
|
||||||
|
- name: <string>
|
||||||
|
url: <string>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Replacing an existing repository
|
||||||
|
|
||||||
|
`apt.sources[].uri-to-replace`, `apk.repositories[].url_to_replace` and `yum.repositories[].index-url-to-replace`
|
||||||
|
are optional [`fnmatch`](https://docs.python.org/3/library/fnmatch.html) patterns. When set, the configured
|
||||||
|
repository is only written if the pattern matches at least one URI/URL already present in `--src`'s existing
|
||||||
|
configuration - and only the matching existing entries are disabled, the rest are left untouched. This lets a
|
||||||
|
configuration safely target "whatever machine this is applied to": e.g. `uri-to-replace: "http://deb.debian.org/*"`
|
||||||
|
replaces Debian's default mirror if (and only if) it's actually there, without disturbing any other source a
|
||||||
|
given machine might already have. Leaving the field unset never disables anything - the configured repository
|
||||||
|
is simply added alongside whatever is already present.
|
||||||
|
|
||||||
|
### Non-HTTPS repositories
|
||||||
|
|
||||||
|
A repository URL doesn't have to be HTTPS - several modules automatically adjust the tool's configuration so
|
||||||
|
the plain-HTTP repository is actually usable, instead of silently producing a config the package manager
|
||||||
|
would refuse at run time:
|
||||||
|
|
||||||
|
- `python`: pip gets the repository's host added to `trusted-host`.
|
||||||
|
- `php`: Composer gets `secure-http` disabled, and `packagist.org` is disabled alongside it (an HTTP source
|
||||||
|
implies a private, non-internet setup where the public registry wouldn't be reachable anyway).
|
||||||
|
- `nuget`: the source gets `allowInsecureConnections="true"`.
|
||||||
|
- `java`: SBT's resolver gets `allowInsecureProtocol` appended. **Leiningen is the one exception**: its
|
||||||
|
standalone launcher refuses any non-HTTPS repository unconditionally, with no configuration able to change
|
||||||
|
that - a repository configured here simply won't be usable from Leiningen specifically.
|
||||||
|
- `go`, `rust`, `apt`, `apk`, `yum`: no adjustment is needed - these already work the same regardless of
|
||||||
|
scheme.
|
||||||
|
|
||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
| Module | Applies to |
|
| Module | Applies to |
|
||||||
|----------------|--------------------------------------------------------------------------------------------------------|
|
|----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `certificates` | Installs root certificates into the system's CA store (`update-ca-certificates`). |
|
| `certificates` | Installs root certificates into the system's CA store, rebuilding the trust bundle in pure Python (Debian/Ubuntu and RHEL/Fedora trust stores). |
|
||||||
| `apt` | Disables existing APT sources and adds the configured ones, in DEB822 format. |
|
| `apt` | Adds the configured APT sources (DEB822 format); disables only the existing entries matching `uri-to-replace`. |
|
||||||
| `apk` | Disables existing Alpine `/etc/apk/repositories` entries and adds the configured ones, plus their keys.|
|
| `apk` | Adds the configured Alpine `/etc/apk/repositories` entries and their keys; disables only the existing lines matching `url_to_replace`. |
|
||||||
| `yum` | Disables existing YUM/DNF `.repo` files, adds the configured ones, and imports their GPG keys. |
|
| `yum` | Adds the configured YUM/DNF `.repo` files and imports their GPG keys; disables only the existing sections matching `index-url-to-replace`. Covers DNF too - same config files. |
|
||||||
| `python` | Points pip, uv, poetry and `easy_install` to the configured Python package indexes. |
|
| `python` | Points pip, uv, poetry and `easy_install` to the configured Python package indexes. |
|
||||||
| `java` | Adds root certificates to every detected JKS/PKCS12 keystore, and configures Maven, Gradle, Ivy, SBT and Leiningen repositories. |
|
| `java` | Adds root certificates to every detected JKS/PKCS12 keystore, and configures Maven, Gradle, Ivy, SBT and Leiningen repositories. |
|
||||||
|
| `php` | Registers the configured repositories with Composer; sets `openssl.cafile` whenever any certificate is configured, so PHP's own OpenSSL calls trust it too. |
|
||||||
|
| `npm` | Points npm at the configured registries and its own `cafile`; sets `NODE_EXTRA_CA_CERTS` system-wide whenever any certificate is configured, since Node ignores the system CA store. |
|
||||||
|
| `rust` | Points cargo at the configured registries, replacing crates.io. |
|
||||||
|
| `go` | Points the Go toolchain at the configured module proxies (`GOPROXY`), replacing proxy.golang.org, and disables `GOSUMDB`. |
|
||||||
|
| `ruby` | Points RubyGems at the configured sources, replacing rubygems.org. |
|
||||||
|
| `nuget` | Points NuGet at the configured package sources, replacing nuget.org. |
|
||||||
|
|
||||||
More modules (npm, nuget, ruby/gem, rust/crates, go, packagist/composer, ...) are planned; see `CLAUDE.md` for the full roadmap.
|
`go`, `nuget`, `php`, `python` and `rust` additionally honor `--user`/`--auto-users` (see "How it works" above),
|
||||||
|
writing the same per-user configuration under each such user's home directory.
|
||||||
|
|
||||||
|
This tool's own modular structure is meant to be extended: see `CLAUDE.md` for the module interface and how to add one.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -118,4 +266,4 @@ uv run pre-commit run --all-files # run all pre-commit hooks
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Proprietary. See [LICENSE](LICENSE). Copyright InterDiode SA.
|
[CeCILL v2.1](https://cecill.info) (GPL-compatible free software license). See [LICENSE](LICENSE). Copyright InterDiode SA.
|
||||||
|
|||||||
+82
-10
@@ -38,6 +38,34 @@ ConfigEnvOption = Annotated[
|
|||||||
help="Base64-encoded JSON/YAML configuration.",
|
help="Base64-encoded JSON/YAML configuration.",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
ConfigUrlOption = Annotated[
|
||||||
|
str | None,
|
||||||
|
typer.Option(
|
||||||
|
"--config-url",
|
||||||
|
help="URL to download a JSON or YAML configuration from.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
ConfigUrlCacertContentOption = Annotated[
|
||||||
|
str | None,
|
||||||
|
typer.Option(
|
||||||
|
"--config-url-cacert-content",
|
||||||
|
help=(
|
||||||
|
"PEM content of the CA certificate to trust when downloading "
|
||||||
|
"--config-url over HTTPS (defaults to the system's trust store)."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
ConfigUrlInsecureOption = Annotated[
|
||||||
|
bool,
|
||||||
|
typer.Option(
|
||||||
|
"--config-url-insecure",
|
||||||
|
help=(
|
||||||
|
"Disable TLS certificate verification when downloading --config-url "
|
||||||
|
"(INSECURE: only for bootstrapping from a server whose certificate "
|
||||||
|
"isn't trustable yet; overrides --config-url-cacert-content)."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
SrcOption = Annotated[
|
SrcOption = Annotated[
|
||||||
Path | None,
|
Path | None,
|
||||||
typer.Option(
|
typer.Option(
|
||||||
@@ -55,6 +83,26 @@ DstOption = Annotated[
|
|||||||
help="Destination directory receiving the modified files.",
|
help="Destination directory receiving the modified files.",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
UserOption = Annotated[
|
||||||
|
list[str],
|
||||||
|
typer.Option(
|
||||||
|
"--user",
|
||||||
|
help="A user to prepare per-user configuration for (may be repeated).",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
AutoUsersOption = Annotated[
|
||||||
|
bool,
|
||||||
|
typer.Option(
|
||||||
|
"--auto-users",
|
||||||
|
help=(
|
||||||
|
"Also prepare per-user configuration for every regular "
|
||||||
|
"(non-system) user found in <src>/etc/passwd: one with a login "
|
||||||
|
"shell (not nologin, sync or false) and a UID at or above "
|
||||||
|
"UID_MIN (from <src>/etc/login.defs, 1000 by default) - root is "
|
||||||
|
"always included regardless of its UID."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
VerboseOption = Annotated[
|
VerboseOption = Annotated[
|
||||||
bool, typer.Option("--verbose", "-v", help="Enable verbose (debug) logging.")
|
bool, typer.Option("--verbose", "-v", help="Enable verbose (debug) logging.")
|
||||||
]
|
]
|
||||||
@@ -67,12 +115,21 @@ def _configure_logging(verbose: bool) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _load_configuration(config: Path | None, config_env: str | None):
|
def _load_configuration(
|
||||||
if config is None and config_env is None:
|
config: Path | None,
|
||||||
|
config_env: str | None,
|
||||||
|
config_url: str | None,
|
||||||
|
config_url_cacert_content: str | None,
|
||||||
|
config_url_insecure: bool = False,
|
||||||
|
):
|
||||||
|
if config is None and config_env is None and config_url is None:
|
||||||
raise typer.BadParameter(
|
raise typer.BadParameter(
|
||||||
"either --config or --config-env (or $ID_CONFIGURATOR_CONFIG) must be provided"
|
"either --config, --config-env (or $ID_CONFIGURATOR_CONFIG) or "
|
||||||
|
"--config-url must be provided"
|
||||||
|
)
|
||||||
|
return load_configuration(
|
||||||
|
config, config_env, config_url, config_url_cacert_content, config_url_insecure
|
||||||
)
|
)
|
||||||
return load_configuration(config, config_env)
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
@@ -80,14 +137,23 @@ def apply(
|
|||||||
dst: DstOption,
|
dst: DstOption,
|
||||||
config: ConfigFileOption = None,
|
config: ConfigFileOption = None,
|
||||||
config_env: ConfigEnvOption = None,
|
config_env: ConfigEnvOption = None,
|
||||||
|
config_url: ConfigUrlOption = None,
|
||||||
|
config_url_cacert_content: ConfigUrlCacertContentOption = None,
|
||||||
|
config_url_insecure: ConfigUrlInsecureOption = False,
|
||||||
src: SrcOption = None,
|
src: SrcOption = None,
|
||||||
|
user: UserOption = [], # noqa: B006 (Typer needs a concrete default to detect --user as repeatable)
|
||||||
|
auto_users: AutoUsersOption = False,
|
||||||
verbose: VerboseOption = False,
|
verbose: VerboseOption = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Apply a configuration to the destination directory."""
|
"""Apply a configuration to the destination directory."""
|
||||||
_configure_logging(verbose)
|
_configure_logging(verbose)
|
||||||
configuration = _load_configuration(config, config_env)
|
configuration = _load_configuration(
|
||||||
|
config, config_env, config_url, config_url_cacert_content, config_url_insecure
|
||||||
|
)
|
||||||
dst.mkdir(parents=True, exist_ok=True)
|
dst.mkdir(parents=True, exist_ok=True)
|
||||||
context = Context(config=configuration, dst=dst, src=src)
|
context = Context(
|
||||||
|
config=configuration, dst=dst, src=src, users=user, auto_users=auto_users
|
||||||
|
)
|
||||||
for module_cls in all_modules():
|
for module_cls in all_modules():
|
||||||
module = module_cls()
|
module = module_cls()
|
||||||
if module.is_relevant(context):
|
if module.is_relevant(context):
|
||||||
@@ -99,13 +165,19 @@ def apply(
|
|||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def validate(
|
def validate(
|
||||||
config: ConfigFileOption = None, config_env: ConfigEnvOption = None
|
config: ConfigFileOption = None,
|
||||||
|
config_env: ConfigEnvOption = None,
|
||||||
|
config_url: ConfigUrlOption = None,
|
||||||
|
config_url_cacert_content: ConfigUrlCacertContentOption = None,
|
||||||
|
config_url_insecure: ConfigUrlInsecureOption = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Validate a configuration file without applying it."""
|
"""Validate a configuration file without applying it."""
|
||||||
configuration = _load_configuration(config, config_env)
|
configuration = _load_configuration(
|
||||||
typer.echo(
|
config, config_env, config_url, config_url_cacert_content, config_url_insecure
|
||||||
f"configuration {configuration.uuid} is valid (organisation: {configuration.organisation})"
|
|
||||||
)
|
)
|
||||||
|
uuid = configuration.uuid if configuration.uuid is not None else "<no uuid>"
|
||||||
|
organisation = configuration.organisation or "<no organisation>"
|
||||||
|
typer.echo(f"configuration {uuid} is valid (organisation: {organisation})")
|
||||||
|
|
||||||
|
|
||||||
@app.command("list-modules")
|
@app.command("list-modules")
|
||||||
|
|||||||
+206
-44
@@ -4,11 +4,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import ssl
|
||||||
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
_CONFIG_URL_TIMEOUT_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
class NamedContent(BaseModel):
|
class NamedContent(BaseModel):
|
||||||
@@ -22,8 +26,31 @@ def _yes_no(value: bool) -> str:
|
|||||||
return "yes" if value else "no"
|
return "yes" if value else "no"
|
||||||
|
|
||||||
|
|
||||||
|
def _fold_deb822_multiline(field: str, value: str) -> str:
|
||||||
|
"""Render `field` as a DEB822 folded multi-line field (sources.list(5)):
|
||||||
|
each of `value`'s own lines indented by one space, with a truly blank
|
||||||
|
line represented as a lone `.` - exactly how an ASCII-armored GPG key
|
||||||
|
block is embedded directly in a `Signed-By` field, verified against a
|
||||||
|
real `apt-get update` run against a genuinely signed repository."""
|
||||||
|
folded = "\n".join(f" {line}" if line else " ." for line in value.splitlines())
|
||||||
|
return f"{field}:\n{folded}"
|
||||||
|
|
||||||
|
|
||||||
class AptSource(BaseModel):
|
class AptSource(BaseModel):
|
||||||
"""A single APT source, in the DEB822 format (see sources.list(5))."""
|
"""A single APT source, in the DEB822 format (see sources.list(5)).
|
||||||
|
|
||||||
|
Two ways to tell APT which key to trust this source with:
|
||||||
|
- `signed_by`: the ASCII-armored (.asc) *content* of a GPG public key,
|
||||||
|
folded inline as a multi-line `Signed-By` field - no separate keyring
|
||||||
|
file, no `gpg` binary needed to apply this configuration.
|
||||||
|
- `signed_by_file`: the *path* to a keyring already expected to exist on
|
||||||
|
the target system, written to the rendered `Signed-By` field verbatim.
|
||||||
|
If both are set, `signed_by` takes precedence.
|
||||||
|
|
||||||
|
`uri_to_replace`, if set, is an fnmatch pattern: this source is only
|
||||||
|
applied if it matches at least one URI already present in the machine's
|
||||||
|
existing apt configuration; otherwise it is skipped entirely.
|
||||||
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
@@ -38,6 +65,7 @@ class AptSource(BaseModel):
|
|||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
trusted: bool | None = None
|
trusted: bool | None = None
|
||||||
signed_by: str | None = Field(default=None, alias="signed-by")
|
signed_by: str | None = Field(default=None, alias="signed-by")
|
||||||
|
signed_by_file: str | None = Field(default=None, alias="signed-by-file")
|
||||||
pdiffs: bool | None = None
|
pdiffs: bool | None = None
|
||||||
by_hash: bool | None = Field(default=None, alias="by-hash")
|
by_hash: bool | None = Field(default=None, alias="by-hash")
|
||||||
allow_insecure: bool | None = Field(default=None, alias="allow-insecure")
|
allow_insecure: bool | None = Field(default=None, alias="allow-insecure")
|
||||||
@@ -51,67 +79,92 @@ class AptSource(BaseModel):
|
|||||||
check_date: bool | None = Field(default=None, alias="check-date")
|
check_date: bool | None = Field(default=None, alias="check-date")
|
||||||
date_max_future: str | None = Field(default=None, alias="date-max-future")
|
date_max_future: str | None = Field(default=None, alias="date-max-future")
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
uri_to_replace: str | None = Field(default=None, alias="uri-to-replace")
|
||||||
|
|
||||||
def to_deb822(self) -> str:
|
def to_deb822(
|
||||||
"""Render this source as a DEB822 stanza (as used in /etc/apt/sources.list.d/*.sources)."""
|
self,
|
||||||
fields = [
|
signed_by_armored_key: str | None = None,
|
||||||
("Types", " ".join(self.types)),
|
signed_by_path: str | None = None,
|
||||||
("URIs", " ".join(self.uris)),
|
) -> str:
|
||||||
("Suites", " ".join(self.suites)),
|
"""Render this source as a DEB822 stanza (as used in
|
||||||
|
/etc/apt/sources.list.d/*.sources).
|
||||||
|
|
||||||
|
Neither `self.signed_by` nor `self.signed_by_file` is ever rendered
|
||||||
|
directly - the caller (the `apt` module) resolves which one applies
|
||||||
|
and passes the result in: `signed_by_armored_key` is folded inline
|
||||||
|
as a multi-line `Signed-By` field (see `_fold_deb822_multiline`);
|
||||||
|
`signed_by_path` is written as a plain single-line `Signed-By`
|
||||||
|
field. If both are given, `signed_by_armored_key` takes precedence.
|
||||||
|
"""
|
||||||
|
lines = [
|
||||||
|
f"Types: {' '.join(self.types)}",
|
||||||
|
f"URIs: {' '.join(self.uris)}",
|
||||||
|
f"Suites: {' '.join(self.suites)}",
|
||||||
]
|
]
|
||||||
if self.components:
|
if self.components:
|
||||||
fields.append(("Components", " ".join(self.components)))
|
lines.append(f"Components: {' '.join(self.components)}")
|
||||||
if self.architectures:
|
if self.architectures:
|
||||||
fields.append(("Architectures", " ".join(self.architectures)))
|
lines.append(f"Architectures: {' '.join(self.architectures)}")
|
||||||
if self.languages:
|
if self.languages:
|
||||||
fields.append(("Languages", " ".join(self.languages)))
|
lines.append(f"Languages: {' '.join(self.languages)}")
|
||||||
if self.targets:
|
if self.targets:
|
||||||
fields.append(("Targets", " ".join(self.targets)))
|
lines.append(f"Targets: {' '.join(self.targets)}")
|
||||||
if self.signed_by:
|
if signed_by_armored_key:
|
||||||
fields.append(("Signed-By", self.signed_by))
|
lines.append(_fold_deb822_multiline("Signed-By", signed_by_armored_key))
|
||||||
|
elif signed_by_path:
|
||||||
|
lines.append(f"Signed-By: {signed_by_path}")
|
||||||
if self.trusted is not None:
|
if self.trusted is not None:
|
||||||
fields.append(("Trusted", _yes_no(self.trusted)))
|
lines.append(f"Trusted: {_yes_no(self.trusted)}")
|
||||||
if self.pdiffs is not None:
|
if self.pdiffs is not None:
|
||||||
fields.append(("PDiffs", _yes_no(self.pdiffs)))
|
lines.append(f"PDiffs: {_yes_no(self.pdiffs)}")
|
||||||
if self.by_hash is not None:
|
if self.by_hash is not None:
|
||||||
fields.append(("By-Hash", _yes_no(self.by_hash)))
|
lines.append(f"By-Hash: {_yes_no(self.by_hash)}")
|
||||||
if self.allow_insecure is not None:
|
if self.allow_insecure is not None:
|
||||||
fields.append(("Allow-Insecure", _yes_no(self.allow_insecure)))
|
lines.append(f"Allow-Insecure: {_yes_no(self.allow_insecure)}")
|
||||||
if self.allow_weak is not None:
|
if self.allow_weak is not None:
|
||||||
fields.append(("Allow-Weak", _yes_no(self.allow_weak)))
|
lines.append(f"Allow-Weak: {_yes_no(self.allow_weak)}")
|
||||||
if self.allow_downgrade_to_insecure is not None:
|
if self.allow_downgrade_to_insecure is not None:
|
||||||
fields.append(
|
lines.append(
|
||||||
(
|
f"Allow-Downgrade-To-Insecure: {_yes_no(self.allow_downgrade_to_insecure)}"
|
||||||
"Allow-Downgrade-To-Insecure",
|
|
||||||
_yes_no(self.allow_downgrade_to_insecure),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if self.check_valid_until is not None:
|
if self.check_valid_until is not None:
|
||||||
fields.append(("Check-Valid-Until", _yes_no(self.check_valid_until)))
|
lines.append(f"Check-Valid-Until: {_yes_no(self.check_valid_until)}")
|
||||||
if self.valid_until_min:
|
if self.valid_until_min:
|
||||||
fields.append(("Valid-Until-Min", self.valid_until_min))
|
lines.append(f"Valid-Until-Min: {self.valid_until_min}")
|
||||||
if self.valid_until_max:
|
if self.valid_until_max:
|
||||||
fields.append(("Valid-Until-Max", self.valid_until_max))
|
lines.append(f"Valid-Until-Max: {self.valid_until_max}")
|
||||||
if self.check_date is not None:
|
if self.check_date is not None:
|
||||||
fields.append(("Check-Date", _yes_no(self.check_date)))
|
lines.append(f"Check-Date: {_yes_no(self.check_date)}")
|
||||||
if self.date_max_future:
|
if self.date_max_future:
|
||||||
fields.append(("Date-Max-Future", self.date_max_future))
|
lines.append(f"Date-Max-Future: {self.date_max_future}")
|
||||||
if self.description:
|
if self.description:
|
||||||
fields.append(("Description", self.description))
|
lines.append(f"Description: {self.description}")
|
||||||
fields.append(("Enabled", _yes_no(self.enabled)))
|
lines.append(f"Enabled: {_yes_no(self.enabled)}")
|
||||||
return "\n".join(f"{key}: {value}" for key, value in fields) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
class AptConfig(BaseModel):
|
class AptConfig(BaseModel):
|
||||||
sources: list[AptSource] = Field(default_factory=list)
|
sources: list[AptSource] = Field(default_factory=list)
|
||||||
keys: list[NamedContent] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class ApkRepository(BaseModel):
|
class ApkRepository(BaseModel):
|
||||||
"""An Alpine APK repository (see /etc/apk/repositories), e.g. a URL ending in /main or /community."""
|
"""An Alpine APK repository (see /etc/apk/repositories), e.g. a URL ending in /main or /community.
|
||||||
|
|
||||||
|
`url_to_replace`, if set, is an fnmatch pattern: this repository is only
|
||||||
|
applied if it matches at least one URL already present in the machine's
|
||||||
|
existing apk configuration; otherwise it is skipped entirely.
|
||||||
|
|
||||||
|
`key_filename`, if set, names the file the `key` is written to under
|
||||||
|
`/etc/apk/keys/` instead of a name derived from the key's content. It
|
||||||
|
must be a single, valid filesystem path component (in particular, no
|
||||||
|
`/`); if it isn't, it is ignored and the derived name is used instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
url: str
|
url: str
|
||||||
key: str | None = None
|
key: str | None = None
|
||||||
|
key_filename: str | None = None
|
||||||
|
url_to_replace: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ApkConfig(BaseModel):
|
class ApkConfig(BaseModel):
|
||||||
@@ -119,10 +172,19 @@ class ApkConfig(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class YumRepository(BaseModel):
|
class YumRepository(BaseModel):
|
||||||
|
"""A YUM/DNF repository (see /etc/yum.repos.d/*.repo).
|
||||||
|
|
||||||
|
`index_url_to_replace`, if set, is an fnmatch pattern: this repository is
|
||||||
|
only applied if it matches at least one baseurl already present in the
|
||||||
|
machine's existing yum/dnf configuration; otherwise it is skipped
|
||||||
|
entirely.
|
||||||
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
name: str
|
name: str
|
||||||
index_url: str = Field(alias="index-url")
|
index_url: str = Field(alias="index-url")
|
||||||
gpg_url: str | None = Field(default=None, alias="gpg-url")
|
gpg_url: str | None = Field(default=None, alias="gpg-url")
|
||||||
|
index_url_to_replace: str | None = Field(default=None, alias="index-url-to-replace")
|
||||||
|
|
||||||
|
|
||||||
class YumConfig(BaseModel):
|
class YumConfig(BaseModel):
|
||||||
@@ -177,15 +239,54 @@ class NpmConfig(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class RustRepository(BaseModel):
|
class RustRepository(BaseModel):
|
||||||
"""A cargo registry (see the "source" tables in .cargo/config.toml),
|
"""A cargo registry (see the "source" tables in .cargo/config.toml).
|
||||||
served over the sparse HTTP protocol."""
|
|
||||||
|
`is_sparse`, if true, marks the registry as served over the sparse
|
||||||
|
HTTP protocol (its index is then declared as `sparse+<url>`) rather
|
||||||
|
than a git-based index.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
is_sparse: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class RustConfig(BaseModel):
|
||||||
|
repositories: list[RustRepository] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class GoRepository(BaseModel):
|
||||||
|
"""A Go module proxy (see GOPROXY), served over the standard Go module
|
||||||
|
proxy protocol."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
url: str
|
url: str
|
||||||
|
|
||||||
|
|
||||||
class RustConfig(BaseModel):
|
class GoConfig(BaseModel):
|
||||||
repositories: list[RustRepository] = Field(default_factory=list)
|
repositories: list[GoRepository] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class RubyRepository(BaseModel):
|
||||||
|
"""A RubyGems source (see the ":sources:" key in gemrc)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class RubyConfig(BaseModel):
|
||||||
|
repositories: list[RubyRepository] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class NuGetRepository(BaseModel):
|
||||||
|
"""A NuGet package source (see the "packageSources" key in NuGet.Config)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class NuGetConfig(BaseModel):
|
||||||
|
repositories: list[NuGetRepository] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ConfigurationsBlock(BaseModel):
|
class ConfigurationsBlock(BaseModel):
|
||||||
@@ -198,14 +299,34 @@ class ConfigurationsBlock(BaseModel):
|
|||||||
php: PhpConfig = Field(default_factory=PhpConfig)
|
php: PhpConfig = Field(default_factory=PhpConfig)
|
||||||
npm: NpmConfig = Field(default_factory=NpmConfig)
|
npm: NpmConfig = Field(default_factory=NpmConfig)
|
||||||
rust: RustConfig = Field(default_factory=RustConfig)
|
rust: RustConfig = Field(default_factory=RustConfig)
|
||||||
|
go: GoConfig = Field(default_factory=GoConfig)
|
||||||
|
ruby: RubyConfig = Field(default_factory=RubyConfig)
|
||||||
|
nuget: NuGetConfig = Field(default_factory=NuGetConfig)
|
||||||
|
|
||||||
|
|
||||||
class Configuration(BaseModel):
|
class Configuration(BaseModel):
|
||||||
uuid: UUID
|
"""`version` is the configuration format's own schema version (currently
|
||||||
organisation: str
|
"1.0"), independent of the id-configurator package version - it lets a
|
||||||
description: str = ""
|
future, incompatible format change be told apart from this one. Omitting
|
||||||
|
it defaults to "1.0", the only version there currently is."""
|
||||||
|
|
||||||
|
version: str = "1.0"
|
||||||
|
uuid: UUID | None = None
|
||||||
|
organisation: str | None = None
|
||||||
|
description: str | None = None
|
||||||
configurations: ConfigurationsBlock = Field(default_factory=ConfigurationsBlock)
|
configurations: ConfigurationsBlock = Field(default_factory=ConfigurationsBlock)
|
||||||
|
|
||||||
|
@field_validator("version", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _coerce_version_to_str(cls, value: object) -> object:
|
||||||
|
"""Accept a bare YAML number (`version: 1.0`) as well as a quoted
|
||||||
|
string (`version: "1.0"`): YAML parses the unquoted, natural-looking
|
||||||
|
form as a float, which would otherwise fail validation with a
|
||||||
|
confusing type error."""
|
||||||
|
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||||
|
return str(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def parse_configuration(raw: str) -> Configuration:
|
def parse_configuration(raw: str) -> Configuration:
|
||||||
"""Parse a configuration from a JSON or YAML string."""
|
"""Parse a configuration from a JSON or YAML string."""
|
||||||
@@ -216,14 +337,55 @@ def parse_configuration(raw: str) -> Configuration:
|
|||||||
return Configuration.model_validate(data)
|
return Configuration.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_config_url(
|
||||||
|
url: str, cacert_content: str | None, insecure: bool = False
|
||||||
|
) -> str:
|
||||||
|
"""Download a JSON or YAML configuration from `url`.
|
||||||
|
|
||||||
|
If `cacert_content` is given, it - and only it, not the system's default
|
||||||
|
trust store - is used to verify an HTTPS connection: for an internal
|
||||||
|
endpoint signed by a private CA the running system doesn't otherwise
|
||||||
|
trust yet (e.g. before the `certificates` module has ever run).
|
||||||
|
|
||||||
|
If `insecure` is set instead, certificate verification (and hostname
|
||||||
|
checking) is disabled entirely - e.g. to bootstrap from a server whose
|
||||||
|
certificate isn't trusted, or trustable, at all yet. `cacert_content` is
|
||||||
|
then ignored: there would be nothing left to verify it against.
|
||||||
|
"""
|
||||||
|
if insecure:
|
||||||
|
# Explicitly requested by the operator via --config-url-insecure.
|
||||||
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
context.check_hostname = False
|
||||||
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
else:
|
||||||
|
context = ssl.create_default_context(
|
||||||
|
cadata=cacert_content if cacert_content is not None else None
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen( # nosec B310 (url is an operator-provided CLI argument)
|
||||||
|
url, timeout=_CONFIG_URL_TIMEOUT_SECONDS, context=context
|
||||||
|
) as response:
|
||||||
|
return response.read().decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def load_configuration(
|
def load_configuration(
|
||||||
config_file: Path | None, config_env: str | None
|
config_file: Path | None,
|
||||||
|
config_env: str | None,
|
||||||
|
config_url: str | None = None,
|
||||||
|
config_url_cacert_content: str | None = None,
|
||||||
|
config_url_insecure: bool = False,
|
||||||
) -> Configuration:
|
) -> Configuration:
|
||||||
"""Load a configuration from a file, or from a base64-encoded string (typically an env var)."""
|
"""Load a configuration from a file, from a base64-encoded string
|
||||||
|
(typically an env var), or by downloading it from a URL."""
|
||||||
if config_file is not None:
|
if config_file is not None:
|
||||||
raw = config_file.read_text(encoding="utf-8")
|
raw = config_file.read_text(encoding="utf-8")
|
||||||
elif config_env is not None:
|
elif config_env is not None:
|
||||||
raw = base64.b64decode(config_env).decode("utf-8")
|
raw = base64.b64decode(config_env).decode("utf-8")
|
||||||
|
elif config_url is not None:
|
||||||
|
raw = _fetch_config_url(
|
||||||
|
config_url, config_url_cacert_content, config_url_insecure
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError("either --config or --config-env must be provided")
|
raise ValueError(
|
||||||
|
"either --config, --config-env or --config-url must be provided"
|
||||||
|
)
|
||||||
return parse_configuration(raw)
|
return parse_configuration(raw)
|
||||||
|
|||||||
@@ -7,18 +7,71 @@ filesystem used to detect the existing configuration.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from id_configurator.config import Configuration
|
from id_configurator.config import Configuration
|
||||||
|
from id_configurator.users import (
|
||||||
|
DEFAULT_UID_MIN,
|
||||||
|
PasswdEntry,
|
||||||
|
parse_login_defs_uid_min,
|
||||||
|
parse_passwd,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Context:
|
class Context:
|
||||||
def __init__(
|
def __init__(
|
||||||
self, config: Configuration, dst: Path, src: Path | None = None
|
self,
|
||||||
|
config: Configuration,
|
||||||
|
dst: Path,
|
||||||
|
src: Path | None = None,
|
||||||
|
users: Sequence[str] | None = None,
|
||||||
|
auto_users: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
self.dst = dst.resolve()
|
self.dst = dst.resolve()
|
||||||
self.src = src.resolve() if src is not None else None
|
self.src = src.resolve() if src is not None else None
|
||||||
|
self._passwd_entries = self._load_passwd_entries()
|
||||||
|
self._uid_min = self._load_uid_min()
|
||||||
|
self.users = self._resolve_users(users or [], auto_users)
|
||||||
|
|
||||||
|
def _load_passwd_entries(self) -> dict[str, PasswdEntry]:
|
||||||
|
content = self.read_src_file("etc/passwd")
|
||||||
|
if content is None:
|
||||||
|
return {}
|
||||||
|
return {entry.name: entry for entry in parse_passwd(content.decode("utf-8"))}
|
||||||
|
|
||||||
|
def _load_uid_min(self) -> int:
|
||||||
|
content = self.read_src_file("etc/login.defs")
|
||||||
|
if content is None:
|
||||||
|
return DEFAULT_UID_MIN
|
||||||
|
uid_min = parse_login_defs_uid_min(content.decode("utf-8"))
|
||||||
|
return DEFAULT_UID_MIN if uid_min is None else uid_min
|
||||||
|
|
||||||
|
def _resolve_users(self, users: Sequence[str], auto_users: bool) -> list[str]:
|
||||||
|
resolved: list[str] = []
|
||||||
|
for user in users:
|
||||||
|
if user not in resolved:
|
||||||
|
resolved.append(user)
|
||||||
|
if auto_users:
|
||||||
|
for name, entry in self._passwd_entries.items():
|
||||||
|
if name not in resolved and entry.is_regular_user(self._uid_min):
|
||||||
|
resolved.append(name)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
def home_dir(self, user: str) -> str:
|
||||||
|
"""Relative path (no leading `/`) to `user`'s home directory, as used
|
||||||
|
by modules that write per-user configuration files.
|
||||||
|
|
||||||
|
Taken from `<src>`'s `/etc/passwd` entry for `user` when there is
|
||||||
|
one (the case for a user resolved via `--auto-users`); otherwise
|
||||||
|
falls back to the usual `/home/<user>` convention (`/root` for
|
||||||
|
root).
|
||||||
|
"""
|
||||||
|
entry = self._passwd_entries.get(user)
|
||||||
|
if entry is not None and entry.home:
|
||||||
|
return entry.home.lstrip("/")
|
||||||
|
return "root" if user == "root" else f"home/{user}"
|
||||||
|
|
||||||
def dst_path(self, relative: str) -> Path:
|
def dst_path(self, relative: str) -> Path:
|
||||||
"""Resolve a path relative to the destination directory, refusing to escape it."""
|
"""Resolve a path relative to the destination directory, refusing to escape it."""
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ from id_configurator.modules import (
|
|||||||
apk,
|
apk,
|
||||||
apt,
|
apt,
|
||||||
certificates,
|
certificates,
|
||||||
|
go,
|
||||||
java,
|
java,
|
||||||
npm,
|
npm,
|
||||||
|
nuget,
|
||||||
php,
|
php,
|
||||||
python,
|
python,
|
||||||
|
ruby,
|
||||||
rust,
|
rust,
|
||||||
yum,
|
yum,
|
||||||
)
|
)
|
||||||
@@ -19,10 +22,13 @@ __all__ = [
|
|||||||
"apk",
|
"apk",
|
||||||
"apt",
|
"apt",
|
||||||
"certificates",
|
"certificates",
|
||||||
|
"go",
|
||||||
"java",
|
"java",
|
||||||
"npm",
|
"npm",
|
||||||
|
"nuget",
|
||||||
"php",
|
"php",
|
||||||
"python",
|
"python",
|
||||||
|
"ruby",
|
||||||
"rust",
|
"rust",
|
||||||
"yum",
|
"yum",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
"""Detect existing APK (Alpine) repositories and configure new ones.
|
"""Detect existing APK (Alpine) repositories and configure new ones.
|
||||||
|
|
||||||
If `<src>/etc/apk` exists, the existing `/etc/apk/repositories` file (if any)
|
If `<src>/etc/apk` exists, the existing `/etc/apk/repositories` file (if any)
|
||||||
is copied into `<dst>` with every active line disabled (commented out), and
|
is copied into `<dst>`, and the repositories declared in the configuration
|
||||||
the repositories declared in the configuration are appended as new, active
|
are appended as new, active lines. An existing active line is only disabled
|
||||||
lines. Public keys provided alongside a repository are written to
|
(commented out) if it matches the `url_to_replace` pattern of one of the
|
||||||
|
configured repositories; lines matching no such pattern are copied through
|
||||||
|
unchanged. Public keys provided alongside a repository are written to
|
||||||
`<dst>/etc/apk/keys/`, named after their content so that repositories
|
`<dst>/etc/apk/keys/`, named after their content so that repositories
|
||||||
sharing the same key (a common case for official Alpine mirrors) only
|
sharing the same key (a common case for official Alpine mirrors) only
|
||||||
produce a single key file.
|
produce a single key file.
|
||||||
|
|
||||||
|
A repository's `url_to_replace`, if set, is a fnmatch pattern checked
|
||||||
|
against the URLs already active in /etc/apk/repositories: the repository
|
||||||
|
is only written out if the pattern matches at least one of them, and
|
||||||
|
skipped entirely otherwise.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fnmatch
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from id_configurator.context import Context
|
from id_configurator.context import Context
|
||||||
@@ -25,11 +34,18 @@ _REPOSITORIES = "etc/apk/repositories"
|
|||||||
_KEYS_DIR = "etc/apk/keys"
|
_KEYS_DIR = "etc/apk/keys"
|
||||||
|
|
||||||
|
|
||||||
def _disable_repositories_content(content: str) -> str:
|
def _disable_repositories_content(content: str, patterns: Sequence[str]) -> str:
|
||||||
lines = [
|
lines = []
|
||||||
line if not line.strip() or line.strip().startswith("#") else f"# {line}"
|
for line in content.splitlines():
|
||||||
for line in content.splitlines()
|
stripped = line.strip()
|
||||||
]
|
if (
|
||||||
|
stripped
|
||||||
|
and not stripped.startswith("#")
|
||||||
|
and any(fnmatch.fnmatch(stripped, pattern) for pattern in patterns)
|
||||||
|
):
|
||||||
|
lines.append(f"# {line}")
|
||||||
|
else:
|
||||||
|
lines.append(line)
|
||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
@@ -37,6 +53,31 @@ def _key_filename(content: bytes) -> str:
|
|||||||
return f"{hashlib.sha256(content).hexdigest()[:16]}.rsa.pub"
|
return f"{hashlib.sha256(content).hexdigest()[:16]}.rsa.pub"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_key_filename(name: str) -> bool:
|
||||||
|
"""Check that `name` is safe to use as a single filename under `_KEYS_DIR`.
|
||||||
|
|
||||||
|
Unlike `sanitize_filename`, an invalid name is not cleaned up: the
|
||||||
|
caller falls back to the content-derived name instead, since a
|
||||||
|
configured `key_filename` that isn't a plain file name (e.g. containing
|
||||||
|
`/` or `\\`, which would turn into extra path segments, or `.`/`..`)
|
||||||
|
most likely indicates a configuration mistake.
|
||||||
|
"""
|
||||||
|
return name not in ("", ".", "..") and "/" not in name and "\\" not in name
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_urls(context: Context) -> set[str]:
|
||||||
|
"""Collect the URLs already declared in /etc/apk/repositories on the
|
||||||
|
source filesystem (one active, non-comment line per URL)."""
|
||||||
|
existing = context.read_src_file(_REPOSITORIES)
|
||||||
|
if existing is None:
|
||||||
|
return set()
|
||||||
|
return {
|
||||||
|
stripped
|
||||||
|
for line in existing.decode("utf-8").splitlines()
|
||||||
|
if (stripped := line.strip()) and not stripped.startswith("#")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@register
|
@register
|
||||||
class ApkModule(Module):
|
class ApkModule(Module):
|
||||||
name = "apk"
|
name = "apk"
|
||||||
@@ -52,27 +93,54 @@ class ApkModule(Module):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def apply(self, context: Context) -> None:
|
def apply(self, context: Context) -> None:
|
||||||
disabled = self._disabled_existing_repositories(context)
|
patterns = [
|
||||||
|
repository.url_to_replace
|
||||||
|
for repository in context.config.configurations.apk.repositories
|
||||||
|
if repository.url_to_replace is not None
|
||||||
|
]
|
||||||
|
disabled = self._disabled_existing_repositories(context, patterns)
|
||||||
configured = self._configured_repositories_lines(context)
|
configured = self._configured_repositories_lines(context)
|
||||||
content = disabled + ("\n".join(configured) + "\n" if configured else "")
|
content = disabled + ("\n".join(configured) + "\n" if configured else "")
|
||||||
if content:
|
if content:
|
||||||
context.write_file(_REPOSITORIES, content.encode("utf-8"))
|
context.write_file(_REPOSITORIES, content.encode("utf-8"))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _disabled_existing_repositories(context: Context) -> str:
|
def _disabled_existing_repositories(
|
||||||
|
context: Context, patterns: Sequence[str]
|
||||||
|
) -> str:
|
||||||
existing = context.read_src_file(_REPOSITORIES)
|
existing = context.read_src_file(_REPOSITORIES)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
return ""
|
return ""
|
||||||
logger.info("disabled existing apk repositories %s", _REPOSITORIES)
|
logger.info("copied existing apk repositories %s", _REPOSITORIES)
|
||||||
return _disable_repositories_content(existing.decode("utf-8"))
|
return _disable_repositories_content(existing.decode("utf-8"), patterns)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _configured_repositories_lines(context: Context) -> list[str]:
|
def _configured_repositories_lines(context: Context) -> list[str]:
|
||||||
lines = []
|
lines = []
|
||||||
|
existing_urls: set[str] | None = None
|
||||||
for repository in context.config.configurations.apk.repositories:
|
for repository in context.config.configurations.apk.repositories:
|
||||||
|
if repository.url_to_replace is not None:
|
||||||
|
if existing_urls is None:
|
||||||
|
existing_urls: set[str] = _existing_urls(context)
|
||||||
|
if not any(
|
||||||
|
fnmatch.fnmatch(url, repository.url_to_replace)
|
||||||
|
for url in existing_urls
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"skipped apk repository %s: url-to-replace %s matched "
|
||||||
|
"no existing url",
|
||||||
|
repository.name,
|
||||||
|
repository.url_to_replace,
|
||||||
|
)
|
||||||
|
continue
|
||||||
lines.append(repository.url)
|
lines.append(repository.url)
|
||||||
logger.info("added apk repository %s", repository.url)
|
logger.info("added apk repository %s", repository.name)
|
||||||
if repository.key:
|
if repository.key:
|
||||||
|
if repository.key_filename and _is_valid_key_filename(
|
||||||
|
repository.key_filename
|
||||||
|
):
|
||||||
|
filename = repository.key_filename
|
||||||
|
else:
|
||||||
filename = _key_filename(repository.key.encode("utf-8"))
|
filename = _key_filename(repository.key.encode("utf-8"))
|
||||||
context.write_file(
|
context.write_file(
|
||||||
f"{_KEYS_DIR}/{filename}", repository.key.encode("utf-8")
|
f"{_KEYS_DIR}/{filename}", repository.key.encode("utf-8")
|
||||||
|
|||||||
+147
-34
@@ -2,45 +2,139 @@
|
|||||||
|
|
||||||
If `<src>/etc/apt` exists, any source file already present there (classic
|
If `<src>/etc/apt` exists, any source file already present there (classic
|
||||||
one-line `.list` files or DEB822 `.sources` files, whether at the top level
|
one-line `.list` files or DEB822 `.sources` files, whether at the top level
|
||||||
or under `sources.list.d/`) is copied into `<dst>/etc/apt`, disabled so it no
|
or under `sources.list.d/`) is copied into `<dst>/etc/apt`, and the sources
|
||||||
longer takes effect, and the sources declared in the configuration are added
|
declared in the configuration are added as new, enabled `.sources` files.
|
||||||
as new, enabled `.sources` files.
|
|
||||||
|
An existing entry (a single `deb`/`deb-src` line, or a DEB822 stanza) is only
|
||||||
|
disabled if its URI matches the `uri_to_replace` pattern of one of the
|
||||||
|
configured sources; entries matching no such pattern are copied through
|
||||||
|
unchanged, still enabled.
|
||||||
|
|
||||||
|
Each source can be trusted two ways: `signed_by` holds the ASCII-armored
|
||||||
|
content of a GPG public key, folded inline as a multi-line `Signed-By`
|
||||||
|
field (per sources.list(5) - verified empirically against a real,
|
||||||
|
genuinely signed repository, so no `gpg` binary or separate keyring file
|
||||||
|
is needed at all to apply this configuration). `signed_by_file` instead
|
||||||
|
names a keyring already expected to exist on the target system, used
|
||||||
|
verbatim as `Signed-By`; if both are given, `signed_by` takes precedence.
|
||||||
|
|
||||||
|
A source's `uri_to_replace`, if set, is an fnmatch pattern checked against
|
||||||
|
the URIs of the sources already present on the source filesystem: the
|
||||||
|
source is only written out if the pattern matches at least one of them,
|
||||||
|
and skipped entirely otherwise - even on an otherwise Debian-like machine.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fnmatch
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from id_configurator.context import Context
|
from id_configurator.context import Context
|
||||||
from id_configurator.modules.base import Module, register
|
from id_configurator.modules.base import Module, register, sanitize_filename
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_APT_DIR = "etc/apt"
|
_APT_DIR = "etc/apt"
|
||||||
_SOURCES_LIST = "etc/apt/sources.list"
|
_SOURCES_LIST = "etc/apt/sources.list"
|
||||||
_SOURCES_LIST_D = "etc/apt/sources.list.d"
|
_SOURCES_LIST_D = "etc/apt/sources.list.d"
|
||||||
_KEYRINGS_DIR = "etc/apt/keyrings"
|
|
||||||
|
|
||||||
|
|
||||||
def _disable_list_content(content: str) -> str:
|
def _uri_from_deb_line(stripped_line: str) -> str | None:
|
||||||
lines = [
|
"""Extract the URI from a single, already-stripped `deb`/`deb-src`
|
||||||
line if not line.strip() or line.strip().startswith("#") else f"# {line}"
|
line (`deb [options] uri suite [component...]`), or `None` if the line
|
||||||
for line in content.splitlines()
|
is not such a line."""
|
||||||
]
|
parts = stripped_line.split()
|
||||||
|
if not parts or parts[0] not in ("deb", "deb-src"):
|
||||||
|
return None
|
||||||
|
parts = parts[1:]
|
||||||
|
while parts and parts[0].startswith("["):
|
||||||
|
while parts and not parts[0].endswith("]"):
|
||||||
|
parts.pop(0)
|
||||||
|
if parts:
|
||||||
|
parts.pop(0)
|
||||||
|
return parts[0] if parts else None
|
||||||
|
|
||||||
|
|
||||||
|
def _disable_list_content(content: str, patterns: Sequence[str]) -> str:
|
||||||
|
lines = []
|
||||||
|
for line in content.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
uri = (
|
||||||
|
None
|
||||||
|
if not stripped or stripped.startswith("#")
|
||||||
|
else _uri_from_deb_line(stripped)
|
||||||
|
)
|
||||||
|
if uri is not None and any(
|
||||||
|
fnmatch.fnmatch(uri, pattern) for pattern in patterns
|
||||||
|
):
|
||||||
|
lines.append(f"# {line}")
|
||||||
|
else:
|
||||||
|
lines.append(line)
|
||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
def _disable_deb822_content(content: str) -> str:
|
def _uris_from_deb822_stanza(stanza: str) -> set[str]:
|
||||||
disabled_stanzas = []
|
uris: set[str] = set()
|
||||||
|
for line in stanza.splitlines():
|
||||||
|
if line.lower().startswith("uris:"):
|
||||||
|
uris.update(line.split(":", 1)[1].split())
|
||||||
|
return uris
|
||||||
|
|
||||||
|
|
||||||
|
def _disable_deb822_content(content: str, patterns: Sequence[str]) -> str:
|
||||||
|
stanzas = []
|
||||||
for stanza in content.split("\n\n"):
|
for stanza in content.split("\n\n"):
|
||||||
if not stanza.strip():
|
if not stanza.strip():
|
||||||
continue
|
continue
|
||||||
lines = stanza.splitlines()
|
lines = stanza.splitlines()
|
||||||
new_lines = [line for line in lines if not line.lower().startswith("enabled:")]
|
uris = _uris_from_deb822_stanza(stanza)
|
||||||
new_lines.append("Enabled: no")
|
if any(fnmatch.fnmatch(uri, pattern) for uri in uris for pattern in patterns):
|
||||||
disabled_stanzas.append("\n".join(new_lines))
|
lines = [line for line in lines if not line.lower().startswith("enabled:")]
|
||||||
return "\n\n".join(disabled_stanzas) + "\n"
|
lines.append("Enabled: no")
|
||||||
|
stanzas.append("\n".join(lines))
|
||||||
|
return "\n\n".join(stanzas) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _uris_from_list_content(content: str) -> set[str]:
|
||||||
|
"""Extract the URIs declared in a classic one-line `.list` file
|
||||||
|
(`deb [options] uri suite [component...]`), skipping comments and any
|
||||||
|
bracketed option list."""
|
||||||
|
uris: set[str] = set()
|
||||||
|
for line in content.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
uri = _uri_from_deb_line(stripped)
|
||||||
|
if uri is not None:
|
||||||
|
uris.add(uri)
|
||||||
|
return uris
|
||||||
|
|
||||||
|
|
||||||
|
def _uris_from_deb822_content(content: str) -> set[str]:
|
||||||
|
"""Extract the URIs declared in the `URIs:` field of each stanza of a
|
||||||
|
DEB822 `.sources` file."""
|
||||||
|
uris: set[str] = set()
|
||||||
|
for stanza in content.split("\n\n"):
|
||||||
|
uris.update(_uris_from_deb822_stanza(stanza))
|
||||||
|
return uris
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_uris(context: Context) -> set[str]:
|
||||||
|
"""Collect every URI declared by the apt sources already present on the
|
||||||
|
source filesystem, whether in the top-level sources.list or under
|
||||||
|
sources.list.d/, in either the classic or the DEB822 format."""
|
||||||
|
uris: set[str] = set()
|
||||||
|
existing_top_level = context.read_src_file(_SOURCES_LIST)
|
||||||
|
if existing_top_level is not None:
|
||||||
|
uris.update(_uris_from_list_content(existing_top_level.decode("utf-8")))
|
||||||
|
for entry in context.iter_src_dir(_SOURCES_LIST_D):
|
||||||
|
if entry.suffix == ".sources":
|
||||||
|
uris.update(_uris_from_deb822_content(entry.read_text(encoding="utf-8")))
|
||||||
|
elif entry.suffix == ".list":
|
||||||
|
uris.update(_uris_from_list_content(entry.read_text(encoding="utf-8")))
|
||||||
|
return uris
|
||||||
|
|
||||||
|
|
||||||
@register
|
@register
|
||||||
@@ -58,43 +152,62 @@ class AptModule(Module):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def apply(self, context: Context) -> None:
|
def apply(self, context: Context) -> None:
|
||||||
self._copy_existing_disabled(context)
|
patterns = [
|
||||||
self._write_configured_keys(context)
|
source.uri_to_replace
|
||||||
|
for source in context.config.configurations.apt.sources
|
||||||
|
if source.uri_to_replace is not None
|
||||||
|
]
|
||||||
|
self._copy_existing_disabled(context, patterns)
|
||||||
self._write_configured_sources(context)
|
self._write_configured_sources(context)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _copy_existing_disabled(context: Context) -> None:
|
def _copy_existing_disabled(context: Context, patterns: Sequence[str]) -> None:
|
||||||
existing_top_level = context.read_src_file(_SOURCES_LIST)
|
existing_top_level = context.read_src_file(_SOURCES_LIST)
|
||||||
if existing_top_level is not None:
|
if existing_top_level is not None:
|
||||||
disabled = _disable_list_content(existing_top_level.decode("utf-8"))
|
disabled = _disable_list_content(
|
||||||
|
existing_top_level.decode("utf-8"), patterns
|
||||||
|
)
|
||||||
context.write_file(_SOURCES_LIST, disabled.encode("utf-8"))
|
context.write_file(_SOURCES_LIST, disabled.encode("utf-8"))
|
||||||
logger.info("disabled existing apt source list %s", _SOURCES_LIST)
|
logger.info("copied existing apt source list %s", _SOURCES_LIST)
|
||||||
|
|
||||||
for entry in context.iter_src_dir(_SOURCES_LIST_D):
|
for entry in context.iter_src_dir(_SOURCES_LIST_D):
|
||||||
if entry.suffix == ".sources":
|
if entry.suffix == ".sources":
|
||||||
disabled = _disable_deb822_content(entry.read_text(encoding="utf-8"))
|
disabled = _disable_deb822_content(
|
||||||
|
entry.read_text(encoding="utf-8"), patterns
|
||||||
|
)
|
||||||
elif entry.suffix == ".list":
|
elif entry.suffix == ".list":
|
||||||
disabled = _disable_list_content(entry.read_text(encoding="utf-8"))
|
disabled = _disable_list_content(
|
||||||
|
entry.read_text(encoding="utf-8"), patterns
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
context.write_file(
|
context.write_file(
|
||||||
f"{_SOURCES_LIST_D}/{entry.name}", disabled.encode("utf-8")
|
f"{_SOURCES_LIST_D}/{entry.name}", disabled.encode("utf-8")
|
||||||
)
|
)
|
||||||
logger.info("disabled existing apt source %s", entry.name)
|
logger.info("copied existing apt source %s", entry.name)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _write_configured_keys(context: Context) -> None:
|
|
||||||
for key in context.config.configurations.apt.keys:
|
|
||||||
context.write_file(
|
|
||||||
f"{_KEYRINGS_DIR}/{key.name}.asc", key.content.encode("utf-8")
|
|
||||||
)
|
|
||||||
logger.info("installed apt keyring %s", key.name)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _write_configured_sources(context: Context) -> None:
|
def _write_configured_sources(context: Context) -> None:
|
||||||
|
existing_uris: set[str] | None = None
|
||||||
for source in context.config.configurations.apt.sources:
|
for source in context.config.configurations.apt.sources:
|
||||||
|
if source.uri_to_replace is not None:
|
||||||
|
if existing_uris is None:
|
||||||
|
existing_uris = _existing_uris(context)
|
||||||
|
if not any(
|
||||||
|
fnmatch.fnmatch(uri, source.uri_to_replace) for uri in existing_uris
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"skipped apt source %s: uri-to-replace %s matched no "
|
||||||
|
"existing uri",
|
||||||
|
source.name,
|
||||||
|
source.uri_to_replace,
|
||||||
|
)
|
||||||
|
continue
|
||||||
context.write_file(
|
context.write_file(
|
||||||
f"{_SOURCES_LIST_D}/{source.name}.sources",
|
f"{_SOURCES_LIST_D}/{sanitize_filename(source.name)}.sources",
|
||||||
source.to_deb822().encode("utf-8"),
|
source.to_deb822(
|
||||||
|
signed_by_armored_key=source.signed_by,
|
||||||
|
signed_by_path=source.signed_by_file,
|
||||||
|
).encode("utf-8"),
|
||||||
)
|
)
|
||||||
logger.info("added apt source %s", source.name)
|
logger.info("added apt source %s", source.name)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ the destination directory.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
from id_configurator.context import Context
|
from id_configurator.context import Context
|
||||||
@@ -34,3 +35,37 @@ def register(module_cls: type[Module]) -> type[Module]:
|
|||||||
|
|
||||||
def all_modules() -> list[type[Module]]:
|
def all_modules() -> list[type[Module]]:
|
||||||
return list(_REGISTRY)
|
return list(_REGISTRY)
|
||||||
|
|
||||||
|
|
||||||
|
_IDENTIFIER_FORBIDDEN_CHARS = re.compile(r"[^A-Za-z0-9_-]")
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_filename(name: str) -> str:
|
||||||
|
"""Make a user-supplied name safe to use as a single filesystem path
|
||||||
|
component.
|
||||||
|
|
||||||
|
Modules that derive a file's name from configuration data (a
|
||||||
|
certificate's name, a repository's name, ...) must run it through this
|
||||||
|
first. Every character that isn't a letter, a digit, `_` or `-` is
|
||||||
|
replaced with `_`: this rules out `/` and `\\` (which would silently
|
||||||
|
turn into extra, unintended path segments), `.` (so the result can
|
||||||
|
never be `.` or `..`), as well as spaces, control characters and any
|
||||||
|
other character that could confuse the tools reading these files. An
|
||||||
|
empty name yields `_`, so the result is always a non-empty file name.
|
||||||
|
"""
|
||||||
|
return _IDENTIFIER_FORBIDDEN_CHARS.sub("_", name) or "_"
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_repository_name(name: str) -> str:
|
||||||
|
"""Replace every character of `name` that isn't a plain identifier
|
||||||
|
character (letters, digits, `_`, `-`) with `_`.
|
||||||
|
|
||||||
|
Several modules write a repository's name verbatim as a bare,
|
||||||
|
unquoted identifier in a generated config file - an INI section
|
||||||
|
header, a TOML table key, ... - where an unrestricted name could break
|
||||||
|
out of that syntax (e.g. `]`, a newline, or - for TOML - a literal
|
||||||
|
`.`, which would silently open extra, nested tables instead of naming
|
||||||
|
the one table it's meant to). Spaces and every other non-identifier
|
||||||
|
character are replaced the same way, for consistency across callers.
|
||||||
|
"""
|
||||||
|
return _IDENTIFIER_FORBIDDEN_CHARS.sub("_", name)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import posixpath
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from id_configurator.context import Context
|
from id_configurator.context import Context
|
||||||
from id_configurator.modules.base import Module, register
|
from id_configurator.modules.base import Module, register, sanitize_filename
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ class CertificatesModule(Module):
|
|||||||
|
|
||||||
def apply(self, context: Context) -> None:
|
def apply(self, context: Context) -> None:
|
||||||
configured_names = {
|
configured_names = {
|
||||||
certificate.name
|
sanitize_filename(certificate.name)
|
||||||
for certificate in context.config.configurations.certificates
|
for certificate in context.config.configurations.certificates
|
||||||
}
|
}
|
||||||
for existing in context.iter_src_dir(_LOCAL_CERTS_DIR):
|
for existing in context.iter_src_dir(_LOCAL_CERTS_DIR):
|
||||||
@@ -152,7 +152,7 @@ class CertificatesModule(Module):
|
|||||||
)
|
)
|
||||||
for certificate in context.config.configurations.certificates:
|
for certificate in context.config.configurations.certificates:
|
||||||
context.write_file(
|
context.write_file(
|
||||||
f"{_LOCAL_CERTS_DIR}/{certificate.name}.crt",
|
f"{_LOCAL_CERTS_DIR}/{sanitize_filename(certificate.name)}.crt",
|
||||||
certificate.content.encode(),
|
certificate.content.encode(),
|
||||||
)
|
)
|
||||||
logger.info("installed root certificate %s", certificate.name)
|
logger.info("installed root certificate %s", certificate.name)
|
||||||
@@ -213,8 +213,18 @@ class CertificatesModule(Module):
|
|||||||
logger.info("rebuilt %s from %d certificate(s)", _CERT_BUNDLE, len(entries))
|
logger.info("rebuilt %s from %d certificate(s)", _CERT_BUNDLE, len(entries))
|
||||||
|
|
||||||
if etccertsdir_relative == _ETCCERTSDIR:
|
if etccertsdir_relative == _ETCCERTSDIR:
|
||||||
cert_file = context.dst_path(_OPENSSL_DEFAULT_CERT_FILE)
|
# Not context.dst_path(_OPENSSL_DEFAULT_CERT_FILE) directly: on a
|
||||||
cert_file.parent.mkdir(parents=True, exist_ok=True)
|
# host that already has this symlink (e.g. re-applying to a live
|
||||||
|
# `--dst /`), resolving the leaf itself would follow it straight
|
||||||
|
# to the bundle we just wrote, and the unlink+symlink_to below
|
||||||
|
# would then target the bundle's own path instead of this one -
|
||||||
|
# replacing the bundle with a symlink to itself. Resolving only
|
||||||
|
# the containing directory keeps the leaf name literal.
|
||||||
|
cert_file_dir = context.dst_path(
|
||||||
|
posixpath.dirname(_OPENSSL_DEFAULT_CERT_FILE)
|
||||||
|
)
|
||||||
|
cert_file_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
cert_file = cert_file_dir / posixpath.basename(_OPENSSL_DEFAULT_CERT_FILE)
|
||||||
cert_file.unlink(missing_ok=True)
|
cert_file.unlink(missing_ok=True)
|
||||||
cert_file.symlink_to(f"/{etccertsdir_relative}/{_CERT_BUNDLE}")
|
cert_file.symlink_to(f"/{etccertsdir_relative}/{_CERT_BUNDLE}")
|
||||||
|
|
||||||
@@ -227,7 +237,7 @@ class CertificatesModule(Module):
|
|||||||
)
|
)
|
||||||
for certificate in context.config.configurations.certificates:
|
for certificate in context.config.configurations.certificates:
|
||||||
context.write_file(
|
context.write_file(
|
||||||
f"{_RHEL_ANCHORS_DIR}/{certificate.name}.crt",
|
f"{_RHEL_ANCHORS_DIR}/{sanitize_filename(certificate.name)}.crt",
|
||||||
certificate.content.encode(),
|
certificate.content.encode(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Configure the Go toolchain to use the configured GoRepository entries as
|
||||||
|
its module proxy (GOPROXY), replacing the default (proxy.golang.org). Since
|
||||||
|
an air-gapped mirror won't be listed in the public checksum database and
|
||||||
|
sum.golang.org is unreachable anyway, GOSUMDB is also turned off - each
|
||||||
|
module's hash is then simply trusted on first use, exactly as it would be
|
||||||
|
for any other GOPROXY.
|
||||||
|
|
||||||
|
Go's own HTTPS client already trusts whatever the `certificates` module
|
||||||
|
installs into the system CA store, so no separate certificate configuration
|
||||||
|
is needed here, unlike npm's.
|
||||||
|
|
||||||
|
Written to `$GOROOT/go.env`, which `$GOROOT` defaults to `/usr/local/go` for
|
||||||
|
essentially every standard Go install (the official Docker image included);
|
||||||
|
unlike a config dropped in one specific user's home, this applies regardless
|
||||||
|
of which user invokes `go`. The go command reads `$GOROOT/go.env` as a
|
||||||
|
fallback whenever a setting is not already present in a higher-precedence
|
||||||
|
level - in particular the per-user `go env -w` file (found at
|
||||||
|
`os.UserConfigDir()/go/env`, e.g. `$HOME/.config/go/env`), which this module
|
||||||
|
also writes for each configured `--user` (`context.users`), so that one
|
||||||
|
takes precedence should that user ever run `go env -w` themselves.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from id_configurator.config import GoRepository
|
||||||
|
from id_configurator.context import Context
|
||||||
|
from id_configurator.modules.base import Module, register
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_GO_ENV = "usr/local/go/go.env"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_go_env(context: Context, repositories: list[GoRepository]) -> None:
|
||||||
|
proxy = ",".join(repository.url for repository in repositories)
|
||||||
|
lines = [f"GOPROXY={proxy}", "GOSUMDB=off"]
|
||||||
|
content = ("\n".join(lines) + "\n").encode("utf-8")
|
||||||
|
context.write_file(_GO_ENV, content)
|
||||||
|
for user in context.users:
|
||||||
|
context.write_file(f"{context.home_dir(user)}/.config/go/env", content)
|
||||||
|
logger.info("configured go for user %s", user)
|
||||||
|
logger.info("configured go to use %d module prox(y/ies)", len(repositories))
|
||||||
|
|
||||||
|
|
||||||
|
@register
|
||||||
|
class GoModule(Module):
|
||||||
|
name = "go"
|
||||||
|
|
||||||
|
def is_relevant(self, context: Context) -> bool:
|
||||||
|
return bool(context.config.configurations.go.repositories)
|
||||||
|
|
||||||
|
def apply(self, context: Context) -> None:
|
||||||
|
_write_go_env(context, context.config.configurations.go.repositories)
|
||||||
@@ -28,9 +28,20 @@ tool targets Docker images, which are built and run as root:
|
|||||||
- Ivy: `~/.ivy2/ivysettings.xml`, as a chain of `ibiblio` resolvers.
|
- Ivy: `~/.ivy2/ivysettings.xml`, as a chain of `ibiblio` resolvers.
|
||||||
- SBT: `~/.sbt/repositories`, the resolver override file SBT reads for its
|
- SBT: `~/.sbt/repositories`, the resolver override file SBT reads for its
|
||||||
own bootstrapping and, when combined with the `sbt.override.build.repos`
|
own bootstrapping and, when combined with the `sbt.override.build.repos`
|
||||||
system property, for every project's resolvers as well.
|
system property, for every project's resolvers as well. A repository
|
||||||
|
whose URL isn't HTTPS gets `, allowInsecureProtocol` appended to its
|
||||||
|
line - SBT's Coursier-backed resolver otherwise refuses it outright
|
||||||
|
("insecure protocol is unsupported"), unlike every other entry in
|
||||||
|
`~/.sbt/repositories` which takes no such suffix.
|
||||||
- Leiningen: `~/.lein/profiles.clj`, adding the repositories to the `:user`
|
- Leiningen: `~/.lein/profiles.clj`, adding the repositories to the `:user`
|
||||||
profile.
|
profile. Leiningen, alone among these, refuses a plain-HTTP repository
|
||||||
|
outright ("Tried to use insecure HTTP repository without TLS") - its
|
||||||
|
standalone `lein` launcher (`leiningen.core.main/-main`) unconditionally
|
||||||
|
registers an aborting wagon for the "http" scheme before even reading the
|
||||||
|
project/profile config, so there is no project.clj/profiles.clj setting
|
||||||
|
(there is no `:insecure` key anywhere in Leiningen's own source) that
|
||||||
|
can work around this: a non-HTTPS repository configured here will not be
|
||||||
|
usable from Leiningen specifically, regardless of what this module does.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -39,6 +50,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import jks
|
import jks
|
||||||
from cryptography import x509
|
from cryptography import x509
|
||||||
@@ -50,7 +62,7 @@ from cryptography.hazmat.primitives.serialization import (
|
|||||||
|
|
||||||
from id_configurator.config import JavaRepository, NamedContent
|
from id_configurator.config import JavaRepository, NamedContent
|
||||||
from id_configurator.context import Context
|
from id_configurator.context import Context
|
||||||
from id_configurator.modules.base import Module, register
|
from id_configurator.modules.base import Module, register, sanitize_repository_name
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -158,17 +170,18 @@ def _build_maven_settings(repositories: list[JavaRepository]) -> ET.Element:
|
|||||||
repositories_el = ET.SubElement(profile, "repositories")
|
repositories_el = ET.SubElement(profile, "repositories")
|
||||||
plugin_repositories_el = ET.SubElement(profile, "pluginRepositories")
|
plugin_repositories_el = ET.SubElement(profile, "pluginRepositories")
|
||||||
for repository in repositories:
|
for repository in repositories:
|
||||||
|
name = sanitize_repository_name(repository.name)
|
||||||
for parent, tag in (
|
for parent, tag in (
|
||||||
(repositories_el, "repository"),
|
(repositories_el, "repository"),
|
||||||
(plugin_repositories_el, "pluginRepository"),
|
(plugin_repositories_el, "pluginRepository"),
|
||||||
):
|
):
|
||||||
entry = ET.SubElement(parent, tag)
|
entry = ET.SubElement(parent, tag)
|
||||||
ET.SubElement(entry, "id").text = repository.name
|
ET.SubElement(entry, "id").text = name
|
||||||
ET.SubElement(entry, "url").text = repository.url
|
ET.SubElement(entry, "url").text = repository.url
|
||||||
active_profiles = ET.SubElement(settings, "activeProfiles")
|
active_profiles = ET.SubElement(settings, "activeProfiles")
|
||||||
ET.SubElement(active_profiles, "activeProfile").text = "id-configurator"
|
ET.SubElement(active_profiles, "activeProfile").text = "id-configurator"
|
||||||
mirror = ET.SubElement(ET.SubElement(settings, "mirrors"), "mirror")
|
mirror = ET.SubElement(ET.SubElement(settings, "mirrors"), "mirror")
|
||||||
ET.SubElement(mirror, "id").text = repositories[0].name
|
ET.SubElement(mirror, "id").text = sanitize_repository_name(repositories[0].name)
|
||||||
ET.SubElement(mirror, "url").text = repositories[0].url
|
ET.SubElement(mirror, "url").text = repositories[0].url
|
||||||
ET.SubElement(mirror, "mirrorOf").text = "*"
|
ET.SubElement(mirror, "mirrorOf").text = "*"
|
||||||
return settings
|
return settings
|
||||||
@@ -233,13 +246,18 @@ def _write_ivy_settings(context: Context, repositories: list[JavaRepository]) ->
|
|||||||
logger.info("configured ivy to use %d repositories", len(repositories))
|
logger.info("configured ivy to use %d repositories", len(repositories))
|
||||||
|
|
||||||
|
|
||||||
|
def _sbt_repository_line(repository: JavaRepository) -> str:
|
||||||
|
line = f" {repository.name}: {repository.url}"
|
||||||
|
if urlparse(repository.url).scheme.lower() != "https":
|
||||||
|
line += ", allowInsecureProtocol"
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
def _write_sbt_repositories(
|
def _write_sbt_repositories(
|
||||||
context: Context, repositories: list[JavaRepository]
|
context: Context, repositories: list[JavaRepository]
|
||||||
) -> None:
|
) -> None:
|
||||||
lines = ["[repositories]", " local"]
|
lines = ["[repositories]", " local"]
|
||||||
lines.extend(
|
lines.extend(_sbt_repository_line(repository) for repository in repositories)
|
||||||
f" {repository.name}: {repository.url}" for repository in repositories
|
|
||||||
)
|
|
||||||
context.write_file(_SBT_REPOSITORIES, ("\n".join(lines) + "\n").encode("utf-8"))
|
context.write_file(_SBT_REPOSITORIES, ("\n".join(lines) + "\n").encode("utf-8"))
|
||||||
logger.info("configured sbt to use %d repositories", len(repositories))
|
logger.info("configured sbt to use %d repositories", len(repositories))
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,19 @@ separate, higher-precedence level in npm's own terminology. `$prefix`
|
|||||||
defaults to `/usr/local` for essentially every standard Node.js install
|
defaults to `/usr/local` for essentially every standard Node.js install
|
||||||
(the official Docker image included); unlike a config dropped in one
|
(the official Docker image included); unlike a config dropped in one
|
||||||
specific user's home, this applies regardless of which user invokes npm.
|
specific user's home, this applies regardless of which user invokes npm.
|
||||||
|
|
||||||
|
`cafile` only covers npm's own registry client, though - it does nothing for
|
||||||
|
Node itself, e.g. a package's install script or the application's own code
|
||||||
|
making an HTTPS request. Node has no config file for that: the only way to
|
||||||
|
extend its trust store is the `NODE_EXTRA_CA_CERTS` environment variable,
|
||||||
|
read once at process startup. So whenever any certificate is configured -
|
||||||
|
regardless of whether any npm repository is - it is also set system-wide in
|
||||||
|
`/etc/environment` (merged with whatever is already there), which every
|
||||||
|
login-based process (an SSH session, `su -`, ...) picks up via PAM's
|
||||||
|
`pam_env`. This does not reach a `docker build`/`RUN` step or a container's
|
||||||
|
own `ENTRYPOINT`/`CMD`, which never go through a login shell: propagating it
|
||||||
|
there is the resulting image's own `Dockerfile`'s job (a plain `ENV`
|
||||||
|
instruction), outside what a tool that only writes files can do.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -29,6 +42,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_NPMRC = "usr/local/etc/npmrc"
|
_NPMRC = "usr/local/etc/npmrc"
|
||||||
_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
|
_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
|
||||||
|
_ETC_ENVIRONMENT = "etc/environment"
|
||||||
|
_NODE_EXTRA_CA_CERTS_VAR = "NODE_EXTRA_CA_CERTS"
|
||||||
|
|
||||||
|
|
||||||
def _write_npmrc(context: Context, repositories: list[NpmRepository]) -> None:
|
def _write_npmrc(context: Context, repositories: list[NpmRepository]) -> None:
|
||||||
@@ -41,12 +56,29 @@ def _write_npmrc(context: Context, repositories: list[NpmRepository]) -> None:
|
|||||||
logger.info("configured npm to use %s", repositories[0].url)
|
logger.info("configured npm to use %s", repositories[0].url)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_etc_environment(context: Context) -> None:
|
||||||
|
existing = context.read_src_file(_ETC_ENVIRONMENT)
|
||||||
|
lines = [] if existing is None else existing.decode("utf-8").splitlines()
|
||||||
|
lines = [
|
||||||
|
line for line in lines if not line.startswith(f"{_NODE_EXTRA_CA_CERTS_VAR}=")
|
||||||
|
]
|
||||||
|
lines.append(f"{_NODE_EXTRA_CA_CERTS_VAR}={_CA_BUNDLE}")
|
||||||
|
context.write_file(_ETC_ENVIRONMENT, ("\n".join(lines) + "\n").encode("utf-8"))
|
||||||
|
logger.info("configured %s in %s", _NODE_EXTRA_CA_CERTS_VAR, _ETC_ENVIRONMENT)
|
||||||
|
|
||||||
|
|
||||||
@register
|
@register
|
||||||
class NpmModule(Module):
|
class NpmModule(Module):
|
||||||
name = "npm"
|
name = "npm"
|
||||||
|
|
||||||
def is_relevant(self, context: Context) -> bool:
|
def is_relevant(self, context: Context) -> bool:
|
||||||
return bool(context.config.configurations.npm.repositories)
|
return bool(context.config.configurations.npm.repositories) or bool(
|
||||||
|
context.config.configurations.certificates
|
||||||
|
)
|
||||||
|
|
||||||
def apply(self, context: Context) -> None:
|
def apply(self, context: Context) -> None:
|
||||||
_write_npmrc(context, context.config.configurations.npm.repositories)
|
repositories = context.config.configurations.npm.repositories
|
||||||
|
if repositories:
|
||||||
|
_write_npmrc(context, repositories)
|
||||||
|
if context.config.configurations.certificates:
|
||||||
|
_update_etc_environment(context)
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Configure NuGet to use the configured NuGetRepository entries as its
|
||||||
|
package sources, replacing the default (nuget.org).
|
||||||
|
|
||||||
|
NuGet's own HTTPS client (.NET's HttpClient, backed by OpenSSL on Linux)
|
||||||
|
already trusts whatever the `certificates` module installs into the system
|
||||||
|
CA store, so no separate certificate configuration is needed here, unlike
|
||||||
|
npm's.
|
||||||
|
|
||||||
|
Microsoft documents a "Computer"-wide config file for this purpose,
|
||||||
|
`/etc/opt/NuGet/Config` on Linux (or `$NUGET_COMMON_APPLICATION_DATA/NuGet/Config`
|
||||||
|
should that variable be set) - but the .NET SDK does not actually read it in
|
||||||
|
practice (confirmed empirically; a known, still-open upstream gap, see
|
||||||
|
dotnet/sdk#12674), so it would silently have no effect. This instead writes
|
||||||
|
the "user" level config, `~/.nuget/NuGet/NuGet.Config` (the location the
|
||||||
|
.NET CLI itself uses; Mono/NuGet.exe use `~/.config/NuGet/NuGet.Config`
|
||||||
|
instead). This tool targets Docker images, which are built and run as root,
|
||||||
|
so `~` is assumed to be `/root`, exactly like a fresh dotnet install does
|
||||||
|
when not overridden - the same tradeoff the `php` module's Composer config
|
||||||
|
already accepts. A `<clear />` element is included so the configured
|
||||||
|
sources fully replace nuget.org rather than being merged alongside it,
|
||||||
|
matching every other package-manager module's behaviour.
|
||||||
|
|
||||||
|
The same file is additionally written under each configured `--user`'s own
|
||||||
|
home directory (`context.users`), since a non-root user invoking `dotnet`
|
||||||
|
reads its config from there instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from id_configurator.config import NuGetRepository
|
||||||
|
from id_configurator.context import Context
|
||||||
|
from id_configurator.modules.base import Module, register
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_NUGET_CONFIG = "root/.nuget/NuGet/NuGet.Config"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_xml(root: ET.Element) -> bytes:
|
||||||
|
ET.indent(root)
|
||||||
|
content = '<?xml version="1.0" encoding="utf-8"?>\n' + ET.tostring(
|
||||||
|
root, encoding="unicode"
|
||||||
|
)
|
||||||
|
return (content + "\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_nuget_config(context: Context, repositories: list[NuGetRepository]) -> None:
|
||||||
|
configuration = ET.Element("configuration")
|
||||||
|
package_sources = ET.SubElement(configuration, "packageSources")
|
||||||
|
ET.SubElement(package_sources, "clear")
|
||||||
|
for repository in repositories:
|
||||||
|
attributes = {"key": repository.name, "value": repository.url}
|
||||||
|
if urlparse(repository.url).scheme.lower() != "https":
|
||||||
|
attributes["allowInsecureConnections"] = "true"
|
||||||
|
ET.SubElement(package_sources, "add", attributes)
|
||||||
|
content = _render_xml(configuration)
|
||||||
|
context.write_file(_NUGET_CONFIG, content)
|
||||||
|
for user in context.users:
|
||||||
|
context.write_file(
|
||||||
|
f"{context.home_dir(user)}/.nuget/NuGet/NuGet.Config", content
|
||||||
|
)
|
||||||
|
logger.info("configured nuget for user %s", user)
|
||||||
|
logger.info("configured nuget to use %d package source(s)", len(repositories))
|
||||||
|
|
||||||
|
|
||||||
|
@register
|
||||||
|
class NuGetModule(Module):
|
||||||
|
name = "nuget"
|
||||||
|
|
||||||
|
def is_relevant(self, context: Context) -> bool:
|
||||||
|
return bool(context.config.configurations.nuget.repositories)
|
||||||
|
|
||||||
|
def apply(self, context: Context) -> None:
|
||||||
|
_write_nuget_config(context, context.config.configurations.nuget.repositories)
|
||||||
@@ -3,23 +3,43 @@
|
|||||||
|
|
||||||
Composer has no "replace the default registry" switch either: Packagist
|
Composer has no "replace the default registry" switch either: Packagist
|
||||||
stays enabled unless a repository is explicitly declared under the name
|
stays enabled unless a repository is explicitly declared under the name
|
||||||
"packagist.org" and disabled. This just makes the configured repositories
|
"packagist.org" and disabled. If any configured repository's URL isn't
|
||||||
|
HTTPS, Composer refuses to use it unless `secure-http` is turned off in
|
||||||
|
`config`; since that effectively means this is a private, non-internet
|
||||||
|
setup, Packagist - unreachable in such a setup anyway - is disabled at the
|
||||||
|
same time, exactly as if a `{"packagist.org": false}` repository entry had
|
||||||
|
been declared. This just makes the configured repositories
|
||||||
available system-wide, via Composer's global configuration file:
|
available system-wide, via Composer's global configuration file:
|
||||||
`/root/.composer/config.json` (`COMPOSER_HOME/config.json`; this tool
|
`/root/.composer/config.json` (`COMPOSER_HOME/config.json`; this tool
|
||||||
targets Docker images, which are built and run as root, so `COMPOSER_HOME`
|
targets Docker images, which are built and run as root, so `COMPOSER_HOME`
|
||||||
is assumed to default to `/root/.composer`, exactly like a fresh Composer
|
is assumed to default to `/root/.composer`, exactly like a fresh Composer
|
||||||
install does when not overridden).
|
install does when not overridden). The same file is additionally written
|
||||||
|
under each configured `--user`'s own home directory (`context.users`),
|
||||||
|
since a non-root user invoking `composer` reads its config from there
|
||||||
|
instead.
|
||||||
|
|
||||||
Composer's own HTTPS client (PHP's stream wrapper, generally backed by
|
Composer's own HTTPS client (PHP's stream wrapper, generally backed by
|
||||||
curl) already trusts whatever the `certificates` module installs into the
|
curl) already trusts whatever the `certificates` module installs into the
|
||||||
system CA store, so no per-repository certificate configuration is needed
|
system CA store, so no per-repository certificate configuration is needed
|
||||||
here, unlike Poetry's.
|
here, unlike Poetry's.
|
||||||
|
|
||||||
|
Whenever any certificate is configured - regardless of whether any PHP
|
||||||
|
repository is - `openssl.cafile` is also set, pointing at the same bundle,
|
||||||
|
in a dedicated `conf.d` snippet: `usr/local/etc/php/conf.d/id-configurator-
|
||||||
|
openssl.ini`, the directory the official PHP Docker images scan for extra
|
||||||
|
`.ini` files (`docker-php-ext-enable`'s own mechanism), applied regardless
|
||||||
|
of which SAPI (cli, fpm, apache) or php.ini is in use. This is normally
|
||||||
|
inferred from OpenSSL's own compiled-in default CAfile (which the
|
||||||
|
`certificates` module also rebuilds), but not every PHP build honors that
|
||||||
|
default, so setting it explicitly makes every PHP script's own OpenSSL-based
|
||||||
|
code (not just Composer's) reliably trust the configured certificates too.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from id_configurator.config import PhpRepository
|
from id_configurator.config import PhpRepository
|
||||||
from id_configurator.context import Context
|
from id_configurator.context import Context
|
||||||
@@ -28,29 +48,53 @@ from id_configurator.modules.base import Module, register
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_COMPOSER_CONFIG = "root/.composer/config.json"
|
_COMPOSER_CONFIG = "root/.composer/config.json"
|
||||||
|
_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
|
||||||
|
_OPENSSL_INI = "usr/local/etc/php/conf.d/id-configurator-openssl.ini"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_https(url: str) -> bool:
|
||||||
|
return urlparse(url).scheme.lower() == "https"
|
||||||
|
|
||||||
|
|
||||||
def _write_composer_config(context: Context, repositories: list[PhpRepository]) -> None:
|
def _write_composer_config(context: Context, repositories: list[PhpRepository]) -> None:
|
||||||
content = {
|
composer_repositories: list[dict[str, str | bool]] = [
|
||||||
"repositories": [
|
|
||||||
{"name": repository.name, "type": "composer", "url": repository.url}
|
{"name": repository.name, "type": "composer", "url": repository.url}
|
||||||
for repository in repositories
|
for repository in repositories
|
||||||
]
|
]
|
||||||
}
|
content: dict[str, object] = {"repositories": composer_repositories}
|
||||||
context.write_file(
|
composer_repositories.append({"packagist.org": False})
|
||||||
_COMPOSER_CONFIG, (json.dumps(content, indent=4) + "\n").encode("utf-8")
|
if any(not _is_https(repository.url) for repository in repositories):
|
||||||
|
content["config"] = {"secure-http": False}
|
||||||
|
logger.info(
|
||||||
|
"disabled secure-http and packagist.org: non-HTTPS repository configured"
|
||||||
)
|
)
|
||||||
|
rendered = (json.dumps(content, indent=4) + "\n").encode("utf-8")
|
||||||
|
context.write_file(_COMPOSER_CONFIG, rendered)
|
||||||
|
for user in context.users:
|
||||||
|
context.write_file(f"{context.home_dir(user)}/.composer/config.json", rendered)
|
||||||
|
logger.info("configured composer for user %s", user)
|
||||||
logger.info(
|
logger.info(
|
||||||
"configured composer to use %d additional repositories", len(repositories)
|
"configured composer to use %d additional repositories", len(repositories)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_openssl_ini(context: Context) -> None:
|
||||||
|
context.write_file(_OPENSSL_INI, f"openssl.cafile={_CA_BUNDLE}\n".encode())
|
||||||
|
logger.info("configured openssl.cafile to use %s", _CA_BUNDLE)
|
||||||
|
|
||||||
|
|
||||||
@register
|
@register
|
||||||
class PhpModule(Module):
|
class PhpModule(Module):
|
||||||
name = "php"
|
name = "php"
|
||||||
|
|
||||||
def is_relevant(self, context: Context) -> bool:
|
def is_relevant(self, context: Context) -> bool:
|
||||||
return bool(context.config.configurations.php.repositories)
|
return bool(context.config.configurations.php.repositories) or bool(
|
||||||
|
context.config.configurations.certificates
|
||||||
|
)
|
||||||
|
|
||||||
def apply(self, context: Context) -> None:
|
def apply(self, context: Context) -> None:
|
||||||
_write_composer_config(context, context.config.configurations.php.repositories)
|
repositories = context.config.configurations.php.repositories
|
||||||
|
if repositories:
|
||||||
|
_write_composer_config(context, repositories)
|
||||||
|
if context.config.configurations.certificates:
|
||||||
|
_write_openssl_ini(context)
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ PythonRepository entries instead of pypi.org, and to trust the root
|
|||||||
certificates installed by the certificates module.
|
certificates installed by the certificates module.
|
||||||
|
|
||||||
- pip: system-wide `/etc/pip.conf` (`index-url`/`extra-index-url`/`cert`).
|
- pip: system-wide `/etc/pip.conf` (`index-url`/`extra-index-url`/`cert`).
|
||||||
|
Any repository whose URL isn't HTTPS has its host added to
|
||||||
|
`trusted-host`, since pip otherwise refuses a plain HTTP index.
|
||||||
- uv: system-wide `/etc/uv/uv.toml` (`[[index]]` entries, `native-tls`).
|
- uv: system-wide `/etc/uv/uv.toml` (`[[index]]` entries, `native-tls`).
|
||||||
- poetry: `/root/.config/pypoetry/config.toml` (`repositories.*`/`certificates.*`).
|
- poetry: `/root/.config/pypoetry/config.toml` (`repositories.*`/`certificates.*`).
|
||||||
Poetry has no global "replace PyPI" switch: a project's `pyproject.toml`
|
Poetry has no global "replace PyPI" switch: a project's `pyproject.toml`
|
||||||
still has to reference these repositories as sources. This only makes the
|
still has to reference these repositories as sources. This only makes the
|
||||||
repository and its certificate available system-wide. It also assumes the
|
repository and its certificate available system-wide. Since poetry only
|
||||||
container runs as root, since poetry only reads its config from the
|
reads its config from the current user's home directory, this assumes the
|
||||||
current user's home directory.
|
container runs as root; the same file is additionally written under each
|
||||||
|
configured `--user`'s own home directory (`context.users`), for the
|
||||||
|
benefit of a non-root user invoking poetry.
|
||||||
- easy_install: `distutils.cfg` (`[easy_install] index_url`), written next
|
- easy_install: `distutils.cfg` (`[easy_install] index_url`), written next
|
||||||
to every Python installation detected under `<src>` (there is no location
|
to every Python installation detected under `<src>` (there is no location
|
||||||
independent of a specific Python prefix).
|
independent of a specific Python prefix).
|
||||||
@@ -21,10 +25,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from id_configurator.config import PythonRepository
|
from id_configurator.config import PythonRepository
|
||||||
from id_configurator.context import Context
|
from id_configurator.context import Context
|
||||||
from id_configurator.modules.base import Module, register
|
from id_configurator.modules.base import Module, register, sanitize_repository_name
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -36,7 +41,24 @@ _DISTUTILS_GLOBS = ("usr/lib/python3.*", "usr/local/lib/python3.*")
|
|||||||
|
|
||||||
|
|
||||||
def _toml_string(value: str) -> str:
|
def _toml_string(value: str) -> str:
|
||||||
return json.dumps(value)
|
"""Render `value` as a TOML basic string.
|
||||||
|
|
||||||
|
JSON and TOML basic strings share the same escape syntax, so
|
||||||
|
`json.dumps` is a convenient way to produce one - *except* for
|
||||||
|
non-ASCII characters: with the default `ensure_ascii=True`, a
|
||||||
|
character outside the Basic Multilingual Plane (e.g. an emoji) comes
|
||||||
|
out as a UTF-16 surrogate pair (`\\uXXXX\\uXXXX`), which is valid JSON
|
||||||
|
but not valid TOML - TOML's `\\uXXXX` must itself be a Unicode scalar
|
||||||
|
value, not one half of a surrogate pair. `ensure_ascii=False` instead
|
||||||
|
leaves every non-ASCII character as a literal, raw UTF-8 character,
|
||||||
|
which TOML basic strings allow directly.
|
||||||
|
"""
|
||||||
|
return json.dumps(value, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _trusted_host(repository: PythonRepository) -> str | None:
|
||||||
|
parsed = urlparse(repository.index_url)
|
||||||
|
return None if parsed.scheme.lower() == "https" else parsed.hostname
|
||||||
|
|
||||||
|
|
||||||
def _write_pip_conf(context: Context, repositories: list[PythonRepository]) -> None:
|
def _write_pip_conf(context: Context, repositories: list[PythonRepository]) -> None:
|
||||||
@@ -44,6 +66,12 @@ def _write_pip_conf(context: Context, repositories: list[PythonRepository]) -> N
|
|||||||
if len(repositories) > 1:
|
if len(repositories) > 1:
|
||||||
lines.append("extra-index-url =")
|
lines.append("extra-index-url =")
|
||||||
lines.extend(f" {repository.index_url}" for repository in repositories[1:])
|
lines.extend(f" {repository.index_url}" for repository in repositories[1:])
|
||||||
|
trusted_hosts = dict.fromkeys(
|
||||||
|
host for repository in repositories if (host := _trusted_host(repository))
|
||||||
|
)
|
||||||
|
if trusted_hosts:
|
||||||
|
lines.append("trusted-host =")
|
||||||
|
lines.extend(f" {host}" for host in trusted_hosts)
|
||||||
lines.append(f"cert = {_CA_BUNDLE}")
|
lines.append(f"cert = {_CA_BUNDLE}")
|
||||||
context.write_file(_PIP_CONF, ("\n".join(lines) + "\n").encode("utf-8"))
|
context.write_file(_PIP_CONF, ("\n".join(lines) + "\n").encode("utf-8"))
|
||||||
logger.info("configured pip to use %s", repositories[0].index_url)
|
logger.info("configured pip to use %s", repositories[0].index_url)
|
||||||
@@ -52,8 +80,9 @@ def _write_pip_conf(context: Context, repositories: list[PythonRepository]) -> N
|
|||||||
def _write_uv_toml(context: Context, repositories: list[PythonRepository]) -> None:
|
def _write_uv_toml(context: Context, repositories: list[PythonRepository]) -> None:
|
||||||
lines = ["native-tls = true", ""]
|
lines = ["native-tls = true", ""]
|
||||||
for position, repository in enumerate(repositories):
|
for position, repository in enumerate(repositories):
|
||||||
|
name = sanitize_repository_name(repository.name)
|
||||||
lines.append("[[index]]")
|
lines.append("[[index]]")
|
||||||
lines.append(f"name = {_toml_string(repository.name)}")
|
lines.append(f"name = {_toml_string(name)}")
|
||||||
lines.append(f"url = {_toml_string(repository.index_url)}")
|
lines.append(f"url = {_toml_string(repository.index_url)}")
|
||||||
if position == 0:
|
if position == 0:
|
||||||
lines.append("default = true")
|
lines.append("default = true")
|
||||||
@@ -67,15 +96,20 @@ def _write_poetry_config(
|
|||||||
) -> None:
|
) -> None:
|
||||||
lines = []
|
lines = []
|
||||||
for repository in repositories:
|
for repository in repositories:
|
||||||
lines.append(f"[repositories.{repository.name}]")
|
name = sanitize_repository_name(repository.name)
|
||||||
|
lines.append(f"[repositories.{name}]")
|
||||||
lines.append(f"url = {_toml_string(repository.index_url)}")
|
lines.append(f"url = {_toml_string(repository.index_url)}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(f"[certificates.{repository.name}]")
|
lines.append(f"[certificates.{name}]")
|
||||||
lines.append(f"cert = {_toml_string(_CA_BUNDLE)}")
|
lines.append(f"cert = {_toml_string(_CA_BUNDLE)}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
content = ("\n".join(lines).rstrip() + "\n").encode("utf-8")
|
||||||
|
context.write_file(_POETRY_CONFIG, content)
|
||||||
|
for user in context.users:
|
||||||
context.write_file(
|
context.write_file(
|
||||||
_POETRY_CONFIG, ("\n".join(lines).rstrip() + "\n").encode("utf-8")
|
f"{context.home_dir(user)}/.config/pypoetry/config.toml", content
|
||||||
)
|
)
|
||||||
|
logger.info("configured poetry for user %s", user)
|
||||||
logger.info("registered %d poetry repositories", len(repositories))
|
logger.info("registered %d poetry repositories", len(repositories))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Configure RubyGems to use the configured RubyRepository entries as its
|
||||||
|
gem sources, replacing the default (rubygems.org).
|
||||||
|
|
||||||
|
RubyGems' own HTTPS client (Net::HTTP, backed by OpenSSL) already trusts
|
||||||
|
whatever the `certificates` module installs into the system CA store, so no
|
||||||
|
separate certificate configuration is needed here, unlike npm's.
|
||||||
|
|
||||||
|
Written to RubyGems' system-wide config file, `Gem::ConfigFile::SYSTEM_WIDE_CONFIG_FILE`
|
||||||
|
- `$sysconfdir/gemrc`, where `$sysconfdir` is Ruby's own configured
|
||||||
|
sysconfdir, not necessarily `/etc`. The official Ruby Docker image builds
|
||||||
|
Ruby with `--prefix=/usr/local`, giving `/usr/local/etc/gemrc` (confirmed
|
||||||
|
empirically, since Debian's own `sysconfdir` convention of `/etc` does not
|
||||||
|
apply to a from-source build). Also written to the more conventional
|
||||||
|
`/etc/gemrc`, for any Ruby built with the Debian/`sysconfdir=/etc`
|
||||||
|
convention instead (e.g. a distro-packaged `ruby` rather than the official
|
||||||
|
image) - harmless to write in both places, since only whichever one a given
|
||||||
|
Ruby build actually treats as its sysconfdir is ever read. Unlike a config
|
||||||
|
dropped in one specific user's home (`~/.gemrc`, RubyGems' separate "per
|
||||||
|
user" level), this applies regardless of which user invokes `gem`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from id_configurator.config import RubyRepository
|
||||||
|
from id_configurator.context import Context
|
||||||
|
from id_configurator.modules.base import Module, register
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_GEMRC_PATHS = ("usr/local/etc/gemrc", "etc/gemrc")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_gemrc(context: Context, repositories: list[RubyRepository]) -> None:
|
||||||
|
lines = [":sources:"]
|
||||||
|
lines.extend(f"- {repository.url}" for repository in repositories)
|
||||||
|
content = ("\n".join(lines) + "\n").encode("utf-8")
|
||||||
|
for path in _GEMRC_PATHS:
|
||||||
|
context.write_file(path, content)
|
||||||
|
logger.info("configured gem to use %d source(s)", len(repositories))
|
||||||
|
|
||||||
|
|
||||||
|
@register
|
||||||
|
class RubyModule(Module):
|
||||||
|
name = "ruby"
|
||||||
|
|
||||||
|
def is_relevant(self, context: Context) -> bool:
|
||||||
|
return bool(context.config.configurations.ruby.repositories)
|
||||||
|
|
||||||
|
def apply(self, context: Context) -> None:
|
||||||
|
_write_gemrc(context, context.config.configurations.ruby.repositories)
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
"""Configure cargo to use the configured RustRepository entries: the first
|
"""Configure cargo to use the configured RustRepository entries: the first
|
||||||
one replaces crates.io entirely (via `[source.crates-io] replace-with`), and
|
one replaces crates.io entirely (via `[source.crates-io] replace-with`), and
|
||||||
any further ones are registered as additional named registries (usable via
|
any further ones are registered as additional named registries (usable via
|
||||||
`cargo add --registry <name>` or a dependency's `registry = "<name>"`), all
|
`cargo add --registry <name>` or a dependency's `registry = "<name>"`). A
|
||||||
using the sparse (plain HTTPS) protocol rather than a git-based index.
|
repository's index is declared as `sparse+<url>` - the sparse (plain HTTPS)
|
||||||
|
protocol - only when its `is_sparse` option is set; otherwise `<url>` is used
|
||||||
|
as-is, for a git-based index.
|
||||||
|
|
||||||
Cargo's own HTTPS client already trusts whatever the `certificates` module
|
Cargo's own HTTPS client already trusts whatever the `certificates` module
|
||||||
installs into the system CA store, so no separate certificate configuration
|
installs into the system CA store, so no separate certificate configuration
|
||||||
@@ -17,6 +19,11 @@ directory to `/`, consulting `/.cargo/config.toml` there, *before* falling
|
|||||||
back to `$CARGO_HOME/config.toml` - so this is the one genuinely global
|
back to `$CARGO_HOME/config.toml` - so this is the one genuinely global
|
||||||
location: found regardless of which user, working directory or `CARGO_HOME`
|
location: found regardless of which user, working directory or `CARGO_HOME`
|
||||||
is in effect, unlike a config dropped in one specific user's home.
|
is in effect, unlike a config dropped in one specific user's home.
|
||||||
|
|
||||||
|
For each configured `--user` (`context.users`), the same config is
|
||||||
|
additionally written at that user's own `$HOME/.cargo/config.toml` - the
|
||||||
|
conventional, un-overridden `CARGO_HOME` location for that specific user,
|
||||||
|
which this module otherwise has no visibility into.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -25,29 +32,39 @@ import logging
|
|||||||
|
|
||||||
from id_configurator.config import RustRepository
|
from id_configurator.config import RustRepository
|
||||||
from id_configurator.context import Context
|
from id_configurator.context import Context
|
||||||
from id_configurator.modules.base import Module, register
|
from id_configurator.modules.base import Module, register, sanitize_repository_name
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_CARGO_CONFIG = ".cargo/config.toml"
|
_CARGO_CONFIG = ".cargo/config.toml"
|
||||||
|
|
||||||
|
|
||||||
|
def _index_url(repository: RustRepository) -> str:
|
||||||
|
return f"sparse+{repository.url}" if repository.is_sparse else repository.url
|
||||||
|
|
||||||
|
|
||||||
def _write_cargo_config(context: Context, repositories: list[RustRepository]) -> None:
|
def _write_cargo_config(context: Context, repositories: list[RustRepository]) -> None:
|
||||||
primary = repositories[0]
|
primary = repositories[0]
|
||||||
|
primary_name = sanitize_repository_name(primary.name)
|
||||||
lines = [
|
lines = [
|
||||||
"[source.crates-io]",
|
"[source.crates-io]",
|
||||||
f'replace-with = "{primary.name}"',
|
f'replace-with = "{primary_name}"',
|
||||||
"",
|
"",
|
||||||
f"[source.{primary.name}]",
|
f"[source.{primary_name}]",
|
||||||
f'registry = "sparse+{primary.url}"',
|
f'registry = "{_index_url(primary)}"',
|
||||||
]
|
]
|
||||||
for repository in repositories[1:]:
|
for repository in repositories[1:]:
|
||||||
|
name = sanitize_repository_name(repository.name)
|
||||||
lines += [
|
lines += [
|
||||||
"",
|
"",
|
||||||
f"[registries.{repository.name}]",
|
f"[registries.{name}]",
|
||||||
f'index = "sparse+{repository.url}"',
|
f'index = "{_index_url(repository)}"',
|
||||||
]
|
]
|
||||||
context.write_file(_CARGO_CONFIG, ("\n".join(lines) + "\n").encode("utf-8"))
|
content = ("\n".join(lines) + "\n").encode("utf-8")
|
||||||
|
context.write_file(_CARGO_CONFIG, content)
|
||||||
|
for user in context.users:
|
||||||
|
context.write_file(f"{context.home_dir(user)}/.cargo/config.toml", content)
|
||||||
|
logger.info("configured cargo for user %s", user)
|
||||||
logger.info("configured cargo to use %s", primary.url)
|
logger.info("configured cargo to use %s", primary.url)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ DNF is a drop-in successor to YUM and reads the exact same `.repo` files
|
|||||||
under `/etc/yum.repos.d/`, so a single module covers both.
|
under `/etc/yum.repos.d/`, so a single module covers both.
|
||||||
|
|
||||||
If `<src>/etc/yum.repos.d` exists, every `.repo` file already there is
|
If `<src>/etc/yum.repos.d` exists, every `.repo` file already there is
|
||||||
copied into `<dst>` with all of its sections disabled (`enabled=0`), before
|
copied into `<dst>`, before the repositories declared in the configuration
|
||||||
the repositories declared in the configuration are written as new, enabled
|
are written as new, enabled `.repo` files (so a configured repository
|
||||||
`.repo` files (so a configured repository reusing an existing file name
|
reusing an existing file name ends up enabled, taking precedence over the
|
||||||
ends up enabled, taking precedence over the disabled copy). Each `.repo`
|
copied one). A section of an existing `.repo` file is only disabled
|
||||||
|
(`enabled=0`) if its `baseurl`/`mirrorlist` matches the
|
||||||
|
`index_url_to_replace` pattern of one of the configured repositories;
|
||||||
|
sections matching no such pattern are copied through unchanged. Each `.repo`
|
||||||
file is written referencing its `gpg-url` (used verbatim as `gpgkey=`,
|
file is written referencing its `gpg-url` (used verbatim as `gpgkey=`,
|
||||||
exactly as YUM/DNF expect: `https://`, or a `file://` path already present
|
exactly as YUM/DNF expect: `https://`, or a `file://` path already present
|
||||||
on the target system). Separately, each GPG key declared in
|
on the target system). Separately, each GPG key declared in
|
||||||
@@ -17,20 +20,32 @@ conventional location such keys live at on RPM-based systems. `gpg` is only
|
|||||||
used here to validate the key; the actual trust decision is left to
|
used here to validate the key; the actual trust decision is left to
|
||||||
YUM/DNF, which import it from that file the first time the repository is
|
YUM/DNF, which import it from that file the first time the repository is
|
||||||
used (as `rpm --import` would).
|
used (as `rpm --import` would).
|
||||||
|
|
||||||
|
A repository's `index_url_to_replace`, if set, is an fnmatch pattern
|
||||||
|
checked against the baseurl of every section of every existing `.repo`
|
||||||
|
file: the repository is only written out if the pattern matches at least
|
||||||
|
one of them, and skipped entirely otherwise.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import configparser
|
import configparser
|
||||||
|
import fnmatch
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from collections.abc import Sequence
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
|
||||||
from id_configurator.config import NamedContent, YumRepository
|
from id_configurator.config import NamedContent, YumRepository
|
||||||
from id_configurator.context import Context
|
from id_configurator.context import Context
|
||||||
from id_configurator.modules.base import Module, register
|
from id_configurator.modules.base import (
|
||||||
|
Module,
|
||||||
|
register,
|
||||||
|
sanitize_filename,
|
||||||
|
sanitize_repository_name,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -38,30 +53,53 @@ _REPOS_DIR = "etc/yum.repos.d"
|
|||||||
_RPM_GPG_DIR = "etc/pki/rpm-gpg"
|
_RPM_GPG_DIR = "etc/pki/rpm-gpg"
|
||||||
|
|
||||||
|
|
||||||
def _disable_repo_content(content: str) -> str:
|
def _disable_repo_content(content: str, patterns: Sequence[str]) -> str:
|
||||||
parser = configparser.ConfigParser(interpolation=None)
|
parser = configparser.ConfigParser(interpolation=None)
|
||||||
parser.optionxform = str
|
parser.optionxform = str
|
||||||
parser.read_string(content)
|
parser.read_string(content)
|
||||||
for section in parser.sections():
|
for section in parser.sections():
|
||||||
|
urls = [parser[section].get(key) for key in ("baseurl", "mirrorlist")]
|
||||||
|
urls = [url for url in urls if url]
|
||||||
|
if any(fnmatch.fnmatch(url, pattern) for url in urls for pattern in patterns):
|
||||||
parser[section]["enabled"] = "0"
|
parser[section]["enabled"] = "0"
|
||||||
buffer = StringIO()
|
buffer = StringIO()
|
||||||
parser.write(buffer, space_around_delimiters=False)
|
parser.write(buffer, space_around_delimiters=False)
|
||||||
return buffer.getvalue()
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def _disable_existing_repos(context: Context) -> None:
|
def _existing_index_urls(context: Context) -> set[str]:
|
||||||
|
"""Collect the baseurl of every section of every .repo file already
|
||||||
|
present on the source filesystem."""
|
||||||
|
urls: set[str] = set()
|
||||||
for entry in context.iter_src_dir(_REPOS_DIR):
|
for entry in context.iter_src_dir(_REPOS_DIR):
|
||||||
if entry.suffix != ".repo":
|
if entry.suffix != ".repo":
|
||||||
continue
|
continue
|
||||||
disabled = _disable_repo_content(entry.read_text(encoding="utf-8"))
|
parser = configparser.ConfigParser(interpolation=None)
|
||||||
|
parser.optionxform = str
|
||||||
|
parser.read_string(entry.read_text(encoding="utf-8"))
|
||||||
|
for section in parser.sections():
|
||||||
|
# noinspection SpellCheckingInspection
|
||||||
|
for key in ("baseurl", "mirrorlist"):
|
||||||
|
url = parser[section].get(key)
|
||||||
|
if url:
|
||||||
|
urls.add(url)
|
||||||
|
return urls
|
||||||
|
|
||||||
|
|
||||||
|
def _disable_existing_repos(context: Context, patterns: Sequence[str]) -> None:
|
||||||
|
for entry in context.iter_src_dir(_REPOS_DIR):
|
||||||
|
if entry.suffix != ".repo":
|
||||||
|
continue
|
||||||
|
disabled = _disable_repo_content(entry.read_text(encoding="utf-8"), patterns)
|
||||||
context.write_file(f"{_REPOS_DIR}/{entry.name}", disabled.encode("utf-8"))
|
context.write_file(f"{_REPOS_DIR}/{entry.name}", disabled.encode("utf-8"))
|
||||||
logger.info("disabled existing yum/dnf repository file %s", entry.name)
|
logger.info("copied existing yum/dnf repository file %s", entry.name)
|
||||||
|
|
||||||
|
|
||||||
def _write_repo_file(context: Context, repository: YumRepository) -> None:
|
def _write_repo_file(context: Context, repository: YumRepository) -> None:
|
||||||
|
name = sanitize_repository_name(repository.name)
|
||||||
lines = [
|
lines = [
|
||||||
f"[{repository.name}]",
|
f"[{name}]",
|
||||||
f"name={repository.name}",
|
f"name={name}",
|
||||||
f"baseurl={repository.index_url}",
|
f"baseurl={repository.index_url}",
|
||||||
"enabled=1",
|
"enabled=1",
|
||||||
]
|
]
|
||||||
@@ -71,10 +109,10 @@ def _write_repo_file(context: Context, repository: YumRepository) -> None:
|
|||||||
else:
|
else:
|
||||||
lines.append("gpgcheck=0")
|
lines.append("gpgcheck=0")
|
||||||
context.write_file(
|
context.write_file(
|
||||||
f"{_REPOS_DIR}/{repository.name}.repo",
|
f"{_REPOS_DIR}/{name}.repo",
|
||||||
("\n".join(lines) + "\n").encode("utf-8"),
|
("\n".join(lines) + "\n").encode("utf-8"),
|
||||||
)
|
)
|
||||||
logger.info("added yum/dnf repository %s", repository.name)
|
logger.info("added yum/dnf repository %s", name)
|
||||||
|
|
||||||
|
|
||||||
def _validate_gpg_key(name: str, content: bytes) -> None:
|
def _validate_gpg_key(name: str, content: bytes) -> None:
|
||||||
@@ -100,7 +138,8 @@ def _validate_gpg_key(name: str, content: bytes) -> None:
|
|||||||
def _import_key(context: Context, key: NamedContent) -> None:
|
def _import_key(context: Context, key: NamedContent) -> None:
|
||||||
_validate_gpg_key(key.name, key.content.encode("utf-8"))
|
_validate_gpg_key(key.name, key.content.encode("utf-8"))
|
||||||
context.write_file(
|
context.write_file(
|
||||||
f"{_RPM_GPG_DIR}/RPM-GPG-KEY-{key.name}", key.content.encode("utf-8")
|
f"{_RPM_GPG_DIR}/RPM-GPG-KEY-{sanitize_filename(key.name)}",
|
||||||
|
key.content.encode("utf-8"),
|
||||||
)
|
)
|
||||||
logger.info("imported yum/dnf GPG key %s", key.name)
|
logger.info("imported yum/dnf GPG key %s", key.name)
|
||||||
|
|
||||||
@@ -119,8 +158,28 @@ class YumModule(Module):
|
|||||||
return bool(yum.repositories) or bool(yum.keys) or any(existing_repo_files)
|
return bool(yum.repositories) or bool(yum.keys) or any(existing_repo_files)
|
||||||
|
|
||||||
def apply(self, context: Context) -> None:
|
def apply(self, context: Context) -> None:
|
||||||
_disable_existing_repos(context)
|
patterns = [
|
||||||
|
repository.index_url_to_replace
|
||||||
|
for repository in context.config.configurations.yum.repositories
|
||||||
|
if repository.index_url_to_replace is not None
|
||||||
|
]
|
||||||
|
_disable_existing_repos(context, patterns)
|
||||||
|
existing_index_urls: set[str] | None = None
|
||||||
for repository in context.config.configurations.yum.repositories:
|
for repository in context.config.configurations.yum.repositories:
|
||||||
|
if repository.index_url_to_replace is not None:
|
||||||
|
if existing_index_urls is None:
|
||||||
|
existing_index_urls: set[str] = _existing_index_urls(context)
|
||||||
|
if not any(
|
||||||
|
fnmatch.fnmatch(url, repository.index_url_to_replace)
|
||||||
|
for url in existing_index_urls
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"skipped yum/dnf repository %s: index-url-to-replace %s "
|
||||||
|
"matched no existing baseurl",
|
||||||
|
repository.name,
|
||||||
|
repository.index_url_to_replace,
|
||||||
|
)
|
||||||
|
continue
|
||||||
_write_repo_file(context, repository)
|
_write_repo_file(context, repository)
|
||||||
for key in context.config.configurations.yum.keys:
|
for key in context.config.configurations.yum.keys:
|
||||||
_import_key(context, key)
|
_import_key(context, key)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -3,6 +3,7 @@ import logging
|
|||||||
import typer
|
import typer
|
||||||
|
|
||||||
from id_tools.build import app as build_app
|
from id_tools.build import app as build_app
|
||||||
|
from id_tools.publication import app as publication_app
|
||||||
from id_tools.test import app as test_app
|
from id_tools.test import app as test_app
|
||||||
|
|
||||||
SUCCESS = 25
|
SUCCESS = 25
|
||||||
@@ -44,5 +45,6 @@ def main():
|
|||||||
|
|
||||||
app.add_typer(build_app, name="build")
|
app.add_typer(build_app, name="build")
|
||||||
app.add_typer(test_app, name="test")
|
app.add_typer(test_app, name="test")
|
||||||
|
app.add_typer(publication_app, name="publication")
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app()
|
app()
|
||||||
|
|||||||
+6
-5
@@ -72,17 +72,18 @@ LABEL org.opencontainers.image.version="{id_configurator_version}"
|
|||||||
LABEL description="Air gap made simple!"
|
LABEL description="Air gap made simple!"
|
||||||
COPY --from=interdiode-install --chown=0:0 /chroot/usr/local/bin /usr/local/bin
|
COPY --from=interdiode-install --chown=0:0 /chroot/usr/local/bin /usr/local/bin
|
||||||
COPY --from=interdiode-install /usr/local/interdiode /usr/local/interdiode
|
COPY --from=interdiode-install /usr/local/interdiode /usr/local/interdiode
|
||||||
RUN DEBIAN_FRONTEND=noninteractive mkdir -p /usr/local/interdiode \
|
RUN apt-get update \
|
||||||
|
&& DEBIAN_FRONTEND=noninteractive apt-get -o Dpkg::Options::="--force-confnew" --allow-downgrades --allow-remove-essential --allow-unauthenticated -fuy dist-upgrade \
|
||||||
|
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes -qq --no-install-recommends gpg gpg-agent \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& mkdir -p /usr/local/interdiode \
|
||||||
&& chown -R {docker_user}:{docker_group} /usr/local/interdiode
|
&& chown -R {docker_user}:{docker_group} /usr/local/interdiode
|
||||||
COPY ./LICENSE /chroot/licenses/LICENSE
|
COPY ./LICENSE /chroot/licenses/LICENSE
|
||||||
COPY ./LICENSE /chroot/usr/share/doc/interdiode/LICENSE
|
COPY ./LICENSE /chroot/usr/share/doc/interdiode/LICENSE
|
||||||
WORKDIR /
|
WORKDIR /
|
||||||
"""
|
"""
|
||||||
|
|
||||||
NPM_TO_COPY = [
|
|
||||||
("bootstrap/dist/js/bootstrap.esm.js.map", "js/bootstrap.esm.js.map"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def build_docker_image(
|
def build_docker_image(
|
||||||
config: SourceConfig,
|
config: SourceConfig,
|
||||||
|
|||||||
@@ -0,0 +1,653 @@
|
|||||||
|
#!/usr/bin/python
|
||||||
|
"""Ansible module wrapping id-configurator.
|
||||||
|
|
||||||
|
Applies an id-configurator configuration directly to this host's live root
|
||||||
|
filesystem, exactly like the `id_configurator` role does, but as a native
|
||||||
|
Ansible module: it imports id-configurator's own Python API in-process
|
||||||
|
(instead of shelling out to the CLI through a temporary file), which also
|
||||||
|
lets it honor Ansible's check mode - reporting which modules would be
|
||||||
|
applied without writing anything.
|
||||||
|
|
||||||
|
This module is a thin, additive wrapper: it does not change anything about
|
||||||
|
id-configurator's own behavior (the `id_configurator` Python package,
|
||||||
|
including its module registry and every individual module, is used exactly
|
||||||
|
as-is - the same `Configuration`, `Context` and module registry the CLI
|
||||||
|
itself uses).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ansible.module_utils.basic import AnsibleModule
|
||||||
|
|
||||||
|
DOCUMENTATION = r"""
|
||||||
|
module: id_configurator
|
||||||
|
short_description: Apply an id-configurator configuration to this host.
|
||||||
|
description:
|
||||||
|
- Assembles the given options into an id-configurator configuration and
|
||||||
|
applies it directly to this host's live root filesystem, by importing
|
||||||
|
id-configurator's own Python API in-process (no CLI subprocess, no
|
||||||
|
temporary file).
|
||||||
|
- The option names and structure mirror id_configurator.config.Configuration
|
||||||
|
and id_configurator.config.ConfigurationsBlock one for one. The
|
||||||
|
`id_configurator` Python package must already be installed on the
|
||||||
|
target host, using the same Python interpreter Ansible uses there.
|
||||||
|
- Supports check mode - reports which modules would be applied without
|
||||||
|
writing anything.
|
||||||
|
author:
|
||||||
|
- InterDiode
|
||||||
|
options:
|
||||||
|
version:
|
||||||
|
type: str
|
||||||
|
default: "1.0"
|
||||||
|
description: Configuration.version - the configuration format's own schema version.
|
||||||
|
uuid:
|
||||||
|
type: str
|
||||||
|
description: Configuration.uuid - a UUID identifying this configuration, optional.
|
||||||
|
organisation:
|
||||||
|
type: str
|
||||||
|
description: Configuration.organisation, optional.
|
||||||
|
description:
|
||||||
|
type: str
|
||||||
|
description: Configuration.description, optional.
|
||||||
|
certificates:
|
||||||
|
type: list
|
||||||
|
elements: dict
|
||||||
|
default: []
|
||||||
|
description: ConfigurationsBlock.certificates - root certificates to install.
|
||||||
|
suboptions:
|
||||||
|
name:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: A short, unique name for this entry.
|
||||||
|
content:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: PEM-encoded certificate content.
|
||||||
|
apt:
|
||||||
|
type: dict
|
||||||
|
default: {}
|
||||||
|
description: ConfigurationsBlock.apt.
|
||||||
|
suboptions:
|
||||||
|
sources:
|
||||||
|
type: list
|
||||||
|
elements: dict
|
||||||
|
default: []
|
||||||
|
description: DEB822 APT sources (AptSource).
|
||||||
|
suboptions:
|
||||||
|
name:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: A short, unique name for this source.
|
||||||
|
types:
|
||||||
|
type: list
|
||||||
|
elements: str
|
||||||
|
default: [deb]
|
||||||
|
description: DEB822 "Types" field.
|
||||||
|
uris:
|
||||||
|
type: list
|
||||||
|
elements: str
|
||||||
|
required: true
|
||||||
|
description: DEB822 "URIs" field.
|
||||||
|
suites:
|
||||||
|
type: list
|
||||||
|
elements: str
|
||||||
|
required: true
|
||||||
|
description: DEB822 "Suites" field.
|
||||||
|
components:
|
||||||
|
type: list
|
||||||
|
elements: str
|
||||||
|
default: []
|
||||||
|
description: DEB822 "Components" field.
|
||||||
|
architectures:
|
||||||
|
type: list
|
||||||
|
elements: str
|
||||||
|
default: []
|
||||||
|
description: DEB822 "Architectures" field.
|
||||||
|
languages:
|
||||||
|
type: list
|
||||||
|
elements: str
|
||||||
|
default: []
|
||||||
|
description: DEB822 "Languages" field.
|
||||||
|
targets:
|
||||||
|
type: list
|
||||||
|
elements: str
|
||||||
|
default: []
|
||||||
|
description: DEB822 "Targets" field.
|
||||||
|
enabled:
|
||||||
|
type: bool
|
||||||
|
default: true
|
||||||
|
description: DEB822 "Enabled" field.
|
||||||
|
trusted:
|
||||||
|
type: bool
|
||||||
|
description: DEB822 "Trusted" field.
|
||||||
|
signed_by:
|
||||||
|
type: str
|
||||||
|
description:
|
||||||
|
- ASCII-armored content of a GPG public key to trust this
|
||||||
|
source with. Folded inline as a multi-line Signed-By field
|
||||||
|
(no separate keyring file or gpg binary needed). Takes
|
||||||
|
precedence over signed_by_file if both are set.
|
||||||
|
signed_by_file:
|
||||||
|
type: str
|
||||||
|
description:
|
||||||
|
- Path to a keyring already expected to exist on the target
|
||||||
|
system, used verbatim as the rendered Signed-By field.
|
||||||
|
Ignored if signed_by is also set.
|
||||||
|
pdiffs:
|
||||||
|
type: bool
|
||||||
|
description: DEB822 "PDiffs" field.
|
||||||
|
by_hash:
|
||||||
|
type: bool
|
||||||
|
description: DEB822 "By-Hash" field.
|
||||||
|
allow_insecure:
|
||||||
|
type: bool
|
||||||
|
description: DEB822 "Allow-Insecure" field.
|
||||||
|
allow_weak:
|
||||||
|
type: bool
|
||||||
|
description: DEB822 "Allow-Weak" field.
|
||||||
|
allow_downgrade_to_insecure:
|
||||||
|
type: bool
|
||||||
|
description: DEB822 "Allow-Downgrade-To-Insecure" field.
|
||||||
|
check_valid_until:
|
||||||
|
type: bool
|
||||||
|
description: DEB822 "Check-Valid-Until" field.
|
||||||
|
valid_until_min:
|
||||||
|
type: str
|
||||||
|
description: DEB822 "Valid-Until-Min" field.
|
||||||
|
valid_until_max:
|
||||||
|
type: str
|
||||||
|
description: DEB822 "Valid-Until-Max" field.
|
||||||
|
check_date:
|
||||||
|
type: bool
|
||||||
|
description: DEB822 "Check-Date" field.
|
||||||
|
date_max_future:
|
||||||
|
type: str
|
||||||
|
description: DEB822 "Date-Max-Future" field.
|
||||||
|
description:
|
||||||
|
type: str
|
||||||
|
description: DEB822 "Description" field.
|
||||||
|
uri_to_replace:
|
||||||
|
type: str
|
||||||
|
description:
|
||||||
|
- fnmatch pattern checked against the URIs already present in
|
||||||
|
the machine's existing apt configuration. If set, this
|
||||||
|
source is only applied when the pattern matches at least
|
||||||
|
one of them; otherwise it is skipped entirely.
|
||||||
|
apk:
|
||||||
|
type: dict
|
||||||
|
default: {}
|
||||||
|
description: ConfigurationsBlock.apk.
|
||||||
|
suboptions:
|
||||||
|
repositories:
|
||||||
|
type: list
|
||||||
|
elements: dict
|
||||||
|
default: []
|
||||||
|
description: Alpine APK repositories (ApkRepository).
|
||||||
|
suboptions:
|
||||||
|
url:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: Repository URL, e.g. ending in /main or /community.
|
||||||
|
key:
|
||||||
|
type: str
|
||||||
|
description: The signing key's name, if any (see NamedContent).
|
||||||
|
url_to_replace:
|
||||||
|
type: str
|
||||||
|
description:
|
||||||
|
- fnmatch pattern checked against the URLs already active in
|
||||||
|
the machine's existing /etc/apk/repositories. If set, this
|
||||||
|
repository is only applied when the pattern matches at
|
||||||
|
least one of them; otherwise it is skipped entirely.
|
||||||
|
yum:
|
||||||
|
type: dict
|
||||||
|
default: {}
|
||||||
|
description: ConfigurationsBlock.yum.
|
||||||
|
suboptions:
|
||||||
|
repositories:
|
||||||
|
type: list
|
||||||
|
elements: dict
|
||||||
|
default: []
|
||||||
|
description: YUM/DNF repositories (YumRepository).
|
||||||
|
suboptions:
|
||||||
|
name:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: A short, unique name for this repository.
|
||||||
|
index_url:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: The repository's baseurl.
|
||||||
|
gpg_url:
|
||||||
|
type: str
|
||||||
|
description: The repository's gpgkey URL.
|
||||||
|
index_url_to_replace:
|
||||||
|
type: str
|
||||||
|
description:
|
||||||
|
- fnmatch pattern checked against the baseurl of every
|
||||||
|
section of every existing .repo file. If set, this
|
||||||
|
repository is only applied when the pattern matches at
|
||||||
|
least one of them; otherwise it is skipped entirely.
|
||||||
|
keys:
|
||||||
|
type: list
|
||||||
|
elements: dict
|
||||||
|
default: []
|
||||||
|
description: GPG keys imported for the repositories above (NamedContent).
|
||||||
|
suboptions:
|
||||||
|
name:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: A short, unique name for this entry.
|
||||||
|
content:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: PEM-encoded/ASCII-armored key content.
|
||||||
|
python:
|
||||||
|
type: dict
|
||||||
|
default: {}
|
||||||
|
description: ConfigurationsBlock.python.
|
||||||
|
suboptions:
|
||||||
|
repositories:
|
||||||
|
type: list
|
||||||
|
elements: dict
|
||||||
|
default: []
|
||||||
|
description: Python package indexes (PythonRepository).
|
||||||
|
suboptions:
|
||||||
|
name:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: A short, unique name for this index.
|
||||||
|
index_url:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: The package index's URL.
|
||||||
|
java:
|
||||||
|
type: dict
|
||||||
|
default: {}
|
||||||
|
description: ConfigurationsBlock.java.
|
||||||
|
suboptions:
|
||||||
|
repositories:
|
||||||
|
type: list
|
||||||
|
elements: dict
|
||||||
|
default: []
|
||||||
|
description: Maven-compatible repositories (JavaRepository).
|
||||||
|
suboptions:
|
||||||
|
name:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: A short, unique name for this repository.
|
||||||
|
url:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: The repository's URL.
|
||||||
|
php:
|
||||||
|
type: dict
|
||||||
|
default: {}
|
||||||
|
description: ConfigurationsBlock.php.
|
||||||
|
suboptions:
|
||||||
|
repositories:
|
||||||
|
type: list
|
||||||
|
elements: dict
|
||||||
|
default: []
|
||||||
|
description: Composer repositories (PhpRepository).
|
||||||
|
suboptions:
|
||||||
|
name:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: A short, unique name for this repository.
|
||||||
|
url:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: The repository's URL.
|
||||||
|
npm:
|
||||||
|
type: dict
|
||||||
|
default: {}
|
||||||
|
description: ConfigurationsBlock.npm.
|
||||||
|
suboptions:
|
||||||
|
repositories:
|
||||||
|
type: list
|
||||||
|
elements: dict
|
||||||
|
default: []
|
||||||
|
description: npm registries (NpmRepository).
|
||||||
|
suboptions:
|
||||||
|
name:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: A short, unique name for this repository.
|
||||||
|
url:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: The repository's URL.
|
||||||
|
rust:
|
||||||
|
type: dict
|
||||||
|
default: {}
|
||||||
|
description: ConfigurationsBlock.rust.
|
||||||
|
suboptions:
|
||||||
|
repositories:
|
||||||
|
type: list
|
||||||
|
elements: dict
|
||||||
|
default: []
|
||||||
|
description: Cargo registries (RustRepository).
|
||||||
|
suboptions:
|
||||||
|
name:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: A short, unique name for this repository.
|
||||||
|
url:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: The repository's URL.
|
||||||
|
go:
|
||||||
|
type: dict
|
||||||
|
default: {}
|
||||||
|
description: ConfigurationsBlock.go.
|
||||||
|
suboptions:
|
||||||
|
repositories:
|
||||||
|
type: list
|
||||||
|
elements: dict
|
||||||
|
default: []
|
||||||
|
description: Go module proxies (GoRepository).
|
||||||
|
suboptions:
|
||||||
|
name:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: A short, unique name for this repository.
|
||||||
|
url:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: The repository's URL.
|
||||||
|
ruby:
|
||||||
|
type: dict
|
||||||
|
default: {}
|
||||||
|
description: ConfigurationsBlock.ruby.
|
||||||
|
suboptions:
|
||||||
|
repositories:
|
||||||
|
type: list
|
||||||
|
elements: dict
|
||||||
|
default: []
|
||||||
|
description: RubyGems sources (RubyRepository).
|
||||||
|
suboptions:
|
||||||
|
name:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: A short, unique name for this repository.
|
||||||
|
url:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: The repository's URL.
|
||||||
|
nuget:
|
||||||
|
type: dict
|
||||||
|
default: {}
|
||||||
|
description: ConfigurationsBlock.nuget.
|
||||||
|
suboptions:
|
||||||
|
repositories:
|
||||||
|
type: list
|
||||||
|
elements: dict
|
||||||
|
default: []
|
||||||
|
description: NuGet package sources (NuGetRepository).
|
||||||
|
suboptions:
|
||||||
|
name:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: A short, unique name for this repository.
|
||||||
|
url:
|
||||||
|
type: str
|
||||||
|
required: true
|
||||||
|
description: The repository's URL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
EXAMPLES = r"""
|
||||||
|
- name: Apply an id-configurator configuration to this host
|
||||||
|
id_configurator:
|
||||||
|
uuid: 12345678-1234-5678-1234-567812345678
|
||||||
|
organisation: InterDiode
|
||||||
|
certificates:
|
||||||
|
- name: Company Root CA
|
||||||
|
content: "{{ lookup('file', 'files/company-root-ca.pem') }}"
|
||||||
|
apt:
|
||||||
|
sources:
|
||||||
|
- name: internal
|
||||||
|
uris: ["https://apt.internal.example/"]
|
||||||
|
suites: ["stable"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
RETURN = r"""
|
||||||
|
applied_modules:
|
||||||
|
description: Names of the id-configurator modules that were (or, in check
|
||||||
|
mode, would be) applied, in registration order.
|
||||||
|
returned: always
|
||||||
|
type: list
|
||||||
|
elements: str
|
||||||
|
sample: [certificates, apt]
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Every key here must match one of ConfigurationsBlock's own field names
|
||||||
|
# (id_configurator/config.py) - see the module docstring above.
|
||||||
|
_CONFIGURATIONS_BLOCK_FIELDS = (
|
||||||
|
"certificates",
|
||||||
|
"apt",
|
||||||
|
"apk",
|
||||||
|
"yum",
|
||||||
|
"python",
|
||||||
|
"java",
|
||||||
|
"php",
|
||||||
|
"npm",
|
||||||
|
"rust",
|
||||||
|
"go",
|
||||||
|
"ruby",
|
||||||
|
"nuget",
|
||||||
|
)
|
||||||
|
|
||||||
|
_NAMED_CONTENT_OPTIONS = {
|
||||||
|
"name": {"type": "str", "required": True},
|
||||||
|
"content": {"type": "str", "required": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
_NAME_URL_OPTIONS = {
|
||||||
|
"name": {"type": "str", "required": True},
|
||||||
|
"url": {"type": "str", "required": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _name_url_repository_config() -> dict:
|
||||||
|
"""Argument spec for a `{repositories: [{name, url}]}` config block,
|
||||||
|
shared by java/php/npm/rust/go/ruby/nuget."""
|
||||||
|
return {
|
||||||
|
"type": "dict",
|
||||||
|
"default": {},
|
||||||
|
"options": {
|
||||||
|
"repositories": {
|
||||||
|
"type": "list",
|
||||||
|
"elements": "dict",
|
||||||
|
"default": [],
|
||||||
|
"options": dict(_NAME_URL_OPTIONS),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ARGUMENT_SPEC = {
|
||||||
|
"version": {"type": "str", "default": "1.0"},
|
||||||
|
"uuid": {"type": "str"},
|
||||||
|
"organisation": {"type": "str"},
|
||||||
|
"description": {"type": "str"},
|
||||||
|
"certificates": {
|
||||||
|
"type": "list",
|
||||||
|
"elements": "dict",
|
||||||
|
"default": [],
|
||||||
|
"options": dict(_NAMED_CONTENT_OPTIONS),
|
||||||
|
},
|
||||||
|
"apt": {
|
||||||
|
"type": "dict",
|
||||||
|
"default": {},
|
||||||
|
"options": {
|
||||||
|
"sources": {
|
||||||
|
"type": "list",
|
||||||
|
"elements": "dict",
|
||||||
|
"default": [],
|
||||||
|
"options": {
|
||||||
|
"name": {"type": "str", "required": True},
|
||||||
|
"types": {"type": "list", "elements": "str", "default": ["deb"]},
|
||||||
|
"uris": {"type": "list", "elements": "str", "required": True},
|
||||||
|
"suites": {"type": "list", "elements": "str", "required": True},
|
||||||
|
"components": {"type": "list", "elements": "str", "default": []},
|
||||||
|
"architectures": {
|
||||||
|
"type": "list",
|
||||||
|
"elements": "str",
|
||||||
|
"default": [],
|
||||||
|
},
|
||||||
|
"languages": {"type": "list", "elements": "str", "default": []},
|
||||||
|
"targets": {"type": "list", "elements": "str", "default": []},
|
||||||
|
"enabled": {"type": "bool", "default": True},
|
||||||
|
"trusted": {"type": "bool"},
|
||||||
|
"signed_by": {"type": "str"},
|
||||||
|
"signed_by_file": {"type": "str"},
|
||||||
|
"pdiffs": {"type": "bool"},
|
||||||
|
"by_hash": {"type": "bool"},
|
||||||
|
"allow_insecure": {"type": "bool"},
|
||||||
|
"allow_weak": {"type": "bool"},
|
||||||
|
"allow_downgrade_to_insecure": {"type": "bool"},
|
||||||
|
"check_valid_until": {"type": "bool"},
|
||||||
|
"valid_until_min": {"type": "str"},
|
||||||
|
"valid_until_max": {"type": "str"},
|
||||||
|
"check_date": {"type": "bool"},
|
||||||
|
"date_max_future": {"type": "str"},
|
||||||
|
"description": {"type": "str"},
|
||||||
|
"uri_to_replace": {"type": "str"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"apk": {
|
||||||
|
"type": "dict",
|
||||||
|
"default": {},
|
||||||
|
"options": {
|
||||||
|
"repositories": {
|
||||||
|
"type": "list",
|
||||||
|
"elements": "dict",
|
||||||
|
"default": [],
|
||||||
|
"options": {
|
||||||
|
"url": {"type": "str", "required": True},
|
||||||
|
"key": {"type": "str"},
|
||||||
|
"url_to_replace": {"type": "str"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"yum": {
|
||||||
|
"type": "dict",
|
||||||
|
"default": {},
|
||||||
|
"options": {
|
||||||
|
"repositories": {
|
||||||
|
"type": "list",
|
||||||
|
"elements": "dict",
|
||||||
|
"default": [],
|
||||||
|
"options": {
|
||||||
|
"name": {"type": "str", "required": True},
|
||||||
|
"index_url": {"type": "str", "required": True},
|
||||||
|
"gpg_url": {"type": "str"},
|
||||||
|
"index_url_to_replace": {"type": "str"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"keys": {
|
||||||
|
"type": "list",
|
||||||
|
"elements": "dict",
|
||||||
|
"default": [],
|
||||||
|
"options": dict(_NAMED_CONTENT_OPTIONS),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"python": {
|
||||||
|
"type": "dict",
|
||||||
|
"default": {},
|
||||||
|
"options": {
|
||||||
|
"repositories": {
|
||||||
|
"type": "list",
|
||||||
|
"elements": "dict",
|
||||||
|
"default": [],
|
||||||
|
"options": {
|
||||||
|
"name": {"type": "str", "required": True},
|
||||||
|
"index_url": {"type": "str", "required": True},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"java": _name_url_repository_config(),
|
||||||
|
"php": _name_url_repository_config(),
|
||||||
|
"npm": _name_url_repository_config(),
|
||||||
|
"rust": _name_url_repository_config(),
|
||||||
|
"go": _name_url_repository_config(),
|
||||||
|
"ruby": _name_url_repository_config(),
|
||||||
|
"nuget": _name_url_repository_config(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import id_configurator.modules # noqa: F401 (registers the built-in modules)
|
||||||
|
from id_configurator.config import Configuration
|
||||||
|
from id_configurator.context import Context
|
||||||
|
from id_configurator.modules.base import all_modules
|
||||||
|
|
||||||
|
HAS_ID_CONFIGURATOR = True
|
||||||
|
ID_CONFIGURATOR_IMPORT_ERROR = None
|
||||||
|
except ImportError as exc:
|
||||||
|
HAS_ID_CONFIGURATOR = False
|
||||||
|
ID_CONFIGURATOR_IMPORT_ERROR = exc
|
||||||
|
|
||||||
|
|
||||||
|
def _build_configuration_data(params: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"version": params["version"],
|
||||||
|
"uuid": params["uuid"],
|
||||||
|
"organisation": params["organisation"],
|
||||||
|
"description": params["description"],
|
||||||
|
"configurations": {
|
||||||
|
field: params[field] for field in _CONFIGURATIONS_BLOCK_FIELDS
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_configuration(configuration, check_mode: bool) -> list[str]:
|
||||||
|
"""Apply `configuration` to this host's live root filesystem, returning
|
||||||
|
the names of every module that was (or, in check mode, would be)
|
||||||
|
applied. Uses id-configurator's own module registry and `Context`
|
||||||
|
unchanged - this is a thin caller, not a reimplementation."""
|
||||||
|
root = Path("/")
|
||||||
|
context = Context(config=configuration, dst=root, src=root)
|
||||||
|
applied_modules = []
|
||||||
|
for module_cls in all_modules():
|
||||||
|
config_module = module_cls()
|
||||||
|
if config_module.is_relevant(context):
|
||||||
|
applied_modules.append(config_module.name)
|
||||||
|
if not check_mode:
|
||||||
|
config_module.apply(context)
|
||||||
|
return applied_modules
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
module = AnsibleModule(argument_spec=ARGUMENT_SPEC, supports_check_mode=True)
|
||||||
|
|
||||||
|
if not HAS_ID_CONFIGURATOR:
|
||||||
|
module.fail_json(
|
||||||
|
msg=(
|
||||||
|
"id_configurator is not importable with this host's Python "
|
||||||
|
f"interpreter: {ID_CONFIGURATOR_IMPORT_ERROR}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
configuration = Configuration.model_validate(
|
||||||
|
_build_configuration_data(module.params)
|
||||||
|
)
|
||||||
|
except Exception as exc: # pydantic.ValidationError, kept generic on purpose
|
||||||
|
module.fail_json(msg=f"invalid id-configurator configuration: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
applied_modules = _apply_configuration(configuration, module.check_mode)
|
||||||
|
module.exit_json(changed=bool(applied_modules), applied_modules=applied_modules)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+3
-4
@@ -21,11 +21,9 @@ authors = [
|
|||||||
]
|
]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
'Development Status :: 5 - Production/Stable',
|
'Development Status :: 5 - Production/Stable',
|
||||||
'License :: Other/Proprietary License',
|
|
||||||
'Operating System :: POSIX :: Linux',
|
'Operating System :: POSIX :: Linux',
|
||||||
'Operating System :: Unix',
|
'Operating System :: Unix',
|
||||||
'Natural Language :: English',
|
'Natural Language :: English',
|
||||||
"Private :: Do Not Upload",
|
|
||||||
'Programming Language :: Python :: 3 :: Only',
|
'Programming Language :: Python :: 3 :: Only',
|
||||||
'Programming Language :: Python :: 3.13',
|
'Programming Language :: Python :: 3.13',
|
||||||
'Programming Language :: Python :: 3.14'
|
'Programming Language :: Python :: 3.14'
|
||||||
@@ -38,14 +36,15 @@ dependencies = [
|
|||||||
"typer>=0.27.1"
|
"typer>=0.27.1"
|
||||||
]
|
]
|
||||||
description = "Configure a device in an air-gapped environment."
|
description = "Configure a device in an air-gapped environment."
|
||||||
license = {text = "Proprietary"}
|
license = "CECILL-2.1"
|
||||||
|
license-files = ["LICENSE"]
|
||||||
maintainers = [
|
maintainers = [
|
||||||
{ name = "InterDiode", email = "contact@mail.interdiode.fr" }
|
{ name = "InterDiode", email = "contact@mail.interdiode.fr" }
|
||||||
]
|
]
|
||||||
name = "id-configurator"
|
name = "id-configurator"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
version = "0.1.0"
|
version = "2026.10.24"
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
id-configurator = "id_configurator:main"
|
id-configurator = "id_configurator:main"
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ configurations:
|
|||||||
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
|
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
|
||||||
apt:
|
apt:
|
||||||
sources: []
|
sources: []
|
||||||
keys: []
|
|
||||||
apk:
|
apk:
|
||||||
repositories:
|
repositories:
|
||||||
- url: https://host.docker.internal:{{ 'APK_REPO_PORT' | env }}/
|
- name: 'demo _.-/\$.:@;''"'
|
||||||
|
{% set scheme = 'APK_REPO_SCHEME' | env %}
|
||||||
|
url: {{ scheme }}://host.docker.internal:{{ ('APK_REPO_PORT' if scheme == 'https' else 'APK_REPO_HTTP_PORT') | env }}/
|
||||||
|
url_to_replace: https://dl-cdn.alpinelinux.org/*
|
||||||
key: |
|
key: |
|
||||||
{{ 'dockerfiles/repos/040d0af5f97a0a44.rsa.pub' | file | indent(8, first=true) }}
|
{{ 'dockerfiles/repos/040d0af5f97a0a44.rsa.pub' | file | indent(8, first=true) }}
|
||||||
yum:
|
yum:
|
||||||
@@ -27,3 +29,9 @@ configurations:
|
|||||||
repositories: []
|
repositories: []
|
||||||
rust:
|
rust:
|
||||||
repositories: []
|
repositories: []
|
||||||
|
go:
|
||||||
|
repositories: []
|
||||||
|
ruby:
|
||||||
|
repositories: []
|
||||||
|
nuget:
|
||||||
|
repositories: []
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ configurations:
|
|||||||
- name: demo
|
- name: demo
|
||||||
types: [deb]
|
types: [deb]
|
||||||
uris:
|
uris:
|
||||||
- https://host.docker.internal:{{ 'APT_REPO_PORT' | env }}/
|
{% set scheme = 'APT_REPO_SCHEME' | env %}
|
||||||
|
- {{ scheme }}://host.docker.internal:{{ ('APT_REPO_PORT' if scheme == 'https' else 'APT_REPO_HTTP_PORT') | env }}/
|
||||||
suites: ["./"]
|
suites: ["./"]
|
||||||
trusted: true
|
uri-to-replace: http://deb.debian.org/*
|
||||||
keys: []
|
signed-by: |
|
||||||
|
{{ 'dockerfiles/repos/apt-signing-key.pub.asc' | file | indent(8, first=true) }}
|
||||||
apk:
|
apk:
|
||||||
repositories: []
|
repositories: []
|
||||||
yum:
|
yum:
|
||||||
@@ -30,3 +32,9 @@ configurations:
|
|||||||
repositories: []
|
repositories: []
|
||||||
rust:
|
rust:
|
||||||
repositories: []
|
repositories: []
|
||||||
|
go:
|
||||||
|
repositories: []
|
||||||
|
ruby:
|
||||||
|
repositories: []
|
||||||
|
nuget:
|
||||||
|
repositories: []
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ configurations:
|
|||||||
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
|
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
|
||||||
apt:
|
apt:
|
||||||
sources: []
|
sources: []
|
||||||
keys: []
|
|
||||||
apk:
|
apk:
|
||||||
repositories: []
|
repositories: []
|
||||||
yum:
|
yum:
|
||||||
@@ -24,3 +23,9 @@ configurations:
|
|||||||
repositories: []
|
repositories: []
|
||||||
rust:
|
rust:
|
||||||
repositories: []
|
repositories: []
|
||||||
|
go:
|
||||||
|
repositories: []
|
||||||
|
ruby:
|
||||||
|
repositories: []
|
||||||
|
nuget:
|
||||||
|
repositories: []
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
uuid: 3f1a2b4c-5d6e-4f70-8a91-2b3c4d5e6f70
|
||||||
|
organisation: InterDiode
|
||||||
|
description: Go test configuration
|
||||||
|
configurations:
|
||||||
|
certificates:
|
||||||
|
- name: Test CA
|
||||||
|
content: |
|
||||||
|
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
|
||||||
|
apt:
|
||||||
|
sources: []
|
||||||
|
apk:
|
||||||
|
repositories: []
|
||||||
|
yum:
|
||||||
|
repositories: []
|
||||||
|
keys: []
|
||||||
|
python:
|
||||||
|
repositories: []
|
||||||
|
java:
|
||||||
|
repositories: []
|
||||||
|
php:
|
||||||
|
repositories: []
|
||||||
|
npm:
|
||||||
|
repositories: []
|
||||||
|
rust:
|
||||||
|
repositories: []
|
||||||
|
go:
|
||||||
|
repositories:
|
||||||
|
- name: 'demo _.-/\$.:@;''"'
|
||||||
|
{% set scheme = 'GO_REPO_SCHEME' | env %}
|
||||||
|
url: {{ scheme }}://host.docker.internal:{{ ('GO_REPO_PORT' if scheme == 'https' else 'GO_REPO_HTTP_PORT') | env }}/
|
||||||
|
ruby:
|
||||||
|
repositories: []
|
||||||
|
nuget:
|
||||||
|
repositories: []
|
||||||
@@ -8,7 +8,6 @@ configurations:
|
|||||||
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
|
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
|
||||||
apt:
|
apt:
|
||||||
sources: []
|
sources: []
|
||||||
keys: []
|
|
||||||
apk:
|
apk:
|
||||||
repositories: []
|
repositories: []
|
||||||
yum:
|
yum:
|
||||||
@@ -24,7 +23,8 @@ configurations:
|
|||||||
# during the Dockerfile's warm-up step. All other tools just see it as
|
# during the Dockerfile's warm-up step. All other tools just see it as
|
||||||
# an ordinary repository, and find the test artifact here.
|
# an ordinary repository, and find the test artifact here.
|
||||||
- name: central
|
- name: central
|
||||||
url: https://host.docker.internal:{{ 'JAVA_REPO_PORT' | env }}/
|
{% set scheme = 'JAVA_REPO_SCHEME' | env %}
|
||||||
|
url: {{ scheme }}://host.docker.internal:{{ ('JAVA_REPO_PORT' if scheme == 'https' else 'JAVA_REPO_HTTP_PORT') | env }}/
|
||||||
# Second entry: only SBT needs this. With
|
# Second entry: only SBT needs this. With
|
||||||
# -Dsbt.override.build.repos=true (the only way SBT actually consults
|
# -Dsbt.override.build.repos=true (the only way SBT actually consults
|
||||||
# this file for project dependencies, not just its own bootstrap), the
|
# this file for project dependencies, not just its own bootstrap), the
|
||||||
@@ -39,3 +39,9 @@ configurations:
|
|||||||
repositories: []
|
repositories: []
|
||||||
rust:
|
rust:
|
||||||
repositories: []
|
repositories: []
|
||||||
|
go:
|
||||||
|
repositories: []
|
||||||
|
ruby:
|
||||||
|
repositories: []
|
||||||
|
nuget:
|
||||||
|
repositories: []
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ configurations:
|
|||||||
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
|
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
|
||||||
apt:
|
apt:
|
||||||
sources: []
|
sources: []
|
||||||
keys: []
|
|
||||||
apk:
|
apk:
|
||||||
repositories: []
|
repositories: []
|
||||||
yum:
|
yum:
|
||||||
@@ -22,7 +21,14 @@ configurations:
|
|||||||
repositories: []
|
repositories: []
|
||||||
npm:
|
npm:
|
||||||
repositories:
|
repositories:
|
||||||
- name: demo
|
- name: 'demo _.-/\$.:@;''"'
|
||||||
url: https://host.docker.internal:{{ 'NPM_REPO_PORT' | env }}/
|
{% set scheme = 'NPM_REPO_SCHEME' | env %}
|
||||||
|
url: {{ scheme }}://host.docker.internal:{{ ('NPM_REPO_PORT' if scheme == 'https' else 'NPM_REPO_HTTP_PORT') | env }}/
|
||||||
rust:
|
rust:
|
||||||
repositories: []
|
repositories: []
|
||||||
|
go:
|
||||||
|
repositories: []
|
||||||
|
ruby:
|
||||||
|
repositories: []
|
||||||
|
nuget:
|
||||||
|
repositories: []
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
uuid: 5b3c4d6e-7f80-4192-8b13-4d5e6f708192
|
||||||
|
organisation: InterDiode
|
||||||
|
description: NuGet test configuration
|
||||||
|
configurations:
|
||||||
|
certificates:
|
||||||
|
- name: Test CA
|
||||||
|
content: |
|
||||||
|
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
|
||||||
|
apt:
|
||||||
|
sources: []
|
||||||
|
apk:
|
||||||
|
repositories: []
|
||||||
|
yum:
|
||||||
|
repositories: []
|
||||||
|
keys: []
|
||||||
|
python:
|
||||||
|
repositories: []
|
||||||
|
java:
|
||||||
|
repositories: []
|
||||||
|
php:
|
||||||
|
repositories: []
|
||||||
|
npm:
|
||||||
|
repositories: []
|
||||||
|
rust:
|
||||||
|
repositories: []
|
||||||
|
go:
|
||||||
|
repositories: []
|
||||||
|
ruby:
|
||||||
|
repositories: []
|
||||||
|
nuget:
|
||||||
|
repositories:
|
||||||
|
- name: 'demo _.-/\$.:@;''"'
|
||||||
|
{% set scheme = 'NUGET_REPO_SCHEME' | env %}
|
||||||
|
url: {{ scheme }}://host.docker.internal:{{ ('NUGET_REPO_PORT' if scheme == 'https' else 'NUGET_REPO_HTTP_PORT') | env }}/index.json
|
||||||
@@ -8,7 +8,6 @@ configurations:
|
|||||||
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
|
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
|
||||||
apt:
|
apt:
|
||||||
sources: []
|
sources: []
|
||||||
keys: []
|
|
||||||
apk:
|
apk:
|
||||||
repositories: []
|
repositories: []
|
||||||
yum:
|
yum:
|
||||||
@@ -20,9 +19,16 @@ configurations:
|
|||||||
repositories: []
|
repositories: []
|
||||||
php:
|
php:
|
||||||
repositories:
|
repositories:
|
||||||
- name: demo
|
- name: 'demo _.-/\$.:@;''"'
|
||||||
url: https://host.docker.internal:{{ 'PHP_REPO_PORT' | env }}/
|
{% set scheme = 'PHP_REPO_SCHEME' | env %}
|
||||||
|
url: {{ scheme }}://host.docker.internal:{{ ('PHP_REPO_PORT' if scheme == 'https' else 'PHP_REPO_HTTP_PORT') | env }}/
|
||||||
npm:
|
npm:
|
||||||
repositories: []
|
repositories: []
|
||||||
rust:
|
rust:
|
||||||
repositories: []
|
repositories: []
|
||||||
|
go:
|
||||||
|
repositories: []
|
||||||
|
ruby:
|
||||||
|
repositories: []
|
||||||
|
nuget:
|
||||||
|
repositories: []
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ configurations:
|
|||||||
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
|
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
|
||||||
apt:
|
apt:
|
||||||
sources: []
|
sources: []
|
||||||
keys: []
|
|
||||||
apk:
|
apk:
|
||||||
repositories: []
|
repositories: []
|
||||||
yum:
|
yum:
|
||||||
@@ -16,8 +15,9 @@ configurations:
|
|||||||
keys: []
|
keys: []
|
||||||
python:
|
python:
|
||||||
repositories:
|
repositories:
|
||||||
- name: demo
|
- name: 'demo _.-/\$.:@;''"'
|
||||||
index-url: https://host.docker.internal:{{ 'PYPI_REPO_PORT' | env }}/simple/
|
{% set scheme = 'PYPI_REPO_SCHEME' | env %}
|
||||||
|
index-url: {{ scheme }}://host.docker.internal:{{ ('PYPI_REPO_PORT' if scheme == 'https' else 'PYPI_REPO_HTTP_PORT') | env }}/simple/
|
||||||
java:
|
java:
|
||||||
repositories: []
|
repositories: []
|
||||||
php:
|
php:
|
||||||
@@ -26,3 +26,9 @@ configurations:
|
|||||||
repositories: []
|
repositories: []
|
||||||
rust:
|
rust:
|
||||||
repositories: []
|
repositories: []
|
||||||
|
go:
|
||||||
|
repositories: []
|
||||||
|
ruby:
|
||||||
|
repositories: []
|
||||||
|
nuget:
|
||||||
|
repositories: []
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
uuid: 4a2b3c5d-6e7f-4081-9a02-3c4d5e6f7081
|
||||||
|
organisation: InterDiode
|
||||||
|
description: Ruby test configuration
|
||||||
|
configurations:
|
||||||
|
certificates:
|
||||||
|
- name: Test CA
|
||||||
|
content: |
|
||||||
|
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
|
||||||
|
apt:
|
||||||
|
sources: []
|
||||||
|
apk:
|
||||||
|
repositories: []
|
||||||
|
yum:
|
||||||
|
repositories: []
|
||||||
|
keys: []
|
||||||
|
python:
|
||||||
|
repositories: []
|
||||||
|
java:
|
||||||
|
repositories: []
|
||||||
|
php:
|
||||||
|
repositories: []
|
||||||
|
npm:
|
||||||
|
repositories: []
|
||||||
|
rust:
|
||||||
|
repositories: []
|
||||||
|
go:
|
||||||
|
repositories: []
|
||||||
|
ruby:
|
||||||
|
repositories:
|
||||||
|
- name: 'demo _.-/\$.:@;''"'
|
||||||
|
{% set scheme = 'RUBY_REPO_SCHEME' | env %}
|
||||||
|
url: {{ scheme }}://host.docker.internal:{{ ('RUBY_REPO_PORT' if scheme == 'https' else 'RUBY_REPO_HTTP_PORT') | env }}/
|
||||||
|
nuget:
|
||||||
|
repositories: []
|
||||||
@@ -8,7 +8,6 @@ configurations:
|
|||||||
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
|
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
|
||||||
apt:
|
apt:
|
||||||
sources: []
|
sources: []
|
||||||
keys: []
|
|
||||||
apk:
|
apk:
|
||||||
repositories: []
|
repositories: []
|
||||||
yum:
|
yum:
|
||||||
@@ -24,5 +23,13 @@ configurations:
|
|||||||
repositories: []
|
repositories: []
|
||||||
rust:
|
rust:
|
||||||
repositories:
|
repositories:
|
||||||
- name: demo
|
- name: 'demo _.-/\$.:@;''"'
|
||||||
url: https://host.docker.internal:{{ 'CARGO_REPO_PORT' | env }}/
|
{% set scheme = 'CARGO_REPO_SCHEME' | env %}
|
||||||
|
url: {{ scheme }}://host.docker.internal:{{ ('CARGO_REPO_PORT' if scheme == 'https' else 'CARGO_REPO_HTTP_PORT') | env }}/
|
||||||
|
is_sparse: true
|
||||||
|
go:
|
||||||
|
repositories: []
|
||||||
|
ruby:
|
||||||
|
repositories: []
|
||||||
|
nuget:
|
||||||
|
repositories: []
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ configurations:
|
|||||||
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
|
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
|
||||||
apt:
|
apt:
|
||||||
sources: []
|
sources: []
|
||||||
keys: []
|
|
||||||
apk:
|
apk:
|
||||||
repositories: []
|
repositories: []
|
||||||
yum:
|
yum:
|
||||||
repositories:
|
repositories:
|
||||||
- name: demo
|
- name: 'demo _.-/\$.:@;''"'
|
||||||
index-url: https://host.docker.internal:{{ 'YUM_REPO_PORT' | env }}/
|
{% set scheme = 'YUM_REPO_SCHEME' | env %}
|
||||||
|
index-url: {{ scheme }}://host.docker.internal:{{ ('YUM_REPO_PORT' if scheme == 'https' else 'YUM_REPO_HTTP_PORT') | env }}/
|
||||||
|
index-url-to-replace: https://mirrors.rockylinux.org/*
|
||||||
keys: []
|
keys: []
|
||||||
python:
|
python:
|
||||||
repositories: []
|
repositories: []
|
||||||
@@ -26,3 +27,9 @@ configurations:
|
|||||||
repositories: []
|
repositories: []
|
||||||
rust:
|
rust:
|
||||||
repositories: []
|
repositories: []
|
||||||
|
go:
|
||||||
|
repositories: []
|
||||||
|
ruby:
|
||||||
|
repositories: []
|
||||||
|
nuget:
|
||||||
|
repositories: []
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
FROM debian:trixie-slim AS target
|
||||||
|
FROM interdiode/id-configurator:latest AS configuration
|
||||||
|
ARG CONFIG_REPO_PORT
|
||||||
|
ARG CONFIG_URL_CACERT_CONTENT
|
||||||
|
COPY --from=target / /src/
|
||||||
|
RUN id-configurator apply \
|
||||||
|
--config-url "https://host.docker.internal:${CONFIG_REPO_PORT}/config.yaml" \
|
||||||
|
--config-url-cacert-content "$CONFIG_URL_CACERT_CONTENT" \
|
||||||
|
--src /src --dst /dst
|
||||||
|
FROM target AS runtime
|
||||||
|
ARG CONFIG_REPO_PORT
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl
|
||||||
|
COPY --from=configuration /dst/ /
|
||||||
|
# Proves the root CA the downloaded configuration installed is actually
|
||||||
|
# trusted system-wide: no --insecure/--no-check-certificate here, unlike the
|
||||||
|
# --config-url-cacert-content fetch above (which trusts only that one CA to
|
||||||
|
# validate the download itself, before anything has been installed yet).
|
||||||
|
RUN curl -fsS "https://host.docker.internal:${CONFIG_REPO_PORT}/config.yaml" \
|
||||||
|
| grep -q 'organisation: InterDiode'
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
FROM golang:1-bookworm AS target
|
||||||
|
FROM interdiode/id-configurator:latest AS configuration
|
||||||
|
COPY --from=target / /src/
|
||||||
|
COPY --from=generated-config Go.yaml /config.yaml
|
||||||
|
RUN id-configurator apply --config /config.yaml --src /src --dst /dst
|
||||||
|
FROM target AS runtime
|
||||||
|
COPY --from=configuration /dst/ /
|
||||||
|
RUN mkdir -p /tmp/gotest && cd /tmp/gotest && go mod init gotest \
|
||||||
|
&& go get example.invalid/idconfiguratortestpkg@v0.0.1 \
|
||||||
|
&& grep -qx 'test-only module served by the id-configurator integration tests' \
|
||||||
|
"$(find "$(go env GOMODCACHE)" -name marker.txt)"
|
||||||
@@ -4,6 +4,7 @@ COPY --from=target / /src/
|
|||||||
COPY --from=generated-config Java.yaml /config.yaml
|
COPY --from=generated-config Java.yaml /config.yaml
|
||||||
RUN id-configurator apply --config /config.yaml --src /src --dst /dst
|
RUN id-configurator apply --config /config.yaml --src /src --dst /dst
|
||||||
FROM target AS runtime
|
FROM target AS runtime
|
||||||
|
ARG JAVA_REPO_SCHEME
|
||||||
# Every build tool the `java` module configures: Maven itself (already part
|
# Every build tool the `java` module configures: Maven itself (already part
|
||||||
# of this image), Gradle, Ivy and Leiningen (all packaged for this Ubuntu
|
# of this image), Gradle, Ivy and Leiningen (all packaged for this Ubuntu
|
||||||
# base), and SBT (no package available, fetched as a plain release tarball).
|
# base), and SBT (no package available, fetched as a plain release tarball).
|
||||||
@@ -43,12 +44,23 @@ RUN java -jar /usr/share/java/ivy.jar -settings /root/.ivy2/ivysettings.xml \
|
|||||||
-retrieve "/tmp/ivyretrieve/[artifact]-[revision].[ext]" \
|
-retrieve "/tmp/ivyretrieve/[artifact]-[revision].[ext]" \
|
||||||
&& test -f /tmp/ivyretrieve/idconfiguratortestpkg-0.0.1.jar
|
&& test -f /tmp/ivyretrieve/idconfiguratortestpkg-0.0.1.jar
|
||||||
|
|
||||||
|
# Leiningen's standalone launcher unconditionally refuses any plain-HTTP
|
||||||
|
# repository ("Tried to use insecure HTTP repository without TLS") before
|
||||||
|
# even reading project.clj/profiles.clj - there is no `:insecure` key or
|
||||||
|
# other configuration escape hatch for this (confirmed from Leiningen's own
|
||||||
|
# source: leiningen.core.main/-main registers an aborting wagon for the
|
||||||
|
# "http" scheme unconditionally). So this check is skipped for the
|
||||||
|
# module-java-http variant: no id-configurator config could make it pass.
|
||||||
RUN mkdir -p /tmp/leintest && cd /tmp/leintest \
|
RUN mkdir -p /tmp/leintest && cd /tmp/leintest \
|
||||||
&& cat > project.clj <<'EOF'
|
&& cat > project.clj <<'EOF'
|
||||||
(defproject leintest "0.1.0"
|
(defproject leintest "0.1.0"
|
||||||
:dependencies [[org.interdiode/idconfiguratortestpkg "0.0.1"]])
|
:dependencies [[org.interdiode/idconfiguratortestpkg "0.0.1"]])
|
||||||
EOF
|
EOF
|
||||||
RUN cd /tmp/leintest && lein deps :tree | grep -q idconfiguratortestpkg
|
RUN if [ "$JAVA_REPO_SCHEME" = "https" ]; then \
|
||||||
|
cd /tmp/leintest && lein deps :tree | grep -q idconfiguratortestpkg; \
|
||||||
|
else \
|
||||||
|
echo "skipping leiningen: it cannot use a non-HTTPS repository"; \
|
||||||
|
fi
|
||||||
|
|
||||||
RUN mkdir -p /tmp/sbttest/project \
|
RUN mkdir -p /tmp/sbttest/project \
|
||||||
&& cat > /tmp/sbttest/build.sbt <<'EOF'
|
&& cat > /tmp/sbttest/build.sbt <<'EOF'
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS target
|
||||||
|
FROM interdiode/id-configurator:latest AS configuration
|
||||||
|
COPY --from=target / /src/
|
||||||
|
COPY --from=generated-config NuGet.yaml /config.yaml
|
||||||
|
RUN id-configurator apply --config /config.yaml --src /src --dst /dst
|
||||||
|
FROM target AS runtime
|
||||||
|
COPY --from=configuration /dst/ /
|
||||||
|
RUN mkdir -p /tmp/nugettest && cd /tmp/nugettest && dotnet new console -o . --no-restore >/dev/null \
|
||||||
|
&& dotnet add package idconfiguratortestpkg --version 0.0.1 \
|
||||||
|
&& grep -qx 'test-only package served by the id-configurator integration tests' \
|
||||||
|
"$(find /root/.nuget/packages/idconfiguratortestpkg -name marker.txt)"
|
||||||
@@ -5,6 +5,7 @@ COPY --from=generated-config Python.yaml /config.yaml
|
|||||||
RUN id-configurator apply --config /config.yaml --src /src --dst /dst
|
RUN id-configurator apply --config /config.yaml --src /src --dst /dst
|
||||||
FROM target AS runtime
|
FROM target AS runtime
|
||||||
ARG PYPI_REPO_PORT
|
ARG PYPI_REPO_PORT
|
||||||
|
ARG PYPI_REPO_SCHEME
|
||||||
# Warm up uv and poetry themselves from the real, network-reachable PyPI
|
# Warm up uv and poetry themselves from the real, network-reachable PyPI
|
||||||
# while the default index is still in place, so the actual tests below
|
# while the default index is still in place, so the actual tests below
|
||||||
# never need anything but the fixture repository configured by
|
# never need anything but the fixture repository configured by
|
||||||
@@ -20,5 +21,5 @@ RUN uv pip install --system idconfiguratortestpkg \
|
|||||||
&& python3 -c "import idconfiguratortestpkg"
|
&& python3 -c "import idconfiguratortestpkg"
|
||||||
RUN mkdir -p /tmp/poetrytest && cd /tmp/poetrytest \
|
RUN mkdir -p /tmp/poetrytest && cd /tmp/poetrytest \
|
||||||
&& poetry init -n --name poetrytest \
|
&& poetry init -n --name poetrytest \
|
||||||
&& poetry source add --priority explicit demo "https://host.docker.internal:${PYPI_REPO_PORT}/simple/" \
|
&& poetry source add --priority explicit demo "${PYPI_REPO_SCHEME}://host.docker.internal:${PYPI_REPO_PORT}/simple/" \
|
||||||
&& poetry add idconfiguratortestpkg --source demo
|
&& poetry add idconfiguratortestpkg --source demo
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ COPY --from=generated-config Python.yaml /config.yaml
|
|||||||
RUN id-configurator apply --config /config.yaml --src /src --dst /dst
|
RUN id-configurator apply --config /config.yaml --src /src --dst /dst
|
||||||
FROM target AS runtime
|
FROM target AS runtime
|
||||||
ARG PYPI_REPO_PORT
|
ARG PYPI_REPO_PORT
|
||||||
|
ARG PYPI_REPO_SCHEME
|
||||||
# Warm up uv and poetry themselves from the real, network-reachable PyPI
|
# Warm up uv and poetry themselves from the real, network-reachable PyPI
|
||||||
# while the default index is still in place, so the actual tests below
|
# while the default index is still in place, so the actual tests below
|
||||||
# never need anything but the fixture repository configured by
|
# never need anything but the fixture repository configured by
|
||||||
@@ -20,5 +21,5 @@ RUN uv pip install --system idconfiguratortestpkg \
|
|||||||
&& python3 -c "import idconfiguratortestpkg"
|
&& python3 -c "import idconfiguratortestpkg"
|
||||||
RUN mkdir -p /tmp/poetrytest && cd /tmp/poetrytest \
|
RUN mkdir -p /tmp/poetrytest && cd /tmp/poetrytest \
|
||||||
&& poetry init -n --name poetrytest \
|
&& poetry init -n --name poetrytest \
|
||||||
&& poetry source add --priority explicit demo "https://host.docker.internal:${PYPI_REPO_PORT}/simple/" \
|
&& poetry source add --priority explicit demo "${PYPI_REPO_SCHEME}://host.docker.internal:${PYPI_REPO_PORT}/simple/" \
|
||||||
&& poetry add idconfiguratortestpkg --source demo
|
&& poetry add idconfiguratortestpkg --source demo
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
FROM ruby:3-slim AS target
|
||||||
|
FROM interdiode/id-configurator:latest AS configuration
|
||||||
|
COPY --from=target / /src/
|
||||||
|
COPY --from=generated-config Ruby.yaml /config.yaml
|
||||||
|
RUN id-configurator apply --config /config.yaml --src /src --dst /dst
|
||||||
|
FROM target AS runtime
|
||||||
|
COPY --from=configuration /dst/ /
|
||||||
|
RUN gem install idconfiguratortestpkg --no-document \
|
||||||
|
&& grep -qx 'test-only gem served by the id-configurator integration tests' \
|
||||||
|
"$(find "$(gem environment gemdir)" -name marker.txt)"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<PackageId>idconfiguratortestpkg</PackageId>
|
||||||
|
<Version>0.0.1</Version>
|
||||||
|
<Authors>InterDiode</Authors>
|
||||||
|
<Description>test-only package served by the id-configurator integration tests</Description>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="marker.txt" Pack="true" PackagePath="content/marker.txt" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -8,3 +8,13 @@ server {
|
|||||||
root /srv;
|
root /srv;
|
||||||
autoindex off;
|
autoindex off;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Plain-HTTP listener, for the module-*-http tox envs that verify id-configurator
|
||||||
|
# correctly configures a non-HTTPS repository.
|
||||||
|
server {
|
||||||
|
listen 8080 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /srv;
|
||||||
|
autoindex off;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
# Minimal flat-format APT repository serving one hand-built .deb package over
|
# Minimal flat-format APT repository serving one hand-built .deb package over
|
||||||
# TLS, used by tox-docker to exercise the `apt` module integration test
|
# TLS, used by tox-docker to exercise the `apt` module integration test
|
||||||
# (tests/configs/Apt.yaml.tmpl, tests/dockerfiles/Apt.Dockerfile).
|
# (tests/configs/Apt.yaml.j2, tests/dockerfiles/Apt.Dockerfile). Its
|
||||||
|
# InRelease file is genuinely signed with the dedicated key pair
|
||||||
|
# apt-signing-key.key.asc / apt-signing-key.pub.asc, whose public half is
|
||||||
|
# what tests/configs/Apt.yaml.j2 embeds as the configured source's
|
||||||
|
# `signed-by`: the `apt` module dearmors it into a keyring, and APT itself
|
||||||
|
# verifies the InRelease signature against that keyring on `apt-get update`
|
||||||
|
# - no `Trusted: yes` bypass involved.
|
||||||
#
|
#
|
||||||
# Built by tox-docker, whose build context is this directory (not the repo
|
# Built by tox-docker, whose build context is this directory (not the repo
|
||||||
# root) - every COPY source below must live under tests/dockerfiles/repos/.
|
# root) - every COPY source below must live under tests/dockerfiles/repos/.
|
||||||
FROM debian:trixie-slim AS packagebuild
|
FROM debian:trixie-slim AS packagebuild
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends dpkg-dev \
|
&& apt-get install -y --no-install-recommends dpkg-dev apt-utils gnupg \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
pkg=idconfiguratortestpkg; \
|
pkg=idconfiguratortestpkg; \
|
||||||
@@ -18,6 +24,13 @@ RUN set -eux; \
|
|||||||
WORKDIR /srv
|
WORKDIR /srv
|
||||||
RUN dpkg-scanpackages --multiversion . > Packages \
|
RUN dpkg-scanpackages --multiversion . > Packages \
|
||||||
&& gzip -9c Packages > Packages.gz
|
&& gzip -9c Packages > Packages.gz
|
||||||
|
COPY apt-signing-key.key.asc /tmp/apt-signing-key.key.asc
|
||||||
|
RUN gnupg_home="$(mktemp -d)" \
|
||||||
|
&& gpg --homedir "$gnupg_home" --batch --import /tmp/apt-signing-key.key.asc \
|
||||||
|
&& key_id="$(gpg --homedir "$gnupg_home" --list-secret-keys --with-colons | awk -F: '/^sec/{print $5; exit}')" \
|
||||||
|
&& apt-ftparchive release . > Release \
|
||||||
|
&& gpg --homedir "$gnupg_home" --batch --yes --pinentry-mode loopback --default-key "$key_id" --clearsign -o InRelease Release \
|
||||||
|
&& rm -rf "$gnupg_home" /tmp/apt-signing-key.key.asc
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
COPY --from=packagebuild /srv /srv
|
COPY --from=packagebuild /srv /srv
|
||||||
|
|||||||
@@ -8,3 +8,13 @@ server {
|
|||||||
root /srv;
|
root /srv;
|
||||||
autoindex off;
|
autoindex off;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Plain-HTTP listener, for the module-*-http tox envs that verify id-configurator
|
||||||
|
# correctly configures a non-HTTPS repository.
|
||||||
|
server {
|
||||||
|
listen 8080 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /srv;
|
||||||
|
autoindex off;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-----BEGIN PGP PRIVATE KEY BLOCK-----
|
||||||
|
|
||||||
|
lQOYBGqg6rMBCADaMUd27S41+kInPXuRZ0aL41aMZjqr+mw4W34w8i/JRYMJ6eGb
|
||||||
|
sXf40AaFfS7zkbUpQZpmh3aYPF4jUAdWV03ZNNYdcGerIAKc2xsugSKjJIm1XPQq
|
||||||
|
upz914b4/hp/UslDUH7SPZ+3Tq17TXW+xP6aUM9VcWMnlJT/DFgxh5sZB3nfDeYr
|
||||||
|
yBV+PmEVfuQaUSIwKAh3Hv0ZaBHS8KzsawLzbJ+Pa9W9wjVME1izi9ctloGSxtLc
|
||||||
|
p1suS2IS33QB0jGgj4VbuMcjo2iCpzW3Xd7jQ7WpXGjrl0bHnJTnWWB0ihDD3cJe
|
||||||
|
tJlk9GScMQu3Z3th67DmqTXQru3btrDjxMifABEBAAEAB/sG3JffwJO8dNNRwpg5
|
||||||
|
OWM5kPuuzKQ5tlVi7FWqmmMwVDRLwoQqzkj0lS4vUoGAfWuSEZMWeFodbYCUYbbp
|
||||||
|
WzOCiivVnyxTcF1Eq4tOShdZxBNklbjEAzxeq0F+YRdaO7NCn6xtVizIqfvAre3v
|
||||||
|
Aujy12m9ySQSIZUR9ERtwlL5VXj4yzcXciyM9aNbh2D4BFAoo+fBL1B5mhpKQ16B
|
||||||
|
BafkpOf0myWZrwLdcn1t7BQ6UcLqsPJj6COCXXRDHoZvim38UBMMajNP4+8eC2Vk
|
||||||
|
DDR6iFz53FRvIREQWkoEEW4P80v1lgcnP1apJGhkrJYCPM7ObZk0MoQkBiBdlPPQ
|
||||||
|
VLiRBADhNsKjNd7v3Af5XGPJGWWZxCMxPlgwc6EMjCX3SQDql042eSIqwn1FFP6H
|
||||||
|
Hi8lyq/XWz0JcUwcrpFxpIC3OLUsBleX3Z4E17Z6AotSV0FC6y23cqgtweMeLA0L
|
||||||
|
tayMRmXJ7Aan+xfvi8MbW6lYGzM2uYHl27rVBuCt0kvxn+U4lwQA+ATO8KaBreCL
|
||||||
|
E/vXGZaPWJs2GoiDBavKwqdYtlXDKlVP5XvBgaPfY6zKLtCsC6N+DKkVo42Stwu3
|
||||||
|
oB6HSSLvXvFIhvUReh5PLpijmOroAJzvLQN9qmZBvF4IOUgaHmlYlL0DGzlxcwLr
|
||||||
|
SdzqPaGRa+EgraFDDGGu5oQ5A2wNKTkEAMDS2w5SibOfsvnfK4aDc0mi8/fftzCO
|
||||||
|
SwsvVzR0SmbXtaCPBnBjlA7YJhRZ6n/7nDpswrWPk8g125A0vK1cNRGM0ufKYW1f
|
||||||
|
DMbB/aYrJjAKz8zWH6cgOD4fqM2gA9JounkIcJvWOToGume8pXhmem94u2FP8aik
|
||||||
|
A6hL/Os+WRQcMNi0NWlkLWNvbmZpZ3VyYXRvci10ZXN0cyA8dGVzdHNAaWQtY29u
|
||||||
|
ZmlndXJhdG9yLmludmFsaWQ+iQFuBBMBCABYFiEEaZF4ALsB1xUU3IP5rl3Himib
|
||||||
|
1RgFAmqg6rMbFIAAAAAABAAObWFudTIsMi41KzEuMTIsMCwzAxsvBAULCQgHAgIi
|
||||||
|
AgYVCgkICwIEFgIDAQIeBwIXgAAKCRCuXceKaJvVGFRrB/wIbG1HHQANDCHgyl4T
|
||||||
|
RTPuKBX2c9lmJi2plp+T5L5sXyTMbAMLfTCkJWL/zHLM3N2v6GZuzbwZM8Dzj9zL
|
||||||
|
LMpC6newmZe4YX+ZtyZGufBI963kh8PvlSWWUSyH+oYoIhlyJqj7tCQXoijEvQnS
|
||||||
|
ReKGHUTvQGvQK2Ybp735NmAiDOM3/3KKh2Dqas/Tc2mxec4whr8RyUidsF+Yp6wT
|
||||||
|
esfMxeax6DmWIcSLDWpUBndeahZ2Ff0c96BzbpF1v2hbvyI/IaP5snb4Nm1xpREm
|
||||||
|
+y8iDAQhOklfXCyXrWgN3sIrk3tDndGrn+nw0iusnaBVki0keqjlH0camoUP28tu
|
||||||
|
3sxG
|
||||||
|
=TrE4
|
||||||
|
-----END PGP PRIVATE KEY BLOCK-----
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
|
|
||||||
|
mQENBGqg6rMBCADaMUd27S41+kInPXuRZ0aL41aMZjqr+mw4W34w8i/JRYMJ6eGb
|
||||||
|
sXf40AaFfS7zkbUpQZpmh3aYPF4jUAdWV03ZNNYdcGerIAKc2xsugSKjJIm1XPQq
|
||||||
|
upz914b4/hp/UslDUH7SPZ+3Tq17TXW+xP6aUM9VcWMnlJT/DFgxh5sZB3nfDeYr
|
||||||
|
yBV+PmEVfuQaUSIwKAh3Hv0ZaBHS8KzsawLzbJ+Pa9W9wjVME1izi9ctloGSxtLc
|
||||||
|
p1suS2IS33QB0jGgj4VbuMcjo2iCpzW3Xd7jQ7WpXGjrl0bHnJTnWWB0ihDD3cJe
|
||||||
|
tJlk9GScMQu3Z3th67DmqTXQru3btrDjxMifABEBAAG0NWlkLWNvbmZpZ3VyYXRv
|
||||||
|
ci10ZXN0cyA8dGVzdHNAaWQtY29uZmlndXJhdG9yLmludmFsaWQ+iQFuBBMBCABY
|
||||||
|
FiEEaZF4ALsB1xUU3IP5rl3Himib1RgFAmqg6rMbFIAAAAAABAAObWFudTIsMi41
|
||||||
|
KzEuMTIsMCwzAxsvBAULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRCuXceK
|
||||||
|
aJvVGFRrB/wIbG1HHQANDCHgyl4TRTPuKBX2c9lmJi2plp+T5L5sXyTMbAMLfTCk
|
||||||
|
JWL/zHLM3N2v6GZuzbwZM8Dzj9zLLMpC6newmZe4YX+ZtyZGufBI963kh8PvlSWW
|
||||||
|
USyH+oYoIhlyJqj7tCQXoijEvQnSReKGHUTvQGvQK2Ybp735NmAiDOM3/3KKh2Dq
|
||||||
|
as/Tc2mxec4whr8RyUidsF+Yp6wTesfMxeax6DmWIcSLDWpUBndeahZ2Ff0c96Bz
|
||||||
|
bpF1v2hbvyI/IaP5snb4Nm1xpREm+y8iDAQhOklfXCyXrWgN3sIrk3tDndGrn+nw
|
||||||
|
0iusnaBVki0keqjlH0camoUP28tu3sxG
|
||||||
|
=qZrp
|
||||||
|
-----END PGP PUBLIC KEY BLOCK-----
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Build a minimal, hand-rolled RubyGems package and a full gem repository
|
||||||
|
# index for it (legacy + compact index formats), for integration-testing
|
||||||
|
# the `ruby` module of id-configurator. Uses RubyGems' own `gem build` and
|
||||||
|
# `gem generate_index` (the same tools a real gem server host would use)
|
||||||
|
# rather than hand-rolling the compact index's binary/marshal formats.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
PACKAGE=idconfiguratortestpkg
|
||||||
|
VERSION=0.0.1
|
||||||
|
MARKER="test-only gem served by the id-configurator integration tests"
|
||||||
|
|
||||||
|
gem install rubygems-generate_index --no-document
|
||||||
|
|
||||||
|
mkdir -p "/build/${PACKAGE}-${VERSION}"
|
||||||
|
cd "/build/${PACKAGE}-${VERSION}"
|
||||||
|
cat > "${PACKAGE}.gemspec" <<EOF
|
||||||
|
Gem::Specification.new do |s|
|
||||||
|
s.name = "${PACKAGE}"
|
||||||
|
s.version = "${VERSION}"
|
||||||
|
s.summary = "test-only gem"
|
||||||
|
s.authors = ["InterDiode"]
|
||||||
|
s.license = "MIT"
|
||||||
|
s.files = ["marker.txt"]
|
||||||
|
end
|
||||||
|
EOF
|
||||||
|
echo "${MARKER}" > marker.txt
|
||||||
|
gem build "${PACKAGE}.gemspec"
|
||||||
|
|
||||||
|
mkdir -p /srv/gems
|
||||||
|
cp ./*.gem /srv/gems/
|
||||||
|
gem generate_index -d /srv
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Build a minimal, offline, hand-rolled Go module proxy tree, for
|
||||||
|
integration-testing the `go` module of id-configurator.
|
||||||
|
|
||||||
|
No network access and no Go toolchain is used: the module proxy protocol
|
||||||
|
(https://go.dev/ref/mod#module-proxy) only requires a handful of static
|
||||||
|
files, none of which embed the server's own base URL (unlike Composer/
|
||||||
|
npm/cargo), so this can be served directly by nginx with no rewriting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
MODULE = "example.invalid/idconfiguratortestpkg"
|
||||||
|
VERSION = "v0.0.1"
|
||||||
|
OUT_DIR = pathlib.Path("/srv")
|
||||||
|
MARKER = "test-only module served by the id-configurator integration tests\n"
|
||||||
|
|
||||||
|
GO_MOD = f"module {MODULE}\n\ngo 1.21\n"
|
||||||
|
|
||||||
|
|
||||||
|
def build_zip() -> bytes:
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
prefix = f"{MODULE}@{VERSION}"
|
||||||
|
with zipfile.ZipFile(buffer, "w") as archive:
|
||||||
|
archive.writestr(f"{prefix}/go.mod", GO_MOD)
|
||||||
|
archive.writestr(f"{prefix}/marker.txt", MARKER)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
module_dir = OUT_DIR / MODULE / "@v"
|
||||||
|
module_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(module_dir / "list").write_text(f"{VERSION}\n", encoding="utf-8")
|
||||||
|
(module_dir / f"{VERSION}.info").write_text(
|
||||||
|
json.dumps({"Version": VERSION, "Time": "2024-01-01T00:00:00Z"}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(module_dir / f"{VERSION}.mod").write_text(GO_MOD, encoding="utf-8")
|
||||||
|
(module_dir / f"{VERSION}.zip").write_bytes(build_zip())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Build a minimal NuGet v3 static feed around one real, `dotnet pack`-built
|
||||||
|
.nupkg, for integration-testing the `nuget` module of id-configurator.
|
||||||
|
|
||||||
|
Only two resources are populated: the flat container (`PackageBaseAddress`,
|
||||||
|
used to enumerate versions and download the .nupkg directly) and the
|
||||||
|
top-level service index pointing at it. `dotnet add package`/`dotnet
|
||||||
|
restore` with an explicit, dependency-free package version only ever calls
|
||||||
|
these two - never the (considerably more complex) registration resource -
|
||||||
|
so it is intentionally not implemented here.
|
||||||
|
|
||||||
|
The service index bakes in a __BASE_URL__ placeholder for its "@id" fields,
|
||||||
|
since the host:port this fixture is actually reached on is only known once
|
||||||
|
tox-docker starts the container, not at image-build time (see
|
||||||
|
nuget-repo.nginx.conf). The flat container's own files never embed a URL,
|
||||||
|
so they need no such rewriting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
PACKAGE_ID = "idconfiguratortestpkg"
|
||||||
|
VERSION = "0.0.1"
|
||||||
|
NUPKG_NAME = f"{PACKAGE_ID}.{VERSION}.nupkg"
|
||||||
|
NUPKG_SRC = pathlib.Path("/nupkg") / NUPKG_NAME
|
||||||
|
OUT_DIR = pathlib.Path("/srv")
|
||||||
|
|
||||||
|
|
||||||
|
def write_flat_container() -> None:
|
||||||
|
package_dir = OUT_DIR / "flatcontainer" / PACKAGE_ID
|
||||||
|
version_dir = package_dir / VERSION
|
||||||
|
version_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(package_dir / "index.json").write_text(
|
||||||
|
json.dumps({"versions": [VERSION]}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
shutil.copy(NUPKG_SRC, version_dir / NUPKG_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
def write_service_index() -> None:
|
||||||
|
index = {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"@id": "__BASE_URL__/flatcontainer/",
|
||||||
|
"@type": "PackageBaseAddress/3.0.0",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
(OUT_DIR / "index.json").write_text(json.dumps(index), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
write_flat_container()
|
||||||
|
write_service_index()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -12,10 +12,28 @@ server {
|
|||||||
# templates, since the host:port this fixture is actually reached on is
|
# templates, since the host:port this fixture is actually reached on is
|
||||||
# only known once tox-docker starts the container, not at image-build
|
# only known once tox-docker starts the container, not at image-build
|
||||||
# time. $http_host reflects exactly what the client (cargo) used to
|
# time. $http_host reflects exactly what the client (cargo) used to
|
||||||
# connect, e.g. host.docker.internal:55123.
|
# connect, e.g. host.docker.internal:55123; $scheme likewise reflects
|
||||||
|
# whichever of this file's two listeners actually served the request.
|
||||||
location = /config.json {
|
location = /config.json {
|
||||||
default_type application/json;
|
default_type application/json;
|
||||||
sub_filter '__BASE_URL__' 'https://$http_host';
|
sub_filter '__BASE_URL__' '$scheme://$http_host';
|
||||||
|
sub_filter_once off;
|
||||||
|
sub_filter_types application/json;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Plain-HTTP listener, for the module-rust-http tox env that verifies
|
||||||
|
# id-configurator correctly configures a non-HTTPS repository.
|
||||||
|
server {
|
||||||
|
listen 8080 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /srv;
|
||||||
|
autoindex off;
|
||||||
|
|
||||||
|
location = /config.json {
|
||||||
|
default_type application/json;
|
||||||
|
sub_filter '__BASE_URL__' '$scheme://$http_host';
|
||||||
sub_filter_once off;
|
sub_filter_once off;
|
||||||
sub_filter_types application/json;
|
sub_filter_types application/json;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Static id-configurator configuration served by the config-url-repo fixture,
|
||||||
|
# to be downloaded (not copied in locally) via `id-configurator apply
|
||||||
|
# --config-url ... --config-url-cacert-content ...`
|
||||||
|
# (tests/dockerfiles/ConfigUrl.Dockerfile). No per-run templating is needed:
|
||||||
|
# unlike the module-* fixtures, nothing in this document needs to reference
|
||||||
|
# its own server's dynamic host:port.
|
||||||
|
#
|
||||||
|
# The certificate below is tests/certs/test-ca.crt.pem - the same Test CA
|
||||||
|
# that signs repo-server.crt.pem in this directory. It's duplicated here
|
||||||
|
# (rather than read at build time) because a Docker build's context is
|
||||||
|
# this directory alone; keep both copies in sync if the Test CA is ever
|
||||||
|
# regenerated.
|
||||||
|
uuid: 8f3e4b2c-9a41-4d3e-8f1a-2b6c7d8e9f01
|
||||||
|
organisation: InterDiode
|
||||||
|
description: Configuration downloaded via --config-url
|
||||||
|
configurations:
|
||||||
|
certificates:
|
||||||
|
- name: Test CA
|
||||||
|
content: |
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDdTCCAl2gAwIBAgIUKq4URHQwyg7IzJ3ViAGGlM9pGCwwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwQjEeMBwGA1UECgwVaWQtY29uZmlndXJhdG9yLXRlc3RzMSAwHgYDVQQDDBdp
|
||||||
|
ZC1jb25maWd1cmF0b3IgVGVzdCBDQTAeFw0yNjA5MDUyMTA4MTNaFw00NjA4MzEy
|
||||||
|
MTA4MTNaMEIxHjAcBgNVBAoMFWlkLWNvbmZpZ3VyYXRvci10ZXN0czEgMB4GA1UE
|
||||||
|
AwwXaWQtY29uZmlndXJhdG9yIFRlc3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IB
|
||||||
|
DwAwggEKAoIBAQD8bMteh3MDnbW6OoFMlhA4Z2QAabVoSFlSE/Si7ut+L/3v1GY4
|
||||||
|
lixenwLQc8a2koIH9CtmOodzfO7hpro/VUjYJ9J2eTsXLLiOg7OgggG1cKV9/kt7
|
||||||
|
RF0Q4hkcanGmiffc4xbz4ZedU6lP1vzLCsgU0A8OPt6svcNjGcWbjxss0KjgxGZ7
|
||||||
|
H4UKdL7Mv4W2Y6Fw1b4eV2M6jErkQK6IPXsHGFh7JkzliNcb0sX1VfbfauS84tBh
|
||||||
|
f3EHwzLNhbjtEmQhQdqgsg4Cbk/gWur//6aEvlD/X12PAUbrp4DFD+CDuVagLX7I
|
||||||
|
7TQBXNFGKhozzQuDg7DFg1gIV8SFx2m+s60/AgMBAAGjYzBhMB0GA1UdDgQWBBSb
|
||||||
|
zJ7DsaxwCeqbfOXGJ3x+u7bjozAfBgNVHSMEGDAWgBSbzJ7DsaxwCeqbfOXGJ3x+
|
||||||
|
u7bjozAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0B
|
||||||
|
AQsFAAOCAQEAEV1+dG3agh0S/AR8jC8M33HXFyvneCCRtfb5q/loJ//VoWPbD3Gs
|
||||||
|
G+2SR0MVN+KKOMBvCBh3KrNSgFF9iFmBjNCJJZ8ycDIszUK3xLjUP2EzN1pNNl8S
|
||||||
|
D/yHzUCro3547Q+I+Nn5zRciG95EpMvtkIWem4H8luYSBt7w8y9uDUKWzaca/v3z
|
||||||
|
oGZPZP99w8WVqMKGRbsK94wsTVsWiE+oHzSufwrhvZJfQQ4jU6102IEnRS/eQFXA
|
||||||
|
+fozgEWuFatZe3Gz0HUJakrzSBwSTV6wmnEHCfu+uwdns1Ttq5FylZ4StJHsmweL
|
||||||
|
14w1xa3tjC5bQ3e1QWK3R5InHcSZ7tLpGA==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Serves a static id-configurator configuration document over TLS, using the
|
||||||
|
# same Test CA as the other *-repo fixtures, used by tox-docker to exercise
|
||||||
|
# `id-configurator apply --config-url ... --config-url-cacert-content ...`
|
||||||
|
# (tests/dockerfiles/ConfigUrl.Dockerfile).
|
||||||
|
#
|
||||||
|
# Built by tox-docker, whose build context is this directory (not the repo
|
||||||
|
# root) - every COPY source below must live under tests/dockerfiles/repos/.
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY config-url-config.yaml /srv/config.yaml
|
||||||
|
COPY repo-server.crt.pem /etc/nginx/certs/repo-server.crt.pem
|
||||||
|
COPY repo-server.key.pem /etc/nginx/certs/repo-server.key.pem
|
||||||
|
COPY config-url-repo.nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 8443
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
server {
|
||||||
|
listen 8443 ssl default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/repo-server.crt.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/repo-server.key.pem;
|
||||||
|
|
||||||
|
root /srv;
|
||||||
|
autoindex off;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Minimal Go module proxy serving one hand-built module over TLS, using the
|
||||||
|
# same Test CA as the other *-repo fixtures, used by tox-docker to exercise
|
||||||
|
# the `go` module integration test (tests/configs/Go.yaml.j2,
|
||||||
|
# tests/dockerfiles/Go.Dockerfile).
|
||||||
|
#
|
||||||
|
# Built by tox-docker, whose build context is this directory (not the repo
|
||||||
|
# root) - every COPY source below must live under tests/dockerfiles/repos/.
|
||||||
|
FROM python:3.13-slim AS packagebuild
|
||||||
|
COPY build_test_go_module.py /build_test_go_module.py
|
||||||
|
RUN python3 /build_test_go_module.py
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=packagebuild /srv /srv
|
||||||
|
COPY repo-server.crt.pem /etc/nginx/certs/repo-server.crt.pem
|
||||||
|
COPY repo-server.key.pem /etc/nginx/certs/repo-server.key.pem
|
||||||
|
COPY go-repo.nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 8443
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
server {
|
||||||
|
listen 8443 ssl default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/repo-server.crt.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/repo-server.key.pem;
|
||||||
|
|
||||||
|
root /srv;
|
||||||
|
autoindex off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Plain-HTTP listener, for the module-*-http tox envs that verify id-configurator
|
||||||
|
# correctly configures a non-HTTPS repository.
|
||||||
|
server {
|
||||||
|
listen 8080 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /srv;
|
||||||
|
autoindex off;
|
||||||
|
}
|
||||||
@@ -8,3 +8,13 @@ server {
|
|||||||
root /srv;
|
root /srv;
|
||||||
autoindex off;
|
autoindex off;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Plain-HTTP listener, for the module-*-http tox envs that verify id-configurator
|
||||||
|
# correctly configures a non-HTTPS repository.
|
||||||
|
server {
|
||||||
|
listen 8080 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /srv;
|
||||||
|
autoindex off;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,10 +12,29 @@ server {
|
|||||||
# its tarball URL, since the host:port this fixture is actually reached
|
# its tarball URL, since the host:port this fixture is actually reached
|
||||||
# on is only known once tox-docker starts the container, not at
|
# on is only known once tox-docker starts the container, not at
|
||||||
# image-build time. $http_host reflects exactly what the client (npm)
|
# image-build time. $http_host reflects exactly what the client (npm)
|
||||||
# used to connect, e.g. host.docker.internal:55123.
|
# used to connect, e.g. host.docker.internal:55123; $scheme likewise
|
||||||
|
# reflects whichever of this file's two listeners actually served the
|
||||||
|
# request.
|
||||||
location = /idconfiguratortestpkg {
|
location = /idconfiguratortestpkg {
|
||||||
default_type application/json;
|
default_type application/json;
|
||||||
sub_filter '__BASE_URL__' 'https://$http_host';
|
sub_filter '__BASE_URL__' '$scheme://$http_host';
|
||||||
|
sub_filter_once off;
|
||||||
|
sub_filter_types application/json;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Plain-HTTP listener, for the module-npm-http tox env that verifies
|
||||||
|
# id-configurator correctly configures a non-HTTPS repository.
|
||||||
|
server {
|
||||||
|
listen 8080 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /srv;
|
||||||
|
autoindex off;
|
||||||
|
|
||||||
|
location = /idconfiguratortestpkg {
|
||||||
|
default_type application/json;
|
||||||
|
sub_filter '__BASE_URL__' '$scheme://$http_host';
|
||||||
sub_filter_once off;
|
sub_filter_once off;
|
||||||
sub_filter_types application/json;
|
sub_filter_types application/json;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Minimal NuGet v3 static feed serving one real, dotnet-packed .nupkg over
|
||||||
|
# TLS, using the same Test CA as the other *-repo fixtures, used by
|
||||||
|
# tox-docker to exercise the `nuget` module integration test
|
||||||
|
# (tests/configs/NuGet.yaml.j2, tests/dockerfiles/NuGet.Dockerfile).
|
||||||
|
#
|
||||||
|
# Built by tox-docker, whose build context is this directory (not the repo
|
||||||
|
# root) - every COPY source below must live under tests/dockerfiles/repos/.
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS packagebuild
|
||||||
|
COPY IdConfiguratorTestPkg.csproj /pkgsrc/IdConfiguratorTestPkg.csproj
|
||||||
|
RUN echo 'test-only package served by the id-configurator integration tests' > /pkgsrc/marker.txt \
|
||||||
|
&& dotnet pack /pkgsrc -c Release -o /nupkg
|
||||||
|
|
||||||
|
FROM python:3.13-slim AS feedbuild
|
||||||
|
COPY --from=packagebuild /nupkg /nupkg
|
||||||
|
COPY build_test_nuget_feed.py /build_test_nuget_feed.py
|
||||||
|
RUN python3 /build_test_nuget_feed.py
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=feedbuild /srv /srv
|
||||||
|
COPY repo-server.crt.pem /etc/nginx/certs/repo-server.crt.pem
|
||||||
|
COPY repo-server.key.pem /etc/nginx/certs/repo-server.key.pem
|
||||||
|
COPY nuget-repo.nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 8443
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
server {
|
||||||
|
listen 8443 ssl default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/repo-server.crt.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/repo-server.key.pem;
|
||||||
|
|
||||||
|
root /srv;
|
||||||
|
autoindex off;
|
||||||
|
|
||||||
|
# The service index bakes in a __BASE_URL__ placeholder for its "@id"
|
||||||
|
# fields, since the host:port this fixture is actually reached on is
|
||||||
|
# only known once tox-docker starts the container, not at image-build
|
||||||
|
# time. $http_host reflects exactly what the client (nuget) used to
|
||||||
|
# connect, e.g. host.docker.internal:55123; $scheme likewise reflects
|
||||||
|
# whichever of this file's two listeners actually served the request.
|
||||||
|
location = /index.json {
|
||||||
|
default_type application/json;
|
||||||
|
sub_filter '__BASE_URL__' '$scheme://$http_host';
|
||||||
|
sub_filter_once off;
|
||||||
|
sub_filter_types application/json;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Plain-HTTP listener, for the module-nuget-http tox env that verifies
|
||||||
|
# id-configurator correctly configures a non-HTTPS repository.
|
||||||
|
server {
|
||||||
|
listen 8080 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /srv;
|
||||||
|
autoindex off;
|
||||||
|
|
||||||
|
location = /index.json {
|
||||||
|
default_type application/json;
|
||||||
|
sub_filter '__BASE_URL__' '$scheme://$http_host';
|
||||||
|
sub_filter_once off;
|
||||||
|
sub_filter_types application/json;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,10 +12,28 @@ server {
|
|||||||
# since the host:port this fixture is actually reached on is only known
|
# since the host:port this fixture is actually reached on is only known
|
||||||
# once tox-docker starts the container, not at image-build time.
|
# once tox-docker starts the container, not at image-build time.
|
||||||
# $http_host reflects exactly what the client (Composer) used to
|
# $http_host reflects exactly what the client (Composer) used to
|
||||||
# connect, e.g. host.docker.internal:55123.
|
# connect, e.g. host.docker.internal:55123; $scheme likewise reflects
|
||||||
|
# whichever of this file's two listeners actually served the request.
|
||||||
location = /packages.json {
|
location = /packages.json {
|
||||||
default_type application/json;
|
default_type application/json;
|
||||||
sub_filter '__BASE_URL__' 'https://$http_host';
|
sub_filter '__BASE_URL__' '$scheme://$http_host';
|
||||||
|
sub_filter_once off;
|
||||||
|
sub_filter_types application/json;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Plain-HTTP listener, for the module-php-http tox env that verifies
|
||||||
|
# id-configurator correctly configures a non-HTTPS repository.
|
||||||
|
server {
|
||||||
|
listen 8080 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /srv;
|
||||||
|
autoindex off;
|
||||||
|
|
||||||
|
location = /packages.json {
|
||||||
|
default_type application/json;
|
||||||
|
sub_filter '__BASE_URL__' '$scheme://$http_host';
|
||||||
sub_filter_once off;
|
sub_filter_once off;
|
||||||
sub_filter_types application/json;
|
sub_filter_types application/json;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,3 +8,13 @@ server {
|
|||||||
root /srv;
|
root /srv;
|
||||||
autoindex off;
|
autoindex off;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Plain-HTTP listener, for the module-*-http tox envs that verify id-configurator
|
||||||
|
# correctly configures a non-HTTPS repository.
|
||||||
|
server {
|
||||||
|
listen 8080 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /srv;
|
||||||
|
autoindex off;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Minimal RubyGems repository serving one hand-built gem over TLS, using the
|
||||||
|
# same Test CA as the other *-repo fixtures, used by tox-docker to exercise
|
||||||
|
# the `ruby` module integration test (tests/configs/Ruby.yaml.j2,
|
||||||
|
# tests/dockerfiles/Ruby.Dockerfile).
|
||||||
|
#
|
||||||
|
# Built by tox-docker, whose build context is this directory (not the repo
|
||||||
|
# root) - every COPY source below must live under tests/dockerfiles/repos/.
|
||||||
|
FROM ruby:3-slim AS packagebuild
|
||||||
|
COPY build_test_gem.sh /build_test_gem.sh
|
||||||
|
RUN sh /build_test_gem.sh
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=packagebuild /srv /srv
|
||||||
|
COPY repo-server.crt.pem /etc/nginx/certs/repo-server.crt.pem
|
||||||
|
COPY repo-server.key.pem /etc/nginx/certs/repo-server.key.pem
|
||||||
|
COPY ruby-repo.nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 8443
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
server {
|
||||||
|
listen 8443 ssl default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/repo-server.crt.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/repo-server.key.pem;
|
||||||
|
|
||||||
|
root /srv;
|
||||||
|
autoindex off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Plain-HTTP listener, for the module-*-http tox envs that verify id-configurator
|
||||||
|
# correctly configures a non-HTTPS repository.
|
||||||
|
server {
|
||||||
|
listen 8080 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /srv;
|
||||||
|
autoindex off;
|
||||||
|
}
|
||||||
@@ -8,3 +8,13 @@ server {
|
|||||||
root /srv;
|
root /srv;
|
||||||
autoindex off;
|
autoindex off;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Plain-HTTP listener, for the module-*-http tox envs that verify id-configurator
|
||||||
|
# correctly configures a non-HTTPS repository.
|
||||||
|
server {
|
||||||
|
listen 8080 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /srv;
|
||||||
|
autoindex off;
|
||||||
|
}
|
||||||
|
|||||||
+195
-4
@@ -14,10 +14,11 @@ CONFIG_DATA = {
|
|||||||
"apk": {
|
"apk": {
|
||||||
"repositories": [
|
"repositories": [
|
||||||
{
|
{
|
||||||
|
"name": "main",
|
||||||
"url": "https://example.invalid/v3.19/main",
|
"url": "https://example.invalid/v3.19/main",
|
||||||
"key": KEY_BYTES,
|
"key": KEY_BYTES,
|
||||||
},
|
},
|
||||||
{"url": "https://example.invalid/v3.19/community"},
|
{"name": "community", "url": "https://example.invalid/v3.19/community"},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -47,7 +48,9 @@ def test_is_relevant_true_with_apk_dir(tmp_path) -> None:
|
|||||||
assert ApkModule().is_relevant(context) is True
|
assert ApkModule().is_relevant(context) is True
|
||||||
|
|
||||||
|
|
||||||
def test_apply_disables_existing_and_adds_configured_repositories(tmp_path) -> None:
|
def test_apply_copies_existing_repositories_unchanged_without_url_to_replace(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
src = tmp_path / "src"
|
src = tmp_path / "src"
|
||||||
apk_dir = src / "etc/apk"
|
apk_dir = src / "etc/apk"
|
||||||
apk_dir.mkdir(parents=True)
|
apk_dir.mkdir(parents=True)
|
||||||
@@ -61,11 +64,13 @@ def test_apply_disables_existing_and_adds_configured_repositories(tmp_path) -> N
|
|||||||
|
|
||||||
ApkModule().apply(context)
|
ApkModule().apply(context)
|
||||||
|
|
||||||
|
# No configured repository declares a url_to_replace, so the existing
|
||||||
|
# lines are copied through unchanged, still active.
|
||||||
content = (dst / "etc/apk/repositories").read_text(encoding="utf-8")
|
content = (dst / "etc/apk/repositories").read_text(encoding="utf-8")
|
||||||
assert content == (
|
assert content == (
|
||||||
"# comment\n"
|
"# comment\n"
|
||||||
"# https://old-mirror.invalid/v3.18/main\n"
|
"https://old-mirror.invalid/v3.18/main\n"
|
||||||
"# https://old-mirror.invalid/v3.18/community\n"
|
"https://old-mirror.invalid/v3.18/community\n"
|
||||||
"https://example.invalid/v3.19/main\n"
|
"https://example.invalid/v3.19/main\n"
|
||||||
"https://example.invalid/v3.19/community\n"
|
"https://example.invalid/v3.19/community\n"
|
||||||
)
|
)
|
||||||
@@ -74,6 +79,42 @@ def test_apply_disables_existing_and_adds_configured_repositories(tmp_path) -> N
|
|||||||
assert key_content == KEY_BYTES
|
assert key_content == KEY_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_disables_only_existing_lines_matching_url_to_replace(tmp_path) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
apk_dir = src / "etc/apk"
|
||||||
|
apk_dir.mkdir(parents=True)
|
||||||
|
(apk_dir / "repositories").write_text(
|
||||||
|
"https://old-mirror.invalid/v3.18/main\nhttps://old-mirror.invalid/v3.18/community\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"apk": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "main",
|
||||||
|
"url": "https://example.invalid/v3.19/main",
|
||||||
|
"url_to_replace": "https://old-mirror.invalid/v3.18/main",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=Configuration.model_validate(data), dst=dst, src=src)
|
||||||
|
|
||||||
|
ApkModule().apply(context)
|
||||||
|
|
||||||
|
content = (dst / "etc/apk/repositories").read_text(encoding="utf-8")
|
||||||
|
assert content == (
|
||||||
|
"# https://old-mirror.invalid/v3.18/main\n"
|
||||||
|
"https://old-mirror.invalid/v3.18/community\n"
|
||||||
|
"https://example.invalid/v3.19/main\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_apply_without_existing_repositories_file(tmp_path) -> None:
|
def test_apply_without_existing_repositories_file(tmp_path) -> None:
|
||||||
src = tmp_path / "src"
|
src = tmp_path / "src"
|
||||||
(src / "etc/apk").mkdir(parents=True)
|
(src / "etc/apk").mkdir(parents=True)
|
||||||
@@ -89,6 +130,156 @@ def test_apply_without_existing_repositories_file(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_skips_repository_when_url_to_replace_matches_nothing(tmp_path) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
apk_dir = src / "etc/apk"
|
||||||
|
apk_dir.mkdir(parents=True)
|
||||||
|
(apk_dir / "repositories").write_text(
|
||||||
|
"https://old-mirror.invalid/v3.18/main\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"apk": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "main",
|
||||||
|
"url": "https://example.invalid/v3.19/main",
|
||||||
|
"url_to_replace": "https://other.invalid/*",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=Configuration.model_validate(data), dst=dst, src=src)
|
||||||
|
|
||||||
|
ApkModule().apply(context)
|
||||||
|
|
||||||
|
content = (dst / "etc/apk/repositories").read_text(encoding="utf-8")
|
||||||
|
assert "example.invalid" not in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_writes_repository_when_url_to_replace_matches_existing(tmp_path) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
apk_dir = src / "etc/apk"
|
||||||
|
apk_dir.mkdir(parents=True)
|
||||||
|
(apk_dir / "repositories").write_text(
|
||||||
|
"https://old-mirror.invalid/v3.18/main\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"apk": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "main",
|
||||||
|
"url": "https://example.invalid/v3.19/main",
|
||||||
|
"url_to_replace": "https://old-mirror.invalid/*",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=Configuration.model_validate(data), dst=dst, src=src)
|
||||||
|
|
||||||
|
ApkModule().apply(context)
|
||||||
|
|
||||||
|
content = (dst / "etc/apk/repositories").read_text(encoding="utf-8")
|
||||||
|
assert "https://example.invalid/v3.19/main" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_reuses_cached_existing_urls_and_handles_missing_file(tmp_path) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
(src / "etc/apk").mkdir(parents=True)
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"apk": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "main",
|
||||||
|
"url": "https://example.invalid/v3.19/main",
|
||||||
|
"url_to_replace": "https://old.invalid/*",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "community",
|
||||||
|
"url": "https://example.invalid/v3.19/community",
|
||||||
|
"url_to_replace": "https://old.invalid/*",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=Configuration.model_validate(data), dst=dst, src=src)
|
||||||
|
|
||||||
|
ApkModule().apply(context)
|
||||||
|
|
||||||
|
assert not (dst / "etc/apk/repositories").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_uses_key_filename_when_valid(tmp_path) -> None:
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"apk": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "main",
|
||||||
|
"url": "https://example.invalid/v3.19/main",
|
||||||
|
"key": "fake-apk-key-bytes",
|
||||||
|
"key_filename": "main.rsa.pub",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
src = tmp_path / "src"
|
||||||
|
(src / "etc/apk").mkdir(parents=True)
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=Configuration.model_validate(data), dst=dst, src=src)
|
||||||
|
|
||||||
|
ApkModule().apply(context)
|
||||||
|
|
||||||
|
key_content = (dst / "etc/apk/keys" / "main.rsa.pub").read_bytes()
|
||||||
|
assert key_content == b"fake-apk-key-bytes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_falls_back_to_derived_name_when_key_filename_invalid(tmp_path) -> None:
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"apk": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "main",
|
||||||
|
"url": "https://example.invalid/v3.19/main",
|
||||||
|
"key": "fake-apk-key-bytes",
|
||||||
|
"key_filename": "../escape",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
src = tmp_path / "src"
|
||||||
|
(src / "etc/apk").mkdir(parents=True)
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=Configuration.model_validate(data), dst=dst, src=src)
|
||||||
|
|
||||||
|
ApkModule().apply(context)
|
||||||
|
|
||||||
|
assert not (dst / "etc/apk/keys" / "../escape").exists()
|
||||||
|
key_content = (dst / "etc/apk/keys" / KEY_FILENAME).read_bytes()
|
||||||
|
assert key_content == KEY_BYTES
|
||||||
|
|
||||||
|
|
||||||
def test_apply_writes_nothing_when_no_repositories_at_all(tmp_path) -> None:
|
def test_apply_writes_nothing_when_no_repositories_at_all(tmp_path) -> None:
|
||||||
empty_config = Configuration.model_validate(
|
empty_config = Configuration.model_validate(
|
||||||
{"uuid": "12345678-1234-5678-1234-567812345678", "organisation": "InterDiode"}
|
{"uuid": "12345678-1234-5678-1234-567812345678", "organisation": "InterDiode"}
|
||||||
|
|||||||
+267
-23
@@ -2,6 +2,13 @@ from id_configurator.config import Configuration
|
|||||||
from id_configurator.context import Context
|
from id_configurator.context import Context
|
||||||
from id_configurator.modules.apt import AptModule
|
from id_configurator.modules.apt import AptModule
|
||||||
|
|
||||||
|
KEY_CONTENT = (
|
||||||
|
"-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
|
||||||
|
"\n"
|
||||||
|
"mQENBGqh08IBCAC0\n"
|
||||||
|
"-----END PGP PUBLIC KEY BLOCK-----\n"
|
||||||
|
)
|
||||||
|
|
||||||
SOURCE_CONTENT = {
|
SOURCE_CONTENT = {
|
||||||
"uuid": "12345678-1234-5678-1234-567812345678",
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
"organisation": "InterDiode",
|
"organisation": "InterDiode",
|
||||||
@@ -12,13 +19,7 @@ SOURCE_CONTENT = {
|
|||||||
"name": "interdiode",
|
"name": "interdiode",
|
||||||
"uris": ["http://example.invalid/debian"],
|
"uris": ["http://example.invalid/debian"],
|
||||||
"suites": ["stable"],
|
"suites": ["stable"],
|
||||||
"signed-by": "/etc/apt/keyrings/interdiode.asc",
|
"signed-by": KEY_CONTENT,
|
||||||
}
|
|
||||||
],
|
|
||||||
"keys": [
|
|
||||||
{
|
|
||||||
"name": "interdiode",
|
|
||||||
"content": "fake-key-bytes",
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -30,6 +31,26 @@ def make_config() -> Configuration:
|
|||||||
return Configuration.model_validate(SOURCE_CONTENT)
|
return Configuration.model_validate(SOURCE_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
def make_config_with(source_extra: dict, name: str = "interdiode") -> Configuration:
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"apt": {
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"uris": ["http://example.invalid/debian"],
|
||||||
|
"suites": ["stable"],
|
||||||
|
**source_extra,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return Configuration.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
def test_is_relevant_false_without_src(tmp_path) -> None:
|
def test_is_relevant_false_without_src(tmp_path) -> None:
|
||||||
context = Context(config=make_config(), dst=tmp_path / "dst", src=None)
|
context = Context(config=make_config(), dst=tmp_path / "dst", src=None)
|
||||||
assert AptModule().is_relevant(context) is False
|
assert AptModule().is_relevant(context) is False
|
||||||
@@ -49,7 +70,9 @@ def test_is_relevant_true_with_apt_dir(tmp_path) -> None:
|
|||||||
assert AptModule().is_relevant(context) is True
|
assert AptModule().is_relevant(context) is True
|
||||||
|
|
||||||
|
|
||||||
def test_apply_disables_existing_sources_and_adds_configured_ones(tmp_path) -> None:
|
def test_apply_copies_existing_sources_unchanged_without_uri_to_replace(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
src = tmp_path / "src"
|
src = tmp_path / "src"
|
||||||
apt_dir = src / "etc/apt"
|
apt_dir = src / "etc/apt"
|
||||||
sources_list_d = apt_dir / "sources.list.d"
|
sources_list_d = apt_dir / "sources.list.d"
|
||||||
@@ -76,37 +99,88 @@ def test_apply_disables_existing_sources_and_adds_configured_ones(tmp_path) -> N
|
|||||||
|
|
||||||
AptModule().apply(context)
|
AptModule().apply(context)
|
||||||
|
|
||||||
disabled_top_level = (dst / "etc/apt/sources.list").read_text(encoding="utf-8")
|
# No configured source declares a uri_to_replace, so none of the existing
|
||||||
|
# entries are disabled: they are copied through unchanged.
|
||||||
|
top_level = (dst / "etc/apt/sources.list").read_text(encoding="utf-8")
|
||||||
assert (
|
assert (
|
||||||
disabled_top_level
|
top_level
|
||||||
== "# already disabled\n# deb http://example.invalid/legacy stable main\n\n"
|
== "# already disabled\ndeb http://example.invalid/legacy stable main\n\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
disabled_list = (dst / "etc/apt/sources.list.d/extra.list").read_text(
|
extra_list = (dst / "etc/apt/sources.list.d/extra.list").read_text(encoding="utf-8")
|
||||||
encoding="utf-8"
|
assert extra_list == "deb http://example.invalid/extra stable main\n"
|
||||||
)
|
|
||||||
assert disabled_list == "# deb http://example.invalid/extra stable main\n"
|
|
||||||
|
|
||||||
disabled_sources = (dst / "etc/apt/sources.list.d/docker.sources").read_text(
|
docker_sources = (dst / "etc/apt/sources.list.d/docker.sources").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
assert disabled_sources == (
|
assert docker_sources == (
|
||||||
"Types: deb\nURIs: http://example.invalid/docker\nSuites: stable\nEnabled: no"
|
"Types: deb\nURIs: http://example.invalid/docker\nSuites: stable\nEnabled: yes"
|
||||||
"\n\n"
|
"\n\n"
|
||||||
"Types: deb\nURIs: http://example.invalid/docker2\nSuites: stable\nEnabled: no\n"
|
"Types: deb\nURIs: http://example.invalid/docker2\nSuites: stable\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert not (dst / "etc/apt/sources.list.d/ignored.txt").exists()
|
assert not (dst / "etc/apt/sources.list.d/ignored.txt").exists()
|
||||||
|
|
||||||
keyring = (dst / "etc/apt/keyrings/interdiode.asc").read_bytes()
|
|
||||||
assert keyring == b"fake-key-bytes"
|
|
||||||
|
|
||||||
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
|
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
assert configured_source == (
|
assert configured_source == (
|
||||||
"Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\n"
|
"Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\n"
|
||||||
"Signed-By: /etc/apt/keyrings/interdiode.asc\nEnabled: yes\n"
|
"Signed-By:\n"
|
||||||
|
" -----BEGIN PGP PUBLIC KEY BLOCK-----\n"
|
||||||
|
" .\n"
|
||||||
|
" mQENBGqh08IBCAC0\n"
|
||||||
|
" -----END PGP PUBLIC KEY BLOCK-----\n"
|
||||||
|
"Enabled: yes\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_disables_only_existing_sources_matching_uri_to_replace(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
apt_dir = src / "etc/apt"
|
||||||
|
sources_list_d = apt_dir / "sources.list.d"
|
||||||
|
sources_list_d.mkdir(parents=True)
|
||||||
|
|
||||||
|
(apt_dir / "sources.list").write_text(
|
||||||
|
"deb http://example.invalid/legacy stable main\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(sources_list_d / "extra.list").write_text(
|
||||||
|
"deb http://example.invalid/extra stable main\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(sources_list_d / "docker.sources").write_text(
|
||||||
|
"Types: deb\nURIs: http://example.invalid/extra\nSuites: stable\nEnabled: yes\n"
|
||||||
|
"\n"
|
||||||
|
"Types: deb\nURIs: http://example.invalid/docker2\nSuites: stable\n"
|
||||||
|
"\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(
|
||||||
|
config=make_config_with({"uri-to-replace": "http://example.invalid/extra"}),
|
||||||
|
dst=dst,
|
||||||
|
src=src,
|
||||||
|
)
|
||||||
|
|
||||||
|
AptModule().apply(context)
|
||||||
|
|
||||||
|
# The legacy source doesn't match the pattern, so it stays enabled...
|
||||||
|
top_level = (dst / "etc/apt/sources.list").read_text(encoding="utf-8")
|
||||||
|
assert top_level == "deb http://example.invalid/legacy stable main\n"
|
||||||
|
|
||||||
|
# ...but every entry whose URI matches is disabled, regardless of format.
|
||||||
|
extra_list = (dst / "etc/apt/sources.list.d/extra.list").read_text(encoding="utf-8")
|
||||||
|
assert extra_list == "# deb http://example.invalid/extra stable main\n"
|
||||||
|
|
||||||
|
docker_sources = (dst / "etc/apt/sources.list.d/docker.sources").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert docker_sources == (
|
||||||
|
"Types: deb\nURIs: http://example.invalid/extra\nSuites: stable\nEnabled: no"
|
||||||
|
"\n\n"
|
||||||
|
"Types: deb\nURIs: http://example.invalid/docker2\nSuites: stable\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -120,3 +194,173 @@ def test_apply_without_existing_top_level_sources_list(tmp_path) -> None:
|
|||||||
|
|
||||||
assert not (dst / "etc/apt/sources.list").exists()
|
assert not (dst / "etc/apt/sources.list").exists()
|
||||||
assert (dst / "etc/apt/sources.list.d/interdiode.sources").exists()
|
assert (dst / "etc/apt/sources.list.d/interdiode.sources").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_uses_signed_by_file_when_no_key_content(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(
|
||||||
|
config=make_config_with({"signed-by-file": "/etc/apt/keyrings/existing.gpg"}),
|
||||||
|
dst=dst,
|
||||||
|
src=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
AptModule().apply(context)
|
||||||
|
|
||||||
|
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert configured_source == (
|
||||||
|
"Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\n"
|
||||||
|
"Signed-By: /etc/apt/keyrings/existing.gpg\nEnabled: yes\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_signed_by_takes_precedence_over_signed_by_file(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(
|
||||||
|
config=make_config_with(
|
||||||
|
{
|
||||||
|
"signed-by": KEY_CONTENT,
|
||||||
|
"signed-by-file": "/etc/apt/keyrings/existing.gpg",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
dst=dst,
|
||||||
|
src=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
AptModule().apply(context)
|
||||||
|
|
||||||
|
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert "Signed-By:\n -----BEGIN PGP PUBLIC KEY BLOCK-----" in configured_source
|
||||||
|
assert "existing.gpg" not in configured_source
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_without_signed_by_writes_no_field(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config_with({}), dst=dst, src=None)
|
||||||
|
|
||||||
|
AptModule().apply(context)
|
||||||
|
|
||||||
|
configured_source = (dst / "etc/apt/sources.list.d/interdiode.sources").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert configured_source == (
|
||||||
|
"Types: deb\nURIs: http://example.invalid/debian\nSuites: stable\nEnabled: yes\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_skips_source_when_uri_to_replace_matches_nothing(tmp_path) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
apt_dir = src / "etc/apt"
|
||||||
|
apt_dir.mkdir(parents=True)
|
||||||
|
(apt_dir / "sources.list").write_text(
|
||||||
|
"deb http://example.invalid/legacy stable main\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(
|
||||||
|
config=make_config_with({"uri-to-replace": "http://other.invalid/*"}),
|
||||||
|
dst=dst,
|
||||||
|
src=src,
|
||||||
|
)
|
||||||
|
|
||||||
|
AptModule().apply(context)
|
||||||
|
|
||||||
|
assert not (dst / "etc/apt/sources.list.d/interdiode.sources").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_writes_source_when_uri_to_replace_matches_existing_uri(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
apt_dir = src / "etc/apt"
|
||||||
|
apt_dir.mkdir(parents=True)
|
||||||
|
(apt_dir / "sources.list").write_text(
|
||||||
|
"deb http://legacy.invalid/debian stable main\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(
|
||||||
|
config=make_config_with({"uri-to-replace": "http://legacy.invalid/*"}),
|
||||||
|
dst=dst,
|
||||||
|
src=src,
|
||||||
|
)
|
||||||
|
|
||||||
|
AptModule().apply(context)
|
||||||
|
|
||||||
|
assert (dst / "etc/apt/sources.list.d/interdiode.sources").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_skips_source_when_uri_to_replace_set_without_src(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(
|
||||||
|
config=make_config_with({"uri-to-replace": "http://*"}), dst=dst, src=None
|
||||||
|
)
|
||||||
|
|
||||||
|
AptModule().apply(context)
|
||||||
|
|
||||||
|
assert not (dst / "etc/apt/sources.list.d/interdiode.sources").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_matches_uri_to_replace_across_list_line_variants(tmp_path) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
apt_dir = src / "etc/apt"
|
||||||
|
sources_list_d = apt_dir / "sources.list.d"
|
||||||
|
sources_list_d.mkdir(parents=True)
|
||||||
|
|
||||||
|
(apt_dir / "sources.list").write_text(
|
||||||
|
"# a comment\n"
|
||||||
|
"\n"
|
||||||
|
"garbage line here\n"
|
||||||
|
"deb [unterminated bracket with no closing marker\n"
|
||||||
|
"deb [arch=amd64 trusted=yes] http://example.invalid/extra stable main\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(sources_list_d / "ignored.txt").write_text("not a source file\n", encoding="utf-8")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"apt": {
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"name": "first",
|
||||||
|
"uris": ["http://example.invalid/first"],
|
||||||
|
"suites": ["stable"],
|
||||||
|
"uri-to-replace": "http://example.invalid/extra",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "second",
|
||||||
|
"uris": ["http://example.invalid/second"],
|
||||||
|
"suites": ["stable"],
|
||||||
|
"uri-to-replace": "http://example.invalid/extra",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=Configuration.model_validate(data), dst=dst, src=src)
|
||||||
|
|
||||||
|
AptModule().apply(context)
|
||||||
|
|
||||||
|
assert (dst / "etc/apt/sources.list.d/first.sources").exists()
|
||||||
|
assert (dst / "etc/apt/sources.list.d/second.sources").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_sanitizes_source_name_with_slash(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(
|
||||||
|
config=make_config_with({"signed-by": KEY_CONTENT}, name="../../evil"),
|
||||||
|
dst=dst,
|
||||||
|
src=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
AptModule().apply(context)
|
||||||
|
|
||||||
|
source_file = dst / "etc/apt/sources.list.d/______evil.sources"
|
||||||
|
assert source_file.exists()
|
||||||
|
assert not (dst / "etc/apt/sources.list.d/evil.sources").exists()
|
||||||
|
|||||||
@@ -54,6 +54,27 @@ def test_apply_writes_certificate_and_rebuilds_bundle(tmp_path) -> None:
|
|||||||
assert openssl_default.readlink().as_posix() == "/etc/ssl/certs/ca-certificates.crt"
|
assert openssl_default.readlink().as_posix() == "/etc/ssl/certs/ca-certificates.crt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_sanitizes_certificate_name_with_slash(tmp_path) -> None:
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"certificates": [{"name": "../../evil", "content": CERT_CONTENT}]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=Configuration.model_validate(data), dst=dst, src=None)
|
||||||
|
|
||||||
|
CertificatesModule().apply(context)
|
||||||
|
|
||||||
|
local_cert = dst / "usr/local/share/ca-certificates/______evil.crt"
|
||||||
|
assert local_cert.read_bytes() == b"fake-cert-bytes"
|
||||||
|
# The "/" must never be interpreted as a path separator: without
|
||||||
|
# sanitizing, "../../evil" would have reshuffled the write up to
|
||||||
|
# usr/local/evil.crt instead of staying under ca-certificates/.
|
||||||
|
assert not (dst / "usr/local/evil.crt").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_apply_merges_conf_listed_and_local_certificates(tmp_path) -> None:
|
def test_apply_merges_conf_listed_and_local_certificates(tmp_path) -> None:
|
||||||
src = tmp_path / "src"
|
src = tmp_path / "src"
|
||||||
local_certs_dir = src / "usr/local/share/ca-certificates"
|
local_certs_dir = src / "usr/local/share/ca-certificates"
|
||||||
@@ -237,3 +258,37 @@ def test_apply_writes_bundle_to_rhel_style_symlinked_etccertsdir(tmp_path) -> No
|
|||||||
# /usr/lib/ssl/cert.pem is Debian/Ubuntu-specific: not OpenSSL's default
|
# /usr/lib/ssl/cert.pem is Debian/Ubuntu-specific: not OpenSSL's default
|
||||||
# CAfile on this (RHEL-style) system, so it must not be created.
|
# CAfile on this (RHEL-style) system, so it must not be created.
|
||||||
assert not (dst / "usr/lib/ssl/cert.pem").exists()
|
assert not (dst / "usr/lib/ssl/cert.pem").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_is_idempotent_when_src_and_dst_are_the_same_live_root(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
# Regression test: applying directly to a live root (--src / --dst /,
|
||||||
|
# as the Ansible role does) where /usr/lib/ssl/cert.pem already exists
|
||||||
|
# (as it would on any host with ca-certificates already installed) must
|
||||||
|
# not resolve that pre-existing symlink away when recreating it - doing
|
||||||
|
# so would unlink/replace the bundle it points to instead, corrupting it
|
||||||
|
# into a symlink to itself and breaking every subsequent run.
|
||||||
|
root = tmp_path / "root"
|
||||||
|
etc_ssl_certs = root / "etc/ssl/certs"
|
||||||
|
etc_ssl_certs.mkdir(parents=True)
|
||||||
|
(etc_ssl_certs / "ca-certificates.crt").write_bytes(b"preexisting-bundle-bytes")
|
||||||
|
usr_lib_ssl = root / "usr/lib/ssl"
|
||||||
|
usr_lib_ssl.mkdir(parents=True)
|
||||||
|
(usr_lib_ssl / "cert.pem").symlink_to("/etc/ssl/certs/ca-certificates.crt")
|
||||||
|
|
||||||
|
context = Context(config=make_config(True), dst=root, src=root)
|
||||||
|
|
||||||
|
for _ in range(2):
|
||||||
|
CertificatesModule().apply(context)
|
||||||
|
|
||||||
|
bundle = etc_ssl_certs / "ca-certificates.crt"
|
||||||
|
assert not bundle.is_symlink()
|
||||||
|
assert bundle.read_bytes() == b"fake-cert-bytes\n"
|
||||||
|
|
||||||
|
openssl_default = usr_lib_ssl / "cert.pem"
|
||||||
|
assert openssl_default.is_symlink()
|
||||||
|
assert (
|
||||||
|
openssl_default.readlink().as_posix()
|
||||||
|
== "/etc/ssl/certs/ca-certificates.crt"
|
||||||
|
)
|
||||||
|
|||||||
+116
-1
@@ -15,6 +15,12 @@ CONFIG_WITH_CERT = {
|
|||||||
**MINIMAL_CONFIG,
|
**MINIMAL_CONFIG,
|
||||||
"configurations": {"certificates": [{"name": "my-ca", "content": "fake"}]},
|
"configurations": {"certificates": [{"name": "my-ca", "content": "fake"}]},
|
||||||
}
|
}
|
||||||
|
CONFIG_WITH_GO_REPO = {
|
||||||
|
**MINIMAL_CONFIG,
|
||||||
|
"configurations": {
|
||||||
|
"go": {"repositories": [{"name": "demo", "url": "https://example.invalid/"}]}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def write_config(tmp_path, data) -> str:
|
def write_config(tmp_path, data) -> str:
|
||||||
@@ -30,10 +36,56 @@ def test_validate_valid_config(tmp_path) -> None:
|
|||||||
assert "is valid" in result.stdout
|
assert "is valid" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_without_uuid_or_organisation(tmp_path) -> None:
|
||||||
|
config_path = write_config(tmp_path, {})
|
||||||
|
result = runner.invoke(cli.app, ["validate", "--config", config_path])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "<no uuid>" in result.stdout
|
||||||
|
assert "<no organisation>" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
def test_validate_without_config_fails() -> None:
|
def test_validate_without_config_fails() -> None:
|
||||||
result = runner.invoke(cli.app, ["validate"])
|
result = runner.invoke(cli.app, ["validate"])
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
assert "either --config or --config-env" in result.output
|
assert "either --config, --config-env" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_urlopen(content: bytes) -> MagicMock:
|
||||||
|
response = MagicMock()
|
||||||
|
response.read.return_value = content
|
||||||
|
response.__enter__.return_value = response
|
||||||
|
response.__exit__.return_value = False
|
||||||
|
return MagicMock(return_value=response)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_from_config_url(monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"id_configurator.config.urllib.request.urlopen",
|
||||||
|
_mock_urlopen(json.dumps(MINIMAL_CONFIG).encode("utf-8")),
|
||||||
|
)
|
||||||
|
result = runner.invoke(
|
||||||
|
cli.app, ["validate", "--config-url", "https://example.invalid/config.json"]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "is valid" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_from_config_url_insecure(monkeypatch) -> None:
|
||||||
|
mock_urlopen = _mock_urlopen(json.dumps(MINIMAL_CONFIG).encode("utf-8"))
|
||||||
|
monkeypatch.setattr("id_configurator.config.urllib.request.urlopen", mock_urlopen)
|
||||||
|
result = runner.invoke(
|
||||||
|
cli.app,
|
||||||
|
[
|
||||||
|
"validate",
|
||||||
|
"--config-url",
|
||||||
|
"https://example.invalid/config.json",
|
||||||
|
"--config-url-insecure",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "is valid" in result.stdout
|
||||||
|
_, kwargs = mock_urlopen.call_args
|
||||||
|
assert kwargs["context"].verify_mode.name == "CERT_NONE"
|
||||||
|
|
||||||
|
|
||||||
def test_list_modules() -> None:
|
def test_list_modules() -> None:
|
||||||
@@ -43,10 +95,13 @@ def test_list_modules() -> None:
|
|||||||
"apk",
|
"apk",
|
||||||
"apt",
|
"apt",
|
||||||
"certificates",
|
"certificates",
|
||||||
|
"go",
|
||||||
"java",
|
"java",
|
||||||
"npm",
|
"npm",
|
||||||
|
"nuget",
|
||||||
"php",
|
"php",
|
||||||
"python",
|
"python",
|
||||||
|
"ruby",
|
||||||
"rust",
|
"rust",
|
||||||
"yum",
|
"yum",
|
||||||
}
|
}
|
||||||
@@ -80,6 +135,66 @@ def test_apply_runs_relevant_module(tmp_path) -> None:
|
|||||||
assert (dst / "etc/ssl/certs/ca-certificates.crt").read_bytes() == b"fake\n"
|
assert (dst / "etc/ssl/certs/ca-certificates.crt").read_bytes() == b"fake\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_with_user_option_writes_per_user_config(tmp_path) -> None:
|
||||||
|
config_path = write_config(tmp_path, CONFIG_WITH_GO_REPO)
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
result = runner.invoke(
|
||||||
|
cli.app,
|
||||||
|
[
|
||||||
|
"apply",
|
||||||
|
"--config",
|
||||||
|
config_path,
|
||||||
|
"--dst",
|
||||||
|
str(dst),
|
||||||
|
"--user",
|
||||||
|
"alice",
|
||||||
|
"--user",
|
||||||
|
"root",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert (dst / "home/alice/.config/go/env").read_text(encoding="utf-8") == (
|
||||||
|
"GOPROXY=https://example.invalid/\nGOSUMDB=off\n"
|
||||||
|
)
|
||||||
|
assert (dst / "root/.config/go/env").read_text(encoding="utf-8") == (
|
||||||
|
"GOPROXY=https://example.invalid/\nGOSUMDB=off\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_with_auto_users_reads_passwd_from_src(tmp_path) -> None:
|
||||||
|
config_path = write_config(tmp_path, CONFIG_WITH_GO_REPO)
|
||||||
|
src = tmp_path / "src"
|
||||||
|
(src / "etc").mkdir(parents=True)
|
||||||
|
(src / "etc/passwd").write_text(
|
||||||
|
"root:x:0:0:root:/root:/bin/bash\n"
|
||||||
|
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n"
|
||||||
|
"daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
result = runner.invoke(
|
||||||
|
cli.app,
|
||||||
|
[
|
||||||
|
"apply",
|
||||||
|
"--config",
|
||||||
|
config_path,
|
||||||
|
"--dst",
|
||||||
|
str(dst),
|
||||||
|
"--src",
|
||||||
|
str(src),
|
||||||
|
"--auto-users",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
# root is always included despite its uid 0; daemon (nologin shell) is
|
||||||
|
# a system account, excluded.
|
||||||
|
assert (dst / "root/.config/go/env").exists()
|
||||||
|
assert (dst / "home/alice/.config/go/env").exists()
|
||||||
|
assert not (dst / "usr/sbin/.config/go/env").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_main_default_name_calls_app(monkeypatch) -> None:
|
def test_main_default_name_calls_app(monkeypatch) -> None:
|
||||||
mock_app = MagicMock()
|
mock_app = MagicMock()
|
||||||
monkeypatch.setattr(cli, "app", mock_app)
|
monkeypatch.setattr(cli, "app", mock_app)
|
||||||
|
|||||||
+171
-6
@@ -1,7 +1,14 @@
|
|||||||
import base64
|
import base64
|
||||||
|
import datetime
|
||||||
import json
|
import json
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from cryptography.hazmat.primitives.serialization import Encoding
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
|
||||||
from id_configurator.config import (
|
from id_configurator.config import (
|
||||||
AptSource,
|
AptSource,
|
||||||
@@ -27,7 +34,6 @@ FULL_CONFIG = {
|
|||||||
"signed-by": "/etc/apt/keyrings/interdiode.asc",
|
"signed-by": "/etc/apt/keyrings/interdiode.asc",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"keys": [{"name": "interdiode", "content": "ZmFrZQ=="}],
|
|
||||||
},
|
},
|
||||||
"yum": {
|
"yum": {
|
||||||
"repositories": [
|
"repositories": [
|
||||||
@@ -69,7 +75,6 @@ def test_parse_configuration_from_json() -> None:
|
|||||||
configuration.configurations.apt.sources[0].signed_by
|
configuration.configurations.apt.sources[0].signed_by
|
||||||
== "/etc/apt/keyrings/interdiode.asc"
|
== "/etc/apt/keyrings/interdiode.asc"
|
||||||
)
|
)
|
||||||
assert configuration.configurations.apt.keys[0].name == "interdiode"
|
|
||||||
assert configuration.configurations.yum.keys[0].name == "interdiode"
|
assert configuration.configurations.yum.keys[0].name == "interdiode"
|
||||||
|
|
||||||
|
|
||||||
@@ -80,10 +85,38 @@ def test_parse_configuration_from_yaml() -> None:
|
|||||||
"""
|
"""
|
||||||
configuration = parse_configuration(raw)
|
configuration = parse_configuration(raw)
|
||||||
assert configuration.organisation == "InterDiode"
|
assert configuration.organisation == "InterDiode"
|
||||||
assert configuration.description == ""
|
assert configuration.description is None
|
||||||
assert configuration.configurations.certificates == []
|
assert configuration.configurations.certificates == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_configuration_without_uuid_or_organisation() -> None:
|
||||||
|
configuration = parse_configuration(json.dumps({}))
|
||||||
|
assert configuration.uuid is None
|
||||||
|
assert configuration.organisation is None
|
||||||
|
assert configuration.version == "1.0"
|
||||||
|
assert configuration.description is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_configuration_defaults_version_to_1_0() -> None:
|
||||||
|
configuration = parse_configuration(json.dumps(FULL_CONFIG))
|
||||||
|
assert configuration.version == "1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_configuration_with_explicit_version() -> None:
|
||||||
|
configuration = parse_configuration(json.dumps({**FULL_CONFIG, "version": "1.0"}))
|
||||||
|
assert configuration.version == "1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_configuration_coerces_unquoted_yaml_version_to_str() -> None:
|
||||||
|
raw = """
|
||||||
|
version: 1.0
|
||||||
|
uuid: 12345678-1234-5678-1234-567812345678
|
||||||
|
organisation: InterDiode
|
||||||
|
"""
|
||||||
|
configuration = parse_configuration(raw)
|
||||||
|
assert configuration.version == "1.0"
|
||||||
|
|
||||||
|
|
||||||
def test_load_configuration_from_file(tmp_path) -> None:
|
def test_load_configuration_from_file(tmp_path) -> None:
|
||||||
config_file = tmp_path / "config.json"
|
config_file = tmp_path / "config.json"
|
||||||
config_file.write_text(json.dumps(FULL_CONFIG), encoding="utf-8")
|
config_file.write_text(json.dumps(FULL_CONFIG), encoding="utf-8")
|
||||||
@@ -98,10 +131,107 @@ def test_load_configuration_from_env() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_load_configuration_without_source_raises() -> None:
|
def test_load_configuration_without_source_raises() -> None:
|
||||||
with pytest.raises(ValueError, match="either --config or --config-env"):
|
with pytest.raises(
|
||||||
|
ValueError, match="either --config, --config-env or --config-url"
|
||||||
|
):
|
||||||
load_configuration(None, None)
|
load_configuration(None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_urlopen(content: bytes) -> MagicMock:
|
||||||
|
response = MagicMock()
|
||||||
|
response.read.return_value = content
|
||||||
|
response.__enter__.return_value = response
|
||||||
|
response.__exit__.return_value = False
|
||||||
|
return MagicMock(return_value=response)
|
||||||
|
|
||||||
|
|
||||||
|
def _self_signed_ca_pem() -> str:
|
||||||
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
subject = issuer = x509.Name(
|
||||||
|
[x509.NameAttribute(NameOID.COMMON_NAME, "test-config-url-ca")]
|
||||||
|
)
|
||||||
|
now = datetime.datetime.now(datetime.UTC)
|
||||||
|
cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(subject)
|
||||||
|
.issuer_name(issuer)
|
||||||
|
.public_key(key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now)
|
||||||
|
.not_valid_after(now + datetime.timedelta(days=1))
|
||||||
|
.sign(key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
return cert.public_bytes(Encoding.PEM).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_configuration_from_url(monkeypatch) -> None:
|
||||||
|
mock_urlopen = _mock_urlopen(json.dumps(FULL_CONFIG).encode("utf-8"))
|
||||||
|
monkeypatch.setattr("id_configurator.config.urllib.request.urlopen", mock_urlopen)
|
||||||
|
|
||||||
|
configuration = load_configuration(
|
||||||
|
None, None, "https://example.invalid/config.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert configuration.organisation == "InterDiode"
|
||||||
|
args, kwargs = mock_urlopen.call_args
|
||||||
|
assert args[0] == "https://example.invalid/config.json"
|
||||||
|
assert kwargs["timeout"] == 30
|
||||||
|
assert kwargs["context"].verify_mode.name == "CERT_REQUIRED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_configuration_from_url_with_custom_cacert(monkeypatch) -> None:
|
||||||
|
mock_urlopen = _mock_urlopen(json.dumps(FULL_CONFIG).encode("utf-8"))
|
||||||
|
monkeypatch.setattr("id_configurator.config.urllib.request.urlopen", mock_urlopen)
|
||||||
|
cacert_content = _self_signed_ca_pem()
|
||||||
|
|
||||||
|
configuration = load_configuration(
|
||||||
|
None, None, "https://example.invalid/config.json", cacert_content
|
||||||
|
)
|
||||||
|
|
||||||
|
assert configuration.organisation == "InterDiode"
|
||||||
|
_, kwargs = mock_urlopen.call_args
|
||||||
|
stats = kwargs["context"].cert_store_stats()
|
||||||
|
assert stats["x509"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_configuration_from_url_insecure_disables_verification(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
mock_urlopen = _mock_urlopen(json.dumps(FULL_CONFIG).encode("utf-8"))
|
||||||
|
monkeypatch.setattr("id_configurator.config.urllib.request.urlopen", mock_urlopen)
|
||||||
|
|
||||||
|
configuration = load_configuration(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"https://example.invalid/config.json",
|
||||||
|
None,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert configuration.organisation == "InterDiode"
|
||||||
|
_, kwargs = mock_urlopen.call_args
|
||||||
|
assert kwargs["context"].verify_mode.name == "CERT_NONE"
|
||||||
|
assert kwargs["context"].check_hostname is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_configuration_from_url_insecure_ignores_cacert(monkeypatch) -> None:
|
||||||
|
mock_urlopen = _mock_urlopen(json.dumps(FULL_CONFIG).encode("utf-8"))
|
||||||
|
monkeypatch.setattr("id_configurator.config.urllib.request.urlopen", mock_urlopen)
|
||||||
|
cacert_content = _self_signed_ca_pem()
|
||||||
|
|
||||||
|
configuration = load_configuration(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"https://example.invalid/config.json",
|
||||||
|
cacert_content,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert configuration.organisation == "InterDiode"
|
||||||
|
_, kwargs = mock_urlopen.call_args
|
||||||
|
assert kwargs["context"].verify_mode.name == "CERT_NONE"
|
||||||
|
|
||||||
|
|
||||||
def test_apt_source_to_deb822_minimal() -> None:
|
def test_apt_source_to_deb822_minimal() -> None:
|
||||||
source = AptSource(
|
source = AptSource(
|
||||||
name="interdiode", uris=["http://example.invalid/debian"], suites=["stable"]
|
name="interdiode", uris=["http://example.invalid/debian"], suites=["stable"]
|
||||||
@@ -125,7 +255,6 @@ def test_apt_source_to_deb822_full() -> None:
|
|||||||
enabled=False,
|
enabled=False,
|
||||||
trusted=True,
|
trusted=True,
|
||||||
**{
|
**{
|
||||||
"signed-by": "/etc/apt/keyrings/interdiode.asc",
|
|
||||||
"by-hash": True,
|
"by-hash": True,
|
||||||
"allow-insecure": False,
|
"allow-insecure": False,
|
||||||
"allow-weak": False,
|
"allow-weak": False,
|
||||||
@@ -139,7 +268,7 @@ def test_apt_source_to_deb822_full() -> None:
|
|||||||
pdiffs=True,
|
pdiffs=True,
|
||||||
description="InterDiode APT repository",
|
description="InterDiode APT repository",
|
||||||
)
|
)
|
||||||
rendered = source.to_deb822()
|
rendered = source.to_deb822(signed_by_path="/etc/apt/keyrings/interdiode.asc")
|
||||||
assert rendered.splitlines() == [
|
assert rendered.splitlines() == [
|
||||||
"Types: deb deb-src",
|
"Types: deb deb-src",
|
||||||
"URIs: http://example.invalid/debian",
|
"URIs: http://example.invalid/debian",
|
||||||
@@ -163,3 +292,39 @@ def test_apt_source_to_deb822_full() -> None:
|
|||||||
"Description: InterDiode APT repository",
|
"Description: InterDiode APT repository",
|
||||||
"Enabled: no",
|
"Enabled: no",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apt_source_to_deb822_folds_inline_signed_by_key() -> None:
|
||||||
|
source = AptSource(
|
||||||
|
name="interdiode", uris=["http://example.invalid/debian"], suites=["stable"]
|
||||||
|
)
|
||||||
|
armored_key = (
|
||||||
|
"-----BEGIN PGP PUBLIC KEY BLOCK-----\n"
|
||||||
|
"\n"
|
||||||
|
"mQENBGqh08IBCAC0\n"
|
||||||
|
"-----END PGP PUBLIC KEY BLOCK-----\n"
|
||||||
|
)
|
||||||
|
rendered = source.to_deb822(signed_by_armored_key=armored_key)
|
||||||
|
assert rendered == (
|
||||||
|
"Types: deb\n"
|
||||||
|
"URIs: http://example.invalid/debian\n"
|
||||||
|
"Suites: stable\n"
|
||||||
|
"Signed-By:\n"
|
||||||
|
" -----BEGIN PGP PUBLIC KEY BLOCK-----\n"
|
||||||
|
" .\n"
|
||||||
|
" mQENBGqh08IBCAC0\n"
|
||||||
|
" -----END PGP PUBLIC KEY BLOCK-----\n"
|
||||||
|
"Enabled: yes\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apt_source_to_deb822_signed_by_armored_key_takes_precedence() -> None:
|
||||||
|
source = AptSource(
|
||||||
|
name="interdiode", uris=["http://example.invalid/debian"], suites=["stable"]
|
||||||
|
)
|
||||||
|
rendered = source.to_deb822(
|
||||||
|
signed_by_armored_key="-----BEGIN PGP PUBLIC KEY BLOCK-----\nabc\n-----END PGP PUBLIC KEY BLOCK-----\n",
|
||||||
|
signed_by_path="/etc/apt/keyrings/unused.gpg",
|
||||||
|
)
|
||||||
|
assert "Signed-By:\n" in rendered
|
||||||
|
assert "unused.gpg" not in rendered
|
||||||
|
|||||||
+132
-2
@@ -8,14 +8,16 @@ CONFIG = Configuration.model_validate(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def make_context(tmp_path, with_src: bool = True) -> Context:
|
def make_context(
|
||||||
|
tmp_path, with_src: bool = True, users=None, auto_users: bool = False
|
||||||
|
) -> Context:
|
||||||
dst = tmp_path / "dst"
|
dst = tmp_path / "dst"
|
||||||
dst.mkdir()
|
dst.mkdir()
|
||||||
src = None
|
src = None
|
||||||
if with_src:
|
if with_src:
|
||||||
src = tmp_path / "src"
|
src = tmp_path / "src"
|
||||||
src.mkdir()
|
src.mkdir()
|
||||||
return Context(config=CONFIG, dst=dst, src=src)
|
return Context(config=CONFIG, dst=dst, src=src, users=users, auto_users=auto_users)
|
||||||
|
|
||||||
|
|
||||||
def test_dst_path_within_destination(tmp_path) -> None:
|
def test_dst_path_within_destination(tmp_path) -> None:
|
||||||
@@ -97,3 +99,131 @@ def test_glob_src_matches_pattern(tmp_path) -> None:
|
|||||||
(context.src / "usr/lib/python-legacy").mkdir(parents=True)
|
(context.src / "usr/lib/python-legacy").mkdir(parents=True)
|
||||||
result = context.glob_src("usr/lib/python3.*")
|
result = context.glob_src("usr/lib/python3.*")
|
||||||
assert [entry.name for entry in result] == ["python3.11", "python3.12"]
|
assert [entry.name for entry in result] == ["python3.11", "python3.12"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_users_defaults_to_empty_list(tmp_path) -> None:
|
||||||
|
context = make_context(tmp_path, with_src=False)
|
||||||
|
assert context.users == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_users_keeps_explicit_names_in_order_without_duplicates(tmp_path) -> None:
|
||||||
|
context = make_context(tmp_path, with_src=False, users=["alice", "bob", "alice"])
|
||||||
|
assert context.users == ["alice", "bob"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_users_expands_from_passwd_excluding_disabled_shells(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
dst.mkdir()
|
||||||
|
src = tmp_path / "src"
|
||||||
|
(src / "etc").mkdir(parents=True)
|
||||||
|
(src / "etc/passwd").write_text(
|
||||||
|
"root:x:0:0:root:/root:/bin/bash\n"
|
||||||
|
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n"
|
||||||
|
"daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
context = Context(config=CONFIG, dst=dst, src=src, auto_users=True)
|
||||||
|
# root is always included despite its uid 0; daemon (nologin shell) is
|
||||||
|
# excluded as a system account.
|
||||||
|
assert context.users == ["root", "alice"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_users_excludes_root_with_disabled_shell(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
dst.mkdir()
|
||||||
|
src = tmp_path / "src"
|
||||||
|
(src / "etc").mkdir(parents=True)
|
||||||
|
(src / "etc/passwd").write_text(
|
||||||
|
"root:x:0:0:root:/root:/usr/sbin/nologin\n"
|
||||||
|
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
context = Context(config=CONFIG, dst=dst, src=src, auto_users=True)
|
||||||
|
assert context.users == ["alice"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_users_excludes_uid_below_default_uid_min(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
dst.mkdir()
|
||||||
|
src = tmp_path / "src"
|
||||||
|
(src / "etc").mkdir(parents=True)
|
||||||
|
(src / "etc/passwd").write_text(
|
||||||
|
"svc:x:999:999:service account:/home/svc:/bin/bash\n"
|
||||||
|
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
context = Context(config=CONFIG, dst=dst, src=src, auto_users=True)
|
||||||
|
assert context.users == ["alice"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_users_honors_uid_min_from_login_defs(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
dst.mkdir()
|
||||||
|
src = tmp_path / "src"
|
||||||
|
(src / "etc").mkdir(parents=True)
|
||||||
|
(src / "etc/passwd").write_text(
|
||||||
|
"svc:x:999:999:service account:/home/svc:/bin/bash\n"
|
||||||
|
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(src / "etc/login.defs").write_text("UID_MIN\t500\n", encoding="utf-8")
|
||||||
|
context = Context(config=CONFIG, dst=dst, src=src, auto_users=True)
|
||||||
|
assert context.users == ["svc", "alice"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_users_combines_with_explicit_names_without_duplicates(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
dst.mkdir()
|
||||||
|
src = tmp_path / "src"
|
||||||
|
src.mkdir()
|
||||||
|
(src / "etc").mkdir()
|
||||||
|
(src / "etc/passwd").write_text(
|
||||||
|
"root:x:0:0:root:/root:/bin/bash\nalice:x:1000:1000:Alice:/home/alice:/bin/bash\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
context = Context(
|
||||||
|
config=CONFIG, dst=dst, src=src, users=["bob", "alice"], auto_users=True
|
||||||
|
)
|
||||||
|
# explicit names come first, in order; auto-detected names are appended,
|
||||||
|
# skipping "alice" since it's already present.
|
||||||
|
assert context.users == ["bob", "alice", "root"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_users_false_ignores_passwd(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
dst.mkdir()
|
||||||
|
src = tmp_path / "src"
|
||||||
|
(src / "etc").mkdir(parents=True)
|
||||||
|
(src / "etc/passwd").write_text(
|
||||||
|
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
context = Context(config=CONFIG, dst=dst, src=src)
|
||||||
|
assert context.users == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_users_without_src_is_empty(tmp_path) -> None:
|
||||||
|
context = make_context(tmp_path, with_src=False, auto_users=True)
|
||||||
|
assert context.users == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_dir_for_root(tmp_path) -> None:
|
||||||
|
context = make_context(tmp_path, with_src=False)
|
||||||
|
assert context.home_dir("root") == "root"
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_dir_falls_back_to_convention(tmp_path) -> None:
|
||||||
|
context = make_context(tmp_path, with_src=False)
|
||||||
|
assert context.home_dir("alice") == "home/alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_dir_uses_passwd_entry_when_available(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
dst.mkdir()
|
||||||
|
src = tmp_path / "src"
|
||||||
|
src.mkdir()
|
||||||
|
(src / "etc").mkdir()
|
||||||
|
(src / "etc/passwd").write_text(
|
||||||
|
"alice:x:1000:1000:Alice:/srv/alice:/bin/bash\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
context = Context(config=CONFIG, dst=dst, src=src)
|
||||||
|
assert context.home_dir("alice") == "srv/alice"
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
from id_configurator.config import Configuration
|
||||||
|
from id_configurator.context import Context
|
||||||
|
from id_configurator.modules.go import GoModule
|
||||||
|
|
||||||
|
CONFIG_WITH_REPO = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"go": {"repositories": [{"name": "demo", "url": "https://example.invalid/"}]}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
CONFIG_WITH_TWO_REPOS = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"go": {
|
||||||
|
"repositories": [
|
||||||
|
{"name": "demo", "url": "https://example.invalid/"},
|
||||||
|
{"name": "other", "url": "https://other.invalid/"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
EMPTY_CONFIG = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_config(data: dict) -> Configuration:
|
||||||
|
return Configuration.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_relevant_false_when_no_repositories(tmp_path) -> None:
|
||||||
|
context = Context(config=make_config(EMPTY_CONFIG), dst=tmp_path / "dst", src=None)
|
||||||
|
assert GoModule().is_relevant(context) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_relevant_true_with_repository(tmp_path) -> None:
|
||||||
|
context = Context(
|
||||||
|
config=make_config(CONFIG_WITH_REPO), dst=tmp_path / "dst", src=None
|
||||||
|
)
|
||||||
|
assert GoModule().is_relevant(context) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_writes_goproxy(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None)
|
||||||
|
|
||||||
|
GoModule().apply(context)
|
||||||
|
|
||||||
|
go_env = (dst / "usr/local/go/go.env").read_text(encoding="utf-8")
|
||||||
|
assert go_env == "GOPROXY=https://example.invalid/\nGOSUMDB=off\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_writes_go_env_for_each_configured_user(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(
|
||||||
|
config=make_config(CONFIG_WITH_REPO), dst=dst, src=None, users=["alice"]
|
||||||
|
)
|
||||||
|
|
||||||
|
GoModule().apply(context)
|
||||||
|
|
||||||
|
go_env = (dst / "home/alice/.config/go/env").read_text(encoding="utf-8")
|
||||||
|
assert go_env == "GOPROXY=https://example.invalid/\nGOSUMDB=off\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_joins_multiple_repositories_with_comma(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None)
|
||||||
|
|
||||||
|
GoModule().apply(context)
|
||||||
|
|
||||||
|
go_env = (dst / "usr/local/go/go.env").read_text(encoding="utf-8")
|
||||||
|
assert go_env == (
|
||||||
|
"GOPROXY=https://example.invalid/,https://other.invalid/\nGOSUMDB=off\n"
|
||||||
|
)
|
||||||
@@ -339,6 +339,31 @@ def test_apply_writes_sbt_repositories(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_marks_non_https_sbt_repository_as_insecure(tmp_path) -> None:
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"java": {
|
||||||
|
"repositories": [
|
||||||
|
{"name": "interdiode", "url": "http://example.invalid/maven"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(data), dst=dst, src=None)
|
||||||
|
|
||||||
|
JavaModule().apply(context)
|
||||||
|
|
||||||
|
repositories = (dst / "root/.sbt/repositories").read_text(encoding="utf-8")
|
||||||
|
assert repositories == (
|
||||||
|
"[repositories]\n"
|
||||||
|
" local\n"
|
||||||
|
" interdiode: http://example.invalid/maven, allowInsecureProtocol\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_apply_writes_lein_profiles(tmp_path) -> None:
|
def test_apply_writes_lein_profiles(tmp_path) -> None:
|
||||||
dst = tmp_path / "dst"
|
dst = tmp_path / "dst"
|
||||||
context = Context(config=make_config(TWO_REPOS_CONFIG), dst=dst, src=None)
|
context = Context(config=make_config(TWO_REPOS_CONFIG), dst=dst, src=None)
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
from id_configurator.modules import base as base_module
|
from id_configurator.modules import base as base_module
|
||||||
from id_configurator.modules.base import Module, all_modules, register
|
from id_configurator.modules.base import (
|
||||||
|
Module,
|
||||||
|
all_modules,
|
||||||
|
register,
|
||||||
|
sanitize_filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_register_and_all_modules_are_isolated(monkeypatch) -> None:
|
def test_register_and_all_modules_are_isolated(monkeypatch) -> None:
|
||||||
@@ -17,3 +22,32 @@ def test_register_and_all_modules_are_isolated(monkeypatch) -> None:
|
|||||||
register(DummyModule)
|
register(DummyModule)
|
||||||
|
|
||||||
assert all_modules() == [DummyModule]
|
assert all_modules() == [DummyModule]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_filename_leaves_plain_names_untouched() -> None:
|
||||||
|
assert sanitize_filename("interdiode") == "interdiode"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_filename_replaces_forward_slash() -> None:
|
||||||
|
assert sanitize_filename("../../etc/passwd") == "______etc_passwd"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_filename_replaces_backslash() -> None:
|
||||||
|
assert sanitize_filename(r"a\b") == "a_b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_filename_replaces_non_identifier_characters() -> None:
|
||||||
|
assert sanitize_filename("My Root CA (v2).pem\n") == "My_Root_CA__v2__pem_"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_filename_keeps_letters_digits_underscore_and_dash() -> None:
|
||||||
|
assert sanitize_filename("Inter_Diode-2026") == "Inter_Diode-2026"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_filename_never_returns_dot_names() -> None:
|
||||||
|
assert sanitize_filename(".") == "_"
|
||||||
|
assert sanitize_filename("..") == "__"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_filename_never_returns_empty_string() -> None:
|
||||||
|
assert sanitize_filename("") == "_"
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ EMPTY_CONFIG = {
|
|||||||
"organisation": "InterDiode",
|
"organisation": "InterDiode",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CONFIG_WITH_CERTIFICATE_ONLY = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"certificates": [{"name": "interdiode-ca", "content": "-----BEGIN...-----"}]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def make_config(data: dict) -> Configuration:
|
def make_config(data: dict) -> Configuration:
|
||||||
return Configuration.model_validate(data)
|
return Configuration.model_validate(data)
|
||||||
@@ -38,6 +46,13 @@ def test_is_relevant_false_when_no_repositories(tmp_path) -> None:
|
|||||||
assert NpmModule().is_relevant(context) is False
|
assert NpmModule().is_relevant(context) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_relevant_true_with_certificate_only(tmp_path) -> None:
|
||||||
|
context = Context(
|
||||||
|
config=make_config(CONFIG_WITH_CERTIFICATE_ONLY), dst=tmp_path / "dst", src=None
|
||||||
|
)
|
||||||
|
assert NpmModule().is_relevant(context) is True
|
||||||
|
|
||||||
|
|
||||||
def test_is_relevant_true_with_repository(tmp_path) -> None:
|
def test_is_relevant_true_with_repository(tmp_path) -> None:
|
||||||
context = Context(
|
context = Context(
|
||||||
config=make_config(CONFIG_WITH_REPO), dst=tmp_path / "dst", src=None
|
config=make_config(CONFIG_WITH_REPO), dst=tmp_path / "dst", src=None
|
||||||
@@ -69,3 +84,50 @@ def test_apply_writes_additional_repositories_scoped(tmp_path) -> None:
|
|||||||
"registry=https://example.invalid/\n"
|
"registry=https://example.invalid/\n"
|
||||||
"@myorg:registry=https://other.invalid/\n"
|
"@myorg:registry=https://other.invalid/\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_without_certificates_does_not_write_etc_environment(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None)
|
||||||
|
|
||||||
|
NpmModule().apply(context)
|
||||||
|
|
||||||
|
assert not (dst / "etc/environment").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_sets_node_extra_ca_certs_when_certificates_configured(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(
|
||||||
|
config=make_config(CONFIG_WITH_CERTIFICATE_ONLY), dst=dst, src=None
|
||||||
|
)
|
||||||
|
|
||||||
|
NpmModule().apply(context)
|
||||||
|
|
||||||
|
assert not (dst / "usr/local/etc/npmrc").exists()
|
||||||
|
environment = (dst / "etc/environment").read_text(encoding="utf-8")
|
||||||
|
assert environment == "NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_merges_node_extra_ca_certs_into_existing_etc_environment(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
src.mkdir()
|
||||||
|
(src / "etc").mkdir()
|
||||||
|
(src / "etc/environment").write_text(
|
||||||
|
"PATH=/usr/bin\nNODE_EXTRA_CA_CERTS=/old/stale.pem\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(
|
||||||
|
config=make_config(CONFIG_WITH_CERTIFICATE_ONLY), dst=dst, src=src
|
||||||
|
)
|
||||||
|
|
||||||
|
NpmModule().apply(context)
|
||||||
|
|
||||||
|
environment = (dst / "etc/environment").read_text(encoding="utf-8")
|
||||||
|
assert environment == (
|
||||||
|
"PATH=/usr/bin\nNODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt\n"
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
from id_configurator.config import Configuration
|
||||||
|
from id_configurator.context import Context
|
||||||
|
from id_configurator.modules.nuget import NuGetModule
|
||||||
|
|
||||||
|
CONFIG_WITH_REPO = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"nuget": {"repositories": [{"name": "demo", "url": "https://example.invalid/"}]}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
CONFIG_WITH_TWO_REPOS = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"nuget": {
|
||||||
|
"repositories": [
|
||||||
|
{"name": "demo", "url": "https://example.invalid/"},
|
||||||
|
{"name": "other", "url": "https://other.invalid/"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
EMPTY_CONFIG = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_config(data: dict) -> Configuration:
|
||||||
|
return Configuration.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_relevant_false_when_no_repositories(tmp_path) -> None:
|
||||||
|
context = Context(config=make_config(EMPTY_CONFIG), dst=tmp_path / "dst", src=None)
|
||||||
|
assert NuGetModule().is_relevant(context) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_relevant_true_with_repository(tmp_path) -> None:
|
||||||
|
context = Context(
|
||||||
|
config=make_config(CONFIG_WITH_REPO), dst=tmp_path / "dst", src=None
|
||||||
|
)
|
||||||
|
assert NuGetModule().is_relevant(context) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_writes_nuget_config(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None)
|
||||||
|
|
||||||
|
NuGetModule().apply(context)
|
||||||
|
|
||||||
|
content = (dst / "root/.nuget/NuGet/NuGet.Config").read_text(encoding="utf-8")
|
||||||
|
assert content == (
|
||||||
|
'<?xml version="1.0" encoding="utf-8"?>\n'
|
||||||
|
"<configuration>\n"
|
||||||
|
" <packageSources>\n"
|
||||||
|
" <clear />\n"
|
||||||
|
' <add key="demo" value="https://example.invalid/" />\n'
|
||||||
|
" </packageSources>\n"
|
||||||
|
"</configuration>\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_marks_non_https_source_as_insecure(tmp_path) -> None:
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"nuget": {
|
||||||
|
"repositories": [{"name": "demo", "url": "http://example.invalid/"}]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(data), dst=dst, src=None)
|
||||||
|
|
||||||
|
NuGetModule().apply(context)
|
||||||
|
|
||||||
|
content = (dst / "root/.nuget/NuGet/NuGet.Config").read_text(encoding="utf-8")
|
||||||
|
assert content == (
|
||||||
|
'<?xml version="1.0" encoding="utf-8"?>\n'
|
||||||
|
"<configuration>\n"
|
||||||
|
" <packageSources>\n"
|
||||||
|
" <clear />\n"
|
||||||
|
' <add key="demo" value="http://example.invalid/" allowInsecureConnections="true" />\n'
|
||||||
|
" </packageSources>\n"
|
||||||
|
"</configuration>\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_writes_config_for_each_configured_user(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(
|
||||||
|
config=make_config(CONFIG_WITH_REPO), dst=dst, src=None, users=["alice", "root"]
|
||||||
|
)
|
||||||
|
|
||||||
|
NuGetModule().apply(context)
|
||||||
|
|
||||||
|
root_content = (dst / "root/.nuget/NuGet/NuGet.Config").read_text(encoding="utf-8")
|
||||||
|
alice_content = (dst / "home/alice/.nuget/NuGet/NuGet.Config").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert alice_content == root_content
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_writes_multiple_sources_in_order(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None)
|
||||||
|
|
||||||
|
NuGetModule().apply(context)
|
||||||
|
|
||||||
|
content = (dst / "root/.nuget/NuGet/NuGet.Config").read_text(encoding="utf-8")
|
||||||
|
assert content == (
|
||||||
|
'<?xml version="1.0" encoding="utf-8"?>\n'
|
||||||
|
"<configuration>\n"
|
||||||
|
" <packageSources>\n"
|
||||||
|
" <clear />\n"
|
||||||
|
' <add key="demo" value="https://example.invalid/" />\n'
|
||||||
|
' <add key="other" value="https://other.invalid/" />\n'
|
||||||
|
" </packageSources>\n"
|
||||||
|
"</configuration>\n"
|
||||||
|
)
|
||||||
+105
-2
@@ -34,6 +34,14 @@ EMPTY_CONFIG = {
|
|||||||
"organisation": "InterDiode",
|
"organisation": "InterDiode",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CONFIG_WITH_CERTIFICATE_ONLY = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"certificates": [{"name": "interdiode-ca", "content": "-----BEGIN...-----"}]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def make_config(data: dict) -> Configuration:
|
def make_config(data: dict) -> Configuration:
|
||||||
return Configuration.model_validate(data)
|
return Configuration.model_validate(data)
|
||||||
@@ -44,6 +52,13 @@ def test_is_relevant_false_when_no_repositories(tmp_path) -> None:
|
|||||||
assert PhpModule().is_relevant(context) is False
|
assert PhpModule().is_relevant(context) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_relevant_true_with_certificate_only(tmp_path) -> None:
|
||||||
|
context = Context(
|
||||||
|
config=make_config(CONFIG_WITH_CERTIFICATE_ONLY), dst=tmp_path / "dst", src=None
|
||||||
|
)
|
||||||
|
assert PhpModule().is_relevant(context) is True
|
||||||
|
|
||||||
|
|
||||||
def test_is_relevant_true_with_repository(tmp_path) -> None:
|
def test_is_relevant_true_with_repository(tmp_path) -> None:
|
||||||
context = Context(
|
context = Context(
|
||||||
config=make_config(CONFIG_WITH_REPO), dst=tmp_path / "dst", src=None
|
config=make_config(CONFIG_WITH_REPO), dst=tmp_path / "dst", src=None
|
||||||
@@ -65,7 +80,8 @@ def test_apply_writes_global_composer_config(tmp_path) -> None:
|
|||||||
"name": "demo",
|
"name": "demo",
|
||||||
"type": "composer",
|
"type": "composer",
|
||||||
"url": "https://example.invalid/composer",
|
"url": "https://example.invalid/composer",
|
||||||
}
|
},
|
||||||
|
{"packagist.org": False},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,4 +94,91 @@ def test_apply_writes_multiple_repositories_in_order(tmp_path) -> None:
|
|||||||
|
|
||||||
config_path = dst / "root/.composer/config.json"
|
config_path = dst / "root/.composer/config.json"
|
||||||
content = json.loads(config_path.read_text(encoding="utf-8"))
|
content = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
assert [repo["name"] for repo in content["repositories"]] == ["demo", "other"]
|
names = [repo["name"] for repo in content["repositories"] if "name" in repo]
|
||||||
|
assert names == ["demo", "other"]
|
||||||
|
assert {"packagist.org": False} in content["repositories"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_disables_secure_http_and_packagist_when_repo_not_https(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"php": {
|
||||||
|
"repositories": [
|
||||||
|
{"name": "demo", "url": "https://example.invalid/composer"},
|
||||||
|
{"name": "insecure", "url": "http://other.invalid/composer"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(data), dst=dst, src=None)
|
||||||
|
|
||||||
|
PhpModule().apply(context)
|
||||||
|
|
||||||
|
config_path = dst / "root/.composer/config.json"
|
||||||
|
content = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
assert content["config"] == {"secure-http": False}
|
||||||
|
assert {"packagist.org": False} in content["repositories"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_does_not_disable_secure_http_when_all_repos_https(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None)
|
||||||
|
|
||||||
|
PhpModule().apply(context)
|
||||||
|
|
||||||
|
config_path = dst / "root/.composer/config.json"
|
||||||
|
content = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
assert "config" not in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_always_disables_packagist(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None)
|
||||||
|
|
||||||
|
PhpModule().apply(context)
|
||||||
|
|
||||||
|
config_path = dst / "root/.composer/config.json"
|
||||||
|
content = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
assert {"packagist.org": False} in content["repositories"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_writes_composer_config_for_each_configured_user(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(
|
||||||
|
config=make_config(CONFIG_WITH_REPO), dst=dst, src=None, users=["alice"]
|
||||||
|
)
|
||||||
|
|
||||||
|
PhpModule().apply(context)
|
||||||
|
|
||||||
|
root_content = (dst / "root/.composer/config.json").read_bytes()
|
||||||
|
alice_content = (dst / "home/alice/.composer/config.json").read_bytes()
|
||||||
|
assert alice_content == root_content
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_without_certificates_does_not_write_openssl_ini(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None)
|
||||||
|
|
||||||
|
PhpModule().apply(context)
|
||||||
|
|
||||||
|
assert not (dst / "usr/local/etc/php/conf.d/id-configurator-openssl.ini").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_sets_openssl_cafile_when_certificates_configured(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(
|
||||||
|
config=make_config(CONFIG_WITH_CERTIFICATE_ONLY), dst=dst, src=None
|
||||||
|
)
|
||||||
|
|
||||||
|
PhpModule().apply(context)
|
||||||
|
|
||||||
|
assert not (dst / "root/.composer/config.json").exists()
|
||||||
|
ini_content = (
|
||||||
|
dst / "usr/local/etc/php/conf.d/id-configurator-openssl.ini"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
assert ini_content == "openssl.cafile=/etc/ssl/certs/ca-certificates.crt\n"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import tomllib
|
||||||
|
|
||||||
from id_configurator.config import Configuration
|
from id_configurator.config import Configuration
|
||||||
from id_configurator.context import Context
|
from id_configurator.context import Context
|
||||||
from id_configurator.modules.python import PythonModule
|
from id_configurator.modules.python import PythonModule
|
||||||
@@ -76,6 +78,111 @@ def test_apply_single_repository(tmp_path) -> None:
|
|||||||
assert not (dst / "usr/lib").exists()
|
assert not (dst / "usr/lib").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_sanitizes_uv_index_name_with_non_bmp_characters(tmp_path) -> None:
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"python": {
|
||||||
|
"repositories": [
|
||||||
|
{"name": "demo 😀", "index-url": "https://example.invalid/simple"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
config = Configuration.model_validate(data)
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=config, dst=dst, src=None)
|
||||||
|
|
||||||
|
PythonModule().apply(context)
|
||||||
|
|
||||||
|
uv_toml = (dst / "etc/uv/uv.toml").read_text(encoding="utf-8")
|
||||||
|
# The emoji isn't a plain identifier character, so sanitize_repository_name
|
||||||
|
# replaces it (and the space before it) with "_", same as every other
|
||||||
|
# non-ASCII or syntax-breaking character.
|
||||||
|
assert '"demo__"' in uv_toml
|
||||||
|
parsed = tomllib.loads(uv_toml)
|
||||||
|
assert parsed["index"][0]["name"] == "demo__"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_adds_trusted_host_for_non_https_repository(tmp_path) -> None:
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"python": {
|
||||||
|
"repositories": [
|
||||||
|
{"name": "interdiode", "index-url": "http://example.invalid/simple"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
config = Configuration.model_validate(data)
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=config, dst=dst, src=None)
|
||||||
|
|
||||||
|
PythonModule().apply(context)
|
||||||
|
|
||||||
|
pip_conf = (dst / "etc/pip.conf").read_text(encoding="utf-8")
|
||||||
|
assert pip_conf == (
|
||||||
|
"[global]\n"
|
||||||
|
"index-url = http://example.invalid/simple\n"
|
||||||
|
"trusted-host =\n"
|
||||||
|
" example.invalid\n"
|
||||||
|
"cert = /etc/ssl/certs/ca-certificates.crt\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_adds_trusted_host_only_for_non_https_among_mixed_repositories(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"python": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "interdiode",
|
||||||
|
"index-url": "https://example.invalid/simple",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "insecure",
|
||||||
|
"index-url": "http://insecure.invalid/simple",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
config = Configuration.model_validate(data)
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=config, dst=dst, src=None)
|
||||||
|
|
||||||
|
PythonModule().apply(context)
|
||||||
|
|
||||||
|
pip_conf = (dst / "etc/pip.conf").read_text(encoding="utf-8")
|
||||||
|
assert pip_conf == (
|
||||||
|
"[global]\n"
|
||||||
|
"index-url = https://example.invalid/simple\n"
|
||||||
|
"extra-index-url =\n"
|
||||||
|
" http://insecure.invalid/simple\n"
|
||||||
|
"trusted-host =\n"
|
||||||
|
" insecure.invalid\n"
|
||||||
|
"cert = /etc/ssl/certs/ca-certificates.crt\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_does_not_add_trusted_host_when_all_repositories_https(tmp_path) -> None:
|
||||||
|
config = Configuration.model_validate(ONE_REPO_CONFIG)
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=config, dst=dst, src=None)
|
||||||
|
|
||||||
|
PythonModule().apply(context)
|
||||||
|
|
||||||
|
pip_conf = (dst / "etc/pip.conf").read_text(encoding="utf-8")
|
||||||
|
assert "trusted-host" not in pip_conf
|
||||||
|
|
||||||
|
|
||||||
def test_apply_multiple_repositories(tmp_path) -> None:
|
def test_apply_multiple_repositories(tmp_path) -> None:
|
||||||
config = Configuration.model_validate(TWO_REPOS_CONFIG)
|
config = Configuration.model_validate(TWO_REPOS_CONFIG)
|
||||||
dst = tmp_path / "dst"
|
dst = tmp_path / "dst"
|
||||||
@@ -105,6 +212,57 @@ def test_apply_multiple_repositories(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_sanitizes_poetry_repository_name_with_special_characters(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"python": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "weird.name]here[bad",
|
||||||
|
"index-url": "https://example.invalid/simple",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
config = Configuration.model_validate(data)
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=config, dst=dst, src=None)
|
||||||
|
|
||||||
|
PythonModule().apply(context)
|
||||||
|
|
||||||
|
poetry_config = (dst / "root/.config/pypoetry/config.toml").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert poetry_config == (
|
||||||
|
"[repositories.weird_name_here_bad]\n"
|
||||||
|
'url = "https://example.invalid/simple"\n'
|
||||||
|
"\n"
|
||||||
|
"[certificates.weird_name_here_bad]\n"
|
||||||
|
'cert = "/etc/ssl/certs/ca-certificates.crt"\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_writes_poetry_config_for_each_configured_user(tmp_path) -> None:
|
||||||
|
config = Configuration.model_validate(ONE_REPO_CONFIG)
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=config, dst=dst, src=None, users=["alice"])
|
||||||
|
|
||||||
|
PythonModule().apply(context)
|
||||||
|
|
||||||
|
root_content = (dst / "root/.config/pypoetry/config.toml").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
alice_content = (dst / "home/alice/.config/pypoetry/config.toml").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert alice_content == root_content
|
||||||
|
|
||||||
|
|
||||||
def test_apply_writes_distutils_cfg_for_detected_python_prefixes(tmp_path) -> None:
|
def test_apply_writes_distutils_cfg_for_detected_python_prefixes(tmp_path) -> None:
|
||||||
config = Configuration.model_validate(ONE_REPO_CONFIG)
|
config = Configuration.model_validate(ONE_REPO_CONFIG)
|
||||||
src = tmp_path / "src"
|
src = tmp_path / "src"
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from id_configurator.config import Configuration
|
||||||
|
from id_configurator.context import Context
|
||||||
|
from id_configurator.modules.ruby import RubyModule
|
||||||
|
|
||||||
|
CONFIG_WITH_REPO = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"ruby": {"repositories": [{"name": "demo", "url": "https://example.invalid/"}]}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
CONFIG_WITH_TWO_REPOS = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"ruby": {
|
||||||
|
"repositories": [
|
||||||
|
{"name": "demo", "url": "https://example.invalid/"},
|
||||||
|
{"name": "other", "url": "https://other.invalid/"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
EMPTY_CONFIG = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_config(data: dict) -> Configuration:
|
||||||
|
return Configuration.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_relevant_false_when_no_repositories(tmp_path) -> None:
|
||||||
|
context = Context(config=make_config(EMPTY_CONFIG), dst=tmp_path / "dst", src=None)
|
||||||
|
assert RubyModule().is_relevant(context) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_relevant_true_with_repository(tmp_path) -> None:
|
||||||
|
context = Context(
|
||||||
|
config=make_config(CONFIG_WITH_REPO), dst=tmp_path / "dst", src=None
|
||||||
|
)
|
||||||
|
assert RubyModule().is_relevant(context) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_writes_gemrc(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None)
|
||||||
|
|
||||||
|
RubyModule().apply(context)
|
||||||
|
|
||||||
|
expected = ":sources:\n- https://example.invalid/\n"
|
||||||
|
assert (dst / "usr/local/etc/gemrc").read_text(encoding="utf-8") == expected
|
||||||
|
assert (dst / "etc/gemrc").read_text(encoding="utf-8") == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_writes_multiple_sources_in_order(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None)
|
||||||
|
|
||||||
|
RubyModule().apply(context)
|
||||||
|
|
||||||
|
expected = ":sources:\n- https://example.invalid/\n- https://other.invalid/\n"
|
||||||
|
assert (dst / "usr/local/etc/gemrc").read_text(encoding="utf-8") == expected
|
||||||
|
assert (dst / "etc/gemrc").read_text(encoding="utf-8") == expected
|
||||||
+104
-3
@@ -6,7 +6,15 @@ CONFIG_WITH_REPO = {
|
|||||||
"uuid": "12345678-1234-5678-1234-567812345678",
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
"organisation": "InterDiode",
|
"organisation": "InterDiode",
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"rust": {"repositories": [{"name": "demo", "url": "https://example.invalid/"}]}
|
"rust": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "demo",
|
||||||
|
"url": "https://example.invalid/",
|
||||||
|
"is_sparse": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,8 +24,16 @@ CONFIG_WITH_TWO_REPOS = {
|
|||||||
"configurations": {
|
"configurations": {
|
||||||
"rust": {
|
"rust": {
|
||||||
"repositories": [
|
"repositories": [
|
||||||
{"name": "demo", "url": "https://example.invalid/"},
|
{
|
||||||
{"name": "other", "url": "https://other.invalid/"},
|
"name": "demo",
|
||||||
|
"url": "https://example.invalid/",
|
||||||
|
"is_sparse": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "other",
|
||||||
|
"url": "https://other.invalid/",
|
||||||
|
"is_sparse": True,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -61,6 +77,91 @@ def test_apply_writes_replacement_source(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_sanitizes_repository_names_with_special_characters(tmp_path) -> None:
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"rust": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "weird.demo]\nname",
|
||||||
|
"url": "https://example.invalid/",
|
||||||
|
"is_sparse": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(data), dst=dst, src=None)
|
||||||
|
|
||||||
|
RustModule().apply(context)
|
||||||
|
|
||||||
|
config_toml = (dst / ".cargo/config.toml").read_text(encoding="utf-8")
|
||||||
|
assert config_toml == (
|
||||||
|
"[source.crates-io]\n"
|
||||||
|
'replace-with = "weird_demo__name"\n'
|
||||||
|
"\n"
|
||||||
|
"[source.weird_demo__name]\n"
|
||||||
|
'registry = "sparse+https://example.invalid/"\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_does_not_prefix_url_when_not_sparse(tmp_path) -> None:
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"rust": {
|
||||||
|
"repositories": [{"name": "demo", "url": "https://example.invalid/"}]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(data), dst=dst, src=None)
|
||||||
|
|
||||||
|
RustModule().apply(context)
|
||||||
|
|
||||||
|
config_toml = (dst / ".cargo/config.toml").read_text(encoding="utf-8")
|
||||||
|
assert config_toml == (
|
||||||
|
"[source.crates-io]\n"
|
||||||
|
'replace-with = "demo"\n'
|
||||||
|
"\n"
|
||||||
|
"[source.demo]\n"
|
||||||
|
'registry = "https://example.invalid/"\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_prefixes_url_with_sparse_when_configured(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None)
|
||||||
|
|
||||||
|
RustModule().apply(context)
|
||||||
|
|
||||||
|
config_toml = (dst / ".cargo/config.toml").read_text(encoding="utf-8")
|
||||||
|
assert config_toml == (
|
||||||
|
"[source.crates-io]\n"
|
||||||
|
'replace-with = "demo"\n'
|
||||||
|
"\n"
|
||||||
|
"[source.demo]\n"
|
||||||
|
'registry = "sparse+https://example.invalid/"\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_writes_cargo_config_for_each_configured_user(tmp_path) -> None:
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(
|
||||||
|
config=make_config(CONFIG_WITH_REPO), dst=dst, src=None, users=["alice"]
|
||||||
|
)
|
||||||
|
|
||||||
|
RustModule().apply(context)
|
||||||
|
|
||||||
|
root_content = (dst / ".cargo/config.toml").read_text(encoding="utf-8")
|
||||||
|
alice_content = (dst / "home/alice/.cargo/config.toml").read_text(encoding="utf-8")
|
||||||
|
assert alice_content == root_content
|
||||||
|
|
||||||
|
|
||||||
def test_apply_writes_additional_repositories_as_named_registries(tmp_path) -> None:
|
def test_apply_writes_additional_repositories_as_named_registries(tmp_path) -> None:
|
||||||
dst = tmp_path / "dst"
|
dst = tmp_path / "dst"
|
||||||
context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None)
|
context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None)
|
||||||
|
|||||||
@@ -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
@@ -126,7 +126,9 @@ def test_apply_unsigned_repo_disables_gpgcheck(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_apply_disables_existing_repos_before_adding_configured_ones(tmp_path) -> None:
|
def test_apply_copies_existing_repos_unchanged_without_index_url_to_replace(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
src = tmp_path / "src"
|
src = tmp_path / "src"
|
||||||
repos_dir = src / "etc/yum.repos.d"
|
repos_dir = src / "etc/yum.repos.d"
|
||||||
repos_dir.mkdir(parents=True)
|
repos_dir.mkdir(parents=True)
|
||||||
@@ -148,13 +150,13 @@ def test_apply_disables_existing_repos_before_adding_configured_ones(tmp_path) -
|
|||||||
|
|
||||||
YumModule().apply(context)
|
YumModule().apply(context)
|
||||||
|
|
||||||
disabled_multi = (dst / "etc/yum.repos.d/multi.repo").read_text(encoding="utf-8")
|
# No configured repository declares an index_url_to_replace, so the
|
||||||
assert "[old-one]" in disabled_multi
|
# existing sections are copied through unchanged, still enabled.
|
||||||
assert "[old-two]" in disabled_multi
|
copied_multi = (dst / "etc/yum.repos.d/multi.repo").read_text(encoding="utf-8")
|
||||||
parser = ConfigParser(interpolation=None)
|
parser = ConfigParser(interpolation=None)
|
||||||
parser.read_string(disabled_multi)
|
parser.read_string(copied_multi)
|
||||||
assert parser["old-one"]["enabled"] == "0"
|
assert parser["old-one"]["enabled"] == "1"
|
||||||
assert parser["old-two"]["enabled"] == "0"
|
assert parser["old-two"]["enabled"] == "1"
|
||||||
|
|
||||||
assert not (dst / "etc/yum.repos.d/ignored.txt").exists()
|
assert not (dst / "etc/yum.repos.d/ignored.txt").exists()
|
||||||
|
|
||||||
@@ -165,6 +167,203 @@ def test_apply_disables_existing_repos_before_adding_configured_ones(tmp_path) -
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_disables_only_existing_sections_matching_index_url_to_replace(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
repos_dir = src / "etc/yum.repos.d"
|
||||||
|
repos_dir.mkdir(parents=True)
|
||||||
|
(repos_dir / "multi.repo").write_text(
|
||||||
|
"[old-one]\nname=old-one\nbaseurl=https://old-one.invalid\nenabled=1\ngpgcheck=1\n"
|
||||||
|
"\n"
|
||||||
|
"[old-two]\nname=old-two\nbaseurl=https://old-two.invalid\nenabled=1\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"yum": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "unsigned",
|
||||||
|
"index-url": "https://example.invalid/yum",
|
||||||
|
"index-url-to-replace": "https://old-one.invalid",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(data), dst=dst, src=src)
|
||||||
|
|
||||||
|
YumModule().apply(context)
|
||||||
|
|
||||||
|
disabled_multi = (dst / "etc/yum.repos.d/multi.repo").read_text(encoding="utf-8")
|
||||||
|
parser = ConfigParser(interpolation=None)
|
||||||
|
parser.read_string(disabled_multi)
|
||||||
|
assert parser["old-one"]["enabled"] == "0"
|
||||||
|
assert parser["old-two"]["enabled"] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_skips_repository_when_index_url_to_replace_matches_nothing(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
repos_dir = src / "etc/yum.repos.d"
|
||||||
|
repos_dir.mkdir(parents=True)
|
||||||
|
(repos_dir / "old.repo").write_text(
|
||||||
|
"[old]\nname=old\nbaseurl=https://old.invalid\nenabled=1\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"yum": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "unsigned",
|
||||||
|
"index-url": "https://example.invalid/yum",
|
||||||
|
"index-url-to-replace": "https://other.invalid/*",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(data), dst=dst, src=src)
|
||||||
|
|
||||||
|
YumModule().apply(context)
|
||||||
|
|
||||||
|
assert not (dst / "etc/yum.repos.d/unsigned.repo").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_writes_repository_when_index_url_to_replace_matches_existing(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
repos_dir = src / "etc/yum.repos.d"
|
||||||
|
repos_dir.mkdir(parents=True)
|
||||||
|
(repos_dir / "old.repo").write_text(
|
||||||
|
"[old]\nname=old\nbaseurl=https://old.invalid/yum\nenabled=1\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"yum": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "unsigned",
|
||||||
|
"index-url": "https://example.invalid/yum",
|
||||||
|
"index-url-to-replace": "https://old.invalid/*",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(data), dst=dst, src=src)
|
||||||
|
|
||||||
|
YumModule().apply(context)
|
||||||
|
|
||||||
|
assert (dst / "etc/yum.repos.d/unsigned.repo").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_matches_index_url_to_replace_with_unrelated_files_and_multiple_repos(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
src = tmp_path / "src"
|
||||||
|
repos_dir = src / "etc/yum.repos.d"
|
||||||
|
repos_dir.mkdir(parents=True)
|
||||||
|
(repos_dir / "old.repo").write_text(
|
||||||
|
"[old]\nname=old\nbaseurl=https://old.invalid/yum\nenabled=1\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(repos_dir / "ignored.txt").write_text("not a repo file\n", encoding="utf-8")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"yum": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "first",
|
||||||
|
"index-url": "https://example.invalid/first",
|
||||||
|
"index-url-to-replace": "https://old.invalid/*",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "second",
|
||||||
|
"index-url": "https://example.invalid/second",
|
||||||
|
"index-url-to-replace": "https://old.invalid/*",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(data), dst=dst, src=src)
|
||||||
|
|
||||||
|
YumModule().apply(context)
|
||||||
|
|
||||||
|
assert (dst / "etc/yum.repos.d/first.repo").exists()
|
||||||
|
assert (dst / "etc/yum.repos.d/second.repo").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_sanitizes_key_name_with_slash(tmp_path, monkeypatch) -> None:
|
||||||
|
mock_run = _mock_gpg_success(monkeypatch)
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"yum": {"keys": [{"name": "../../evil-key", "content": KEY_CONTENT}]}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(data), dst=dst, src=None)
|
||||||
|
|
||||||
|
YumModule().apply(context)
|
||||||
|
|
||||||
|
key_content = (dst / "etc/pki/rpm-gpg/RPM-GPG-KEY-______evil-key").read_bytes()
|
||||||
|
assert key_content == b"fake-rpm-gpg-key-bytes"
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_sanitizes_repository_name_with_disallowed_characters(tmp_path) -> None:
|
||||||
|
data = {
|
||||||
|
"uuid": "12345678-1234-5678-1234-567812345678",
|
||||||
|
"organisation": "InterDiode",
|
||||||
|
"configurations": {
|
||||||
|
"yum": {
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"name": "weird/repo]here[bad",
|
||||||
|
"index-url": "https://example.invalid/yum",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dst = tmp_path / "dst"
|
||||||
|
context = Context(config=make_config(data), dst=dst, src=None)
|
||||||
|
|
||||||
|
YumModule().apply(context)
|
||||||
|
|
||||||
|
repo_file = dst / "etc/yum.repos.d/weird_repo_here_bad.repo"
|
||||||
|
assert repo_file.exists()
|
||||||
|
repo_content = repo_file.read_text(encoding="utf-8")
|
||||||
|
assert repo_content == (
|
||||||
|
"[weird_repo_here_bad]\n"
|
||||||
|
"name=weird_repo_here_bad\n"
|
||||||
|
"baseurl=https://example.invalid/yum\n"
|
||||||
|
"enabled=1\n"
|
||||||
|
"gpgcheck=0\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_import_key_raises_when_gpg_binary_missing(tmp_path, monkeypatch) -> None:
|
def test_import_key_raises_when_gpg_binary_missing(tmp_path, monkeypatch) -> None:
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"id_configurator.modules.yum.subprocess.run",
|
"id_configurator.modules.yum.subprocess.run",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[tox]
|
[tox]
|
||||||
env_list = py313,py314,coverage,packaging,packaging-amd64,packaging-arm64,publish,module-python,module-apt,module-yum,module-apk,module-java,module-certificates,module-php,module-npm,module-rust
|
env_list = py313,py314,coverage,packaging,packaging-amd64,packaging-arm64,publish,module-python,module-python-http,module-apt,module-apt-http,module-yum,module-yum-http,module-apk,module-apk-http,module-java,module-java-http,module-certificates,module-php,module-php-http,module-npm,module-npm-http,module-rust,module-rust-http,module-go,module-go-http,module-ruby,module-ruby-http,module-nuget,module-nuget-http,config-url
|
||||||
isolated_build = true
|
isolated_build = true
|
||||||
requires =
|
requires =
|
||||||
tox>=4
|
tox>=4
|
||||||
@@ -74,20 +74,24 @@ description =
|
|||||||
The version is taken from the pyproject.toml file.
|
The version is taken from the pyproject.toml file.
|
||||||
Make sure that you have the rights to upload the image to Docker Hub.
|
Make sure that you have the rights to upload the image to Docker Hub.
|
||||||
commands =
|
commands =
|
||||||
python3 -m uv sync --all-groups --active
|
python3 -m uv sync --all-groups -U --active
|
||||||
python3 -m id_tools build docker --platform linux/amd64,linux/arm64/v8 --push
|
python3 -m id_tools build docker --platform linux/amd64,linux/arm64/v8 --push
|
||||||
python3 -m id_tools publication sign-docker
|
python3 -m id_tools publication sign-docker
|
||||||
|
python3 -m uv build
|
||||||
|
python3 -m uv publish
|
||||||
docker =
|
docker =
|
||||||
passenv =
|
passenv =
|
||||||
COSIGN_PASSWORD
|
COSIGN_PASSWORD
|
||||||
COSIGN_PRIVATE_KEY
|
COSIGN_PRIVATE_KEY
|
||||||
DOCKER_HUB_LOGIN
|
DOCKER_HUB_LOGIN
|
||||||
DOCKER_HUB_TOKEN
|
DOCKER_HUB_TOKEN
|
||||||
|
UV_PUBLISH_TOKEN
|
||||||
|
|
||||||
[docker:pypi-repo]
|
[docker:pypi-repo]
|
||||||
dockerfile = {tox_root}/tests/dockerfiles/repos/pypi-repo.Dockerfile
|
dockerfile = {tox_root}/tests/dockerfiles/repos/pypi-repo.Dockerfile
|
||||||
expose =
|
expose =
|
||||||
PYPI_REPO_PORT=8443/tcp
|
PYPI_REPO_PORT=8443/tcp
|
||||||
|
PYPI_REPO_HTTP_PORT=8080/tcp
|
||||||
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/simple/idconfiguratortestpkg/ || exit 1
|
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/simple/idconfiguratortestpkg/ || exit 1
|
||||||
healthcheck_interval = 1
|
healthcheck_interval = 1
|
||||||
healthcheck_retries = 30
|
healthcheck_retries = 30
|
||||||
@@ -96,23 +100,52 @@ healthcheck_start_period = 1
|
|||||||
|
|
||||||
[testenv:module-python]
|
[testenv:module-python]
|
||||||
package = skip
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
change_dir = {tox_root}
|
change_dir = {tox_root}
|
||||||
dependency_groups = dev
|
dependency_groups = dev
|
||||||
docker =
|
docker =
|
||||||
pypi-repo
|
pypi-repo
|
||||||
allowlist_externals =
|
allowlist_externals =
|
||||||
docker
|
docker
|
||||||
|
set_env =
|
||||||
|
PYPI_REPO_SCHEME = https
|
||||||
commands =
|
commands =
|
||||||
python3 -m id_tools test render-config {tox_root}/tests/configs/Python.yaml.j2 {env_dir}/configs/Python.yaml
|
python3 -m id_tools test render-config {tox_root}/tests/configs/Python.yaml.j2 {env_dir}/configs/Python.yaml
|
||||||
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg PYPI_REPO_PORT={env:PYPI_REPO_PORT} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Python-alpine.Dockerfile -t id-configurator-python .
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg PYPI_REPO_SCHEME={env:PYPI_REPO_SCHEME} --build-arg PYPI_REPO_PORT={env:PYPI_REPO_PORT} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Python-alpine.Dockerfile -t id-configurator-python .
|
||||||
docker rmi id-configurator-python
|
docker rmi id-configurator-python
|
||||||
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg PYPI_REPO_PORT={env:PYPI_REPO_PORT} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Python.Dockerfile -t id-configurator-python .
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg PYPI_REPO_SCHEME={env:PYPI_REPO_SCHEME} --build-arg PYPI_REPO_PORT={env:PYPI_REPO_PORT} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Python.Dockerfile -t id-configurator-python .
|
||||||
docker rmi id-configurator-python
|
docker rmi id-configurator-python
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
|
[testenv:module-python-http]
|
||||||
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
|
change_dir = {tox_root}
|
||||||
|
dependency_groups = dev
|
||||||
|
docker =
|
||||||
|
pypi-repo
|
||||||
|
allowlist_externals =
|
||||||
|
docker
|
||||||
|
description =
|
||||||
|
Same as module-python, but against a plain-HTTP index, to verify pip's
|
||||||
|
trusted-host is configured correctly for a non-HTTPS repository.
|
||||||
|
set_env =
|
||||||
|
PYPI_REPO_SCHEME = http
|
||||||
|
commands =
|
||||||
|
python3 -m id_tools test render-config {tox_root}/tests/configs/Python.yaml.j2 {env_dir}/configs/Python.yaml
|
||||||
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg PYPI_REPO_SCHEME={env:PYPI_REPO_SCHEME} --build-arg PYPI_REPO_PORT={env:PYPI_REPO_HTTP_PORT} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Python-alpine.Dockerfile -t id-configurator-python-http .
|
||||||
|
docker rmi id-configurator-python-http
|
||||||
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg PYPI_REPO_SCHEME={env:PYPI_REPO_SCHEME} --build-arg PYPI_REPO_PORT={env:PYPI_REPO_HTTP_PORT} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Python.Dockerfile -t id-configurator-python-http .
|
||||||
|
docker rmi id-configurator-python-http
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
[docker:apt-repo]
|
[docker:apt-repo]
|
||||||
dockerfile = {tox_root}/tests/dockerfiles/repos/apt-repo.Dockerfile
|
dockerfile = {tox_root}/tests/dockerfiles/repos/apt-repo.Dockerfile
|
||||||
expose =
|
expose =
|
||||||
APT_REPO_PORT=8443/tcp
|
APT_REPO_PORT=8443/tcp
|
||||||
|
APT_REPO_HTTP_PORT=8080/tcp
|
||||||
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/Packages || exit 1
|
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/Packages || exit 1
|
||||||
healthcheck_interval = 1
|
healthcheck_interval = 1
|
||||||
healthcheck_retries = 30
|
healthcheck_retries = 30
|
||||||
@@ -121,21 +154,48 @@ healthcheck_start_period = 1
|
|||||||
|
|
||||||
[testenv:module-apt]
|
[testenv:module-apt]
|
||||||
package = skip
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
change_dir = {tox_root}
|
change_dir = {tox_root}
|
||||||
dependency_groups = dev
|
dependency_groups = dev
|
||||||
docker =
|
docker =
|
||||||
apt-repo
|
apt-repo
|
||||||
allowlist_externals =
|
allowlist_externals =
|
||||||
docker
|
docker
|
||||||
|
set_env =
|
||||||
|
APT_REPO_SCHEME = https
|
||||||
commands =
|
commands =
|
||||||
python3 -m id_tools test render-config {tox_root}/tests/configs/Apt.yaml.j2 {env_dir}/configs/Apt.yaml
|
python3 -m id_tools test render-config {tox_root}/tests/configs/Apt.yaml.j2 {env_dir}/configs/Apt.yaml
|
||||||
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Apt.Dockerfile -t id-configurator-apt .
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Apt.Dockerfile -t id-configurator-apt .
|
||||||
docker rmi id-configurator-apt
|
docker rmi id-configurator-apt
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
|
[testenv:module-apt-http]
|
||||||
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
|
change_dir = {tox_root}
|
||||||
|
dependency_groups = dev
|
||||||
|
docker =
|
||||||
|
apt-repo
|
||||||
|
allowlist_externals =
|
||||||
|
docker
|
||||||
|
description =
|
||||||
|
Same as module-apt, but against a plain-HTTP source, since apt's trust
|
||||||
|
model is the signed-by keyring, independent of transport scheme.
|
||||||
|
set_env =
|
||||||
|
APT_REPO_SCHEME = http
|
||||||
|
commands =
|
||||||
|
python3 -m id_tools test render-config {tox_root}/tests/configs/Apt.yaml.j2 {env_dir}/configs/Apt.yaml
|
||||||
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Apt.Dockerfile -t id-configurator-apt-http .
|
||||||
|
docker rmi id-configurator-apt-http
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
[docker:yum-repo]
|
[docker:yum-repo]
|
||||||
dockerfile = {tox_root}/tests/dockerfiles/repos/yum-repo.Dockerfile
|
dockerfile = {tox_root}/tests/dockerfiles/repos/yum-repo.Dockerfile
|
||||||
expose =
|
expose =
|
||||||
YUM_REPO_PORT=8443/tcp
|
YUM_REPO_PORT=8443/tcp
|
||||||
|
YUM_REPO_HTTP_PORT=8080/tcp
|
||||||
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/repodata/repomd.xml || exit 1
|
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/repodata/repomd.xml || exit 1
|
||||||
healthcheck_interval = 1
|
healthcheck_interval = 1
|
||||||
healthcheck_retries = 30
|
healthcheck_retries = 30
|
||||||
@@ -144,21 +204,48 @@ healthcheck_start_period = 1
|
|||||||
|
|
||||||
[testenv:module-yum]
|
[testenv:module-yum]
|
||||||
package = skip
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
change_dir = {tox_root}
|
change_dir = {tox_root}
|
||||||
dependency_groups = dev
|
dependency_groups = dev
|
||||||
docker =
|
docker =
|
||||||
yum-repo
|
yum-repo
|
||||||
allowlist_externals =
|
allowlist_externals =
|
||||||
docker
|
docker
|
||||||
|
set_env =
|
||||||
|
YUM_REPO_SCHEME = https
|
||||||
commands =
|
commands =
|
||||||
python3 -m id_tools test render-config {tox_root}/tests/configs/Yum.yaml.j2 {env_dir}/configs/Yum.yaml
|
python3 -m id_tools test render-config {tox_root}/tests/configs/Yum.yaml.j2 {env_dir}/configs/Yum.yaml
|
||||||
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Yum.Dockerfile -t id-configurator-yum .
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Yum.Dockerfile -t id-configurator-yum .
|
||||||
docker rmi id-configurator-yum
|
docker rmi id-configurator-yum
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
|
[testenv:module-yum-http]
|
||||||
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
|
change_dir = {tox_root}
|
||||||
|
dependency_groups = dev
|
||||||
|
docker =
|
||||||
|
yum-repo
|
||||||
|
allowlist_externals =
|
||||||
|
docker
|
||||||
|
description =
|
||||||
|
Same as module-yum, but against a plain-HTTP repository, since yum/dnf's
|
||||||
|
trust model is the imported GPG key, independent of transport scheme.
|
||||||
|
set_env =
|
||||||
|
YUM_REPO_SCHEME = http
|
||||||
|
commands =
|
||||||
|
python3 -m id_tools test render-config {tox_root}/tests/configs/Yum.yaml.j2 {env_dir}/configs/Yum.yaml
|
||||||
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Yum.Dockerfile -t id-configurator-yum-http .
|
||||||
|
docker rmi id-configurator-yum-http
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
[docker:apk-repo]
|
[docker:apk-repo]
|
||||||
dockerfile = {tox_root}/tests/dockerfiles/repos/apk-repo.Dockerfile
|
dockerfile = {tox_root}/tests/dockerfiles/repos/apk-repo.Dockerfile
|
||||||
expose =
|
expose =
|
||||||
APK_REPO_PORT=8443/tcp
|
APK_REPO_PORT=8443/tcp
|
||||||
|
APK_REPO_HTTP_PORT=8080/tcp
|
||||||
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/APKINDEX.tar.gz || exit 1
|
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/APKINDEX.tar.gz || exit 1
|
||||||
healthcheck_interval = 1
|
healthcheck_interval = 1
|
||||||
healthcheck_retries = 30
|
healthcheck_retries = 30
|
||||||
@@ -167,6 +254,7 @@ healthcheck_start_period = 1
|
|||||||
|
|
||||||
[testenv:module-apk]
|
[testenv:module-apk]
|
||||||
package = skip
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
change_dir = {tox_root}
|
change_dir = {tox_root}
|
||||||
dependency_groups = dev
|
dependency_groups = dev
|
||||||
docker =
|
docker =
|
||||||
@@ -182,15 +270,43 @@ description =
|
|||||||
native platform keeps them in agreement everywhere (including under
|
native platform keeps them in agreement everywhere (including under
|
||||||
QEMU emulation on non-amd64 hosts); CI runners are amd64, so this still
|
QEMU emulation on non-amd64 hosts); CI runners are amd64, so this still
|
||||||
matches the other envs there.
|
matches the other envs there.
|
||||||
|
set_env =
|
||||||
|
APK_REPO_SCHEME = https
|
||||||
commands =
|
commands =
|
||||||
python3 -m id_tools test render-config {tox_root}/tests/configs/Apk.yaml.j2 {env_dir}/configs/Apk.yaml
|
python3 -m id_tools test render-config {tox_root}/tests/configs/Apk.yaml.j2 {env_dir}/configs/Apk.yaml
|
||||||
docker buildx build --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Apk.Dockerfile -t id-configurator-apk .
|
docker buildx build --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Apk.Dockerfile -t id-configurator-apk .
|
||||||
docker rmi id-configurator-apk
|
docker rmi id-configurator-apk
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
|
[testenv:module-apk-http]
|
||||||
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
|
change_dir = {tox_root}
|
||||||
|
dependency_groups = dev
|
||||||
|
docker =
|
||||||
|
apk-repo
|
||||||
|
allowlist_externals =
|
||||||
|
docker
|
||||||
|
description =
|
||||||
|
Same as module-apk, but against a plain-HTTP repository, since apk's
|
||||||
|
trust model is the signed index, independent of transport scheme. See
|
||||||
|
module-apk's own description for why this isn't pinned to --platform
|
||||||
|
linux/amd64 either.
|
||||||
|
set_env =
|
||||||
|
APK_REPO_SCHEME = http
|
||||||
|
commands =
|
||||||
|
python3 -m id_tools test render-config {tox_root}/tests/configs/Apk.yaml.j2 {env_dir}/configs/Apk.yaml
|
||||||
|
docker buildx build --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Apk.Dockerfile -t id-configurator-apk-http .
|
||||||
|
docker rmi id-configurator-apk-http
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
[docker:java-repo]
|
[docker:java-repo]
|
||||||
dockerfile = {tox_root}/tests/dockerfiles/repos/java-repo.Dockerfile
|
dockerfile = {tox_root}/tests/dockerfiles/repos/java-repo.Dockerfile
|
||||||
expose =
|
expose =
|
||||||
JAVA_REPO_PORT=8443/tcp
|
JAVA_REPO_PORT=8443/tcp
|
||||||
|
JAVA_REPO_HTTP_PORT=8080/tcp
|
||||||
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/org/interdiode/idconfiguratortestpkg/0.0.1/idconfiguratortestpkg-0.0.1.pom || exit 1
|
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/org/interdiode/idconfiguratortestpkg/0.0.1/idconfiguratortestpkg-0.0.1.pom || exit 1
|
||||||
healthcheck_interval = 1
|
healthcheck_interval = 1
|
||||||
healthcheck_retries = 30
|
healthcheck_retries = 30
|
||||||
@@ -199,16 +315,43 @@ healthcheck_start_period = 1
|
|||||||
|
|
||||||
[testenv:module-java]
|
[testenv:module-java]
|
||||||
package = skip
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
change_dir = {tox_root}
|
change_dir = {tox_root}
|
||||||
dependency_groups = dev
|
dependency_groups = dev
|
||||||
docker =
|
docker =
|
||||||
java-repo
|
java-repo
|
||||||
allowlist_externals =
|
allowlist_externals =
|
||||||
docker
|
docker
|
||||||
|
set_env =
|
||||||
|
JAVA_REPO_SCHEME = https
|
||||||
commands =
|
commands =
|
||||||
python3 -m id_tools test render-config {tox_root}/tests/configs/Java.yaml.j2 {env_dir}/configs/Java.yaml
|
python3 -m id_tools test render-config {tox_root}/tests/configs/Java.yaml.j2 {env_dir}/configs/Java.yaml
|
||||||
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Java.Dockerfile -t id-configurator-java .
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg JAVA_REPO_SCHEME={env:JAVA_REPO_SCHEME} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Java.Dockerfile -t id-configurator-java .
|
||||||
docker rmi id-configurator-java
|
docker rmi id-configurator-java
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
|
[testenv:module-java-http]
|
||||||
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
|
change_dir = {tox_root}
|
||||||
|
dependency_groups = dev
|
||||||
|
docker =
|
||||||
|
java-repo
|
||||||
|
allowlist_externals =
|
||||||
|
docker
|
||||||
|
description =
|
||||||
|
Same as module-java, but against a plain-HTTP repository. Leiningen is
|
||||||
|
skipped here (see Java.Dockerfile): it refuses any non-HTTPS repository
|
||||||
|
unconditionally, with no configuration able to change that.
|
||||||
|
set_env =
|
||||||
|
JAVA_REPO_SCHEME = http
|
||||||
|
commands =
|
||||||
|
python3 -m id_tools test render-config {tox_root}/tests/configs/Java.yaml.j2 {env_dir}/configs/Java.yaml
|
||||||
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg JAVA_REPO_SCHEME={env:JAVA_REPO_SCHEME} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Java.Dockerfile -t id-configurator-java-http .
|
||||||
|
docker rmi id-configurator-java-http
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
[docker:certificate-repo]
|
[docker:certificate-repo]
|
||||||
dockerfile = {tox_root}/tests/dockerfiles/repos/certificate-repo.Dockerfile
|
dockerfile = {tox_root}/tests/dockerfiles/repos/certificate-repo.Dockerfile
|
||||||
@@ -222,6 +365,7 @@ healthcheck_start_period = 1
|
|||||||
|
|
||||||
[testenv:module-certificates]
|
[testenv:module-certificates]
|
||||||
package = skip
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
change_dir = {tox_root}
|
change_dir = {tox_root}
|
||||||
dependency_groups = dev
|
dependency_groups = dev
|
||||||
docker =
|
docker =
|
||||||
@@ -237,11 +381,14 @@ commands =
|
|||||||
python3 -m id_tools test render-config {tox_root}/tests/configs/Certificate.yaml.j2 {env_dir}/configs/Certificate.yaml
|
python3 -m id_tools test render-config {tox_root}/tests/configs/Certificate.yaml.j2 {env_dir}/configs/Certificate.yaml
|
||||||
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg CERTIFICATE_REPO_PORT={env:CERTIFICATE_REPO_PORT} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Certificate.Dockerfile -t id-configurator-certificates .
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg CERTIFICATE_REPO_PORT={env:CERTIFICATE_REPO_PORT} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Certificate.Dockerfile -t id-configurator-certificates .
|
||||||
docker rmi id-configurator-certificates
|
docker rmi id-configurator-certificates
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
[docker:php-repo]
|
[docker:php-repo]
|
||||||
dockerfile = {tox_root}/tests/dockerfiles/repos/php-repo.Dockerfile
|
dockerfile = {tox_root}/tests/dockerfiles/repos/php-repo.Dockerfile
|
||||||
expose =
|
expose =
|
||||||
PHP_REPO_PORT=8443/tcp
|
PHP_REPO_PORT=8443/tcp
|
||||||
|
PHP_REPO_HTTP_PORT=8080/tcp
|
||||||
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/packages.json || exit 1
|
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/packages.json || exit 1
|
||||||
healthcheck_interval = 1
|
healthcheck_interval = 1
|
||||||
healthcheck_retries = 30
|
healthcheck_retries = 30
|
||||||
@@ -250,21 +397,48 @@ healthcheck_start_period = 1
|
|||||||
|
|
||||||
[testenv:module-php]
|
[testenv:module-php]
|
||||||
package = skip
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
change_dir = {tox_root}
|
change_dir = {tox_root}
|
||||||
dependency_groups = dev
|
dependency_groups = dev
|
||||||
docker =
|
docker =
|
||||||
php-repo
|
php-repo
|
||||||
allowlist_externals =
|
allowlist_externals =
|
||||||
docker
|
docker
|
||||||
|
set_env =
|
||||||
|
PHP_REPO_SCHEME = https
|
||||||
commands =
|
commands =
|
||||||
python3 -m id_tools test render-config {tox_root}/tests/configs/Php.yaml.j2 {env_dir}/configs/Php.yaml
|
python3 -m id_tools test render-config {tox_root}/tests/configs/Php.yaml.j2 {env_dir}/configs/Php.yaml
|
||||||
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Php.Dockerfile -t id-configurator-php .
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Php.Dockerfile -t id-configurator-php .
|
||||||
docker rmi id-configurator-php
|
docker rmi id-configurator-php
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
|
[testenv:module-php-http]
|
||||||
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
|
change_dir = {tox_root}
|
||||||
|
dependency_groups = dev
|
||||||
|
docker =
|
||||||
|
php-repo
|
||||||
|
allowlist_externals =
|
||||||
|
docker
|
||||||
|
description =
|
||||||
|
Same as module-php, but against a plain-HTTP repository, to verify
|
||||||
|
secure-http is disabled (and packagist.org too) for a non-HTTPS source.
|
||||||
|
set_env =
|
||||||
|
PHP_REPO_SCHEME = http
|
||||||
|
commands =
|
||||||
|
python3 -m id_tools test render-config {tox_root}/tests/configs/Php.yaml.j2 {env_dir}/configs/Php.yaml
|
||||||
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Php.Dockerfile -t id-configurator-php-http .
|
||||||
|
docker rmi id-configurator-php-http
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
[docker:npm-repo]
|
[docker:npm-repo]
|
||||||
dockerfile = {tox_root}/tests/dockerfiles/repos/npm-repo.Dockerfile
|
dockerfile = {tox_root}/tests/dockerfiles/repos/npm-repo.Dockerfile
|
||||||
expose =
|
expose =
|
||||||
NPM_REPO_PORT=8443/tcp
|
NPM_REPO_PORT=8443/tcp
|
||||||
|
NPM_REPO_HTTP_PORT=8080/tcp
|
||||||
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/idconfiguratortestpkg || exit 1
|
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/idconfiguratortestpkg || exit 1
|
||||||
healthcheck_interval = 1
|
healthcheck_interval = 1
|
||||||
healthcheck_retries = 30
|
healthcheck_retries = 30
|
||||||
@@ -273,21 +447,47 @@ healthcheck_start_period = 1
|
|||||||
|
|
||||||
[testenv:module-npm]
|
[testenv:module-npm]
|
||||||
package = skip
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
change_dir = {tox_root}
|
change_dir = {tox_root}
|
||||||
dependency_groups = dev
|
dependency_groups = dev
|
||||||
docker =
|
docker =
|
||||||
npm-repo
|
npm-repo
|
||||||
allowlist_externals =
|
allowlist_externals =
|
||||||
docker
|
docker
|
||||||
|
set_env =
|
||||||
|
NPM_REPO_SCHEME = https
|
||||||
commands =
|
commands =
|
||||||
python3 -m id_tools test render-config {tox_root}/tests/configs/Npm.yaml.j2 {env_dir}/configs/Npm.yaml
|
python3 -m id_tools test render-config {tox_root}/tests/configs/Npm.yaml.j2 {env_dir}/configs/Npm.yaml
|
||||||
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Npm.Dockerfile -t id-configurator-npm .
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Npm.Dockerfile -t id-configurator-npm .
|
||||||
docker rmi id-configurator-npm
|
docker rmi id-configurator-npm
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
|
[testenv:module-npm-http]
|
||||||
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
|
change_dir = {tox_root}
|
||||||
|
dependency_groups = dev
|
||||||
|
docker =
|
||||||
|
npm-repo
|
||||||
|
allowlist_externals =
|
||||||
|
docker
|
||||||
|
description =
|
||||||
|
Same as module-npm, but against a plain-HTTP registry.
|
||||||
|
set_env =
|
||||||
|
NPM_REPO_SCHEME = http
|
||||||
|
commands =
|
||||||
|
python3 -m id_tools test render-config {tox_root}/tests/configs/Npm.yaml.j2 {env_dir}/configs/Npm.yaml
|
||||||
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Npm.Dockerfile -t id-configurator-npm-http .
|
||||||
|
docker rmi id-configurator-npm-http
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
[docker:cargo-repo]
|
[docker:cargo-repo]
|
||||||
dockerfile = {tox_root}/tests/dockerfiles/repos/cargo-repo.Dockerfile
|
dockerfile = {tox_root}/tests/dockerfiles/repos/cargo-repo.Dockerfile
|
||||||
expose =
|
expose =
|
||||||
CARGO_REPO_PORT=8443/tcp
|
CARGO_REPO_PORT=8443/tcp
|
||||||
|
CARGO_REPO_HTTP_PORT=8080/tcp
|
||||||
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/config.json || exit 1
|
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/config.json || exit 1
|
||||||
healthcheck_interval = 1
|
healthcheck_interval = 1
|
||||||
healthcheck_retries = 30
|
healthcheck_retries = 30
|
||||||
@@ -296,13 +496,218 @@ healthcheck_start_period = 1
|
|||||||
|
|
||||||
[testenv:module-rust]
|
[testenv:module-rust]
|
||||||
package = skip
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
change_dir = {tox_root}
|
change_dir = {tox_root}
|
||||||
dependency_groups = dev
|
dependency_groups = dev
|
||||||
docker =
|
docker =
|
||||||
cargo-repo
|
cargo-repo
|
||||||
allowlist_externals =
|
allowlist_externals =
|
||||||
docker
|
docker
|
||||||
|
set_env =
|
||||||
|
CARGO_REPO_SCHEME = https
|
||||||
commands =
|
commands =
|
||||||
python3 -m id_tools test render-config {tox_root}/tests/configs/Rust.yaml.j2 {env_dir}/configs/Rust.yaml
|
python3 -m id_tools test render-config {tox_root}/tests/configs/Rust.yaml.j2 {env_dir}/configs/Rust.yaml
|
||||||
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Rust.Dockerfile -t id-configurator-rust .
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Rust.Dockerfile -t id-configurator-rust .
|
||||||
docker rmi id-configurator-rust
|
docker rmi id-configurator-rust
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
|
[testenv:module-rust-http]
|
||||||
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
|
change_dir = {tox_root}
|
||||||
|
dependency_groups = dev
|
||||||
|
docker =
|
||||||
|
cargo-repo
|
||||||
|
allowlist_externals =
|
||||||
|
docker
|
||||||
|
description =
|
||||||
|
Same as module-rust, but against a plain-HTTP sparse registry.
|
||||||
|
set_env =
|
||||||
|
CARGO_REPO_SCHEME = http
|
||||||
|
commands =
|
||||||
|
python3 -m id_tools test render-config {tox_root}/tests/configs/Rust.yaml.j2 {env_dir}/configs/Rust.yaml
|
||||||
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Rust.Dockerfile -t id-configurator-rust-http .
|
||||||
|
docker rmi id-configurator-rust-http
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
|
[docker:go-repo]
|
||||||
|
dockerfile = {tox_root}/tests/dockerfiles/repos/go-repo.Dockerfile
|
||||||
|
expose =
|
||||||
|
GO_REPO_PORT=8443/tcp
|
||||||
|
GO_REPO_HTTP_PORT=8080/tcp
|
||||||
|
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/example.invalid/idconfiguratortestpkg/@v/list || exit 1
|
||||||
|
healthcheck_interval = 1
|
||||||
|
healthcheck_retries = 30
|
||||||
|
healthcheck_timeout = 2
|
||||||
|
healthcheck_start_period = 1
|
||||||
|
|
||||||
|
[testenv:module-go]
|
||||||
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
|
change_dir = {tox_root}
|
||||||
|
dependency_groups = dev
|
||||||
|
docker =
|
||||||
|
go-repo
|
||||||
|
allowlist_externals =
|
||||||
|
docker
|
||||||
|
set_env =
|
||||||
|
GO_REPO_SCHEME = https
|
||||||
|
commands =
|
||||||
|
python3 -m id_tools test render-config {tox_root}/tests/configs/Go.yaml.j2 {env_dir}/configs/Go.yaml
|
||||||
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Go.Dockerfile -t id-configurator-go .
|
||||||
|
docker rmi id-configurator-go
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
|
[testenv:module-go-http]
|
||||||
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
|
change_dir = {tox_root}
|
||||||
|
dependency_groups = dev
|
||||||
|
docker =
|
||||||
|
go-repo
|
||||||
|
allowlist_externals =
|
||||||
|
docker
|
||||||
|
description =
|
||||||
|
Same as module-go, but against a plain-HTTP module proxy, to verify
|
||||||
|
GOINSECURE is configured correctly for a non-HTTPS GOPROXY.
|
||||||
|
set_env =
|
||||||
|
GO_REPO_SCHEME = http
|
||||||
|
commands =
|
||||||
|
python3 -m id_tools test render-config {tox_root}/tests/configs/Go.yaml.j2 {env_dir}/configs/Go.yaml
|
||||||
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Go.Dockerfile -t id-configurator-go-http .
|
||||||
|
docker rmi id-configurator-go-http
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
|
[docker:ruby-repo]
|
||||||
|
dockerfile = {tox_root}/tests/dockerfiles/repos/ruby-repo.Dockerfile
|
||||||
|
expose =
|
||||||
|
RUBY_REPO_PORT=8443/tcp
|
||||||
|
RUBY_REPO_HTTP_PORT=8080/tcp
|
||||||
|
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/versions || exit 1
|
||||||
|
healthcheck_interval = 1
|
||||||
|
healthcheck_retries = 30
|
||||||
|
healthcheck_timeout = 2
|
||||||
|
healthcheck_start_period = 1
|
||||||
|
|
||||||
|
[testenv:module-ruby]
|
||||||
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
|
change_dir = {tox_root}
|
||||||
|
dependency_groups = dev
|
||||||
|
docker =
|
||||||
|
ruby-repo
|
||||||
|
allowlist_externals =
|
||||||
|
docker
|
||||||
|
set_env =
|
||||||
|
RUBY_REPO_SCHEME = https
|
||||||
|
commands =
|
||||||
|
python3 -m id_tools test render-config {tox_root}/tests/configs/Ruby.yaml.j2 {env_dir}/configs/Ruby.yaml
|
||||||
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Ruby.Dockerfile -t id-configurator-ruby .
|
||||||
|
docker rmi id-configurator-ruby
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
|
[testenv:module-ruby-http]
|
||||||
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
|
change_dir = {tox_root}
|
||||||
|
dependency_groups = dev
|
||||||
|
docker =
|
||||||
|
ruby-repo
|
||||||
|
allowlist_externals =
|
||||||
|
docker
|
||||||
|
description =
|
||||||
|
Same as module-ruby, but against a plain-HTTP gem source.
|
||||||
|
set_env =
|
||||||
|
RUBY_REPO_SCHEME = http
|
||||||
|
commands =
|
||||||
|
python3 -m id_tools test render-config {tox_root}/tests/configs/Ruby.yaml.j2 {env_dir}/configs/Ruby.yaml
|
||||||
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Ruby.Dockerfile -t id-configurator-ruby-http .
|
||||||
|
docker rmi id-configurator-ruby-http
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
|
[docker:nuget-repo]
|
||||||
|
dockerfile = {tox_root}/tests/dockerfiles/repos/nuget-repo.Dockerfile
|
||||||
|
expose =
|
||||||
|
NUGET_REPO_PORT=8443/tcp
|
||||||
|
NUGET_REPO_HTTP_PORT=8080/tcp
|
||||||
|
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/index.json || exit 1
|
||||||
|
healthcheck_interval = 1
|
||||||
|
healthcheck_retries = 30
|
||||||
|
healthcheck_timeout = 2
|
||||||
|
healthcheck_start_period = 1
|
||||||
|
|
||||||
|
[testenv:module-nuget]
|
||||||
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
|
change_dir = {tox_root}
|
||||||
|
dependency_groups = dev
|
||||||
|
docker =
|
||||||
|
nuget-repo
|
||||||
|
allowlist_externals =
|
||||||
|
docker
|
||||||
|
set_env =
|
||||||
|
NUGET_REPO_SCHEME = https
|
||||||
|
commands =
|
||||||
|
python3 -m id_tools test render-config {tox_root}/tests/configs/NuGet.yaml.j2 {env_dir}/configs/NuGet.yaml
|
||||||
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/NuGet.Dockerfile -t id-configurator-nuget .
|
||||||
|
docker rmi id-configurator-nuget
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
|
[testenv:module-nuget-http]
|
||||||
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
|
change_dir = {tox_root}
|
||||||
|
dependency_groups = dev
|
||||||
|
docker =
|
||||||
|
nuget-repo
|
||||||
|
allowlist_externals =
|
||||||
|
docker
|
||||||
|
description =
|
||||||
|
Same as module-nuget, but against a plain-HTTP feed, to verify
|
||||||
|
allowInsecureConnections is configured correctly for a non-HTTPS source.
|
||||||
|
set_env =
|
||||||
|
NUGET_REPO_SCHEME = http
|
||||||
|
commands =
|
||||||
|
python3 -m id_tools test render-config {tox_root}/tests/configs/NuGet.yaml.j2 {env_dir}/configs/NuGet.yaml
|
||||||
|
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/NuGet.Dockerfile -t id-configurator-nuget-http .
|
||||||
|
docker rmi id-configurator-nuget-http
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|
||||||
|
[docker:config-url-repo]
|
||||||
|
dockerfile = {tox_root}/tests/dockerfiles/repos/config-url-repo.Dockerfile
|
||||||
|
expose =
|
||||||
|
CONFIG_REPO_PORT=8443/tcp
|
||||||
|
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/config.yaml || exit 1
|
||||||
|
healthcheck_interval = 1
|
||||||
|
healthcheck_retries = 30
|
||||||
|
healthcheck_timeout = 2
|
||||||
|
healthcheck_start_period = 1
|
||||||
|
|
||||||
|
[testenv:config-url]
|
||||||
|
package = skip
|
||||||
|
labels = end-to-end
|
||||||
|
change_dir = {tox_root}
|
||||||
|
dependency_groups = dev
|
||||||
|
docker =
|
||||||
|
config-url-repo
|
||||||
|
allowlist_externals =
|
||||||
|
docker
|
||||||
|
sh
|
||||||
|
description =
|
||||||
|
Verifies `id-configurator apply --config-url ... --config-url-cacert-content
|
||||||
|
...`: the configuration itself is downloaded over HTTPS from the
|
||||||
|
config-url-repo fixture (trusting only the given root CA content, not the
|
||||||
|
system's trust store), rather than read from a local --config file.
|
||||||
|
commands =
|
||||||
|
sh -c 'docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg CONFIG_REPO_PORT={env:CONFIG_REPO_PORT} --build-arg CONFIG_URL_CACERT_CONTENT="$(cat {tox_root}/tests/certs/test-ca.crt.pem)" -f {tox_root}/tests/dockerfiles/ConfigUrl.Dockerfile -t id-configurator-config-url .'
|
||||||
|
docker rmi id-configurator-config-url
|
||||||
|
commands_post =
|
||||||
|
docker buildx prune --all --force
|
||||||
|
|||||||
Reference in New Issue
Block a user