diff --git a/agent/build_only.py b/agent/build_only.py new file mode 100644 index 00000000..84c076a5 --- /dev/null +++ b/agent/build_only.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import ipaddress +import os +import posixpath +import stat + + +MAIN_DOCKER_SOCKETS = {"/var/run/docker.sock", "/run/docker.sock"} +BUILD_ONLY_ENDPOINTS = { + ("ping", "GET"), + ("get_version", "GET"), + ("build_image", "POST"), + ("jobs", "GET"), + ("cancel_job", "POST"), +} + + +def validate_build_only_bind_address(address: str | None) -> str: + """Accept only a specific IPv4 address from Tailscale's CGNAT range.""" + try: + parsed = ipaddress.ip_address(address or "") + except ValueError as exc: + raise ValueError("Build-only mode requires a specific Tailnet IPv4 bind address.") from exc + if ( + parsed.version != 4 + or parsed.is_unspecified + or parsed.is_loopback + or parsed not in ipaddress.ip_network("100.64.0.0/10") + ): + raise ValueError("Build-only mode must bind to a specific Tailscale IPv4 address.") + return str(parsed) + + +def is_build_only_request(endpoint: str | None, method: str) -> bool: + return (endpoint, method.upper()) in BUILD_ONLY_ENDPOINTS + + +def validate_docker_target( + docker_host: str | None, + expected_docker_root: str | None, + *, + stat_fn=os.stat, + exists_fn=os.path.exists, + realpath_fn=posixpath.realpath, +) -> tuple[bool, str]: + """Fail closed unless DOCKER_HOST is a dedicated Unix socket and root.""" + if not docker_host or not docker_host.startswith("unix://"): + return False, "Build-only mode requires DOCKER_HOST to be a Unix socket." + + socket_path = docker_host.removeprefix("unix://") + if not posixpath.isabs(socket_path) or socket_path in MAIN_DOCKER_SOCKETS: + return False, "Build-only mode refuses the default Docker socket." + if realpath_fn(socket_path) != socket_path: + return False, "Build-only Docker socket must not be a symlink." + + try: + socket_stat = stat_fn(socket_path) + except OSError: + return False, "Configured build Docker socket is unavailable." + if not stat.S_ISSOCK(socket_stat.st_mode): + return False, "Configured build Docker endpoint is not a Unix socket." + + for main_socket in MAIN_DOCKER_SOCKETS: + if not exists_fn(main_socket): + continue + try: + main_stat = stat_fn(main_socket) + except OSError: + continue + if (socket_stat.st_dev, socket_stat.st_ino) == (main_stat.st_dev, main_stat.st_ino): + return False, "Build-only Docker socket resolves to the host Docker daemon." + + if not expected_docker_root or not posixpath.isabs(expected_docker_root): + return False, "Build-only mode requires an absolute Docker data-root allowlist." + return True, realpath_fn(expected_docker_root) + + +def validate_docker_daemon( + docker_host: str | None, + expected_docker_root: str | None, + *, + client_factory=None, + stat_fn=os.stat, + exists_fn=os.path.exists, + realpath_fn=posixpath.realpath, +) -> tuple[bool, str]: + """Verify the daemon identity via Docker API; no fallback is attempted.""" + valid, expected_root = validate_docker_target( + docker_host, + expected_docker_root, + stat_fn=stat_fn, + exists_fn=exists_fn, + realpath_fn=realpath_fn, + ) + if not valid: + return valid, expected_root + + try: + if client_factory is None: + import docker + + client_factory = docker.DockerClient + client = client_factory(base_url=docker_host, timeout=5) + info = client.info() + client.close() + except Exception: + return False, "Configured build Docker daemon is unreachable." + + actual_root = realpath_fn(info.get("DockerRootDir", "")) + if not actual_root or actual_root != expected_root: + return False, "Docker daemon data-root does not match the configured build-only root." + return True, actual_root + + +def require_build_only_docker() -> None: + """Require an explicitly configured isolated daemon in build-only workers.""" + if os.environ.get("PRESS_AGENT_BUILD_ONLY") != "1": + return + + if any(os.environ.get(name) for name in ("DOCKER_CONTEXT", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH")): + raise RuntimeError("Build-only mode refuses Docker context or TLS overrides.") + + valid, message = validate_docker_daemon( + os.environ.get("DOCKER_HOST"), os.environ.get("BUILD_DOCKER_ROOT") + ) + if not valid: + raise RuntimeError(message) diff --git a/agent/build_utils/validations.py b/agent/build_utils/validations.py index 8127288b..f92188b3 100644 --- a/agent/build_utils/validations.py +++ b/agent/build_utils/validations.py @@ -124,7 +124,7 @@ def get_package_manager_files(repo_path_map: dict[str, str]) -> PackageManagerFi return pfiles_map -def check_python_syntax(dirpath: str) -> str: +def check_python_syntax(dirpath: str, python_validation_image: str | None = None) -> str: """ Script `compileall` will compile all the Python files in the given directory. @@ -140,12 +140,23 @@ def check_python_syntax(dirpath: str) -> str: # Bytecode records its source path, which differs per build, so keep it out of the # build context: a changed file there stops Docker from reusing the app's build step with tempfile.TemporaryDirectory() as pycache_prefix: - proc = subprocess.run( - shlex.split(command), - text=True, - capture_output=True, - env={**os.environ, "PYTHONPYCACHEPREFIX": pycache_prefix}, - ) + try: + proc = subprocess.run( + shlex.split(command), + text=True, + capture_output=True, + env={**os.environ, "PYTHONPYCACHEPREFIX": pycache_prefix}, + ) + except FileNotFoundError: + if ( + os.path.basename(_python) != "python3.14" + or os.environ.get("PRESS_AGENT_BUILD_ONLY") != "1" + or not python_validation_image + ): + raise + return _check_python_syntax_in_build_container( + dirpath, pycache_prefix, python_validation_image + ) if proc.returncode == 0: return "" @@ -155,6 +166,47 @@ def check_python_syntax(dirpath: str) -> str: return proc.stdout +def _check_python_syntax_in_build_container( + dirpath: str, pycache_prefix: str, image: str +) -> str: + """Run Python 3.14 validation only through the explicitly isolated build daemon.""" + import docker + from docker.errors import ContainerError, ImageNotFound + + from agent.build_only import require_build_only_docker + + # This validates the socket inode and daemon data-root and fails closed; never + # let Docker SDK defaults redirect validation to the host Docker daemon. + require_build_only_docker() + client = docker.from_env(timeout=5 * 60) + try: + try: + client.images.get(image) + except ImageNotFound: + client.images.pull(image) + + try: + client.containers.run( + image, + ["python3.14", "-m", "compileall", "-q", "-o", "0", "/src"], + volumes={ + os.path.abspath(dirpath): {"bind": "/src", "mode": "ro"}, + os.path.abspath(pycache_prefix): {"bind": "/pycache", "mode": "rw"}, + }, + environment={"PYTHONPYCACHEPREFIX": "/pycache"}, + network_disabled=True, + read_only=True, + remove=True, + ) + except ContainerError as error: + output = error.stderr or error.stdout or b"Python 3.14 syntax validation failed" + return output.decode(errors="replace") if isinstance(output, bytes) else output + finally: + client.close() + + return "" + + def get_python_path(dirpath: str) -> str: """Check for python version in the pyproject.toml file if present else return bench python path""" pyproject_path = os.path.join(dirpath, "pyproject.toml") diff --git a/agent/builder.py b/agent/builder.py index beb63728..df339e9f 100644 --- a/agent/builder.py +++ b/agent/builder.py @@ -22,6 +22,7 @@ import semantic_version as sv from agent.base import PUBLISH_INTERVAL_SECONDS, Base, Throttle +from agent.build_only import require_build_only_docker from agent.build_utils.validations import check_python_syntax, get_package_manager_files from agent.exceptions import AgentException, RegistryDownException from agent.job import Job, Step, job, step @@ -405,8 +406,12 @@ def check_version(actual: str, expected: str) -> bool: def _validate_repositories(self): invalid_releases = [] + bench_version = self.get_dependency_version("bench") + validation_image = f"frappe/bench:v{bench_version}" for app, pm in self.pmf.items(): - invalidation_reason = check_python_syntax(pm["repo_path"]) + invalidation_reason = check_python_syntax( + pm["repo_path"], python_validation_image=validation_image + ) if invalidation_reason: app_info = next((info for info in self.clone_instructions if info["app"] == app), None) @@ -650,6 +655,7 @@ def _build_and_push(self, context_tar_filepath: str): def _upload_built_assets(self): """Upload the assets this build made from the finished image, never from inside the build.""" + require_build_only_docker() uploads = [asset for asset in self.asset_store if asset.get("upload_url")] if not uploads: return @@ -684,6 +690,7 @@ def _upload_built_assets(self): def _build_image(self, context_tar_filepath: str): # Note: build command and environment are different from when # build runs on the press server. + require_build_only_docker() command = self._get_build_command() environment = self._get_build_environment() result = self._run( @@ -740,6 +747,7 @@ def _wait_for_registry_recovery(self): @step("Push Docker Image") def _push_docker_image(self): + require_build_only_docker() max_retries = 3 environment = os.environ.copy() client = docker.from_env(environment=environment, timeout=5 * 60) @@ -863,6 +871,7 @@ def _get_image_name(self) -> str: @job("Run Patch Build") def run_patch_build(self): try: + require_build_only_docker() self._start_base_container() self._pull_app_updates() self._commit_patch_image() @@ -875,6 +884,7 @@ def run_patch_build(self): @step("Start Base Container") def _start_base_container(self): """Docker login and pull base image""" + require_build_only_docker() self.execute( f"docker login " f"-u {self.registry['username']} " @@ -933,6 +943,7 @@ def _bench_build_app(self, app): def _docker_exec(self, command: str, publish: bool = True) -> str: """Execute a command inside the running container and return the output""" + require_build_only_docker() result = self.execute(f"docker exec {self.container_name} bash -c {shlex.quote(command)}") output = result.get("output", "") if isinstance(result, dict) else "" if publish: @@ -942,12 +953,14 @@ def _docker_exec(self, command: str, publish: bool = True) -> str: @step("Commit Image") def _commit_patch_image(self): + require_build_only_docker() self.execute( f"docker commit --change='CMD [\"supervisord\"]' {self.container_name} {self._get_image_name()}" ) @step("Push Docker Image") def _push_patch_image(self): + require_build_only_docker() environment = os.environ.copy() client = docker.from_env(environment=environment, timeout=5 * 60) auth_config = { diff --git a/agent/cli.py b/agent/cli.py index 4eee3d3d..4eaed0df 100644 --- a/agent/cli.py +++ b/agent/cli.py @@ -76,17 +76,45 @@ def ping_server(password: str): @click.option("--proxy-ip", required=False, type=str, default=None) @click.option("--sentry-dsn", required=False, type=str) @click.option("--press-url", required=False, type=str) -def config(name, user, workers, job_timeout, proxy_ip=None, sentry_dsn=None, press_url=None, db_port=3306): +@click.option("--web-port", required=False, type=int, default=25052) +@click.option("--redis-port", required=False, type=int, default=25025) +@click.option("--tls-cert-file", required=False, type=str) +@click.option("--tls-key-file", required=False, type=str) +@click.option("--build-only", is_flag=True, default=False) +@click.option("--docker-host", required=False, type=str) +@click.option("--docker-root", required=False, type=str) +@click.option("--web-bind-address", required=False, type=str) +def config( + name, + user, + workers, + job_timeout, + proxy_ip=None, + sentry_dsn=None, + press_url=None, + db_port=3306, + web_port=25052, + redis_port=25025, + tls_cert_file=None, + tls_key_file=None, + build_only=False, + docker_host=None, + docker_root=None, + web_bind_address=None, +): + if not (1 <= web_port <= 65535 and 1 <= redis_port <= 65535): + raise click.BadParameter("Web and Redis ports must be between 1 and 65535.") + config = { "benches_directory": f"/home/{user}/benches", "name": name, "tls_directory": f"/home/{user}/agent/tls", "nginx_directory": f"/home/{user}/agent/nginx", - "redis_port": 25025, + "redis_port": redis_port, "user": user, "workers": workers, "gunicorn_workers": 2, - "web_port": 25052, + "web_port": web_port, "press_url": "https://frappecloud.com", "db_port": db_port, "job_timeout": job_timeout, @@ -98,6 +126,29 @@ def config(name, user, workers, job_timeout, proxy_ip=None, sentry_dsn=None, pre if sentry_dsn: config["sentry_dsn"] = sentry_dsn + if build_only: + from agent.build_only import validate_build_only_bind_address, validate_docker_daemon + + if not tls_cert_file or not tls_key_file: + raise click.ClickException("Build-only mode requires an HTTPS certificate and key.") + valid, message = validate_docker_daemon(docker_host, docker_root) + if not valid: + raise click.ClickException(message) + try: + bind_address = validate_build_only_bind_address(web_bind_address) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + config.update( + { + "build_only": True, + "docker_host": docker_host, + "docker_root": docker_root, + "web_bind_address": bind_address, + "tls_cert_file": tls_cert_file, + "tls_key_file": tls_key_file, + } + ) + with open("config.json", "w") as f: json.dump(config, f, sort_keys=True, indent=4) @@ -119,6 +170,15 @@ def authentication(password): Server().setup_authentication(password) +@setup.command("authentication-stdin") +def authentication_stdin(): + """Set the Agent token from stdin to avoid exposing it in process arguments.""" + password = sys.stdin.readline().rstrip("\r\n") + if not password: + raise click.ClickException("Agent authentication token was empty.") + Server().setup_authentication(password) + + @setup.command() @click.option("--sentry-dsn", required=True) def sentry(sentry_dsn): diff --git a/agent/server.py b/agent/server.py index f931babc..74b7a231 100644 --- a/agent/server.py +++ b/agent/server.py @@ -1285,7 +1285,15 @@ def _generate_supervisor_config(self): "user": self.config["user"], "sentry_dsn": self.config.get("sentry_dsn"), "is_standalone": self.config.get("standalone", False), + "build_only": self.config.get("build_only", False), + "docker_host": self.config.get("docker_host"), + "docker_root": self.config.get("docker_root"), + "web_bind_address": self.config.get("web_bind_address", "127.0.0.1"), + "tls_cert_file": self.config.get("tls_cert_file"), + "tls_key_file": self.config.get("tls_key_file"), } + if data["build_only"]: + data["backup_workers"] = 0 if self.config.get("name").startswith("n") and not self.config.get("name").startswith("nat"): data["is_proxy_server"] = True diff --git a/agent/templates/agent/supervisor.conf.jinja2 b/agent/templates/agent/supervisor.conf.jinja2 index 4095f05c..39dce491 100644 --- a/agent/templates/agent/supervisor.conf.jinja2 +++ b/agent/templates/agent/supervisor.conf.jinja2 @@ -1,6 +1,11 @@ [program:web] +{% if build_only %} +command=bash -c "{{ directory }}/repo/wait-for-it.sh redis://127.0.0.1:{{ redis_port }} && {{ directory }}/env/bin/gunicorn --bind {{ web_bind_address }}:{{ web_port }} --certfile {{ tls_cert_file }} --keyfile {{ tls_key_file }} --workers {{ gunicorn_workers }} agent.web:application" +environment=PYTHONUNBUFFERED=1, PRESS_AGENT_BUILD_ONLY=1, DOCKER_HOST="{{ docker_host }}", BUILD_DOCKER_ROOT="{{ docker_root }}" +{% else %} command=bash -c "{{ directory }}/repo/wait-for-it.sh redis://127.0.0.1:{{ redis_port }} && {{ directory }}/env/bin/gunicorn --bind 127.0.0.1:{{ web_port }} --workers {{ gunicorn_workers }} agent.web:application" environment=PYTHONUNBUFFERED=1{% if sentry_dsn %}, SENTRY_DSN="{{ sentry_dsn }}"{% endif %} +{% endif %} autostart=true autorestart=true stdout_logfile={{ directory }}/logs/web.log @@ -19,7 +24,11 @@ directory={{ directory }} [program:worker] command=bash -c "{{ directory }}/repo/wait-for-it.sh redis://127.0.0.1:{{ redis_port }} && exec {{ directory }}/env/bin/rq worker {% if sentry_dsn %}--sentry-dsn '{{ sentry_dsn }}'{% endif %} --url redis://127.0.0.1:{{ redis_port }} high default low" +{% if build_only %} +environment=PYTHONUNBUFFERED=1, PRESS_AGENT_BUILD_ONLY=1, DOCKER_HOST="{{ docker_host }}", BUILD_DOCKER_ROOT="{{ docker_root }}" +{% else %} environment=PYTHONUNBUFFERED=1 +{% endif %} autostart=true autorestart=true stopwaitsecs=1500 @@ -31,6 +40,7 @@ stderr_logfile={{ directory }}/logs/worker.error.log user={{ user }} directory={{ directory }} +{% if not build_only %} {# Backups get their own worker so they can't head-of-line block the shared queues. #} {# Queue order matters: backup is drained first, the rest are picked up only when backup is idle. #} {# Known ceiling: a long `low` job here delays a newly-enqueued backup until it finishes — drop #} @@ -48,8 +58,9 @@ stdout_logfile={{ directory }}/logs/worker_backup.log stderr_logfile={{ directory }}/logs/worker_backup.error.log user={{ user }} directory={{ directory }} +{% endif %} -{% if is_proxy_server or is_standalone %} +{% if not build_only and (is_proxy_server or is_standalone) %} [program:nginx_reload_manager] command=bash -c "{{ directory }}/repo/wait-for-it.sh redis://127.0.0.1:{{ redis_port }} && exec {{directory}}/env/bin/python {{ directory }}/repo/agent/nginx_reload_manager.py" environment=PYTHONUNBUFFERED=1 @@ -64,9 +75,13 @@ directory={{ directory }} [group:agent] +{% if build_only %} +{% set programs = "web, redis, worker" %} +{% else %} {% set programs = "web, redis, worker, worker_backup" %} +{% endif %} -{% if is_proxy_server or is_standalone %} +{% if not build_only and (is_proxy_server or is_standalone) %} {% set programs = programs + ", nginx_reload_manager" %} {% endif %} diff --git a/agent/tests/test_build_only.py b/agent/tests/test_build_only.py new file mode 100644 index 00000000..3f55cdf5 --- /dev/null +++ b/agent/tests/test_build_only.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import stat +import unittest +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from agent.build_only import ( + is_build_only_request, + require_build_only_docker, + validate_build_only_bind_address, + validate_docker_daemon, + validate_docker_target, +) + + +class TestBuildOnlyAccess(unittest.TestCase): + def test_build_only_bind_requires_specific_tailnet_address(self): + for address in (None, "0.0.0.0", "::", "127.0.0.1", "192.0.2.1", "100.128.0.1"): + with self.subTest(address=address), self.assertRaises(ValueError): + validate_build_only_bind_address(address) + self.assertEqual(validate_build_only_bind_address("100.106.180.55"), "100.106.180.55") + + def test_only_read_and_build_routes_are_allowed(self): + self.assertTrue(is_build_only_request("ping", "GET")) + self.assertTrue(is_build_only_request("get_version", "GET")) + self.assertTrue(is_build_only_request("build_image", "POST")) + self.assertTrue(is_build_only_request("jobs", "GET")) + self.assertTrue(is_build_only_request("cancel_job", "POST")) + self.assertFalse(is_build_only_request("restart_nginx", "POST")) + self.assertFalse(is_build_only_request("get_server", "GET")) + self.assertFalse(is_build_only_request("get_metrics", "GET")) + self.assertFalse(is_build_only_request("build_image", "GET")) + + def test_missing_or_non_unix_endpoint_fails_closed(self): + for docker_host in (None, "", "tcp://127.0.0.1:2375", "unix:///var/run/docker.sock"): + with self.subTest(docker_host=docker_host): + valid, _ = validate_docker_target(docker_host, "/srv/build/docker") + self.assertFalse(valid) + + def test_dedicated_socket_and_root_are_accepted(self): + dedicated = "/run/albasar-build/docker.sock" + root = "/srv/albasar-build/mnt/docker" + stat_fn = Mock(return_value=SimpleNamespace(st_mode=stat.S_IFSOCK, st_dev=1, st_ino=10)) + exists_fn = Mock(return_value=False) + realpath_fn = lambda path: path + + valid, actual_root = validate_docker_target( + f"unix://{dedicated}", + root, + stat_fn=stat_fn, + exists_fn=exists_fn, + realpath_fn=realpath_fn, + ) + + self.assertTrue(valid) + self.assertEqual(actual_root, root) + + def test_socket_aliasing_main_docker_fails_closed(self): + dedicated = "/run/albasar-build/docker.sock" + stat_fn = Mock(return_value=SimpleNamespace(st_mode=stat.S_IFSOCK, st_dev=1, st_ino=10)) + exists_fn = Mock(return_value=True) + realpath_fn = lambda path: path + + valid, _ = validate_docker_target( + f"unix://{dedicated}", + "/srv/albasar-build/mnt/docker", + stat_fn=stat_fn, + exists_fn=exists_fn, + realpath_fn=realpath_fn, + ) + + self.assertFalse(valid) + + def test_symlink_socket_fails_closed(self): + valid, _ = validate_docker_target( + "unix:///run/albasar-build/docker.sock", + "/srv/albasar-build/mnt/docker", + stat_fn=Mock(return_value=SimpleNamespace(st_mode=stat.S_IFSOCK, st_dev=1, st_ino=10)), + exists_fn=Mock(return_value=False), + realpath_fn=lambda path: "/var/run/docker.sock" if path.endswith("docker.sock") else path, + ) + self.assertFalse(valid) + + def test_daemon_api_is_bound_to_the_exact_configured_socket(self): + dedicated = "unix:///run/albasar-build/docker.sock" + factory = Mock(return_value=SimpleNamespace(info=Mock(return_value={"DockerRootDir": "/srv/build/docker"}), close=Mock())) + stat_fn = Mock(return_value=SimpleNamespace(st_mode=stat.S_IFSOCK, st_dev=1, st_ino=10)) + + valid, root = validate_docker_daemon( + dedicated, + "/srv/build/docker", + client_factory=factory, + stat_fn=stat_fn, + exists_fn=Mock(return_value=False), + realpath_fn=lambda path: path, + ) + + self.assertTrue(valid) + self.assertEqual(root, "/srv/build/docker") + factory.assert_called_once_with(base_url=dedicated, timeout=5) + + def test_failed_daemon_never_retries_with_the_default_socket(self): + factory = Mock(side_effect=OSError("socket unavailable")) + stat_fn = Mock(return_value=SimpleNamespace(st_mode=stat.S_IFSOCK, st_dev=1, st_ino=10)) + exists_fn = Mock(return_value=False) + + valid, _ = validate_docker_daemon( + "unix:///run/albasar-build/docker.sock", + "/srv/build/docker", + client_factory=factory, + stat_fn=stat_fn, + exists_fn=Mock(return_value=False), + realpath_fn=lambda path: path, + ) + + self.assertFalse(valid) + factory.assert_called_once_with( + base_url="unix:///run/albasar-build/docker.sock", timeout=5 + ) + + @patch.dict( + "os.environ", + { + "PRESS_AGENT_BUILD_ONLY": "1", + "DOCKER_HOST": "unix:///run/albasar-build/docker.sock", + "BUILD_DOCKER_ROOT": "/srv/albasar-build/mnt/docker", + }, + ) + @patch("agent.build_only.validate_docker_daemon", return_value=(False, "daemon unavailable")) + def test_build_worker_fails_closed_when_its_socket_is_unavailable(self, validate_daemon): + with self.assertRaisesRegex(RuntimeError, "daemon unavailable"): + require_build_only_docker() + validate_daemon.assert_called_once_with( + "unix:///run/albasar-build/docker.sock", "/srv/albasar-build/mnt/docker" + ) + + @patch.dict( + "os.environ", + { + "PRESS_AGENT_BUILD_ONLY": "1", + "DOCKER_HOST": "unix:///run/albasar-build/docker.sock", + "BUILD_DOCKER_ROOT": "/srv/albasar-build/mnt/docker", + "DOCKER_CONTEXT": "default", + }, + ) + @patch("agent.build_only.validate_docker_daemon") + def test_build_worker_rejects_docker_context_override(self, validate_daemon): + with self.assertRaisesRegex(RuntimeError, "context or TLS overrides"): + require_build_only_docker() + validate_daemon.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/agent/tests/test_validations.py b/agent/tests/test_validations.py index 3673be25..32f65be1 100644 --- a/agent/tests/test_validations.py +++ b/agent/tests/test_validations.py @@ -5,7 +5,7 @@ import sys import tempfile import unittest -from unittest.mock import patch +from unittest.mock import MagicMock, patch from agent.build_utils.validations import check_python_syntax @@ -48,3 +48,37 @@ def test_syntax_error_is_reported(self): self.write("broken.py", "def broken(:\n") self.assertIn("broken.py", check_python_syntax(self.app)) + + @patch.dict(os.environ, {"PRESS_AGENT_BUILD_ONLY": "1"}) + @patch("agent.build_only.require_build_only_docker") + @patch("docker.from_env") + @patch("agent.build_utils.validations.subprocess.run", side_effect=FileNotFoundError) + def test_missing_python314_uses_only_isolated_build_docker( + self, _run, from_env, require_build_only_docker + ): + self.write("hooks.py", "app_name = 'builder'\n") + self.write("pyproject.toml", '[project]\nrequires-python = ">=3.14"\n') + client = MagicMock() + from_env.return_value = client + + result = check_python_syntax(self.app, "frappe/bench:v5.31.0") + + self.assertEqual(result, "") + require_build_only_docker.assert_called_once_with() + from_env.assert_called_once_with(timeout=5 * 60) + client.containers.run.assert_called_once() + args, kwargs = client.containers.run.call_args + self.assertEqual(args[0], "frappe/bench:v5.31.0") + self.assertEqual(args[1][:3], ["python3.14", "-m", "compileall"]) + self.assertTrue(kwargs["network_disabled"]) + self.assertTrue(kwargs["read_only"]) + self.assertEqual(kwargs["volumes"][os.path.abspath(self.app)]["mode"], "ro") + client.close.assert_called_once_with() + + @patch.dict(os.environ, {}, clear=True) + @patch("agent.build_utils.validations.subprocess.run", side_effect=FileNotFoundError) + def test_missing_python314_fails_closed_outside_build_only(self, _run): + self.write("hooks.py", "app_name = 'builder'\n") + + with self.assertRaises(FileNotFoundError): + check_python_syntax(self.app, "frappe/bench:v5.31.0") diff --git a/agent/web.py b/agent/web.py index ddd239f7..2a3d1e52 100644 --- a/agent/web.py +++ b/agent/web.py @@ -2,6 +2,7 @@ import json import logging +import os import sys import traceback from base64 import b64decode @@ -9,6 +10,7 @@ from typing import TYPE_CHECKING from flask import Flask, Response, jsonify, request +from werkzeug.middleware.dispatcher import DispatcherMiddleware from passlib.hash import pbkdf2_sha256 as pbkdf2 from playhouse.shortcuts import model_to_dict from redis.exceptions import ConnectionError as RedisConnectionError @@ -18,6 +20,7 @@ from agent.backup_log import InvalidRange, parse_range from agent.base import AgentException +from agent.build_only import is_build_only_request, require_build_only_docker from agent.builder import ImageBuilder, PatchImageBuilder from agent.database import JSONEncoderForSQLQueryResult from agent.database_physical_backup import DatabasePhysicalBackup @@ -54,6 +57,7 @@ class ExecuteReturn(TypedDict): application = Flask(__name__) +BUILD_ONLY_MODE = os.environ.get("PRESS_AGENT_BUILD_ONLY") == "1" SENSITIVE_CONFIG_KEYS = { "access_token", @@ -100,26 +104,36 @@ def wrapper(*args, **kwargs): @application.before_request def validate_access_token(): - exempt_endpoints = ["get_metrics"] - if request.endpoint in exempt_endpoints: - return None + if BUILD_ONLY_MODE: + if not is_build_only_request(request.endpoint, request.method): + return jsonify({"message": "Endpoint unavailable on build-only agent"}), 404 + authenticated = request.endpoint == "get_metrics" and not BUILD_ONLY_MODE try: - if application.debug: - return None - method, access_token = request.headers["Authorization"].split(" ") - stored_hash = Server().config["access_token"] - if method.lower() == "bearer" and pbkdf2.verify(access_token, stored_hash): - return None - access_token = b64decode(access_token).decode().split(":")[1] - if method.lower() == "basic" and pbkdf2.verify(access_token, stored_hash): - return None + if application.debug and not BUILD_ONLY_MODE: + authenticated = True + elif not authenticated: + method, access_token = request.headers["Authorization"].split(" ") + stored_hash = Server().config["access_token"] + if method.lower() == "basic": + access_token = b64decode(access_token).decode().split(":")[1] + authenticated = method.lower() in {"bearer", "basic"} and pbkdf2.verify( + access_token, stored_hash + ) except Exception: - pass - - response = jsonify({"message": "Unauthenticated"}) - response.headers.set("WWW-Authenticate", "Basic") - return response, 401 + authenticated = False + + if not authenticated: + response = jsonify({"message": "Unauthenticated"}) + response.headers.set("WWW-Authenticate", "Basic") + return response, 401 + + if BUILD_ONLY_MODE: + try: + require_build_only_docker() + except RuntimeError: + return jsonify({"message": "Configured build daemon is unavailable or mismatched"}), 503 + return None @application.route("/authentication", methods=["POST"]) @@ -2015,3 +2029,12 @@ def backup_db(): offsite = data.get("offsite") job = SnapshotRecovery().backup_db(site, database_ip, database_name, mariadb_root_password, offsite) return {"job": job} + + +if BUILD_ONLY_MODE: + # The normal Press Agent is mounted behind NGINX at /agent/. Build-only hosts + # expose that prefix directly over their dedicated TLS listener instead. + application.wsgi_app = DispatcherMiddleware( + lambda environ, start_response: Response("Not Found", status=404)(environ, start_response), + {"/agent": application.wsgi_app}, + )