diff --git a/docs/how-to/configure-the-build-base-for-an-expressjs-app.rst b/docs/how-to/configure-the-build-base-for-an-expressjs-app.rst new file mode 100644 index 000000000..a0373289f --- /dev/null +++ b/docs/how-to/configure-the-build-base-for-an-expressjs-app.rst @@ -0,0 +1,83 @@ +.. _configure-the-build-base-for-an-expressjs-app: + +Configure the build base for an ExpressJS app +============================================= + +When using the ``expressjs-framework`` extension, there are four different cases +for customising the Ubuntu base and the Node.js version to be included. +The main differences between the cases are: + +- Whether to use the bare base or Ubuntu 24.04 base. +- Whether Node.js is installed from Ubuntu package archive or the NPM plugin. + +The remainder of this page discusses the combinations of +Ubuntu bases and sources for Node.js. + +Ubuntu 24.04 base, Node.js from Ubuntu package archive +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: yaml + :caption: rockcraft.yaml + + base: ubuntu@24.04 + extensions: + - expressjs-framework + +In this case, the NPM plugin copies the app's files and installs its +dependencies. Node.js then provides a runtime and launches the app. The NPM and +Node.js versions are determined by the versions of NPM and Node.js shipped with +the Ubuntu base. This `link to the Ubuntu archive +`_ shows the NPM version shipped with the +Ubuntu 24.04 base. + +Ubuntu 24.04 base, Node.js from NPM plugin +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: yaml + :caption: rockcraft.yaml + + base: ubuntu@24.04 + extensions: + - expressjs-framework + parts: + expressjs-framework/install-app: + npm-include-node: true + npm-node-version: 20.12 + +In this case, the NPM plugin copies the app's files and installs its +dependencies. Node.js and NPM is installed by the NPM plugin. + +Bare base, Node.js from Ubuntu package archive +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: yaml + :caption: rockcraft.yaml + + base: bare + build-base: ubuntu@24.04 + extensions: + - expressjs-framework + +In this case, the NPM plugin copies the app's files and installs its +dependencies. Node.js then provides a runtime and launches the app. The NPM and +Node.js versions are determined by the versions of NPM and Node.js shipped with +the Ubuntu base. + +Bare base, Node.js from NPM plugin +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: yaml + :caption: rockcraft.yaml + + base: bare + build-base: ubuntu@24.04 + parts: + expressjs-framework/install-app: + npm-include-node: true + npm-node-version: 20.12 + +In this case, the NPM plugin copies the app's files and installs its +dependencies. Node.js then provides a runtime and launches the app. Node.js and +NPM is installed by the NPM plugin. For different possible values for the +``npm-node-version`` key, refer to +:ref:`NPM plugin documentation `. diff --git a/docs/how-to/index.rst b/docs/how-to/index.rst index b20dbafdc..49e1d37e8 100644 --- a/docs/how-to/index.rst +++ b/docs/how-to/index.rst @@ -40,6 +40,16 @@ compiled a couple of guides for you to facilitate your contribution. documentation +Extensions +---------- + +Learn how to use framework and platform extensions in your project. + +.. toctree:: + :maxdepth: 1 + + configure-the-build-base-for-an-expressjs-app + Rocks ----- diff --git a/docs/reference/extensions/expressjs-framework.rst b/docs/reference/extensions/expressjs-framework.rst new file mode 100644 index 000000000..43bcda053 --- /dev/null +++ b/docs/reference/extensions/expressjs-framework.rst @@ -0,0 +1,54 @@ +.. _expressjs-framework-reference: + +expressjs-framework +------------------- + +The ExpressJS extension streamlines the process of building ExpressJS +application rocks. + +It facilitates the installation of ExpressJS application dependencies, including +Node.js and NPM, inside the rock. Additionally, it transfers your project files +to ``/app`` within the rock. + +The ExpressJS extension is compatible with the ``bare`` and ``ubuntu@24.04`` +bases. + +Project requirements +==================== + +There are 3 requirements to be able to use the ``expressjs-framework`` +extension: + +1. The application should reside in the ``app`` directory. +2. The application should have a ``package.json`` file. +3. The ``package.json`` file should define the ``start`` script. + + +``parts`` > ``expressjs-framework/install-app`` > ``npm-include-node`` +====================================================================== + +You can use this field to specify the version of Node.js to be installed. For +example: + +.. code-block:: yaml + + parts: + expressjs-framework/install-app: + npm-include-node: true + npm-node-version: 20.12.2 + +For more examples of the ``npm-node-version`` key, see +:ref:`craft_parts_npm_plugin`. + +If you don't customise the version of node, it will be installed from the Ubuntu +package repository. + +``parts`` > ``expressjs-framework/runtime:`` > ``stage-packages`` +================================================================= + +Installing additional runtime packages is currently unsupported. + +See also +======== + +- :ref:`build-a-rock-for-an-expressjs-application` diff --git a/docs/reference/extensions/index.rst b/docs/reference/extensions/index.rst index 3bf8ae6c6..73a46dcce 100644 --- a/docs/reference/extensions/index.rst +++ b/docs/reference/extensions/index.rst @@ -15,3 +15,4 @@ initiating a new rock. django-framework fastapi-framework go-framework + expressjs-framework diff --git a/docs/reference/rockcraft.yaml.rst b/docs/reference/rockcraft.yaml.rst index b55a79ec8..8a41a9baa 100644 --- a/docs/reference/rockcraft.yaml.rst +++ b/docs/reference/rockcraft.yaml.rst @@ -268,6 +268,7 @@ Currently supported extensions: - ``django-framework`` - ``go-framework`` - ``fastapi-framework`` +- ``expressjs-framework`` Example ======= diff --git a/docs/tutorial/code/django/task.yaml b/docs/tutorial/code/django/task.yaml index 265517ecc..5fb42fb6f 100644 --- a/docs/tutorial/code/django/task.yaml +++ b/docs/tutorial/code/django/task.yaml @@ -26,7 +26,7 @@ execute: | retry -n 5 --wait 2 curl --fail localhost:8000 # [docs:curl-django] - curl localhost:8000 + curl --fail localhost:8000 # [docs:curl-django-end] kill $! @@ -62,7 +62,7 @@ execute: | retry -n 5 --wait 2 curl --fail localhost:8000 # [docs:curl-django-rock] - curl localhost:8000 + curl --fail localhost:8000 # [docs:curl-django-rock-end] # [docs:get-logs] @@ -100,7 +100,7 @@ execute: | retry -n 5 --wait 2 curl --fail localhost:8000 # [docs:curl-django-bare-rock] - curl localhost:8000 + curl --fail localhost:8000 # [docs:curl-django-bare-rock-end] # [docs:stop-docker-chisel] @@ -131,7 +131,7 @@ execute: | retry -n 5 --wait 2 curl --fail localhost:8000/time/ # [docs:curl-time] - curl localhost:8000/time/ + curl --fail localhost:8000/time/ # [docs:curl-time-end] # [docs:stop-docker-updated] diff --git a/docs/tutorial/code/expressjs/task.yaml b/docs/tutorial/code/expressjs/task.yaml new file mode 100644 index 000000000..c1c2bd151 --- /dev/null +++ b/docs/tutorial/code/expressjs/task.yaml @@ -0,0 +1,117 @@ +########################################### +# IMPORTANT +# Comments matter! +# The docs use the wrapping comments as +# markers for including said instructions +# as snippets in the docs. +########################################### +summary: Getting started with ExpressJS tutorial + +environment: + +execute: | + # [docs:install-deps] + sudo apt-get update -y && sudo apt-get install npm -y + # [docs:install-deps-end] + + # [docs:init-app] + sudo npm install -g express-generator@4 + express app + cd app && npm install + # [docs:init-app-end] + + npm start & + cd .. + retry -n 5 --wait 2 curl --fail localhost:3000 + + # [docs:curl-expressjs] + curl --fail localhost:3000 + # [docs:curl-expressjs-end] + + kill $! + kill $(lsof -t -i:3000) + + # [docs:create-rockcraft-yaml] + rockcraft init --profile expressjs-framework + # [docs:create-rockcraft-yaml-end] + + sed -i "s/name: .*/name: expressjs-hello-world/g" rockcraft.yaml + + # [docs:experimental] + export ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS=true + # [docs:experimental-end] + + # [docs:pack] + rockcraft pack + # [docs:pack-end] + + # [docs:ls-rock] + ls *.rock -l --block-size=MB + # [docs:ls-rock-end] + + # [docs:skopeo-copy] + sudo rockcraft.skopeo --insecure-policy \ + copy oci-archive:expressjs-hello-world_0.1_amd64.rock \ + docker-daemon:expressjs-hello-world:0.1 + # [docs:skopeo-copy-end] + + # [docs:docker-images] + sudo docker images expressjs-hello-world:0.1 + # [docs:docker-images-end] + + # [docs:docker-run] + sudo docker run --rm -d -p 3000:3000 \ + --name expressjs-hello-world expressjs-hello-world:0.1 + # [docs:docker-run-end] + retry -n 5 --wait 2 curl --fail localhost:3000 + + # [docs:curl-expressjs-rock] + curl --fail localhost:3000 + # [docs:curl-expressjs-rock-end] + + # [docs:get-logs] + sudo docker exec expressjs-hello-world pebble logs expressjs + # [docs:get-logs-end] + + # [docs:stop-docker] + sudo docker stop expressjs-hello-world + sudo docker rmi expressjs-hello-world:0.1 + # [docs:stop-docker-end] + + mv time.js app/routes/ + original_line="var app = express();" + # Remove comments from time_app.js + sed -i 's:^//.*::g' time_app.js + append_line="$(cat time_app.js | tr '\n' ' ')" + sed -i "/$original_line/a$append_line" app/app.js + sed -i "s/version: .*/version: 0.2/g" rockcraft.yaml + + # [docs:docker-run-update] + rockcraft pack + sudo rockcraft.skopeo --insecure-policy \ + copy oci-archive:expressjs-hello-world_0.2_amd64.rock \ + docker-daemon:expressjs-hello-world:0.2 + sudo docker images expressjs-hello-world:0.2 + sudo docker run --rm -d -p 3000:3000 \ + --name expressjs-hello-world expressjs-hello-world:0.2 + # [docs:docker-run-update-end] + retry -n 5 --wait 2 curl --fail localhost:3000/time + + # [docs:curl-time] + curl --fail localhost:3000/time + # [docs:curl-time-end] + + # [docs:stop-docker-updated] + sudo docker stop expressjs-hello-world + sudo docker rmi expressjs-hello-world:0.2 + # [docs:stop-docker-updated-end] + + # [docs:cleanup] + # delete all the files created during the tutorial + sudo npm uninstall -g express-generator@4 + sudo apt-get remove npm -y + rm -rf app + rm expressjs-hello-world_0.1_amd64.rock \ + expressjs-hello-world_0.2_amd64.rock \ + rockcraft.yaml + # [docs:cleanup-end] diff --git a/docs/tutorial/code/expressjs/time.js b/docs/tutorial/code/expressjs/time.js new file mode 100644 index 000000000..eeb7c7ddd --- /dev/null +++ b/docs/tutorial/code/expressjs/time.js @@ -0,0 +1,8 @@ +var express = require('express'); +var router = express.Router(); + +router.get('/', function (req, res, next) { + res.send(Date()); +}); + +module.exports = router; diff --git a/docs/tutorial/code/expressjs/time_app.js b/docs/tutorial/code/expressjs/time_app.js new file mode 100644 index 000000000..907026608 --- /dev/null +++ b/docs/tutorial/code/expressjs/time_app.js @@ -0,0 +1,4 @@ +// [docs:append-lines] +var timeRouter = require('./routes/time'); +app.use('/time', timeRouter); +// [docs:append-lines-end] diff --git a/docs/tutorial/code/fastapi/task.yaml b/docs/tutorial/code/fastapi/task.yaml index 4ef8fb497..53381ee8c 100644 --- a/docs/tutorial/code/fastapi/task.yaml +++ b/docs/tutorial/code/fastapi/task.yaml @@ -21,7 +21,7 @@ execute: | retry -n 5 --wait 2 curl --fail localhost:8000 # [docs:curl-fastapi] - curl localhost:8000 + curl --fail localhost:8000 # [docs:curl-fastapi-end] kill $! @@ -56,7 +56,7 @@ execute: | retry -n 5 --wait 2 curl --fail localhost:8000 # [docs:curl-fastapi-rock] - curl localhost:8000 + curl --fail localhost:8000 # [docs:curl-fastapi-rock-end] # [docs:get-logs] @@ -94,7 +94,7 @@ execute: | retry -n 5 --wait 2 curl --fail localhost:8000 # [docs:curl-fastapi-bare-rock] - curl localhost:8000 + curl --fail localhost:8000 # [docs:curl-fastapi-bare-rock-end] # [docs:stop-docker-chisel] @@ -117,7 +117,7 @@ execute: | retry -n 5 --wait 2 curl --fail localhost:8000/time # [docs:curl-time] - curl localhost:8000/time + curl --fail localhost:8000/time # [docs:curl-time-end] # [docs:stop-docker-updated] diff --git a/docs/tutorial/code/flask/task.yaml b/docs/tutorial/code/flask/task.yaml index dd54cd2dd..bfa4299ec 100644 --- a/docs/tutorial/code/flask/task.yaml +++ b/docs/tutorial/code/flask/task.yaml @@ -21,7 +21,7 @@ execute: | retry -n 5 --wait 2 curl --fail localhost:8000 # [docs:curl-flask] - curl localhost:8000 + curl --fail localhost:8000 # [docs:curl-flask-end] kill $! @@ -56,7 +56,7 @@ execute: | retry -n 5 --wait 2 curl --fail localhost:8000 # [docs:curl-flask-rock] - curl localhost:8000 + curl --fail localhost:8000 # [docs:curl-flask-rock-end] # [docs:get-logs] @@ -94,7 +94,7 @@ execute: | retry -n 5 --wait 2 curl --fail localhost:8000 # [docs:curl-flask-bare-rock] - curl localhost:8000 + curl --fail localhost:8000 # [docs:curl-flask-bare-rock-end] # [docs:stop-docker-chisel] @@ -118,7 +118,7 @@ execute: | retry -n 5 --wait 2 curl --fail localhost:8000/time # [docs:curl-time] - curl localhost:8000/time + curl --fail localhost:8000/time # [docs:curl-time-end] # [docs:stop-docker-updated] diff --git a/docs/tutorial/django.rst b/docs/tutorial/django.rst index 3aeac3526..bdd621b50 100644 --- a/docs/tutorial/django.rst +++ b/docs/tutorial/django.rst @@ -154,8 +154,7 @@ Check that the image was successfully loaded into Docker: The output should list the Django container image, along with its tag, ID and size: -.. code-block:: text - :class: log-snippets +.. terminal:: REPOSITORY TAG IMAGE ID CREATED SIZE django-hello-world 0.1 5cd019b51db9 6 days ago 184MB @@ -188,7 +187,7 @@ View the application logs ~~~~~~~~~~~~~~~~~~~~~~~~~ When deploying the Django rock, we can always get the application logs via -``pebble``: +:ref:`pebble_explanation_page`: .. literalinclude:: code/django/task.yaml :language: text @@ -196,12 +195,11 @@ When deploying the Django rock, we can always get the application logs via :end-before: [docs:get-logs-end] :dedent: 2 -As a result, :ref:`pebble_explanation_page` will give us the logs for the +As a result, Pebble will give us the logs for the ``django`` service running inside the container. We should expect to see something similar to this: -.. code-block:: text - :class: log-snippets +.. terminal:: 2024-08-20T06:34:36.114Z [django] [2024-08-20 06:34:36 +0000] [17] [INFO] Starting gunicorn 23.0.0 2024-08-20T06:34:36.115Z [django] [2024-08-20 06:34:36 +0000] [17] [INFO] Listening at: http://0.0.0.0:8000 (17) @@ -288,7 +286,7 @@ and then using the same ``curl`` request: :end-before: [docs:curl-django-bare-rock-end] :dedent: 2 -Unsurprisingly, the Django application should still respond with +The Django application should still respond with ``The install worked successfully! Congratulations!``. Cleanup diff --git a/docs/tutorial/expressjs.rst b/docs/tutorial/expressjs.rst new file mode 100644 index 000000000..3837ef9c8 --- /dev/null +++ b/docs/tutorial/expressjs.rst @@ -0,0 +1,334 @@ +.. _build-a-rock-for-an-expressjs-application: + +Build a rock for an ExpressJS application +----------------------------------------- + +In this tutorial, we'll containerise a simple ExpressJS application into a rock +using Rockcraft's ``expressjs-framework`` :ref:`extension +`. + +It should take 25 minutes for you to complete. + +You won’t need to come prepared with intricate knowledge of software +packaging, but familiarity with Linux paradigms, terminal operations, +and ExpressJS is required. + +Once you complete this tutorial, you’ll have a working rock for an ExpressJS +application. You’ll gain familiarity with Rockcraft and the +``expressjs-framework`` extension, and have the experience to create +rocks for ExpressJS applications. + +Setup +===== + +.. include:: /reuse/tutorial/setup_edge.rst + +This tutorial requires the ``latest/edge`` channel of Rockcraft. Run +``sudo snap refresh rockcraft --channel latest/edge`` to get the latest +edge version. + +In order to test the ExpressJS application locally, before packing it into a +rock, install ``npm`` and initialize the starter app. + +.. literalinclude:: code/expressjs/task.yaml + :language: bash + :start-after: [docs:install-deps] + :end-before: [docs:install-deps-end] + :dedent: 2 + + +Create the ExpressJS application +================================ + +Start by generating the ExpressJS starter template using the express-generator. + +.. literalinclude:: code/expressjs/task.yaml + :language: bash + :start-after: [docs:init-app] + :end-before: [docs:init-app-end] + :dedent: 2 + +Let's Run the Express application to verify that it works: + +.. code:: bash + + npm start + +The application starts an HTTP server listening on port 3000 +that we can test by using curl to send a request to the root +endpoint. We may need a new terminal for this -- if using Multipass, run +``multipass shell rock-dev`` to get another terminal: + +.. literalinclude:: code/expressjs/task.yaml + :language: bash + :start-after: [docs:curl-expressjs] + :end-before: [docs:curl-expressjs-end] + :dedent: 2 + +The ExpressJS application should respond with *Welcome to Express* web page. + +.. note:: + The response from the ExpressJS application includes HTML and CSS which + makes it difficult to read on a terminal. Visit ``http://localhost:3000`` + using a browser to see the fully rendered page. + +The ExpressJS application looks good, so let's stop it for now +with :kbd:`Ctrl` + :kbd:`C`, then move out of the application directory +``cd ..``. + +Pack the Express application into a rock +======================================== + +First, we'll need a ``rockcraft.yaml`` project file. Rockcraft will automate its +creation and tailor it for a ExpressJS application when we tell it to use the +``expressjs-framework`` profile: + +.. literalinclude:: code/expressjs/task.yaml + :language: bash + :start-after: [docs:create-rockcraft-yaml] + :end-before: [docs:create-rockcraft-yaml-end] + :dedent: 2 + +Open ``rockcraft.yaml`` in a text editor and check that the ``name`` +key is set to ``expressjs-hello-world``. Ensure that ``platforms`` includes +the architecture of the host. For example, if the host uses the ARM +architecture, include ``arm64`` in ``platforms``. + +As the ``expressjs-framework`` extension is still experimental, export the +environment variable ``ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS``: + +.. literalinclude:: code/expressjs/task.yaml + :language: bash + :start-after: [docs:experimental] + :end-before: [docs:experimental-end] + :dedent: 2 + +Pack the rock: + +.. literalinclude:: code/expressjs/task.yaml + :language: bash + :start-after: [docs:pack] + :end-before: [docs:pack-end] + :dedent: 2 + +Depending on the network, this step can take a couple of minutes to finish. + +Once Rockcraft has finished packing the ExpressJS rock, we'll find a new file in +the working directory (an `OCI `_ image) with the ``.rock`` +extension: + +.. literalinclude:: code/expressjs/task.yaml + :language: bash + :start-after: [docs:ls-rock] + :end-before: [docs:ls-rock-end] + :dedent: 2 + + +Run the ExpressJS rock with Docker +================================== + +We already have the rock as an OCI image. Load the image into Docker: + +.. literalinclude:: code/expressjs/task.yaml + :language: bash + :start-after: [docs:skopeo-copy] + :end-before: [docs:skopeo-copy-end] + :dedent: 2 + + +Check that the image was successfully loaded into Docker: + +.. literalinclude:: code/expressjs/task.yaml + :language: bash + :start-after: [docs:docker-images] + :end-before: [docs:docker-images-end] + :dedent: 2 + +The output should list the ExpressJS image, along with its tag, ID and +size: + +.. terminal:: + + REPOSITORY TAG IMAGE ID CREATED SIZE + expressjs-hello-world 0.1 30c7e5aed202 2 weeks ago 304MB + +Now we're finally ready to run the rock and test the containerised ExpressJS +application: + +.. literalinclude:: code/expressjs/task.yaml + :language: bash + :start-after: [docs:docker-run] + :end-before: [docs:docker-run-end] + :dedent: 2 + +Use the same curl command as before to send a request to the ExpressJS +application's root endpoint which is running inside the container: + +.. literalinclude:: code/expressjs/task.yaml + :language: bash + :start-after: [docs:curl-expressjs-rock] + :end-before: [docs:curl-expressjs-rock-end] + :dedent: 2 + +The ExpressJS application again responds with *Welcome to Express* page. + +View the application logs +~~~~~~~~~~~~~~~~~~~~~~~~~ + +When deploying the ExpressJS rock, we can always get the application logs with +:ref:`pebble_explanation_page`: + +.. literalinclude:: code/expressjs/task.yaml + :language: bash + :start-after: [docs:get-logs] + :end-before: [docs:get-logs-end] + :dedent: 2 + +As a result, Pebble will give us the logs for the +``expressjs`` service running inside the container. +We should expect to see something similar to this: + +.. terminal:: + + app@0.0.0 start + node ./bin/www + GET / 200 62.934 ms - 170 + +We can also choose to follow the logs by using the ``-f`` option with the +``pebble logs`` command above. To stop following the logs, press :kbd:`Ctrl` + +:kbd:`C`. + + +Stop the application +~~~~~~~~~~~~~~~~~~~~ + +Now we have a fully functional rock for a ExpressJS application! This concludes +the first part of this tutorial, so we'll stop the container and remove the +respective image for now: + +.. literalinclude:: code/expressjs/task.yaml + :language: bash + :start-after: [docs:stop-docker] + :end-before: [docs:stop-docker-end] + :dedent: 2 + + +Update the ExpressJS application +================================ + +For our final task, let's update our application. As an example, +let's add a new ``/time`` endpoint that returns the current time. + +Start by creating the ``app/routes/time.js`` file in a text editor and paste the +code from the snippet below: + +.. literalinclude:: code/expressjs/time.js + :caption: time.js + :language: javascript + +Place the code snippet below in ``app/app.js`` under routes registration section +along with other ``app.use(...)`` lines. +It will register the new ``/time`` endpoint: + +.. literalinclude:: code/expressjs/time_app.js + :caption: app.js + :language: javascript + :start-after: [docs:append-lines] + :end-before: [docs:append-lines-end] + +Since we are creating a new version of the application, set +``version: '0.2'`` in the project file. + +Pack and run the rock using similar commands as before: + +.. literalinclude:: code/expressjs/task.yaml + :language: bash + :start-after: [docs:docker-run-update] + :end-before: [docs:docker-run-update-end] + :dedent: 2 + +The resulting ``.rock`` file will be named differently, as +its new version will be part of the filename. + +Finally, use curl to send a request to the ``/time`` endpoint: + +.. literalinclude:: code/expressjs/task.yaml + :language: bash + :start-after: [docs:curl-time] + :end-before: [docs:curl-time-end] + :dedent: 2 + +The updated application should respond with the current date and time (e.g. +``Fri Jan 10 2025 03:11:44 GMT+0000 (Coordinated Universal Time)``). + +.. tip:: + + If you are getting a ``404`` for the ``/time`` endpoint, check the + :ref:`troubleshooting-expressjs` steps below. + +Final Cleanup +~~~~~~~~~~~~~ + +We can now stop the container and remove the corresponding image: + +.. literalinclude:: code/expressjs/task.yaml + :language: bash + :start-after: [docs:stop-docker-updated] + :end-before: [docs:stop-docker-updated-end] + :dedent: 2 + +Reset the environment +===================== + +We've reached the end of this tutorial. + +If we'd like to reset the working environment, we can simply run the +following: + +.. literalinclude:: code/expressjs/task.yaml + :language: bash + :start-after: [docs:cleanup] + :end-before: [docs:cleanup-end] + :dedent: 2 + +.. collapse:: If using Multipass... + + If we created an instance using Multipass, we can also clean it up. + Start by exiting it: + + .. code-block:: bash + + exit + + And then we can proceed with its deletion: + + .. code-block:: bash + + multipass delete rock-dev + multipass purge + +---- + +Next steps +========== + +* :ref:`Rockcraft tutorials`. +* :ref:`expressjs-framework reference`. +* :ref:`why_use_rockcraft`. +* :ref:`What is a Rock?`. + +---- + +.. _troubleshooting-expressjs: + +Troubleshooting +=============== + +**Application updates not taking effect?** + +Upon changing the ExpressJS application and re-packing the rock, if +the changes are not taking effect, try running ``rockcraft clean`` and pack +the rock again with ``rockcraft pack``. + +.. _`lxd-docker-connectivity-issue`: https://documentation.ubuntu.com/lxd/en/latest/howto/network_bridge_firewalld/#prevent-connectivity-issues-with-lxd-and-docker +.. _`install-multipass`: https://multipass.run/docs/install-multipass diff --git a/docs/tutorial/fastapi.rst b/docs/tutorial/fastapi.rst index cc5598e0a..081459b0c 100644 --- a/docs/tutorial/fastapi.rst +++ b/docs/tutorial/fastapi.rst @@ -150,8 +150,7 @@ Check that the image was successfully loaded into Docker: The output should list the FastAPI container image, along with its tag, ID and size: -.. code-block:: text - :class: log-snippets +.. terminal:: REPOSITORY TAG IMAGE ID CREATED SIZE fastapi-hello-world 0.1 30c7e5aed202 2 weeks ago 193MB @@ -184,7 +183,7 @@ View the application logs ~~~~~~~~~~~~~~~~~~~~~~~~~ When deploying the FastAPI rock, we can always get the application logs via -``pebble``: +:ref:`pebble_explanation_page`: .. literalinclude:: code/fastapi/task.yaml :language: text @@ -192,12 +191,11 @@ When deploying the FastAPI rock, we can always get the application logs via :end-before: [docs:get-logs-end] :dedent: 2 -As a result, :ref:`pebble_explanation_page` will give us the logs for the +As a result, Pebble will give us the logs for the ``fastapi`` service running inside the container. We should expect to see something similar to this: -.. code-block:: text - :class: log-snippets +.. terminal:: 2024-10-01T06:32:50.180Z [fastapi] INFO: Started server process [12] 2024-10-01T06:32:50.181Z [fastapi] INFO: Waiting for application startup. @@ -290,7 +288,7 @@ and then using the same ``curl`` request: :end-before: [docs:curl-fastapi-bare-rock-end] :dedent: 2 -Unsurprisingly, the FastAPI application should still respond with +The FastAPI application should still respond with ``{"message":"Hello World"}``. Cleanup diff --git a/docs/tutorial/flask.rst b/docs/tutorial/flask.rst index 779dcb8c9..f6022713d 100644 --- a/docs/tutorial/flask.rst +++ b/docs/tutorial/flask.rst @@ -144,8 +144,7 @@ Check that the image was successfully loaded into Docker: The output should list the Flask container image, along with its tag, ID and size: -.. code-block:: text - :class: log-snippets +.. terminal:: REPOSITORY TAG IMAGE ID CREATED SIZE flask-hello-world 0.1 c256056698ba 2 weeks ago 149MB @@ -178,7 +177,7 @@ View the application logs ~~~~~~~~~~~~~~~~~~~~~~~~~ When deploying the Flask rock, we can always get the application logs via -``pebble``: +:ref:`pebble_explanation_page`: .. literalinclude:: code/flask/task.yaml :language: text @@ -186,12 +185,11 @@ When deploying the Flask rock, we can always get the application logs via :end-before: [docs:get-logs-end] :dedent: 2 -As a result, :ref:`pebble_explanation_page` will give us the logs for the +As a result, Pebble will give us the logs for the ``flask`` service running inside the container. We expect to see something similar to this: -.. code-block:: text - :class: log-snippets +.. terminal:: 2024-06-21T03:41:45.077Z [flask] [2024-06-21 03:41:45 +0000] [17] [INFO] Starting gunicorn 22.0.0 2024-06-21T03:41:45.077Z [flask] [2024-06-21 03:41:45 +0000] [17] [INFO] Listening at: http://0.0.0.0:8000 (17) @@ -283,7 +281,7 @@ and then using the same ``curl`` request: :end-before: [docs:curl-flask-bare-rock-end] :dedent: 2 -Unsurprisingly, the Flask application should still respond with +The Flask application should still respond with ``Hello, world!``. Cleanup diff --git a/docs/tutorial/go.rst b/docs/tutorial/go.rst index 812e5b226..3bb542721 100644 --- a/docs/tutorial/go.rst +++ b/docs/tutorial/go.rst @@ -176,8 +176,7 @@ Check that the image was successfully loaded into Docker: The output should list the Go container image, along with its tag, ID and size: -.. code-block:: text - :class: log-snippets +.. terminal:: REPOSITORY TAG IMAGE ID CREATED SIZE go-hello-world 0.1 f3abf7ebc169 5 minutes ago 15.7MB @@ -215,12 +214,11 @@ When deploying the Go rock, we can always get the application logs with :end-before: [docs:get-logs-end] :dedent: 2 -As a result, ``pebble`` will give the logs for the +As a result, Pebble will give the logs for the ``go`` service running inside the container. We should expect to see something similar to this: -.. code-block:: text - :class: log-snippets +.. terminal:: 2024-10-04T08:51:35.826Z [go] 2024/10/04 08:51:35 starting hello world application 2024-10-04T08:51:39.974Z [go] 2024/10/04 08:51:39 new hello world request diff --git a/docs/tutorial/index.rst b/docs/tutorial/index.rst index 315951f4a..374a11105 100644 --- a/docs/tutorial/index.rst +++ b/docs/tutorial/index.rst @@ -28,4 +28,4 @@ code into container applications: 6. Build a rock for a Django application 7. Build a rock for a FastAPI application 8. Build a rock for a Go application - + 9. Build a rock for an ExpressJS application diff --git a/rockcraft/extensions/__init__.py b/rockcraft/extensions/__init__.py index 621619b56..015de6b0e 100644 --- a/rockcraft/extensions/__init__.py +++ b/rockcraft/extensions/__init__.py @@ -19,6 +19,7 @@ from ._utils import apply_extensions from .fastapi import FastAPIFramework from .go import GoFramework +from .expressjs import ExpressJSFramework from .gunicorn import DjangoFramework, FlaskFramework from .registry import get_extension_class, get_extension_names, register, unregister @@ -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..846098dd7 --- /dev/null +++ b/rockcraft/extensions/expressjs.py @@ -0,0 +1,228 @@ +# -*- 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 +from typing import Any + +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" + + @staticmethod + @override + def get_supported_bases() -> tuple[str, ...]: + """Return supported bases.""" + return "bare", "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@24.04 + - 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": { + "expressjs": { + "override": "replace", + "startup": "enabled", + "user": "_daemon_", + "working-dir": f"/{self.IMAGE_BASE_DIR}", + "environment": { + "NODE_ENV": "production", + }, + } + }, + } + if not self.yaml_data.get("services", {}).get("expressjs", {}).get("command"): + snippet["services"]["expressjs"]["command"] = "npm start" + + snippet["parts"] = { + "expressjs-framework/install-app": self._gen_install_app_part(), + } + runtime_part = self._gen_runtime_part() + if runtime_part: + snippet["parts"]["expressjs-framework/runtime"] = runtime_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 _check_project(self) -> None: + """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 or not isinstance( + self._app_package_json["name"], str + ): + raise ExtensionError( + "missing application name", + doc_slug="/reference/extensions/expressjs-framework", + logpath_report=False, + ) + + def _gen_install_app_part(self) -> dict: + """Generate the install app part using NPM plugin. + + Set the script shell to bash and copy the .npmrc file to the app + directory. This is to ensure that the ExpressJS run in bare container + can use the shell to launch itself. + """ + install_app_part: dict[str, Any] = { + "plugin": "npm", + "source": f"{self.IMAGE_BASE_DIR}/", + "override-build": ( + "rm -rf node_modules\n" + "craftctl default\n" + "npm config set script-shell=bash --location project\n" + "cp ${CRAFT_PART_BUILD}/.npmrc ${CRAFT_PART_INSTALL}/lib/node_modules/" + f"{self._app_name}/.npmrc\n" + f"ln -s /lib/node_modules/{self._app_name} ${{CRAFT_PART_INSTALL}}/app\n" + ), + } + build_packages = self._gen_app_build_packages() + if build_packages: + install_app_part["build-packages"] = build_packages + stage_packages = self._gen_app_stage_packages() + if stage_packages: + install_app_part["stage-packages"] = stage_packages + if self._user_npm_include_node: + install_app_part["npm-include-node"] = self._user_npm_include_node + install_app_part["npm-node-version"] = self._user_install_app_part.get( + "npm-node-version" + ) + return install_app_part + + def _gen_app_build_packages(self) -> list[str]: + """Return the build packages for the install app part.""" + if self._user_npm_include_node: + return [] + return ["nodejs", "npm"] + + def _gen_app_stage_packages(self) -> list[str]: + """Return the stage packages for the install app part.""" + if self._rock_base == "bare": + return ["bash_bins", "ca-certificates_data", "coreutils_bins"] + if not self._user_npm_include_node: + return ["ca-certificates_data", "nodejs_bins"] + return ["ca-certificates_data"] + + def _gen_runtime_part(self) -> dict | None: + """Generate the runtime part.""" + stage_packages = [] + if self._rock_base == "bare": + stage_packages = ["libstdc++6", "zlib1g"] + if not self._user_npm_include_node: + stage_packages.append("npm") + if not stage_packages: + return None + return {"plugin": "nil", "stage-packages": stage_packages} + + @property + def _user_install_app_part(self) -> dict: + """Return the user defined install app part.""" + return self.yaml_data.get("parts", {}).get( + "expressjs-framework/install-app", {} + ) + + @property + def _user_npm_include_node(self) -> bool: + """Return the user defined npm include node flag.""" + return self._user_install_app_part.get("npm-include-node", False) + + @property + def _rock_base(self) -> str: + """Return the base of the rockcraft project.""" + return self.yaml_data["base"] + + @property + def _app_package_json(self) -> dict: + """Return the app package.json contents.""" + package_json_file = self.project_root / self.IMAGE_BASE_DIR / "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") + try: + app_package_json = json.loads(package_json_contents) + if not isinstance(app_package_json, dict): + raise ExtensionError( + "invalid package.json file", + doc_slug="/reference/extensions/expressjs-framework", + logpath_report=False, + ) + return app_package_json + except json.JSONDecodeError as exc: + raise ExtensionError( + "failed to parse package.json file", + doc_slug="/reference/extensions/expressjs-framework", + logpath_report=False, + ) from exc + + @property + def _app_name(self) -> str: + """Return the application name as defined on package.json.""" + return self._app_package_json["name"] diff --git a/rockcraft/templates/expressjs-framework/rockcraft.yaml.j2 b/rockcraft/templates/expressjs-framework/rockcraft.yaml.j2 new file mode 100644 index 000000000..f116abba6 --- /dev/null +++ b/rockcraft/templates/expressjs-framework/rockcraft.yaml.j2 @@ -0,0 +1,34 @@ +name: {{name}} +# see {{versioned_url}}/explanation/bases/ +# for more information about bases and using 'bare' bases for chiselled rocks +base: bare # as an alternative, a ubuntu base can be used +build-base: ubuntu@24.04 # build-base is required when the base is bare +version: '0.1' # just for humans. Semantic versioning is recommended +summary: A summary of your ExpresssJS application # 79 char long summary +description: | + This is {{name}}'s description. You have a paragraph or two to tell the + most important story about it. Keep it under 100 words though, + we live in tweetspace and your description wants to look good in the + container registries out there. +# the platforms this rock should be built on and run on. +# you can check your architecture with `dpkg --print-architecture` +platforms: + amd64: + # arm64: + # ppc64el: + # s390x: + +# to ensure the expressjs-framework extension works properly, your ExpressJS +# application should be inside the app directory and the "start" script defined +# in the package.json's scripts section. +extensions: + - expressjs-framework + +# uncomment the sections you need and adjust according to your requirements. +# parts: # you need to uncomment this line to add or update any part. +# expressjs-framework/install-app: +# # to specify the version of node to be installed, uncomment the following +# # line and set the version to the desired one. +# # see https://documentation.ubuntu.com/rockcraft/en/latest/common/craft-parts/reference/plugins/npm_plugin/ +# npm-include-node: true +# npm-node-version: node diff --git a/spread.yaml b/spread.yaml index 80f78be62..4549f5dbd 100644 --- a/spread.yaml +++ b/spread.yaml @@ -17,6 +17,8 @@ include: - pyproject.toml - rockcraft/ +# The default spread timeout is 15 minutes. This is too short for some tests. +kill-timeout: 25m backends: google: key: '$(HOST: echo "$SPREAD_GOOGLE_KEY")' diff --git a/tests/spread/rockcraft/extension-expressjs/task.yaml b/tests/spread/rockcraft/extension-expressjs/task.yaml new file mode 100644 index 000000000..9b77ff5c9 --- /dev/null +++ b/tests/spread/rockcraft/extension-expressjs/task.yaml @@ -0,0 +1,71 @@ +summary: expressjs extension test +environment: + SCENARIO/bare: bare + SCENARIO/base_2404: ubuntu-24.04 + ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS: "true" + +execute: | + NAME="expressjs-${SCENARIO//./-}" + ROCK_FILE="${NAME}_0.1_amd64.rock" + IMAGE="${NAME}:0.1" + + sudo apt-get update -y && sudo apt-get install npm -y + npm install -g express-generator@4 + express app + cd app && npm install && cd .. + + run_rockcraft init --name "${NAME}" --profile expressjs-framework + sed -i "s/^base: .*/base: ${SCENARIO//-/@}/g" rockcraft.yaml + if [ "${SCENARIO}" != "bare" ]; then + sed -i "s/^build-base: .*/build-base: ${SCENARIO//-/@}/g" rockcraft.yaml + fi + + function run_test() { + # rockcraft clean is required here because the cached layer writes to npmrc + # multiple times, causing the app to crash on first run. + rockcraft clean + run_rockcraft pack + + test -f "${ROCK_FILE}" + test ! -d work + + # Ensure docker does not have this container image + docker rmi --force "${IMAGE}" + # Install container + sudo rockcraft.skopeo --insecure-policy copy "oci-archive:${ROCK_FILE}" \ + "docker-daemon:${IMAGE}" + # Ensure container exists + docker images "${IMAGE}" | MATCH "${NAME}" + + # Ensure container doesn't exist + docker rm -f "${NAME}-container" + + # test the default expressjs service + docker run --name "${NAME}-container" -d -p 8137:3000 "${IMAGE}" + retry -n 5 --wait 2 curl localhost:8137 + http_status=$(curl -s -o /dev/null -w "%{http_code}" localhost:8137) + [ "${http_status}" -eq 200 ] + } + + run_test + + # test the expressjs service with a Node version specified + node_version=20.18.2 + cat <> rockcraft.yaml + parts: + expressjs-framework/install-app: + npm-include-node: true + npm-node-version: $node_version + EOF + run_test + + container_id=$(docker ps -q -f name="${NAME}-container") + container_node_version=$(sudo docker exec $container_id node --version) + [ "$container_node_version" = "v$node_version" ] + +restore: | + NAME="expressjs-${SCENARIO//./-}" + docker stop "${NAME}-container" + docker rm "${NAME}-container" + rm -f "*.rock" rockcraft.yaml + docker system prune -a -f diff --git a/tests/spread/rockcraft/extension-go/task.yaml b/tests/spread/rockcraft/extension-go/task.yaml index 1fdcd7916..55aaf803a 100644 --- a/tests/spread/rockcraft/extension-go/task.yaml +++ b/tests/spread/rockcraft/extension-go/task.yaml @@ -10,7 +10,6 @@ execute: | IMAGE="${NAME}:0.1" run_rockcraft init --name "${NAME}" --profile go-framework - sed -i "s/^name: .*/name: ${NAME}/g" rockcraft.yaml sed -i "s/^base: .*/base: ${SCENARIO//-/@}/g" rockcraft.yaml if [ "${SCENARIO}" != "bare" ]; then sed -i "s/^build-base: .*/build-base: ${SCENARIO//-/@}/g" rockcraft.yaml @@ -25,7 +24,8 @@ execute: | # Ensure docker does not have this container image docker rmi --force "${IMAGE}" # Install container - sudo rockcraft.skopeo --insecure-policy copy "oci-archive:${ROCK_FILE}" "docker-daemon:${IMAGE}" + sudo rockcraft.skopeo --insecure-policy copy "oci-archive:${ROCK_FILE}" \ + "docker-daemon:${IMAGE}" # Ensure container exists docker images "${IMAGE}" | MATCH "${NAME}" diff --git a/tests/unit/extensions/test_expressjs.py b/tests/unit/extensions/test_expressjs.py new file mode 100644 index 000000000..96b22a296 --- /dev/null +++ b/tests/unit/extensions/test_expressjs.py @@ -0,0 +1,322 @@ +# -*- 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 + +_expressjs_project_name = "test-expressjs-project" + + +@pytest.fixture(name="expressjs_input_yaml") +def expressjs_input_yaml_fixture(): + return { + "name": "foo-bar", + "base": "ubuntu@24.04", + "build-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 app_path(tmp_path): + app_path = tmp_path / "app" + app_path.mkdir(parents=True, exist_ok=True) + return app_path + + +@pytest.fixture +def package_json_file(app_path): + (app_path / "package.json").write_text( + f"""{{ + "name": "{_expressjs_project_name}", + "scripts": {{ + "start": "node ./bin/www" + }} +}}""" + ) + + +@pytest.mark.parametrize( + "base, npm_include_node, node_version, expected_yaml_dict", + [ + pytest.param( + "ubuntu@24.04", + False, + None, + { + "base": "ubuntu@24.04", + "build-base": "ubuntu@24.04", + "name": "foo-bar", + "platforms": { + "amd64": {}, + }, + "run-user": "_daemon_", + "parts": { + "expressjs-framework/install-app": { + "plugin": "npm", + "source": "app/", + "npm-include-node": False, + "npm-node-version": None, + "override-build": ( + "rm -rf node_modules\n" + "craftctl default\n" + "npm config set script-shell=bash --location project\n" + "cp ${CRAFT_PART_BUILD}/.npmrc ${CRAFT_PART_INSTALL}/lib/node_modules/" + f"{_expressjs_project_name}/.npmrc\n" + f"ln -s /lib/node_modules/{_expressjs_project_name} " + "${CRAFT_PART_INSTALL}/app\n" + ), + "build-packages": ["nodejs", "npm"], + "stage-packages": ["ca-certificates_data", "nodejs_bins"], + }, + "expressjs-framework/runtime": { + "plugin": "nil", + "stage-packages": ["npm"], + }, + }, + "services": { + "expressjs": { + "override": "replace", + "startup": "enabled", + "user": "_daemon_", + "working-dir": "/app", + "command": "npm start", + "environment": {"NODE_ENV": "production"}, + }, + }, + }, + id="ubuntu@24.04", + ), + pytest.param( + "ubuntu@24.04", + True, + "1.0.0", + { + "base": "ubuntu@24.04", + "build-base": "ubuntu@24.04", + "name": "foo-bar", + "parts": { + "expressjs-framework/install-app": { + "npm-include-node": True, + "npm-node-version": "1.0.0", + "override-build": "rm -rf node_modules\n" + "craftctl default\n" + "npm config set script-shell=bash --location project\n" + "cp ${CRAFT_PART_BUILD}/.npmrc " + "${CRAFT_PART_INSTALL}/lib/node_modules/test-expressjs-project/.npmrc\n" + "ln -s /lib/node_modules/test-expressjs-project " + "${CRAFT_PART_INSTALL}/app\n", + "plugin": "npm", + "source": "app/", + "stage-packages": ["ca-certificates_data"], + } + }, + "platforms": { + "amd64": {}, + }, + "run-user": "_daemon_", + "services": { + "expressjs": { + "command": "npm start", + "environment": { + "NODE_ENV": "production", + }, + "override": "replace", + "startup": "enabled", + "user": "_daemon_", + "working-dir": "/app", + }, + }, + }, + id="ubuntu@24.04", + ), + pytest.param( + "bare", + False, + None, + { + "base": "bare", + "build-base": "ubuntu@24.04", + "name": "foo-bar", + "parts": { + "expressjs-framework/install-app": { + "build-packages": [ + "nodejs", + "npm", + ], + "npm-include-node": False, + "npm-node-version": None, + "override-build": "rm -rf node_modules\n" + "craftctl default\n" + "npm config set script-shell=bash --location project\n" + "cp ${CRAFT_PART_BUILD}/.npmrc " + "${CRAFT_PART_INSTALL}/lib/node_modules/test-expressjs-project/.npmrc\n" + "ln -s /lib/node_modules/test-expressjs-project " + "${CRAFT_PART_INSTALL}/app\n", + "plugin": "npm", + "source": "app/", + "stage-packages": [ + "bash_bins", + "ca-certificates_data", + "coreutils_bins", + ], + }, + "expressjs-framework/runtime": { + "plugin": "nil", + "stage-packages": [ + "libstdc++6", + "zlib1g", + "npm", + ], + }, + }, + "platforms": { + "amd64": {}, + }, + "run-user": "_daemon_", + "services": { + "expressjs": { + "command": "npm start", + "environment": { + "NODE_ENV": "production", + }, + "override": "replace", + "startup": "enabled", + "user": "_daemon_", + "working-dir": "/app", + }, + }, + }, + id="ubuntu@24.04", + ), + pytest.param( + "bare", + True, + "1.0.0", + { + "base": "bare", + "build-base": "ubuntu@24.04", + "name": "foo-bar", + "parts": { + "expressjs-framework/install-app": { + "npm-include-node": True, + "npm-node-version": "1.0.0", + "override-build": "rm -rf node_modules\n" + "craftctl default\n" + "npm config set script-shell=bash --location project\n" + "cp ${CRAFT_PART_BUILD}/.npmrc " + "${CRAFT_PART_INSTALL}/lib/node_modules/test-expressjs-project/.npmrc\n" + "ln -s /lib/node_modules/test-expressjs-project " + "${CRAFT_PART_INSTALL}/app\n", + "plugin": "npm", + "source": "app/", + "stage-packages": [ + "bash_bins", + "ca-certificates_data", + "coreutils_bins", + ], + }, + "expressjs-framework/runtime": { + "plugin": "nil", + "stage-packages": ["libstdc++6", "zlib1g"], + }, + }, + "platforms": { + "amd64": {}, + }, + "run-user": "_daemon_", + "services": { + "expressjs": { + "command": "npm start", + "environment": { + "NODE_ENV": "production", + }, + "override": "replace", + "startup": "enabled", + "user": "_daemon_", + "working-dir": "/app", + }, + }, + }, + id="ubuntu@24.04", + ), + ], +) +@pytest.mark.usefixtures("expressjs_extension", "package_json_file") +def test_expressjs_extension_default( + tmp_path, + expressjs_input_yaml, + base, + npm_include_node, + node_version, + expected_yaml_dict, +): + expressjs_input_yaml["base"] = base + expressjs_input_yaml["parts"] = { + "expressjs-framework/install-app": { + "npm-include-node": npm_include_node, + "npm-node-version": node_version, + } + } + applied = extensions.apply_extensions(tmp_path, expressjs_input_yaml) + + assert applied == expected_yaml_dict + + +@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_path, package_json_contents, error_message", + [ + ("invalid-path", "", "missing package.json file"), + ("package.json", "[]", "invalid package.json file"), + ("package.json", "{", "failed to parse package.json file"), + ("package.json", "{}", "missing start script"), + ("package.json", '{"scripts":{}}', "missing start script"), + ( + "package.json", + '{"scripts":{"start":"node ./bin/www"}}', + "missing application name", + ), + ], +) +@pytest.mark.usefixtures("expressjs_extension") +def test_expressjs_invalid_package_json_scripts_error( + tmp_path, + app_path, + expressjs_input_yaml, + package_json_path, + package_json_contents, + error_message, +): + (app_path / package_json_path).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"