diff --git a/.env.example b/.env.example index dc423ab2..9e0ce114 100644 --- a/.env.example +++ b/.env.example @@ -49,19 +49,21 @@ MINIO_HOST_BIND=127.0.0.1 # MINIO_API_HOST_PORT=9000 # MINIO_CONSOLE_HOST_PORT=9001 -# API (optional defaults; API_SHARED_SECRET required to accept ingest calls) -WEBHOOK_INGEST_HOST=0.0.0.0 -# Host-run web/API processes launched through `./scripts/dev.sh web` ignore this -# `.env` value and compute a deterministic per-worktree port unless you export -# WEBHOOK_INGEST_PORT in your shell. compose.yaml pins the API container -# to 8090 internally; compose.local.yaml varies only the published host port. -WEBHOOK_INGEST_PORT=8090 -WEBHOOK_INGEST_HOST_BIND=127.0.0.1 -# Optional: expose ingest API on a fixed host port for local debugging. +# Web/API service (optional defaults; API_SHARED_SECRET required for protected API calls) +WEB_HOST=0.0.0.0 +# Direct host-run web/API processes use WEB_PORT. Compose pins the container's +# internal listen port to 8090 and uses WEB_HOST_PORT for the published host port. +# `./scripts/dev.sh web` ignores this `.env` value and computes a deterministic +# per-worktree port unless you export WEB_PORT in your shell. +WEB_PORT=8090 +WEB_HOST_BIND=127.0.0.1 +# Optional: expose the web/API service on a fixed host port for Cloudflare Tunnel/local debugging. # Leave unset to let ./scripts/docker-compose.sh compute a deterministic port. -# WEBHOOK_INGEST_HOST_PORT=8090 +# WEB_HOST_PORT=8090 # Host-run web/API processes launched through `./scripts/dev.sh web` override -# `WEBHOOK_INGEST_PORT` with a deterministic per-worktree port automatically. +# `WEB_PORT` with a deterministic per-worktree port automatically. +# Deprecated fallback names still work: WEBHOOK_INGEST_HOST, WEBHOOK_INGEST_PORT, +# WEBHOOK_INGEST_HOST_BIND, WEBHOOK_INGEST_HOST_PORT. # Required: ingest requests are rejected when unset API_SHARED_SECRET= # Authentik admin API (required for /create-sso-user and /create-user-accounts) diff --git a/README.md b/README.md index 7b56c6ed..b61e4628 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This repository follows a service-oriented monorepo layout: ├── apps/ │ ├── discord_bot/ # Discord gateway process │ │ └── src/five08/discord_bot/ -│ ├── api/ # Backend API + dashboard service +│ ├── api/ # Backend API + dashboard code │ │ └── src/five08/backend/ │ └── worker/ # Async queue worker │ └── src/five08/worker/ @@ -19,7 +19,7 @@ This repository follows a service-oriented monorepo layout: │ └── shared/ │ └── src/five08/ # Shared settings, queue helpers, shared clients ├── compose.yaml # canonical Coolify/base container stack -├── compose.local.yaml # local host port publishing override +├── compose.local.yaml # local infra host port publishing override ├── docker-compose.yml # compatibility wrapper including compose.yaml ├── tests/ # Unit and integration tests └── pyproject.toml # uv workspace root @@ -28,7 +28,7 @@ This repository follows a service-oriented monorepo layout: ## Services - `discord_bot`: Discord gateway process. -- `api`: FastAPI dashboard + ingest service that validates and enqueues jobs. +- `web`: FastAPI dashboard + ingest service that validates and enqueues jobs. - `worker`: Dramatiq worker that executes jobs from Redis queue. - `redis`: queue transport between API and worker. - `postgres`: job state persistence, retries, idempotency. @@ -37,7 +37,7 @@ This repository follows a service-oriented monorepo layout: Migrations: - `apps/worker/src/five08/worker/migrations` (Alembic) -- `api` runs `run_job_migrations()` during startup to keep DB schema current. +- `web` runs `run_job_migrations()` during startup to keep DB schema current. ### Job model @@ -150,10 +150,12 @@ For local full-container runs, including deterministic localhost ports: Coolify should use `/compose.yaml` as the base Compose file. A small `docker-compose.yml` compatibility wrapper includes it for tools still configured -to read the older filename. The base file intentionally -does not publish Redis, Postgres, MinIO, or API host ports; Coolify should expose -only the services/domains it manages. The app services also attach to the -shared infra network named by `INFRA_DOCKER_NETWORK` so they can reach +to read the older filename. The `web` service publishes container port `8090` +to `${WEB_HOST_BIND:-127.0.0.1}:${WEB_HOST_PORT:-8090}` +so a host-side Cloudflare Tunnel can target the dashboard/API at localhost. +The base file does not publish Redis, Postgres, or MinIO host ports. The app +services also attach to the shared infra network named by `INFRA_DOCKER_NETWORK` +so they can reach Portainer-managed Bifrost and Langfuse by Docker DNS. The network is declared as external, so pre-create it before running Compose if it does not already exist. @@ -236,13 +238,14 @@ Use `.env.example` as the source of truth for defaults. - Note: `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` are `SharedSettings` alias properties (`minio_access_key`, `minio_secret_key`) and are not env-loaded fields. - Note: use `MINIO_ROOT_USER` and `MINIO_ROOT_PASSWORD` as the actual env vars. -### Backend API Ingest +### Web/API Service -- `Required` for non-dashboard protected endpoints: `API_SHARED_SECRET` (ingest requests are rejected when unset) -- `Optional`: `WEBHOOK_INGEST_HOST` (default: `0.0.0.0`) -- `Optional`: `WEBHOOK_INGEST_HOST_BIND` (default: `127.0.0.1`; Compose host bind for local exposure) -- `Optional`: `WEBHOOK_INGEST_PORT` (host-run `./scripts/dev.sh` ignores `.env` for this key and defaults to a deterministic per-worktree value near `18080 + WORKTREE_ENV_SLOT`; export it in your shell only when you intentionally want a fixed port, and avoid browser-unsafe ports such as `5060`) -- `Optional`: `WEBHOOK_INGEST_HOST_PORT` (default: `8090` when running `docker compose` directly; `./scripts/docker-compose.sh` computes a deterministic per-worktree value when unset, and pinned values must avoid browser-unsafe ports such as `5060`; see `./scripts/docker-compose.sh print-ports`) +- `Required` for non-dashboard protected endpoints: `API_SHARED_SECRET` (protected API requests are rejected when unset) +- `Optional`: `WEB_HOST` (default: `0.0.0.0`; direct process bind host, while Compose pins the container bind host to `0.0.0.0`) +- `Optional`: `WEB_HOST_BIND` (default: `127.0.0.1`; Compose host bind for Cloudflare Tunnel/local exposure) +- `Optional`: `WEB_PORT` (direct process listen port; Compose pins the container's internal listen port to `8090`; host-run `./scripts/dev.sh` ignores `.env` for this key and defaults to a deterministic per-worktree value near `18080 + WORKTREE_ENV_SLOT`) +- `Optional`: `WEB_HOST_PORT` (published host port for Docker/Cloudflare Tunnel; default `8090` when running `docker compose` directly; `./scripts/docker-compose.sh` computes a deterministic per-worktree value when unset, and pinned values must avoid browser-unsafe ports such as `5060`; see `./scripts/docker-compose.sh print-ports`) +- Deprecated fallback names still work for now: `WEBHOOK_INGEST_HOST`, `WEBHOOK_INGEST_PORT`, `WEBHOOK_INGEST_HOST_BIND`, `WEBHOOK_INGEST_HOST_PORT`. ### Backend API OIDC Session Auth @@ -312,7 +315,7 @@ Use `.env.example` as the source of truth for defaults. ### Discord Bot Core - `Required`: `DISCORD_BOT_TOKEN` -- `Optional`: `BACKEND_API_BASE_URL` (default: `http://127.0.0.1:8090`; `./scripts/dev.sh` overrides it to the worktree web/API port, Compose injects `http://api:8090`) +- `Optional`: `BACKEND_API_BASE_URL` (default: `http://127.0.0.1:8090`; `./scripts/dev.sh` overrides it to the worktree web/API port, Compose injects `http://web:8090`) - `Optional`: `HEALTHCHECK_PORT` (host-run `./scripts/dev.sh` ignores `.env` for this key and defaults to a deterministic per-worktree value near `30000 + WORKTREE_ENV_SLOT`; export it in your shell only when you intentionally want a fixed port, and avoid browser-unsafe ports such as `5060`) - Note: bot message chunking uses Discord's 2000 character limit in code. @@ -415,4 +418,4 @@ Deploy as a single Compose application. MinIO is used as the internal transfer mechanism so file handoffs stay inside the stack. External object storage adapters can be added later for multi-cloud or vendor-specific routing. -This keeps one stack and one shared env set while still allowing independent service scaling/restarts (`discord_bot`, `api`, `worker`). +This keeps one stack and one shared env set while still allowing independent service scaling/restarts (`discord_bot`, `web`, `worker`). diff --git a/apps/api/src/five08/backend/api.py b/apps/api/src/five08/backend/api.py index 155ccf99..e3e2ead0 100644 --- a/apps/api/src/five08/backend/api.py +++ b/apps/api/src/five08/backend/api.py @@ -3900,8 +3900,8 @@ def run() -> None: ) uvicorn.run( create_app(), - host=settings.webhook_ingest_host, - port=settings.webhook_ingest_port, + host=settings.web_host, + port=settings.web_port, log_level=settings.log_level.lower(), ) diff --git a/apps/worker/README.md b/apps/worker/README.md index 28ecf1f8..638cbb1f 100644 --- a/apps/worker/README.md +++ b/apps/worker/README.md @@ -14,7 +14,7 @@ Defaults: - Base URL: - Outside Docker: `http://localhost:8090` - - Inside Docker: `http://api:8090` + - Inside Docker: `http://web:8090` - Override: `$WORKER_API_BASE_URL` - API secret: `$API_SHARED_SECRET` (sent as `X-API-Secret`) - Timeout: `10.0` seconds @@ -87,7 +87,7 @@ DiscordWebhookLogger( "color": 15158332, "fields": [ {"name": "Environment", "value": "production", "inline": True}, - {"name": "Service", "value": "api", "inline": True}, + {"name": "Service", "value": "web", "inline": True}, ], } ], diff --git a/apps/worker/src/five08/jobcli.py b/apps/worker/src/five08/jobcli.py index 8d1642c6..0c34e597 100644 --- a/apps/worker/src/five08/jobcli.py +++ b/apps/worker/src/five08/jobcli.py @@ -13,7 +13,7 @@ from five08.queue import JobStatus DEFAULT_API_URL = "http://localhost:8090" -DEFAULT_DOCKER_API_URL = "http://api:8090" +DEFAULT_DOCKER_API_URL = "http://web:8090" DEFAULT_TIMEOUT_SECONDS = 10.0 API_SECRET_ENV_VAR = "API_SHARED_SECRET" JOB_STATUSES = [status.value for status in JobStatus] diff --git a/compose.local.yaml b/compose.local.yaml index 949990e2..1b8ce11b 100644 --- a/compose.local.yaml +++ b/compose.local.yaml @@ -11,7 +11,3 @@ services: ports: - "${MINIO_HOST_BIND:-127.0.0.1}:${MINIO_API_HOST_PORT:-9000}:9000" - "${MINIO_HOST_BIND:-127.0.0.1}:${MINIO_CONSOLE_HOST_PORT:-9001}:9001" - - api: - ports: - - "${WEBHOOK_INGEST_HOST_BIND:-127.0.0.1}:${WEBHOOK_INGEST_HOST_PORT:-8090}:8090" diff --git a/compose.yaml b/compose.yaml index 23db7f1b..6af170eb 100644 --- a/compose.yaml +++ b/compose.yaml @@ -68,7 +68,7 @@ services: environment: REDIS_URL: redis://redis:6379/0 REDIS_QUEUE_NAME: ${REDIS_QUEUE_NAME:-jobs.default} - BACKEND_API_BASE_URL: http://api:8090 + BACKEND_API_BASE_URL: http://web:8090 restart: unless-stopped networks: - default @@ -77,7 +77,7 @@ services: redis: condition: service_healthy - api: + web: build: context: . dockerfile: apps/api/Dockerfile @@ -98,8 +98,12 @@ services: MINIO_ROOT_USER: ${MINIO_ROOT_USER:-internal} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-change-me} MINIO_INTERNAL_BUCKET: ${MINIO_INTERNAL_BUCKET:-internal-transfers} - WEBHOOK_INGEST_HOST: 0.0.0.0 - WEBHOOK_INGEST_PORT: 8090 + # Keep the container's internal listen port fixed; vary only the published + # host port for tunnels/local multi-worktree runs. + WEB_HOST: 0.0.0.0 + WEB_PORT: 8090 + ports: + - "${WEB_HOST_BIND:-${WEBHOOK_INGEST_HOST_BIND:-127.0.0.1}}:${WEB_HOST_PORT:-${WEBHOOK_INGEST_HOST_PORT:-8090}}:8090" restart: unless-stopped networks: - default @@ -127,7 +131,7 @@ services: environment: REDIS_URL: redis://redis:6379/0 REDIS_QUEUE_NAME: ${REDIS_QUEUE_NAME:-jobs.default} - WORKER_API_BASE_URL: http://api:8090 + WORKER_API_BASE_URL: http://web:8090 WORKER_QUEUE_NAMES: ${WORKER_QUEUE_NAMES:-jobs.default} POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} @@ -146,7 +150,7 @@ services: - default - infra depends_on: - api: + web: condition: service_started redis: condition: service_healthy diff --git a/packages/shared/src/five08/settings.py b/packages/shared/src/five08/settings.py index 1e9ce7eb..7232ca36 100644 --- a/packages/shared/src/five08/settings.py +++ b/packages/shared/src/five08/settings.py @@ -3,7 +3,7 @@ import os import sys -from pydantic import field_validator, model_validator +from pydantic import AliasChoices, Field, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -43,8 +43,14 @@ class SharedSettings(BaseSettings): minio_root_password: str = "" minio_internal_bucket: str = "internal-transfers" - webhook_ingest_host: str = "0.0.0.0" - webhook_ingest_port: int = 8090 + web_host: str = Field( + default="0.0.0.0", + validation_alias=AliasChoices("WEB_HOST", "WEBHOOK_INGEST_HOST"), + ) + web_port: int = Field( + default=8090, + validation_alias=AliasChoices("WEB_PORT", "WEBHOOK_INGEST_PORT"), + ) api_shared_secret: str | None = None discord_logs_webhook_url: str | None = None discord_logs_webhook_wait: bool = True @@ -68,7 +74,11 @@ class SharedSettings(BaseSettings): outline_api_key: str | None = None outline_api_timeout_seconds: float = 20.0 - model_config = SettingsConfigDict(env_file=".env", extra="ignore") + model_config = SettingsConfigDict( + env_file=".env", + extra="ignore", + populate_by_name=True, + ) @field_validator("docuseal_member_agreement_template_id", mode="before") @classmethod diff --git a/scripts/dev.sh b/scripts/dev.sh index f0de4be2..83a74da0 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -10,8 +10,8 @@ worktree_env_load "$script_dir" export REDIS_URL="redis://127.0.0.1:${REDIS_HOST_PORT}/0" export POSTGRES_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:${POSTGRES_HOST_PORT}/${POSTGRES_DB}" export MINIO_ENDPOINT="http://127.0.0.1:${MINIO_API_HOST_PORT}" -export BACKEND_API_BASE_URL="http://127.0.0.1:${WEBHOOK_INGEST_PORT}" -export WORKER_API_BASE_URL="http://127.0.0.1:${WEBHOOK_INGEST_PORT}" +export BACKEND_API_BASE_URL="http://127.0.0.1:${WEB_PORT}" +export WORKER_API_BASE_URL="http://127.0.0.1:${WEB_PORT}" export DISCORD_BOT_INTERNAL_BASE_URL="http://127.0.0.1:${HEALTHCHECK_PORT}" shell_quote() { @@ -42,7 +42,7 @@ Infrastructure is running in Docker on localhost: Console: 127.0.0.1:${MINIO_CONSOLE_HOST_PORT} Host-run app ports for this worktree: - Web/API: 127.0.0.1:${WEBHOOK_INGEST_PORT} (hot reload) + Web/API: 127.0.0.1:${WEB_PORT} (hot reload) Bot: 127.0.0.1:${HEALTHCHECK_PORT} Run app services on the host with: @@ -65,7 +65,7 @@ REDIS_HOST_PORT=$REDIS_HOST_PORT POSTGRES_HOST_PORT=$POSTGRES_HOST_PORT MINIO_API_HOST_PORT=$MINIO_API_HOST_PORT MINIO_CONSOLE_HOST_PORT=$MINIO_CONSOLE_HOST_PORT -WEBHOOK_INGEST_PORT=$WEBHOOK_INGEST_PORT +WEB_PORT=$WEB_PORT HEALTHCHECK_PORT=$HEALTHCHECK_PORT EOF ;; @@ -76,7 +76,7 @@ EOF emit_export POSTGRES_DB "$POSTGRES_DB" emit_export MINIO_API_HOST_PORT "$MINIO_API_HOST_PORT" emit_export MINIO_CONSOLE_HOST_PORT "$MINIO_CONSOLE_HOST_PORT" - emit_export WEBHOOK_INGEST_PORT "$WEBHOOK_INGEST_PORT" + emit_export WEB_PORT "$WEB_PORT" emit_export HEALTHCHECK_PORT "$HEALTHCHECK_PORT" emit_export REDIS_URL "$REDIS_URL" emit_export MINIO_ENDPOINT "$MINIO_ENDPOINT" @@ -91,8 +91,8 @@ EOF web|api) exec uv run --package api uvicorn five08.backend.api:create_app \ --factory \ - --host "${WEBHOOK_INGEST_HOST:-0.0.0.0}" \ - --port "$WEBHOOK_INGEST_PORT" \ + --host "${WEB_HOST:-${WEBHOOK_INGEST_HOST:-0.0.0.0}}" \ + --port "$WEB_PORT" \ --reload \ --reload-dir apps/api/src \ --reload-dir apps/worker/src \ diff --git a/scripts/dev_mux.py b/scripts/dev_mux.py index 3f790411..de8bf1f9 100755 --- a/scripts/dev_mux.py +++ b/scripts/dev_mux.py @@ -33,9 +33,9 @@ def _service_commands(env: dict[str, str]) -> list[tuple[str, list[str]]]: "five08.backend.api:create_app", "--factory", "--host", - env.get("WEBHOOK_INGEST_HOST", "0.0.0.0"), + env.get("WEB_HOST", env.get("WEBHOOK_INGEST_HOST", "0.0.0.0")), "--port", - env["WEBHOOK_INGEST_PORT"], + env.get("WEB_PORT", env.get("WEBHOOK_INGEST_PORT", "8090")), "--reload", "--reload-dir", "apps/api/src", diff --git a/scripts/docker-compose.sh b/scripts/docker-compose.sh index b1143031..c2f7befa 100755 --- a/scripts/docker-compose.sh +++ b/scripts/docker-compose.sh @@ -9,12 +9,14 @@ repo_root=$WORKTREE_ENV_REPO_ROOT export COMPOSE_PROJECT_NAME export REDIS_HOST_PORT export POSTGRES_HOST_PORT +export WEB_HOST_PORT export WEBHOOK_INGEST_HOST_PORT export MINIO_API_HOST_PORT export MINIO_CONSOLE_HOST_PORT -# Host-run-only app ports must not leak into Compose interpolation, or the API +# Host-run-only app ports must not leak into Compose interpolation, or the web # container can start on a high worktree port while peers still target :8090. +unset WEB_PORT unset WEBHOOK_INGEST_PORT unset HEALTHCHECK_PORT @@ -24,7 +26,7 @@ WORKTREE=$repo_root COMPOSE_PROJECT_NAME=$COMPOSE_PROJECT_NAME REDIS_HOST_PORT=$REDIS_HOST_PORT POSTGRES_HOST_PORT=$POSTGRES_HOST_PORT -WEBHOOK_INGEST_HOST_PORT=$WEBHOOK_INGEST_HOST_PORT +WEB_HOST_PORT=$WEB_HOST_PORT MINIO_API_HOST_PORT=$MINIO_API_HOST_PORT MINIO_CONSOLE_HOST_PORT=$MINIO_CONSOLE_HOST_PORT EOF diff --git a/scripts/worktree-env.sh b/scripts/worktree-env.sh index bf5d73b5..29bb4526 100755 --- a/scripts/worktree-env.sh +++ b/scripts/worktree-env.sh @@ -182,6 +182,63 @@ worktree_env_resolve_browser_safe_shell_or_default() { "$port_label" } +worktree_env_resolve_browser_safe_port_preferred() { + preferred_key=$1 + legacy_key=$2 + default_value=$3 + env_file=$4 + port_label=$5 + source_label=default + eval "preferred_value=\${$preferred_key-}" + eval "legacy_value=\${$legacy_key-}" + + if [ -n "$preferred_value" ]; then + resolved_value=$preferred_value + source_label=environment + elif file_value=$(worktree_env_get_env_file_value "$preferred_key" "$env_file" 2>/dev/null); then + resolved_value=$file_value + source_label=.env + elif [ -n "$legacy_value" ]; then + resolved_value=$legacy_value + source_label=environment + elif file_value=$(worktree_env_get_env_file_value "$legacy_key" "$env_file" 2>/dev/null); then + resolved_value=$file_value + source_label=.env + else + resolved_value=$default_value + fi + + worktree_env_finalize_browser_safe_port \ + "$resolved_value" \ + "$source_label" \ + "$port_label" +} + +worktree_env_resolve_browser_safe_shell_or_default_preferred() { + preferred_key=$1 + legacy_key=$2 + default_value=$3 + port_label=$4 + source_label=default + eval "preferred_value=\${$preferred_key-}" + eval "legacy_value=\${$legacy_key-}" + + if [ -n "$preferred_value" ]; then + resolved_value=$preferred_value + source_label=environment + elif [ -n "$legacy_value" ]; then + resolved_value=$legacy_value + source_label=environment + else + resolved_value=$default_value + fi + + worktree_env_finalize_browser_safe_port \ + "$resolved_value" \ + "$source_label" \ + "$port_label" +} + worktree_env_load() { script_dir=$1 mode=${2:-host} @@ -203,7 +260,7 @@ worktree_env_load() { POSTGRES_USER=$(worktree_env_resolve_value POSTGRES_USER "postgres" "$WORKTREE_ENV_FILE") POSTGRES_PASSWORD=$(worktree_env_resolve_value POSTGRES_PASSWORD "postgres" "$WORKTREE_ENV_FILE") POSTGRES_DB=$(worktree_env_resolve_value POSTGRES_DB "workflows" "$WORKTREE_ENV_FILE") - WEBHOOK_INGEST_HOST_PORT=$(worktree_env_resolve_browser_safe_port WEBHOOK_INGEST_HOST_PORT "$((20080 + WORKTREE_ENV_SLOT))" "$WORKTREE_ENV_FILE" "WEBHOOK_INGEST_HOST_PORT") + WEB_HOST_PORT=$(worktree_env_resolve_browser_safe_port_preferred WEB_HOST_PORT WEBHOOK_INGEST_HOST_PORT "$((20080 + WORKTREE_ENV_SLOT))" "$WORKTREE_ENV_FILE" "WEB_HOST_PORT") MINIO_API_HOST_PORT=$(worktree_env_resolve_browser_safe_port MINIO_API_HOST_PORT "$((24000 + WORKTREE_ENV_SLOT))" "$WORKTREE_ENV_FILE" "MINIO_API_HOST_PORT") MINIO_CONSOLE_HOST_PORT=$(worktree_env_resolve_browser_safe_port MINIO_CONSOLE_HOST_PORT "$((28000 + WORKTREE_ENV_SLOT))" "$WORKTREE_ENV_FILE" "MINIO_CONSOLE_HOST_PORT") @@ -216,18 +273,21 @@ worktree_env_load() { export POSTGRES_USER export POSTGRES_PASSWORD export POSTGRES_DB - export WEBHOOK_INGEST_HOST_PORT + export WEB_HOST_PORT + export WEBHOOK_INGEST_HOST_PORT=$WEB_HOST_PORT export MINIO_API_HOST_PORT export MINIO_CONSOLE_HOST_PORT if [ "$mode" = "host" ]; then # Keep host-run app ports below the Linux default ephemeral range # (32768-60999) to avoid rare EADDRINUSE races with outbound sockets. - WEBHOOK_INGEST_PORT=$(worktree_env_resolve_browser_safe_shell_or_default WEBHOOK_INGEST_PORT "$((18080 + WORKTREE_ENV_SLOT))" "WEBHOOK_INGEST_PORT") + WEB_PORT=$(worktree_env_resolve_browser_safe_shell_or_default_preferred WEB_PORT WEBHOOK_INGEST_PORT "$((18080 + WORKTREE_ENV_SLOT))" "WEB_PORT") HEALTHCHECK_PORT=$(worktree_env_resolve_browser_safe_shell_or_default HEALTHCHECK_PORT "$((30000 + WORKTREE_ENV_SLOT))" "HEALTHCHECK_PORT") - export WEBHOOK_INGEST_PORT + export WEB_PORT + export WEBHOOK_INGEST_PORT=$WEB_PORT export HEALTHCHECK_PORT else + unset WEB_PORT unset WEBHOOK_INGEST_PORT unset HEALTHCHECK_PORT fi diff --git a/tests/unit/test_dev_mux.py b/tests/unit/test_dev_mux.py index 04e4f1f1..e08555c3 100644 --- a/tests/unit/test_dev_mux.py +++ b/tests/unit/test_dev_mux.py @@ -52,3 +52,19 @@ def fake_run(*args, **kwargs): f"web port {port} is already in use; " "install lsof for owner details or stop the existing listener and retry." ) + + +def test_service_commands_accept_legacy_webhook_ingest_port() -> None: + module = _load_dev_mux_module() + + commands = module._service_commands( + { + "WEBHOOK_INGEST_HOST": "127.0.0.1", + "WEBHOOK_INGEST_PORT": "19090", + } + ) + + web_command = commands[0][1] + + assert web_command[web_command.index("--host") + 1] == "127.0.0.1" + assert web_command[web_command.index("--port") + 1] == "19090" diff --git a/tests/unit/test_jobcli.py b/tests/unit/test_jobcli.py index afc823ee..d638e010 100644 --- a/tests/unit/test_jobcli.py +++ b/tests/unit/test_jobcli.py @@ -176,7 +176,7 @@ def test_jobsctl_rerun_uses_default_secret_from_environment( assert mock_request.call_args.kwargs["headers"]["X-API-Secret"] == "from-env" -def test_jobsctl_status_defaults_to_api_service_in_docker( +def test_jobsctl_status_defaults_to_web_service_in_docker( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("API_SHARED_SECRET", "from-env") @@ -190,14 +190,14 @@ def test_jobsctl_status_defaults_to_api_service_in_docker( status_code=200, payload={"status": "succeeded", "job_id": "job-123"}, method="GET", - url="http://api:8090/jobs/job-123", + url="http://web:8090/jobs/job-123", ) exit_code = jobcli.run(["status", "job-123"]) assert exit_code == 0 mock_request.assert_called_once() - assert mock_request.call_args.kwargs["url"] == "http://api:8090/jobs/job-123" + assert mock_request.call_args.kwargs["url"] == "http://web:8090/jobs/job-123" def test_jobsctl_status_prints_error_when_api_returns_error( diff --git a/tests/unit/test_shared_settings.py b/tests/unit/test_shared_settings.py index 98cad145..db5f974c 100644 --- a/tests/unit/test_shared_settings.py +++ b/tests/unit/test_shared_settings.py @@ -141,3 +141,33 @@ def test_local_service_defaults_target_host_runtime( == "postgresql://postgres:postgres@127.0.0.1:5432/workflows" ) assert settings.minio_endpoint == "http://127.0.0.1:9000" + + +def test_shared_settings_accept_web_service_env_names( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Web/API service settings should use general web env names.""" + monkeypatch.setenv("WEB_HOST", "127.0.0.1") + monkeypatch.setenv("WEB_PORT", "18090") + monkeypatch.delenv("WEBHOOK_INGEST_HOST", raising=False) + monkeypatch.delenv("WEBHOOK_INGEST_PORT", raising=False) + + settings = SharedSettings() + + assert settings.web_host == "127.0.0.1" + assert settings.web_port == 18090 + + +def test_shared_settings_accept_legacy_webhook_ingest_env_names( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Legacy webhook ingest env names should remain usable during migration.""" + monkeypatch.delenv("WEB_HOST", raising=False) + monkeypatch.delenv("WEB_PORT", raising=False) + monkeypatch.setenv("WEBHOOK_INGEST_HOST", "127.0.0.2") + monkeypatch.setenv("WEBHOOK_INGEST_PORT", "18091") + + settings = SharedSettings() + + assert settings.web_host == "127.0.0.2" + assert settings.web_port == 18091 diff --git a/tests/unit/test_worktree_env.py b/tests/unit/test_worktree_env.py index d2bd5e8e..4116972a 100644 --- a/tests/unit/test_worktree_env.py +++ b/tests/unit/test_worktree_env.py @@ -58,7 +58,7 @@ def test_resolve_browser_safe_port_rejects_explicit_unsafe_override() -> None: def test_worktree_env_load_rejects_unsafe_compose_http_port_override() -> None: env = os.environ.copy() - env["WEBHOOK_INGEST_HOST_PORT"] = "5060" + env["WEB_HOST_PORT"] = "5060" result = _run_shell( f""" @@ -71,21 +71,41 @@ def test_worktree_env_load_rejects_unsafe_compose_http_port_override() -> None: assert result.returncode != 0 assert ( - "WEBHOOK_INGEST_HOST_PORT cannot use browser-unsafe port '5060'; " + "WEB_HOST_PORT cannot use browser-unsafe port '5060'; " "pick a different port." in result.stderr ) +def test_worktree_env_load_accepts_legacy_compose_http_port_override() -> None: + env = os.environ.copy() + env.pop("WEB_HOST_PORT", None) + env["WEBHOOK_INGEST_HOST_PORT"] = "23090" + + result = _run_shell( + f""" + set -eu + . {SCRIPT_PATH} + worktree_env_load {REPO_ROOT / "scripts"} compose + printf '%s\\n%s\\n' "$WEB_HOST_PORT" "$WEBHOOK_INGEST_HOST_PORT" + """, + env=env, + ) + + assert result.returncode == 0 + assert result.stdout.splitlines() == ["23090", "23090"] + + def test_worktree_env_load_host_mode_ignores_dotenv_host_port_pins() -> None: with tempfile.TemporaryDirectory() as tmp_dir: repo_root = Path(tmp_dir) scripts_dir = repo_root / "scripts" scripts_dir.mkdir() env = os.environ.copy() + env.pop("WEB_PORT", None) env.pop("WEBHOOK_INGEST_PORT", None) env.pop("HEALTHCHECK_PORT", None) (repo_root / ".env").write_text( - "WEBHOOK_INGEST_PORT=8090\nHEALTHCHECK_PORT=3000\n", + "WEB_PORT=8090\nHEALTHCHECK_PORT=3000\n", encoding="utf-8", ) @@ -94,7 +114,7 @@ def test_worktree_env_load_host_mode_ignores_dotenv_host_port_pins() -> None: set -eu . {SCRIPT_PATH} worktree_env_load {scripts_dir} host - printf '%s\\n%s\\n' "$WEBHOOK_INGEST_PORT" "$HEALTHCHECK_PORT" + printf '%s\\n%s\\n%s\\n' "$WEB_PORT" "$WEBHOOK_INGEST_PORT" "$HEALTHCHECK_PORT" """, env=env, ) @@ -114,6 +134,7 @@ def test_worktree_env_load_host_mode_ignores_dotenv_host_port_pins() -> None: slot = int(hash_value) % 2000 assert result.stdout.splitlines() == [ + str(18080 + slot), str(18080 + slot), str(30000 + slot), ]