diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48f3c38f..83c336e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,16 +64,21 @@ jobs: if: ${{ !cancelled() && steps.set_git_branch.conclusion == 'success' }} # Do not remove the blank lines from the input. run: | - printf "latest\n" | \ + printf "edge\n" | \ GIT_BRANCH="${GIT_BRANCH}" SKIP_PULL=true sudo -E ./deploy/auto-install.sh --upgrade \ || (cat /opt/openwisp/autoinstall.log && exit 1) + - name: Fix permissions for CI user + if: ${{ !cancelled() && steps.auto_install_upgrade.conclusion == 'success' }} + run: | + sudo chown -R $USER:$USER /opt/openwisp + - name: Test if: ${{ !cancelled() && steps.auto_install_upgrade.conclusion == 'success' }} uses: openwisp/openwisp-utils/.github/actions/retry-command@master with: delay_seconds: 30 - max_attempts: 5 + max_attempts: 1 # The auto-install script installs docker-openwisp by default in # /opt/openwisp/docker-openwisp. To ensure the test runs correctly # and environment variables remain intact, it is essential to diff --git a/Makefile b/Makefile index 12f8da46..b5ef7f32 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # the heading "Makefile Options". # The .env file can override ?= variables in the Makefile (e.g. OPENWISP_VERSION, IMAGE_OWNER) -include .env +include .env # RELEASE_VERSION: version string used when tagging a new release. RELEASE_VERSION = 25.10.0 diff --git a/docker-compose.yml b/docker-compose.yml index 67e57f2b..62265c3e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ services: - openwisp_ssh:/home/openwisp/.ssh - influxdb_data:/var/lib/influxdb - ./customization/configuration/django/:/opt/openwisp/openwisp/configuration:ro + - ./customization/theme:/opt/openwisp/static_custom:ro depends_on: - postgres - redis @@ -128,7 +129,7 @@ services: - openwisp_media:/opt/openwisp/public/media:ro - openwisp_private_storage:/opt/openwisp/public/private:ro - openwisp_certs:/etc/letsencrypt - - ./customization/theme:/opt/openwisp/public/custom:ro + - ./customization/nginx:/opt/openwisp/public/custom:ro networks: default: aliases: diff --git a/docs/user/customization.rst b/docs/user/customization.rst index df0afa06..a958189f 100644 --- a/docs/user/customization.rst +++ b/docs/user/customization.rst @@ -24,6 +24,7 @@ adding customizations. Execute these commands in the same location as the touch customization/configuration/django/__init__.py touch customization/configuration/django/custom_django_settings.py mkdir -p customization/theme + mkdir -p customization/nginx You can also refer to the `directory structure of Docker OpenWISP repository @@ -84,16 +85,24 @@ follow the following guide. 2. Create your custom CSS / Javascript file in ``customization/theme`` directory created in the above section. E.g. - ``customization/theme/static/custom/css/custom-theme.css``. -3. Start the nginx containers. + ``customization/theme/custom/css/custom-theme.css``. +3. Recreate the dashboard container to apply the changes: + +.. code-block:: shell + + docker compose up -d --force-recreate dashboard .. note:: - 1. You can edit the styles / JavaScript files now without restarting - the container, as long as file is in the correct place, it will be - picked. - 2. You can create a ``maintenance.html`` file inside the ``customize`` - directory to have a custom maintenance page for scheduled downtime. + After adding, updating, or removing files in ``customization/theme``, + you must recreate the dashboard container using the command above. + + Alternatively, you can apply changes without recreating the container + by running: + + .. code-block:: shell + + docker compose exec dashboard bash -c "python collectstatic.py && uwsgi --reload uwsgi.pid" Supplying Custom uWSGI configuration ------------------------------------ @@ -175,6 +184,21 @@ Docker PATH/TO/YOUR/DEFAULT:/etc/raddb/sites-enabled/default ... +Enabling Maintenance Mode +~~~~~~~~~~~~~~~~~~~~~~~~~ + +To enable maintenance mode, create a ``maintenance.html`` file in the +``customization/nginx/`` directory: + +.. code-block:: shell + + customization/nginx/maintenance.html + +When this file is present, Nginx will automatically serve it instead of +the application for incoming requests. + +To disable maintenance mode, simply remove the file. + Supplying Custom Python Source Code ----------------------------------- diff --git a/images/common/collectstatic.py b/images/common/collectstatic.py index 373b773f..1741615a 100644 --- a/images/common/collectstatic.py +++ b/images/common/collectstatic.py @@ -26,11 +26,41 @@ def get_pip_freeze_hash(): sys.exit(1) -def run_collectstatic(): +def get_dir_shasum(directory_path): + """Return a sha256 hexdigest of all files (names + contents) under directory_path. + + If the directory does not exist, return the hash of empty contents. + """ + if not os.path.exists(directory_path): + return hashlib.sha256(b"").hexdigest() + hasher = hashlib.sha256() + for root, dirs, files in os.walk(directory_path): + dirs.sort() + files.sort() + for fname in files: + fpath = os.path.join(root, fname) + relpath = os.path.relpath(fpath, directory_path) + try: + file_hasher = hashlib.sha256() + with open(fpath, "rb") as fh: + for chunk in iter(lambda: fh.read(4096), b""): + file_hasher.update(chunk) + relpath_bytes = relpath.encode() + hasher.update(len(relpath_bytes).to_bytes(8, "big")) + hasher.update(relpath_bytes) + hasher.update(file_hasher.digest()) + except OSError: + # If a file can't be read, skip it but continue hashing others + continue + return hasher.hexdigest() + + +def run_collectstatic(clear=False): try: - subprocess.run( - [sys.executable, "manage.py", "collectstatic", "--noinput"], check=True - ) + cmd = [sys.executable, "manage.py", "collectstatic", "--noinput"] + if clear: + cmd.append("--clear") + subprocess.run(cmd, check=True) except subprocess.CalledProcessError as e: print(f"Error running 'collectstatic': {e}", file=sys.stderr) sys.exit(1) @@ -38,17 +68,36 @@ def run_collectstatic(): def main(): if os.environ.get("COLLECTSTATIC_WHEN_DEPS_CHANGE", "true").lower() == "false": - run_collectstatic() + run_collectstatic(clear=True) return redis_connection = redis.Redis.from_url(settings.CACHES["default"]["LOCATION"]) current_pip_hash = get_pip_freeze_hash() + current_static_hash = get_dir_shasum( + os.path.join(settings.BASE_DIR, "static_custom") + ) cached_pip_hash = redis_connection.get("pip_freeze_hash") - if not cached_pip_hash or cached_pip_hash.decode() != current_pip_hash: - print("Changes in Python dependencies detected, running collectstatic...") - run_collectstatic() - redis_connection.set("pip_freeze_hash", current_pip_hash) + cached_static_hash = redis_connection.get("static_custom_hash") + pip_changed = not cached_pip_hash or cached_pip_hash.decode() != current_pip_hash + static_changed = ( + not cached_static_hash or cached_static_hash.decode() != current_static_hash + ) + if pip_changed or static_changed: + print( + "Changes in Python dependencies or static_custom detected," + " running collectstatic..." + ) + run_collectstatic(clear=static_changed) + try: + redis_connection.set("pip_freeze_hash", current_pip_hash) + redis_connection.set("static_custom_hash", current_static_hash) + except Exception: + # If caching fails, don't crash the startup; collectstatic already ran + pass else: - print("No changes in Python dependencies, skipping collectstatic...") + print( + "No changes in Python dependencies or static_custom," + " skipping collectstatic..." + ) if __name__ == "__main__": diff --git a/images/common/openwisp/settings.py b/images/common/openwisp/settings.py index a6f1e512..048de604 100644 --- a/images/common/openwisp/settings.py +++ b/images/common/openwisp/settings.py @@ -284,6 +284,7 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.9/howto/static-files/ +STATICFILES_DIRS = [os.path.join(BASE_DIR, "static_custom")] STATIC_ROOT = os.path.join(BASE_DIR, "static") MEDIA_ROOT = os.path.join(BASE_DIR, "media") # PRIVATE_STORAGE_ROOT path should be similar to ansible-openwisp2 diff --git a/tests/config.json b/tests/config.json index a4b9d185..a9fe43bd 100644 --- a/tests/config.json +++ b/tests/config.json @@ -9,5 +9,6 @@ "username": "admin", "password": "admin", "services_max_retries": 25, - "services_delay_retries": 5 + "services_delay_retries": 5, + "custom_css_filename": "custom-openwisp-test.css" } diff --git a/tests/runtests.py b/tests/runtests.py index df9c964f..7eb5bb5f 100644 --- a/tests/runtests.py +++ b/tests/runtests.py @@ -59,11 +59,84 @@ def test_wait_for_services(self): class TestServices(TestUtilities, unittest.TestCase): + custom_static_token = None + @property def failureException(self): TestServices.failed_test = True return super().failureException + @classmethod + def _execute_docker_compose_command(cls, cmd_args, use_text_mode=False): + """Execute a docker compose command and log output. + + Args: + cmd_args: List of command arguments for subprocess.Popen + use_text_mode: If True, use text mode for subprocess output + + Returns: + Tuple of (output, error) from command execution + """ + kwargs = { + "stdout": subprocess.PIPE, + "stderr": subprocess.PIPE, + "cwd": cls.root_location, + } + if use_text_mode: + kwargs["text"] = True + cmd = subprocess.run(cmd_args, check=False, **kwargs) + output, error = map(str, (cmd.stdout, cmd.stderr)) + with open(cls.config["logs_file"], "a") as logs_file: + logs_file.write(output) + logs_file.write(error) + if cmd.returncode != 0: + raise RuntimeError( + f"docker compose command failed " + f"({cmd.returncode}): {' '.join(cmd_args)}" + ) + return output, error + + @classmethod + def _setup_admin_theme_links(cls): + """Configure admin theme links during tests. + + The default docker-compose setup does not allow injecting + OPENWISP_ADMIN_THEME_LINKS dynamically, so this method updates + Django settings inside the running container and reloads uWSGI. + This enables the Selenium tests to verify that a custom static CSS + file is served by the admin interface. + """ + css_path = os.path.join( + cls.root_location, + "customization", + "theme", + cls.config["custom_css_filename"], + ) + cls.custom_static_token = str(time.time_ns()) + with open(css_path, "w") as custom_css_file: + custom_css_file.write( + f"body{{--openwisp-test: {cls.custom_static_token};}}" + ) + script = rf""" + grep -q OPENWISP_ADMIN_THEME_LINKS /opt/openwisp/openwisp/settings.py || \ + printf "\nOPENWISP_ADMIN_THEME_LINKS=[{{\"type\":\"text/css\",\"href\":\"/static/admin/css/openwisp.css\",\"rel\":\"stylesheet\",\"media\":\"all\"}},{{\"type\":\"text/css\",\"href\":\"/static/{cls.config["custom_css_filename"]}\",\"rel\":\"stylesheet\",\"media\":\"all\"}},{{\"type\":\"image/x-icon\",\"href\":\"ui/openwisp/images/favicon.png\",\"rel\":\"icon\"}}]\n" >> /opt/openwisp/openwisp/settings.py && + python collectstatic.py && + uwsgi --reload uwsgi.pid + """ # noqa: E501 + cls._execute_docker_compose_command( + [ + "docker", + "compose", + "exec", + "-T", + "dashboard", + "bash", + "-c", + script, + ], + use_text_mode=True, + ) + @classmethod def setUpClass(cls): cls.failed_test = False @@ -76,7 +149,7 @@ def setUpClass(cls): os.path.dirname(os.path.realpath(__file__)), "data.py" ) entrypoint = "python manage.py shell --command='import data; data.setup()'" - cmd = subprocess.Popen( + cls._execute_docker_compose_command( [ "docker", "compose", @@ -87,22 +160,12 @@ def setUpClass(cls): "--volume", f"{test_data_file}:/opt/openwisp/data.py", "dashboard", - ], - universal_newlines=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - cwd=cls.root_location, + ] ) - output, error = map(str, cmd.communicate()) - with open(cls.config["logs_file"], "w") as logs_file: - logs_file.write(output) - logs_file.write(error) - subprocess.run( + cls._execute_docker_compose_command( ["docker", "compose", "up", "--detach"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - cwd=cls.root_location, ) + cls._setup_admin_theme_links() # Create base drivers (Firefox) if cls.config["driver"] == "firefox": cls.base_driver = cls.get_firefox_webdriver() @@ -122,6 +185,15 @@ def tearDownClass(cls): print(f"Unable to delete resource at: {resource_link}") cls.second_driver.quit() cls.base_driver.quit() + # Remove the temporary custom CSS file created for testing + css_path = os.path.join( + cls.root_location, + "customization", + "theme", + cls.config["custom_css_filename"], + ) + if os.path.exists(css_path): + os.remove(css_path) if cls.failed_test and cls.config["logs"]: cmd = subprocess.Popen( ["docker", "compose", "logs"], @@ -185,6 +257,16 @@ def test_admin_login(self): ) self.fail(message) + def test_custom_static_files_loaded(self): + self.login() + self.open("/admin/") + # Check if the custom CSS variable is applied + value = self.web_driver.execute_script( + "return getComputedStyle(document.body)" + ".getPropertyValue('--openwisp-test');" + ) + self.assertEqual(value.strip(), self.custom_static_token) + def test_device_monitoring_charts(self): self.login() self.get_resource("test-device", "/admin/config/device/") @@ -227,9 +309,11 @@ def test_create_prefix_users(self): prefix_pdf_file_path = self.base_driver.find_element( By.XPATH, '//a[text()="Download User Credentials"]' ).get_property("href") - reqHeader = { - "Cookie": f"sessionid={self.base_driver.get_cookies()[0]['value']}" - } + reqHeader = {} + for cookies in self.base_driver.get_cookies(): + if cookies["name"] == "sessionid": + reqHeader = {"Cookie": f"sessionid={cookies['value']}"} + break curlRequest = request.Request(prefix_pdf_file_path, headers=reqHeader) try: if request.urlopen(curlRequest, context=self.ctx).getcode() != 200: @@ -326,9 +410,10 @@ def test_add_superuser(self): def test_forgot_password(self): """Test forgot password to ensure that postfix is working properly.""" + self.logout() self.open("/accounts/password/reset/") self.find_element(By.NAME, "email").send_keys("admin@example.com") - self.find_element(By.XPATH, '//button[@type="submit"]').click() + self.find_element(By.CSS_SELECTOR, 'button[type="submit"]').click() self._wait_until_page_ready() self.assertIn( "We have sent you an email. If you have not received " @@ -362,9 +447,7 @@ def test_celery(self): "openwisp_firmware_upgrader.tasks.create_all_device_firmwares", "openwisp_firmware_upgrader.tasks.create_device_firmware", "openwisp_firmware_upgrader.tasks.upgrade_firmware", - "openwisp_monitoring.check.tasks.auto_create_config_check", - "openwisp_monitoring.check.tasks.auto_create_iperf3_check", - "openwisp_monitoring.check.tasks.auto_create_ping", + "openwisp_monitoring.check.tasks.auto_create_check", "openwisp_monitoring.check.tasks.perform_check", "openwisp_monitoring.check.tasks.run_checks", "openwisp_monitoring.device.tasks.delete_wifi_clients_and_sessions", @@ -385,7 +468,6 @@ def test_celery(self): "openwisp_notifications.tasks.ns_organization_user_deleted", "openwisp_notifications.tasks.ns_register_unregister_notification_type", "openwisp_notifications.tasks.update_org_user_notificationsetting", - "openwisp_notifications.tasks.update_superuser_notification_settings", "openwisp_radius.tasks.cleanup_stale_radacct", "openwisp_radius.tasks.convert_called_station_id", "openwisp_radius.tasks.deactivate_expired_users", @@ -490,7 +572,4 @@ def test_containers_down(self): if __name__ == "__main__": - suite = unittest.TestSuite() - suite.addTest(TestServices("test_topology_graph")) - runner = unittest.TextTestRunner(verbosity=2) - runner.run(suite) + unittest.main(verbosity=2)