From 99085fb928f3b3cedc586128fa74182f2226ec05 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 8 May 2026 07:51:42 +0200 Subject: [PATCH] OpenCMIS in-memory server --- docs/LOCAL-RUNBOOK.md | 36 ++ docs/LOCAL-TCK-RUNTIME.md | 37 ++ .../cmis-browser-inmemory-pilot.json | 59 ++ profiles/targets/opencmis-inmemory-local.json | 36 ++ scripts/opencmis_inmemory_server.py | 531 ++++++++++++++++++ src/open_cmis_tck/preflight.py | 105 +++- tests/test_open_cmis_tck.py | 89 +++ ...IS-TCK-WP-0002-live-test-infrastructure.md | 32 +- 8 files changed, 919 insertions(+), 6 deletions(-) create mode 100644 profiles/assessments/cmis-browser-inmemory-pilot.json create mode 100644 profiles/targets/opencmis-inmemory-local.json create mode 100644 scripts/opencmis_inmemory_server.py diff --git a/docs/LOCAL-RUNBOOK.md b/docs/LOCAL-RUNBOOK.md index f83c92d..cf85248 100644 --- a/docs/LOCAL-RUNBOOK.md +++ b/docs/LOCAL-RUNBOOK.md @@ -128,6 +128,42 @@ The `.local/` directory is ignored and must not be committed. ## Real TCK Run +For a controlled local pilot target, start the Apache Chemistry OpenCMIS +in-memory server: + +```sh +cd /home/worsch/open-cmis-tck +source .local/toolchains/env.sh +export OPENCMIS_INMEMORY_USER=dummyuser +export OPENCMIS_INMEMORY_PASSWORD=dummysecret +python3 scripts/opencmis_inmemory_server.py start +``` + +Then run the in-memory pilot profile: + +```sh +cd /home/worsch/guide-board +source /home/worsch/open-cmis-tck/.local/toolchains/env.sh +export OPENCMIS_INMEMORY_USER=dummyuser +export OPENCMIS_INMEMORY_PASSWORD=dummysecret +PYTHONPATH=src python3 -m guide_board \ + --extension-dir ../open-cmis-tck \ + run \ + --target ../open-cmis-tck/profiles/targets/opencmis-inmemory-local.json \ + --assessment ../open-cmis-tck/profiles/assessments/cmis-browser-inmemory-pilot.json \ + --output-dir ../open-cmis-tck/.local/runs/opencmis-inmemory-pilot +``` + +Stop the local pilot server after the run: + +```sh +cd /home/worsch/open-cmis-tck +python3 scripts/opencmis_inmemory_server.py stop +``` + +The in-memory target is a test infrastructure pilot only. It proves the local +TCK path before running against the actual CMIS-capable system. + After bootstrap reports `ready`, run the baseline assessment: ```sh diff --git a/docs/LOCAL-TCK-RUNTIME.md b/docs/LOCAL-TCK-RUNTIME.md index b701a44..2695daa 100644 --- a/docs/LOCAL-TCK-RUNTIME.md +++ b/docs/LOCAL-TCK-RUNTIME.md @@ -117,6 +117,43 @@ case evidence. The retained raw stdout/stderr files remain the audit trail; the normalized result records OpenCMIS result statuses, test names, messages, source locations where present, and per-status counts. +## Local In-Memory Pilot Target + +The extension can also launch Apache Chemistry's in-memory server WAR under +Tomcat 9 as a controlled CMIS Browser Binding pilot target: + +```sh +python3 scripts/opencmis_inmemory_server.py start +python3 scripts/opencmis_inmemory_server.py probe +python3 scripts/opencmis_inmemory_server.py stop +``` + +The default Browser Binding URL is: + +```text +http://127.0.0.1:18080/inmemory/browser +``` + +The default repository ID from the bundled in-memory configuration is `A1`. +Runtime files, logs, downloaded WAR/Tomcat artifacts, and server state are kept +under `.local/opencmis-inmemory`. + +Because OpenCMIS 1.1.0 predates modern JDK module removals, the launcher also +adds Java EE API compatibility jars for JAX-WS, JWS, SOAP, and annotations to +the local Tomcat runtime. These files stay under `.local/`. + +The bundled in-memory repository uses the sample credentials from its default +configuration: + +```sh +export OPENCMIS_INMEMORY_USER=dummyuser +export OPENCMIS_INMEMORY_PASSWORD=dummysecret +``` + +Preflight and the TCK adapter both consume the target profile's +`credentials_ref`, so the local pilot profile uses these environment variables +without committing secrets. + ## Session Parameters For Browser Binding runs, the adapter writes OpenCMIS session parameters such diff --git a/profiles/assessments/cmis-browser-inmemory-pilot.json b/profiles/assessments/cmis-browser-inmemory-pilot.json new file mode 100644 index 0000000..8fa5611 --- /dev/null +++ b/profiles/assessments/cmis-browser-inmemory-pilot.json @@ -0,0 +1,59 @@ +{ + "id": "cmis-browser-inmemory-pilot", + "framework_refs": [ + "cmis.browser-binding.compatibility.v1" + ], + "extension_refs": [ + "open-cmis-tck" + ], + "target_profile_ref": "opencmis-inmemory-local", + "selected_check_groups": { + "open-cmis-tck": [ + "repository-type" + ] + }, + "expectations_ref": null, + "waivers_ref": null, + "output_policy": { + "report_formats": [ + "json", + "markdown" + ], + "artifact_retention": "raw-logs-plus-summary" + }, + "retention_policy": { + "summary_days": 365, + "raw_artifact_days": 30 + }, + "runtime_policy": { + "offline": false, + "timeout_seconds": 180, + "opencmis_tck": { + "requires_java_maven": true, + "repository_id": "A1", + "timeout_seconds": 150, + "command": [ + "python3", + "{extension_path}/adapters/opencmis_console_adapter.py", + "--browser-url", + "{browser_url}", + "--repository-id", + "{repository_id}", + "--check-group", + "{check_group}", + "--artifact-dir", + "{artifact_dir}", + "--run-dir", + "{run_dir}", + "--extension-path", + "{extension_path}", + "--credentials-ref", + "{credentials_ref}", + "--target-profile-dir", + "{target_profile_dir}", + "--timeout-seconds", + "{timeout_seconds}" + ] + } + } +} diff --git a/profiles/targets/opencmis-inmemory-local.json b/profiles/targets/opencmis-inmemory-local.json new file mode 100644 index 0000000..4c2b183 --- /dev/null +++ b/profiles/targets/opencmis-inmemory-local.json @@ -0,0 +1,36 @@ +{ + "id": "opencmis-inmemory-local", + "subject_type": "cmis-browser-binding-endpoint", + "subject_name": "Apache Chemistry OpenCMIS InMemory Local", + "environment": "local", + "scope": [ + "CMIS 1.1 Browser Binding local pilot" + ], + "endpoints": [ + { + "id": "browser-binding", + "url": "http://127.0.0.1:18080/inmemory/browser", + "binding": "cmis-browser" + } + ], + "artifacts": [], + "credentials_ref": "env:OPENCMIS_INMEMORY_USER,OPENCMIS_INMEMORY_PASSWORD", + "declared_capabilities": [ + "cmis.repository-info", + "cmis.type-definitions" + ], + "known_gaps": [ + { + "id": "pilot-target-not-production", + "requirement_refs": [ + "cmis.object-services", + "cmis.content-streams", + "cmis.query", + "cmis.acl", + "cmis.versioning" + ], + "reason": "The OpenCMIS in-memory server is used only as a local infrastructure pilot target.", + "status": "unsupported_by_design" + } + ] +} diff --git a/scripts/opencmis_inmemory_server.py b/scripts/opencmis_inmemory_server.py new file mode 100644 index 0000000..8c2069d --- /dev/null +++ b/scripts/opencmis_inmemory_server.py @@ -0,0 +1,531 @@ +#!/usr/bin/env python3 +"""Prepare and run a local Apache Chemistry OpenCMIS in-memory server.""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import signal +import subprocess +import tarfile +import time +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +SERVER_WAR_COORDINATE = "org.apache.chemistry.opencmis:chemistry-opencmis-server-inmemory:1.1.0:war" +TOMCAT_COORDINATE = "org.apache.tomcat:tomcat:9.0.117:tar.gz" +JAVAEE_COMPAT_COORDINATES = [ + "javax.xml.ws:jaxws-api:2.3.1:jar", + "javax.jws:javax.jws-api:1.1:jar", + "javax.xml.soap:javax.xml.soap-api:1.4.0:jar", + "javax.annotation:javax.annotation-api:1.3.2:jar", +] +DEFAULT_CONTEXT_PATH = "/inmemory" +DEFAULT_PORT = 18080 + + +def main() -> int: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + + prepare = subparsers.add_parser("prepare") + prepare.add_argument("--runtime-dir", type=Path, default=_default_runtime_dir()) + + start = subparsers.add_parser("start") + start.add_argument("--runtime-dir", type=Path, default=_default_runtime_dir()) + start.add_argument("--port", type=int, default=DEFAULT_PORT) + start.add_argument("--context-path", default=DEFAULT_CONTEXT_PATH) + start.add_argument("--timeout-seconds", type=int, default=90) + start.add_argument("--no-prepare", action="store_true") + + stop = subparsers.add_parser("stop") + stop.add_argument("--runtime-dir", type=Path, default=_default_runtime_dir()) + + probe = subparsers.add_parser("probe") + probe.add_argument("--runtime-dir", type=Path, default=_default_runtime_dir()) + probe.add_argument("--port", type=int, default=DEFAULT_PORT) + probe.add_argument("--context-path", default=DEFAULT_CONTEXT_PATH) + probe.add_argument("--timeout-seconds", type=int, default=5) + + status = subparsers.add_parser("status") + status.add_argument("--runtime-dir", type=Path, default=_default_runtime_dir()) + + args = parser.parse_args() + if args.command == "prepare": + result = prepare_runtime(args.runtime_dir) + elif args.command == "start": + result = start_server( + runtime_dir=args.runtime_dir, + port=args.port, + context_path=args.context_path, + timeout_seconds=args.timeout_seconds, + prepare=not args.no_prepare, + ) + elif args.command == "stop": + result = stop_server(args.runtime_dir) + elif args.command == "probe": + result = probe_server(args.runtime_dir, args.port, args.context_path, args.timeout_seconds) + elif args.command == "status": + result = status_server(args.runtime_dir) + else: + raise AssertionError(args.command) + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 if result["status"] in {"ready", "running", "stopped"} else 2 + + +def prepare_runtime(runtime_dir: Path) -> dict[str, Any]: + runtime_dir = runtime_dir.resolve() + runtime_dir.mkdir(parents=True, exist_ok=True) + maven = _maven_executable() + downloads = [] + for coordinate in [SERVER_WAR_COORDINATE, TOMCAT_COORDINATE, *JAVAEE_COMPAT_COORDINATES]: + completed = subprocess.run( + [ + str(maven), + "-q", + "-f", + str(_extension_root() / "runtime" / "opencmis-tck" / "pom.xml"), + "org.apache.maven.plugins:maven-dependency-plugin:3.7.1:copy", + f"-Dartifact={coordinate}", + f"-DoutputDirectory={runtime_dir}", + ], + capture_output=True, + text=True, + timeout=300, + check=False, + env=_tool_env(), + ) + downloads.append( + { + "coordinate": coordinate, + "returncode": completed.returncode, + "stdout": completed.stdout[-2000:], + "stderr": completed.stderr[-4000:], + } + ) + if completed.returncode != 0: + return _summary( + runtime_dir, + "blocked", + diagnostics=[ + { + "severity": "error", + "field": "runtime.maven", + "message": f"Maven could not resolve {coordinate}.", + } + ], + downloads=downloads, + ) + tomcat_home = _prepare_tomcat(runtime_dir) + _install_compat_libs(runtime_dir, tomcat_home) + return _summary(runtime_dir, "ready", downloads=downloads) + + +def start_server( + runtime_dir: Path, + port: int, + context_path: str, + timeout_seconds: int, + prepare: bool = True, +) -> dict[str, Any]: + runtime_dir = runtime_dir.resolve() + if prepare: + prepared = prepare_runtime(runtime_dir) + if prepared["status"] != "ready": + return prepared + + war = _server_war(runtime_dir) + tomcat_home = _tomcat_home(runtime_dir) + if war is None or tomcat_home is None: + return _summary( + runtime_dir, + "blocked", + diagnostics=[ + { + "severity": "error", + "field": "runtime.artifacts", + "message": "OpenCMIS in-memory WAR or Tomcat runtime is missing; run prepare first.", + } + ], + ) + + existing = status_server(runtime_dir) + if existing["status"] == "running": + return existing + + logs_dir = runtime_dir / "logs" + logs_dir.mkdir(parents=True, exist_ok=True) + log_path = logs_dir / "opencmis-inmemory.log" + state_path = runtime_dir / "server-state.json" + _deploy_war(tomcat_home, war, context_path, port) + command = [ + str(tomcat_home / "bin" / "catalina.sh"), + "run", + ] + log_handle = log_path.open("ab") + process = subprocess.Popen( + command, + cwd=tomcat_home, + stdout=log_handle, + stderr=subprocess.STDOUT, + start_new_session=True, + env=_tool_env(), + ) + state = { + "pid": process.pid, + "command": command, + "port": port, + "context_path": _normalized_context_path(context_path), + "container": "tomcat", + "tomcat_home": str(tomcat_home), + "browser_url": _browser_url(port, context_path), + "log_path": str(log_path), + "started_at": _now(), + } + state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + deadline = time.time() + timeout_seconds + probe_result: dict[str, Any] = {"status": "blocked"} + while time.time() < deadline: + if process.poll() is not None: + return _summary( + runtime_dir, + "blocked", + server=state, + diagnostics=[ + { + "severity": "error", + "field": "server.process", + "message": f"OpenCMIS in-memory server exited with {process.returncode}.", + } + ], + log_tail=_read_tail(log_path), + ) + probe_result = probe_server(runtime_dir, port, context_path, timeout_seconds=5) + if probe_result["status"] == "ready": + return _summary(runtime_dir, "running", server=state, probe=probe_result) + time.sleep(1) + + stop_server(runtime_dir) + return _summary( + runtime_dir, + "blocked", + server=state, + probe=probe_result, + diagnostics=[ + { + "severity": "error", + "field": "server.probe", + "message": f"OpenCMIS in-memory server did not become ready within {timeout_seconds} seconds.", + } + ], + log_tail=_read_tail(log_path), + ) + + +def stop_server(runtime_dir: Path) -> dict[str, Any]: + runtime_dir = runtime_dir.resolve() + state = _load_state(runtime_dir) + if not state: + return _summary(runtime_dir, "stopped") + pid = state.get("pid") + if isinstance(pid, int) and _is_process_running(pid): + _terminate_process_group(pid, signal.SIGTERM) + deadline = time.time() + 20 + while time.time() < deadline and _is_process_running(pid): + time.sleep(0.5) + if _is_process_running(pid): + _terminate_process_group(pid, signal.SIGKILL) + (runtime_dir / "server-state.json").unlink(missing_ok=True) + return _summary(runtime_dir, "stopped", server=state) + + +def probe_server( + runtime_dir: Path, + port: int, + context_path: str, + timeout_seconds: int = 5, +) -> dict[str, Any]: + runtime_dir = runtime_dir.resolve() + output_path = runtime_dir / "browser-probe.json" + attempts = [] + for url in _probe_urls(port, context_path): + try: + request = urllib.request.Request( + url, + headers={"Accept": "application/json, */*;q=0.1"}, + ) + with urllib.request.urlopen(request, timeout=timeout_seconds) as response: + body = response.read(1024 * 1024) + parsed = _parse_json(body) + attempts.append( + { + "url": url, + "http_status": response.status, + "content_type": response.headers.get("Content-Type"), + "body_preview": _body_preview(body), + } + ) + if parsed is not None: + output_path.write_bytes(body) + return _summary( + runtime_dir, + "ready", + probe={ + "url": url, + "http_status": response.status, + "repository_ids": _repository_ids(parsed), + "response_path": str(output_path), + "attempts": attempts, + }, + ) + except urllib.error.HTTPError as exc: + body = exc.read(64 * 1024) + attempts.append( + { + "url": url, + "http_status": exc.code, + "content_type": exc.headers.get("Content-Type"), + "body_preview": _body_preview(body), + } + ) + except (urllib.error.URLError, TimeoutError, UnicodeDecodeError) as exc: + attempts.append({"url": url, "error": str(exc)}) + return _summary(runtime_dir, "blocked", probe={"attempts": attempts}) + + +def status_server(runtime_dir: Path) -> dict[str, Any]: + runtime_dir = runtime_dir.resolve() + state = _load_state(runtime_dir) + if not state: + return _summary(runtime_dir, "stopped") + pid = state.get("pid") + running = isinstance(pid, int) and _is_process_running(pid) + return _summary(runtime_dir, "running" if running else "stopped", server=state) + + +def _summary(runtime_dir: Path, status: str, **extra: Any) -> dict[str, Any]: + summary = { + "id": "opencmis-inmemory-server", + "status": status, + "created_at": _now(), + "runtime_dir": str(runtime_dir.resolve()), + "server_war_coordinate": SERVER_WAR_COORDINATE, + "tomcat_coordinate": TOMCAT_COORDINATE, + "javaee_compat_coordinates": JAVAEE_COMPAT_COORDINATES, + "default_repository_id": "A1", + "default_browser_url": _browser_url(DEFAULT_PORT, DEFAULT_CONTEXT_PATH), + } + summary.update({key: value for key, value in extra.items() if value is not None}) + (runtime_dir / "inmemory-summary.json").write_text( + json.dumps(summary, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return summary + + +def _repository_ids(value: Any) -> list[str]: + if isinstance(value, dict) and "repositoryId" in value: + return [str(value["repositoryId"])] + if isinstance(value, dict): + ids = [] + for key, item in value.items(): + if isinstance(item, dict): + ids.append(str(item.get("repositoryId", key))) + return sorted(ids) + return [] + + +def _parse_json(body: bytes) -> Any: + try: + return json.loads(body.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + return None + + +def _body_preview(body: bytes, limit: int = 500) -> str: + return body[:limit].decode("utf-8", errors="replace") + + +def _probe_urls(port: int, context_path: str) -> list[str]: + base = f"http://127.0.0.1:{port}{_normalized_context_path(context_path)}" + return [ + f"{base}/browser", + f"{base}/browser/", + f"{base}/cmis-endpoints.json", + f"{base}/", + ] + + +def _server_war(runtime_dir: Path) -> Path | None: + matches = sorted(runtime_dir.glob("chemistry-opencmis-server-inmemory-*.war")) + return matches[-1] if matches else None + + +def _tomcat_archive(runtime_dir: Path) -> Path | None: + matches = sorted(runtime_dir.glob("tomcat-*.tar.gz")) + return matches[-1] if matches else None + + +def _tomcat_home(runtime_dir: Path) -> Path | None: + candidates = sorted((runtime_dir / "containers").glob("apache-tomcat-*")) + return candidates[-1] if candidates else None + + +def _prepare_tomcat(runtime_dir: Path) -> Path: + existing = _tomcat_home(runtime_dir) + if existing is not None: + return existing + archive = _tomcat_archive(runtime_dir) + if archive is None: + raise ValueError("Tomcat archive is missing") + containers_dir = runtime_dir / "containers" + containers_dir.mkdir(parents=True, exist_ok=True) + with tarfile.open(archive, "r:gz") as handle: + _safe_extract(handle, containers_dir) + tomcat_home = _tomcat_home(runtime_dir) + if tomcat_home is None: + raise ValueError("Tomcat archive did not extract an apache-tomcat-* directory") + return tomcat_home + + +def _install_compat_libs(runtime_dir: Path, tomcat_home: Path) -> None: + lib_dir = tomcat_home / "lib" + for artifact_id in ["jaxws-api", "javax.jws-api", "javax.xml.soap-api", "javax.annotation-api"]: + matches = sorted(runtime_dir.glob(f"{artifact_id}-*.jar")) + if matches: + shutil.copy2(matches[-1], lib_dir / matches[-1].name) + + +def _deploy_war(tomcat_home: Path, war: Path, context_path: str, port: int) -> None: + context_name = _normalized_context_path(context_path).strip("/") or "ROOT" + webapps_dir = tomcat_home / "webapps" + _remove_generated_webapp(webapps_dir / context_name) + _remove_generated_webapp(webapps_dir / f"{context_name}.war") + shutil.copy2(war, webapps_dir / f"{context_name}.war") + server_xml = tomcat_home / "conf" / "server.xml" + text = server_xml.read_text(encoding="utf-8") + text = reconfigure_connector_port(text, port) + server_xml.write_text(text, encoding="utf-8") + (tomcat_home / "bin" / "catalina.sh").chmod(0o755) + + +def reconfigure_connector_port(server_xml: str, port: int) -> str: + return server_xml.replace('port="8080"', f'port="{port}"', 1) + + +def _remove_generated_webapp(path: Path) -> None: + if not path.exists(): + return + webapps_dir = path.parent.resolve() + resolved = path.resolve() + try: + resolved.relative_to(webapps_dir) + except ValueError as exc: + raise ValueError(f"Refusing to remove path outside Tomcat webapps: {path}") from exc + if resolved.is_dir(): + shutil.rmtree(resolved) + else: + resolved.unlink() + + +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 _load_state(runtime_dir: Path) -> dict[str, Any] | None: + state_path = runtime_dir / "server-state.json" + if not state_path.exists(): + return None + value = json.loads(state_path.read_text(encoding="utf-8")) + return value if isinstance(value, dict) else None + + +def _is_process_running(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _terminate_process_group(pid: int, sig: signal.Signals) -> None: + try: + os.killpg(pid, sig) + except ProcessLookupError: + return + except PermissionError: + os.kill(pid, sig) + + +def _read_tail(path: Path, limit: int = 8000) -> str: + if not path.exists(): + return "" + data = path.read_bytes()[-limit:] + return data.decode("utf-8", errors="replace") + + +def _java_executable() -> Path: + local = _extension_root() / ".local" / "toolchains" / "current-jdk" / "bin" / "java" + return local if local.exists() else Path("java") + + +def _maven_executable() -> Path: + local = _extension_root() / ".local" / "toolchains" / "current-maven" / "bin" / "mvn" + return local if local.exists() else Path("mvn") + + +def _tool_env() -> dict[str, str]: + env = os.environ.copy() + local_jdk = _extension_root() / ".local" / "toolchains" / "current-jdk" + local_maven = _extension_root() / ".local" / "toolchains" / "current-maven" + path_parts = [] + if local_jdk.exists(): + env["JAVA_HOME"] = str(local_jdk) + path_parts.append(str(local_jdk / "bin")) + if local_maven.exists(): + env["MAVEN_HOME"] = str(local_maven) + path_parts.append(str(local_maven / "bin")) + path_parts.append(env.get("PATH", "")) + env["PATH"] = os.pathsep.join(part for part in path_parts if part) + return env + + +def _browser_url(port: int, context_path: str) -> str: + return f"http://127.0.0.1:{port}{_normalized_context_path(context_path)}/browser" + + +def _normalized_context_path(context_path: str) -> str: + value = "/" + context_path.strip("/") + return "" if value == "/" else value + + +def _default_runtime_dir() -> Path: + return _extension_root() / ".local" / "opencmis-inmemory" + + +def _extension_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/open_cmis_tck/preflight.py b/src/open_cmis_tck/preflight.py index aa970f1..199f0e4 100644 --- a/src/open_cmis_tck/preflight.py +++ b/src/open_cmis_tck/preflight.py @@ -2,7 +2,9 @@ from __future__ import annotations +import base64 import json +import os from pathlib import Path from typing import Any from urllib.error import HTTPError, URLError @@ -26,12 +28,29 @@ def run(context: dict[str, Any]) -> dict[str, Any]: timeout = _timeout_seconds(context) artifact_refs: list[str] = [] + credentials = _load_credentials(context) + if credentials["status"] == "blocked": + return { + "result": "blocked", + "observations": credentials["observations"], + "facts": { + "endpoint_found": True, + "url": endpoint["url"], + "blocked_reason": "credentials_unavailable", + "auth_mode": credentials["auth_mode"], + }, + "artifact_refs": [], + } + headers = { + "Accept": "application/json, */*;q=0.1", + "User-Agent": "guide-board-open-cmis-tck-preflight/0.1.0", + } + authorization = _authorization_header(credentials) + if authorization is not None: + headers["Authorization"] = authorization request = Request( endpoint["url"], - headers={ - "Accept": "application/json, */*;q=0.1", - "User-Agent": "guide-board-open-cmis-tck-preflight/0.1.0", - }, + headers=headers, ) try: with urlopen(request, timeout=timeout) as response: @@ -102,6 +121,7 @@ def run(context: dict[str, Any]) -> dict[str, Any]: "binding": endpoint["binding"], "http_status": status_code, "content_type": content_type, + "auth_mode": credentials["auth_mode"], } parsed = _parse_json(body) @@ -166,6 +186,83 @@ def _browser_endpoint(target: dict[str, Any]) -> dict[str, Any] | None: return None +def _load_credentials(context: dict[str, Any]) -> dict[str, Any]: + target = context["target_profile"] + credentials_ref = target.get("credentials_ref") + if not isinstance(credentials_ref, str) or not credentials_ref: + return {"status": "available", "auth_mode": "anonymous"} + if credentials_ref.startswith("env:"): + return _load_env_credentials(credentials_ref) + if credentials_ref.startswith("file:"): + return _load_file_credentials(credentials_ref, context) + return { + "status": "blocked", + "auth_mode": "unknown", + "observations": [ + "Unsupported credentials_ref. Use env:USER_VAR,PASSWORD_VAR or file:/path/to/credentials.json." + ], + } + + +def _load_env_credentials(credentials_ref: str) -> dict[str, Any]: + names = credentials_ref.removeprefix("env:").split(",", 1) + if len(names) != 2 or not names[0] or not names[1]: + return { + "status": "blocked", + "auth_mode": "env", + "observations": [ + "Environment credentials_ref must be env:USER_VAR,PASSWORD_VAR." + ], + } + user = os.environ.get(names[0]) + password = os.environ.get(names[1]) + missing = [name for name, value in [(names[0], user), (names[1], password)] if not value] + if missing: + return { + "status": "blocked", + "auth_mode": "env", + "observations": [ + "Missing credential environment variable(s): " + ", ".join(missing) + "." + ], + } + return {"status": "available", "auth_mode": "env", "user": user, "password": password} + + +def _load_file_credentials(credentials_ref: str, context: dict[str, Any]) -> dict[str, Any]: + path = Path(credentials_ref.removeprefix("file:")).expanduser() + if not path.is_absolute(): + target_path = context["plan"].get("profile_paths", {}).get("target_profile_path") + if isinstance(target_path, str): + path = Path(target_path).resolve().parent / path + if not path.exists(): + return { + "status": "blocked", + "auth_mode": "file", + "observations": [f"Credential file does not exist: {path}."], + } + payload = json.loads(path.read_text(encoding="utf-8")) + user = payload.get("user") + password = payload.get("password") + if not isinstance(user, str) or not isinstance(password, str): + return { + "status": "blocked", + "auth_mode": "file", + "observations": [ + "Credential file must contain string fields 'user' and 'password'." + ], + } + return {"status": "available", "auth_mode": "file", "user": user, "password": password} + + +def _authorization_header(credentials: dict[str, Any]) -> str | None: + user = credentials.get("user") + password = credentials.get("password") + if not isinstance(user, str) or not isinstance(password, str): + return None + token = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii") + return f"Basic {token}" + + def _timeout_seconds(context: dict[str, Any]) -> float: runtime_policy = context["assessment_profile"].get("runtime_policy", {}) configured = runtime_policy.get("timeout_seconds", 5) diff --git a/tests/test_open_cmis_tck.py b/tests/test_open_cmis_tck.py index b5cd01e..137720a 100644 --- a/tests/test_open_cmis_tck.py +++ b/tests/test_open_cmis_tck.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import http.client import json import os @@ -110,6 +111,25 @@ class OpenCmisTckExtensionTests(unittest.TestCase): profile = validate_target_profile(template_dir / name) self.assertEqual(profile["subject_type"], "cmis-browser-binding-endpoint") + def test_inmemory_pilot_profiles_validate(self) -> None: + target = validate_target_profile(ROOT / "profiles" / "targets" / "opencmis-inmemory-local.json") + assessment = validate_assessment_profile( + ROOT / "profiles" / "assessments" / "cmis-browser-inmemory-pilot.json" + ) + plan = build_run_plan( + CORE_ROOT, + ROOT / "profiles" / "targets" / "opencmis-inmemory-local.json", + ROOT / "profiles" / "assessments" / "cmis-browser-inmemory-pilot.json", + [ROOT], + ) + + self.assertEqual(target["credentials_ref"], "env:OPENCMIS_INMEMORY_USER,OPENCMIS_INMEMORY_PASSWORD") + self.assertEqual(assessment["target_profile_ref"], "opencmis-inmemory-local") + self.assertEqual( + [step["id"] for step in plan["ordered_steps"]], + ["preflight:open-cmis-tck", "check-group:open-cmis-tck:repository-type"], + ) + def test_bootstrap_reports_local_tck_runtime_posture(self) -> None: with TemporaryDirectory() as temporary_directory: output = Path(temporary_directory) / "runtime-summary.json" @@ -388,6 +408,61 @@ class OpenCmisTckExtensionTests(unittest.TestCase): thread.join(timeout=5) server.server_close() + def test_preflight_uses_env_credentials_for_basic_auth(self) -> None: + server = HTTPServer(("127.0.0.1", 0), _BasicAuthCmisHandler) + thread = threading.Thread(target=server.serve_forever) + thread.daemon = True + thread.start() + original_user = os.environ.get("CMIS_AUTH_USER") + original_password = os.environ.get("CMIS_AUTH_PASSWORD") + os.environ["CMIS_AUTH_USER"] = "alice" + os.environ["CMIS_AUTH_PASSWORD"] = "secret" + try: + with TemporaryDirectory() as temporary_directory: + temp_root = Path(temporary_directory) + target_path = temp_root / "target.json" + assessment_path = temp_root / "assessment.json" + _write_target(target_path, server.server_port, "local-cmis-auth-preflight") + target = json.loads(target_path.read_text(encoding="utf-8")) + target["credentials_ref"] = "env:CMIS_AUTH_USER,CMIS_AUTH_PASSWORD" + target_path.write_text(json.dumps(target), encoding="utf-8") + _write_assessment( + assessment_path, + "local-cmis-auth-preflight", + "local-cmis-auth-preflight", + [], + None, + ) + + result = run_assessment( + CORE_ROOT, + target_path, + assessment_path, + temp_root / "run", + [ROOT], + ) + evidence = json.loads( + (Path(result["run_dir"]) / "normalized" / "evidence.json").read_text( + encoding="utf-8" + ) + )["evidence"] + + self.assertEqual(result["status"], "completed") + self.assertEqual(evidence[0]["result"], "pass") + self.assertEqual(evidence[0]["facts"]["auth_mode"], "env") + finally: + if original_user is None: + os.environ.pop("CMIS_AUTH_USER", None) + else: + os.environ["CMIS_AUTH_USER"] = original_user + if original_password is None: + os.environ.pop("CMIS_AUTH_PASSWORD", None) + else: + os.environ["CMIS_AUTH_PASSWORD"] = original_password + server.shutdown() + thread.join(timeout=5) + server.server_close() + def test_runs_cmis_tck_command_wrapper_boundary(self) -> None: server = HTTPServer(("127.0.0.1", 0), _CmisHandler) thread = threading.Thread(target=server.serve_forever) @@ -985,5 +1060,19 @@ class _CmisHandler(BaseHTTPRequestHandler): return +class _BasicAuthCmisHandler(_CmisHandler): + def do_GET(self) -> None: + expected = "Basic " + base64.b64encode(b"alice:secret").decode("ascii") + if self.headers.get("Authorization") != expected: + body = b"unauthorized" + self.send_response(401) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + super().do_GET() + + if __name__ == "__main__": unittest.main() diff --git a/workplans/OPEN-CMIS-TCK-WP-0002-live-test-infrastructure.md b/workplans/OPEN-CMIS-TCK-WP-0002-live-test-infrastructure.md index b2cfa5a..8cd2d2c 100644 --- a/workplans/OPEN-CMIS-TCK-WP-0002-live-test-infrastructure.md +++ b/workplans/OPEN-CMIS-TCK-WP-0002-live-test-infrastructure.md @@ -143,7 +143,7 @@ Progress: ```task id: OPEN-CMIS-TCK-WP-0002-T003 -status: in_progress +status: done priority: high state_hub_task_id: "a446a80f-fc63-4ea8-9720-9294db57ade9" ``` @@ -179,6 +179,21 @@ Progress: `infrastructure_error` because no CMIS target was reachable. - Live repository/type execution remains open until a reachable CMIS Browser Binding target is available. +- Added `scripts/opencmis_inmemory_server.py`, which prepares and launches the + Apache Chemistry OpenCMIS in-memory server WAR under Tomcat 9 as a + controlled local CMIS Browser Binding pilot target. +- The launcher installs Java EE API compatibility jars into the local Tomcat + runtime because OpenCMIS 1.1.0 expects Java 8-era JAX-WS classes that are not + present in JDK 17. +- Added `profiles/targets/opencmis-inmemory-local.json` and + `profiles/assessments/cmis-browser-inmemory-pilot.json` for a local + repository/type TCK pilot against repository `A1`. +- Added credential-aware preflight support so authenticated CMIS targets can be + probed before the TCK adapter runs. +- Ran the repository/type group through guide-board against the local OpenCMIS + in-memory Browser Binding target. The real ConsoleRunner adapter completed + with return code `0`, captured raw stdout/stderr and metadata, and normalized + 51 TCK case results. ## D2.4 - Target Profiles And Credential References @@ -247,7 +262,7 @@ Progress: ```task id: OPEN-CMIS-TCK-WP-0002-T006 -status: in_progress +status: done priority: high state_hub_task_id: "d9eb9384-3352-4b71-9918-57282ee00411" ``` @@ -274,6 +289,19 @@ Progress: scorecard with status `infrastructure_error` at preflight. - Live pilot execution is blocked on starting or providing a reachable CMIS Browser Binding endpoint and, if needed, credentials. +- Added a controlled OpenCMIS in-memory pilot target path so the first live TCK + run can proceed locally even before the actual system endpoint is available. +- Started the local OpenCMIS in-memory server under Tomcat 9 with Java EE API + compatibility jars for JDK 17. +- Ran `cmis-browser-inmemory-pilot` against + `http://127.0.0.1:18080/inmemory/browser` using environment-backed sample + credentials. +- The guide-board run `.local/runs/opencmis-inmemory-pilot` completed and + produced normalized evidence, mappings, findings, assessment package, + Markdown report, retention summary, and CMIS maturity scorecard. +- Pilot result: preflight passed; repository/type TCK completed with 36 pass, + 13 info, and 2 warning case results. Scorecard marks repository/type as + `partial` pending warning review. ## D2.7 - CMIS Capability Maturity Scorecard