blob: 3dd1a47a7015cab458138e79d16e0a41adaedca6 [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.
import argparse
import json
import os
from pathlib import Path
import subprocess
import tempfile
import threading
import unittest
from unittest import mock
import build_versions
class BuildVersionsTest(unittest.TestCase):
def setUp(self):
self.entry = {"id": "1.7", "sha": "a" * 40, "archived": True, "buildKey": "b" * 64}
self.args = argparse.Namespace(output=Path("/tmp/versions-test"),
cache_dir=Path("/tmp/cache-test"), site_origin="https://example.com/",
historical_origin="https://example.com/", force_rebuild=False, workers=2)
def run_one(self, restored, *, failure=None):
with mock.patch.object(build_versions.version_cache, "restore", return_value=restored) as restore, \
mock.patch.object(build_versions.version_cache, "record") as record, \
mock.patch.object(build_versions.subprocess, "run", side_effect=failure) as run:
build_versions.run_version(self.entry, self.args, Path("/tmp/hugo-test"))
return restore, record, run
def test_cached_version_still_validated(self):
_, record, run = self.run_one(True)
self.assertEqual([c.args[0][2] for c in run.call_args_list], ["validate"])
record.assert_not_called()
def test_miss_builds_and_validates(self):
_, record, run = self.run_one(False)
self.assertEqual([c.args[0][2] for c in run.call_args_list], ["build", "validate"])
record.assert_called_once()
self.assertEqual(run.call_args.kwargs["env"]["HUGO_CACHEDIR"], "/tmp/hugo-test/1.7")
def test_invalid_cached_artifact_rebuilt_and_validated(self):
_, record, run = self.run_one(True, failure=[subprocess.CalledProcessError(1, "validate"), None, None])
self.assertEqual([c.args[0][2] for c in run.call_args_list], ["validate", "build", "validate"])
record.assert_called_once()
def test_failed_fresh_validation_not_cached(self):
with mock.patch.object(build_versions.version_cache, "restore", return_value=False), \
mock.patch.object(build_versions.version_cache, "record") as record, \
mock.patch.object(build_versions.subprocess, "run",
side_effect=[None, subprocess.CalledProcessError(1, "validate")]):
with self.assertRaises(subprocess.CalledProcessError):
build_versions.run_version(self.entry, self.args, Path("/tmp/hugo-test"))
record.assert_not_called()
def test_forced_rebuild_does_not_restore(self):
self.args.force_rebuild = True
restore, _, run = self.run_one(True)
restore.assert_not_called()
self.assertEqual([c.args[0][2] for c in run.call_args_list], ["build", "validate"])
def test_restore_io_failure_falls_back_to_build(self):
with mock.patch.object(build_versions.version_cache, "restore", side_effect=OSError("disk")), \
mock.patch.object(build_versions.version_cache, "record"), \
mock.patch.object(build_versions.subprocess, "run") as run:
build_versions.run_version(self.entry, self.args, Path("/tmp/hugo-test"))
self.assertEqual([c.args[0][2] for c in run.call_args_list], ["build", "validate"])
def test_corrupt_cache_symlink_does_not_fail_validated_build(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
self.args.output = root / "output"
self.args.cache_dir = root / "cache"
self.args.cache_dir.mkdir()
protected = root / "protected"
protected.mkdir()
marker = protected / "marker"
marker.write_text("keep")
(self.args.cache_dir / self.entry["id"]).symlink_to(protected, target_is_directory=True)
def build(command, **_):
if command[2] == "build":
artifact = self.args.output / self.entry["id"]
artifact.mkdir(parents=True)
(artifact / ".version.json").write_text(json.dumps(self.entry))
with mock.patch.object(build_versions.subprocess, "run", side_effect=build) as run:
build_versions.run_version(self.entry, self.args, root / "hugo")
self.assertEqual([c.args[0][2] for c in run.call_args_list], ["build", "validate"])
self.assertEqual(marker.read_text(), "keep")
self.assertEqual(list(protected.iterdir()), [marker])
def test_rejects_drifted_plan(self):
with self.assertRaises(ValueError):
build_versions.selected_entries({"versions": [self.entry]},
{"versions": [dict(self.entry, sha="c" * 40)]})
def test_rejects_worker_counts_outside_final_limit(self):
for workers in (0, 3):
with self.subTest(workers=workers):
self.args.workers = workers
with self.assertRaisesRegex(ValueError, "workers must be between 1 and 2"):
build_versions.execute(self.args)
def test_configured_hugo_cache_preserved_and_isolated_by_version(self):
entries = [self.entry, dict(self.entry, id="1.5")]
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
cache = root / "hugo-cache"
cache.mkdir()
marker = cache / "existing-resource"
marker.write_text("keep")
self.args.output = root / "output"
self.args.cache_dir = root / "artifacts"
self.args.plan = root / "plan.json"
self.args.plan.write_text(json.dumps({"versions": entries}))
self.args.resolved_manifest = root / "resolved.json"
with mock.patch.dict(os.environ, HUGO_CACHEDIR=str(cache)), \
mock.patch.object(build_versions.versioning, "prepare_output_directory", return_value=self.args.output), \
mock.patch.object(build_versions.versioning, "load_resolved_manifest", return_value={"versions": entries}), \
mock.patch.object(build_versions.versioning, "run"), \
mock.patch.object(build_versions.version_cache, "restore", return_value=False), \
mock.patch.object(build_versions.version_cache, "record"), \
mock.patch.object(build_versions.subprocess, "run") as run:
build_versions.execute(self.args)
self.assertEqual(marker.read_text(), "keep")
self.assertEqual({c.kwargs["env"]["HUGO_CACHEDIR"] for c in run.call_args_list},
{str(cache.resolve() / "1.7"), str(cache.resolve() / "1.5")})
def test_hugo_cache_inside_output_rejected_before_cleanup(self):
with tempfile.TemporaryDirectory() as tmp:
self.args.plan = Path(tmp) / "plan.json"
self.args.plan.write_text(json.dumps({"versions": [self.entry]}))
self.args.resolved_manifest = Path(tmp) / "resolved.json"
with mock.patch.dict(os.environ, HUGO_CACHEDIR=str(self.args.output / "cache")), \
mock.patch.object(build_versions.versioning, "load_resolved_manifest", return_value={"versions": [self.entry]}), \
mock.patch.object(build_versions.versioning, "prepare_output_directory") as prepare:
with self.assertRaisesRegex(ValueError, "must not overlap"):
build_versions.execute(self.args)
prepare.assert_not_called()
def test_bounded_workers_collect_failures_and_finish_other_versions(self):
entries = [dict(self.entry, id=str(i)) for i in range(4)]
gate = threading.Barrier(2, timeout=5)
finished = []
def work(entry, *_):
gate.wait()
finished.append(entry["id"])
if entry["id"] == "0":
raise RuntimeError("build failed")
with tempfile.TemporaryDirectory() as tmp:
self.args.plan = Path(tmp) / "plan.json"
self.args.plan.write_text(json.dumps({"versions": entries}))
self.args.resolved_manifest = Path(tmp) / "resolved.json"
with mock.patch.object(build_versions.versioning, "load_resolved_manifest", return_value={"versions": entries}), \
mock.patch.object(build_versions.versioning, "prepare_output_directory", return_value=self.args.output), \
mock.patch.object(build_versions.versioning, "run"), \
mock.patch.object(build_versions, "run_version", side_effect=work):
with self.assertRaisesRegex(RuntimeError, "failed versions: 0"):
build_versions.execute(self.args)
self.assertEqual(sorted(finished), ["0", "1", "2", "3"])
if __name__ == "__main__":
unittest.main()