Compare commits

...

34 Commits

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-05 18:30:30 +00:00
d9pouces bfd04dc49f Merge pull request #17 from d9pouces/dev
Dev
2026-10-24 09:02:26 +02:00
d9pouces 8e52f4335b ci: update dependencies
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-24 00:11:52 +02:00
d9pouces 4cb27c9507 ci: update dependencies
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-24 00:09:18 +02:00
d9pouces 4f079469b7 ci: update dependencies
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-24 00:03:17 +02:00
d9pouces dbc3e121ca feat: update version to 2026.10.24 in pyproject.toml
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-24 00:03:01 +02:00
d9pouces 3b6eb7290f feat: enhance filename sanitization to replace non-identifier characters
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-24 00:02:40 +02:00
d9pouces 653579b59e feat: add docker buildx prune commands to cleanup post-build
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-19 07:00:49 +02:00
d9pouces e1ef0e109b ci: update dependencies
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-09 21:07:10 +02:00
d9pouces 7dbc182de4 Merge pull request #14 from d9pouces/dev
Dev
2026-10-09 20:46:39 +02:00
d9pouces 0f019ac6ea feat: update repository names to support special characters in configuration files
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-09 20:22:26 +02:00
d9pouces e58820ac98 feat: update repository names to support special characters in configuration files
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-09 19:13:12 +02:00
d9pouces b5aec64fd8 Merge pull request #13 from d9pouces/dev
Dev
2026-10-07 08:40:55 +02:00
d9pouces 1e9e87b11c feat: add docker cleanup commands to publish workflow
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-07 08:11:31 +02:00
d9pouces dc0f8b7629 feat: add docker cleanup commands to publish workflow
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-07 08:11:18 +02:00
d9pouces abbd424f14 feat: add plain-HTTP listeners for various repository configurations to support non-HTTPS access
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-07 07:48:38 +02:00
d9pouces ddfa9f75b1 Merge pull request #12 from d9pouces/dev
Dev
2026-10-07 07:44:10 +02:00
d9pouces ebea3071bc feat: add plain-HTTP listeners for various repository configurations to support non-HTTPS access
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-06 23:16:42 +02:00
d9pouces 8598f323d9 feat: add plain-HTTP listeners for various repository configurations to support non-HTTPS access
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-06 22:08:02 +02:00
d9pouces 665eb215bd feat: add repository name sanitization to prevent invalid characters in poetry config
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-06 20:34:04 +02:00
d9pouces 80f9bf3a83 Merge pull request #11 from d9pouces/dev
Dev
2026-10-05 22:53:48 +02:00
d9pouces 98b773445f feat: add sparse option for demo repository in Rust.yaml.j2
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-05 22:30:16 +02:00
d9pouces 6813c86fba refactor: remove unnecessary noqa comments in user tests and user entry creation
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-05 22:13:18 +02:00
d9pouces 8e182f6615 feat: add support for trusted-host in pip configuration for non-HTTPS repositories
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-05 22:01:51 +02:00
d9pouces 352173a85c feat: update version to 2026.10.5 in pyproject.toml
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-05 21:30:18 +02:00
d9pouces d705aa970d feat: update version to 2026.10.5 in pyproject.toml
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-05 20:47:02 +02:00
d9pouces bf56cdfcd2 feat: can specify several users to configure
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-05 20:46:38 +02:00
d9pouces 3e0bb3b04c Merge pull request #10 from d9pouces/dev
Dev
2026-10-04 15:09:07 +02:00
d9pouces c498d42da0 ci: version bump
Signed-off-by: d9pouces <github@19pouces.net>
2026-10-04 14:55:09 +02:00
d9pouces 12f0ac46a6 Merge pull request #9 from d9pouces/dev
fix: add missing GPG packages to the Docker image
2026-10-03 11:26:09 +02:00
d9pouces e6a7d4d4d9 Merge pull request #8 from d9pouces/dev
Dev
2026-10-02 23:31:24 +02:00
d9pouces 35ac0c5033 Merge pull request #7 from d9pouces/dev
Dev
2026-09-22 19:38:14 +02:00
d9pouces 235df16f6f Merge pull request #2 from d9pouces/dev
Dev
2026-09-20 22:50:10 +02:00
d9pouces c9dbeb1a8b Merge pull request #1 from d9pouces/dev
Dev
2026-09-06 15:29:29 +02:00
57 changed files with 2512 additions and 934 deletions
+5 -2
View File
@@ -15,15 +15,18 @@ jobs:
- 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: curl -O -L "https://github.com/sigstore/cosign/releases/latest/download/cosign_3.1.3_amd64.deb"
- run: sudo dpkg -i cosign_3.1.3_amd64.deb
- run: python3 -m pip install --upgrade tox tox-docker-id tox-uv
- run: tox -e packaging-amd64
- run: tox -e module-rust
- run: tox -e packaging-amd64,py313,py314,coverage
- run: tox -m end-to-end
- run: docker builder prune --all --force
- run: docker system prune --all --force
- uses: docker/setup-buildx-action@v3
strategy:
matrix:
+3 -2
View File
@@ -17,15 +17,16 @@ jobs:
- 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: curl -O -L "https://github.com/sigstore/cosign/releases/latest/download/cosign_3.1.3_amd64.deb"
- run: sudo dpkg -i cosign_3.1.3_amd64.deb
- run: python3 -m pip install --upgrade tox tox-docker-id tox-uv
- run: tox -e packaging-amd64,py313,py314,coverage
- run: tox -m end-to-end
- run: docker builder prune --all --force
- run: docker system prune --all --force
- uses: docker/setup-buildx-action@v3
- run: tox -e publish
strategy:
+1 -1
View File
@@ -77,7 +77,7 @@ repos:
rev: 1.8.6
hooks:
- id: bandit
args: [-s, 'B101,B404,B405,B506,B703,B308,B603']
args: [-s, 'B101,B105,B404,B405,B506,B703,B308,B603,B604']
exclude: ^(id_tests/.*|npm/node_modules/.*|tools/.*|doc/.*|interdiode/static/vendor/.*)
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.11.0.1
+35
View File
@@ -23,6 +23,14 @@ deployed to the target device.
`--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
@@ -95,6 +103,12 @@ id-configurator apply \
--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
id-configurator validate --config config.yaml
@@ -145,6 +159,7 @@ configurations:
- name: <string>
url: <string>
key: <RSA public key, optional>
key_filename: <string, optional> # name of the key file under /etc/apk/keys/, if not the derived default
url_to_replace: <fnmatch pattern, optional>
yum:
repositories:
@@ -175,6 +190,7 @@ configurations:
repositories:
- name: <string>
url: <string>
is_sparse: <bool, optional, default false> # declares the registry's index as sparse+<url>
go:
repositories:
- name: <string>
@@ -200,6 +216,22 @@ replaces Debian's default mirror if (and only if) it's actually there, without d
given machine might already have. Leaving the field unset never disables anything - the configured repository
is simply added alongside whatever is already present.
### Non-HTTPS repositories
A repository URL doesn't have to be HTTPS - several modules automatically adjust the tool's configuration so
the plain-HTTP repository is actually usable, instead of silently producing a config the package manager
would refuse at run time:
- `python`: pip gets the repository's host added to `trusted-host`.
- `php`: Composer gets `secure-http` disabled, and `packagist.org` is disabled alongside it (an HTTP source
implies a private, non-internet setup where the public registry wouldn't be reachable anyway).
- `nuget`: the source gets `allowInsecureConnections="true"`.
- `java`: SBT's resolver gets `allowInsecureProtocol` appended. **Leiningen is the one exception**: its
standalone launcher refuses any non-HTTPS repository unconditionally, with no configuration able to change
that - a repository configured here simply won't be usable from Leiningen specifically.
- `go`, `rust`, `apt`, `apk`, `yum`: no adjustment is needed - these already work the same regardless of
scheme.
## Modules
| Module | Applies to |
@@ -217,6 +249,9 @@ is simply added alongside whatever is already present.
| `ruby` | Points RubyGems at the configured sources, replacing rubygems.org. |
| `nuget` | Points NuGet at the configured package sources, replacing nuget.org. |
`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
+25 -1
View File
@@ -83,6 +83,26 @@ DstOption = Annotated[
help="Destination directory receiving the modified files.",
),
]
UserOption = Annotated[
list[str],
typer.Option(
"--user",
help="A user to prepare per-user configuration for (may be repeated).",
),
]
AutoUsersOption = Annotated[
bool,
typer.Option(
"--auto-users",
help=(
"Also prepare per-user configuration for every regular "
"(non-system) user found in <src>/etc/passwd: one with a login "
"shell (not nologin, sync or false) and a UID at or above "
"UID_MIN (from <src>/etc/login.defs, 1000 by default) - root is "
"always included regardless of its UID."
),
),
]
VerboseOption = Annotated[
bool, typer.Option("--verbose", "-v", help="Enable verbose (debug) logging.")
]
@@ -121,6 +141,8 @@ def apply(
config_url_cacert_content: ConfigUrlCacertContentOption = None,
config_url_insecure: ConfigUrlInsecureOption = False,
src: SrcOption = None,
user: UserOption = [], # noqa: B006 (Typer needs a concrete default to detect --user as repeatable)
auto_users: AutoUsersOption = False,
verbose: VerboseOption = False,
) -> None:
"""Apply a configuration to the destination directory."""
@@ -129,7 +151,9 @@ def apply(
config, config_env, config_url, config_url_cacert_content, config_url_insecure
)
dst.mkdir(parents=True, exist_ok=True)
context = Context(config=configuration, dst=dst, src=src)
context = Context(
config=configuration, dst=dst, src=src, users=user, auto_users=auto_users
)
for module_cls in all_modules():
module = module_cls()
if module.is_relevant(context):
+7 -2
View File
@@ -239,11 +239,16 @@ class NpmConfig(BaseModel):
class RustRepository(BaseModel):
"""A cargo registry (see the "source" tables in .cargo/config.toml),
served over the sparse HTTP protocol."""
"""A cargo registry (see the "source" tables in .cargo/config.toml).
`is_sparse`, if true, marks the registry as served over the sparse
HTTP protocol (its index is then declared as `sparse+<url>`) rather
than a git-based index.
"""
name: str
url: str
is_sparse: bool = False
class RustConfig(BaseModel):
+54 -1
View File
@@ -7,18 +7,71 @@ filesystem used to detect the existing configuration.
from __future__ import annotations
from collections.abc import Sequence
from pathlib import Path
from id_configurator.config import Configuration
from id_configurator.users import (
DEFAULT_UID_MIN,
PasswdEntry,
parse_login_defs_uid_min,
parse_passwd,
)
class Context:
def __init__(
self, config: Configuration, dst: Path, src: Path | None = None
self,
config: Configuration,
dst: Path,
src: Path | None = None,
users: Sequence[str] | None = None,
auto_users: bool = False,
) -> None:
self.config = config
self.dst = dst.resolve()
self.src = src.resolve() if src is not None else None
self._passwd_entries = self._load_passwd_entries()
self._uid_min = self._load_uid_min()
self.users = self._resolve_users(users or [], auto_users)
def _load_passwd_entries(self) -> dict[str, PasswdEntry]:
content = self.read_src_file("etc/passwd")
if content is None:
return {}
return {entry.name: entry for entry in parse_passwd(content.decode("utf-8"))}
def _load_uid_min(self) -> int:
content = self.read_src_file("etc/login.defs")
if content is None:
return DEFAULT_UID_MIN
uid_min = parse_login_defs_uid_min(content.decode("utf-8"))
return DEFAULT_UID_MIN if uid_min is None else uid_min
def _resolve_users(self, users: Sequence[str], auto_users: bool) -> list[str]:
resolved: list[str] = []
for user in users:
if user not in resolved:
resolved.append(user)
if auto_users:
for name, entry in self._passwd_entries.items():
if name not in resolved and entry.is_regular_user(self._uid_min):
resolved.append(name)
return resolved
def home_dir(self, user: str) -> str:
"""Relative path (no leading `/`) to `user`'s home directory, as used
by modules that write per-user configuration files.
Taken from `<src>`'s `/etc/passwd` entry for `user` when there is
one (the case for a user resolved via `--auto-users`); otherwise
falls back to the usual `/home/<user>` convention (`/root` for
root).
"""
entry = self._passwd_entries.get(user)
if entry is not None and entry.home:
return entry.home.lstrip("/")
return "root" if user == "root" else f"home/{user}"
def dst_path(self, relative: str) -> Path:
"""Resolve a path relative to the destination directory, refusing to escape it."""
+26 -6
View File
@@ -7,6 +7,7 @@ the destination directory.
from __future__ import annotations
import re
from abc import ABC, abstractmethod
from id_configurator.context import Context
@@ -36,16 +37,35 @@ def all_modules() -> list[type[Module]]:
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: without it, a name containing `/` (or `\\`, harmless on the
target systems this tool writes to but stripped for good measure)
would silently turn into extra, unintended path segments - creating
nested directories a careless or malicious name shouldn't be able to
create - instead of the single file the caller asked for.
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 name.replace("/", "_").replace("\\", "_")
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)
+14 -9
View File
@@ -9,14 +9,15 @@ Go's own HTTPS client already trusts whatever the `certificates` module
installs into the system CA store, so no separate certificate configuration
is needed here, unlike npm's.
Written to `$GOROOT/go.env` - not the per-user `go env -w` file (found at
`os.UserConfigDir()/go/env`, e.g. `$HOME/.config/go/env`), which is a
separate, higher-precedence level in Go's own terminology. `$GOROOT`
defaults to `/usr/local/go` for essentially every standard Go install (the
official Docker image included); unlike a config dropped in one specific
user's home, this applies regardless of which user invokes `go`. The go
command reads `$GOROOT/go.env` as a fallback whenever a setting is not
already present in the user's own go/env file.
Written to `$GOROOT/go.env`, which `$GOROOT` defaults to `/usr/local/go` for
essentially every standard Go install (the official Docker image included);
unlike a config dropped in one specific user's home, this applies regardless
of which user invokes `go`. The go command reads `$GOROOT/go.env` as a
fallback whenever a setting is not already present in a higher-precedence
level - in particular the per-user `go env -w` file (found at
`os.UserConfigDir()/go/env`, e.g. `$HOME/.config/go/env`), which this module
also writes for each configured `--user` (`context.users`), so that one
takes precedence should that user ever run `go env -w` themselves.
"""
from __future__ import annotations
@@ -35,7 +36,11 @@ _GO_ENV = "usr/local/go/go.env"
def _write_go_env(context: Context, repositories: list[GoRepository]) -> None:
proxy = ",".join(repository.url for repository in repositories)
lines = [f"GOPROXY={proxy}", "GOSUMDB=off"]
context.write_file(_GO_ENV, ("\n".join(lines) + "\n").encode("utf-8"))
content = ("\n".join(lines) + "\n").encode("utf-8")
context.write_file(_GO_ENV, content)
for user in context.users:
context.write_file(f"{context.home_dir(user)}/.config/go/env", content)
logger.info("configured go for user %s", user)
logger.info("configured go to use %d module prox(y/ies)", len(repositories))
+26 -8
View File
@@ -28,9 +28,20 @@ tool targets Docker images, which are built and run as root:
- Ivy: `~/.ivy2/ivysettings.xml`, as a chain of `ibiblio` resolvers.
- SBT: `~/.sbt/repositories`, the resolver override file SBT reads for its
own bootstrapping and, when combined with the `sbt.override.build.repos`
system property, for every project's resolvers as well.
system property, for every project's resolvers as well. A repository
whose URL isn't HTTPS gets `, allowInsecureProtocol` appended to its
line - SBT's Coursier-backed resolver otherwise refuses it outright
("insecure protocol is unsupported"), unlike every other entry in
`~/.sbt/repositories` which takes no such suffix.
- Leiningen: `~/.lein/profiles.clj`, adding the repositories to the `:user`
profile.
profile. Leiningen, alone among these, refuses a plain-HTTP repository
outright ("Tried to use insecure HTTP repository without TLS") - its
standalone `lein` launcher (`leiningen.core.main/-main`) unconditionally
registers an aborting wagon for the "http" scheme before even reading the
project/profile config, so there is no project.clj/profiles.clj setting
(there is no `:insecure` key anywhere in Leiningen's own source) that
can work around this: a non-HTTPS repository configured here will not be
usable from Leiningen specifically, regardless of what this module does.
"""
from __future__ import annotations
@@ -39,6 +50,7 @@ import json
import logging
import xml.etree.ElementTree as ET
from pathlib import Path
from urllib.parse import urlparse
import jks
from cryptography import x509
@@ -50,7 +62,7 @@ from cryptography.hazmat.primitives.serialization import (
from id_configurator.config import JavaRepository, NamedContent
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__)
@@ -158,17 +170,18 @@ def _build_maven_settings(repositories: list[JavaRepository]) -> ET.Element:
repositories_el = ET.SubElement(profile, "repositories")
plugin_repositories_el = ET.SubElement(profile, "pluginRepositories")
for repository in repositories:
name = sanitize_repository_name(repository.name)
for parent, tag in (
(repositories_el, "repository"),
(plugin_repositories_el, "pluginRepository"),
):
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
active_profiles = ET.SubElement(settings, "activeProfiles")
ET.SubElement(active_profiles, "activeProfile").text = "id-configurator"
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, "mirrorOf").text = "*"
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))
def _sbt_repository_line(repository: JavaRepository) -> str:
line = f" {repository.name}: {repository.url}"
if urlparse(repository.url).scheme.lower() != "https":
line += ", allowInsecureProtocol"
return line
def _write_sbt_repositories(
context: Context, repositories: list[JavaRepository]
) -> None:
lines = ["[repositories]", " local"]
lines.extend(
f" {repository.name}: {repository.url}" for repository in repositories
)
lines.extend(_sbt_repository_line(repository) for repository in repositories)
context.write_file(_SBT_REPOSITORIES, ("\n".join(lines) + "\n").encode("utf-8"))
logger.info("configured sbt to use %d repositories", len(repositories))
+15 -3
View File
@@ -19,12 +19,17 @@ when not overridden - the same tradeoff the `php` module's Composer config
already accepts. A `<clear />` element is included so the configured
sources fully replace nuget.org rather than being merged alongside it,
matching every other package-manager module's behaviour.
The same file is additionally written under each configured `--user`'s own
home directory (`context.users`), since a non-root user invoking `dotnet`
reads its config from there instead.
"""
from __future__ import annotations
import logging
import xml.etree.ElementTree as ET
from urllib.parse import urlparse
from id_configurator.config import NuGetRepository
from id_configurator.context import Context
@@ -48,10 +53,17 @@ def _write_nuget_config(context: Context, repositories: list[NuGetRepository]) -
package_sources = ET.SubElement(configuration, "packageSources")
ET.SubElement(package_sources, "clear")
for repository in repositories:
ET.SubElement(
package_sources, "add", {"key": repository.name, "value": repository.url}
attributes = {"key": repository.name, "value": repository.url}
if urlparse(repository.url).scheme.lower() != "https":
attributes["allowInsecureConnections"] = "true"
ET.SubElement(package_sources, "add", attributes)
content = _render_xml(configuration)
context.write_file(_NUGET_CONFIG, content)
for user in context.users:
context.write_file(
f"{context.home_dir(user)}/.nuget/NuGet/NuGet.Config", content
)
context.write_file(_NUGET_CONFIG, _render_xml(configuration))
logger.info("configured nuget for user %s", user)
logger.info("configured nuget to use %d package source(s)", len(repositories))
+27 -7
View File
@@ -3,12 +3,20 @@
Composer has no "replace the default registry" switch either: Packagist
stays enabled unless a repository is explicitly declared under the name
"packagist.org" and disabled. This just makes the configured repositories
"packagist.org" and disabled. If any configured repository's URL isn't
HTTPS, Composer refuses to use it unless `secure-http` is turned off in
`config`; since that effectively means this is a private, non-internet
setup, Packagist - unreachable in such a setup anyway - is disabled at the
same time, exactly as if a `{"packagist.org": false}` repository entry had
been declared. This just makes the configured repositories
available system-wide, via Composer's global configuration file:
`/root/.composer/config.json` (`COMPOSER_HOME/config.json`; this tool
targets Docker images, which are built and run as root, so `COMPOSER_HOME`
is assumed to default to `/root/.composer`, exactly like a fresh Composer
install does when not overridden).
install does when not overridden). The same file is additionally written
under each configured `--user`'s own home directory (`context.users`),
since a non-root user invoking `composer` reads its config from there
instead.
Composer's own HTTPS client (PHP's stream wrapper, generally backed by
curl) already trusts whatever the `certificates` module installs into the
@@ -31,6 +39,7 @@ from __future__ import annotations
import json
import logging
from urllib.parse import urlparse
from id_configurator.config import PhpRepository
from id_configurator.context import Context
@@ -43,16 +52,27 @@ _CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
_OPENSSL_INI = "usr/local/etc/php/conf.d/id-configurator-openssl.ini"
def _is_https(url: str) -> bool:
return urlparse(url).scheme.lower() == "https"
def _write_composer_config(context: Context, repositories: list[PhpRepository]) -> None:
content = {
"repositories": [
composer_repositories: list[dict[str, str | bool]] = [
{"name": repository.name, "type": "composer", "url": repository.url}
for repository in repositories
]
}
context.write_file(
_COMPOSER_CONFIG, (json.dumps(content, indent=4) + "\n").encode("utf-8")
content: dict[str, object] = {"repositories": composer_repositories}
composer_repositories.append({"packagist.org": False})
if any(not _is_https(repository.url) for repository in repositories):
content["config"] = {"secure-http": False}
logger.info(
"disabled secure-http and packagist.org: non-HTTPS repository configured"
)
rendered = (json.dumps(content, indent=4) + "\n").encode("utf-8")
context.write_file(_COMPOSER_CONFIG, rendered)
for user in context.users:
context.write_file(f"{context.home_dir(user)}/.composer/config.json", rendered)
logger.info("configured composer for user %s", user)
logger.info(
"configured composer to use %d additional repositories", len(repositories)
)
+43 -9
View File
@@ -3,13 +3,17 @@ PythonRepository entries instead of pypi.org, and to trust the root
certificates installed by the certificates module.
- pip: system-wide `/etc/pip.conf` (`index-url`/`extra-index-url`/`cert`).
Any repository whose URL isn't HTTPS has its host added to
`trusted-host`, since pip otherwise refuses a plain HTTP index.
- uv: system-wide `/etc/uv/uv.toml` (`[[index]]` entries, `native-tls`).
- poetry: `/root/.config/pypoetry/config.toml` (`repositories.*`/`certificates.*`).
Poetry has no global "replace PyPI" switch: a project's `pyproject.toml`
still has to reference these repositories as sources. This only makes the
repository and its certificate available system-wide. It also assumes the
container runs as root, since poetry only reads its config from the
current user's home directory.
repository and its certificate available system-wide. Since poetry only
reads its config from the current user's home directory, this assumes the
container runs as root; the same file is additionally written under each
configured `--user`'s own home directory (`context.users`), for the
benefit of a non-root user invoking poetry.
- easy_install: `distutils.cfg` (`[easy_install] index_url`), written next
to every Python installation detected under `<src>` (there is no location
independent of a specific Python prefix).
@@ -21,10 +25,11 @@ from __future__ import annotations
import json
import logging
from urllib.parse import urlparse
from id_configurator.config import PythonRepository
from id_configurator.context import Context
from id_configurator.modules.base import Module, register
from id_configurator.modules.base import Module, register, sanitize_repository_name
logger = logging.getLogger(__name__)
@@ -36,7 +41,24 @@ _DISTUTILS_GLOBS = ("usr/lib/python3.*", "usr/local/lib/python3.*")
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:
@@ -44,6 +66,12 @@ def _write_pip_conf(context: Context, repositories: list[PythonRepository]) -> N
if len(repositories) > 1:
lines.append("extra-index-url =")
lines.extend(f" {repository.index_url}" for repository in repositories[1:])
trusted_hosts = dict.fromkeys(
host for repository in repositories if (host := _trusted_host(repository))
)
if trusted_hosts:
lines.append("trusted-host =")
lines.extend(f" {host}" for host in trusted_hosts)
lines.append(f"cert = {_CA_BUNDLE}")
context.write_file(_PIP_CONF, ("\n".join(lines) + "\n").encode("utf-8"))
logger.info("configured pip to use %s", repositories[0].index_url)
@@ -52,8 +80,9 @@ def _write_pip_conf(context: Context, repositories: list[PythonRepository]) -> N
def _write_uv_toml(context: Context, repositories: list[PythonRepository]) -> None:
lines = ["native-tls = true", ""]
for position, repository in enumerate(repositories):
name = sanitize_repository_name(repository.name)
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)}")
if position == 0:
lines.append("default = true")
@@ -67,15 +96,20 @@ def _write_poetry_config(
) -> None:
lines = []
for repository in repositories:
lines.append(f"[repositories.{repository.name}]")
name = sanitize_repository_name(repository.name)
lines.append(f"[repositories.{name}]")
lines.append(f"url = {_toml_string(repository.index_url)}")
lines.append("")
lines.append(f"[certificates.{repository.name}]")
lines.append(f"[certificates.{name}]")
lines.append(f"cert = {_toml_string(_CA_BUNDLE)}")
lines.append("")
content = ("\n".join(lines).rstrip() + "\n").encode("utf-8")
context.write_file(_POETRY_CONFIG, content)
for user in context.users:
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))
+26 -9
View File
@@ -1,8 +1,10 @@
"""Configure cargo to use the configured RustRepository entries: the first
one replaces crates.io entirely (via `[source.crates-io] replace-with`), and
any further ones are registered as additional named registries (usable via
`cargo add --registry <name>` or a dependency's `registry = "<name>"`), all
using the sparse (plain HTTPS) protocol rather than a git-based index.
`cargo add --registry <name>` or a dependency's `registry = "<name>"`). A
repository's index is declared as `sparse+<url>` - the sparse (plain HTTPS)
protocol - only when its `is_sparse` option is set; otherwise `<url>` is used
as-is, for a git-based index.
Cargo's own HTTPS client already trusts whatever the `certificates` module
installs into the system CA store, so no separate certificate configuration
@@ -17,6 +19,11 @@ directory to `/`, consulting `/.cargo/config.toml` there, *before* falling
back to `$CARGO_HOME/config.toml` - so this is the one genuinely global
location: found regardless of which user, working directory or `CARGO_HOME`
is in effect, unlike a config dropped in one specific user's home.
For each configured `--user` (`context.users`), the same config is
additionally written at that user's own `$HOME/.cargo/config.toml` - the
conventional, un-overridden `CARGO_HOME` location for that specific user,
which this module otherwise has no visibility into.
"""
from __future__ import annotations
@@ -25,29 +32,39 @@ import logging
from id_configurator.config import RustRepository
from id_configurator.context import Context
from id_configurator.modules.base import Module, register
from id_configurator.modules.base import Module, register, sanitize_repository_name
logger = logging.getLogger(__name__)
_CARGO_CONFIG = ".cargo/config.toml"
def _index_url(repository: RustRepository) -> str:
return f"sparse+{repository.url}" if repository.is_sparse else repository.url
def _write_cargo_config(context: Context, repositories: list[RustRepository]) -> None:
primary = repositories[0]
primary_name = sanitize_repository_name(primary.name)
lines = [
"[source.crates-io]",
f'replace-with = "{primary.name}"',
f'replace-with = "{primary_name}"',
"",
f"[source.{primary.name}]",
f'registry = "sparse+{primary.url}"',
f"[source.{primary_name}]",
f'registry = "{_index_url(primary)}"',
]
for repository in repositories[1:]:
name = sanitize_repository_name(repository.name)
lines += [
"",
f"[registries.{repository.name}]",
f'index = "sparse+{repository.url}"',
f"[registries.{name}]",
f'index = "{_index_url(repository)}"',
]
context.write_file(_CARGO_CONFIG, ("\n".join(lines) + "\n").encode("utf-8"))
content = ("\n".join(lines) + "\n").encode("utf-8")
context.write_file(_CARGO_CONFIG, content)
for user in context.users:
context.write_file(f"{context.home_dir(user)}/.cargo/config.toml", content)
logger.info("configured cargo for user %s", user)
logger.info("configured cargo to use %s", primary.url)
+11 -5
View File
@@ -40,7 +40,12 @@ from io import StringIO
from id_configurator.config import NamedContent, YumRepository
from id_configurator.context import Context
from id_configurator.modules.base import Module, register, sanitize_filename
from id_configurator.modules.base import (
Module,
register,
sanitize_filename,
sanitize_repository_name,
)
logger = logging.getLogger(__name__)
@@ -91,9 +96,10 @@ def _disable_existing_repos(context: Context, patterns: Sequence[str]) -> None:
def _write_repo_file(context: Context, repository: YumRepository) -> None:
name = sanitize_repository_name(repository.name)
lines = [
f"[{repository.name}]",
f"name={repository.name}",
f"[{name}]",
f"name={name}",
f"baseurl={repository.index_url}",
"enabled=1",
]
@@ -103,10 +109,10 @@ def _write_repo_file(context: Context, repository: YumRepository) -> None:
else:
lines.append("gpgcheck=0")
context.write_file(
f"{_REPOS_DIR}/{sanitize_filename(repository.name)}.repo",
f"{_REPOS_DIR}/{name}.repo",
("\n".join(lines) + "\n").encode("utf-8"),
)
logger.info("added yum/dnf repository %s", repository.name)
logger.info("added yum/dnf repository %s", name)
def _validate_gpg_key(name: str, content: bytes) -> None:
+78
View File
@@ -0,0 +1,78 @@
"""Parse `/etc/passwd` entries for the `--auto-users` CLI option.
A passwd(5) line is `name:passwd:uid:gid:gecos:home:shell`. `--auto-users`
expands to every entry that looks like a regular (non-system) account - see
`PasswdEntry.is_regular_user`.
"""
from __future__ import annotations
from dataclasses import dataclass
DEFAULT_UID_MIN = 1000
_DISABLED_SHELL_NAMES = {"nologin", "sync", "false"}
@dataclass(frozen=True)
class PasswdEntry:
name: str
uid: int
home: str
shell: str
@property
def has_login_shell(self) -> bool:
return self.shell.rsplit("/", 1)[-1] not in _DISABLED_SHELL_NAMES
def is_regular_user(self, uid_min: int = DEFAULT_UID_MIN) -> bool:
"""True unless `self` looks like a system account.
A login-disabling shell (`has_login_shell` False) only catches
service accounts that follow that convention; it misses others
(and could wrongly flag a merely locked human account). The
authoritative signal `useradd`/`adduser`/`systemd-sysusers` use
instead is the UID: system accounts get one below `uid_min` (the
`UID_MIN` from login.defs(5), 1000 by default). Both checks apply
together here - except for `root`, whose UID 0 is itself a system
convention rather than a sign it shouldn't be configured: it's
exempted from the UID check (though not from the shell one, so a
deliberately locked root account is still excluded).
"""
if not self.has_login_shell:
return False
return self.name == "root" or self.uid >= uid_min
def parse_passwd(content: str) -> list[PasswdEntry]:
entries = []
for line in content.splitlines():
if not line or line.startswith("#"):
continue
fields = line.split(":")
if len(fields) < 7:
continue
try:
uid = int(fields[2])
except ValueError:
continue
entries.append(
PasswdEntry(name=fields[0], uid=uid, home=fields[5], shell=fields[6])
)
return entries
def parse_login_defs_uid_min(content: str) -> int | None:
"""Extract `UID_MIN` from `/etc/login.defs` content (login.defs(5)), or
`None` if it isn't set there."""
for line in content.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
parts = stripped.split()
if len(parts) >= 2 and parts[0] == "UID_MIN":
try:
return int(parts[1])
except ValueError:
return None
return None
+1 -1
View File
@@ -44,7 +44,7 @@ maintainers = [
name = "id-configurator"
readme = "README.md"
requires-python = ">=3.13"
version = "2026.10.3"
version = "2026.10.24"
[project.scripts]
id-configurator = "id_configurator:main"
+3 -2
View File
@@ -10,8 +10,9 @@ configurations:
sources: []
apk:
repositories:
- name: demo
url: https://host.docker.internal:{{ 'APK_REPO_PORT' | env }}/
- name: 'demo _.-/\$.:@;''"'
{% set scheme = 'APK_REPO_SCHEME' | env %}
url: {{ scheme }}://host.docker.internal:{{ ('APK_REPO_PORT' if scheme == 'https' else 'APK_REPO_HTTP_PORT') | env }}/
url_to_replace: https://dl-cdn.alpinelinux.org/*
key: |
{{ 'dockerfiles/repos/040d0af5f97a0a44.rsa.pub' | file | indent(8, first=true) }}
+2 -1
View File
@@ -11,7 +11,8 @@ configurations:
- name: demo
types: [deb]
uris:
- https://host.docker.internal:{{ 'APT_REPO_PORT' | env }}/
{% set scheme = 'APT_REPO_SCHEME' | env %}
- {{ scheme }}://host.docker.internal:{{ ('APT_REPO_PORT' if scheme == 'https' else 'APT_REPO_HTTP_PORT') | env }}/
suites: ["./"]
uri-to-replace: http://deb.debian.org/*
signed-by: |
+3 -2
View File
@@ -25,8 +25,9 @@ configurations:
repositories: []
go:
repositories:
- name: demo
url: https://host.docker.internal:{{ 'GO_REPO_PORT' | env }}/
- 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:
+2 -1
View File
@@ -23,7 +23,8 @@ configurations:
# during the Dockerfile's warm-up step. All other tools just see it as
# an ordinary repository, and find the test artifact here.
- name: central
url: https://host.docker.internal:{{ 'JAVA_REPO_PORT' | env }}/
{% set scheme = 'JAVA_REPO_SCHEME' | env %}
url: {{ scheme }}://host.docker.internal:{{ ('JAVA_REPO_PORT' if scheme == 'https' else 'JAVA_REPO_HTTP_PORT') | env }}/
# Second entry: only SBT needs this. With
# -Dsbt.override.build.repos=true (the only way SBT actually consults
# this file for project dependencies, not just its own bootstrap), the
+3 -2
View File
@@ -21,8 +21,9 @@ configurations:
repositories: []
npm:
repositories:
- name: demo
url: https://host.docker.internal:{{ 'NPM_REPO_PORT' | env }}/
- name: 'demo _.-/\$.:@;''"'
{% set scheme = 'NPM_REPO_SCHEME' | env %}
url: {{ scheme }}://host.docker.internal:{{ ('NPM_REPO_PORT' if scheme == 'https' else 'NPM_REPO_HTTP_PORT') | env }}/
rust:
repositories: []
go:
+3 -2
View File
@@ -29,5 +29,6 @@ configurations:
repositories: []
nuget:
repositories:
- name: demo
url: https://host.docker.internal:{{ 'NUGET_REPO_PORT' | env }}/index.json
- 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
+3 -2
View File
@@ -19,8 +19,9 @@ configurations:
repositories: []
php:
repositories:
- name: demo
url: https://host.docker.internal:{{ 'PHP_REPO_PORT' | env }}/
- name: 'demo _.-/\$.:@;''"'
{% set scheme = 'PHP_REPO_SCHEME' | env %}
url: {{ scheme }}://host.docker.internal:{{ ('PHP_REPO_PORT' if scheme == 'https' else 'PHP_REPO_HTTP_PORT') | env }}/
npm:
repositories: []
rust:
+3 -2
View File
@@ -15,8 +15,9 @@ configurations:
keys: []
python:
repositories:
- name: demo
index-url: https://host.docker.internal:{{ 'PYPI_REPO_PORT' | env }}/simple/
- name: 'demo _.-/\$.:@;''"'
{% set scheme = 'PYPI_REPO_SCHEME' | env %}
index-url: {{ scheme }}://host.docker.internal:{{ ('PYPI_REPO_PORT' if scheme == 'https' else 'PYPI_REPO_HTTP_PORT') | env }}/simple/
java:
repositories: []
php:
+3 -2
View File
@@ -27,7 +27,8 @@ configurations:
repositories: []
ruby:
repositories:
- name: demo
url: https://host.docker.internal:{{ 'RUBY_REPO_PORT' | env }}/
- 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: []
+4 -2
View File
@@ -23,8 +23,10 @@ configurations:
repositories: []
rust:
repositories:
- name: demo
url: https://host.docker.internal:{{ 'CARGO_REPO_PORT' | env }}/
- name: 'demo _.-/\$.:@;''"'
{% set scheme = 'CARGO_REPO_SCHEME' | env %}
url: {{ scheme }}://host.docker.internal:{{ ('CARGO_REPO_PORT' if scheme == 'https' else 'CARGO_REPO_HTTP_PORT') | env }}/
is_sparse: true
go:
repositories: []
ruby:
+3 -2
View File
@@ -12,8 +12,9 @@ configurations:
repositories: []
yum:
repositories:
- name: demo
index-url: https://host.docker.internal:{{ 'YUM_REPO_PORT' | env }}/
- name: 'demo _.-/\$.:@;''"'
{% set scheme = 'YUM_REPO_SCHEME' | env %}
index-url: {{ scheme }}://host.docker.internal:{{ ('YUM_REPO_PORT' if scheme == 'https' else 'YUM_REPO_HTTP_PORT') | env }}/
index-url-to-replace: https://mirrors.rockylinux.org/*
keys: []
python:
+13 -1
View File
@@ -4,6 +4,7 @@ COPY --from=target / /src/
COPY --from=generated-config Java.yaml /config.yaml
RUN id-configurator apply --config /config.yaml --src /src --dst /dst
FROM target AS runtime
ARG JAVA_REPO_SCHEME
# Every build tool the `java` module configures: Maven itself (already part
# of this image), Gradle, Ivy and Leiningen (all packaged for this Ubuntu
# base), and SBT (no package available, fetched as a plain release tarball).
@@ -43,12 +44,23 @@ RUN java -jar /usr/share/java/ivy.jar -settings /root/.ivy2/ivysettings.xml \
-retrieve "/tmp/ivyretrieve/[artifact]-[revision].[ext]" \
&& test -f /tmp/ivyretrieve/idconfiguratortestpkg-0.0.1.jar
# Leiningen's standalone launcher unconditionally refuses any plain-HTTP
# repository ("Tried to use insecure HTTP repository without TLS") before
# even reading project.clj/profiles.clj - there is no `:insecure` key or
# other configuration escape hatch for this (confirmed from Leiningen's own
# source: leiningen.core.main/-main registers an aborting wagon for the
# "http" scheme unconditionally). So this check is skipped for the
# module-java-http variant: no id-configurator config could make it pass.
RUN mkdir -p /tmp/leintest && cd /tmp/leintest \
&& cat > project.clj <<'EOF'
(defproject leintest "0.1.0"
:dependencies [[org.interdiode/idconfiguratortestpkg "0.0.1"]])
EOF
RUN cd /tmp/leintest && lein deps :tree | grep -q idconfiguratortestpkg
RUN if [ "$JAVA_REPO_SCHEME" = "https" ]; then \
cd /tmp/leintest && lein deps :tree | grep -q idconfiguratortestpkg; \
else \
echo "skipping leiningen: it cannot use a non-HTTPS repository"; \
fi
RUN mkdir -p /tmp/sbttest/project \
&& cat > /tmp/sbttest/build.sbt <<'EOF'
+2 -1
View File
@@ -5,6 +5,7 @@ COPY --from=generated-config Python.yaml /config.yaml
RUN id-configurator apply --config /config.yaml --src /src --dst /dst
FROM target AS runtime
ARG PYPI_REPO_PORT
ARG PYPI_REPO_SCHEME
# Warm up uv and poetry themselves from the real, network-reachable PyPI
# while the default index is still in place, so the actual tests below
# never need anything but the fixture repository configured by
@@ -20,5 +21,5 @@ RUN uv pip install --system idconfiguratortestpkg \
&& python3 -c "import idconfiguratortestpkg"
RUN mkdir -p /tmp/poetrytest && cd /tmp/poetrytest \
&& poetry init -n --name poetrytest \
&& poetry source add --priority explicit demo "https://host.docker.internal:${PYPI_REPO_PORT}/simple/" \
&& poetry source add --priority explicit demo "${PYPI_REPO_SCHEME}://host.docker.internal:${PYPI_REPO_PORT}/simple/" \
&& poetry add idconfiguratortestpkg --source demo
+2 -1
View File
@@ -5,6 +5,7 @@ COPY --from=generated-config Python.yaml /config.yaml
RUN id-configurator apply --config /config.yaml --src /src --dst /dst
FROM target AS runtime
ARG PYPI_REPO_PORT
ARG PYPI_REPO_SCHEME
# Warm up uv and poetry themselves from the real, network-reachable PyPI
# while the default index is still in place, so the actual tests below
# never need anything but the fixture repository configured by
@@ -20,5 +21,5 @@ RUN uv pip install --system idconfiguratortestpkg \
&& python3 -c "import idconfiguratortestpkg"
RUN mkdir -p /tmp/poetrytest && cd /tmp/poetrytest \
&& poetry init -n --name poetrytest \
&& poetry source add --priority explicit demo "https://host.docker.internal:${PYPI_REPO_PORT}/simple/" \
&& poetry source add --priority explicit demo "${PYPI_REPO_SCHEME}://host.docker.internal:${PYPI_REPO_PORT}/simple/" \
&& poetry add idconfiguratortestpkg --source demo
@@ -8,3 +8,13 @@ server {
root /srv;
autoindex off;
}
# Plain-HTTP listener, for the module-*-http tox envs that verify id-configurator
# correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
}
@@ -8,3 +8,13 @@ server {
root /srv;
autoindex off;
}
# Plain-HTTP listener, for the module-*-http tox envs that verify id-configurator
# correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
}
+20 -2
View File
@@ -12,10 +12,28 @@ server {
# templates, since the host:port this fixture is actually reached on is
# only known once tox-docker starts the container, not at image-build
# time. $http_host reflects exactly what the client (cargo) used to
# connect, e.g. host.docker.internal:55123.
# connect, e.g. host.docker.internal:55123; $scheme likewise reflects
# whichever of this file's two listeners actually served the request.
location = /config.json {
default_type application/json;
sub_filter '__BASE_URL__' 'https://$http_host';
sub_filter '__BASE_URL__' '$scheme://$http_host';
sub_filter_once off;
sub_filter_types application/json;
}
}
# Plain-HTTP listener, for the module-rust-http tox env that verifies
# id-configurator correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
location = /config.json {
default_type application/json;
sub_filter '__BASE_URL__' '$scheme://$http_host';
sub_filter_once off;
sub_filter_types application/json;
}
@@ -8,3 +8,13 @@ server {
root /srv;
autoindex off;
}
# Plain-HTTP listener, for the module-*-http tox envs that verify id-configurator
# correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
}
@@ -8,3 +8,13 @@ server {
root /srv;
autoindex off;
}
# Plain-HTTP listener, for the module-*-http tox envs that verify id-configurator
# correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
}
+21 -2
View File
@@ -12,10 +12,29 @@ server {
# its tarball URL, since the host:port this fixture is actually reached
# on is only known once tox-docker starts the container, not at
# image-build time. $http_host reflects exactly what the client (npm)
# used to connect, e.g. host.docker.internal:55123.
# used to connect, e.g. host.docker.internal:55123; $scheme likewise
# reflects whichever of this file's two listeners actually served the
# request.
location = /idconfiguratortestpkg {
default_type application/json;
sub_filter '__BASE_URL__' 'https://$http_host';
sub_filter '__BASE_URL__' '$scheme://$http_host';
sub_filter_once off;
sub_filter_types application/json;
}
}
# Plain-HTTP listener, for the module-npm-http tox env that verifies
# id-configurator correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
location = /idconfiguratortestpkg {
default_type application/json;
sub_filter '__BASE_URL__' '$scheme://$http_host';
sub_filter_once off;
sub_filter_types application/json;
}
+20 -2
View File
@@ -12,10 +12,28 @@ server {
# fields, since the host:port this fixture is actually reached on is
# only known once tox-docker starts the container, not at image-build
# time. $http_host reflects exactly what the client (nuget) used to
# connect, e.g. host.docker.internal:55123.
# connect, e.g. host.docker.internal:55123; $scheme likewise reflects
# whichever of this file's two listeners actually served the request.
location = /index.json {
default_type application/json;
sub_filter '__BASE_URL__' 'https://$http_host';
sub_filter '__BASE_URL__' '$scheme://$http_host';
sub_filter_once off;
sub_filter_types application/json;
}
}
# Plain-HTTP listener, for the module-nuget-http tox env that verifies
# id-configurator correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
location = /index.json {
default_type application/json;
sub_filter '__BASE_URL__' '$scheme://$http_host';
sub_filter_once off;
sub_filter_types application/json;
}
+20 -2
View File
@@ -12,10 +12,28 @@ server {
# since the host:port this fixture is actually reached on is only known
# once tox-docker starts the container, not at image-build time.
# $http_host reflects exactly what the client (Composer) used to
# connect, e.g. host.docker.internal:55123.
# connect, e.g. host.docker.internal:55123; $scheme likewise reflects
# whichever of this file's two listeners actually served the request.
location = /packages.json {
default_type application/json;
sub_filter '__BASE_URL__' 'https://$http_host';
sub_filter '__BASE_URL__' '$scheme://$http_host';
sub_filter_once off;
sub_filter_types application/json;
}
}
# Plain-HTTP listener, for the module-php-http tox env that verifies
# id-configurator correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
location = /packages.json {
default_type application/json;
sub_filter '__BASE_URL__' '$scheme://$http_host';
sub_filter_once off;
sub_filter_types application/json;
}
@@ -8,3 +8,13 @@ server {
root /srv;
autoindex off;
}
# Plain-HTTP listener, for the module-*-http tox envs that verify id-configurator
# correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
}
@@ -8,3 +8,13 @@ server {
root /srv;
autoindex off;
}
# Plain-HTTP listener, for the module-*-http tox envs that verify id-configurator
# correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
}
@@ -8,3 +8,13 @@ server {
root /srv;
autoindex off;
}
# Plain-HTTP listener, for the module-*-http tox envs that verify id-configurator
# correctly configures a non-HTTPS repository.
server {
listen 8080 default_server;
server_name _;
root /srv;
autoindex off;
}
+1 -1
View File
@@ -361,6 +361,6 @@ def test_apply_sanitizes_source_name_with_slash(tmp_path) -> None:
AptModule().apply(context)
source_file = dst / "etc/apt/sources.list.d/.._.._evil.sources"
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()
+1 -1
View File
@@ -67,7 +67,7 @@ def test_apply_sanitizes_certificate_name_with_slash(tmp_path) -> None:
CertificatesModule().apply(context)
local_cert = dst / "usr/local/share/ca-certificates/.._.._evil.crt"
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
+66
View File
@@ -15,6 +15,12 @@ CONFIG_WITH_CERT = {
**MINIMAL_CONFIG,
"configurations": {"certificates": [{"name": "my-ca", "content": "fake"}]},
}
CONFIG_WITH_GO_REPO = {
**MINIMAL_CONFIG,
"configurations": {
"go": {"repositories": [{"name": "demo", "url": "https://example.invalid/"}]}
},
}
def write_config(tmp_path, data) -> str:
@@ -129,6 +135,66 @@ def test_apply_runs_relevant_module(tmp_path) -> None:
assert (dst / "etc/ssl/certs/ca-certificates.crt").read_bytes() == b"fake\n"
def test_apply_with_user_option_writes_per_user_config(tmp_path) -> None:
config_path = write_config(tmp_path, CONFIG_WITH_GO_REPO)
dst = tmp_path / "dst"
result = runner.invoke(
cli.app,
[
"apply",
"--config",
config_path,
"--dst",
str(dst),
"--user",
"alice",
"--user",
"root",
],
)
assert result.exit_code == 0
assert (dst / "home/alice/.config/go/env").read_text(encoding="utf-8") == (
"GOPROXY=https://example.invalid/\nGOSUMDB=off\n"
)
assert (dst / "root/.config/go/env").read_text(encoding="utf-8") == (
"GOPROXY=https://example.invalid/\nGOSUMDB=off\n"
)
def test_apply_with_auto_users_reads_passwd_from_src(tmp_path) -> None:
config_path = write_config(tmp_path, CONFIG_WITH_GO_REPO)
src = tmp_path / "src"
(src / "etc").mkdir(parents=True)
(src / "etc/passwd").write_text(
"root:x:0:0:root:/root:/bin/bash\n"
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n"
"daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n",
encoding="utf-8",
)
dst = tmp_path / "dst"
result = runner.invoke(
cli.app,
[
"apply",
"--config",
config_path,
"--dst",
str(dst),
"--src",
str(src),
"--auto-users",
],
)
assert result.exit_code == 0
# root is always included despite its uid 0; daemon (nologin shell) is
# a system account, excluded.
assert (dst / "root/.config/go/env").exists()
assert (dst / "home/alice/.config/go/env").exists()
assert not (dst / "usr/sbin/.config/go/env").exists()
def test_main_default_name_calls_app(monkeypatch) -> None:
mock_app = MagicMock()
monkeypatch.setattr(cli, "app", mock_app)
+132 -2
View File
@@ -8,14 +8,16 @@ CONFIG = Configuration.model_validate(
)
def make_context(tmp_path, with_src: bool = True) -> Context:
def make_context(
tmp_path, with_src: bool = True, users=None, auto_users: bool = False
) -> Context:
dst = tmp_path / "dst"
dst.mkdir()
src = None
if with_src:
src = tmp_path / "src"
src.mkdir()
return Context(config=CONFIG, dst=dst, src=src)
return Context(config=CONFIG, dst=dst, src=src, users=users, auto_users=auto_users)
def test_dst_path_within_destination(tmp_path) -> None:
@@ -97,3 +99,131 @@ def test_glob_src_matches_pattern(tmp_path) -> None:
(context.src / "usr/lib/python-legacy").mkdir(parents=True)
result = context.glob_src("usr/lib/python3.*")
assert [entry.name for entry in result] == ["python3.11", "python3.12"]
def test_users_defaults_to_empty_list(tmp_path) -> None:
context = make_context(tmp_path, with_src=False)
assert context.users == []
def test_users_keeps_explicit_names_in_order_without_duplicates(tmp_path) -> None:
context = make_context(tmp_path, with_src=False, users=["alice", "bob", "alice"])
assert context.users == ["alice", "bob"]
def test_auto_users_expands_from_passwd_excluding_disabled_shells(tmp_path) -> None:
dst = tmp_path / "dst"
dst.mkdir()
src = tmp_path / "src"
(src / "etc").mkdir(parents=True)
(src / "etc/passwd").write_text(
"root:x:0:0:root:/root:/bin/bash\n"
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n"
"daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n",
encoding="utf-8",
)
context = Context(config=CONFIG, dst=dst, src=src, auto_users=True)
# root is always included despite its uid 0; daemon (nologin shell) is
# excluded as a system account.
assert context.users == ["root", "alice"]
def test_auto_users_excludes_root_with_disabled_shell(tmp_path) -> None:
dst = tmp_path / "dst"
dst.mkdir()
src = tmp_path / "src"
(src / "etc").mkdir(parents=True)
(src / "etc/passwd").write_text(
"root:x:0:0:root:/root:/usr/sbin/nologin\n"
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n",
encoding="utf-8",
)
context = Context(config=CONFIG, dst=dst, src=src, auto_users=True)
assert context.users == ["alice"]
def test_auto_users_excludes_uid_below_default_uid_min(tmp_path) -> None:
dst = tmp_path / "dst"
dst.mkdir()
src = tmp_path / "src"
(src / "etc").mkdir(parents=True)
(src / "etc/passwd").write_text(
"svc:x:999:999:service account:/home/svc:/bin/bash\n"
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n",
encoding="utf-8",
)
context = Context(config=CONFIG, dst=dst, src=src, auto_users=True)
assert context.users == ["alice"]
def test_auto_users_honors_uid_min_from_login_defs(tmp_path) -> None:
dst = tmp_path / "dst"
dst.mkdir()
src = tmp_path / "src"
(src / "etc").mkdir(parents=True)
(src / "etc/passwd").write_text(
"svc:x:999:999:service account:/home/svc:/bin/bash\n"
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n",
encoding="utf-8",
)
(src / "etc/login.defs").write_text("UID_MIN\t500\n", encoding="utf-8")
context = Context(config=CONFIG, dst=dst, src=src, auto_users=True)
assert context.users == ["svc", "alice"]
def test_auto_users_combines_with_explicit_names_without_duplicates(tmp_path) -> None:
dst = tmp_path / "dst"
dst.mkdir()
src = tmp_path / "src"
src.mkdir()
(src / "etc").mkdir()
(src / "etc/passwd").write_text(
"root:x:0:0:root:/root:/bin/bash\nalice:x:1000:1000:Alice:/home/alice:/bin/bash\n",
encoding="utf-8",
)
context = Context(
config=CONFIG, dst=dst, src=src, users=["bob", "alice"], auto_users=True
)
# explicit names come first, in order; auto-detected names are appended,
# skipping "alice" since it's already present.
assert context.users == ["bob", "alice", "root"]
def test_auto_users_false_ignores_passwd(tmp_path) -> None:
dst = tmp_path / "dst"
dst.mkdir()
src = tmp_path / "src"
(src / "etc").mkdir(parents=True)
(src / "etc/passwd").write_text(
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n", encoding="utf-8"
)
context = Context(config=CONFIG, dst=dst, src=src)
assert context.users == []
def test_auto_users_without_src_is_empty(tmp_path) -> None:
context = make_context(tmp_path, with_src=False, auto_users=True)
assert context.users == []
def test_home_dir_for_root(tmp_path) -> None:
context = make_context(tmp_path, with_src=False)
assert context.home_dir("root") == "root"
def test_home_dir_falls_back_to_convention(tmp_path) -> None:
context = make_context(tmp_path, with_src=False)
assert context.home_dir("alice") == "home/alice"
def test_home_dir_uses_passwd_entry_when_available(tmp_path) -> None:
dst = tmp_path / "dst"
dst.mkdir()
src = tmp_path / "src"
src.mkdir()
(src / "etc").mkdir()
(src / "etc/passwd").write_text(
"alice:x:1000:1000:Alice:/srv/alice:/bin/bash\n", encoding="utf-8"
)
context = Context(config=CONFIG, dst=dst, src=src)
assert context.home_dir("alice") == "srv/alice"
+12
View File
@@ -55,6 +55,18 @@ def test_apply_writes_goproxy(tmp_path) -> None:
assert go_env == "GOPROXY=https://example.invalid/\nGOSUMDB=off\n"
def test_apply_writes_go_env_for_each_configured_user(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(
config=make_config(CONFIG_WITH_REPO), dst=dst, src=None, users=["alice"]
)
GoModule().apply(context)
go_env = (dst / "home/alice/.config/go/env").read_text(encoding="utf-8")
assert go_env == "GOPROXY=https://example.invalid/\nGOSUMDB=off\n"
def test_apply_joins_multiple_repositories_with_comma(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None)
+25
View File
@@ -339,6 +339,31 @@ def test_apply_writes_sbt_repositories(tmp_path) -> None:
)
def test_apply_marks_non_https_sbt_repository_as_insecure(tmp_path) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"java": {
"repositories": [
{"name": "interdiode", "url": "http://example.invalid/maven"}
]
}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), dst=dst, src=None)
JavaModule().apply(context)
repositories = (dst / "root/.sbt/repositories").read_text(encoding="utf-8")
assert repositories == (
"[repositories]\n"
" local\n"
" interdiode: http://example.invalid/maven, allowInsecureProtocol\n"
)
def test_apply_writes_lein_profiles(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(TWO_REPOS_CONFIG), dst=dst, src=None)
+18 -1
View File
@@ -29,8 +29,25 @@ def test_sanitize_filename_leaves_plain_names_untouched() -> None:
def test_sanitize_filename_replaces_forward_slash() -> None:
assert sanitize_filename("../../etc/passwd") == ".._.._etc_passwd"
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("") == "_"
+42
View File
@@ -63,6 +63,48 @@ def test_apply_writes_nuget_config(tmp_path) -> None:
)
def test_apply_marks_non_https_source_as_insecure(tmp_path) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"nuget": {
"repositories": [{"name": "demo", "url": "http://example.invalid/"}]
}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), dst=dst, src=None)
NuGetModule().apply(context)
content = (dst / "root/.nuget/NuGet/NuGet.Config").read_text(encoding="utf-8")
assert content == (
'<?xml version="1.0" encoding="utf-8"?>\n'
"<configuration>\n"
" <packageSources>\n"
" <clear />\n"
' <add key="demo" value="http://example.invalid/" allowInsecureConnections="true" />\n'
" </packageSources>\n"
"</configuration>\n"
)
def test_apply_writes_config_for_each_configured_user(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(
config=make_config(CONFIG_WITH_REPO), dst=dst, src=None, users=["alice", "root"]
)
NuGetModule().apply(context)
root_content = (dst / "root/.nuget/NuGet/NuGet.Config").read_text(encoding="utf-8")
alice_content = (dst / "home/alice/.nuget/NuGet/NuGet.Config").read_text(
encoding="utf-8"
)
assert alice_content == root_content
def test_apply_writes_multiple_sources_in_order(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None)
+66 -2
View File
@@ -80,7 +80,8 @@ def test_apply_writes_global_composer_config(tmp_path) -> None:
"name": "demo",
"type": "composer",
"url": "https://example.invalid/composer",
}
},
{"packagist.org": False},
]
}
@@ -93,7 +94,70 @@ def test_apply_writes_multiple_repositories_in_order(tmp_path) -> None:
config_path = dst / "root/.composer/config.json"
content = json.loads(config_path.read_text(encoding="utf-8"))
assert [repo["name"] for repo in content["repositories"]] == ["demo", "other"]
names = [repo["name"] for repo in content["repositories"] if "name" in repo]
assert names == ["demo", "other"]
assert {"packagist.org": False} in content["repositories"]
def test_apply_disables_secure_http_and_packagist_when_repo_not_https(
tmp_path,
) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"php": {
"repositories": [
{"name": "demo", "url": "https://example.invalid/composer"},
{"name": "insecure", "url": "http://other.invalid/composer"},
]
}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), dst=dst, src=None)
PhpModule().apply(context)
config_path = dst / "root/.composer/config.json"
content = json.loads(config_path.read_text(encoding="utf-8"))
assert content["config"] == {"secure-http": False}
assert {"packagist.org": False} in content["repositories"]
def test_apply_does_not_disable_secure_http_when_all_repos_https(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None)
PhpModule().apply(context)
config_path = dst / "root/.composer/config.json"
content = json.loads(config_path.read_text(encoding="utf-8"))
assert "config" not in content
def test_apply_always_disables_packagist(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None)
PhpModule().apply(context)
config_path = dst / "root/.composer/config.json"
content = json.loads(config_path.read_text(encoding="utf-8"))
assert {"packagist.org": False} in content["repositories"]
def test_apply_writes_composer_config_for_each_configured_user(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(
config=make_config(CONFIG_WITH_REPO), dst=dst, src=None, users=["alice"]
)
PhpModule().apply(context)
root_content = (dst / "root/.composer/config.json").read_bytes()
alice_content = (dst / "home/alice/.composer/config.json").read_bytes()
assert alice_content == root_content
def test_apply_without_certificates_does_not_write_openssl_ini(tmp_path) -> None:
+158
View File
@@ -1,3 +1,5 @@
import tomllib
from id_configurator.config import Configuration
from id_configurator.context import Context
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()
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:
config = Configuration.model_validate(TWO_REPOS_CONFIG)
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:
config = Configuration.model_validate(ONE_REPO_CONFIG)
src = tmp_path / "src"
+104 -3
View File
@@ -6,7 +6,15 @@ CONFIG_WITH_REPO = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"rust": {"repositories": [{"name": "demo", "url": "https://example.invalid/"}]}
"rust": {
"repositories": [
{
"name": "demo",
"url": "https://example.invalid/",
"is_sparse": True,
}
]
}
},
}
@@ -16,8 +24,16 @@ CONFIG_WITH_TWO_REPOS = {
"configurations": {
"rust": {
"repositories": [
{"name": "demo", "url": "https://example.invalid/"},
{"name": "other", "url": "https://other.invalid/"},
{
"name": "demo",
"url": "https://example.invalid/",
"is_sparse": True,
},
{
"name": "other",
"url": "https://other.invalid/",
"is_sparse": True,
},
]
}
},
@@ -61,6 +77,91 @@ def test_apply_writes_replacement_source(tmp_path) -> None:
)
def test_apply_sanitizes_repository_names_with_special_characters(tmp_path) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"rust": {
"repositories": [
{
"name": "weird.demo]\nname",
"url": "https://example.invalid/",
"is_sparse": True,
}
]
}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), dst=dst, src=None)
RustModule().apply(context)
config_toml = (dst / ".cargo/config.toml").read_text(encoding="utf-8")
assert config_toml == (
"[source.crates-io]\n"
'replace-with = "weird_demo__name"\n'
"\n"
"[source.weird_demo__name]\n"
'registry = "sparse+https://example.invalid/"\n'
)
def test_apply_does_not_prefix_url_when_not_sparse(tmp_path) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"rust": {
"repositories": [{"name": "demo", "url": "https://example.invalid/"}]
}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), dst=dst, src=None)
RustModule().apply(context)
config_toml = (dst / ".cargo/config.toml").read_text(encoding="utf-8")
assert config_toml == (
"[source.crates-io]\n"
'replace-with = "demo"\n'
"\n"
"[source.demo]\n"
'registry = "https://example.invalid/"\n'
)
def test_apply_prefixes_url_with_sparse_when_configured(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None)
RustModule().apply(context)
config_toml = (dst / ".cargo/config.toml").read_text(encoding="utf-8")
assert config_toml == (
"[source.crates-io]\n"
'replace-with = "demo"\n'
"\n"
"[source.demo]\n"
'registry = "sparse+https://example.invalid/"\n'
)
def test_apply_writes_cargo_config_for_each_configured_user(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(
config=make_config(CONFIG_WITH_REPO), dst=dst, src=None, users=["alice"]
)
RustModule().apply(context)
root_content = (dst / ".cargo/config.toml").read_text(encoding="utf-8")
alice_content = (dst / "home/alice/.cargo/config.toml").read_text(encoding="utf-8")
assert alice_content == root_content
def test_apply_writes_additional_repositories_as_named_registries(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None)
+101
View File
@@ -0,0 +1,101 @@
from id_configurator.users import PasswdEntry, parse_login_defs_uid_min, parse_passwd
PASSWD_CONTENT = (
"root:x:0:0:root:/root:/bin/bash\n"
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n"
"daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n"
"sync:x:4:65534:sync:/bin:/bin/sync\n"
"nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/false\n"
"# a comment\n"
"\n"
)
def test_parse_passwd_returns_one_entry_per_line() -> None:
entries = parse_passwd(PASSWD_CONTENT)
assert [entry.name for entry in entries] == [
"root",
"alice",
"daemon",
"sync",
"nobody",
]
def test_parse_passwd_skips_comments_and_blank_lines() -> None:
entries = parse_passwd(PASSWD_CONTENT)
assert len(entries) == 5
def test_parse_passwd_extracts_uid_home_and_shell() -> None:
entries = {entry.name: entry for entry in parse_passwd(PASSWD_CONTENT)}
assert entries["alice"].uid == 1000
assert entries["alice"].home == "/home/alice"
assert entries["alice"].shell == "/bin/bash"
def test_parse_passwd_skips_malformed_lines() -> None:
entries = parse_passwd(
"tooshort:x:0\nnotanumber:x:abc:0:x:/home/x:/bin/bash\n"
"root:x:0:0:root:/root:/bin/bash\n"
)
assert [entry.name for entry in entries] == ["root"]
def test_has_login_shell_true_for_regular_shell() -> None:
entry = PasswdEntry(name="alice", uid=1000, home="/home/alice", shell="/bin/bash")
assert entry.has_login_shell is True
def test_has_login_shell_false_for_nologin_sync_or_false() -> None:
nologin = PasswdEntry(
name="daemon", uid=1, home="/usr/sbin", shell="/usr/sbin/nologin"
)
sync = PasswdEntry(name="sync", uid=4, home="/bin", shell="/bin/sync")
false_shell = PasswdEntry(
name="nobody", uid=65534, home="/nonexistent", shell="/usr/sbin/false"
)
assert nologin.has_login_shell is False
assert sync.has_login_shell is False
assert false_shell.has_login_shell is False
def test_is_regular_user_true_for_human_account() -> None:
entry = PasswdEntry(name="alice", uid=1000, home="/home/alice", shell="/bin/bash")
assert entry.is_regular_user() is True
def test_is_regular_user_true_for_root_despite_uid_zero() -> None:
entry = PasswdEntry(name="root", uid=0, home="/root", shell="/bin/bash")
assert entry.is_regular_user() is True
def test_is_regular_user_false_for_root_with_disabled_shell() -> None:
entry = PasswdEntry(name="root", uid=0, home="/root", shell="/usr/sbin/nologin")
assert entry.is_regular_user() is False
def test_is_regular_user_false_for_disabled_shell_despite_high_uid() -> None:
entry = PasswdEntry(
name="nobody", uid=65534, home="/nonexistent", shell="/usr/sbin/false"
)
assert entry.is_regular_user() is False
def test_is_regular_user_honors_custom_uid_min() -> None:
entry = PasswdEntry(name="svc", uid=500, home="/home/svc", shell="/bin/bash")
assert entry.is_regular_user(uid_min=1000) is False
assert entry.is_regular_user(uid_min=100) is True
def test_parse_login_defs_uid_min_found() -> None:
content = "# comment\nUID_MIN\t500\nGID_MIN\t500\n"
assert parse_login_defs_uid_min(content) == 500
def test_parse_login_defs_uid_min_missing() -> None:
assert parse_login_defs_uid_min("GID_MIN\t500\n") is None
def test_parse_login_defs_uid_min_malformed_value() -> None:
assert parse_login_defs_uid_min("UID_MIN\tnot-a-number\n") is None
+31 -10
View File
@@ -313,10 +313,26 @@ def test_apply_matches_index_url_to_replace_with_unrelated_files_and_multiple_re
assert (dst / "etc/yum.repos.d/second.repo").exists()
def test_apply_sanitizes_repository_and_key_names_with_slash(
tmp_path, monkeypatch
) -> None:
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",
@@ -324,11 +340,10 @@ def test_apply_sanitizes_repository_and_key_names_with_slash(
"yum": {
"repositories": [
{
"name": "../../evil-repo",
"name": "weird/repo]here[bad",
"index-url": "https://example.invalid/yum",
}
],
"keys": [{"name": "../../evil-key", "content": KEY_CONTENT}],
]
}
},
}
@@ -337,10 +352,16 @@ def test_apply_sanitizes_repository_and_key_names_with_slash(
YumModule().apply(context)
assert (dst / "etc/yum.repos.d/.._.._evil-repo.repo").exists()
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()
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:
+296 -4
View File
@@ -1,5 +1,5 @@
[tox]
env_list = py313,py314,coverage,packaging,packaging-amd64,packaging-arm64,publish,module-python,module-apt,module-yum,module-apk,module-java,module-certificates,module-php,module-npm,module-rust,module-go,module-ruby,module-nuget,config-url
env_list = py313,py314,coverage,packaging,packaging-amd64,packaging-arm64,publish,module-python,module-python-http,module-apt,module-apt-http,module-yum,module-yum-http,module-apk,module-apk-http,module-java,module-java-http,module-certificates,module-php,module-php-http,module-npm,module-npm-http,module-rust,module-rust-http,module-go,module-go-http,module-ruby,module-ruby-http,module-nuget,module-nuget-http,config-url
isolated_build = true
requires =
tox>=4
@@ -91,6 +91,7 @@ passenv =
dockerfile = {tox_root}/tests/dockerfiles/repos/pypi-repo.Dockerfile
expose =
PYPI_REPO_PORT=8443/tcp
PYPI_REPO_HTTP_PORT=8080/tcp
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/simple/idconfiguratortestpkg/ || exit 1
healthcheck_interval = 1
healthcheck_retries = 30
@@ -106,17 +107,45 @@ docker =
pypi-repo
allowlist_externals =
docker
set_env =
PYPI_REPO_SCHEME = https
commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Python.yaml.j2 {env_dir}/configs/Python.yaml
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg PYPI_REPO_PORT={env:PYPI_REPO_PORT} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Python-alpine.Dockerfile -t id-configurator-python .
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg PYPI_REPO_SCHEME={env:PYPI_REPO_SCHEME} --build-arg PYPI_REPO_PORT={env:PYPI_REPO_PORT} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Python-alpine.Dockerfile -t id-configurator-python .
docker rmi id-configurator-python
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg PYPI_REPO_PORT={env:PYPI_REPO_PORT} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Python.Dockerfile -t id-configurator-python .
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg PYPI_REPO_SCHEME={env:PYPI_REPO_SCHEME} --build-arg PYPI_REPO_PORT={env:PYPI_REPO_PORT} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Python.Dockerfile -t id-configurator-python .
docker rmi id-configurator-python
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]
dockerfile = {tox_root}/tests/dockerfiles/repos/apt-repo.Dockerfile
expose =
APT_REPO_PORT=8443/tcp
APT_REPO_HTTP_PORT=8080/tcp
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/Packages || exit 1
healthcheck_interval = 1
healthcheck_retries = 30
@@ -132,15 +161,41 @@ docker =
apt-repo
allowlist_externals =
docker
set_env =
APT_REPO_SCHEME = https
commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Apt.yaml.j2 {env_dir}/configs/Apt.yaml
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Apt.Dockerfile -t id-configurator-apt .
docker rmi id-configurator-apt
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]
dockerfile = {tox_root}/tests/dockerfiles/repos/yum-repo.Dockerfile
expose =
YUM_REPO_PORT=8443/tcp
YUM_REPO_HTTP_PORT=8080/tcp
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/repodata/repomd.xml || exit 1
healthcheck_interval = 1
healthcheck_retries = 30
@@ -156,15 +211,41 @@ docker =
yum-repo
allowlist_externals =
docker
set_env =
YUM_REPO_SCHEME = https
commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Yum.yaml.j2 {env_dir}/configs/Yum.yaml
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Yum.Dockerfile -t id-configurator-yum .
docker rmi id-configurator-yum
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]
dockerfile = {tox_root}/tests/dockerfiles/repos/apk-repo.Dockerfile
expose =
APK_REPO_PORT=8443/tcp
APK_REPO_HTTP_PORT=8080/tcp
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/APKINDEX.tar.gz || exit 1
healthcheck_interval = 1
healthcheck_retries = 30
@@ -189,15 +270,43 @@ description =
native platform keeps them in agreement everywhere (including under
QEMU emulation on non-amd64 hosts); CI runners are amd64, so this still
matches the other envs there.
set_env =
APK_REPO_SCHEME = https
commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Apk.yaml.j2 {env_dir}/configs/Apk.yaml
docker buildx build --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Apk.Dockerfile -t id-configurator-apk .
docker rmi id-configurator-apk
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]
dockerfile = {tox_root}/tests/dockerfiles/repos/java-repo.Dockerfile
expose =
JAVA_REPO_PORT=8443/tcp
JAVA_REPO_HTTP_PORT=8080/tcp
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/org/interdiode/idconfiguratortestpkg/0.0.1/idconfiguratortestpkg-0.0.1.pom || exit 1
healthcheck_interval = 1
healthcheck_retries = 30
@@ -213,10 +322,36 @@ docker =
java-repo
allowlist_externals =
docker
set_env =
JAVA_REPO_SCHEME = https
commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Java.yaml.j2 {env_dir}/configs/Java.yaml
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Java.Dockerfile -t id-configurator-java .
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg JAVA_REPO_SCHEME={env:JAVA_REPO_SCHEME} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Java.Dockerfile -t id-configurator-java .
docker rmi id-configurator-java
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]
dockerfile = {tox_root}/tests/dockerfiles/repos/certificate-repo.Dockerfile
@@ -246,11 +381,14 @@ commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Certificate.yaml.j2 {env_dir}/configs/Certificate.yaml
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg CERTIFICATE_REPO_PORT={env:CERTIFICATE_REPO_PORT} --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Certificate.Dockerfile -t id-configurator-certificates .
docker rmi id-configurator-certificates
commands_post =
docker buildx prune --all --force
[docker:php-repo]
dockerfile = {tox_root}/tests/dockerfiles/repos/php-repo.Dockerfile
expose =
PHP_REPO_PORT=8443/tcp
PHP_REPO_HTTP_PORT=8080/tcp
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/packages.json || exit 1
healthcheck_interval = 1
healthcheck_retries = 30
@@ -266,15 +404,41 @@ docker =
php-repo
allowlist_externals =
docker
set_env =
PHP_REPO_SCHEME = https
commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Php.yaml.j2 {env_dir}/configs/Php.yaml
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Php.Dockerfile -t id-configurator-php .
docker rmi id-configurator-php
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]
dockerfile = {tox_root}/tests/dockerfiles/repos/npm-repo.Dockerfile
expose =
NPM_REPO_PORT=8443/tcp
NPM_REPO_HTTP_PORT=8080/tcp
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/idconfiguratortestpkg || exit 1
healthcheck_interval = 1
healthcheck_retries = 30
@@ -290,15 +454,40 @@ docker =
npm-repo
allowlist_externals =
docker
set_env =
NPM_REPO_SCHEME = https
commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Npm.yaml.j2 {env_dir}/configs/Npm.yaml
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Npm.Dockerfile -t id-configurator-npm .
docker rmi id-configurator-npm
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]
dockerfile = {tox_root}/tests/dockerfiles/repos/cargo-repo.Dockerfile
expose =
CARGO_REPO_PORT=8443/tcp
CARGO_REPO_HTTP_PORT=8080/tcp
healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/config.json || exit 1
healthcheck_interval = 1
healthcheck_retries = 30
@@ -314,15 +503,40 @@ docker =
cargo-repo
allowlist_externals =
docker
set_env =
CARGO_REPO_SCHEME = https
commands =
python3 -m id_tools test render-config {tox_root}/tests/configs/Rust.yaml.j2 {env_dir}/configs/Rust.yaml
docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-context generated-config={env_dir}/configs -f {tox_root}/tests/dockerfiles/Rust.Dockerfile -t id-configurator-rust .
docker rmi id-configurator-rust
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
@@ -338,15 +552,41 @@ 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
@@ -362,15 +602,40 @@ 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
@@ -386,10 +651,35 @@ 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
@@ -419,3 +709,5 @@ description =
commands =
sh -c 'docker buildx build --platform linux/amd64 --add-host=host.docker.internal:host-gateway --build-arg CONFIG_REPO_PORT={env:CONFIG_REPO_PORT} --build-arg CONFIG_URL_CACERT_CONTENT="$(cat {tox_root}/tests/certs/test-ca.crt.pem)" -f {tox_root}/tests/dockerfiles/ConfigUrl.Dockerfile -t id-configurator-config-url .'
docker rmi id-configurator-config-url
commands_post =
docker buildx prune --all --force
Generated
+426 -377
View File
File diff suppressed because it is too large Load Diff