Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,19 @@ 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)

# The test suite needs to create files during execution. Because the deploy
# script runs with `sudo`, the installation directory ends up owned by root,
# making it inaccessible to the unprivileged CI user. This step fixes the
# ownership so the test suite can write the required files.
- name: Fix permissions for CI user
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a comment explaining why this is needed please.

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
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
38 changes: 31 additions & 7 deletions docs/user/customization.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
------------------------------------
Expand Down Expand Up @@ -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
-----------------------------------

Expand Down
69 changes: 59 additions & 10 deletions images/common/collectstatic.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,29 +26,78 @@ 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)


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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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__":
Expand Down
1 change: 1 addition & 0 deletions images/common/openwisp/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion tests/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
125 changes: 110 additions & 15 deletions tests/runtests.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,89 @@ 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)
if use_text_mode:
output, error = cmd.stdout, cmd.stderr
else:
output = cmd.stdout.decode("utf-8", errors="replace") if cmd.stdout else ""
error = cmd.stderr.decode("utf-8", errors="replace") if cmd.stderr else ""
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
Comment on lines +69 to +102
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider the bytes-to-string conversion for log output.

When use_text_mode=False, cmd.stdout and cmd.stderr are bytes objects. Using map(str, ...) on bytes produces strings like "b'actual content'" rather than decoded text, which makes the logs harder to read.

♻️ Proposed improvement
-        cmd = subprocess.run(cmd_args, check=False, **kwargs)
-        output, error = map(str, (cmd.stdout, cmd.stderr))
+        cmd = subprocess.run(cmd_args, check=False, **kwargs)
+        if use_text_mode:
+            output, error = cmd.stdout, cmd.stderr
+        else:
+            output = cmd.stdout.decode("utf-8", errors="replace") if cmd.stdout else ""
+            error = cmd.stderr.decode("utf-8", errors="replace") if cmd.stderr else ""
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/runtests.py` around lines 67 - 95, The _execute_docker_compose_command
function currently uses map(str, (cmd.stdout, cmd.stderr)) which yields b'...'
style strings when use_text_mode is False; change the handling so that when
use_text_mode is True you use cmd.stdout/cmd.stderr directly, otherwise decode
the bytes using .decode('utf-8', errors='replace') (or similar) to produce
proper text; ensure you update the variables output and error accordingly before
writing to cls.config["logs_file"] and before returning, referencing the
_execute_docker_compose_command method and the cmd.stdout/cmd.stderr variables.


@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
Expand All @@ -76,7 +154,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",
Expand All @@ -87,22 +165,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()
Expand All @@ -122,6 +190,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"],
Expand Down Expand Up @@ -156,6 +233,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/")
Expand Down Expand Up @@ -235,9 +322,17 @@ def test_forgot_password(self):
"""Test forgot password to ensure that postfix is working properly."""

self.logout()
try:
WebDriverWait(self.base_driver, 3).until(
EC.text_to_be_present_in_element(
(By.CSS_SELECTOR, ".title-wrapper h1"), "Logged out"
)
)
except TimeoutException:
self.fail("Logout failed.")
Comment thread
nemesifier marked this conversation as resolved.
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 "
Expand Down
Loading