blob: 559b416fb5a53b1aba69fbe3e9c683af725a259e [file]
#!/usr/bin/env python3
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with this
# work for additional information regarding copyright ownership. The ASF
# licenses this file to You under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
"""Small fixtures for provenance failures; actual Docker behavior is a CI gate."""
import copy
import contextlib
import io
import json
import os
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
from pathlib import Path
import java17_image as images
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "hugegraph-client/assembly/travis"))
import verify_candidate_image_sdk as sdk
class ServerArchiveExtractionTest(unittest.TestCase):
def test_server_uses_shared_filtered_extraction_after_digest_gate(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
archive = root / "server.tar.gz"
archive.write_bytes(b"fixture")
manifest = {"server_archive": str(archive),
"server_archive_sha256": images.digest(archive)}
with patch.object(images.HTTP, "extract_archive",
side_effect=RuntimeError("extraction stopped")) as extract:
with self.assertRaisesRegex(RuntimeError, "extraction stopped"):
images.start_server(manifest, root / "runtime", root)
extract.assert_called_once_with(archive, root / "runtime")
extract.reset_mock()
archive.write_bytes(b"changed")
with self.assertRaisesRegex(RuntimeError, "changed after candidate build"):
images.start_server(manifest, root / "runtime", root)
extract.assert_not_called()
class ImageEvidenceTest(unittest.TestCase):
def setUp(self):
self.source_commit = "d9abcd" + "1" * 34
identity = patch.dict(os.environ, CANDIDATE_SOURCE_COMMIT=self.source_commit)
identity.start()
self.addCleanup(identity.stop)
def fixture(self, root):
repository = root / "m2"
artifacts, modules = [], []
for source_pom in sorted(sdk.REQUIRED_MODULES):
artifact = Path(source_pom).parent.name if source_pom != "pom.xml" else "hugegraph"
packaging = "pom" if (source_pom.count("/") == 1 and artifact != "hugegraph-struct") or source_pom == "pom.xml" else "jar"
files = []
for extension in (["pom", "jar"] if packaging == "jar" else ["pom"]):
filename = artifact + "-1.8.0." + extension
path = repository / "org/apache/hugegraph" / artifact / "1.8.0" / filename
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(filename.encode())
with (path.parent / "_remote.repositories").open("a") as origins:
origins.write(filename + ">=\n")
relative = str(path.relative_to(repository))
files.append(relative)
artifacts.append({"path": relative, "sha256": sdk.digest(path), "source_reactor_install": True})
modules.append({"group_id": "org.apache.hugegraph", "artifact_id": artifact,
"version": "1.8.0", "packaging": packaging,
"source_pom": source_pom, "files": files})
manifest = {"repository": sdk.REPOSITORY, "commit": self.source_commit,
"source_revision": "1.8.0", "java_version": "17",
"required_sdk_modules": modules, "artifacts": artifacts}
self.write_manifest(repository, manifest)
return repository, manifest
def write_manifest(self, repository, manifest):
(repository / "candidate-sdk-manifest.json").write_text(json.dumps(manifest))
def test_sdk_manifest_requires_same_source_parents_and_jars(self):
with tempfile.TemporaryDirectory() as directory:
repository, manifest = self.fixture(Path(directory))
_, expected = sdk.validate_sdk(repository)
self.assertEqual(expected, images.verify_jars("hubble", expected, expected))
wrong = repository / "org/apache/hugegraph/hugegraph-common/1.8.0/hugegraph-common-1.8.0.jar"
wrong.write_bytes(b"central artifact with the same version")
with self.assertRaisesRegex(RuntimeError, "hash mismatch"):
sdk.validate_sdk(repository)
def test_required_sdk_pom_and_install_origin_cannot_be_waived(self):
with tempfile.TemporaryDirectory() as directory:
repository, manifest = self.fixture(Path(directory))
for change in ("missing_parent", "wrong_commit", "central_origin", "incomplete_manifest", "wrong_coordinates"):
bad = copy.deepcopy(manifest)
if change == "missing_parent":
bad["required_sdk_modules"].pop(0)
elif change == "wrong_commit":
bad["commit"] = "59ad5374b4bf90ec3ee7aa133b830e66788b51ec"
elif change == "central_origin":
bad["artifacts"][0]["source_reactor_install"] = False
elif change == "incomplete_manifest":
bad["artifacts"].pop(0)
else:
bad["required_sdk_modules"][0]["artifact_id"] = "not-the-source-module"
self.write_manifest(repository, bad)
with self.subTest(change=change), self.assertRaises(RuntimeError):
sdk.validate_sdk(repository)
self.write_manifest(repository, manifest)
pom = repository / "org/apache/hugegraph/hugegraph/1.8.0/hugegraph-1.8.0.pom"
(pom.parent / "_remote.repositories").write_text(pom.name + ">central=\n")
with self.assertRaisesRegex(RuntimeError, "not installed from source"):
sdk.validate_sdk(repository)
def test_server_archive_digest_is_bound_to_sdk_build_output(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
repository, manifest = self.fixture(root)
archive = root / "source-server.tar.gz"
archive.write_bytes(b"same source archive output")
expected = sdk.digest(archive)
work, evidence = root / "work", root / "evidence"
work.mkdir()
evidence.mkdir()
images.prepare("loader", self.source_commit, repository, archive, expected, work, evidence)
output = json.loads((evidence / "manifest.json").read_text())
self.assertEqual(expected, output["server_archive_sha256"])
self.assertEqual(self.source_commit, output["server_commit"])
with self.assertRaisesRegex(RuntimeError, "locked SDK commit"):
images.prepare("loader", self.source_commit[:6], repository, archive, expected, work, evidence)
archive.write_bytes(b"replacement archive")
with self.assertRaisesRegex(RuntimeError, "changed same-source server archive"):
images.prepare("loader", self.source_commit, repository, archive, expected, work, evidence)
def test_runtime_hash_and_required_libraries_cannot_be_waived(self):
expected = {artifact + "-1.8.0.jar": "candidate" for artifact in (*images.REQUIRED, "hugegraph-core")}
for wrong in ({**expected, "hugegraph-common-1.8.0.jar": "central"},
{key: value for key, value in expected.items() if not key.startswith("hugegraph-core-")},
{**expected, "hugegraph-common-1.5.0.jar": "old"},
{**expected, "hg-store-client-1.5.0.jar": "old"}):
with self.subTest(jars=list(wrong)), self.assertRaises(RuntimeError):
images.verify_jars("hubble", expected, wrong)
def test_java21_does_not_substitute_for_java17(self):
images.require_java17('openjdk version "17.0.20"')
for output in ('openjdk version "11.0.29"', 'openjdk version "21.0.9"', ""):
with self.assertRaises(RuntimeError):
images.require_java17(output)
def test_fixture_configuration_removes_auth_without_touching_other_keys(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "server.properties"
path.write_text("# reference\nusePD=true\nauth.authenticator=fixture.Auth\ngraphs=./conf/graphs\n")
images.configure(path, {"usePD": "false"}, ("auth.authenticator",))
self.assertEqual("# reference\ngraphs=./conf/graphs\nusePD=false\n", path.read_text())
def test_smoke_reports_failure_types_without_exposing_exception_details(self):
with tempfile.TemporaryDirectory() as directory:
evidence = Path(directory)
manifest = {"module": "loader", "image": "fixture", "server_commit": self.source_commit,
"toolchain_commit": "fixture"}
(evidence / "manifest.json").write_text(json.dumps(manifest))
original_error = "https://user:password@example.invalid/?token=secret"
cleanup_error = "Command ['docker', '--password=secret'] timed out"
runtime = evidence / "fixture-runtime"
logs = runtime / "apache-hugegraph-fixture/logs"
logs.mkdir(parents=True)
(logs / "fixture.log").write_text("fixture log")
copy_error = "log copy failed: password=secret https://example.invalid/?token=secret"
stderr = io.StringIO()
with patch.object(images.tempfile, "TemporaryDirectory",
return_value=contextlib.nullcontext(str(runtime))), \
patch.object(images, "run", side_effect=[RuntimeError(original_error),
OSError(cleanup_error), "", ""]), \
patch.object(images.shutil, "copy2", side_effect=OSError(copy_error)) as copy_log, \
contextlib.redirect_stderr(stderr):
self.assertFalse(images.smoke(evidence))
report = json.loads((evidence / "report.json").read_text())
self.assertEqual(report["error"], "smoke: RuntimeError")
self.assertEqual(report["cleanup_errors"], ["cleanup: OSError", "cleanup: OSError"])
copy_log.assert_called_once()
self.assertEqual(report["status"], "failed")
for secret in ("secret", "password", "https://", "Command"):
self.assertNotIn(secret, json.dumps(report))
output = stderr.getvalue()
self.assertIn("smoke: RuntimeError", output)
self.assertIn("cleanup: OSError", output)
self.assertIn(str(evidence / "report.json"), output)
self.assertNotIn("secret", output)
self.assertNotIn("https://", output)
self.assertNotIn("Command", output)
self.assertLess(len(output.encode("utf-8")), 8192)
def test_full_smoke_rejects_invalid_public_entrypoint_and_container_identity(self):
# All data/provenance stages otherwise succeed: losing a migrated check
# would incorrectly accept these images after workflow consolidation.
cases = [("loader", "verify_image", RuntimeError("wrong container image")),
("loader", "check_loader", PermissionError("public script is not executable")),
("hubble", "check_hubble_ui", RuntimeError("HTML fallback for missing bundle")),
("loader", None, None), ("hubble", None, None)]
for module, failing_check, failure in cases:
with self.subTest(module=module, check=failing_check), tempfile.TemporaryDirectory() as directory:
evidence = Path(directory)
manifest = {"module": module, "image": "fixture-image", "server_commit": "server",
"toolchain_commit": "toolchain", "candidate_jars": {}}
(evidence / "manifest.json").write_text(json.dumps(manifest))
started = []
def run(args, *unused, **kwargs):
if args[:3] == ["docker", "image", "inspect"]:
return "server toolchain" if "Labels" in " ".join(args) else "sha256:built"
if args[:2] == ["docker", "run"]:
started.append("container-" + str(len(started)))
return started[-1]
if "-version" in args:
return 'openjdk version "17.0.20"'
return ""
with contextlib.ExitStack() as stack:
stack.enter_context(patch.object(images, "run", side_effect=run))
stack.enter_context(patch.object(images.socket, "socket"))
stack.enter_context(patch.object(images, "start_server", return_value=evidence))
stack.enter_context(patch.object(images, "verify_jars", return_value={}))
stack.enter_context(patch.object(images, "check_loader", return_value={}))
stack.enter_context(patch.object(images, "check_hubble_before_restart", return_value=("favorite", {})))
stack.enter_context(patch.object(images.HTTP, "wait_for_health"))
stack.enter_context(patch.object(images, "api", return_value={"content": "g.E().count()",
"name": "image_persistence"}))
for check in ("verify_image", "check_loader", "check_hubble_ui"):
stack.enter_context(patch.object(images.IMAGE_STARTUP, check,
side_effect=failure if check == failing_check else None))
stack.enter_context(contextlib.redirect_stderr(io.StringIO()))
self.assertEqual(failing_check is None, images.smoke(evidence))
report = json.loads((evidence / "report.json").read_text())
self.assertEqual("failed" if failing_check else "passed", report["status"])
if failing_check:
self.assertEqual("smoke: " + type(failure).__name__, report["error"])
def test_prepare_failure_reports_existing_log_and_preserves_exit_code(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
binary, evidence = root / "bin", root / "evidence"
binary.mkdir()
message = "fixture exception: password=secret"
fake_python = binary / "python3"
fake_python.write_text("#!/bin/bash\n"
"case \"$1\" in\n"
" */verify_candidate_image_sdk.py) exit 0 ;;\n"
f" */java17_image.py) echo '{message}' >&2; exit 23 ;;\n"
" *) exit 99 ;;\n"
"esac\n")
fake_python.chmod(0o755)
fake_docker = binary / "docker"
fake_docker.write_text(f"#!/bin/bash\ntouch '{root / 'docker-called'}'\nexit 99\n")
fake_docker.chmod(0o755)
environment = dict(os.environ, PATH=str(binary) + os.pathsep + os.environ["PATH"],
RUNNER_TEMP=str(root))
result = subprocess.run(["bash", str(images.ROOT / ".github/scripts/build-java17-image.sh"),
"loader", self.source_commit, str(root / "repository"),
str(root / "server.tar.gz"), "fixture-sha", str(evidence)],
cwd=images.ROOT, env=environment, capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 23)
self.assertIn("prepare candidate provenance (exit 23)", result.stderr)
self.assertIn(str(evidence / "prepare.log"), result.stderr)
self.assertNotIn("secret", result.stderr)
self.assertEqual((evidence / "prepare.log").read_text(), message + "\n")
self.assertFalse((evidence / "manifest.json").exists())
self.assertFalse((root / "docker-called").exists())
if __name__ == "__main__":
unittest.main()