fix(ci): bound dependency downloads and recover through mirrors
This commit is contained in:
1 parent
fb135a1d6a
commit
344ffdc1d5
13 files changed
+1312
-94
No files matched your search
@@ -0,0 +1,54 @@
|
||||
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
|
||||
import org.gradle.api.artifacts.repositories.MavenArtifactRepository
|
||||
|
||||
def target = System.getenv('DBX_CI_GRADLE_ROOT')
|
||||
def backup = System.getenv('DBX_CI_GRADLE_SOURCE') == 'backup'
|
||||
def mirror = 'https://maven-central.storage-download.googleapis.com/maven2/'
|
||||
def applies = { File directory -> target != null && directory.canonicalPath == target }
|
||||
|
||||
beforeSettings { settings ->
|
||||
if (applies(settings.settingsDir)) {
|
||||
if (System.getenv('DBX_CI_GRADLE_PHASE') == 'execute') {
|
||||
gradle.startParameter.offline = true
|
||||
}
|
||||
if (backup) {
|
||||
settings.pluginManagement.repositories.clear()
|
||||
settings.pluginManagement.repositories.maven { url = uri(mirror) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterProject { project ->
|
||||
if (applies(project.rootDir) && backup) {
|
||||
project.repositories.withType(MavenArtifactRepository).each { repository ->
|
||||
if (repository.url.toString() in ['https://repo.maven.apache.org/maven2/',
|
||||
'https://repo.maven.apache.org/maven2']) {
|
||||
if (repository.credentials.username || repository.credentials.password) {
|
||||
throw new GradleException('CI mirror switching does not support authenticated Central repositories')
|
||||
}
|
||||
repository.url = uri(mirror)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
projectsEvaluated {
|
||||
if (applies(rootProject.rootDir)) {
|
||||
rootProject.tasks.register('dbxCiAcquireDependencies') {
|
||||
doLast {
|
||||
rootProject.allprojects.each { project ->
|
||||
project.configurations.findAll { configuration ->
|
||||
configuration.canBeResolved &&
|
||||
(System.getenv('DBX_CI_GRADLE_SCOPE') != 'jdbc' ||
|
||||
configuration.name in ['compileClasspath', 'runtimeClasspath', 'annotationProcessor', 'shadow'])
|
||||
}.each { configuration ->
|
||||
def artifacts = configuration.incoming.artifactView {
|
||||
componentFilter { identifier -> identifier instanceof ModuleComponentIdentifier }
|
||||
}.files.files
|
||||
println("Acquired ${project.path}:${configuration.name}: ${artifacts.size()} external artifacts")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Acquire Gradle dependencies with bounded recovery, then execute the original command once offline."""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
INIT = Path(__file__).with_name("ci-gradle.init.gradle")
|
||||
starting_process = False
|
||||
pending_cancel = None
|
||||
|
||||
|
||||
class Cancelled(BaseException):
|
||||
def __init__(self, signum):
|
||||
self.signum = signum
|
||||
|
||||
|
||||
def cancel(signum, _frame):
|
||||
global pending_cancel
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||
if starting_process:
|
||||
pending_cancel = signum
|
||||
else:
|
||||
raise Cancelled(signum)
|
||||
|
||||
|
||||
def dispose(process):
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return process.communicate()[0] or ""
|
||||
|
||||
|
||||
def run_process(command, cwd, env, budget, capture=True):
|
||||
global starting_process, pending_cancel
|
||||
process = None
|
||||
timed_out = False
|
||||
try:
|
||||
starting_process = True
|
||||
process = subprocess.Popen(command, cwd=cwd, env=env,
|
||||
stdout=subprocess.PIPE if capture else None,
|
||||
stderr=subprocess.STDOUT if capture else None,
|
||||
text=True, start_new_session=True)
|
||||
starting_process = False
|
||||
if pending_cancel is not None:
|
||||
signum, pending_cancel = pending_cancel, None
|
||||
raise Cancelled(signum)
|
||||
try:
|
||||
output = process.communicate(timeout=budget)[0] or ""
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
output = dispose(process)
|
||||
except BaseException:
|
||||
if process is not None:
|
||||
dispose(process)
|
||||
raise
|
||||
finally:
|
||||
starting_process = False
|
||||
if capture:
|
||||
def redact(match):
|
||||
url = urlsplit(match[0])
|
||||
return f"{url.scheme}://{url.hostname}{url.path}"
|
||||
print(re.sub(r"https?://[^\s'\"<>]+", redact, output), end="", flush=True)
|
||||
return process.returncode, output, timed_out
|
||||
|
||||
|
||||
def public_maven_url(value):
|
||||
url = urlsplit(value)
|
||||
return (url.scheme == "https" and not url.username and not url.password and
|
||||
not url.query and not url.fragment and url.port in (None, 443) and
|
||||
((url.hostname == "repo.maven.apache.org" and url.path.startswith("/maven2/")) or
|
||||
(url.hostname == "plugins.gradle.org" and url.path.startswith("/m2/")) or
|
||||
(url.hostname == "maven-central.storage-download.googleapis.com" and url.path.startswith("/maven2/"))))
|
||||
|
||||
|
||||
def acquisition_failure(output, timed_out=False):
|
||||
if re.search(r"certificate|PKIX|SSLHandshake|checksum|signature|verification failed|"
|
||||
r"No space left|Permission denied|Could not compile|Compilation failed|"
|
||||
r"Could not find |Could not resolve.*conflict|401\b|404\b", output, re.I):
|
||||
return None
|
||||
section = output.split("* What went wrong:", 1)[-1].split("* Try:", 1)[0]
|
||||
requests = re.findall(r"Could not (?:GET|HEAD) '([^']+)'", section)
|
||||
if timed_out and "* What went wrong:" not in output and not requests:
|
||||
return "transient"
|
||||
if not requests or not all(public_maven_url(url) for url in requests):
|
||||
return None
|
||||
network = re.compile(r"timed out|Connection (?:reset|refused)|UnknownHostException|"
|
||||
r"Temporary failure|Name or service not known|Remote host terminated|"
|
||||
r"Received status code (?:403|429|500|502|503|504)\b", re.I)
|
||||
context = re.compile(r"^(?:A problem occurred configuring |Could not resolve |"
|
||||
r"Could not get resource |Could not (?:GET|HEAD) '|"
|
||||
r"There (?:is|are) \d+ more failure)")
|
||||
details = re.findall(r"^\s*>\s*(.+)$", section, re.M)
|
||||
if not details or any(not context.search(line) and not network.search(line) for line in details):
|
||||
return None
|
||||
if any(re.search(r"Received status code (?!403\b|429\b|500\b|502\b|503\b|504\b)\d+", line)
|
||||
for line in details):
|
||||
return None
|
||||
if re.search(r"Received status code 403\b", section):
|
||||
return "forbidden"
|
||||
return "transient" if timed_out or network.search(section) else None
|
||||
|
||||
|
||||
def wrapper_failure(output, timed_out):
|
||||
if re.search(r"certificate|PKIX|checksum|signature|Permission denied|No space left|"
|
||||
r"HTTP response code: (?:401|403|404)", output, re.I):
|
||||
return False
|
||||
return timed_out or bool(re.search(r"timed out|UnknownHostException|Connection (?:reset|refused)|"
|
||||
r"HTTP response code: (?:429|500|502|503|504)", output, re.I))
|
||||
|
||||
|
||||
def acquire_and_run(project, scope, command, runner=run_process, sleep=time.sleep):
|
||||
env = {**os.environ, "LC_ALL": "C", "DBX_CI_GRADLE_ROOT": str(project.resolve()),
|
||||
"DBX_CI_GRADLE_SCOPE": scope, "DBX_CI_GRADLE_SOURCE": "primary",
|
||||
"DBX_CI_GRADLE_PHASE": "acquire"}
|
||||
env["GRADLE_OPTS"] = env.get("GRADLE_OPTS", "") + " -Dorg.gradle.daemon=false -Duser.language=en -Duser.country=US"
|
||||
home = Path(env.get("GRADLE_USER_HOME", str(Path.home() / ".gradle"))).expanduser().resolve()
|
||||
env["GRADLE_USER_HOME"] = str(home)
|
||||
init_directory = home / "init.d"
|
||||
init_directory.mkdir(parents=True, exist_ok=True)
|
||||
wrapper = [str(project / "gradlew"), "--no-daemon", "--console=plain"]
|
||||
init_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(prefix="dbx-ci-acquire-", suffix=".gradle", dir=init_directory,
|
||||
delete=False) as temporary:
|
||||
init_path = Path(temporary.name)
|
||||
temporary.write(INIT.read_bytes())
|
||||
for attempt in range(3):
|
||||
print(f"Gradle phase=wrapper attempt={attempt + 1} budget=180s", flush=True)
|
||||
code, output, timed_out = runner([*wrapper, "--version"], project, env, 180)
|
||||
if code == 0 and not timed_out:
|
||||
break
|
||||
if attempt == 2 or not wrapper_failure(output, timed_out):
|
||||
return code or 1
|
||||
sleep(2 ** (attempt + 1))
|
||||
acquisition_count = 0
|
||||
for source in ["primary", "backup"]:
|
||||
env["DBX_CI_GRADLE_SOURCE"] = source
|
||||
attempts = 2 if source == "primary" else 3 - acquisition_count
|
||||
for attempt in range(attempts):
|
||||
acquisition_count += 1
|
||||
print(f"Gradle phase=acquire source={source} attempt={attempt + 1} budget=600s", flush=True)
|
||||
code, output, timed_out = runner([*wrapper, "dbxCiAcquireDependencies"], project, env, 600)
|
||||
if code == 0 and not timed_out:
|
||||
env["DBX_CI_GRADLE_PHASE"] = "execute"
|
||||
print(f"Gradle phase=execute source={source} offline=true attempts=1", flush=True)
|
||||
code, _output, _timed_out = runner(command, project, env, None, capture=False)
|
||||
return code
|
||||
reason = acquisition_failure(output, timed_out)
|
||||
if reason is None:
|
||||
return code or 1
|
||||
if source == "primary" and (reason == "forbidden" or attempt + 1 == attempts):
|
||||
break
|
||||
if attempt + 1 == attempts:
|
||||
return code or 1
|
||||
sleep(2)
|
||||
if source == "primary":
|
||||
print("Gradle switching once to Google-hosted Maven Central mirror", flush=True)
|
||||
return 1
|
||||
finally:
|
||||
if init_path is not None:
|
||||
init_path.unlink()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("project", choices=["agents", "jdbc"])
|
||||
parser.add_argument("command", nargs=argparse.REMAINDER)
|
||||
args = parser.parse_args()
|
||||
command = args.command[1:] if args.command[:1] == ["--"] else args.command
|
||||
if not command:
|
||||
parser.error("an original command is required after --")
|
||||
project = ROOT / ("agents" if args.project == "agents" else "plugins/jdbc")
|
||||
signal.signal(signal.SIGINT, cancel)
|
||||
signal.signal(signal.SIGTERM, cancel)
|
||||
try:
|
||||
return acquire_and_run(project, args.project, command)
|
||||
except Cancelled as error:
|
||||
print("Gradle cancelled; no acquisition retry", file=sys.stderr)
|
||||
return 128 + error.signum
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,222 @@
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("ci_gradle", Path(__file__).with_name("ci-gradle.py"))
|
||||
gradle = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(gradle)
|
||||
OK = (0, "", False)
|
||||
|
||||
|
||||
def failure(status=403, url="https://repo.maven.apache.org/maven2/example/a.pom"):
|
||||
return (1, f"""FAILURE: Build failed with an exception.
|
||||
* What went wrong:
|
||||
A problem occurred configuring root project 'fixture'.
|
||||
> Could not resolve all artifacts for configuration 'classpath'.
|
||||
> Could not resolve example:a:1.
|
||||
> Could not get resource '{url}'.
|
||||
> Could not GET '{url}'.
|
||||
> Received status code {status} from server: Forbidden
|
||||
* Try:
|
||||
> Run with --stacktrace option to get the stack trace.
|
||||
""", False)
|
||||
|
||||
|
||||
class AcquisitionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.root = Path(self.directory.name)
|
||||
self.project = self.root / "project"
|
||||
self.project.mkdir()
|
||||
self.home = self.root / "gradle-home"
|
||||
self.calls = []
|
||||
self.sleeps = []
|
||||
|
||||
def execute(self, responses):
|
||||
responses = iter(responses)
|
||||
|
||||
def run(command, cwd, env, budget, capture=True):
|
||||
self.assertEqual(cwd, self.project)
|
||||
self.assertEqual(len(list((self.home / "init.d").glob("dbx-ci-acquire-*.gradle"))), 1)
|
||||
self.calls.append((command, dict(env), budget, capture))
|
||||
result = next(responses)
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
return result
|
||||
|
||||
with patch.dict(os.environ, {"GRADLE_USER_HOME": str(self.home)}):
|
||||
try:
|
||||
return gradle.acquire_and_run(self.project, "agents", ["original", "test", "shadowJar", "--continue"],
|
||||
runner=run, sleep=self.sleeps.append)
|
||||
finally:
|
||||
self.assertFalse(list((self.home / "init.d").glob("dbx-ci-acquire-*.gradle")))
|
||||
|
||||
def test_success_acquires_before_one_offline_original_command(self):
|
||||
self.assertEqual(self.execute([OK, OK, OK]), 0)
|
||||
self.assertEqual([call[2] for call in self.calls], [180, 600, None])
|
||||
self.assertEqual(self.calls[1][0][-1], "dbxCiAcquireDependencies")
|
||||
self.assertEqual(self.calls[2][0], ["original", "test", "shadowJar", "--continue"])
|
||||
self.assertEqual(self.calls[2][1]["DBX_CI_GRADLE_PHASE"], "execute")
|
||||
self.assertEqual(self.calls[2][1]["DBX_CI_GRADLE_SOURCE"], "primary")
|
||||
self.assertFalse(self.calls[2][3])
|
||||
self.assertEqual(self.sleeps, [])
|
||||
|
||||
def test_public_forbidden_switches_once_without_repeating_original(self):
|
||||
self.assertEqual(self.execute([OK, failure(), OK, OK]), 0)
|
||||
self.assertEqual([call[1]["DBX_CI_GRADLE_SOURCE"] for call in self.calls],
|
||||
["primary", "primary", "backup", "backup"])
|
||||
self.assertEqual(self.sleeps, [])
|
||||
|
||||
def test_transient_retries_once_before_backup(self):
|
||||
self.assertEqual(self.execute([OK, failure(503), failure(503), OK, OK]), 0)
|
||||
self.assertEqual(self.sleeps, [2])
|
||||
self.assertEqual([call[1]["DBX_CI_GRADLE_SOURCE"] for call in self.calls[1:]],
|
||||
["primary", "primary", "backup", "backup"])
|
||||
|
||||
def test_backup_failure_and_execution_failure_are_terminal(self):
|
||||
for responses, count in [([OK, failure(), failure(), failure()], 4),
|
||||
([OK, OK, failure()], 3)]:
|
||||
with self.subTest(responses=responses):
|
||||
self.calls.clear()
|
||||
self.assertEqual(self.execute(responses), 1)
|
||||
self.assertEqual(len(self.calls), count)
|
||||
|
||||
def test_forbidden_preserves_remaining_attempt_for_backup(self):
|
||||
backup_error = failure(503, "https://maven-central.storage-download.googleapis.com/maven2/example/a.pom")
|
||||
self.assertEqual(self.execute([OK, failure(), backup_error, OK, OK]), 0)
|
||||
self.assertEqual([call[1]["DBX_CI_GRADLE_SOURCE"] for call in self.calls[1:]],
|
||||
["primary", "backup", "backup", "backup"])
|
||||
self.assertEqual(self.sleeps, [2])
|
||||
|
||||
def test_timeout_recovers_only_acquisition(self):
|
||||
timeout = (-9, "Downloading dependencies\n", True)
|
||||
self.assertEqual(self.execute([OK, timeout, timeout, OK, OK]), 0)
|
||||
self.assertEqual(self.sleeps, [2])
|
||||
|
||||
def test_wrapper_has_separate_bounded_retry_and_no_project_task(self):
|
||||
self.assertEqual(self.execute([(1, "java.net.SocketTimeoutException: Read timed out", False), OK, OK, OK]), 0)
|
||||
self.assertEqual(self.sleeps, [2])
|
||||
self.assertTrue(all(call[0][-1] == "--version" for call in self.calls[:2]))
|
||||
|
||||
def test_deterministic_private_and_mixed_failures_do_not_switch(self):
|
||||
errors = [failure(404), failure(401), failure(url="https://private.test/maven2/a.pom"),
|
||||
failure(url="https://user:password@repo.maven.apache.org/maven2/a.pom"),
|
||||
failure(url="https://repo.maven.apache.org/maven2/a.pom?token=secret"),
|
||||
(1, failure()[1] + "\n> No space left on device", False),
|
||||
(1, failure()[1].replace("* Try:", "> PKIX certificate failure\n* Try:"), False),
|
||||
(1, failure()[1].replace("* Try:", "> An unknown failure\n* Try:"), False)]
|
||||
for error in errors:
|
||||
with self.subTest(error=error):
|
||||
self.calls.clear()
|
||||
self.assertEqual(self.execute([OK, error]), 1)
|
||||
self.assertEqual(len(self.calls), 2)
|
||||
|
||||
def test_unknown_wrapper_failure_does_not_enter_acquisition(self):
|
||||
self.assertEqual(self.execute([(1, "checksum verification failed", False)]), 1)
|
||||
self.assertEqual(len(self.calls), 1)
|
||||
|
||||
def test_cancellation_never_switches_and_preserves_other_init_scripts(self):
|
||||
directory = self.home / "init.d"
|
||||
directory.mkdir(parents=True)
|
||||
other = directory / "existing.gradle"
|
||||
other.write_text("// existing")
|
||||
with self.assertRaises(gradle.Cancelled):
|
||||
self.execute([OK, gradle.Cancelled(signal.SIGTERM)])
|
||||
self.assertEqual(len(self.calls), 2)
|
||||
self.assertEqual(other.read_text(), "// existing")
|
||||
|
||||
def test_original_exception_still_cleans_up(self):
|
||||
with self.assertRaises(OSError):
|
||||
self.execute([OK, OK, OSError("cannot start original")])
|
||||
self.assertEqual(len(self.calls), 3)
|
||||
|
||||
@unittest.skipUnless(sys.platform == "linux", "requires Linux process groups")
|
||||
def test_spawn_cancellation_reaps_child_before_init_cleanup(self):
|
||||
events = []
|
||||
with patch.object(gradle.signal, "signal"), patch.object(gradle.os, "killpg", side_effect=lambda *_: events.append("kill")), \
|
||||
patch.object(gradle.subprocess, "Popen") as popen:
|
||||
child = popen.return_value
|
||||
child.communicate.side_effect = lambda: (events.append("reap") or "", None)
|
||||
|
||||
def spawn(*_args, **_kwargs):
|
||||
gradle.cancel(signal.SIGTERM, None)
|
||||
return child
|
||||
|
||||
popen.side_effect = spawn
|
||||
with self.assertRaises(gradle.Cancelled):
|
||||
gradle.run_process(["gradle"], self.project, {}, 1)
|
||||
self.assertEqual(events, ["kill", "reap"])
|
||||
|
||||
@unittest.skipUnless(sys.platform == "linux", "requires Linux process groups and /proc")
|
||||
def test_timeout_and_cancellation_dispose_owned_descendants(self):
|
||||
for signum in [0, signal.SIGTERM, signal.SIGINT]:
|
||||
with self.subTest(signum=signum):
|
||||
case = self.root / str(signum)
|
||||
case.mkdir()
|
||||
pids = case / "pids"
|
||||
harness = textwrap.dedent("""
|
||||
import importlib.util, os, signal, sys
|
||||
from pathlib import Path
|
||||
spec = importlib.util.spec_from_file_location('acquire', sys.argv[1])
|
||||
acquire = importlib.util.module_from_spec(spec); spec.loader.exec_module(acquire)
|
||||
signal.signal(signal.SIGINT, acquire.cancel)
|
||||
signal.signal(signal.SIGTERM, acquire.cancel)
|
||||
os.environ['GRADLE_USER_HOME'] = sys.argv[2]
|
||||
payload = '''import os, subprocess, sys, time
|
||||
child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])
|
||||
with open(sys.argv[1], 'a') as output: print(os.getpid(), child.pid, file=output)
|
||||
time.sleep(60)
|
||||
'''
|
||||
def run(command, cwd, env, budget, capture=True):
|
||||
if command[-1] == '--version': return 0, '', False
|
||||
if not capture: raise RuntimeError('execution reached without acquisition')
|
||||
return acquire.run_process([sys.executable, '-c', payload, sys.argv[3]], cwd, env, 1)
|
||||
try:
|
||||
code = acquire.acquire_and_run(Path(sys.argv[2]), 'agents', ['original'], runner=run, sleep=lambda _: None)
|
||||
sys.exit(code)
|
||||
except acquire.Cancelled as error:
|
||||
sys.exit(128 + error.signum)
|
||||
""")
|
||||
process = subprocess.Popen([sys.executable, "-c", harness, gradle.__file__,
|
||||
str(case), str(pids)], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
try:
|
||||
deadline = time.monotonic() + 10
|
||||
while (not pids.exists() or not pids.read_text().strip()) and process.poll() is None and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
self.assertTrue(pids.exists() and pids.read_text().strip(), "fake acquisition did not start")
|
||||
if signum:
|
||||
process.send_signal(signum)
|
||||
output = process.communicate(timeout=10)[0]
|
||||
self.assertNotEqual(process.returncode, 0, output)
|
||||
self.assertNotIn("phase=execute", output)
|
||||
self.assertEqual(len(pids.read_text().splitlines()), 1 if signum else 3)
|
||||
self.assertFalse(list((case / "init.d").glob("dbx-ci-acquire-*.gradle")))
|
||||
for pid in pids.read_text().split():
|
||||
status = Path(f"/proc/{pid}/stat")
|
||||
deadline = time.monotonic() + 5
|
||||
while status.exists() and status.read_text().split()[2] != "Z" and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
self.assertTrue(not status.exists() or status.read_text().split()[2] == "Z", f"owned child {pid} survived")
|
||||
finally:
|
||||
if pids.exists():
|
||||
for line in pids.read_text().splitlines():
|
||||
try:
|
||||
os.killpg(int(line.split()[0]), signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.communicate(timeout=5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
archive=$(mktemp)
|
||||
trap 'rm -f -- "$archive"' EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
curl --fail --location --silent --show-error \
|
||||
--retry 2 --retry-connrefused --retry-delay 2 \
|
||||
--connect-timeout 15 --max-time 120 --retry-max-time 360 \
|
||||
https://github.com/rui314/mold/releases/download/v2.42.1/mold-2.42.1-x86_64-linux.tar.gz \
|
||||
--output "$archive"
|
||||
sudo tar -C /usr/local --strip-components=1 -xzf "$archive"
|
||||
mold --version
|
||||
@@ -0,0 +1,117 @@
|
||||
import http.server
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
SCRIPT = Path(__file__).with_name("ci-install-mold.sh")
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == "linux", "requires the Linux CI shell and curl")
|
||||
class MoldDownloadTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.root = Path(self.directory.name)
|
||||
self.bin = self.root / "bin"
|
||||
self.bin.mkdir()
|
||||
self.downloads = self.root / "downloads"
|
||||
self.downloads.mkdir()
|
||||
self.statuses = [200]
|
||||
self.requests = 0
|
||||
self.started = threading.Event()
|
||||
self.release = threading.Event()
|
||||
owner = self
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
status = owner.statuses[min(owner.requests, len(owner.statuses) - 1)]
|
||||
owner.requests += 1
|
||||
self.send_response(200 if status == "partial" else status)
|
||||
self.send_header("Content-Length", "1000" if status == "partial" else "7")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"archive")
|
||||
self.wfile.flush()
|
||||
owner.started.set()
|
||||
if status == "partial":
|
||||
owner.release.wait(10)
|
||||
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
self.addCleanup(server.server_close)
|
||||
self.addCleanup(server.shutdown)
|
||||
self.addCleanup(self.release.set)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
self.env = {**os.environ, "PATH": str(self.bin) + os.pathsep + os.environ["PATH"],
|
||||
"REAL_CURL": shutil.which("curl"), "TEST_URL": f"http://127.0.0.1:{server.server_port}/mold",
|
||||
"TEST_ROOT": str(self.root), "TMPDIR": str(self.downloads)}
|
||||
self.tool("curl", """import os, sys
|
||||
args = sys.argv[1:]
|
||||
assert '--retry-all-errors' not in args
|
||||
for option, value in [('--retry', '2'), ('--connect-timeout', '15'), ('--max-time', '120'), ('--retry-max-time', '360')]:
|
||||
assert args[args.index(option) + 1] == value
|
||||
assert 'https://github.com/rui314/mold/releases/download/v2.42.1/mold-2.42.1-x86_64-linux.tar.gz' in args
|
||||
args = [os.environ['TEST_URL'] if arg.startswith('https://') else arg for arg in args]
|
||||
os.execv(os.environ['REAL_CURL'], [os.environ['REAL_CURL'], *args])
|
||||
""")
|
||||
self.tool("sudo", """import os, sys
|
||||
from pathlib import Path
|
||||
assert sys.argv[1:6] == ['tar', '-C', '/usr/local', '--strip-components=1', '-xzf']
|
||||
assert Path(sys.argv[-1]).read_bytes() == b'archive'
|
||||
with (Path(os.environ['TEST_ROOT']) / 'installed').open('a') as output: output.write('once\\n')
|
||||
""")
|
||||
self.tool("mold", "print('mold 2.42.1')\n")
|
||||
|
||||
def tool(self, name, body):
|
||||
path = self.bin / name
|
||||
path.write_text(f"#!{sys.executable}\n" + body)
|
||||
path.chmod(0o755)
|
||||
|
||||
def run_script(self):
|
||||
result = subprocess.run(["bash", str(SCRIPT)], env=self.env, capture_output=True, text=True, timeout=20)
|
||||
self.assertEqual(list(self.downloads.iterdir()), [])
|
||||
return result
|
||||
|
||||
def test_transient_status_recovers_before_one_install(self):
|
||||
self.statuses = [503, 503, 200]
|
||||
result = self.run_script()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(self.requests, 3)
|
||||
self.assertEqual((self.root / "installed").read_text(), "once\n")
|
||||
|
||||
def test_not_found_and_exhausted_retries_never_install(self):
|
||||
for statuses, requests in [([404], 1), ([503], 3)]:
|
||||
with self.subTest(statuses=statuses):
|
||||
self.statuses = statuses
|
||||
self.requests = 0
|
||||
result = self.run_script()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.requests, requests)
|
||||
self.assertFalse((self.root / "installed").exists())
|
||||
|
||||
def test_cancellation_cleans_partial_file_without_install(self):
|
||||
self.statuses = ["partial"]
|
||||
process = subprocess.Popen(["bash", str(SCRIPT)], env=self.env, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, start_new_session=True)
|
||||
try:
|
||||
self.assertTrue(self.started.wait(5), "curl did not start")
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
process.communicate(timeout=5)
|
||||
self.assertNotEqual(process.returncode, 0)
|
||||
self.assertFalse((self.root / "installed").exists())
|
||||
self.assertEqual(list(self.downloads.iterdir()), [])
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.communicate(timeout=5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download Ubuntu dependencies with at most two mirror switches, then install offline."""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
SOURCES = Path("/etc/apt/sources.list.d/ubuntu.sources")
|
||||
PRIMARY = "https://archive.ubuntu.com/ubuntu/"
|
||||
BACKUP = "https://mirrors.edge.kernel.org/ubuntu/"
|
||||
LAST_RESORT = "http://azure.archive.ubuntu.com/ubuntu/"
|
||||
MIRRORS = [("primary", PRIMARY), ("backup", BACKUP), ("last-resort", LAST_RESORT)]
|
||||
PACKAGES = ["libwebkit2gtk-4.1-dev", "libappindicator3-dev", "librsvg2-dev",
|
||||
"patchelf", "libssl-dev", "libsecret-1-dev"]
|
||||
PACKAGE_GROUPS = {"gtk": PACKAGES, "packaging": ["zstd"],
|
||||
"duckdb": ["libdbus-1-dev", "pkg-config"]}
|
||||
ACQUIRE_OPTIONS = ["-o", "Acquire::Retries=0", "-o", "Acquire::http::Timeout=30",
|
||||
"-o", "Acquire::https::Timeout=30", "-o", "APT::Update::Error-Mode=any"]
|
||||
starting_process = False
|
||||
pending_cancel = None
|
||||
|
||||
|
||||
def rewrite_ubuntu_uris(source, mirror):
|
||||
known = {"mirror+file:/etc/apt/apt-mirrors.txt", PRIMARY, PRIMARY.rstrip("/"),
|
||||
BACKUP, BACKUP.rstrip("/"), LAST_RESORT, LAST_RESORT.rstrip("/")}
|
||||
found = 0
|
||||
in_uris = False
|
||||
result = []
|
||||
|
||||
def replace(match):
|
||||
nonlocal found
|
||||
if match.group() in known:
|
||||
found += 1
|
||||
return mirror
|
||||
return match.group()
|
||||
|
||||
for line in source.splitlines(keepends=True):
|
||||
if line.lstrip().startswith("#"):
|
||||
result.append(line)
|
||||
continue
|
||||
field = re.match(r"^([A-Za-z][A-Za-z0-9-]*):", line)
|
||||
if field:
|
||||
in_uris = field[1].lower() == "uris"
|
||||
elif not line.strip() or not line[0].isspace():
|
||||
in_uris = False
|
||||
if in_uris:
|
||||
prefix = field.end() if field else 0
|
||||
line = line[:prefix] + re.sub(r"\S+", replace, line[prefix:])
|
||||
result.append(line)
|
||||
if not found:
|
||||
raise ValueError("Ubuntu sources do not contain the expected runner mirror URI")
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def is_retryable_acquisition_failure(output, timed_out):
|
||||
if re.search(r"signature|NO_PUBKEY|GPG error|certificate|not signed|Release file|"
|
||||
r"Hash Sum mismatch|unexpected size|Unable to locate package|unmet dependencies|"
|
||||
r"held broken packages|Could not get lock|Unable to acquire|Waiting for cache lock|"
|
||||
r"dpkg was interrupted|Permission denied|No space left|404\s", output, re.I):
|
||||
return False
|
||||
reasons = []
|
||||
for line in output.splitlines():
|
||||
fetch = re.match(r"[EW]: Failed to fetch \S+\s+(.+)", line)
|
||||
if fetch:
|
||||
reasons.append(fetch[1])
|
||||
elif line.startswith("E:") and not line.startswith((
|
||||
"E: Unable to fetch some archives,", "E: Some index files failed to download."
|
||||
)):
|
||||
return False
|
||||
network = re.compile(r"Temporary failure resolving|Could not resolve|Could not connect|"
|
||||
r"Connection (?:failed|timed out|reset)|Network is unreachable|"
|
||||
r"Error reading from server|Remote end closed|"
|
||||
r"(?:^|\s)(?:429|500|502|503|504)(?:\s|$)", re.I)
|
||||
if any(not network.search(reason) for reason in reasons):
|
||||
return False
|
||||
return timed_out or bool(reasons)
|
||||
|
||||
|
||||
class Cancelled(BaseException):
|
||||
def __init__(self, signum):
|
||||
self.signum = signum
|
||||
|
||||
|
||||
def cancel(signum, _frame):
|
||||
global pending_cancel
|
||||
# Finish child disposal and source restoration even if cancellation repeats.
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||
if starting_process:
|
||||
pending_cancel = signum
|
||||
return
|
||||
raise Cancelled(signum)
|
||||
|
||||
|
||||
def dispose(process):
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return process.communicate()[0]
|
||||
|
||||
|
||||
def run_apt(arguments, budget):
|
||||
global starting_process, pending_cancel
|
||||
started = time.monotonic()
|
||||
process = None
|
||||
timed_out = False
|
||||
try:
|
||||
starting_process = True
|
||||
process = subprocess.Popen(
|
||||
["apt-get", *ACQUIRE_OPTIONS, *arguments], stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True, env={**os.environ, "LC_ALL": "C"},
|
||||
start_new_session=True,
|
||||
)
|
||||
starting_process = False
|
||||
if pending_cancel is not None:
|
||||
signum, pending_cancel = pending_cancel, None
|
||||
raise Cancelled(signum)
|
||||
try:
|
||||
output = process.communicate(timeout=budget)[0]
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
output = dispose(process)
|
||||
except BaseException:
|
||||
if process is not None:
|
||||
dispose(process)
|
||||
raise
|
||||
finally:
|
||||
starting_process = False
|
||||
print(output, end="", flush=True)
|
||||
print(f"APT finished elapsed={time.monotonic() - started:.1f}s "
|
||||
f"exit={process.returncode} timed_out={timed_out}", flush=True)
|
||||
return process.returncode, output, timed_out
|
||||
|
||||
|
||||
def install(source_path=SOURCES, group="gtk"):
|
||||
packages = PACKAGE_GROUPS[group]
|
||||
original = source_path.read_bytes()
|
||||
source = original.decode("utf-8")
|
||||
candidates = [(label, rewrite_ubuntu_uris(source, mirror)) for label, mirror in MIRRORS]
|
||||
try:
|
||||
for index, (label, candidate) in enumerate(candidates):
|
||||
source_path.write_bytes(candidate.encode("utf-8"))
|
||||
for phase, arguments, budget in [
|
||||
("index", ["update"], 60),
|
||||
("download", ["--download-only", "install", "-y", *packages], 120),
|
||||
]:
|
||||
print(f"APT source={label} phase={phase} budget={budget}s", flush=True)
|
||||
code, output, timed_out = run_apt(arguments, budget)
|
||||
if code != 0 or timed_out:
|
||||
if index + 1 < len(candidates) and is_retryable_acquisition_failure(output, timed_out):
|
||||
reason = "acquisition deadline" if timed_out else "transient acquisition error"
|
||||
print(f"APT switching to next source {MIRRORS[index + 1][1]}: {reason}", flush=True)
|
||||
break
|
||||
raise RuntimeError(f"APT {label} {phase} failed (exit={code}, timed_out={timed_out})")
|
||||
else:
|
||||
break
|
||||
print("APT phase=install budget=1200s downloads=disabled", flush=True)
|
||||
code, _output, timed_out = run_apt(["--no-download", "install", "-y", *packages], 1200)
|
||||
if code != 0 or timed_out:
|
||||
raise RuntimeError(f"APT offline install failed (exit={code}, timed_out={timed_out})")
|
||||
finally:
|
||||
source_path.write_bytes(original)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
signal.signal(signal.SIGINT, cancel)
|
||||
signal.signal(signal.SIGTERM, cancel)
|
||||
try:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--group", choices=PACKAGE_GROUPS, default="gtk")
|
||||
install(group=parser.parse_args().group)
|
||||
except Cancelled as error:
|
||||
print("APT cancelled; no mirror retry", file=sys.stderr)
|
||||
sys.exit(128 + error.signum)
|
||||
except Exception as error:
|
||||
print(f"::error::{error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,282 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
SCRIPT = Path(__file__).with_name("ci-install-system-deps.py")
|
||||
spec = importlib.util.spec_from_file_location("deps", SCRIPT)
|
||||
deps = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(deps)
|
||||
|
||||
SOURCE = """Types: deb
|
||||
URIs: mirror+file:/etc/apt/apt-mirrors.txt
|
||||
Suites: noble noble-updates noble-backports
|
||||
Components: main restricted universe multiverse
|
||||
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
|
||||
|
||||
Types: deb
|
||||
URIs:
|
||||
mirror+file:/etc/apt/apt-mirrors.txt
|
||||
Suites: noble-security
|
||||
Components: main restricted universe multiverse
|
||||
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
|
||||
|
||||
Types: deb
|
||||
URIs: https://security.ubuntu.com/ubuntu/
|
||||
Suites: noble-security
|
||||
Components: main
|
||||
|
||||
Types: deb
|
||||
URIs: https://example.test/apt
|
||||
Suites: stable
|
||||
Components: main
|
||||
"""
|
||||
OK = (0, "", False)
|
||||
TIMEOUT = (-9, "", True)
|
||||
NETWORK = (100, "E: Failed to fetch http://azure.archive.ubuntu.com/a.deb Connection timed out\n"
|
||||
"E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing?\n", False)
|
||||
|
||||
|
||||
class InstallerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.source = Path(self.directory.name) / "ubuntu.sources"
|
||||
self.source.write_bytes(SOURCE.encode())
|
||||
self.calls = []
|
||||
|
||||
def execute(self, results, group="gtk"):
|
||||
responses = iter(results)
|
||||
|
||||
def run(arguments, budget):
|
||||
self.calls.append((arguments, budget, self.source.read_text()))
|
||||
return next(responses)
|
||||
|
||||
with patch.object(deps, "run_apt", side_effect=run):
|
||||
try:
|
||||
deps.install(self.source, group)
|
||||
finally:
|
||||
self.assertEqual(self.source.read_bytes(), SOURCE.encode())
|
||||
|
||||
def test_success_downloads_before_one_offline_install(self):
|
||||
self.execute([OK, OK, OK])
|
||||
self.assertEqual([call[1] for call in self.calls], [60, 120, 1200])
|
||||
self.assertEqual(self.calls[1][0], ["--download-only", "install", "-y", *deps.PACKAGES])
|
||||
self.assertEqual(self.calls[2][0], ["--no-download", "install", "-y", *deps.PACKAGES])
|
||||
self.assertTrue(all(deps.BACKUP not in call[2] for call in self.calls))
|
||||
|
||||
def test_fixed_package_groups_preserve_each_caller_list(self):
|
||||
for group, packages in {"packaging": ["zstd"],
|
||||
"duckdb": ["libdbus-1-dev", "pkg-config"]}.items():
|
||||
with self.subTest(group=group):
|
||||
self.calls.clear()
|
||||
self.execute([OK, OK, OK], group)
|
||||
self.assertEqual(self.calls[1][0], ["--download-only", "install", "-y", *packages])
|
||||
self.assertEqual(self.calls[2][0], ["--no-download", "install", "-y", *packages])
|
||||
|
||||
def test_official_primary_and_known_runner_sources(self):
|
||||
self.assertEqual(deps.PRIMARY, "https://archive.ubuntu.com/ubuntu/")
|
||||
for uri in [deps.PRIMARY, deps.BACKUP, deps.LAST_RESORT, "mirror+file:/etc/apt/apt-mirrors.txt"]:
|
||||
with self.subTest(uri=uri):
|
||||
source = SOURCE.replace("mirror+file:/etc/apt/apt-mirrors.txt", uri)
|
||||
rewritten = deps.rewrite_ubuntu_uris(source, deps.PRIMARY)
|
||||
self.assertIn("URIs: https://archive.ubuntu.com/ubuntu/", rewritten)
|
||||
self.assertIn("URIs: https://security.ubuntu.com/ubuntu/", rewritten)
|
||||
self.assertIn("Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg", rewritten)
|
||||
|
||||
def test_timeout_or_network_failure_switches_once_and_reuses_cache(self):
|
||||
for failure in [TIMEOUT, NETWORK]:
|
||||
with self.subTest(failure=failure):
|
||||
self.calls.clear()
|
||||
self.execute([OK, failure, OK, OK, OK])
|
||||
self.assertEqual([call[1] for call in self.calls], [60, 120, 60, 120, 1200])
|
||||
self.assertIn(deps.PRIMARY, self.calls[1][2])
|
||||
self.assertNotIn(deps.PRIMARY, self.calls[2][2])
|
||||
self.assertIn(deps.BACKUP, self.calls[2][2])
|
||||
self.assertFalse(any("clean" in call[0] or "--fix-missing" in call[0] for call in self.calls))
|
||||
self.assertTrue(all(not any("Dir::Cache" in arg for arg in call[0]) for call in self.calls))
|
||||
|
||||
def test_network_index_failure_refreshes_backup_before_downloading(self):
|
||||
self.execute([NETWORK, OK, OK, OK])
|
||||
self.assertEqual([call[0][0] for call in self.calls], ["update", "update", "--download-only", "--no-download"])
|
||||
|
||||
def test_all_three_sources_fail_without_installing_partial_downloads(self):
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.execute([OK, TIMEOUT, OK, NETWORK, OK, NETWORK])
|
||||
self.assertEqual(len(self.calls), 6)
|
||||
self.assertFalse(any("--no-download" in call[0] for call in self.calls))
|
||||
|
||||
def test_second_switch_is_last_resort_and_never_loops(self):
|
||||
self.execute([OK, TIMEOUT, OK, NETWORK, OK, OK, OK])
|
||||
self.assertEqual(len(self.calls), 7)
|
||||
self.assertIn(deps.PRIMARY, self.calls[1][2])
|
||||
self.assertIn(deps.BACKUP, self.calls[3][2])
|
||||
self.assertIn(deps.LAST_RESORT, self.calls[5][2])
|
||||
self.assertEqual(self.calls[6][0][0], "--no-download")
|
||||
|
||||
def test_deterministic_and_unknown_errors_never_switch(self):
|
||||
for output in ["E: Unable to locate package missing", "W: GPG error: invalid signatures",
|
||||
"E: Failed to fetch https://archive.ubuntu.com/a.deb Certificate verification failed",
|
||||
"E: Failed to fetch http://azure.archive.ubuntu.com/a.deb 404 Not Found",
|
||||
"E: Failed to fetch http://azure.archive.ubuntu.com/a.deb Hash Sum mismatch",
|
||||
"E: Could not get lock /var/lib/dpkg/lock", "E: No space left on device",
|
||||
"E: Unknown error", NETWORK[1] + "E: unmet dependencies"]:
|
||||
for timed_out in [False, True]:
|
||||
with self.subTest(output=output, timed_out=timed_out):
|
||||
self.calls.clear()
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.execute([OK, (100, output, timed_out)])
|
||||
self.assertEqual(len(self.calls), 2)
|
||||
self.assertFalse(deps.is_retryable_acquisition_failure("", False))
|
||||
self.assertFalse(deps.is_retryable_acquisition_failure(
|
||||
NETWORK[1] + "E: Failed to fetch https://example.test/a.deb Unknown error", False))
|
||||
|
||||
def test_http_transient_errors_are_allowed_but_not_unknown_fetch_errors(self):
|
||||
for status in [429, 500, 502, 503, 504]:
|
||||
self.assertTrue(deps.is_retryable_acquisition_failure(
|
||||
f"E: Failed to fetch https://archive.ubuntu.com/a.deb {status} Server error", False))
|
||||
self.assertFalse(deps.is_retryable_acquisition_failure(
|
||||
"E: Failed to fetch https://archive.ubuntu.com/a.deb Something went wrong", False))
|
||||
|
||||
def test_install_failure_is_terminal_without_retry(self):
|
||||
for failure in [TIMEOUT, NETWORK, (100, "E: dpkg failed", False)]:
|
||||
self.calls.clear()
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.execute([OK, OK, failure])
|
||||
self.assertEqual(len(self.calls), 3)
|
||||
|
||||
def test_source_rewrite_preserves_all_non_uri_fields_and_security(self):
|
||||
rewritten = deps.rewrite_ubuntu_uris(SOURCE, deps.BACKUP)
|
||||
self.assertEqual(rewritten, SOURCE.replace("mirror+file:/etc/apt/apt-mirrors.txt", deps.BACKUP))
|
||||
self.assertEqual(deps.rewrite_ubuntu_uris(SOURCE.replace("mirror+file:/etc/apt/apt-mirrors.txt", deps.PRIMARY), deps.BACKUP), rewritten)
|
||||
self.source.write_text("Types: deb\nURIs: https://unknown.test/ubuntu\nSuites: noble\n")
|
||||
original = self.source.read_bytes()
|
||||
with self.assertRaises(ValueError), patch.object(deps, "run_apt") as run:
|
||||
deps.install(self.source)
|
||||
run.assert_not_called()
|
||||
self.assertEqual(self.source.read_bytes(), original)
|
||||
|
||||
def test_run_apt_uses_an_owned_process_group_and_c_locale(self):
|
||||
with patch.object(deps.subprocess, "Popen") as popen:
|
||||
popen.return_value.communicate.return_value = ("downloaded\n", None)
|
||||
popen.return_value.returncode = 0
|
||||
self.assertEqual(deps.run_apt(["--download-only", "install", "-y", *deps.PACKAGES], 120), (0, "downloaded\n", False))
|
||||
self.assertTrue(popen.call_args.kwargs["start_new_session"])
|
||||
self.assertEqual(popen.call_args.kwargs["env"]["LC_ALL"], "C")
|
||||
self.assertIn("Acquire::Retries=0", popen.call_args.args[0])
|
||||
|
||||
@unittest.skipUnless(sys.platform == "linux", "requires Linux process groups")
|
||||
def test_cancellation_during_spawn_reaps_before_restoring_sources(self):
|
||||
events = []
|
||||
write = Path.write_bytes
|
||||
|
||||
def record_write(path, content):
|
||||
if content == SOURCE.encode():
|
||||
events.append("restore")
|
||||
return write(path, content)
|
||||
|
||||
with patch.object(deps.subprocess, "Popen") as popen, \
|
||||
patch.object(deps.signal, "signal"), \
|
||||
patch.object(deps.os, "killpg", side_effect=lambda *_: events.append("kill")), \
|
||||
patch.object(Path, "write_bytes", autospec=True, side_effect=record_write):
|
||||
process = popen.return_value
|
||||
process.communicate.side_effect = lambda: (events.append("reap") or "", None)
|
||||
|
||||
def spawn(*_args, **_kwargs):
|
||||
events.append("spawn")
|
||||
deps.cancel(signal.SIGTERM, None)
|
||||
return process
|
||||
|
||||
popen.side_effect = spawn
|
||||
with self.assertRaises(deps.Cancelled):
|
||||
deps.install(self.source)
|
||||
popen.assert_called_once()
|
||||
self.assertEqual(events, ["spawn", "kill", "reap", "restore"])
|
||||
self.assertEqual(self.source.read_bytes(), SOURCE.encode())
|
||||
|
||||
def run_child_case(self, signum=0):
|
||||
pid_file = Path(self.directory.name) / f"child-{signum}.pid"
|
||||
payload = textwrap.dedent("""
|
||||
import os, subprocess, sys, time
|
||||
child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])
|
||||
open(sys.argv[1], 'w').write(f'{os.getpid()} {child.pid}')
|
||||
time.sleep(60)
|
||||
""")
|
||||
harness = textwrap.dedent("""
|
||||
import importlib.util, signal, subprocess, sys
|
||||
from pathlib import Path
|
||||
spec = importlib.util.spec_from_file_location('deps', sys.argv[1])
|
||||
deps = importlib.util.module_from_spec(spec); spec.loader.exec_module(deps)
|
||||
deps.signal.signal(signal.SIGINT, deps.cancel)
|
||||
deps.signal.signal(signal.SIGTERM, deps.cancel)
|
||||
original_popen = subprocess.Popen
|
||||
deps.subprocess.Popen = lambda *a, **kw: original_popen([sys.executable, '-c', sys.argv[3], sys.argv[4]], **kw)
|
||||
original_run = deps.run_apt
|
||||
deps.run_apt = lambda args, budget: original_run(args, 3)
|
||||
try:
|
||||
deps.install(Path(sys.argv[2]))
|
||||
except deps.Cancelled as error:
|
||||
sys.exit(128 + error.signum)
|
||||
except RuntimeError:
|
||||
sys.exit(1)
|
||||
""")
|
||||
process = subprocess.Popen([sys.executable, "-c", harness, str(SCRIPT), str(self.source), payload, str(pid_file)],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
def cleanup():
|
||||
if pid_file.exists() and pid_file.read_text():
|
||||
try:
|
||||
os.killpg(int(pid_file.read_text().split()[0]), signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.communicate(timeout=5)
|
||||
|
||||
self.addCleanup(cleanup)
|
||||
deadline = time.monotonic() + 10
|
||||
while not pid_file.exists() and process.poll() is None and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
self.assertTrue(pid_file.exists(), "fake apt did not start")
|
||||
if signum:
|
||||
process.send_signal(signum)
|
||||
output = process.communicate(timeout=15)[0]
|
||||
self.assertEqual(process.returncode, 128 + signum if signum else 1, output)
|
||||
self.assertEqual(self.source.read_bytes(), SOURCE.encode())
|
||||
self.assertNotIn("phase=install", output)
|
||||
child = Path(f"/proc/{pid_file.read_text().split()[1]}/stat")
|
||||
|
||||
def child_running():
|
||||
try:
|
||||
return child.read_text().split()[2] != "Z"
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
deadline = time.monotonic() + 5
|
||||
while child_running() and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
self.assertFalse(child_running(), "descendant still running")
|
||||
return output
|
||||
|
||||
@unittest.skipUnless(sys.platform == "linux", "requires Linux process groups and /proc")
|
||||
def test_timeout_disposes_descendants_before_backup(self):
|
||||
output = self.run_child_case()
|
||||
self.assertIn("timed_out=True", output)
|
||||
self.assertEqual(output.count("switching to next source"), 2)
|
||||
|
||||
@unittest.skipUnless(sys.platform == "linux", "requires Linux process groups and /proc")
|
||||
def test_cancellation_disposes_descendants_without_backup_and_restores_sources(self):
|
||||
for signum in [signal.SIGINT, signal.SIGTERM]:
|
||||
with self.subTest(signum=signum):
|
||||
output = self.run_child_case(signum)
|
||||
self.assertNotIn("switching to next source", output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -232,7 +232,21 @@ test("code generation and test-only dependencies participate in impact analysis"
|
||||
assert.ok(plan(["crates/dbx-formats/src/lib.rs"], { metadata: fixture }).affected_packages.includes("dbx-types"));
|
||||
});
|
||||
|
||||
for (const file of ["Cargo.toml", "Cargo.lock", ".cargo/config.toml", "rust-toolchain.toml", "vendor/wry/src/lib.rs", "crates/dbx-sql/Cargo.toml", ".github/workflows/ci.yml", ".github/scripts/ci-plan.mjs", ".github/scripts/ci-gate.test.mjs"]) {
|
||||
for (const file of [
|
||||
"Cargo.toml",
|
||||
"Cargo.lock",
|
||||
".cargo/config.toml",
|
||||
"rust-toolchain.toml",
|
||||
"vendor/wry/src/lib.rs",
|
||||
"crates/dbx-sql/Cargo.toml",
|
||||
".github/workflows/ci.yml",
|
||||
".github/scripts/ci-plan.mjs",
|
||||
".github/scripts/ci-gate.test.mjs",
|
||||
".github/scripts/ci-gradle.py",
|
||||
".github/scripts/ci-gradle.init.gradle",
|
||||
".github/scripts/ci-install-system-deps.py",
|
||||
".github/scripts/ci-install-mold.sh",
|
||||
]) {
|
||||
test(`shared input ${file} retains full workspace and Agent coverage`, () => {
|
||||
const result = plan([file]);
|
||||
assert.deepEqual(groups(result), ["workspace"]);
|
||||
|
||||
@@ -279,15 +279,36 @@ test("Rust build timings remain available after failures without waiting for can
|
||||
assert.ok(job("agent-rust").includes('--manifest-path "drivers/$DRIVER/Cargo.toml" --locked --no-fail-fast --timings'));
|
||||
});
|
||||
|
||||
test("Linux Rust dependency installs bound apt mirror stalls", () => {
|
||||
for (const name of ["packages", "rust-fmt-clippy", "rust-test", "agent-rust"]) {
|
||||
const content = job(name);
|
||||
const deadlines = [...content.matchAll(/sudo timeout --kill-after=30s (\d+)m apt-get/g)].map((match) => Number(match[1]));
|
||||
assert.deepEqual(deadlines, [10, name === "agent-rust" ? 10 : 20]);
|
||||
for (const option of ["Acquire::Retries=3", "Acquire::http::Timeout=30", "Acquire::https::Timeout=30"]) {
|
||||
assert.ok(content.includes(option));
|
||||
}
|
||||
test("Linux dependency installs share bounded acquisition before offline installation", () => {
|
||||
for (const name of ["packages", "rust-fmt-clippy", "rust-test"]) {
|
||||
assert.ok(job(name).includes("sudo python3 .github/scripts/ci-install-system-deps.py"));
|
||||
assert.ok(!job(name).includes("apt-get"));
|
||||
}
|
||||
for (const name of ["agent-checks", "agent-java"]) {
|
||||
assert.ok(job(name).includes("sudo python3 ../.github/scripts/ci-install-system-deps.py --group packaging"));
|
||||
}
|
||||
assert.ok(job("agent-rust").includes("sudo python3 ../.github/scripts/ci-install-system-deps.py --group duckdb"));
|
||||
assert.ok(job("github-scripts").includes("python3 .github/scripts/ci-install-system-deps.test.py"));
|
||||
});
|
||||
|
||||
test("Java CI acquires before original commands without retrying packaging or tests", () => {
|
||||
assert.ok(job("agent-java").includes("python3 ../.github/scripts/ci-gradle.py agents -- ./gradlew test shadowJar --continue"));
|
||||
for (const name of ["jdbc", "offline-jdbc-release"]) {
|
||||
assert.ok(job(name).includes("python3 .github/scripts/ci-gradle.py jdbc -- ./package.sh"));
|
||||
}
|
||||
assert.ok(job("github-scripts").includes("python3 .github/scripts/ci-gradle.test.py"));
|
||||
});
|
||||
|
||||
test("mold downloads are isolated from extraction in CI and keepalive", () => {
|
||||
for (const name of ["packages", "rust-test"]) {
|
||||
assert.ok(job(name).includes("run: bash .github/scripts/ci-install-mold.sh"));
|
||||
}
|
||||
assert.ok(job("agent-rust").includes("run: bash ../.github/scripts/ci-install-mold.sh"));
|
||||
const keepalive = readFileSync(new URL("../workflows/cache-keepalive.yml", import.meta.url), "utf8");
|
||||
assert.ok(keepalive.includes("run: bash .github/scripts/ci-install-mold.sh"));
|
||||
assert.ok(keepalive.includes("sudo python3 .github/scripts/ci-install-system-deps.py"));
|
||||
assert.ok(job("github-scripts").includes("python3 .github/scripts/ci-install-mold.test.py"));
|
||||
assert.ok(job("github-scripts").includes("./.github/scripts/prepare-webview2-win7-runtime.test.ps1"));
|
||||
});
|
||||
|
||||
for (const name of ["rust-test", "agent-integration"]) {
|
||||
@@ -297,7 +318,14 @@ for (const name of ["rust-test", "agent-integration"]) {
|
||||
const block = step.slice(step.indexOf(" timeout 30s")).replace(/^ /gm, "");
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "dbx-docker-ready-"));
|
||||
try {
|
||||
const result = spawnSync("bash", ["-e", "-o", "pipefail", "-c", `
|
||||
const result = spawnSync(
|
||||
"bash",
|
||||
[
|
||||
"-e",
|
||||
"-o",
|
||||
"pipefail",
|
||||
"-c",
|
||||
`
|
||||
sudo() { "$@"; }
|
||||
systemctl() { printf 'restart\\n' >> "$STATE/restarts"; }
|
||||
timeout() { shift; "$@"; }
|
||||
@@ -314,7 +342,10 @@ for (const name of ["rust-test", "agent-integration"]) {
|
||||
}
|
||||
${block}
|
||||
printf 'verified\\n'
|
||||
`], { env: { ...process.env, STATE: directory.replaceAll("\\", "/"), SCENARIO: scenario }, encoding: "utf8", timeout: 15000 });
|
||||
`,
|
||||
],
|
||||
{ env: { ...process.env, STATE: directory.replaceAll("\\", "/"), SCENARIO: scenario }, encoding: "utf8", timeout: 15000 },
|
||||
);
|
||||
assert.ifError(result.error);
|
||||
assert.equal(readFileSync(path.join(directory, "restarts"), "utf8"), "restart\n");
|
||||
const count = Number(readFileSync(path.join(directory, "count"), "utf8"));
|
||||
@@ -389,7 +420,8 @@ test("Windows compatibility jobs cache Rust compilation without wrapping C or C+
|
||||
const win7 = job("windows-win7-bundle");
|
||||
assert.ok(win7.includes("needs.changes.outputs.windows_win7_bundle == 'true'"));
|
||||
assert.doesNotMatch(win7, /x86_64-pc-windows-msvc|Setup Rust for standard Windows/);
|
||||
for (const setting of ["RUSTC_WRAPPER: sccache", "SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}", "SCCACHE_GHA_VERSION: win7-webview2-1.0.902.49-v1", 'SCCACHE_IDLE_TIMEOUT: "0"', 'CARGO_PROFILE_RELEASE_LTO: "off"', 'CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "8"']) assert.ok(win7.includes(setting));
|
||||
for (const setting of ["RUSTC_WRAPPER: sccache", "SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}", "SCCACHE_GHA_VERSION: win7-webview2-1.0.902.49-v1", 'SCCACHE_IDLE_TIMEOUT: "0"', 'CARGO_PROFILE_RELEASE_LTO: "off"', 'CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "8"'])
|
||||
assert.ok(win7.includes(setting));
|
||||
assert.ok(win7.includes("fc920bf0ec8de6ee65d409111f7ec508035751ba"));
|
||||
assert.ok(win7.includes('version: "v0.16.0"'));
|
||||
assert.ok(win7.includes("sccache --show-stats"));
|
||||
@@ -432,7 +464,10 @@ test("Win7 TLS artifacts come from main and retain a build fallback", () => {
|
||||
const win7 = job("windows-win7-bundle");
|
||||
assert.ok(win7.includes("actions: read"));
|
||||
assert.doesNotMatch(win7, /uses: actions\/cache(?:\/restore|\/save)?@v4/);
|
||||
for (const [name, id] of [["AWS-LC", "aws-lc-artifact"], ["OpenSSL", "openssl-artifact"]]) {
|
||||
for (const [name, id] of [
|
||||
["AWS-LC", "aws-lc-artifact"],
|
||||
["OpenSSL", "openssl-artifact"],
|
||||
]) {
|
||||
assert.ok(win7.includes(`if: steps.${id}.outputs.run_id != ''`));
|
||||
assert.ok(win7.includes(`if: steps.${id}.outputs.run_id == ''`));
|
||||
assert.ok(win7.includes(`if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.${id}.outputs.run_id == ''`));
|
||||
|
||||
@@ -7,6 +7,64 @@ param(
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Get-WebView2Archive {
|
||||
param([string]$Uri, [string]$OutFile)
|
||||
|
||||
for ($attempt = 0; $attempt -lt 3; $attempt++) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri $Uri -OutFile $OutFile -TimeoutSec 120
|
||||
return
|
||||
} catch {
|
||||
$errorObject = $_.Exception
|
||||
$retryable = $false
|
||||
if ($errorObject.PSObject.Properties["Response"] -and $null -ne $errorObject.Response) {
|
||||
$retryable = [int]$errorObject.Response.StatusCode -in @(408, 429, 500, 502, 503, 504)
|
||||
} elseif ($errorObject -is [System.Net.Http.HttpRequestException] -and $null -ne $errorObject.StatusCode) {
|
||||
$retryable = [int]$errorObject.StatusCode -in @(408, 429, 500, 502, 503, 504)
|
||||
} elseif ($errorObject -is [System.Net.Http.HttpRequestException]) {
|
||||
$networkCause = $false
|
||||
$unsafeCause = $false
|
||||
for ($cause = $errorObject.InnerException; $null -ne $cause; $cause = $cause.InnerException) {
|
||||
if ($cause -is [System.Security.Authentication.AuthenticationException] -or
|
||||
$cause -is [System.OperationCanceledException]) {
|
||||
$unsafeCause = $true
|
||||
} elseif ($cause -is [System.Net.Sockets.SocketException]) {
|
||||
$networkCause = $networkCause -or $cause.SocketErrorCode -in @(
|
||||
[System.Net.Sockets.SocketError]::ConnectionRefused,
|
||||
[System.Net.Sockets.SocketError]::ConnectionReset,
|
||||
[System.Net.Sockets.SocketError]::ConnectionAborted,
|
||||
[System.Net.Sockets.SocketError]::TimedOut,
|
||||
[System.Net.Sockets.SocketError]::HostNotFound,
|
||||
[System.Net.Sockets.SocketError]::TryAgain,
|
||||
[System.Net.Sockets.SocketError]::NetworkUnreachable,
|
||||
[System.Net.Sockets.SocketError]::HostUnreachable,
|
||||
[System.Net.Sockets.SocketError]::NetworkDown
|
||||
)
|
||||
}
|
||||
}
|
||||
$retryable = $networkCause -and !$unsafeCause
|
||||
} elseif ($errorObject -is [System.Net.WebException]) {
|
||||
$retryable = $errorObject.Status -in @(
|
||||
[System.Net.WebExceptionStatus]::Timeout,
|
||||
[System.Net.WebExceptionStatus]::ConnectFailure,
|
||||
[System.Net.WebExceptionStatus]::NameResolutionFailure,
|
||||
[System.Net.WebExceptionStatus]::ConnectionClosed,
|
||||
[System.Net.WebExceptionStatus]::ReceiveFailure,
|
||||
[System.Net.WebExceptionStatus]::SendFailure
|
||||
)
|
||||
} elseif ($errorObject -is [System.Threading.Tasks.TaskCanceledException]) {
|
||||
$retryable = $true
|
||||
}
|
||||
if (!$retryable -or $attempt -eq 2) { throw }
|
||||
Write-Host "WebView2 archive acquisition failed temporarily; retry $($attempt + 2)/3."
|
||||
if (Test-Path -LiteralPath $OutFile) {
|
||||
Remove-Item -LiteralPath $OutFile -Force
|
||||
}
|
||||
Start-Sleep -Seconds ([math]::Pow(2, $attempt + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$runtimeVersion = "109.0.1518.78"
|
||||
$runtimeFolderName = "Microsoft.WebView2.FixedVersionRuntime.$runtimeVersion.x64"
|
||||
$archiveName = "$runtimeFolderName.cab"
|
||||
@@ -32,7 +90,7 @@ if (Test-Path $archivePath) {
|
||||
|
||||
if (!(Test-Path $archivePath)) {
|
||||
Write-Host "Downloading WebView2 fixed runtime $runtimeVersion for Windows 7..."
|
||||
Invoke-WebRequest -Uri $runtimeUrl -OutFile $archivePath
|
||||
Get-WebView2Archive -Uri $runtimeUrl -OutFile $archivePath
|
||||
}
|
||||
|
||||
$actualHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
$source = Join-Path $PSScriptRoot "prepare-webview2-win7-runtime.ps1"
|
||||
$tokens = $null
|
||||
$errors = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile($source, [ref]$tokens, [ref]$errors)
|
||||
if ($errors.Count) { throw $errors[0] }
|
||||
$definition = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq "Get-WebView2Archive" }, $true)
|
||||
. ([scriptblock]::Create($definition.Extent.Text))
|
||||
|
||||
$directory = Join-Path ([IO.Path]::GetTempPath()) "dbx-ci-webview-$([guid]::NewGuid())"
|
||||
New-Item -ItemType Directory -Path $directory | Out-Null
|
||||
$outFile = Join-Path $directory "archive.cab"
|
||||
|
||||
function Invoke-WebRequest {
|
||||
param($Uri, $OutFile, $TimeoutSec)
|
||||
if ($TimeoutSec -ne 120) { throw "Download timeout changed" }
|
||||
if ($global:dbxWebviewTestcalls -gt 0 -and (Test-Path -LiteralPath $OutFile)) { throw "Partial download was reused" }
|
||||
$result = $global:dbxWebviewTestresponses[$global:dbxWebviewTestcalls]
|
||||
$global:dbxWebviewTestcalls++
|
||||
[IO.File]::WriteAllText($OutFile, "partial")
|
||||
switch ($result) {
|
||||
"ok" { [IO.File]::WriteAllText($OutFile, "complete"); return }
|
||||
"timeout" { throw [Net.WebException]::new("timeout", [Net.WebExceptionStatus]::Timeout) }
|
||||
"refused" { throw [Net.Http.HttpRequestException]::new("connection refused", [Net.Sockets.SocketException]::new([int][Net.Sockets.SocketError]::ConnectionRefused)) }
|
||||
"dns" { throw [Net.Http.HttpRequestException]::new("name lookup failed", [Net.Sockets.SocketException]::new([int][Net.Sockets.SocketError]::HostNotFound)) }
|
||||
"socket-denied" { throw [Net.Http.HttpRequestException]::new("access denied", [Net.Sockets.SocketException]::new([int][Net.Sockets.SocketError]::AccessDenied)) }
|
||||
"cancel-socket" { throw [Net.Http.HttpRequestException]::new("cancelled", [OperationCanceledException]::new("cancelled", [Net.Sockets.SocketException]::new([int][Net.Sockets.SocketError]::ConnectionReset))) }
|
||||
"tls-socket" { throw [Net.Http.HttpRequestException]::new("TLS failed", [Security.Authentication.AuthenticationException]::new("certificate failure", [Net.Sockets.SocketException]::new([int][Net.Sockets.SocketError]::ConnectionReset))) }
|
||||
"tls" { throw [Net.WebException]::new("certificate failure", [Net.WebExceptionStatus]::TrustFailure) }
|
||||
"cancel" { throw [OperationCanceledException]::new("cancelled") }
|
||||
"unknown" { throw "Unknown failure" }
|
||||
default { throw [Net.Http.HttpRequestException]::new("HTTP failure", $null, [Net.HttpStatusCode][int]$result) }
|
||||
}
|
||||
}
|
||||
function Start-Sleep { param($Seconds) $global:dbxWebviewTestdelays += $Seconds }
|
||||
|
||||
try {
|
||||
foreach ($case in @(
|
||||
@{ Responses = @("ok"); Count = 1; Delays = @(); Success = $true },
|
||||
@{ Responses = @("503", "ok"); Count = 2; Delays = @(2); Success = $true },
|
||||
@{ Responses = @("timeout", "429", "ok"); Count = 3; Delays = @(2, 4); Success = $true },
|
||||
@{ Responses = @("refused", "ok"); Count = 2; Delays = @(2); Success = $true },
|
||||
@{ Responses = @("dns", "ok"); Count = 2; Delays = @(2); Success = $true },
|
||||
@{ Responses = @("socket-denied"); Count = 1; Delays = @(); Success = $false },
|
||||
@{ Responses = @("cancel-socket"); Count = 1; Delays = @(); Success = $false },
|
||||
@{ Responses = @("tls-socket"); Count = 1; Delays = @(); Success = $false },
|
||||
@{ Responses = @("503", "503", "503"); Count = 3; Delays = @(2, 4); Success = $false },
|
||||
@{ Responses = @("404"); Count = 1; Delays = @(); Success = $false },
|
||||
@{ Responses = @("403"); Count = 1; Delays = @(); Success = $false },
|
||||
@{ Responses = @("tls"); Count = 1; Delays = @(); Success = $false },
|
||||
@{ Responses = @("cancel"); Count = 1; Delays = @(); Success = $false },
|
||||
@{ Responses = @("unknown"); Count = 1; Delays = @(); Success = $false }
|
||||
)) {
|
||||
Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue
|
||||
$global:dbxWebviewTestresponses = $case.Responses
|
||||
$global:dbxWebviewTestcalls = 0
|
||||
$global:dbxWebviewTestdelays = @()
|
||||
$success = $true
|
||||
try { Get-WebView2Archive -Uri "https://example.test/archive.cab" -OutFile $outFile } catch { $success = $false }
|
||||
if ($success -ne $case.Success -or $global:dbxWebviewTestcalls -ne $case.Count -or
|
||||
($global:dbxWebviewTestdelays -join ",") -ne ($case.Delays -join ",")) {
|
||||
throw "Unexpected acquisition result for $($case.Responses -join ','): success=$success calls=$global:dbxWebviewTestcalls delays=$global:dbxWebviewTestdelays"
|
||||
}
|
||||
}
|
||||
|
||||
# Exercise the actual entry point: downloaded corrupt bytes must not be retried or expanded.
|
||||
Remove-Item -LiteralPath $outFile -Force
|
||||
$global:dbxWebviewTestresponses = @("ok")
|
||||
$global:dbxWebviewTestcalls = 0
|
||||
$global:dbxWebviewTestdelays = @()
|
||||
$failed = $false
|
||||
try { & $source -RuntimeDirectory (Join-Path $directory "runtime") -DownloadDirectory $directory } catch {
|
||||
$failed = $_.Exception.Message -like "*SHA-256 mismatch*"
|
||||
}
|
||||
if (!$failed -or $global:dbxWebviewTestcalls -ne 1) { throw "Corrupt archive did not fail once before extraction" }
|
||||
|
||||
function Get-FileHash { param($LiteralPath, $Algorithm) [pscustomobject]@{ Hash = "7622281cf83de1a35e3a471f432f7a897d65f0a7d3975df08512b7b253dd45c7" } }
|
||||
function Get-AuthenticodeSignature { param($LiteralPath) [pscustomobject]@{ Status = [System.Management.Automation.SignatureStatus]::NotSigned; SignerCertificate = $null } }
|
||||
$failed = $false
|
||||
$global:dbxWebviewTestcalls = 0
|
||||
try { & $source -RuntimeDirectory (Join-Path $directory "runtime") -DownloadDirectory $directory } catch {
|
||||
$failed = $_.Exception.Message -like "*valid Microsoft signature*"
|
||||
}
|
||||
if (!$failed -or $global:dbxWebviewTestcalls -ne 0) { throw "Invalid signature did not fail before extraction without re-download" }
|
||||
Write-Host "WebView2 acquisition and integrity tests passed."
|
||||
} finally {
|
||||
$resolved = [IO.Path]::GetFullPath($directory)
|
||||
if ((Split-Path -Parent $resolved) -ne ([IO.Path]::GetTempPath()).TrimEnd('\', '/')) { throw "Unexpected test cleanup path" }
|
||||
Remove-Item -LiteralPath $resolved -Recurse -Force
|
||||
}
|
||||
@@ -41,25 +41,13 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
update
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
|
||||
run: sudo python3 .github/scripts/ci-install-system-deps.py
|
||||
|
||||
# Pin mold because runner image updates can change bundled C++ links.
|
||||
# See the ci.yml rust-test note for the pinned upstream build.
|
||||
- name: Install mold linker
|
||||
run: |
|
||||
curl -fsSL https://github.com/rui314/mold/releases/download/v2.42.1/mold-2.42.1-x86_64-linux.tar.gz \
|
||||
| sudo tar -C /usr/local --strip-components=1 -xz
|
||||
mold --version
|
||||
timeout-minutes: 8
|
||||
run: bash .github/scripts/ci-install-mold.sh
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1.97.1
|
||||
|
||||
+30
-65
@@ -174,6 +174,19 @@ jobs:
|
||||
- name: Docker mirror config tests
|
||||
run: python3 .github/scripts/configure-docker-mirror.test.py
|
||||
|
||||
- name: APT acquisition tests
|
||||
run: python3 .github/scripts/ci-install-system-deps.test.py
|
||||
|
||||
- name: Gradle acquisition tests
|
||||
run: python3 .github/scripts/ci-gradle.test.py
|
||||
|
||||
- name: Mold acquisition tests
|
||||
run: python3 .github/scripts/ci-install-mold.test.py
|
||||
|
||||
- name: WebView2 acquisition tests
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/prepare-webview2-win7-runtime.test.ps1
|
||||
|
||||
- name: PowerShell MCP installer tests
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/install-mcp-powershell.test.ps1
|
||||
@@ -207,25 +220,13 @@ jobs:
|
||||
cache: pnpm
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
update
|
||||
sudo timeout --kill-after=30s 20m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
|
||||
run: sudo python3 .github/scripts/ci-install-system-deps.py
|
||||
|
||||
# Pin mold because runner image updates can change bundled DuckDB C++ links.
|
||||
# The pinned build prevents duckdb_create_config runtime failures.
|
||||
- name: Install mold linker
|
||||
run: |
|
||||
curl -fsSL https://github.com/rui314/mold/releases/download/v2.42.1/mold-2.42.1-x86_64-linux.tar.gz \
|
||||
| sudo tar -C /usr/local --strip-components=1 -xz
|
||||
mold --version
|
||||
timeout-minutes: 8
|
||||
run: bash .github/scripts/ci-install-mold.sh
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -906,17 +907,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
update
|
||||
sudo timeout --kill-after=30s 20m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
|
||||
run: sudo python3 .github/scripts/ci-install-system-deps.py
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1.97.1
|
||||
@@ -1026,26 +1017,14 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
update
|
||||
sudo timeout --kill-after=30s 20m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
|
||||
run: sudo python3 .github/scripts/ci-install-system-deps.py
|
||||
if: matrix.group != 'foundation'
|
||||
|
||||
# Pin mold because runner image updates can change bundled C++ links.
|
||||
# See the packages job note for the pinned upstream build.
|
||||
- name: Install mold linker
|
||||
run: |
|
||||
curl -fsSL https://github.com/rui314/mold/releases/download/v2.42.1/mold-2.42.1-x86_64-linux.tar.gz \
|
||||
| sudo tar -C /usr/local --strip-components=1 -xz
|
||||
mold --version
|
||||
timeout-minutes: 8
|
||||
run: bash .github/scripts/ci-install-mold.sh
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1.97.1
|
||||
@@ -1174,7 +1153,7 @@ jobs:
|
||||
node .github/scripts/check-jdbc-plugin-version.mjs "$BASE_SHA" HEAD
|
||||
|
||||
- name: JDBC plugin package check
|
||||
run: ./plugins/jdbc/package.sh
|
||||
run: python3 .github/scripts/ci-gradle.py jdbc -- ./package.sh
|
||||
|
||||
- name: JDBC response recovery tests
|
||||
run: bash ./plugins/jdbc/gradlew -p plugins/jdbc --no-daemon test --tests app.dbx.jdbc.LargeResponseRecoveryTest --tests app.dbx.jdbc.StatementCancelTest --tests app.dbx.jdbc.DeferredLobValuesTest
|
||||
@@ -1203,7 +1182,7 @@ jobs:
|
||||
env:
|
||||
DBX_OFFLINE_JDBC_MAVEN_CACHE: ~/.cache/dbx-offline-jdbc-maven
|
||||
run: |
|
||||
./plugins/jdbc/package.sh
|
||||
python3 .github/scripts/ci-gradle.py jdbc -- ./package.sh
|
||||
JDBC_VERSION="$(sed -nE "s/^version[[:space:]]*=[[:space:]]*'([^']+)'.*/\1/p" plugins/jdbc/build.gradle | head -n 1)"
|
||||
node agents/scripts/build_offline_jdbc_payload.mjs \
|
||||
release \
|
||||
@@ -1330,8 +1309,10 @@ jobs:
|
||||
- '.github/workflows/ci.yml'
|
||||
jdbc:
|
||||
- 'plugins/jdbc/**'
|
||||
- '.github/scripts/ci-gradle*'
|
||||
offline_jdbc:
|
||||
- 'plugins/jdbc/**'
|
||||
- '.github/scripts/ci-gradle*'
|
||||
- 'agents/scripts/build_offline_jdbc_payload.mjs'
|
||||
- 'agents/scripts/build_offline_zip.sh'
|
||||
- 'agents/scripts/verify_offline_jdbc_release.mjs'
|
||||
@@ -1464,9 +1445,7 @@ jobs:
|
||||
with:
|
||||
node-version: 22.23.3
|
||||
- name: Install packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zstd
|
||||
run: sudo python3 ../.github/scripts/ci-install-system-deps.py --group packaging
|
||||
- name: Agent script tests
|
||||
run: |
|
||||
python3 -m unittest discover -s scripts -p '*_test.py'
|
||||
@@ -1494,11 +1473,9 @@ jobs:
|
||||
with:
|
||||
cache-provider: basic
|
||||
- name: Install packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zstd
|
||||
run: sudo python3 ../.github/scripts/ci-install-system-deps.py --group packaging
|
||||
- name: Java agent tests and packages
|
||||
run: ./gradlew test shadowJar --continue
|
||||
run: python3 ../.github/scripts/ci-gradle.py agents -- ./gradlew test shadowJar --continue
|
||||
- name: Agent jar validation
|
||||
run: python3 scripts/validate_agent_jars.py
|
||||
agent-rust:
|
||||
@@ -1516,26 +1493,14 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Install system dependencies
|
||||
if: matrix.driver == 'duckdb'
|
||||
run: |
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
update
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
install -y libdbus-1-dev pkg-config
|
||||
run: sudo python3 ../.github/scripts/ci-install-system-deps.py --group duckdb
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1.97.1
|
||||
# Pin mold because runner image updates can change bundled DuckDB C++ links.
|
||||
# See the packages job note for the pinned upstream build.
|
||||
- name: Install mold linker
|
||||
run: |
|
||||
curl -fsSL https://github.com/rui314/mold/releases/download/v2.42.1/mold-2.42.1-x86_64-linux.tar.gz \
|
||||
| sudo tar -C /usr/local --strip-components=1 -xz
|
||||
mold --version
|
||||
timeout-minutes: 8
|
||||
run: bash ../.github/scripts/ci-install-mold.sh
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
|
||||
Reference in new issue
Block a user