From abf568332d74727e8c1d52ed34c0495b85d1f23d Mon Sep 17 00:00:00 2001 From: Brad Byrd Date: Sun, 20 Sep 2026 02:29:29 -0500 Subject: [PATCH] feat(config): read the HTTP port from .wflcfg --- .github/workflows/wfl-tests.yml | 4 +- .wflcfg | 7 +- CLAUDE.md | 8 +- README.md | 17 ++- TestPrograms/util.test.wfl | 31 ++++++ app/util.wfl | 14 +++ docs/ARCHITECTURE.md | 11 +- main.wfl | 8 +- testing.md | 25 ++++- tests/integration/test_server_port.py | 155 ++++++++++++++++++++++++++ 10 files changed, 260 insertions(+), 20 deletions(-) create mode 100644 tests/integration/test_server_port.py diff --git a/.github/workflows/wfl-tests.yml b/.github/workflows/wfl-tests.yml index 51e3c41..4c337f5 100644 --- a/.github/workflows/wfl-tests.yml +++ b/.github/workflows/wfl-tests.yml @@ -45,9 +45,10 @@ jobs: echo "- Scribe: $(git -C lib/scribe rev-parse HEAD)" echo '- Runner: blacksmith-2vcpu-ubuntu-2404 (Linux x64)' echo '- Command: python3 scripts/run_tests.py --include-scribe' + echo '- HTTP checks: python3 -m unittest discover -s tests/integration -v' } | tee -a "$GITHUB_STEP_SUMMARY" - - name: Run Scriptorium and pinned Scribe suites + - name: Run WFL suites and HTTP configuration checks shell: bash env: RESOLVED_IMAGE: ${{ steps.runtime.outputs.image }} @@ -64,6 +65,7 @@ jobs: python3 --version wfl --version python3 scripts/run_tests.py --include-scribe + python3 -m unittest discover -s tests/integration -v ' - name: Clean up test container diff --git a/.wflcfg b/.wflcfg index 2174a56..34138cb 100644 --- a/.wflcfg +++ b/.wflcfg @@ -1,4 +1,4 @@ -# Scriptorium runtime configuration (WFL settings) +# Scriptorium configuration (WFL runtime and application settings) timeout_seconds = 60 logging_enabled = false debug_report_enabled = false @@ -9,6 +9,11 @@ log_level = info # 0.0.0.0 -> listen on all interfaces (use behind a reverse proxy) web_server_bind_address = 127.0.0.1 +# HTTP port, read by Scriptorium at boot rather than by the WFL runtime. +# Use a whole number from 1 to 65535. Missing or invalid values use 8080. +# Restart Scriptorium after changing it. +web_server_port = 8080 + # To terminate TLS directly in Scriptorium, point these at your PEM files and # start with a `listen ... secured` port (see docs/ARCHITECTURE.md). # web_server_tls_cert_file = /etc/scriptorium/tls/cert.pem diff --git a/CLAUDE.md b/CLAUDE.md index 3907df4..79e71d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,9 +85,11 @@ violate the standard. its summary records the resolved image digest, runtime version, and source revisions. See [testing.md](testing.md) for commands, coverage limits, and merge evidence. -- **`data_dir` is an application convention, not a WFL runtime feature.** - `main.wfl` reads `.wflcfg` itself at boot and parses the key via - `config_value_from` in `app/util.wfl`. The runtime ignores it. +- **`data_dir` and `web_server_port` are application configuration.** + `main.wfl` reads `.wflcfg` itself at boot using helpers in `app/util.wfl`; + the runtime does not apply these keys. The HTTP port accepts whole numbers + from 1 to 65535 and defaults to 8080 for a missing file or setting, or an + empty, malformed, fractional, or out-of-range value. Restart after changing it. ## Known gaps worth knowing before you touch rendering diff --git a/README.md b/README.md index 6718d27..c511891 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,12 @@ Open — you will be sent to `/install`. Choose a site title, tagline, admin username, and password. When setup finishes you are signed in at `/admin`. Add more users (admins or authors) under **Users**. -> Bind address and TLS are set in `.wflcfg`. The server listens on -> `127.0.0.1:8080` by default; set `web_server_bind_address = 0.0.0.0` to expose -> it behind a reverse proxy. +> Network settings are in `.wflcfg`. The server listens on `127.0.0.1:8080` +> by default; set `web_server_bind_address = 0.0.0.0` to expose it behind a +> reverse proxy. Scriptorium reads `web_server_port = 8080` at boot; change it +> to a whole number from 1 to 65535 and restart to use a different HTTP port. +> A missing file or setting, or an empty, malformed, fractional, or out-of-range +> value, uses 8080. TLS settings are also in `.wflcfg`. ### Choosing a theme @@ -94,8 +97,9 @@ theme_root = themes > **A value runs to the end of the line.** `.wflcfg` supports whole-line `#` > comments only, so `theme = logbie # my theme` sets the theme to > `logbie # my theme` and every lookup misses. Put comments on their own line. -> This applies to `data_dir` too. Boot warns when the configured theme -> directory does not exist, which is what a trailing comment looks like. +> This applies to `data_dir` and `web_server_port` too. Boot warns when the +> configured theme directory does not exist, which is what a trailing comment +> looks like. A body template resolves as `//body/.html`, then `//templates/.html`, then the base theme — so a theme @@ -181,7 +185,7 @@ as a hidden `csrf_token` field) — requests without it get a 403. ```text main.wfl Boot (open DB, migrate, backfill) + request loop + router + handlers -.wflcfg WFL runtime config (bind address, TLS, body-size cap, data_dir) +.wflcfg Runtime and app config (bind address, HTTP port, TLS, body-size cap, data_dir) app/ util.wfl slugify, to_int, field_or, truncate, file_ext/stem, config_value_from (parsing is stdlib) db.wfl SQLite schema + every query/execute helper @@ -212,6 +216,7 @@ From the repository root, with Python 3.11+ and WFL on PATH: ```sh python scripts/run_tests.py # all five Scriptorium suites python scripts/run_tests.py --include-scribe # also test the pinned Scribe engine +python -m unittest discover -s tests/integration -v # HTTP port configuration python -m unittest discover -s tests/tooling -v python scripts/check_repo_hygiene.py ``` diff --git a/TestPrograms/util.test.wfl b/TestPrograms/util.test.wfl index e337e25..496d15c 100644 --- a/TestPrograms/util.test.wfl +++ b/TestPrograms/util.test.wfl @@ -74,6 +74,37 @@ describe "Scriptorium utilities": expect config_value_from of "" and "data_dir" and "./data" to equal "./data" end test + test "config_port_from defaults when absent or commented": + expect config_port_from of "" to equal 8080 + expect config_port_from of "web_server_bind_address = 127.0.0.1" to equal 8080 + expect config_port_from of "# web_server_port = 9090" to equal 8080 + end test + + test "config_port_from reads the configured port and trims whitespace": + expect config_port_from of "web_server_port = 9090" to equal 9090 + expect config_port_from of " web_server_port = 12345 \r\n" to equal 12345 + end test + + test "config_port_from accepts the valid port boundaries": + expect config_port_from of "web_server_port = 1" to equal 1 + expect config_port_from of "web_server_port = 65535" to equal 65535 + end test + + test "config_port_from rejects empty and nonnumeric values": + expect config_port_from of "web_server_port = " to equal 8080 + expect config_port_from of "web_server_port = nope" to equal 8080 + expect config_port_from of "web_server_port = true" to equal 8080 + expect config_port_from of "web_server_port = null" to equal 8080 + expect config_port_from of "web_server_port = 9090 # inline comment" to equal 8080 + end test + + test "config_port_from rejects fractional and out-of-range ports": + expect config_port_from of "web_server_port = 9090.5" to equal 8080 + expect config_port_from of "web_server_port = 0" to equal 8080 + expect config_port_from of "web_server_port = -1" to equal 8080 + expect config_port_from of "web_server_port = 65536" to equal 8080 + end test + test "install_validate rejects an empty title": expect install_validate of "" and "ada" and "secret" and "secret" to equal "Site title is required." end test diff --git a/app/util.wfl b/app/util.wfl index f7255e2..4147dfb 100644 --- a/app/util.wfl +++ b/app/util.wfl @@ -157,6 +157,20 @@ define action called config_value_from with parameters cfg_text and cfg_key and return fallback end action +// config_port_from: resolve Scriptorium's HTTP port from .wflcfg text. +// Missing or invalid values preserve the default listener on port 8080. +define action called config_port_from with parameters cfg_text: + store raw_port as config_value_from of cfg_text and "web_server_port" and "8080" + store configured_port as to_int of raw_port and 8080 + check if (configured_port is less than 1) or (configured_port is greater than 65535): + return 8080 + end check + check if configured_port is not equal to (floor of configured_port): + return 8080 + end check + return configured_port +end action + // install_validate: first-run installer field checks. Pure (no I/O). // Title and username are trimmed; passwords are compared as submitted. define action called install_validate with parameters site_title and username and password and password_confirm: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ace5590..4e3d27a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -15,7 +15,7 @@ reads as deliberate rather than accidental. ``` Browser - │ HTTP (127.0.0.1:8080) + │ HTTP (127.0.0.1:8080 by default) ▼ main.wfl ── listen on port ── main loop: wait for request ── route ──┐ │ │ @@ -61,6 +61,15 @@ main.wfl ── site_ext.wfl (+ defines the router and every request handler) already exist), the request loop, the `route`-based dispatcher with the first-run lock, and all handler actions. +## Network configuration + +WFL reads `web_server_bind_address` and TLS settings from `.wflcfg`. Scriptorium +reads `web_server_port` itself at boot, like `data_dir`, and passes the resolved +port to `listen`. The default is 8080; the configured value must be a whole +number from 1 to 65535. A missing file or setting, or an empty, malformed, +fractional, or out-of-range value, falls back to 8080. Changes require a restart. +The shared bind address remains `127.0.0.1`. + ## Request lifecycle WFL's web server is **pull-based and single-threaded**: the loop blocks on diff --git a/main.wfl b/main.wfl index 5559df5..90d392e 100644 --- a/main.wfl +++ b/main.wfl @@ -1315,6 +1315,8 @@ store cfg_text as "" check if file exists at ".wflcfg": change cfg_text to scribe_read_file of ".wflcfg" end check +// The HTTP port is an application setting; preserve 8080 for existing installs. +store server_port as config_port_from of cfg_text store data_dir as config_value_from of cfg_text and "data_dir" and "" check if data_dir is not equal to "": check if directory exists at data_dir: @@ -1395,12 +1397,12 @@ otherwise: create directory at uploads_dir end check -listen on port 8080 as web_server -display "Scriptorium is running at http://127.0.0.1:8080" +listen on port server_port as web_server +display "Scriptorium is running at http://127.0.0.1:" with server_port check if (install_is_done of db) is equal to yes: display " admin: /admin" otherwise: - display " First run — open http://127.0.0.1:8080/install to set up your site" + display " First run — open http://127.0.0.1:" with server_port with "/install to set up your site" end check main loop: diff --git a/testing.md b/testing.md index 6e5f03e..81139f2 100644 --- a/testing.md +++ b/testing.md @@ -105,9 +105,22 @@ pin changes and changes affecting Scribe integration. CMS-specific Scribe regressions remain in `TestPrograms/scribe.test.wfl` even when upstream tests also cover related behavior. +The HTTP port configuration checks start disposable Scriptorium processes and +exercise `/install` using synthetic temporary databases: + +```sh +python -m unittest discover -s tests/integration -v +``` + +These checks require WFL on `PATH` (or `WFL_EXECUTABLE` set to its executable) +and an available loopback port 8080. They check an explicitly configured port, +the default when the setting or file is absent, and startup URLs. They do not +exercise installer submission or other complete CMS journeys. The WFL tests +workflow runs them inside its disposable nightly container after the WFL suites. + | Existing suite | Direct command from the repository root | What it currently exercises | |---|---|---| -| Utilities | `wfl --test TestPrograms/util.test.wfl` | Slugs, number/field helpers, parsing, config values, installer validation | +| Utilities | `wfl --test TestPrograms/util.test.wfl` | Slugs, number/field helpers, parsing, config values and valid/default ports, installer validation | | Data | `wfl --test TestPrograms/db.test.wfl` | In-memory SQLite CRUD helpers, sessions, installer state, rate-limit records, legacy CSRF-column migration | | Auth | `wfl --test TestPrograms/auth.test.wfl` | CSRF helper acceptance/rejection and session token binding | | Scribe integration | `wfl --test TestPrograms/scribe.test.wfl` | Markdown, safe-marker/filter propagation, escaping, nested blockquotes | @@ -180,8 +193,9 @@ keyboard behavior, or data integrity. concrete restore or forward-repair plan. An in-memory migration test alone does not prove crash recovery or database/upload consistency. - **Configuration:** exercise unset/default keys and explicit values for - `data_dir`, `theme`, and `theme_root`, including malformed values. Account for - the current whole-line-comment parsing and out-of-tree theme paths. + `web_server_port`, `data_dir`, `theme`, and `theme_root`, including malformed + values. Account for the current whole-line-comment parsing and out-of-tree + theme paths. - **Dependencies:** inspect the actual pinned Scribe diff, run both local and upstream suites, and exercise affected rendering paths. Runtime upgrades also require the application suites and affected web/SQLite/crypto boundaries. @@ -210,7 +224,8 @@ moving: retain the run URL and digest with PR evidence so a later nightly does not obscure which runtime was tested. The nightly workflow is not a declaration that every nightly, platform, or production configuration is supported. -There is no automated HTTP/browser journey suite in this repository. The +Automated HTTP coverage is limited to startup port configuration and serving +the installer form. Complete HTTP/browser journeys remain unautomated. The scheduled Scribe updater only proposes dependency changes; its successful run alone is not runtime test evidence. @@ -248,7 +263,7 @@ remain a separate adoption item below. | Gap | Required next step and trigger | |---|---| -| No router/HTTP or browser automation | Add real-boundary regression coverage with each affected behavior change; plan coverage of all critical journeys before the next production release. | +| HTTP coverage is limited to port configuration; no browser automation | Add real-boundary regression coverage with each affected behavior change; plan coverage of all critical journeys before the next production release. | | No declared compatibility matrix or release-candidate workflow | Define supported runtime/platform/configuration tuples and retain candidate results before the next production release. | | No coverage measurement, performance budgets, or scheduled extended tests | Establish baselines and risk-based targets before claiming those properties; review at the next profile review. | | Host protection settings are external | Maintainer verifies required checks and review rules on GitHub at adoption and after workflow changes. | diff --git a/tests/integration/test_server_port.py b/tests/integration/test_server_port.py new file mode 100644 index 0000000..06468c9 --- /dev/null +++ b/tests/integration/test_server_port.py @@ -0,0 +1,155 @@ +"""Exercise the configured listening port through a disposable Scriptorium site. + +Run sequentially with ``python -m unittest discover -s tests/integration -v``. +WFL_EXECUTABLE may select a runtime; otherwise wfl must be on PATH. The default +port cases require loopback port 8080 to be free and never stop other services. +""" + +import http.client +import os +from pathlib import Path +import shutil +import socket +import subprocess +import tempfile +import time +import unittest + + +SOURCE = Path(__file__).resolve().parents[2] +STARTUP_SECONDS = 20 +BASE_CONFIG = ( + "web_server_bind_address = 127.0.0.1\n" + "timeout_seconds = 60\n" + "logging_enabled = false\n" + "debug_report_enabled = false\n" +) + + +class ServerPortTests(unittest.TestCase): + def setUp(self): + requested = os.environ.get("WFL_EXECUTABLE", "wfl") + executable = shutil.which(requested) + self.assertIsNotNone(executable, f"WFL executable not found: {requested}") + self.executable = str(Path(executable).resolve()) + self.temporary = tempfile.TemporaryDirectory(prefix="scriptorium-port-test-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) / "site" + self.root.mkdir() + # The runtime searches parent directories for configuration, whereas + # Scriptorium reads only its own .wflcfg. Keep the absent-file test on + # loopback too, regardless of the developer's global runtime settings. + (self.root.parent / ".wflcfg").write_text(BASE_CONFIG, encoding="utf-8") + runtime_files = [ + Path("main.wfl"), + Path("lib/scribe/src/scribe.wfl"), + Path("admin/templates/install.html"), + *(Path("app") / name for name in + ("util.wfl", "db.wfl", "auth.wfl", "render.wfl", "site_ext.wfl")), + ] + for relative in runtime_files: + source = SOURCE / relative + self.assertTrue(source.is_file(), f"Missing runtime source: {source}") + destination = self.root / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + # Never copy a checkout's database, uploads, configuration, or .git. + (self.root / "static").mkdir() + self.log_path = self.root.parent / "server.log" + self.log = self.log_path.open("wb") + self.addCleanup(self.log.close) + self.process = None + self.addCleanup(self.stop_server) + + def stop_server(self): + if self.process is not None and self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=5) + + def server_log(self): + return self.log_path.read_text(encoding="utf-8", errors="replace") + + def available_port(self, requested=0): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as reservation: + if os.name == "nt": + reservation.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1) + else: + # Permit a previous test's TIME_WAIT sockets, but not a listener. + reservation.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + reservation.bind(("127.0.0.1", requested)) + except OSError as exc: + self.fail( + f"Prerequisite: loopback port {requested} must be free; " + f"no existing service was stopped ({exc})" + ) + return reservation.getsockname()[1] + + def assert_installer(self, expected_port, config): + if config is not None: + (self.root / ".wflcfg").write_text( + BASE_CONFIG + "data_dir = runtime-data\n" + config, encoding="utf-8" + ) + # Also keep the old hardcoded port free when proving the configured + # case, so a regression can start and explain its actual bind in logs. + self.available_port(8080) + self.process = subprocess.Popen( + [self.executable, "main.wfl"], cwd=self.root, + stdin=subprocess.DEVNULL, stdout=self.log, stderr=subprocess.STDOUT, + ) + deadline = time.monotonic() + STARTUP_SECONDS + last_error = "server has not responded" + while time.monotonic() < deadline: + if self.process.poll() is not None: + self.fail( + f"Server exited with {self.process.returncode} before serving " + f"port {expected_port}:\n{self.server_log()}" + ) + connection = http.client.HTTPConnection("127.0.0.1", expected_port, timeout=1) + try: + connection.request("GET", "/install") + response = connection.getresponse() + body = response.read().decode("utf-8") + except (OSError, http.client.HTTPException) as exc: + last_error = str(exc) + else: + diagnostic = self.server_log() + self.assertEqual(response.status, 200, diagnostic + "\n" + body) + self.assertIn("text/html", response.getheader("Content-Type", "")) + self.assertIn("Set up your site", body) + self.assertIn('action="/install"', body) + self.assertIn('name="csrf_token"', body) + self.assertIn("Scriptorium", body) + self.assertIn( + f"Scriptorium is running at http://127.0.0.1:{expected_port}", diagnostic + ) + self.assertIn( + f"First run — open http://127.0.0.1:{expected_port}/install", diagnostic + ) + return + finally: + connection.close() + time.sleep(0.1) + self.fail( + f"No installer response on configured port {expected_port} within " + f"{STARTUP_SECONDS}s ({last_error}). Server output:\n{self.server_log()}" + ) + + def test_configured_port_serves_installer_and_updates_startup_urls(self): + selected = self.available_port() + self.assertNotEqual(selected, 8080, "OS ephemeral range must exclude default port 8080") + self.assert_installer(selected, f"web_server_port = {selected}\n") + + def test_missing_port_setting_defaults_to_8080(self): + self.assert_installer(8080, "# web_server_port intentionally omitted\n") + + def test_missing_config_file_defaults_to_8080(self): + self.assert_installer(8080, None) + + +if __name__ == "__main__": + unittest.main()