diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 508df084..135c8b55 100755 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -80,8 +80,9 @@ ENV SYSTEM_SERVICES=/services ENV SYSTEM_SERVICES_SCRIPTS=${SYSTEM_SERVICES}/scripts ENV SYSTEM_SERVICES_CONFIG=${SYSTEM_SERVICES}/config ENV SYSTEM_NGINX_CONFIG=${SYSTEM_SERVICES_CONFIG}/nginx -ENV SYSTEM_NGINX_CONFIG_FILE=${SYSTEM_NGINX_CONFIG}/nginx.conf +ENV SYSTEM_NGINX_CONFIG_TEMPLATE=${SYSTEM_NGINX_CONFIG}/netalertx.conf.template ENV SYSTEM_SERVICES_ACTIVE_CONFIG=/tmp/nginx/active-config +ENV SYSTEM_SERVICES_ACTIVE_CONFIG_FILE=${SYSTEM_SERVICES_ACTIVE_CONFIG}/nginx.conf ENV SYSTEM_SERVICES_PHP_FOLDER=${SYSTEM_SERVICES_CONFIG}/php ENV SYSTEM_SERVICES_PHP_FPM_D=${SYSTEM_SERVICES_PHP_FOLDER}/php-fpm.d ENV SYSTEM_SERVICES_CROND=${SYSTEM_SERVICES_CONFIG}/crond @@ -138,6 +139,9 @@ RUN install -d -o ${NETALERTX_USER} -g ${NETALERTX_GROUP} -m 700 ${READ_WRITE_FO sh -c "find ${NETALERTX_APP} -type f \( -name '*.sh' -o -name 'speedtest-cli' \) \ -exec chmod 750 {} \;" +# Copy version information into the image +COPY --chown=${NETALERTX_USER}:${NETALERTX_GROUP} .VERSION ${NETALERTX_APP}/.VERSION + # Copy the virtualenv from the builder stage COPY --from=builder --chown=20212:20212 ${VIRTUAL_ENV} ${VIRTUAL_ENV} diff --git a/.devcontainer/resources/devcontainer-overlay/services/config/nginx/netalertx.conf.template b/.devcontainer/resources/devcontainer-overlay/services/config/nginx/netalertx.conf.template deleted file mode 100755 index 6db121cb..00000000 --- a/.devcontainer/resources/devcontainer-overlay/services/config/nginx/netalertx.conf.template +++ /dev/null @@ -1,118 +0,0 @@ -# DO NOT MODIFY THIS FILE DIRECTLY. IT IS AUTO-GENERATED BY .devcontainer/scripts/generate-configs.sh -# Generated from: install/production-filesystem/services/config/nginx/netalertx.conf.template - -# Set number of worker processes automatically based on number of CPU cores. -worker_processes auto; - -# Enables the use of JIT for regular expressions to speed-up their processing. -pcre_jit on; - -# Configures default error logger. -error_log /tmp/log/nginx-error.log warn; - -pid /tmp/run/nginx.pid; - -events { - # The maximum number of simultaneous connections that can be opened by - # a worker process. - worker_connections 1024; -} - -http { - # Mapping of temp paths for various nginx modules. - client_body_temp_path /tmp/nginx/client_body; - proxy_temp_path /tmp/nginx/proxy; - fastcgi_temp_path /tmp/nginx/fastcgi; - uwsgi_temp_path /tmp/nginx/uwsgi; - scgi_temp_path /tmp/nginx/scgi; - - # Includes mapping of file name extensions to MIME types of responses - # and defines the default type. - include /services/config/nginx/mime.types; - default_type application/octet-stream; - - # Name servers used to resolve names of upstream servers into addresses. - # It's also needed when using tcpsocket and udpsocket in Lua modules. - #resolver 1.1.1.1 1.0.0.1 [2606:4700:4700::1111] [2606:4700:4700::1001]; - - # Don't tell nginx version to the clients. Default is 'on'. - server_tokens off; - - # Specifies the maximum accepted body size of a client request, as - # indicated by the request header Content-Length. If the stated content - # length is greater than this size, then the client receives the HTTP - # error code 413. Set to 0 to disable. Default is '1m'. - client_max_body_size 1m; - - # Sendfile copies data between one FD and other from within the kernel, - # which is more efficient than read() + write(). Default is off. - sendfile on; - - # Causes nginx to attempt to send its HTTP response head in one packet, - # instead of using partial frames. Default is 'off'. - tcp_nopush on; - - - # Enables the specified protocols. Default is TLSv1 TLSv1.1 TLSv1.2. - # TIP: If you're not obligated to support ancient clients, remove TLSv1.1. - ssl_protocols TLSv1.2 TLSv1.3; - - # Path of the file with Diffie-Hellman parameters for EDH ciphers. - # TIP: Generate with: `openssl dhparam -out /etc/ssl/nginx/dh2048.pem 2048` - #ssl_dhparam /etc/ssl/nginx/dh2048.pem; - - # Specifies that our cipher suits should be preferred over client ciphers. - # Default is 'off'. - ssl_prefer_server_ciphers on; - - # Enables a shared SSL cache with size that can hold around 8000 sessions. - # Default is 'none'. - ssl_session_cache shared:SSL:2m; - - # Specifies a time during which a client may reuse the session parameters. - # Default is '5m'. - ssl_session_timeout 1h; - - # Disable TLS session tickets (they are insecure). Default is 'on'. - ssl_session_tickets off; - - - # Enable gzipping of responses. - gzip on; - - # Set the Vary HTTP header as defined in the RFC 2616. Default is 'off'. - gzip_vary on; - - - # Specifies the main log format. - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - # Sets the path, format, and configuration for a buffered log write. - access_log /tmp/log/nginx-access.log main; - - - # Virtual host config - server { - listen 0.0.0.0:20211 default_server; - large_client_header_buffers 4 16k; - root /app/front; - index index.php; - add_header X-Forwarded-Prefix "/app" always; - - location ~* \.php$ { - # Set Cache-Control header to prevent caching on the first load - add_header Cache-Control "no-store"; - fastcgi_pass unix:/tmp/run/php.sock; - include /services/config/nginx/fastcgi_params; - fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; - fastcgi_param SCRIPT_NAME $fastcgi_script_name; - - fastcgi_param PHP_VALUE "xdebug.remote_enable=1"; - fastcgi_connect_timeout 75; - fastcgi_send_timeout 600; - fastcgi_read_timeout 600; - } - } -} diff --git a/.devcontainer/scripts/generate-configs.sh b/.devcontainer/scripts/generate-configs.sh index ee8e249c..c4a8dcc4 100755 --- a/.devcontainer/scripts/generate-configs.sh +++ b/.devcontainer/scripts/generate-configs.sh @@ -30,33 +30,4 @@ cat "${DEVCONTAINER_DIR}/resources/devcontainer-Dockerfile" >> "$OUT_FILE" echo "Generated $OUT_FILE using root dir $ROOT_DIR" >&2 -# Generate devcontainer nginx config from production template -echo "Generating devcontainer nginx config" -NGINX_TEMPLATE="${ROOT_DIR}/install/production-filesystem/services/config/nginx/netalertx.conf.template" -NGINX_OUT="${DEVCONTAINER_DIR}/resources/devcontainer-overlay/services/config/nginx/netalertx.conf.template" - -# Create output directory if it doesn't exist -mkdir -p "$(dirname "$NGINX_OUT")" - -# Start with header comment -cat > "$NGINX_OUT" << 'EOF' -# DO NOT MODIFY THIS FILE DIRECTLY. IT IS AUTO-GENERATED BY .devcontainer/scripts/generate-configs.sh -# Generated from: install/production-filesystem/services/config/nginx/netalertx.conf.template - -EOF - -# Process the template: replace listen directive and inject Xdebug params -sed 's/${LISTEN_ADDR}:${PORT}/0.0.0.0:20211/g' "$NGINX_TEMPLATE" | \ -awk ' -/fastcgi_param SCRIPT_NAME \$fastcgi_script_name;/ { - print $0 - print "" - print " fastcgi_param PHP_VALUE \"xdebug.remote_enable=1\";" - next -} -{ print } -' >> "$NGINX_OUT" - -echo "Generated $NGINX_OUT from $NGINX_TEMPLATE" >&2 - echo "Done." \ No newline at end of file diff --git a/.devcontainer/scripts/setup.sh b/.devcontainer/scripts/setup.sh index 5bcf5ef8..a4190606 100755 --- a/.devcontainer/scripts/setup.sh +++ b/.devcontainer/scripts/setup.sh @@ -50,9 +50,6 @@ sudo chmod 777 /tmp/log /tmp/api /tmp/run /tmp/nginx -sudo rm -rf "${SYSTEM_NGINX_CONFIG}/conf.active" -sudo ln -s "${SYSTEM_SERVICES_ACTIVE_CONFIG}" "${SYSTEM_NGINX_CONFIG}/conf.active" - sudo rm -rf /entrypoint.d sudo ln -s "${SOURCE_DIR}/install/production-filesystem/entrypoint.d" /entrypoint.d @@ -67,6 +64,7 @@ for dir in \ "${SYSTEM_SERVICES_RUN_LOG}" \ "${SYSTEM_SERVICES_ACTIVE_CONFIG}" \ "${NETALERTX_PLUGINS_LOG}" \ + "${SYSTEM_SERVICES_RUN_TMP}" \ "/tmp/nginx/client_body" \ "/tmp/nginx/proxy" \ "/tmp/nginx/fastcgi" \ @@ -75,9 +73,6 @@ for dir in \ sudo install -d -m 777 "${dir}" done -# Create nginx temp subdirs with permissions -sudo mkdir -p "${SYSTEM_SERVICES_RUN_TMP}/client_body" "${SYSTEM_SERVICES_RUN_TMP}/proxy" "${SYSTEM_SERVICES_RUN_TMP}/fastcgi" "${SYSTEM_SERVICES_RUN_TMP}/uwsgi" "${SYSTEM_SERVICES_RUN_TMP}/scgi" -sudo chmod -R 777 "${SYSTEM_SERVICES_RUN_TMP}" for var in "${LOG_FILES[@]}"; do path=${!var} diff --git a/Dockerfile b/Dockerfile index 3a368164..42263d05 100755 --- a/Dockerfile +++ b/Dockerfile @@ -77,8 +77,9 @@ ENV SYSTEM_SERVICES=/services ENV SYSTEM_SERVICES_SCRIPTS=${SYSTEM_SERVICES}/scripts ENV SYSTEM_SERVICES_CONFIG=${SYSTEM_SERVICES}/config ENV SYSTEM_NGINX_CONFIG=${SYSTEM_SERVICES_CONFIG}/nginx -ENV SYSTEM_NGINX_CONFIG_FILE=${SYSTEM_NGINX_CONFIG}/nginx.conf +ENV SYSTEM_NGINX_CONFIG_TEMPLATE=${SYSTEM_NGINX_CONFIG}/netalertx.conf.template ENV SYSTEM_SERVICES_ACTIVE_CONFIG=/tmp/nginx/active-config +ENV SYSTEM_SERVICES_ACTIVE_CONFIG_FILE=${SYSTEM_SERVICES_ACTIVE_CONFIG}/nginx.conf ENV SYSTEM_SERVICES_PHP_FOLDER=${SYSTEM_SERVICES_CONFIG}/php ENV SYSTEM_SERVICES_PHP_FPM_D=${SYSTEM_SERVICES_PHP_FOLDER}/php-fpm.d ENV SYSTEM_SERVICES_CROND=${SYSTEM_SERVICES_CONFIG}/crond diff --git a/install/production-filesystem/build/init-php-fpm.sh b/install/production-filesystem/build/init-php-fpm.sh index 99e94156..7952a8fd 100755 --- a/install/production-filesystem/build/init-php-fpm.sh +++ b/install/production-filesystem/build/init-php-fpm.sh @@ -1,7 +1,4 @@ #!/bin/bash echo "Initializing php-fpm..." # Set up PHP-FPM directories and socket configuration -install -d -o netalertx -g netalertx /services/config/run - - echo "php-fpm initialized." diff --git a/install/production-filesystem/services/config/nginx/conf.active b/install/production-filesystem/services/config/nginx/conf.active deleted file mode 120000 index 70d9b1c6..00000000 --- a/install/production-filesystem/services/config/nginx/conf.active +++ /dev/null @@ -1 +0,0 @@ -/tmp/nginx/active-config \ No newline at end of file diff --git a/install/production-filesystem/services/start-nginx.sh b/install/production-filesystem/services/start-nginx.sh index 87c92290..cc57863d 100755 --- a/install/production-filesystem/services/start-nginx.sh +++ b/install/production-filesystem/services/start-nginx.sh @@ -5,8 +5,6 @@ set -euo pipefail LOG_DIR=${NETALERTX_LOG} RUN_DIR=${SYSTEM_SERVICES_RUN} TMP_DIR=/tmp/nginx -SYSTEM_NGINX_CONFIG_TEMPLATE="/services/config/nginx/netalertx.conf.template" -SYSTEM_NGINX_CONFIG_FILE="/services/config/nginx/conf.active/netalertx.conf" # Create directories if they don't exist mkdir -p "${LOG_DIR}" "${RUN_DIR}" "${TMP_DIR}" @@ -33,9 +31,9 @@ done TEMP_CONFIG_FILE=$(mktemp "${TMP_DIR}/netalertx.conf.XXXXXX") if envsubst '${LISTEN_ADDR} ${PORT}' < "${SYSTEM_NGINX_CONFIG_TEMPLATE}" > "${TEMP_CONFIG_FILE}" 2>/dev/null; then - mv "${TEMP_CONFIG_FILE}" "${SYSTEM_NGINX_CONFIG_FILE}" + mv "${TEMP_CONFIG_FILE}" "${SYSTEM_SERVICES_ACTIVE_CONFIG_FILE}" else - echo "Note: Unable to write to ${SYSTEM_NGINX_CONFIG_FILE}. Using default configuration." + echo "Note: Unable to write to ${SYSTEM_SERVICES_ACTIVE_CONFIG_FILE}. Using default configuration." rm -f "${TEMP_CONFIG_FILE}" fi @@ -49,10 +47,10 @@ chmod -R 777 "/tmp/nginx" 2>/dev/null || true # Execute nginx with overrides # echo the full nginx command then run it -echo "Starting /usr/sbin/nginx -p \"${RUN_DIR}/\" -c \"${SYSTEM_NGINX_CONFIG_FILE}\" -g \"error_log /dev/stderr; error_log ${NETALERTX_LOG}/nginx-error.log; daemon off;\" &" +echo "Starting /usr/sbin/nginx -p \"${RUN_DIR}/\" -c \"${SYSTEM_SERVICES_ACTIVE_CONFIG_FILE}\" -g \"error_log /dev/stderr; error_log ${NETALERTX_LOG}/nginx-error.log; daemon off;\" &" /usr/sbin/nginx \ -p "${RUN_DIR}/" \ - -c "${SYSTEM_NGINX_CONFIG_FILE}" \ + -c "${SYSTEM_SERVICES_ACTIVE_CONFIG_FILE}" \ -g "error_log /dev/stderr; error_log ${NETALERTX_LOG}/nginx-error.log; daemon off;" & nginx_pid=$! diff --git a/test/docker_tests/test_docker_compose_scenarios.py b/test/docker_tests/test_docker_compose_scenarios.py index d3b222cf..8444a2f2 100644 --- a/test/docker_tests/test_docker_compose_scenarios.py +++ b/test/docker_tests/test_docker_compose_scenarios.py @@ -9,7 +9,13 @@ import copy import os import pathlib import re +import shutil +import socket import subprocess +import time +from collections.abc import Callable, Iterable + +from _pytest.outcomes import Skipped import pytest import yaml @@ -29,6 +35,55 @@ CONTAINER_PATHS = { TMPFS_ROOT = "/tmp:uid=20211,gid=20211,mode=1700,rw,noexec,nosuid,nodev,async,noatime,nodiratime" +DEFAULT_HTTP_PORT = int(os.environ.get("NETALERTX_DEFAULT_HTTP_PORT", "20211")) +COMPOSE_PORT_WAIT_TIMEOUT = int(os.environ.get("NETALERTX_COMPOSE_PORT_WAIT_TIMEOUT", "180")) +COMPOSE_SETTLE_WAIT_SECONDS = int(os.environ.get("NETALERTX_COMPOSE_SETTLE_WAIT", "15")) +PREFERRED_CUSTOM_PORTS = (22111, 22112) +HOST_ADDR_ENV = os.environ.get("NETALERTX_HOST_ADDRS", "") + + +def _discover_host_addresses() -> tuple[str, ...]: + """Return candidate loopback addresses for reaching host-mode containers.""" + + candidates: list[str] = ["127.0.0.1"] + + if HOST_ADDR_ENV: + env_addrs = [addr.strip() for addr in HOST_ADDR_ENV.split(",") if addr.strip()] + candidates.extend(env_addrs) + + ip_cmd = shutil.which("ip") + if ip_cmd: + try: + route_proc = subprocess.run( + [ip_cmd, "-4", "route", "show", "default"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + route_proc = None + if route_proc and route_proc.returncode == 0 and route_proc.stdout: + match = re.search(r"default\s+via\s+(?P\S+)", route_proc.stdout) + if match: + gateway = match.group("gateway") + candidates.append(gateway) + + # Deduplicate while preserving order + seen: set[str] = set() + deduped: list[str] = [] + for addr in candidates: + if addr not in seen: + deduped.append(addr) + seen.add(addr) + + return tuple(deduped) + + +HOST_ADDRESS_CANDIDATES = _discover_host_addresses() +LAST_PORT_SUCCESSES: dict[int, str] = {} + pytestmark = [pytest.mark.docker, pytest.mark.compose] IMAGE = os.environ.get("NETALERTX_TEST_IMAGE", "netalertx-test") @@ -151,12 +206,142 @@ def _extract_conflict_container_name(output: str) -> str | None: return None +def _port_is_free(port: int) -> bool: + """Return True if a TCP port is available on localhost.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(("127.0.0.1", port)) + except OSError: + return False + return True + + +def _wait_for_ports(ports: Iterable[int], timeout: int = COMPOSE_PORT_WAIT_TIMEOUT) -> None: + """Block until every port in the iterable accepts TCP connections or timeout expires.""" + + remaining = set(ports) + deadline = time.time() + timeout + last_errors: dict[int, dict[str, BaseException]] = {port: {} for port in remaining} + + while remaining and time.time() < deadline: + ready: list[int] = [] + for port in list(remaining): + for addr in HOST_ADDRESS_CANDIDATES: + try: + with socket.create_connection((addr, port), timeout=2): + ready.append(port) + LAST_PORT_SUCCESSES[port] = addr + break + except OSError as exc: + last_errors.setdefault(port, {})[addr] = exc + else: + continue + for port in ready: + remaining.discard(port) + if remaining: + time.sleep(1) + + if remaining: + details: list[str] = [] + for port in sorted(remaining): + addr_errors = last_errors.get(port, {}) + if addr_errors: + error_summary = ", ".join(f"{addr}: {err}" for addr, err in addr_errors.items()) + else: + error_summary = "no connection attempts recorded" + details.append(f"{port} -> {error_summary}") + raise TimeoutError( + "Ports did not become ready before timeout: " + "; ".join(details) + ) + + +def _select_custom_ports() -> tuple[int, int]: + """Choose a pair of non-default ports, preferring the standard high test pair when free.""" + preferred_http, preferred_graphql = PREFERRED_CUSTOM_PORTS + if _port_is_free(preferred_http) and _port_is_free(preferred_graphql): + return preferred_http, preferred_graphql + + # Fall back to scanning ephemeral range for the first free consecutive pair. + for port in range(30000, 60000, 2): + if _port_is_free(port) and _port_is_free(port + 1): + return port, port + 1 + + raise RuntimeError("Unable to locate two free high ports for compose testing") + + +def _make_port_check_hook(ports: tuple[int, ...]) -> Callable[[], None]: + """Return a callback that waits for the provided ports to accept TCP connections.""" + + def _hook() -> None: + for port in ports: + LAST_PORT_SUCCESSES.pop(port, None) + time.sleep(COMPOSE_SETTLE_WAIT_SECONDS) + _wait_for_ports(ports, timeout=COMPOSE_PORT_WAIT_TIMEOUT) + + return _hook + + +def _write_normal_startup_compose( + base_dir: pathlib.Path, + project_name: str, + env_overrides: dict[str, str] | None, +) -> pathlib.Path: + """Generate a compose file for the normal startup scenario with optional environment overrides.""" + + compose_config = copy.deepcopy(COMPOSE_CONFIGS["normal_startup"]) + service = compose_config["services"]["netalertx"] + + data_volume_name = f"{project_name}_data" + service["volumes"][0]["source"] = data_volume_name + + if env_overrides: + service_env = service.setdefault("environment", {}) + service_env.update(env_overrides) + + compose_config["volumes"] = {data_volume_name: {}} + + compose_file = base_dir / "docker-compose.yml" + with open(compose_file, "w") as f: + yaml.dump(compose_config, f) + + return compose_file + + +def _assert_ports_ready( + result: subprocess.CompletedProcess, + project_name: str, + ports: tuple[int, ...], +) -> str: + """Validate the post-up hook succeeded and return sanitized compose logs for further assertions.""" + + post_error = getattr(result, "post_up_error", None) + clean_output = ANSI_ESCAPE.sub("", result.output) + port_hosts = {port: LAST_PORT_SUCCESSES.get(port) for port in ports} + result.port_hosts = port_hosts # type: ignore[attr-defined] + + if post_error: + pytest.fail( + "Port readiness check failed for project" + f" {project_name} on ports {ports}: {post_error}\n" + f"Compose logs:\n{clean_output}" + ) + + port_summary = ", ".join( + f"{port}@{addr if addr else 'unresolved'}" for port, addr in port_hosts.items() + ) + print(f"[compose port hosts] {project_name}: {port_summary}") + + return clean_output + + def _run_docker_compose( compose_file: pathlib.Path, project_name: str, timeout: int = 5, env_vars: dict | None = None, detached: bool = False, + post_up: Callable[[], None] | None = None, ) -> subprocess.CompletedProcess: """Run docker compose up and capture output.""" cmd = [ @@ -219,10 +404,21 @@ def _run_docker_compose( continue return proc + post_up_exc: BaseException | None = None + skip_exc: Skipped | None = None + try: if detached: up_result = _run_with_conflict_retry(up_cmd, timeout) + if post_up: + try: + post_up() + except Skipped as exc: + skip_exc = exc + except BaseException as exc: # noqa: BLE001 - bubble the root cause through the result payload + post_up_exc = exc + logs_cmd = cmd + ["logs"] logs_result = subprocess.run( logs_cmd, @@ -255,6 +451,9 @@ def _run_docker_compose( # Combine stdout and stderr result.output = result.stdout + result.stderr + result.post_up_error = post_up_exc # type: ignore[attr-defined] + if skip_exc is not None: + raise skip_exc # Surface command context and IO for any caller to aid debugging print("\n[compose command]", " ".join(up_cmd)) @@ -339,43 +538,34 @@ def test_normal_startup_no_warnings_compose(tmp_path: pathlib.Path) -> None: """ base_dir = tmp_path / "normal_startup" base_dir.mkdir() + default_http_port = DEFAULT_HTTP_PORT + default_ports = (default_http_port,) + if not _port_is_free(default_http_port): + pytest.skip( + "Default NetAlertX ports are already bound on this host; " + "skipping compose normal-startup validation." + ) - project_name = "netalertx-normal" + default_dir = base_dir / "default" + default_dir.mkdir() + default_project = "netalertx-normal-default" - # Create compose file mirroring production docker-compose.yml - compose_config = copy.deepcopy(COMPOSE_CONFIGS["normal_startup"]) - service = compose_config["services"]["netalertx"] + default_compose_file = _write_normal_startup_compose(default_dir, default_project, None) + default_result = _run_docker_compose( + default_compose_file, + default_project, + timeout=60, + detached=True, + post_up=_make_port_check_hook(default_ports), + ) + default_output = _assert_ports_ready(default_result, default_project, default_ports) - data_volume_name = f"{project_name}_data" - - service["volumes"][0]["source"] = data_volume_name - - service.setdefault("environment", {}) - service["environment"].update({ - "PORT": "22111", - "GRAPHQL_PORT": "22112", - }) - - compose_config["volumes"] = { - data_volume_name: {}, - } - - compose_file = base_dir / "docker-compose.yml" - with open(compose_file, 'w') as f: - yaml.dump(compose_config, f) - - # Run docker compose - result = _run_docker_compose(compose_file, project_name, detached=True) - - clean_output = ANSI_ESCAPE.sub("", result.output) - - # Check that startup completed without critical issues and mounts table shows success - assert "Startup pre-checks" in clean_output - assert "❌" not in clean_output + assert "Startup pre-checks" in default_output + assert "❌" not in default_output data_line = "" data_parts: list[str] = [] - for line in clean_output.splitlines(): + for line in default_output.splitlines(): if CONTAINER_PATHS['data'] not in line or '|' not in line: continue parts = [segment.strip() for segment in line.split('|')] @@ -387,15 +577,46 @@ def test_normal_startup_no_warnings_compose(tmp_path: pathlib.Path) -> None: break assert data_line, "Expected /data row in mounts table" + assert data_parts[1] == CONTAINER_PATHS['data'], f"Unexpected path column in /data row: {data_parts}" + assert data_parts[2] == "✅" and data_parts[3] == "✅", ( + f"Unexpected mount row values for /data: {data_parts[2:4]}" + ) - parts = data_parts - assert parts[1] == CONTAINER_PATHS['data'], f"Unexpected path column in /data row: {parts}" - assert parts[2] == "✅" and parts[3] == "✅", f"Unexpected mount row values for /data: {parts[2:4]}" + assert "Write permission denied" not in default_output + assert "CRITICAL" not in default_output + assert "⚠️" not in default_output - # Ensure no critical errors or permission problems surfaced - assert "Write permission denied" not in clean_output - assert "CRITICAL" not in clean_output - assert "⚠️" not in clean_output + custom_http, custom_graphql = _select_custom_ports() + assert custom_http != default_http_port + custom_ports = (custom_http,) + + custom_dir = base_dir / "custom" + custom_dir.mkdir() + custom_project = "netalertx-normal-custom" + + custom_compose_file = _write_normal_startup_compose( + custom_dir, + custom_project, + { + "PORT": str(custom_http), + "GRAPHQL_PORT": str(custom_graphql), + }, + ) + + custom_result = _run_docker_compose( + custom_compose_file, + custom_project, + timeout=60, + detached=True, + post_up=_make_port_check_hook(custom_ports), + ) + custom_output = _assert_ports_ready(custom_result, custom_project, custom_ports) + + assert "Startup pre-checks" in custom_output + assert "❌" not in custom_output + assert "Write permission denied" not in custom_output + assert "CRITICAL" not in custom_output + assert "⚠️" not in custom_output def test_ram_disk_mount_analysis_compose(tmp_path: pathlib.Path) -> None: