diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3442e0ae06d3d4cc4d7fbd95948f5f36f9fbedfc..0858d8b9c6e603e4b4ae6b13dd7fe47a6be3d7b0 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -48,6 +48,7 @@ Build Images: trigger: include: - project: dom/project-templates + ref: f7997d0d file: /pipeline-templates/build-image.yml strategy: depend diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index aaf0022a368bdd22ab546f51d7de4191f5b3101b..bccbe620e5e5ff5eadbf7263f3f7c6c8b2afbf03 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,6 +41,7 @@ repos: - id: check-for-squash - id: copyright-notice exclude: ^data/|^scripts/(compile-|install-) + args: [--min-size=1000] - id: protect-first-parent - repo: https://github.com/pre-commit/pygrep-hooks @@ -93,4 +94,4 @@ repos: args: ["--python-version=3.9"] additional_dependencies: - types-requests - - behave-utils ~=0.3.2 + - behave-utils ~=0.4.1 diff --git a/doc/configuration.md b/doc/configuration.md index bfa468d9c27bb84ffff881de68bc6e7ddd00f5d1..87e6191e0c0458c04b52c432c99f9b41d1c22c20 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -39,10 +39,14 @@ to the container, however: - Unlike the way most configuration systems treat environment variables, they do not overwrite options provided in the configuration files unless - the files are specifically written to honour the environment variables. + the files are specifically written to honour the environment variables, + for instance by using the "+=" operator to append to arrays. -- For array options the value provided in the environment variable will - become the first item (index 0). +- For array options the value provided will be split on whitespace. + Unfortunately at the moment there is no way to escape or quote whitespace + within items. If you find yourself needing to add items with whitespace, + consider using configuration files instead, which are interpreted with + Bash, or one of the [convenience files](#convenience-files). Convenience Files @@ -55,6 +59,10 @@ options are optional plain text files listing additional entries (one per line) to append to the [**PLUGINS**](#plugins), [**THEMES**](#themes) and [**LANGUAGES**](#languages) option arrays respectively. +> **Note:** Every line in these files MUST be correctly terminated in the Unix +> style (with a line-feed character). Please pay special attention to the final +> line as some text editors do not correctly terminate them. + Options ------- @@ -115,7 +123,7 @@ the missing language pack will be silently ignored. **Required**: no\ **Default**: /etc/wordpress/languages.txt -The path to a file containing lines to append to +The path to a plain text file containing lines to append to [**LANGUAGES**](#languages). ### PLUGINS @@ -133,7 +141,8 @@ be the latest stable available in the wordpress.org registry. **Required**: no\ **Default**: /etc/wordpress/plugins.txt -The path to a file containing lines to append to [**PLUGINS**](#plugins). +The path to a plain text file containing lines to append to +[**PLUGINS**](#plugins). ### PHP_DIRECTIVES @@ -231,7 +240,8 @@ latest stable available in the wordpress.org registry. **Required**: no\ **Default**: /etc/wordpress/themes.txt -The path to a file containing lines to append to [**THEMES**](#themes). +The path to a plain text file containing lines to append to +[**THEMES**](#themes). ### WP_CONFIGS diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 4089fb065f0d94b483646587094c416e89053741..4e267be7217b7acf839b72bba164f87a425ba51f 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -276,6 +276,18 @@ run_background_cron() exec -a wp-cron /bin/bash <<&2 "WARNING: unterminated line in" `readlink /proc/self/fd/0` + ARRAY+=( "$line" ) + fi +} + mkdir -p ${CONFIG_DIR} cd ${CONFIG_DIR} @@ -284,13 +296,13 @@ for file in **/*.conf; do done if [[ -e ${PLUGINS_LIST:=${CONFIG_DIR}/plugins.txt} ]]; then - PLUGINS+=( $(<"${PLUGINS_LIST}") ) + readlines PLUGINS <"${PLUGINS_LIST}" fi if [[ -e ${THEMES_LIST:=${CONFIG_DIR}/themes.txt} ]]; then - THEMES+=( $(<"${THEMES_LIST}") ) + readlines THEMES <"${THEMES_LIST}" fi if [[ -e ${LANGUAGES_LIST:=${CONFIG_DIR}/languages.txt} ]]; then - LANGUAGES+=( $(<"${LANGUAGES_LIST}") ) + readlines LANGUAGES <"${LANGUAGES_LIST}" fi declare -a extra_args diff --git a/tests/configs/bad-plugins.txt b/tests/configs/bad-plugins.txt new file mode 100644 index 0000000000000000000000000000000000000000..50121a13f54ded701f045de06964c1a1fe6cb34d --- /dev/null +++ b/tests/configs/bad-plugins.txt @@ -0,0 +1 @@ +this-plugin-does-not-exist diff --git a/tests/configs/make-eggs.php b/tests/configs/make-eggs.php new file mode 100644 index 0000000000000000000000000000000000000000..37915710437cc9a9b58beaaf0bd52eb1f835e536 --- /dev/null +++ b/tests/configs/make-eggs.php @@ -0,0 +1,9 @@ +// Awesome Website of King Arthur + """ + And the email address of arthur is "the.king@kodo.org.uk" + And the password of arthur is "password1" diff --git a/tests/environment.py b/tests/environment.py index 3d839be4e963260ed4dcfbb2e20606da2aa533d4..5f2f04b915fb77adbb866600a2d7b27a7b139b5d 100644 --- a/tests/environment.py +++ b/tests/environment.py @@ -1,4 +1,4 @@ -# Copyright 2021-2022 Dominik Sekotill +# Copyright 2021-2023 Dominik Sekotill # # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this @@ -15,94 +15,42 @@ https://behave.readthedocs.io/en/stable/tutorial.html#environmental-controls from __future__ import annotations import sys -from os import environ from typing import TYPE_CHECKING -from typing import Any -from typing import Iterator -from behave import fixture from behave import use_fixture from behave.model import Feature from behave.model import Scenario from behave.runner import Context -from behave_utils import URL -from behave_utils import redirect from behave_utils.mysql import snapshot_rollback -from requests.sessions import Session -from wp import Site -from wp import test_cluster +from wp import running_site_fixture if TYPE_CHECKING: from behave.runner import FeatureContext from behave.runner import ScenarioContext -SITE_URL = URL("http://test.example.com") - def before_all(context: Context) -> None: """ - Setup fixtures for all tests + Prepare fixtures for all tests """ - context.site = use_fixture(setup_test_cluster, context, SITE_URL) + use_fixture(running_site_fixture, context) def before_feature(context: FeatureContext, feature: Feature) -> None: """ - Setup/revert fixtures before each feature + Prepare/revert fixtures before each feature """ - use_fixture(snapshot_rollback, context, context.site.database) + site = use_fixture(running_site_fixture, context) + use_fixture(snapshot_rollback, context, site.database) def before_scenario(context: ScenarioContext, scenario: Scenario) -> None: """ - Setup tools for each scenario - """ - context.session = use_fixture(requests_session, context) - - -# Todo(dom.sekotill): When PEP-612 is properly implemented in mypy the [*a, **k] and default -# values nonsense can be removed from fixtures - -@fixture -def setup_test_cluster(context: Context, /, site_url: URL|None = None, *a: Any, **k: Any) -> Iterator[Site]: - """ - Prepare and return the details of a site fixture - """ - assert site_url is not None, \ - "site_url is required, but default supplied until PEP-612 supported" - with test_cluster(site_url) as site: - yield site - - -@fixture -def requests_session(context: ScenarioContext, /, *a: Any, **k: Any) -> Iterator[Session]: - """ - Create and configure a `requests` session for accessing site fixtures - """ - site = context.site - with Session() as session: - redirect(session, site.url, site.address) - yield session - - -@fixture -def db_snapshot_rollback(context: FeatureContext, /, *a: Any, **k: Any) -> Iterator[None]: - """ - Manage the state of a site's database as a revertible fixture + Prepare tools for each scenario """ - db = context.site.database - snapshot = db.mysqldump("--all-databases", deserialiser=bytes) - yield - db.mysql(input=snapshot) - - -if __name__ == "__main__": - from subprocess import run - with test_cluster(SITE_URL) as site: - run([environ.get("SHELL", "/bin/sh")]) -elif not sys.stderr.isatty(): +if not sys.stderr.isatty(): import logging logging.basicConfig(level=logging.DEBUG, stream=sys.stderr) diff --git a/tests/requirements.txt b/tests/requirements.txt index bb41f9e7b4c6a446ef3f5b5105e5181b28bea9ac..6c2c89dc1afd81469e7f62dbdf08eaf4d5abe223 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,5 +1,6 @@ Python ~=3.9; python_version < '3.9' behave -behave-utils ~=0.3.2 +behave-utils ~=0.4.0 requests ~=2.26 +typing-extensions ~=4.0 diff --git a/tests/steps/commands.py b/tests/steps/commands.py index acd1f72327a317eef1cf51317cb93e279a3cb5f7..2664c8e2b3a71b323b5acd4baae953bb6880bff5 100644 --- a/tests/steps/commands.py +++ b/tests/steps/commands.py @@ -1,4 +1,4 @@ -# Copyright 2021-2022 Dominik Sekotill +# Copyright 2021-2023 Dominik Sekotill # # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this @@ -15,10 +15,12 @@ import shlex from typing import TYPE_CHECKING from behave import then +from behave import use_fixture from behave import when from behave_utils.behave import PatternEnum from behave_utils.behave import register_pattern from wp import Container +from wp import running_site_fixture if TYPE_CHECKING: from behave.runner import Context @@ -54,8 +56,9 @@ def run_command(context: Context, args: Arguments) -> None: """ if len(args) == 0: raise ValueError("No arguments in argument list") + site = use_fixture(running_site_fixture, context) if args[0] in ('wp', 'php'): - container: Container = context.site.backend + container: Container = site.backend else: raise ValueError(f"Unknown command: {args[0]}") context.process = container.run(args, capture_output=True) diff --git a/tests/steps/pages.py b/tests/steps/pages.py index 83756966ca7cace3455a0cc830f81e903807ea43..c240b0a030dbcf73069ce9609100165116575809 100644 --- a/tests/steps/pages.py +++ b/tests/steps/pages.py @@ -11,7 +11,6 @@ Step implementations involving creating and requesting WP posts (and pages) from __future__ import annotations from codecs import decode as utf8_decode -from typing import Any from typing import Iterator from behave import fixture @@ -25,6 +24,7 @@ from behave_utils import JSONArray from behave_utils import JSONObject from behave_utils import PatternEnum from request_steps import get_request +from wp import running_site_fixture DEFAULT_CONTENT = """ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut @@ -49,13 +49,13 @@ def assert_not_exist(context: Context, path: str) -> None: """ Assert that the path does not route to any resource """ + site = use_fixture(running_site_fixture, context) cmd = [ "post", "list", "--field=url", "--format=json", "--post_type=post,page", "--post_status=publish", ] - urls = {*context.site.backend.cli(*cmd, deserialiser=JSONArray.from_string)} - assert context.site.url / path not in urls, \ - f"{context.site.url / path} exists" + urls = {*site.backend.cli(*cmd, deserialiser=JSONArray.from_string)} + assert site.url / path not in urls, f"{site.url / path} exists" @given("a blank {post_type:PostType} exists") @@ -123,18 +123,13 @@ def assert_contains( @fixture def wp_post( context: Context, /, - post_type: PostType|None = None, + post_type: PostType, content: str = DEFAULT_CONTENT, - *a: Any, - **k: Any, ) -> Iterator[JSONObject]: """ Create a WP post fixture of the given type with the given content """ - assert post_type is not None, \ - "post_type MUST be supplied to use_fixture when calling with wp_post" - - wp = context.site.backend + wp = use_fixture(running_site_fixture, context).backend postid = wp.cli( "post", "create", f"--post_type={post_type.value}", "--post_status=publish", @@ -162,15 +157,13 @@ def set_specials( context: Context, /, homepage: JSONObject|None = None, posts: JSONObject|None = None, - *a: Any, - **k: Any, ) -> Iterator[None]: """ Set the homepage and post index to new pages, creating default pages if needed Pages are reset at the end of a scenario """ - wp = context.site.backend + wp = use_fixture(running_site_fixture, context).backend options = { opt["option_name"]: opt["option_value"] diff --git a/tests/steps/request_steps.py b/tests/steps/request_steps.py index f146f9e880ac30a70d0dbbf594001e39f55dbaa9..ae98172d0f12b14c15d9e4a48424b1c8e3f4d3c2 100644 --- a/tests/steps/request_steps.py +++ b/tests/steps/request_steps.py @@ -12,14 +12,21 @@ from __future__ import annotations import json from collections.abc import Collection +from textwrap import dedent from typing import Any +from typing import Iterator from typing import TypeVar +from behave import fixture from behave import then +from behave import use_fixture from behave import when from behave.runner import Context from behave_utils import URL from behave_utils import PatternEnum +from behave_utils.http import redirect +from requests import Session +from wp import running_site_fixture T = TypeVar("T") @@ -87,12 +94,29 @@ class ResponseCode(int, PatternEnum): attr.update(additional) +@fixture +def requests_session(context: Context, /) -> Iterator[Session]: + """ + Create and configure a `requests` session for accessing site fixtures + """ + if hasattr(context, "session"): + assert isinstance(context.session, Session) + yield context.session + return + site = use_fixture(running_site_fixture, context) + with Session() as context.session: + redirect(context.session, site.url, site.address) + yield context.session + + @when("{url:URL} is requested") def get_request(context: Context, url: URL) -> None: """ Assign the response from making a GET request to "url" to the context """ - context.response = context.session.get(context.site.url / url, allow_redirects=False) + site = use_fixture(running_site_fixture, context) + session = use_fixture(requests_session, context) + context.response = session.get(site.url / url, allow_redirects=False) @when("data is sent with {method:Method} to {url:URL}") @@ -102,9 +126,11 @@ def post_request(context: Context, method: Method, url: URL) -> None: """ if context.text is None: raise ValueError("Missing data, please add as text to step definition") - context.response = context.session.request( + site = use_fixture(running_site_fixture, context) + session = use_fixture(requests_session, context) + context.response = session.request( method.value, - context.site.url / url, + site.url / url, data=context.text.strip().format(context=context).encode("utf-8"), allow_redirects=False, ) @@ -136,7 +162,8 @@ def assert_header(context: Context, header_name: str, header_value: str) -> None Assert that an expected header was received during a previous step """ if SAMPLE_SITE_NAME in header_value: - header_value = header_value.replace(SAMPLE_SITE_NAME, context.site.url) + site = use_fixture(running_site_fixture, context) + header_value = header_value.replace(SAMPLE_SITE_NAME, site.url) headers = context.response.headers assert header_name in headers, \ f"Expected header not found in response: {header_name!r}" @@ -153,3 +180,15 @@ def assert_is_json(context: Context) -> None: context.response.json() except json.JSONDecodeError: raise AssertionError("Response is not a JSON document") + + +@then("the response body contains") +def assert_body_contains(context: Context) -> None: + """ + Assert the response body of a previous step contains the text attached to the step + """ + if context.text is None: + raise ValueError("this step needs text to check") + text = dedent(context.text).encode("utf-8") + assert text in context.response.content, \ + f"text not found in {context.response.content[:100]}" diff --git a/tests/steps/site.py b/tests/steps/site.py new file mode 100644 index 0000000000000000000000000000000000000000..4b83fb9bf13c1865cbfdd70bef6dfcd2957642b3 --- /dev/null +++ b/tests/steps/site.py @@ -0,0 +1,228 @@ +# Copyright 2023 Dominik Sekotill +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +""" +Step implementations involving setting configurations in the backend +""" + +from __future__ import annotations + +from base64 import b32encode as b32 +from collections.abc import Iterator +from pathlib import Path +from tempfile import NamedTemporaryFile + +from behave import fixture +from behave import given +from behave import then +from behave import use_fixture +from behave import when +from behave.runner import Context +from behave_utils.behave import PatternEnum +from behave_utils.behave import register_pattern +from behave_utils.docker import Cli +from behave_utils.docker import Container +from behave_utils.url import URL +from wp import CURRENT_SITE +from wp import Site +from wp import running_site_fixture +from wp import site_fixture + +CONFIG_DIR = Path(__file__).parent.parent / "configs" +DELAYED_SITE = URL("http://delayed.example.com") + + +register_pattern("\S+", Path) + + +class Addon(PatternEnum): + """ + Addon types for WP; i.e. themes or plugins + """ + + theme = "theme" + plugin = "plugin" + + +class Status(PatternEnum): + """ + Status values for `wp {plugin|theme} is-installed` commands + + The values of these enums are the expected return codes from executing one of the above + commands. + """ + + active = 0 + inactive = 1 + + +@fixture +def unstarted_site_fixture(context: Context, /, url: URL = CURRENT_SITE) -> Iterator[Site]: + """ + Return a wrapper around `wp.site_fixture` that checks the site has not been started + """ + site = use_fixture(site_fixture, context, url) + assert not site.backend.is_running(raise_on_exit=True), \ + 'Please run this step after the step "Given the site is not running"' + yield site + + +@fixture +def container_file( + context: Context, + container: Container, + path: Path, + contents: bytes, +) -> Iterator[None]: + """ + Create a file in a container as a fixture + """ + # For running containers, use commands within the container to make and delete the file + # This relies on "tee" and "rm" existing in the container image + if container.is_running(): + run = Cli(container) + run("tee", path, input=contents) + yield + run("rm", path) + return + + # For unstarted containers, write to a temporary file and add it to the volumes mapping + with NamedTemporaryFile("wb") as temp: + temp.write(contents) + temp.flush() + container.volumes.append((Path(temp.name), path)) + yield + + +@given("the site is not running") +def unstarted_site(context: Context) -> None: + """ + Mask any current site with a new, unstarted one for the rest of a feature or scenario + """ + uid = b32(id(context.feature).to_bytes(10, "big")).decode("ascii") + use_fixture(site_fixture, context, url=URL(f"http://{uid}.delayed.example.com")) + + +@given("{fixture:Path} is mounted in {directory:Path}") +@given("{fixture:Path} is mounted in {directory:Path} as {name}") +def mount_volume( + context: Context, + fixture: Path, + directory: Path, + name: str|None = None, +) -> None: + """ + Prepare volume mounts in the backend + """ + fixture = CONFIG_DIR / fixture + if not fixture.exists(): + raise FileNotFoundError(fixture) + if fixture.is_dir(): + raise IsADirectoryError(fixture) + if name is None: + name = fixture.name + + site = use_fixture(unstarted_site_fixture, context, CURRENT_SITE) + site.backend.volumes.append((fixture.absolute(), directory / name)) + + +@given("{path:Path} exists in the {container_name}") +def create_file(context: Context, path: Path, container_name: str) -> None: + """ + Create a file in the named container + """ + site = use_fixture(site_fixture, context) + container = getattr(site, container_name) + content = context.text.encode("utf-8") if context.text else b"This is a data file!" + use_fixture(container_file, context, container, path, content) + + +@given("{path:Path} contains") +def write_file(context: Context, path: Path) -> None: + """ + Write the contents of the step's text string to a fixture Path + """ + if context.text is None: + raise ValueError("A text value is needed for this step") + # If creating a file in /etc/wordpress there is not much point unless the site is + # unstarted, so use unstarted_site_fixture to ensure it's checked + site = use_fixture( + unstarted_site_fixture if Path("/etc/wordpress") in path.parents else site_fixture, + context, + ) + content = context.text.encode("utf-8") + b"\n" + use_fixture(container_file, context, site.backend, path, content) + + +@given("the environment variable {name} is \"{value}\"") +def set_environment(context: Context, name: str, value: str) -> None: + """ + Set the named environment variable in the backend + """ + site = use_fixture(unstarted_site_fixture, context, CURRENT_SITE) + site.backend.env[name] = value + + +@when("the site is started") +def start_backend(context: Context) -> None: + """ + Start the site backend (FPM) + """ + use_fixture(running_site_fixture, context, CURRENT_SITE) + + +@then("the {addon:Addon} {name} is installed") +@then("the {addon:Addon} {name} is {status:Status}") +def is_plugin_installed( + context: Context, + addon: Addon, + name: str, + status: Status|None = None, +) -> None: + """ + Check that the named theme or plugin is installed + """ + site = use_fixture(site_fixture, context, CURRENT_SITE) + assert site.backend.cli(addon.value, "is-installed", name, query=True) == 0, \ + f"{addon.name} {name} is not installed" + if status is not None: + assert site.backend.cli(addon.value, "is-active", name, query=True) == status.value + + +@then("the email address of {user} is \"{value}\"") +@then("the email address of {user} is '{value}'") +def is_user_email(context: Context, user: str, value: str) -> None: + """ + Check that the email address of an existing user matches the given value + """ + site = use_fixture(site_fixture, context, CURRENT_SITE) + email = site.backend.cli( + "user", "get", user, "--field=email", + deserialiser=lambda mv: str(mv, "utf-8").strip(), + ) + assert email == value, f"user's email {email} != {value}" + + +@then("the password of {user} is \"{value}\"") +@then("the password of {user} is '{value}'") +def is_user_password(context: Context, user: str, value: str) -> None: + """ + Check that the password of an existing user matches the given value + """ + site = use_fixture(site_fixture, context, CURRENT_SITE) + assert site.backend.cli("user", "check-password", user, value, query=True) == 0, \ + "passwords do not match" + + +@then("the password of {user} is not \"{value}\"") +@then("the password of {user} is not '{value}'") +def is_not_user_password(context: Context, user: str, value: str) -> None: + """ + Check that the password of an existing user does not match the given value + """ + site = use_fixture(site_fixture, context, CURRENT_SITE) + assert site.backend.cli("user", "check-password", user, value, query=True) != 0, \ + "passwords match" diff --git a/tests/steps/static.py b/tests/steps/static.py deleted file mode 100644 index 76ce819d90a4b0c15cf8ae6190f8e793161c28f1..0000000000000000000000000000000000000000 --- a/tests/steps/static.py +++ /dev/null @@ -1,39 +0,0 @@ -# Copyright 2022-2023 Dominik Sekotill -# -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. - -""" -Step implementations involving creating files in containers -""" - -from __future__ import annotations - -from typing import Iterator - -from behave import fixture -from behave import given -from behave import use_fixture -from behave.runner import Context -from behave_utils.docker import Cli - - -@given("{path} exists in the {container}") -def step_impl(context: Context, path: str, container: str) -> None: - """ - Create a file in the named container - """ - use_fixture(container_file, context, path, container) - - -@fixture -def container_file(context: Context, path: str, container_name: str) -> Iterator[None]: - """ - Create a file in a named container as a fixture - """ - container = getattr(context.site, container_name) - run = Cli(container) - run("tee", path, input=(context.text or "This is a data file!")) - yield - run("rm", path) diff --git a/tests/wp.py b/tests/wp.py index efc01b3e8f99751265d7c8c76891a7bb7ef77def..e0542a39cfdc44dad495c35700bae7056590bc48 100644 --- a/tests/wp.py +++ b/tests/wp.py @@ -1,4 +1,4 @@ -# Copyright 2021-2022 Dominik Sekotill +# Copyright 2021-2023 Dominik Sekotill # # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this @@ -14,8 +14,10 @@ from contextlib import contextmanager from os import environ from pathlib import Path from typing import Iterator -from typing import NamedTuple +from behave import fixture +from behave import use_fixture +from behave.runner import Context from behave_utils import URL from behave_utils import wait from behave_utils.docker import Cli @@ -23,9 +25,13 @@ from behave_utils.docker import Container as Container from behave_utils.docker import Image from behave_utils.docker import IPv4Address from behave_utils.docker import Network +from behave_utils.docker import inspect from behave_utils.mysql import Mysql +from typing_extensions import Self BUILD_CONTEXT = Path(__file__).parent.parent +DEFAULT_URL = URL("http://test.example.com") +CURRENT_SITE = URL("current://") class Wordpress(Container): @@ -49,7 +55,6 @@ class Wordpress(Container): ], env=dict( SITE_URL=site_url, - SITE_ADMIN_EMAIL="test@kodo.org.uk", DB_NAME=database.name, DB_USER=database.user, DB_PASS=database.password, @@ -66,11 +71,14 @@ class Wordpress(Container): return Cli(self, "wp") @contextmanager - def started(self) -> Iterator[Container]: + def started(self) -> Iterator[Self]: + """ + Return a context in which the container is guaranteed to be started and running + """ with self: self.start() cmd = ["bash", "-c", "[[ /proc/1/exe -ef `which php-fpm` ]]"] - wait(lambda: self.is_running() and self.run(cmd).returncode == 0, timeout=600) + wait(lambda: self.run(cmd).returncode == 0, timeout=600) yield self @@ -92,35 +100,111 @@ class Nginx(Container): ) -class Site(NamedTuple): +class Site: """ - A named-tuple of information about the containers for a site fixture + Manage all the containers of a site fixture """ - url: str - address: IPv4Address - frontend: Nginx - backend: Wordpress - database: Mysql + def __init__( + self, + url: URL, + network: Network, + frontend: Nginx, + backend: Wordpress, + database: Mysql, + ): + self.url = url + self.network = network + self.frontend = frontend + self.backend = backend + self.database = database + self._address: IPv4Address|None = None + self._running = False + + @classmethod + @contextmanager + def build(cls, site_url: URL) -> Iterator[Self]: + """ + Return a context that constructs a ready-to-go instance on entry + """ + with ( + Network() as network, + Mysql(network=network) as database, + Wordpress(site_url, database, network=network) as backend, + Nginx(backend, network=network) as frontend, + ): + yield cls(site_url, network, frontend, backend, database) + @contextmanager + def running(self) -> Iterator[Self]: + """ + Return a context in which all containers are guaranteed to be started and running + """ + if self._running: + yield self + return + self._running = True + with self.backend.started(), self.frontend.started(): + try: + yield self + finally: + self._running = False + self._address = None -@contextmanager -def test_cluster(site_url: URL) -> Iterator[Site]: - """ - Configure and start all the necessary containers for use as test fixtures - """ - test_dir = Path(__file__).parent - db_init = test_dir / "mysql-init.sql" - - with Network() as network: - database = Mysql(network=network, init_files=[db_init]) - database.start() # Get a head start on initialising the database - backend = Wordpress(site_url, database, network=network) - frontend = Nginx(backend, network=network) - - with database.started(), backend.started(), frontend.started(): - addr = frontend.inspect().path( - f"$.NetworkSettings.Networks.{network}.IPAddress", + @property + def address(self) -> IPv4Address: + """ + Return an IPv4 address through which test code can access the site + """ + if self._address is None: + if not self.frontend.is_running(): + raise RuntimeError( + "Site.address may only be accessed inside a Site.running() context", + ) + self._address = inspect(self.frontend).path( + f"$.NetworkSettings.Networks.{self.network}.IPAddress", str, IPv4Address, ) - yield Site(site_url, addr, frontend, backend, database) + return self._address + + +@fixture +def site_fixture(context: Context, /, url: URL = CURRENT_SITE) -> Iterator[Site]: + """ + Return a currently in-scope Site instance when used with `use_fixture` + + If "url" is provided and it doesn't match a current Site instance, a new instance + will be created in the current context. + + >>> use_fixture(site_fixture, context) + <<< + """ + if not hasattr(context, "sites"): + context.sites = dict[URL, Site]() + assert len(context.sites) == 0 or \ + len(context.sites) >= 2 and CURRENT_SITE in context.sites, \ + f'Both ["url" or DEFAULT_URL] and [CURRENT_SITE] must be added to sites: ' \ + f'{context.sites!r}' + if url in context.sites: + yield context.sites[url] + return + url = DEFAULT_URL if url == CURRENT_SITE else url + prev = context.sites.get(CURRENT_SITE) + with Site.build(url) as context.sites[url]: + context.sites[CURRENT_SITE] = context.sites[url] + yield context.sites[url] + del context.sites[url] + del context.sites[CURRENT_SITE] + if prev: + context.sites[CURRENT_SITE] = prev + + +@fixture +def running_site_fixture(context: Context, /, url: URL = CURRENT_SITE) -> Iterator[Site]: + """ + Return a currently in-scope Site instance that is running when used with `use_fixture` + + Like `site_fixture` but additionally entered into the `Site.running` context manager. + """ + with use_fixture(site_fixture, context, url=url).running() as site: + yield site