generated from coulomb/repo-seed
OpenCMIS in-memory server
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
59
profiles/assessments/cmis-browser-inmemory-pilot.json
Normal file
59
profiles/assessments/cmis-browser-inmemory-pilot.json
Normal 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}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
36
profiles/targets/opencmis-inmemory-local.json
Normal file
36
profiles/targets/opencmis-inmemory-local.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
531
scripts/opencmis_inmemory_server.py
Normal file
531
scripts/opencmis_inmemory_server.py
Normal 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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user