Repository: canonical/rockcraft
Pull request #833: Feat(12-factor): Extensions are updated to let charms use non-root users
Author: login: alithethird
Created: 2025-03-12T11:49:06Z | Merged: 2025-05-08T10:29:17Z

As of [Juju 3.6](https://discourse.charmhub.io/t/juju-3-6-0-released/16027) Charms can have [non-root users](https://canonical-charmcraft.readthedocs-hosted.com/en/stable/reference/files/metadata-yaml-file/#charm-user). We want to make use of the non-root charm containers. We had some issues with some file/folder permissions because we are creating/modifying some files from the charm code. 

This PR fixes these permission issues by creating the folders inside the rockcraft.yaml file and setting the user as `_daemon_`. Adds a new part into all 12 Factor extensions called `logging`. This part creates the folders necessary for logging and sets their user to `_daemon_` so that the non-root charms can interact with them.

For Flask and Django extensions, it also sets the `gunicorn.conf.py` file and its parent folder to `_daemon_` user.
Again for Flask and Django extensions, it creates the `/var/log/django` and `/var/log/flask` folders and sets their user to `_daemon_`.

Reviewers:
@javierdelapuente
@gregory-schiano

- [x] Have you signed the [CLA](http://www.ubuntu.com/legal/contributors/)?

---



Diff:
diff --git a/docs/tutorial/code/expressjs/task.yaml b/docs/tutorial/code/expressjs/task.yaml
index f459e5fcd..3ad7b5deb 100644
--- a/docs/tutorial/code/expressjs/task.yaml
+++ b/docs/tutorial/code/expressjs/task.yaml
@@ -80,7 +80,7 @@ execute: |
 
   # [docs:stop-docker]
   sudo docker stop expressjs-hello-world
-  sudo docker rmi expressjs-hello-world:0.1
+  sudo docker rmi expressjs-hello-world:0.1 --force
   # [docs:stop-docker-end]
 
   mv time.js app/routes/
@@ -108,7 +108,7 @@ execute: |
 
   # [docs:stop-docker-updated]
   sudo docker stop expressjs-hello-world
-  sudo docker rmi expressjs-hello-world:0.2
+  sudo docker rmi expressjs-hello-world:0.2 --force
   # [docs:stop-docker-updated-end]
 
   # [docs:cleanup]
diff --git a/rockcraft/extensions/__init__.py b/rockcraft/extensions/__init__.py
index 015de6b0e..6626b097e 100644
--- a/rockcraft/extensions/__init__.py
+++ b/rockcraft/extensions/__init__.py
@@ -22,6 +22,7 @@
 from .expressjs import ExpressJSFramework
 from .gunicorn import DjangoFramework, FlaskFramework
 from .registry import get_extension_class, get_extension_names, register, unregister
+from .app_parts import gen_logging_part
 
 __all__ = [
     "get_extension_class",
@@ -29,6 +30,7 @@
     "apply_extensions",
     "register",
     "unregister",
+    "gen_logging_part",
 ]
 
 register("django-framework", DjangoFramework)
diff --git a/rockcraft/extensions/app_parts.py b/rockcraft/extensions/app_parts.py
new file mode 100644
index 000000000..5f64d8d38
--- /dev/null
+++ b/rockcraft/extensions/app_parts.py
@@ -0,0 +1,47 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright 2025 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 <http://www.gnu.org/licenses/>.
+
+"""Common extension application parts."""
+
+from rockcraft.usernames import SUPPORTED_GLOBAL_USERNAMES
+
+USER_UID: int = SUPPORTED_GLOBAL_USERNAMES["_daemon_"]["uid"]
+
+
+def gen_logging_part(
+    override_build_lines: list[str] | None = None,
+    permissions: list[dict[str, str | int]] | None = None,
+) -> dict[str, str | list[dict[str, str | int]]]:
+    """Generate a logging part for the application."""
+    _override_build_lines = [
+        "craftctl default",
+        "mkdir -p $CRAFT_PART_INSTALL/opt/promtail",
+        "mkdir -p $CRAFT_PART_INSTALL/etc/promtail",
+    ]
+    if override_build_lines:
+        _override_build_lines.extend(override_build_lines)
+    _permissions: list[dict[str, str | int]] = [
+        {"path": "opt/promtail", "owner": USER_UID, "group": USER_UID},
+        {"path": "etc/promtail", "owner": USER_UID, "group": USER_UID},
+    ]
+    if permissions:
+        _permissions.extend(permissions)
+
+    return {
+        "plugin": "nil",
+        "override-build": "\n".join(_override_build_lines),
+        "permissions": _permissions,
+    }
diff --git a/rockcraft/extensions/expressjs.py b/rockcraft/extensions/expressjs.py
index 5db080825..365832f35 100644
--- a/rockcraft/extensions/expressjs.py
+++ b/rockcraft/extensions/expressjs.py
@@ -22,9 +22,13 @@
 from overrides import override  # type: ignore[reportUnknownVariableType]
 
 from rockcraft.errors import ExtensionError
+from rockcraft.usernames import SUPPORTED_GLOBAL_USERNAMES
 
+from .app_parts import gen_logging_part
 from .extension import Extension
 
+USER_UID: int = SUPPORTED_GLOBAL_USERNAMES["_daemon_"]["uid"]
+
 
 class ExpressJSFramework(Extension):
     """An extension for constructing Javascript applications based on the ExpressJS framework."""
@@ -79,6 +83,7 @@ def get_root_snippet(self) -> dict[str, Any]:
         runtime_part = self._gen_runtime_part()
         if runtime_part:
             snippet["parts"]["expressjs-framework/runtime"] = runtime_part
+        snippet["parts"]["expressjs-framework/logging"] = gen_logging_part()
         return snippet
 
     @override
@@ -138,7 +143,11 @@ def _gen_install_app_part(self) -> dict[str, Any]:
                 "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"
+                # we can not user `permissions` block here because it doesn't work with symlinks
+                # bug: https://github.com/canonical/rockcraft/issues/660
+                f"chown -R {USER_UID}:{USER_UID} ${{CRAFT_PART_INSTALL}}/lib/node_modules/{self._app_name}\n"
                 f"ln -s /lib/node_modules/{self._app_name} ${{CRAFT_PART_INSTALL}}/app\n"
+                f"chown -R {USER_UID}:{USER_UID} ${{CRAFT_PART_INSTALL}}/app\n"
             ),
         }
         build_packages = self._gen_app_build_packages()
diff --git a/rockcraft/extensions/fastapi.py b/rockcraft/extensions/fastapi.py
index b5b8a551b..f18854f92 100644
--- a/rockcraft/extensions/fastapi.py
+++ b/rockcraft/extensions/fastapi.py
@@ -26,10 +26,14 @@
 from overrides import override  # type: ignore[reportUnknownVariableType]
 
 from rockcraft.errors import ExtensionError
+from rockcraft.usernames import SUPPORTED_GLOBAL_USERNAMES
 
 from ._python_utils import has_global_variable
+from .app_parts import gen_logging_part
 from .extension import Extension
 
+USER_UID: int = SUPPORTED_GLOBAL_USERNAMES["_daemon_"]["uid"]
+
 
 class FastAPIFramework(Extension):
     """An extension base class for Python FastAPI/starlette framework extension."""
@@ -106,7 +110,10 @@ def _get_parts(self) -> dict[str, Any]:
                 "python-packages": ["uvicorn"],
                 "python-requirements": ["requirements.txt"],
             },
-            "fastapi-framework/install-app": self._get_install_app_part(),
+            "fastapi-framework/install-app": {
+                **self._get_install_app_part(),
+                "permissions": [{"owner": USER_UID, "group": USER_UID}],
+            },
         }
         if self.yaml_data["base"] == "bare":
             parts["fastapi-framework/runtime"] = {
@@ -123,6 +130,7 @@ def _get_parts(self) -> dict[str, Any]:
                 "plugin": "nil",
                 "stage-packages": ["ca-certificates_data"],
             }
+        parts["fastapi-framework/logging"] = gen_logging_part()
         return parts
 
     def _get_install_app_part(self) -> dict[str, Any]:
diff --git a/rockcraft/extensions/go.py b/rockcraft/extensions/go.py
index 9ee559048..7d3721dee 100644
--- a/rockcraft/extensions/go.py
+++ b/rockcraft/extensions/go.py
@@ -23,9 +23,13 @@
 from overrides import override  # type: ignore[reportUnknownVariableType]
 
 from rockcraft.errors import ExtensionError
+from rockcraft.usernames import SUPPORTED_GLOBAL_USERNAMES
 
+from .app_parts import gen_logging_part
 from .extension import Extension
 
+USER_UID: int = SUPPORTED_GLOBAL_USERNAMES["_daemon_"]["uid"]
+
 
 class GoFramework(Extension):
     """An extension class for Go applications."""
@@ -69,12 +73,14 @@ def get_root_snippet(self) -> dict[str, Any]:
             "go-framework/base-layout": {
                 "plugin": "nil",
                 "override-build": "mkdir -p ${CRAFT_PART_INSTALL}/app",
+                "permissions": [{"owner": USER_UID, "group": USER_UID}],
             },
             "go-framework/install-app": self._get_install_app_part(),
             "go-framework/runtime": {
                 "plugin": "nil",
                 "stage-packages": stage_packages,
             },
+            "go-framework/logging": gen_logging_part(),
         }
 
         assets_part = self._get_install_assets_part()
diff --git a/rockcraft/extensions/gunicorn.py b/rockcraft/extensions/gunicorn.py
index 19b636167..c1b7363e4 100644
--- a/rockcraft/extensions/gunicorn.py
+++ b/rockcraft/extensions/gunicorn.py
@@ -28,11 +28,15 @@
 from packaging.requirements import InvalidRequirement, Requirement
 
 from rockcraft.errors import ExtensionError
+from rockcraft.usernames import SUPPORTED_GLOBAL_USERNAMES
 
 from ._python_utils import has_global_variable
 from ._utils import find_ubuntu_base_python_version
+from .app_parts import gen_logging_part
 from .extension import Extension, get_extensions_data_dir
 
+USER_UID: int = SUPPORTED_GLOBAL_USERNAMES["_daemon_"]["uid"]
+
 
 class _GunicornBase(Extension):
     """An extension base class for Python WSGI framework extensions."""
@@ -97,13 +101,23 @@ def _gen_parts(self) -> dict[str, Any]:
                 "python-requirements": ["requirements.txt"],
                 "build-environment": build_environment,
             },
-            f"{self.framework}-framework/install-app": self.gen_install_app_part(),
+            f"{self.framework}-framework/install-app": {
+                **self.gen_install_app_part(),
+                "permissions": [{"owner": USER_UID, "group": USER_UID}],
+            },
             f"{self.framework}-framework/config-files": {
                 "plugin": "dump",
                 "source": str(data_dir / f"{self.framework}-framework"),
                 "organize": {
                     "gunicorn.conf.py": f"{self.framework}/gunicorn.conf.py",
                 },
+                "permissions": [
+                    {
+                        "path": f"{self.framework}/gunicorn.conf.py",
+                        "owner": USER_UID,
+                        "group": USER_UID,
+                    },
+                ],
             },
             f"{self.framework}-framework/statsd-exporter": {
                 "build-snaps": ["go"],
@@ -111,6 +125,18 @@ def _gen_parts(self) -> dict[str, Any]:
                 "plugin": "go",
                 "source": "https://github.com/prometheus/statsd_exporter.git",
             },
+            f"{self.framework}-framework/logging": gen_logging_part(
+                override_build_lines=[
+                    f"mkdir -p $CRAFT_PART_INSTALL/var/log/{self.framework}"
+                ],
+                permissions=[
+                    {
+                        "path": f"var/log/{self.framework}",
+                        "owner": USER_UID,
+                        "group": USER_UID,
+                    }
+                ],
+            ),
         }
         if self.yaml_data["base"] == "bare":
             parts[f"{self.framework}-framework/runtime"] = {
@@ -348,7 +374,7 @@ def is_experimental(base: str | None) -> bool:  # noqa: ARG004 (unused arg)
 
     @override
     def gen_install_app_part(self) -> dict[str, Any]:
-        """Return the prime list for the Flask project."""
+        """Return the prime list for the Django project."""
         if "django-framework/install-app" not in self.yaml_data.get("parts", {}):
             return {
                 "plugin": "dump",
diff --git a/tests/spread/rockcraft/extension-django/task.yaml b/tests/spread/rockcraft/extension-django/task.yaml
index 693a06334..6684b3ecd 100644
--- a/tests/spread/rockcraft/extension-django/task.yaml
+++ b/tests/spread/rockcraft/extension-django/task.yaml
@@ -19,15 +19,26 @@ execute: |
   sed -i "s/^base: .*/base: ${BASE}/g" rockcraft.yaml
   echo "build-base: ${BUILD_BASE}" >> rockcraft.yaml
 
+  # Install findutils for the bare scenario
+  # This is a workaround for the fact that the bare scenario does not have
+  # findutils installed by default, which is required for the test
+  cat >> rockcraft.yaml <<EOL
+  parts:
+    find-app:
+      plugin: nil
+      stage-packages:
+        - findutils
+  EOL
+
   run_rockcraft pack
 
   test -f example-django_0.1_amd64.rock
 
   # Ensure docker does not have this container image
   docker rmi --force example-django
-  # Install container
+  # Install image
   sudo rockcraft.skopeo --insecure-policy copy oci-archive:example-django_0.1_amd64.rock docker-daemon:example-django:latest
-  # Ensure container exists
+  # Ensure image exists
   docker images example-django | MATCH "example-django"
 
   # ensure container doesn't exist
@@ -38,6 +49,19 @@ execute: |
   docker run --rm --entrypoint /bin/python3 example-django -c "import pathlib;assert pathlib.Path('/django/app/manage.py').is_file()"
   docker run --rm --entrypoint /bin/python3 example-django -c "import pathlib;assert pathlib.Path('/django/app/.foobar').is_file()"
 
+  # ensure the django and promtail folders have the correct user
+  check_directory_ownership() {
+    local directory="$1"
+    local shell="/bin/bash"
+    if [ "${BASE}" == "bare" ] && [ "${BUILD_BASE}" == "ubuntu@24.04" ]; then    shell="/usr/bin/bash"
+    fi
+    docker run --rm --entrypoint ${shell} example-django -c "find ${directory}/ ! -user '_daemon_' || { echo 'Error: ${directory} or its contents are not owned by _daemon_'; exit 2; }"
+  }
+
+  check_directory_ownership "/django"
+  check_directory_ownership "/opt/promtail"
+  check_directory_ownership "/etc/promtail"
+
   # test the default django service
   docker run --name example-django-container -d -p 8138:8000 example-django
   retry -n 5 --wait 2 curl localhost:8138
diff --git a/tests/spread/rockcraft/extension-expressjs/task.yaml b/tests/spread/rockcraft/extension-expressjs/task.yaml
index 72db1d8f5..72ecb124c 100644
--- a/tests/spread/rockcraft/extension-expressjs/task.yaml
+++ b/tests/spread/rockcraft/extension-expressjs/task.yaml
@@ -1,4 +1,6 @@
 summary: expressjs extension test
+
+kill-timeout: 45m
 environment:
   SCENARIO/bare: bare
   SCENARIO/base_2404: ubuntu-24.04
@@ -19,8 +21,19 @@ execute: |
   sed -i "s/^base: .*/base: ${SCENARIO//-/@}/g" rockcraft.yaml
   if [ "${SCENARIO}" != "bare" ]; then
     sed -i "s/^build-base: .*/build-base: ${SCENARIO//-/@}/g" rockcraft.yaml
+  else
+    # Install findutils for the bare scenario
+    # This is a workaround for the fact that the bare scenario does not have
+    # findutils installed by default, which is required for the test
+    cat >> rockcraft.yaml <<EOL
+  parts:
+    find-app:
+      plugin: nil
+      stage-packages:
+        - findutils
+  EOL
   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.
@@ -35,7 +48,19 @@ execute: |
     # Install container
     sudo rockcraft.skopeo --insecure-policy copy "oci-archive:${ROCK_FILE}" \
     "docker-daemon:${IMAGE}"
-    # Ensure container exists
+
+    # ensure the app and promtail folders have the correct user
+    check_directory_ownership() {
+      local directory="$1"
+      docker run --rm --entrypoint /usr/bin/bash "${IMAGE}" -c "find ${directory}/ ! -user '_daemon_' || { echo 'Error: ${directory} or its contents are not owned by _daemon_'; exit 2; }"
+    }
+
+    check_directory_ownership "/app"
+    check_directory_ownership "/lib/node_modules/app"
+    check_directory_ownership "/opt/promtail"
+    check_directory_ownership "/etc/promtail"
+
+    # Ensure image exists
     docker images "${IMAGE}" | MATCH "${NAME}"
 
     # Ensure container doesn't exist
@@ -50,10 +75,13 @@ execute: |
 
   run_test
 
+
+  if [ "${SCENARIO}" != "bare" ]; then
+    echo "parts:" >> rockcraft.yaml
+  fi
   # test the expressjs service with a Node version specified
   node_version=20.18.2
   cat <<EOF >> rockcraft.yaml
-  parts:
     expressjs-framework/install-app:
       npm-include-node: true
       npm-node-version: $node_version
diff --git a/tests/spread/rockcraft/extension-fastapi/task.yaml b/tests/spread/rockcraft/extension-fastapi/task.yaml
index 227716bef..e6f5e5772 100644
--- a/tests/spread/rockcraft/extension-fastapi/task.yaml
+++ b/tests/spread/rockcraft/extension-fastapi/task.yaml
@@ -12,9 +12,16 @@ execute: |
   run_rockcraft init --name fastapi-extension --profile fastapi-framework
   sed -i "s/name: .*/name: ${NAME}/g" rockcraft.yaml
   if [ "${SCENARIO}" = "bare" ]; then
-      sed -i "s/base: .*/base: ${SCENARIO}\nbuild-base: ubuntu@24.04/g" rockcraft.yaml
+    sed -i "s/base: .*/base: ${SCENARIO}\nbuild-base: ubuntu@24.04/g" rockcraft.yaml
+    cat >> rockcraft.yaml <<EOL
+  parts:
+    find-app:
+      plugin: nil
+      stage-packages:
+        - findutils
+  EOL
   else
-      sed -i "s/base: .*/base: ${SCENARIO//-/@}/g" rockcraft.yaml
+    sed -i "s/base: .*/base: ${SCENARIO//-/@}/g" rockcraft.yaml
   fi
   run_rockcraft pack
 
@@ -23,15 +30,25 @@ execute: |
   # Ensure docker does not have this container image
   docker rmi --force "${IMAGE}"
 
-  # Install container
+  # Install image
   sudo rockcraft.skopeo --insecure-policy copy "oci-archive:${ROCK_FILE}" "docker-daemon:${IMAGE}"
 
-  # Ensure container exists
+  # Ensure image exists
   docker images "${IMAGE}" | MATCH "${NAME}"
 
   # ensure container doesn't exist
   docker rm -f "${NAME}-container"
 
+  # ensure the app and promtail folders have the correct user
+  check_directory_ownership() {
+    local directory="$1"
+    docker run --rm --entrypoint /usr/bin/bash "${IMAGE}" -c "find ${directory}/ ! -user '_daemon_' || { echo 'Error: ${directory} or its contents are not owned by _daemon_'; exit 2; }"
+  }
+
+  check_directory_ownership "/app"
+  check_directory_ownership "/opt/promtail"
+  check_directory_ownership "/etc/promtail"
+
   # test the default fastapi service
   docker run --name "${NAME}-container" -d -p 8137:8000 "${IMAGE}"
   retry -n 5 --wait 2 curl localhost:8137
diff --git a/tests/spread/rockcraft/extension-flask/task.yaml b/tests/spread/rockcraft/extension-flask/task.yaml
index 18af99dd6..4b153ed4c 100644
--- a/tests/spread/rockcraft/extension-flask/task.yaml
+++ b/tests/spread/rockcraft/extension-flask/task.yaml
@@ -11,9 +11,20 @@ execute: |
   run_rockcraft init --name flask-extension --profile flask-framework
   sed -i "s/name: .*/name: ${NAME}/g" rockcraft.yaml
   if [ "${SCENARIO}" = "bare" ]; then
-      sed -i "s/base: .*/base: ${SCENARIO}\nbuild-base: ubuntu@22.04/g" rockcraft.yaml
+    sed -i "s/base: .*/base: ${SCENARIO}\nbuild-base: ubuntu@22.04/g" rockcraft.yaml
+
+    # Install findutils for the bare scenario
+    # This is a workaround for the fact that the bare scenario does not have
+    # findutils installed by default, which is required for the test
+    cat >> rockcraft.yaml <<EOL
+  parts:
+    find-app:
+      plugin: nil
+      stage-packages:
+        - findutils
+  EOL
   else
-      sed -i "s/base: .*/base: ${SCENARIO//-/@}/g" rockcraft.yaml
+    sed -i "s/base: .*/base: ${SCENARIO//-/@}/g" rockcraft.yaml
   fi
   run_rockcraft pack
 
@@ -35,8 +46,19 @@ execute: |
   docker run --rm --entrypoint /bin/python3 "${IMAGE}" -c "import pathlib;assert pathlib.Path('/flask/app/static/js/test.js').is_file()"
   docker run --rm --entrypoint /bin/python3 "${IMAGE}" -c "import pathlib;assert not pathlib.Path('/flask/app/node_modules').exists()"
 
+  # ensure the flask and promtail folders have the correct user
+  check_directory_ownership() {
+    local directory="$1"
+    docker run --rm --entrypoint /bin/bash "${IMAGE}" -c "find ${directory}/ ! -user '_daemon_' || { echo 'Error: ${directory} or its contents are not owned by _daemon_'; exit 2; }"
+  }
+
+  check_directory_ownership "/flask"
+  check_directory_ownership "/opt/promtail"
+  check_directory_ownership "/etc/promtail"
+
   # test the default flask service
   docker run --name "${NAME}-container" -d -p 8137:8000 "${IMAGE}"
+
   retry -n 5 --wait 2 curl localhost:8137
   [ "$(curl -sSf localhost:8137)" == "ok" ]
 
diff --git a/tests/spread/rockcraft/extension-go/task.yaml b/tests/spread/rockcraft/extension-go/task.yaml
index e58235d39..7549b8a67 100644
--- a/tests/spread/rockcraft/extension-go/task.yaml
+++ b/tests/spread/rockcraft/extension-go/task.yaml
@@ -13,6 +13,17 @@ execute: |
   sed -i "s/^base: .*/base: ${SCENARIO//-/@}/g" rockcraft.yaml
   if [ "${SCENARIO}" != "bare" ]; then
     sed -i "s/^build-base: .*/build-base: ${SCENARIO//-/@}/g" rockcraft.yaml
+  else
+    # Install findutils for the bare scenario
+    # This is a workaround for the fact that the bare scenario does not have
+    # findutils installed by default, which is required for the test
+    cat >> rockcraft.yaml <<EOL
+  parts:
+    find-app:
+      plugin: nil
+      stage-packages:
+        - findutils
+  EOL
   fi
   sed -i "s/NAME/${NAME}/g" go.mod
 
@@ -23,12 +34,22 @@ execute: |
 
   # Ensure docker does not have this container image
   docker rmi --force "${IMAGE}"
-  # Install container
+  # Install image
   sudo rockcraft.skopeo --insecure-policy copy "oci-archive:${ROCK_FILE}" \
     "docker-daemon:${IMAGE}"
-  # Ensure container exists
+  # Ensure image exists
   docker images "${IMAGE}" | MATCH "${NAME}"
 
+  # ensure the app and promtail folders have the correct user
+  check_directory_ownership() {
+    local directory="$1"
+    docker run --rm --entrypoint /usr/bin/bash "${IMAGE}" -c "find ${directory}/ ! -user '_daemon_' || { echo 'Error: ${directory} or its contents are not owned by _daemon_'; exit 2; }"
+  }
+
+  check_directory_ownership "/app"
+  check_directory_ownership "/opt/promtail"
+  check_directory_ownership "/etc/promtail"
+
   # ensure container doesn't exist
   docker rm -f "${NAME}-container"
 
diff --git a/tests/unit/extensions/test_expressjs.py b/tests/unit/extensions/test_expressjs.py
index 01822e3de..594afffbb 100644
--- a/tests/unit/extensions/test_expressjs.py
+++ b/tests/unit/extensions/test_expressjs.py
@@ -77,15 +77,15 @@ def package_json_file(app_path):
                         "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"
-                        ),
+                        "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"chown -R 584792:584792 ${{CRAFT_PART_INSTALL}}/lib/node_modules/{_expressjs_project_name}\n"
+                        f"ln -s /lib/node_modules/{_expressjs_project_name} "
+                        "${CRAFT_PART_INSTALL}/app\n"
+                        "chown -R 584792:584792 ${CRAFT_PART_INSTALL}/app\n",
                         "build-packages": ["nodejs", "npm"],
                         "stage-packages": ["ca-certificates_data", "nodejs_bins"],
                         "build-environment": [{"UV_USE_IO_URING": "0"}],
@@ -94,6 +94,18 @@ def package_json_file(app_path):
                         "plugin": "nil",
                         "stage-packages": ["npm"],
                     },
+                    "expressjs-framework/logging": {
+                        "plugin": "nil",
+                        "override-build": (
+                            "craftctl default\n"
+                            "mkdir -p $CRAFT_PART_INSTALL/opt/promtail\n"
+                            "mkdir -p $CRAFT_PART_INSTALL/etc/promtail"
+                        ),
+                        "permissions": [
+                            {"path": "opt/promtail", "owner": 584792, "group": 584792},
+                            {"path": "etc/promtail", "owner": 584792, "group": 584792},
+                        ],
+                    },
                 },
                 "services": {
                     "expressjs": {
@@ -125,13 +137,27 @@ def package_json_file(app_path):
                         "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"
+                        "chown -R 584792:584792 ${CRAFT_PART_INSTALL}/lib/node_modules/test-expressjs-project\n"
                         "ln -s /lib/node_modules/test-expressjs-project "
-                        "${CRAFT_PART_INSTALL}/app\n",
+                        "${CRAFT_PART_INSTALL}/app\n"
+                        "chown -R 584792:584792 ${CRAFT_PART_INSTALL}/app\n",
                         "plugin": "npm",
                         "source": "app/",
                         "stage-packages": ["ca-certificates_data"],
                         "build-environment": [{"UV_USE_IO_URING": "0"}],
-                    }
+                    },
+                    "expressjs-framework/logging": {
+                        "plugin": "nil",
+                        "override-build": (
+                            "craftctl default\n"
+                            "mkdir -p $CRAFT_PART_INSTALL/opt/promtail\n"
+                            "mkdir -p $CRAFT_PART_INSTALL/etc/promtail"
+                        ),
+                        "permissions": [
+                            {"path": "opt/promtail", "owner": 584792, "group": 584792},
+                            {"path": "etc/promtail", "owner": 584792, "group": 584792},
+                        ],
+                    },
                 },
                 "platforms": {
                     "amd64": {},
@@ -173,8 +199,10 @@ def package_json_file(app_path):
                         "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"
+                        "chown -R 584792:584792 ${CRAFT_PART_INSTALL}/lib/node_modules/test-expressjs-project\n"
                         "ln -s /lib/node_modules/test-expressjs-project "
-                        "${CRAFT_PART_INSTALL}/app\n",
+                        "${CRAFT_PART_INSTALL}/app\n"
+                        "chown -R 584792:584792 ${CRAFT_PART_INSTALL}/app\n",
                         "plugin": "npm",
                         "source": "app/",
                         "stage-packages": [
@@ -192,6 +220,18 @@ def package_json_file(app_path):
                             "npm",
                         ],
                     },
+                    "expressjs-framework/logging": {
+                        "plugin": "nil",
+                        "override-build": (
+                            "craftctl default\n"
+                            "mkdir -p $CRAFT_PART_INSTALL/opt/promtail\n"
+                            "mkdir -p $CRAFT_PART_INSTALL/etc/promtail"
+                        ),
+                        "permissions": [
+                            {"path": "opt/promtail", "owner": 584792, "group": 584792},
+                            {"path": "etc/promtail", "owner": 584792, "group": 584792},
+                        ],
+                    },
                 },
                 "platforms": {
                     "amd64": {},
@@ -229,8 +269,10 @@ def package_json_file(app_path):
                         "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"
+                        "chown -R 584792:584792 ${CRAFT_PART_INSTALL}/lib/node_modules/test-expressjs-project\n"
                         "ln -s /lib/node_modules/test-expressjs-project "
-                        "${CRAFT_PART_INSTALL}/app\n",
+                        "${CRAFT_PART_INSTALL}/app\n"
+                        "chown -R 584792:584792 ${CRAFT_PART_INSTALL}/app\n",
                         "plugin": "npm",
                         "source": "app/",
                         "stage-packages": [
@@ -244,6 +286,18 @@ def package_json_file(app_path):
                         "plugin": "nil",
                         "stage-packages": ["libstdc++6", "zlib1g"],
                     },
+                    "expressjs-framework/logging": {
+                        "plugin": "nil",
+                        "override-build": (
+                            "craftctl default\n"
+                            "mkdir -p $CRAFT_PART_INSTALL/opt/promtail\n"
+                            "mkdir -p $CRAFT_PART_INSTALL/etc/promtail"
+                        ),
+                        "permissions": [
+                            {"path": "opt/promtail", "owner": 584792, "group": 584792},
+                            {"path": "etc/promtail", "owner": 584792, "group": 584792},
+                        ],
+                    },
                 },
                 "platforms": {
                     "amd64": {},
diff --git a/tests/unit/extensions/test_fastapi.py b/tests/unit/extensions/test_fastapi.py
index 87aca146b..c426aff15 100644
--- a/tests/unit/extensions/test_fastapi.py
+++ b/tests/unit/extensions/test_fastapi.py
@@ -63,11 +63,24 @@ def test_fastapi_extension_default(tmp_path, fastapi_input_yaml, packages):
                     "app.py": "app/app.py",
                 },
                 "stage": ["app/app.py"],
+                "permissions": [{"owner": 584792, "group": 584792}],
             },
             "fastapi-framework/runtime": {
                 "plugin": "nil",
                 "stage-packages": ["ca-certificates_data"],
             },
+            "fastapi-framework/logging": {
+                "plugin": "nil",
+                "override-build": (
+                    "craftctl default\n"
+                    "mkdir -p $CRAFT_PART_INSTALL/opt/promtail\n"
+                    "mkdir -p $CRAFT_PART_INSTALL/etc/promtail"
+                ),
+                "permissions": [
+                    {"path": "opt/promtail", "owner": 584792, "group": 584792},
+                    {"path": "etc/promtail", "owner": 584792, "group": 584792},
+                ],
+            },
         },
         "services": {
             "fastapi": {
@@ -149,6 +162,7 @@ def test_fastapi_extension_asgi_entrypoints(
         install_app_part["organize"].values()
     )
     assert applied["services"]["fastapi"]["command"] == command
+    assert install_app_part["permissions"] == [{"owner": 584792, "group": 584792}]
 
 
 @pytest.mark.usefixtures("fastapi_extension")
@@ -259,6 +273,7 @@ def test_fastapi_framework_exclude_prime(tmp_path, fastapi_input_yaml):
         "app/test",
         "app/webapp",
     ]
+    assert install_app_part["permissions"] == [{"owner": 584792, "group": 584792}]
 
 
 @pytest.mark.usefixtures("fastapi_extension")
@@ -282,6 +297,7 @@ def test_fastapi_framework_service_overridden(tmp_path, fastapi_input_yaml):
             "webapp.py": "app/webapp.py",
         },
         "stage": ["app/requirements.txt", "app/webapp.py"],
+        "permissions": [{"owner": 584792, "group": 584792}],
     }
 
 
diff --git a/tests/unit/extensions/test_go.py b/tests/unit/extensions/test_go.py
index 8dd88fbfe..36e893f8f 100644
--- a/tests/unit/extensions/test_go.py
+++ b/tests/unit/extensions/test_go.py
@@ -49,6 +49,7 @@ def test_go_extension_default(tmp_path, go_input_yaml):
             "go-framework/base-layout": {
                 "override-build": "mkdir -p ${CRAFT_PART_INSTALL}/app",
                 "plugin": "nil",
+                "permissions": [{"owner": 584792, "group": 584792}],
             },
             "go-framework/install-app": {
                 "plugin": "go",
@@ -63,6 +64,18 @@ def test_go_extension_default(tmp_path, go_input_yaml):
                     "ca-certificates_data",
                 ],
             },
+            "go-framework/logging": {
+                "plugin": "nil",
+                "override-build": (
+                    "craftctl default\n"
+                    "mkdir -p $CRAFT_PART_INSTALL/opt/promtail\n"
+                    "mkdir -p $CRAFT_PART_INSTALL/etc/promtail"
+                ),
+                "permissions": [
+                    {"path": "opt/promtail", "owner": 584792, "group": 584792},
+                    {"path": "etc/promtail", "owner": 584792, "group": 584792},
+                ],
+            },
         },
         "services": {
             "go": {
@@ -120,6 +133,10 @@ def test_go_extension_base_bare(tmp_path, go_input_yaml, build_environment):
     ]
     assert applied_build_environment == [{"CGO_ENABLED": "0"}, *build_environment]
 
+    assert "permissions" in applied["parts"]["go-framework/base-layout"]
+    applied_permissions = applied["parts"]["go-framework/base-layout"]["permissions"]
+    assert applied_permissions == [{"owner": 584792, "group": 584792}]
+
 
 @pytest.mark.usefixtures("go_extension")
 @pytest.mark.parametrize(
diff --git a/tests/unit/extensions/test_gunicorn.py b/tests/unit/extensions/test_gunicorn.py
index 28dbf228b..90d32aa47 100644
--- a/tests/unit/extensions/test_gunicorn.py
+++ b/tests/unit/extensions/test_gunicorn.py
@@ -77,6 +77,13 @@ def test_flask_extension_default(
                     "gunicorn.conf.py": "flask/gunicorn.conf.py",
                 },
                 "plugin": "dump",
+                "permissions": [
+                    {
+                        "path": "flask/gunicorn.conf.py",
+                        "owner": 584792,
+                        "group": 584792,
+                    }
+                ],
             },
             "flask-framework/dependencies": {
                 "plugin": "python",
@@ -95,11 +102,35 @@ def test_flask_extension_default(
                 "prime": ["flask/app/app.py", "flask/app/static"],
                 "source": ".",
                 "stage": ["flask/app/app.py", "flask/app/static"],
+                "permissions": [
+                    {
+                        "owner": 584792,
+                        "group": 584792,
+                    },
+                ],
             },
             "flask-framework/runtime": {
                 "plugin": "nil",
                 "stage-packages": ["ca-certificates_data"],
             },
+            "flask-framework/logging": {
+                "plugin": "nil",
+                "override-build": (
+                    "craftctl default\n"
+                    "mkdir -p $CRAFT_PART_INSTALL/opt/promtail\n"
+                    "mkdir -p $CRAFT_PART_INSTALL/etc/promtail\n"
+                    "mkdir -p $CRAFT_PART_INSTALL/var/log/flask"
+                ),
+                "permissions": [
+                    {"path": "opt/promtail", "owner": 584792, "group": 584792},
+                    {"path": "etc/promtail", "owner": 584792, "group": 584792},
+                    {
+                        "path": "var/log/flask",
+                        "owner": 584792,
+                        "group": 584792,
+                    },
+                ],
+            },
             "flask-framework/statsd-exporter": {
                 "build-snaps": ["go"],
                 "plugin": "go",
@@ -166,6 +197,12 @@ def test_flask_extension_prime_override(tmp_path, flask_input_yaml):
         "flask/app/requirements.txt",
         "flask/app/static",
     ]
+    assert install_app_part["permissions"] == [
+        {
+            "owner": 584792,
+            "group": 584792,
+        },
+    ]
 
 
 @pytest.mark.usefixtures("flask_extension")
@@ -200,6 +237,12 @@ def test_flask_framework_exclude_prime(tmp_path, flask_input_yaml):
         "flask/app/test",
         "flask/app/webapp",
     ]
+    assert install_app_part["permissions"] == [
+        {
+            "owner": 584792,
+            "group": 584792,
+        },
+    ]
 
 
 @pytest.mark.usefixtures("flask_extension")
@@ -285,6 +328,12 @@ def test_flask_extension_override_parts(tmp_path, flask_input_yaml):
         "stage-packages": ["python3-venv"],
         "build-environment": [],
     }
+    assert applied["parts"]["flask-framework/install-app"]["permissions"] == [
+        {
+            "owner": 584792,
+            "group": 584792,
+        },
+    ]
 
 
 @pytest.mark.parametrize(
@@ -325,6 +374,12 @@ def test_flask_extension_bare(
             {"PARTS_PYTHON_INTERPRETER": expected_python_interpreter}
         ],
     }
+    assert applied["parts"]["flask-framework/install-app"]["permissions"] == [
+        {
+            "owner": 584792,
+            "group": 584792,
+        },
+    ]
 
 
 @pytest.mark.usefixtures("flask_extension")
@@ -494,6 +549,13 @@ def test_django_extension_default(
             "django-framework/config-files": {
                 "organize": {"gunicorn.conf.py": "django/gunicorn.conf.py"},
                 "plugin": "dump",
+                "permissions": [
+                    {
+                        "path": "django/gunicorn.conf.py",
+                        "owner": 584792,
+                        "group": 584792,
+                    },
+                ],
             },
             "django-framework/dependencies": {
                 "plugin": "python",
@@ -508,11 +570,35 @@ def test_django_extension_default(
                 "plugin": "dump",
                 "source": "foo_bar",
                 "stage": ["-django/app/db.sqlite3"],
+                "permissions": [
+                    {
+                        "owner": 584792,
+                        "group": 584792,
+                    },
+                ],
             },
             "django-framework/runtime": {
                 "plugin": "nil",
                 "stage-packages": ["ca-certificates_data"],
             },
+            "django-framework/logging": {
+                "plugin": "nil",
+                "override-build": (
+                    "craftctl default\n"
+                    "mkdir -p $CRAFT_PART_INSTALL/opt/promtail\n"
+                    "mkdir -p $CRAFT_PART_INSTALL/etc/promtail\n"
+                    "mkdir -p $CRAFT_PART_INSTALL/var/log/django"
+                ),
+                "permissions": [
+                    {"path": "opt/promtail", "owner": 584792, "group": 584792},
+                    {"path": "etc/promtail", "owner": 584792, "group": 584792},
+                    {
+                        "path": "var/log/django",
+                        "owner": 584792,
+                        "group": 584792,
+                    },
+                ],
+            },
             "django-framework/statsd-exporter": {
                 "build-snaps": ["go"],
                 "plugin": "go",
@@ -568,6 +654,12 @@ def test_django_extension_override_install_app(tmp_path, django_input_yaml):
         "plugin": "dump",
         "source": ".",
         "organize": {"foobar": "django/app"},
+        "permissions": [
+            {
+                "owner": 584792,
+                "group": 584792,
+            },
+        ],
     }
 
 


Reviews:
user: login: jdkandersson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T06:16:29Z
user: login: medubelko
body: Doc changes LGTM.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T20:25:27Z
user: login: gregory-schiano
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T10:57:58Z
user: login: javierdelapuente
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T05:34:11Z
user: login: tigarmo
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T13:03:30Z
user: login: alithethird
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T04:29:58Z
user: login: alithethird
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T04:30:31Z
user: login: alithethird
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T04:30:47Z
user: login: alithethird
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T04:33:44Z
user: login: alithethird
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T04:35:09Z
user: login: alithethird
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T04:35:17Z
user: login: tigarmo
body: Thanks. I think my last concern is about the `uv.lock` file.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T08:56:18Z
user: login: alithethird
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T09:02:03Z
user: login: alithethird
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T09:08:14Z
user: login: tigarmo
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T09:26:21Z
user: login: tigarmo
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T12:36:34Z
user: login: alithethird
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T13:14:15Z
user: login: tigarmo
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-07T13:45:30Z
user: login: mr-cal
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T13:55:50Z
user: login: tigarmo
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-08T06:24:26Z

Review comments:
diff_hunk: @@ -222,6 +247,13 @@ def gen_install_app_part(self) -> dict[str, Any]:
             "organize": renaming_map,
             "stage": list(renaming_map.values()),
             "prime": self._app_prime,
+            "permissions": [
+                {
+                    "path": self.framework,
+                    "owner": 584792,
+                    "group": 584792,
+                },
+            ],
path: rockcraft/extensions/gunicorn.py
user: login: jdkandersson
body: Let's recursively update everything inside to be daemon owned
created_at: 2025-03-14T06:14:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -222,6 +247,13 @@ def gen_install_app_part(self) -> dict[str, Any]:
             "organize": renaming_map,
             "stage": list(renaming_map.values()),
             "prime": self._app_prime,
+            "permissions": [
+                {
+                    "path": self.framework,
+                    "owner": 584792,
+                    "group": 584792,
+                },
+            ],
path: rockcraft/extensions/gunicorn.py
user: login: jdkandersson
body: This should be done for all the frameworks
created_at: 2025-03-14T06:14:37Z
author_association: CONTRIBUTOR
path: tests/unit/extensions/test_gunicorn.py
user: login: jdkandersson
body: let's make a change in the spread test that checks for all the permissions
created_at: 2025-03-14T06:15:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -138,7 +140,9 @@ def _gen_install_app_part(self) -> dict[str, Any]:
                 "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"chown -R 584792:584792 ${{CRAFT_PART_INSTALL}}/lib/node_modules/{self._app_name}\n"
                 f"ln -s /lib/node_modules/{self._app_name} ${{CRAFT_PART_INSTALL}}/app\n"
+                "chown -R 584792:584792 ${CRAFT_PART_INSTALL}/app\n"
path: rockcraft/extensions/expressjs.py
user: login: gregory-schiano
body: I think it's worthwhile adding a comment to explain why we're changing the permissions here instead of using rockcraft feature (a link to the bug also)
created_at: 2025-04-10T10:48:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -38,6 +38,16 @@ execute: |
   docker run --rm --entrypoint /bin/python3 example-django -c "import pathlib;assert pathlib.Path('/django/app/manage.py').is_file()"
   docker run --rm --entrypoint /bin/python3 example-django -c "import pathlib;assert pathlib.Path('/django/app/.foobar').is_file()"
 
+  # ensure the django and promtail folders have the correct user
+  check_directory_ownership() {
+    local directory="$1"
+    docker run --rm --entrypoint /bin/bash "${IMAGE}" -c "find ${directory} -exec stat -c '%U' {} + | grep -q '^_daemon_$' || { echo 'Error: ${directory} or its contents are not owned by _daemon_'; exit 2; }"
path: tests/spread/rockcraft/extension-django/task.yaml
user: login: gregory-schiano
body: `find ${directory} ! -user '_daemon_' ...` might work (or the syntax is `-not` not sure)
created_at: 2025-04-10T10:52:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -77,22 +77,34 @@ def package_json_file(app_path):
                         "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"
-                        ),
+                        "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"chown -R 584792:584792 ${{CRAFT_PART_INSTALL}}/lib/node_modules/{_expressjs_project_name}\n"
+                        f"ln -s /lib/node_modules/{_expressjs_project_name} "
+                        "${CRAFT_PART_INSTALL}/app\n"
+                        "chown -R 584792:584792 ${CRAFT_PART_INSTALL}/app\n",
                         "build-packages": ["nodejs", "npm"],
                         "stage-packages": ["ca-certificates_data", "nodejs_bins"],
                     },
                     "expressjs-framework/runtime": {
                         "plugin": "nil",
                         "stage-packages": ["npm"],
                     },
+                    "expressjs-framework/logging": {
+                        "plugin": "nil",
+                        "override-build": (
+                            "craftctl default\n"
+                            "mkdir -p $CRAFT_PART_INSTALL/opt/promtail\n"
+                            "mkdir -p $CRAFT_PART_INSTALL/etc/promtail"
+                        ),
+                        "permissions": [
+                            {"path": "opt/promtail", "owner": 584792, "group": 584792},
path: tests/unit/extensions/test_expressjs.py
user: login: gregory-schiano
body: Might be useful to set a constant for owner ID and group ID and use it everywhere
created_at: 2025-04-10T10:56:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,6 +22,7 @@ platforms:
   amd64:
   armhf:
     build-on: ["armhf", "arm64"]
+    build-for: ["armhf", "arm64"]
path: docs/reference/code/example/rockcraft.yaml
user: login: tigarmo
body: why is this updated?
This probably doesn't have the semantics you expect: it means that you can build on either `armhf` or `arm64`, and will create a rock that works on both archs (which is currently not supported)
created_at: 2025-04-28T12:50:03Z
author_association: COLLABORATOR
diff_hunk: @@ -7,6 +7,7 @@
 ###########################################
 summary: Getting started with ExpressJS tutorial
 
+kill-timeout: 1h
path: docs/tutorial/code/expressjs/task.yaml
user: login: tigarmo
body: how come? 
created_at: 2025-04-28T12:50:22Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,46 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright 2025 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 <http://www.gnu.org/licenses/>.
+
+"""Common extension application parts."""
+
+USER_ID = 584792
path: rockcraft/extensions/app_parts.py
user: login: tigarmo
body: I think this should be read from `rockcraft.usernames`
created_at: 2025-04-28T12:51:25Z
author_association: COLLABORATOR
diff_hunk: @@ -97,20 +98,42 @@ def _gen_parts(self) -> dict[str, Any]:
                 "python-requirements": ["requirements.txt"],
                 "build-environment": build_environment,
             },
-            f"{self.framework}-framework/install-app": self.gen_install_app_part(),
+            f"{self.framework}-framework/install-app": {
+                **self.gen_install_app_part(),
+                "permissions": [{"owner": USER_ID, "group": GROUP_ID}],
path: rockcraft/extensions/gunicorn.py
user: login: tigarmo
body: Can I ask the motivation for this PR? The title says "let" charms use non-root users, but it seems to me that all 12f extensions are updated to *always* use the non-root user? Isn't this a breaking change?

Also is this assuming that the rockcraft.yaml author will add `run-user: _daemon_` themselves? Is this checked anywhere?
created_at: 2025-04-28T12:58:30Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,46 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright 2025 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 <http://www.gnu.org/licenses/>.
+
+"""Common extension application parts."""
+
+USER_ID = 584792
+GROUP_ID = 584792
+
+
+def gen_logging_part(
+    override_build_lines: list[str] | None = None,
+    permissions: list[dict[str, str | int]] | None = None,
+) -> dict[str, str | list[dict[str, str | int]]]:
+    """Generate a logging part for the application."""
+    _override_build_lines = [
+        "craftctl default",
+        "mkdir -p $CRAFT_PART_INSTALL/opt/promtail",
path: rockcraft/extensions/app_parts.py
user: login: tigarmo
body: what's the relation between non-root users and logging?
created_at: 2025-04-28T13:00:09Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,46 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright 2025 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 <http://www.gnu.org/licenses/>.
+
+"""Common extension application parts."""
+
+USER_ID = 584792
+GROUP_ID = 584792
+
+
+def gen_logging_part(
+    override_build_lines: list[str] | None = None,
+    permissions: list[dict[str, str | int]] | None = None,
+) -> dict[str, str | list[dict[str, str | int]]]:
+    """Generate a logging part for the application."""
+    _override_build_lines = [
+        "craftctl default",
+        "mkdir -p $CRAFT_PART_INSTALL/opt/promtail",
path: rockcraft/extensions/app_parts.py
user: login: tigarmo
body: who generates/consumes these log files?
created_at: 2025-04-28T13:03:09Z
author_association: COLLABORATOR
diff_hunk: @@ -22,6 +22,7 @@ platforms:
   amd64:
   armhf:
     build-on: ["armhf", "arm64"]
+    build-for: ["armhf", "arm64"]
path: docs/reference/code/example/rockcraft.yaml
user: login: alithethird
body: This was a mistake that will be deleted.
I only add it because one of the tests were failing and I thought this was the fix for it but it was not.
created_at: 2025-04-29T04:29:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7,6 +7,7 @@
 ###########################################
 summary: Getting started with ExpressJS tutorial
 
+kill-timeout: 1h
path: docs/tutorial/code/expressjs/task.yaml
user: login: alithethird
body: Sometimes npm install takes quite some time.
created_at: 2025-04-29T04:30:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,46 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright 2025 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 <http://www.gnu.org/licenses/>.
+
+"""Common extension application parts."""
+
+USER_ID = 584792
path: rockcraft/extensions/app_parts.py
user: login: alithethird
body: I did not know that existed, thank you! I will use that.
created_at: 2025-04-29T04:30:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,20 +98,42 @@ def _gen_parts(self) -> dict[str, Any]:
                 "python-requirements": ["requirements.txt"],
                 "build-environment": build_environment,
             },
-            f"{self.framework}-framework/install-app": self.gen_install_app_part(),
+            f"{self.framework}-framework/install-app": {
+                **self.gen_install_app_part(),
+                "permissions": [{"owner": USER_ID, "group": GROUP_ID}],
path: rockcraft/extensions/gunicorn.py
user: login: alithethird
body: These changes just set the directories/files that will be used by pebble to `_daemon_` user. This is not a breaking change. 
12f extensions still default to root users and root users are still able to use the directories owned by `_daemon_`

It is not assuming `run-user: _daemon_` to be added anywhere. These changes are just there to resolve permission errors incase the use wants to enable non-root.
created_at: 2025-04-29T04:33:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,46 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright 2025 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 <http://www.gnu.org/licenses/>.
+
+"""Common extension application parts."""
+
+USER_ID = 584792
+GROUP_ID = 584792
+
+
+def gen_logging_part(
+    override_build_lines: list[str] | None = None,
+    permissions: list[dict[str, str | int]] | None = None,
+) -> dict[str, str | list[dict[str, str | int]]]:
+    """Generate a logging part for the application."""
+    _override_build_lines = [
+        "craftctl default",
+        "mkdir -p $CRAFT_PART_INSTALL/opt/promtail",
path: rockcraft/extensions/app_parts.py
user: login: alithethird
body: Promtail directories are used by Loki and in the loki charm lib they create these directories in runtime if they don't exist.

If the charm is `non-root` Loki charm lib can not create the directories and fail, so we create them beforehand and set the user.
created_at: 2025-04-29T04:35:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,46 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright 2025 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 <http://www.gnu.org/licenses/>.
+
+"""Common extension application parts."""
+
+USER_ID = 584792
+GROUP_ID = 584792
+
+
+def gen_logging_part(
+    override_build_lines: list[str] | None = None,
+    permissions: list[dict[str, str | int]] | None = None,
+) -> dict[str, str | list[dict[str, str | int]]]:
+    """Generate a logging part for the application."""
+    _override_build_lines = [
+        "craftctl default",
+        "mkdir -p $CRAFT_PART_INSTALL/opt/promtail",
path: rockcraft/extensions/app_parts.py
user: login: alithethird
body: Loki charm library.
created_at: 2025-04-29T04:35:17Z
author_association: CONTRIBUTOR
path: uv.lock
user: login: tigarmo
body: why is this file changed? Is there a package update?
created_at: 2025-05-07T08:56:01Z
author_association: COLLABORATOR
path: uv.lock
user: login: alithethird
body: With the new `uv` version the cli version output is changed and in `craft-parts 2.9.1` they fixed this issue. https://github.com/canonical/craft-parts/releases/tag/2.9.1
created_at: 2025-05-07T09:02:02Z
author_association: CONTRIBUTOR
path: uv.lock
user: login: alithethird
body: The problem also affects the other PR's like [this doc pr](https://github.com/canonical/rockcraft/actions/runs/14837500375/job/41652045896?pr=877)
created_at: 2025-05-07T09:08:14Z
author_association: CONTRIBUTOR
path: uv.lock
user: login: tigarmo
body: I see; I'd like to keep the craft-parts update separate so I opened [this PR](https://github.com/canonical/rockcraft/pull/879). Once that's merged we can undo the changes here
created_at: 2025-05-07T09:26:20Z
author_association: COLLABORATOR
path: uv.lock
user: login: tigarmo
body: @alithethird that PR is now merged; can you undo the uv.lock changes please?
created_at: 2025-05-07T12:36:33Z
author_association: COLLABORATOR
path: uv.lock
user: login: alithethird
body: @tigarmo   Merged main into this pr.
created_at: 2025-05-07T13:14:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,5 +1,5 @@
 version = 1
-revision = 2
+revision = 1
path: uv.lock
user: login: tigarmo
body: ```suggestion
revision = 2
```

I believe the version in `main` has `revision = 2`, so that needs to persist.
created_at: 2025-05-08T06:24:10Z
author_association: COLLABORATOR

PR comments:
user: login: alithethird
created_at: 2025-05-08T04:45:52Z
body: @tigarmo Can we merge?
I don't have permission to merge.
author_association: CONTRIBUTOR
user: login: tigarmo
created_at: 2025-05-08T06:24:59Z
body: @alithethird I'll merge this as soon as the spread tests finish 
author_association: COLLABORATOR
user: login: alithethird
created_at: 2025-05-08T10:23:58Z
body: @tigarmo can you restart the fast tests?
Thank you :)
author_association: CONTRIBUTOR