Public Access
DEVX-150: feat(setup): mirror Ansible collections from Gitea registry with auth
Co-authored-by: emil User <emil.simeonov@tutanota.com>
This commit was merged in pull request #255.
This commit is contained in:
@@ -251,18 +251,62 @@ def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||
process.wait()
|
||||
# Clean up containers left behind by the killed test.
|
||||
click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario))
|
||||
destroy_cmd = ["molecule", "destroy"]
|
||||
if scenario != "default":
|
||||
destroy_cmd.extend(["-s", scenario])
|
||||
with contextlib.suppress(subprocess.SubprocessError, OSError):
|
||||
subprocess.run( # nosec B603, B607
|
||||
destroy_cmd,
|
||||
cwd=str(cwd),
|
||||
env=env,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=120,
|
||||
)
|
||||
sys.exit(1)
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||
process.wait()
|
||||
# Clean up containers left behind by the interrupted test.
|
||||
click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario))
|
||||
destroy_cmd = ["molecule", "destroy"]
|
||||
if scenario != "default":
|
||||
destroy_cmd.extend(["-s", scenario])
|
||||
with contextlib.suppress(subprocess.SubprocessError, OSError):
|
||||
subprocess.run( # nosec B603, B607
|
||||
destroy_cmd,
|
||||
cwd=str(cwd),
|
||||
env=env,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=120,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
rc = process.returncode
|
||||
|
||||
if rc != 0:
|
||||
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
|
||||
# Run molecule destroy to clean up containers left behind by the
|
||||
# failed test. Without this, containers stay running and accumulate
|
||||
# on the runner, consuming disk/memory and degrading CI performance.
|
||||
click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario))
|
||||
destroy_cmd = ["molecule", "destroy"]
|
||||
if scenario != "default":
|
||||
destroy_cmd.extend(["-s", scenario])
|
||||
with contextlib.suppress(subprocess.SubprocessError, OSError):
|
||||
subprocess.run( # nosec B603, B607
|
||||
destroy_cmd,
|
||||
cwd=str(cwd),
|
||||
env=env,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=120,
|
||||
)
|
||||
sys.exit(rc)
|
||||
|
||||
click.echo(_("PASSED: {pair}", pair=pair))
|
||||
|
||||
+102
-2
@@ -15,6 +15,7 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
|
||||
from devx.tokens import get_developer_token
|
||||
|
||||
@@ -56,13 +57,112 @@ def _install_pre_commit_hooks(bin_dir: str) -> None:
|
||||
|
||||
|
||||
def _install_ansible_collections(bin_dir: str) -> None:
|
||||
"""Install required Ansible Galaxy collections if requirements exist."""
|
||||
"""Install required Ansible Galaxy collections if requirements exist.
|
||||
|
||||
If the requirements file uses ``type: url`` entries pointing to the
|
||||
Gitea package registry, downloads them with authentication (using
|
||||
``CI_GITEA_TOKEN`` / ``CI_GITEA_API_TOKEN``) and installs from local
|
||||
files with ``--offline``. Falls back to direct galaxy install if the
|
||||
mirror download fails or no token is available.
|
||||
|
||||
Retries up to 3 times with exponential backoff to handle transient
|
||||
network timeouts when contacting galaxy.ansible.com.
|
||||
"""
|
||||
galaxy = shutil.which("ansible-galaxy") or str(Path(bin_dir) / "ansible-galaxy")
|
||||
requirements = Path("ansible/requirements.yml")
|
||||
if not requirements.exists():
|
||||
click.echo(" ansible/requirements.yml not found — skipping collections.")
|
||||
return
|
||||
_run([galaxy, "collection", "install", "-r", str(requirements), "--no-cache"])
|
||||
|
||||
# Try Gitea mirror first if requirements use type: url
|
||||
if _try_gitea_mirror_install(galaxy, requirements):
|
||||
return
|
||||
|
||||
# Fall back to direct galaxy install with retries
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=2, min=2, max=10), reraise=True)
|
||||
def _do_install() -> None:
|
||||
_run([galaxy, "collection", "install", "-r", str(requirements)])
|
||||
|
||||
_do_install()
|
||||
|
||||
|
||||
def _try_gitea_mirror_install(galaxy: str, requirements: Path) -> bool:
|
||||
"""Download ``type: url`` entries from Gitea with auth and install locally.
|
||||
|
||||
Returns ``True`` if the mirror install succeeded, ``False`` to fall back
|
||||
to direct galaxy install.
|
||||
"""
|
||||
import tempfile
|
||||
import urllib.request # noqa: PTH123 # nosec B404
|
||||
|
||||
import yaml # pyright: ignore[reportMissingImports]
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(requirements.read_text())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
collections = data.get("collections", []) if data else []
|
||||
url_entries = [c for c in collections if c.get("type") == "url"]
|
||||
if not url_entries:
|
||||
return False
|
||||
|
||||
# Resolve Gitea token for authenticated downloads
|
||||
token = os.environ.get("CI_GITEA_API_TOKEN", "").strip()
|
||||
if not token:
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "").strip()
|
||||
if not token:
|
||||
token = os.environ.get("DEVELOPER_GITEA_API_TOKEN", "").strip()
|
||||
if not token:
|
||||
click.echo(" No Gitea token found — falling back to galaxy.ansible.com")
|
||||
return False
|
||||
|
||||
# Download each tarball with auth
|
||||
tmpdir = Path(tempfile.mkdtemp(prefix="ansible-collections-"))
|
||||
local_entries = []
|
||||
try:
|
||||
for entry in url_entries:
|
||||
source = entry.get("source", "")
|
||||
if "/api/packages/" not in source:
|
||||
local_entries.append(entry)
|
||||
continue
|
||||
filename = source.rsplit("/", 1)[-1]
|
||||
dest = tmpdir / filename
|
||||
click.echo(f" Downloading {entry.get('name', filename)} from Gitea mirror...")
|
||||
req = urllib.request.Request(source) # nosec B310
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: PTH123 # nosec B310
|
||||
dest.write_bytes(resp.read())
|
||||
except Exception as e:
|
||||
click.echo(f" WARN: mirror download failed for {entry.get('name')}: {e}")
|
||||
click.echo(" Falling back to galaxy.ansible.com")
|
||||
return False
|
||||
local_entries.append(
|
||||
{
|
||||
"name": entry["name"],
|
||||
"version": entry.get("version"),
|
||||
"type": "file",
|
||||
"source": str(dest),
|
||||
}
|
||||
)
|
||||
|
||||
# Add non-url entries as-is
|
||||
for entry in collections:
|
||||
if entry.get("type") != "url":
|
||||
local_entries.append(entry)
|
||||
|
||||
# Write local requirements file
|
||||
local_req = tmpdir / "requirements.yml"
|
||||
local_req.write_text(yaml.dump({"collections": local_entries}))
|
||||
|
||||
click.echo(" Installing collections from Gitea mirror (offline)...")
|
||||
_run([galaxy, "collection", "install", "-r", str(local_req), "--offline"])
|
||||
return True
|
||||
finally:
|
||||
import shutil as _shutil
|
||||
|
||||
_shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def _configure_tea_login() -> None:
|
||||
|
||||
@@ -3830,5 +3830,13 @@
|
||||
"pl": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
||||
"ru": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
||||
"zh": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)"
|
||||
},
|
||||
"Cleaning up: running molecule destroy for {scenario}": {
|
||||
"en": "Cleaning up: running molecule destroy for {scenario}",
|
||||
"bg": "Изчистване: изпълнение на molecule destroy за {scenario}",
|
||||
"de": "Aufräumen: molecule destroy wird ausgeführt für {scenario}",
|
||||
"pl": "Czyszczenie: uruchamianie molecule destroy dla {scenario}",
|
||||
"ru": "Очистка: запуск molecule destroy для {scenario}",
|
||||
"zh": "清理:正在为 {scenario} 运行 molecule destroy"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user