diff --git a/rockcraft/extensions/__init__.py b/rockcraft/extensions/__init__.py index 621619b56..1815ebc35 100644 --- a/rockcraft/extensions/__init__.py +++ b/rockcraft/extensions/__init__.py @@ -17,6 +17,7 @@ """Extension processor and related utilities.""" from ._utils import apply_extensions +from .expressjs import ExpressJSFramework from .fastapi import FastAPIFramework from .go import GoFramework from .gunicorn import DjangoFramework, FlaskFramework @@ -31,6 +32,7 @@ ] register("django-framework", DjangoFramework) +register("expressjs-framework", ExpressJSFramework) register("fastapi-framework", FastAPIFramework) register("flask-framework", FlaskFramework) register("go-framework", GoFramework) diff --git a/rockcraft/extensions/expressjs.py b/rockcraft/extensions/expressjs.py new file mode 100644 index 000000000..e6db3292d --- /dev/null +++ b/rockcraft/extensions/expressjs.py @@ -0,0 +1,194 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2024 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""An extension for the NodeJS based Javascript application extension.""" +import json +import re +from typing import Any, Dict, Tuple + +from overrides import override + +from ..errors import ExtensionError +from .extension import Extension + + +class ExpressJSFramework(Extension): + """An extension for constructing Javascript applications based on the ExpressJS framework.""" + + IMAGE_BASE_DIR = "app/" + EXPRESS_GENERATOR_DIRS = [ + "bin", + "public", + "routes", + "views", + "app.js", + "package.json", + "package-lock.json", + "node_modules", + ] + RUNTIME_DEPENDENCIES = ["ca-certificates_data", "libpq5", "node"] + + @staticmethod + @override + def get_supported_bases() -> Tuple[str, ...]: + """Return supported bases.""" + return "bare", "ubuntu@22.04", "ubuntu@24.04" + + @staticmethod + @override + def is_experimental(base: str | None) -> bool: + """Check if the extension is in an experimental state.""" + return True + + @override + def get_root_snippet(self) -> Dict[str, Any]: + """Fill in some default root components. + + Default values: + - run_user: _daemon_ + - build-base: ubuntu:22.04 (only if user specify bare without a build-base) + - platform: amd64 + - services: a service to run the ExpressJS server + - parts: see ExpressJSFramework._gen_parts + """ + self._check_project() + + snippet: Dict[str, Any] = { + "run-user": "_daemon_", + "services": { + "app": { + "override": "replace", + "command": "npm start", + "startup": "enabled", + "on-success": "shutdown", + "on-failure": "shutdown", + "working-dir": "/app", + } + }, + } + + snippet["parts"] = { + "expressjs-framework/install-app": self._gen_install_app_part(), + "expressjs-framework/runtime-dependencies": self._gen_runtime_dependencies_part(), + } + return snippet + + @override + def get_part_snippet(self) -> dict[str, Any]: + """Return the part snippet to apply to existing parts. + + This is unused but is required by the ABC. + """ + return {} + + @override + def get_parts_snippet(self) -> dict[str, Any]: + """Return the parts to add to parts. + + This is unused but is required by the ABC. + """ + return {} + + def _gen_install_app_part(self) -> dict: + """Generate the install app part using NPM plugin.""" + return { + "plugin": "npm", + "npm-include-node": False, + "source": "app/", + "organise": self._app_organise, + "override-prime": f"rm -rf lib/node_modules/{self._app_name}", + } + + def _gen_runtime_dependencies_part(self) -> dict: + """Generate the install dependencies part using dump plugin.""" + return { + "plugin": "nil", + "stage-packages": self.RUNTIME_DEPENDENCIES, + } + + @property + def _app_package_json(self): + """Return the app package.json contents.""" + package_json_file = self.project_root / "package.json" + if not package_json_file.exists(): + raise ExtensionError( + "missing package.json file", + doc_slug="/reference/extensions/expressjs-framework", + logpath_report=False, + ) + package_json_contents = package_json_file.read_text(encoding="utf-8") + return json.loads(package_json_contents) + + @property + def _app_name(self) -> str: + """Return the application name as defined on package.json.""" + return self._app_package_json["name"] + + @property + def _app_organise(self): + """Return the organised mapping for the ExpressJS project. + + Use the paths generated by the + express-generator (https://expressjs.com/en/starter/generator.html) tool by default if no + user prime paths are provided. Use only user prime paths otherwise. + """ + user_prime: list[str] = ( + self.yaml_data.get("parts", {}) + .get("expressjs-framework/install-app", {}) + .get("prime", []) + ) + print(user_prime) + if not all(re.match(f"-? *{self.IMAGE_BASE_DIR}/", p) for p in user_prime): + raise ExtensionError( + "expressjs-framework extension requires the 'prime' entry in the " + f"expressjs-framework/install-app part to start with {self.IMAGE_BASE_DIR}/", + doc_slug="/reference/extensions/expressjs-framework", + logpath_report=False, + ) + if not user_prime: + user_prime = self.EXPRESS_GENERATOR_DIRS + project_relative_file_paths = [ + prime_path.removeprefix(self.IMAGE_BASE_DIR) for prime_path in user_prime + ] + lib_dir = f"lib/node_modules/{self._app_name}" + return { + f"{lib_dir}/{f}": f"app/{f}" + for f in project_relative_file_paths + if (self.project_root / "app" / f).exists() + } + + def _check_project(self): + """Ensure this extension can apply to the current rockcraft project. + + The ExpressJS framework assumes that: + - The npm start script exists. + - The application name is defined. + """ + if ( + "scripts" not in self._app_package_json + or "start" not in self._app_package_json["scripts"] + ): + raise ExtensionError( + "missing start script", + doc_slug="/reference/extensions/expressjs-framework", + logpath_report=False, + ) + if "name" not in self._app_package_json: + raise ExtensionError( + "missing application name", + doc_slug="/reference/extensions/expressjs-framework", + logpath_report=False, + ) diff --git a/tests/unit/extensions/test_expressjs.py b/tests/unit/extensions/test_expressjs.py new file mode 100644 index 000000000..ba05dc0b7 --- /dev/null +++ b/tests/unit/extensions/test_expressjs.py @@ -0,0 +1,153 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2024 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +import pytest + +from rockcraft import extensions +from rockcraft.errors import ExtensionError + + +@pytest.fixture(name="expressjs_input_yaml") +def expressjs_input_yaml_fixture(): + return { + "name": "foo-bar", + "base": "ubuntu@24.04", + "platforms": {"amd64": {}}, + "extensions": ["expressjs-framework"], + } + + +@pytest.fixture +def expressjs_extension(mock_extensions, monkeypatch): + monkeypatch.setenv("ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS", "1") + extensions.register("expressjs-framework", extensions.ExpressJSFramework) + + +@pytest.fixture +def expressjs_project_name(): + return "test-expressjs-project" + + +@pytest.fixture +def package_json_file(tmp_path, expressjs_project_name): + (tmp_path / "package.json").write_text( + f"""{{ + "name": "{expressjs_project_name}", + "scripts": {{ + "start": "node ./bin/www" + }} +}}""" + ) + + +@pytest.mark.usefixtures("expressjs_extension", "package_json_file") +def test_expressjs_extension_default( + tmp_path, expressjs_project_name, expressjs_input_yaml +): + applied = extensions.apply_extensions(tmp_path, expressjs_input_yaml) + + assert applied == { + "base": "ubuntu@24.04", + "name": "foo-bar", + "platforms": { + "amd64": {}, + }, + "run-user": "_daemon_", + "parts": { + "expressjs-framework/install-app": { + "plugin": "npm", + "npm-include-node": False, + "source": "app/", + "organise": { + f"lib/node_modules/{expressjs_project_name}/package.json": "app/package.json", + }, + "override-prime": f"rm -rf lib/node_modules/{expressjs_project_name}", + }, + "expressjs-framework/runtime-dependencies": { + "plugin": "nil", + "stage-packages": [ + "ca-certificates_data", + "libpq5", + "node", + ], + }, + }, + "services": { + "app": { + "command": "npm start", + "on-failure": "shutdown", + "on-success": "shutdown", + "override": "replace", + "startup": "enabled", + "working-dir": "/app", + }, + }, + } + + +@pytest.mark.usefixtures("expressjs_extension") +def test_expressjs_no_package_json_error(tmp_path, expressjs_input_yaml): + with pytest.raises(ExtensionError) as exc: + extensions.apply_extensions(tmp_path, expressjs_input_yaml) + assert str(exc.value) == "missing package.json file" + assert str(exc.value.doc_slug) == "/reference/extensions/expressjs-framework" + + +@pytest.mark.parametrize( + "package_json_contents, error_message", + [ + ("{}", "missing start script"), + ('{"scripts":{}}', "missing start script"), + ('{"scripts":{"start":"node ./bin/www"}}', "missing application name"), + ], +) +@pytest.mark.usefixtures("expressjs_extension") +def test_expressjs_invalid_package_json_scripts_error( + tmp_path, expressjs_input_yaml, package_json_contents, error_message +): + (tmp_path / "package.json").write_text(package_json_contents) + with pytest.raises(ExtensionError) as exc: + extensions.apply_extensions(tmp_path, expressjs_input_yaml) + assert str(exc.value) == error_message + assert str(exc.value.doc_slug) == "/reference/extensions/expressjs-framework" + + +@pytest.mark.parametrize( + "existing_files, missing_files, expected_organise", + [ + pytest.param( + ["lib/node_modules/test-expressjs-project/app.js"], + [], + {"lib/node_modules/test-expressjs-project/app.js": "app/app.js"}, + id="single file defined", + ), + ], +) +@pytest.mark.usefixtures("expressjs_extension", "package_json_file") +def test_expressjs_install_app_prime_to_organise_map( + tmp_path, expressjs_input_yaml, existing_files, missing_files, expected_organise +): + for file in existing_files: + (tmp_path / file).parent.mkdir(parents=True) + (tmp_path / file).touch() + prime_files = [*existing_files, *missing_files] + expressjs_input_yaml["parts"] = { + "expressjs-framework/install-app": {"prime": prime_files} + } + applied = extensions.apply_extensions(tmp_path, expressjs_input_yaml) + assert ( + applied["parts"]["expressjs-framework/install-app"]["organise"] + == expected_organise + )