OpenCMIS in-memory server

This commit is contained in:
2026-05-08 07:51:42 +02:00
parent c1e6bd6d65
commit 99085fb928
8 changed files with 919 additions and 6 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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}"
]
}
}
}

View File

@@ -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"
}
]
}

View File

@@ -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())

View File

@@ -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)

View File

@@ -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()

View File

@@ -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