Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions agent/build_only.py
Original file line number Diff line number Diff line change
@@ -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)
66 changes: 59 additions & 7 deletions agent/build_utils/validations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 ""

Expand All @@ -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")
Expand Down
15 changes: 14 additions & 1 deletion agent/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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']} "
Expand Down Expand Up @@ -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:
Expand All @@ -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 = {
Expand Down
66 changes: 63 additions & 3 deletions agent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)

Expand All @@ -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):
Expand Down
Loading