#!/usr/bin/env python3 """Install a local Java/Maven toolchain under .local without sudo.""" from __future__ import annotations import argparse import hashlib import json import os import shutil import subprocess import tarfile import urllib.request from datetime import datetime, timezone from pathlib import Path from typing import Any DEFAULT_JDK_URL = ( "https://api.adoptium.net/v3/binary/latest/17/ga/linux/x64/" "jdk/hotspot/normal/eclipse?project=jdk" ) DEFAULT_MAVEN_VERSION = "3.9.11" DEFAULT_MAVEN_URL = ( "https://archive.apache.org/dist/maven/maven-3/{version}/binaries/" "apache-maven-{version}-bin.tar.gz" ) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--install-dir", type=Path, default=Path(".local") / "toolchains") parser.add_argument("--jdk-url", default=DEFAULT_JDK_URL) parser.add_argument("--jdk-sha256", default="") parser.add_argument("--maven-version", default=DEFAULT_MAVEN_VERSION) parser.add_argument("--maven-url", default="") parser.add_argument("--skip-jdk", action="store_true") parser.add_argument("--skip-maven", action="store_true") parser.add_argument("--force", action="store_true") args = parser.parse_args() extension_root = Path(__file__).resolve().parents[1] install_dir = (extension_root / args.install_dir).resolve() summary = install_toolchain( install_dir=install_dir, jdk_url=args.jdk_url, jdk_sha256=args.jdk_sha256 or None, maven_version=args.maven_version, maven_url=args.maven_url or DEFAULT_MAVEN_URL.format(version=args.maven_version), install_jdk=not args.skip_jdk, install_maven=not args.skip_maven, force=args.force, ) print(json.dumps(summary, indent=2, sort_keys=True)) return 0 if summary["status"] == "ready" else 2 def install_toolchain( install_dir: Path, jdk_url: str, jdk_sha256: str | None, maven_version: str, maven_url: str, install_jdk: bool = True, install_maven: bool = True, force: bool = False, ) -> dict[str, Any]: downloads_dir = install_dir / "downloads" jdks_dir = install_dir / "jdks" mavens_dir = install_dir / "mavens" downloads_dir.mkdir(parents=True, exist_ok=True) jdks_dir.mkdir(parents=True, exist_ok=True) mavens_dir.mkdir(parents=True, exist_ok=True) jdk_home = _existing_link(install_dir / "current-jdk") maven_home = _existing_link(install_dir / "current-maven") downloads: list[dict[str, Any]] = [] if install_jdk and (force or jdk_home is None): archive = downloads_dir / "temurin-jdk-17-linux-x64.tar.gz" downloads.append(_download(jdk_url, archive, expected_sha256=jdk_sha256)) if force: _remove_link_or_dir(install_dir / "current-jdk") jdk_home = _extract_tool(archive, jdks_dir, "bin/java") _replace_symlink_or_copy(jdk_home, install_dir / "current-jdk") if install_maven and (force or maven_home is None): archive = downloads_dir / f"apache-maven-{maven_version}-bin.tar.gz" downloads.append(_download(maven_url, archive)) _verify_maven_sha512(maven_url, archive) if force: _remove_link_or_dir(install_dir / "current-maven") maven_home = _extract_tool(archive, mavens_dir, "bin/mvn") _replace_symlink_or_copy(maven_home, install_dir / "current-maven") jdk_home = _existing_link(install_dir / "current-jdk") maven_home = _existing_link(install_dir / "current-maven") env_path = install_dir / "env.sh" summary_path = install_dir / "toolchain-summary.json" probes = _probe_tools(jdk_home, maven_home) status = "ready" if probes["java"]["available"] and probes["maven"]["available"] else "blocked" if jdk_home is not None and maven_home is not None: _write_env(env_path, jdk_home, maven_home) summary = { "id": "opencmis-local-toolchain", "status": status, "created_at": _now(), "install_dir": str(install_dir), "jdk_home": str(jdk_home) if jdk_home else None, "maven_home": str(maven_home) if maven_home else None, "env_path": str(env_path) if env_path.exists() else None, "summary_path": str(summary_path), "downloads": downloads, "probes": probes, } summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8") return summary def _download(url: str, destination: Path, expected_sha256: str | None = None) -> dict[str, Any]: destination.parent.mkdir(parents=True, exist_ok=True) with _open_url(url, timeout=120) as response: with destination.open("wb") as handle: shutil.copyfileobj(response, handle) digest = _sha256(destination) if expected_sha256 and digest.lower() != expected_sha256.lower(): raise ValueError(f"SHA256 mismatch for {destination.name}") return { "url": url, "path": str(destination), "bytes": destination.stat().st_size, "sha256": digest, } def _verify_maven_sha512(url: str, archive: Path) -> None: with _open_url(url + ".sha512", timeout=60) as response: expected = response.read().decode("utf-8").strip().split()[0] actual = hashlib.sha512(archive.read_bytes()).hexdigest() if actual.lower() != expected.lower(): raise ValueError(f"SHA512 mismatch for {archive.name}") def _open_url(url: str, timeout: int): request = urllib.request.Request( url, headers={"User-Agent": "open-cmis-tck-local-toolchain/0.1"}, ) return urllib.request.urlopen(request, timeout=timeout) def _extract_tool(archive: Path, destination: Path, marker: str) -> Path: before = {path.resolve() for path in destination.iterdir()} if destination.exists() else set() with tarfile.open(archive, "r:gz") as handle: _safe_extract(handle, destination) after = {path.resolve() for path in destination.iterdir()} candidates = sorted(after - before) if not candidates: candidates = sorted(after) for candidate in candidates: if (candidate / marker).exists(): return candidate for candidate in sorted(after): if (candidate / marker).exists(): return candidate raise ValueError(f"Could not find extracted tool marker {marker!r} from {archive.name}") def _safe_extract(handle: tarfile.TarFile, destination: Path) -> None: destination = destination.resolve() for member in handle.getmembers(): target = (destination / member.name).resolve() try: target.relative_to(destination) except ValueError as exc: raise ValueError(f"Archive member escapes destination: {member.name}") from exc handle.extractall(destination) def _replace_symlink_or_copy(source: Path, link: Path) -> None: _remove_link_or_dir(link) try: link.symlink_to(source, target_is_directory=True) except OSError: shutil.copytree(source, link) def _remove_link_or_dir(path: Path) -> None: if path.is_symlink() or path.is_file(): path.unlink() elif path.exists(): shutil.rmtree(path) def _existing_link(path: Path) -> Path | None: if not path.exists(): return None return path.resolve() def _probe_tools(jdk_home: Path | None, maven_home: Path | None) -> dict[str, Any]: java = _probe([str(jdk_home / "bin" / "java"), "-version"]) if jdk_home else _missing_probe() env = os.environ.copy() if jdk_home: env["JAVA_HOME"] = str(jdk_home) env["PATH"] = f"{jdk_home / 'bin'}{os.pathsep}{env.get('PATH', '')}" maven = _probe([str(maven_home / "bin" / "mvn"), "-version"], env=env) if maven_home else _missing_probe() return {"java": java, "maven": maven} def _probe(command: list[str], env: dict[str, str] | None = None) -> dict[str, Any]: completed = subprocess.run( command, capture_output=True, text=True, timeout=30, check=False, env=env, ) output = "\n".join(part.strip() for part in [completed.stdout, completed.stderr] if part.strip()) return { "available": completed.returncode == 0, "command": command, "returncode": completed.returncode, "version_output": output[:4000], } def _missing_probe() -> dict[str, Any]: return { "available": False, "command": None, "returncode": None, "version_output": None, } def _write_env(path: Path, jdk_home: Path, maven_home: Path) -> None: path.write_text( "\n".join( [ "# Source this file to use the local OpenCMIS TCK toolchain.", f"export JAVA_HOME='{jdk_home}'", f"export MAVEN_HOME='{maven_home}'", 'export PATH="$JAVA_HOME/bin:$MAVEN_HOME/bin:$PATH"', "", ] ), encoding="utf-8", ) def _sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def _now() -> str: return datetime.now(timezone.utc).isoformat() if __name__ == "__main__": raise SystemExit(main())