Skip to content

Commit 7bad628

Browse files
authored
Add upload_max_filesize, post_max_size, and max_execution_time configuration (#259)
1 parent 7a2e877 commit 7bad628

File tree

6 files changed

+183
-16
lines changed

6 files changed

+183
-16
lines changed

config.yaml

+16
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,19 @@ options:
100100
description: >
101101
This setting specifies the duration, in seconds, that pebble will wait for a WordPress health check to complete
102102
before timing out. Use this setting to adjust the timeout based on expected system performance and conditions
103+
upload_max_filesize:
104+
type: string
105+
default: 2M
106+
description: >
107+
The maximum size of an uploaded file. https://www.php.net/manual/en/ini.core.php#ini.upload-max-filesize
108+
post_max_size:
109+
type: string
110+
default: 8M
111+
description: >
112+
Sets max size of post data allowed. https://www.php.net/manual/en/ini.core.php#ini.post-max-size
113+
max_execution_time:
114+
type: int
115+
default: 30
116+
description: >
117+
This sets the maximum time in seconds a script is allowed to run before it is terminated by the parser.
118+
https://www.php.net/manual/en/info.configuration.php#ini.max-execution-time

lib/charms/nginx_ingress_integrator/v0/nginx_route.py

+56-14
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Copyright 2024 Canonical Ltd.
2-
# Licensed under the Apache2.0, see LICENCE file in charm source for details.
2+
# Licensed under the Apache2.0. See LICENSE file in charm source for details.
33
"""Library for the nginx-route relation.
44
55
This library contains the require and provide functions for handling
@@ -13,6 +13,7 @@
1313
1414
Other optional arguments include:
1515
- additional_hostnames
16+
- backend_protocol
1617
- limit_rps
1718
- limit_whitelist
1819
- max_body_size
@@ -33,12 +34,12 @@
3334
```python
3435
from charms.nginx_ingress_integrator.v0.nginx_route import NginxRouteRequirer
3536
36-
# In your charm's `__init__` method.
37+
# In your charm's `__init__` method (assuming your app is listening on port 8080).
3738
require_nginx_route(
3839
charm=self,
39-
service_hostname=self.config["external_hostname"],
40+
service_hostname=self.app.name,
4041
service_name=self.app.name,
41-
service_port=80
42+
service_port=8080
4243
)
4344
4445
```
@@ -51,6 +52,23 @@
5152
You _must_ require nginx route as part of the `__init__` method
5253
rather than, for instance, a config-changed event handler, for the relation
5354
changed event to be properly handled.
55+
56+
In the example above we're setting `service_hostname` (which translates to the
57+
external hostname for the application when related to nginx-ingress-integrator)
58+
to `self.app.name` here. This ensures by default the charm will be available on
59+
the name of the deployed juju application, but can be overridden in a
60+
production deployment by setting `service-hostname` on the
61+
nginx-ingress-integrator charm. For example:
62+
```bash
63+
juju deploy nginx-ingress-integrator
64+
juju deploy my-charm
65+
juju relate nginx-ingress-integrator my-charm:nginx-route
66+
# The service is now reachable on the ingress IP(s) of your k8s cluster at
67+
# 'http://my-charm'.
68+
juju config nginx-ingress-integrator service-hostname='my-charm.example.com'
69+
# The service is now reachable on the ingress IP(s) of your k8s cluster at
70+
# 'http://my-charm.example.com'.
71+
```
5472
"""
5573
import logging
5674
import typing
@@ -61,14 +79,14 @@
6179
import ops.model
6280

6381
# The unique Charmhub library identifier, never change it
64-
LIBID = "c13d5d639bcd09f8c4f5b195264ed53d"
82+
LIBID = "3c212b6ed3cf43dfbf9f2e322e634beb"
6583

6684
# Increment this major API version when introducing breaking changes
6785
LIBAPI = 0
6886

6987
# Increment this PATCH version before using `charmcraft publish-lib` or reset
7088
# to 0 if you are raising the major API version
71-
LIBPATCH = 1
89+
LIBPATCH = 7
7290

7391
__all__ = ["require_nginx_route", "provide_nginx_route"]
7492

@@ -101,7 +119,7 @@ class _NginxRouteCharmEvents(ops.charm.CharmEvents):
101119
nginx_route_broken = ops.framework.EventSource(_NginxRouteBrokenEvent)
102120

103121

104-
class _NginxRouteRequirer(ops.framework.Object):
122+
class NginxRouteRequirer(ops.framework.Object):
105123
"""This class defines the functionality for the 'requires' side of the 'nginx-route' relation.
106124
107125
Hook events observed:
@@ -130,7 +148,7 @@ def __init__(
130148
self._config_reconciliation,
131149
)
132150
# Set default values.
133-
self._config: typing.Dict[str, typing.Union[str, int, bool]] = {
151+
self.config: typing.Dict[str, typing.Union[str, int, bool]] = {
134152
"service-namespace": self._charm.model.name,
135153
**config,
136154
}
@@ -145,20 +163,25 @@ def _config_reconciliation(self, _event: typing.Any = None) -> None:
145163
delete_keys = {
146164
relation_field
147165
for relation_field in relation_app_data
148-
if relation_field not in self._config
166+
if relation_field not in self.config
149167
}
150168
for delete_key in delete_keys:
151169
del relation_app_data[delete_key]
152-
relation_app_data.update({k: str(v) for k, v in self._config.items()})
170+
relation_app_data.update({k: str(v) for k, v in self.config.items()})
153171

154172

155-
def require_nginx_route( # pylint: disable=too-many-locals,too-many-branches
173+
# C901 is ignored since the method has too many ifs but wouldn't be
174+
# necessarily good to reduce to smaller methods.
175+
# E501: line too long
176+
def require_nginx_route( # pylint: disable=too-many-locals,too-many-branches,too-many-arguments # noqa: C901,E501
156177
*,
157178
charm: ops.charm.CharmBase,
158179
service_hostname: str,
159180
service_name: str,
160181
service_port: int,
161182
additional_hostnames: typing.Optional[str] = None,
183+
backend_protocol: typing.Optional[str] = None,
184+
enable_access_log: typing.Optional[bool] = None,
162185
limit_rps: typing.Optional[int] = None,
163186
limit_whitelist: typing.Optional[str] = None,
164187
max_body_size: typing.Optional[int] = None,
@@ -172,7 +195,7 @@ def require_nginx_route( # pylint: disable=too-many-locals,too-many-branches
172195
session_cookie_max_age: typing.Optional[int] = None,
173196
tls_secret_name: typing.Optional[str] = None,
174197
nginx_route_relation_name: str = "nginx-route",
175-
) -> None:
198+
) -> NginxRouteRequirer:
176199
"""Set up nginx-route relation handlers on the requirer side.
177200
178201
This function must be invoked in the charm class constructor.
@@ -187,6 +210,10 @@ def require_nginx_route( # pylint: disable=too-many-locals,too-many-branches
187210
option via relation.
188211
additional_hostnames: configure Nginx ingress integrator
189212
additional-hostnames option via relation, optional.
213+
backend_protocol: configure Nginx ingress integrator
214+
backend-protocol option via relation, optional.
215+
enable_access_log: configure Nginx ingress
216+
nginx.ingress.kubernetes.io/enable-access-log option.
190217
limit_rps: configure Nginx ingress integrator limit-rps
191218
option via relation, optional.
192219
limit_whitelist: configure Nginx ingress integrator
@@ -215,6 +242,9 @@ def require_nginx_route( # pylint: disable=too-many-locals,too-many-branches
215242
nginx_route_relation_name: Specifies the relation name of
216243
the relation handled by this requirer class. The relation
217244
must have the nginx-route interface.
245+
246+
Returns:
247+
the NginxRouteRequirer.
218248
"""
219249
config: typing.Dict[str, typing.Union[str, int, bool]] = {}
220250
if service_hostname is not None:
@@ -225,6 +255,10 @@ def require_nginx_route( # pylint: disable=too-many-locals,too-many-branches
225255
config["service-port"] = service_port
226256
if additional_hostnames is not None:
227257
config["additional-hostnames"] = additional_hostnames
258+
if backend_protocol is not None:
259+
config["backend-protocol"] = backend_protocol
260+
if enable_access_log is not None:
261+
config["enable-access-log"] = "true" if enable_access_log else "false"
228262
if limit_rps is not None:
229263
config["limit-rps"] = limit_rps
230264
if limit_whitelist is not None:
@@ -250,7 +284,7 @@ def require_nginx_route( # pylint: disable=too-many-locals,too-many-branches
250284
if tls_secret_name is not None:
251285
config["tls-secret-name"] = tls_secret_name
252286

253-
_NginxRouteRequirer(
287+
return NginxRouteRequirer(
254288
charm=charm, config=config, nginx_route_relation_name=nginx_route_relation_name
255289
)
256290

@@ -297,6 +331,9 @@ def _on_relation_changed(self, event: ops.charm.RelationChangedEvent) -> None:
297331
298332
Args:
299333
event: Event triggering the relation-changed hook for the relation.
334+
335+
Raises:
336+
RuntimeError: if _on_relation changed is triggered by a broken relation.
300337
"""
301338
# `self.unit` isn't available here, so use `self.model.unit`.
302339
if not self._charm.model.unit.is_leader():
@@ -370,8 +407,13 @@ def provide_nginx_route(
370407
on_nginx_route_broken: Callback function for the nginx-route-broken event.
371408
nginx_route_relation_name: Specifies the relation name of the relation handled by this
372409
provider class. The relation must have the nginx-route interface.
410+
411+
Raises:
412+
RuntimeError: If provide_nginx_route was invoked twice with
413+
the same nginx-route relation name
373414
"""
374-
if __provider_references.get(charm, {}).get(nginx_route_relation_name) is not None:
415+
ref_dict: typing.Dict[str, typing.Any] = __provider_references.get(charm, {})
416+
if ref_dict.get(nginx_route_relation_name) is not None:
375417
raise RuntimeError(
376418
"provide_nginx_route was invoked twice with the same nginx-route relation name"
377419
)

src/charm.py

+41-1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class _ReplicaRelationNotReady(Exception):
5555

5656
_WP_CONFIG_PATH = "/var/www/html/wp-config.php"
5757
_WP_UPLOADS_PATH = "/var/www/html/wp-content/uploads"
58+
_PHP_INI_PATH = "/etc/php/7.4/apache2/php.ini"
5859
_CONTAINER_NAME = "wordpress"
5960
_SERVICE_NAME = "wordpress"
6061
_WORDPRESS_USER = "_daemon_"
@@ -769,7 +770,7 @@ def _init_pebble_layer(self):
769770
"override": "replace",
770771
"level": "alive",
771772
"http": {"url": "http://localhost"},
772-
"period": f"{max(10, health_check_timeout*2)}s",
773+
"period": f"{max(10, health_check_timeout * 2)}s",
773774
"timeout": f"{health_check_timeout}s",
774775
},
775776
},
@@ -855,6 +856,37 @@ def _push_wp_config(self, wp_config: str) -> None:
855856
permissions=0o600,
856857
)
857858

859+
def _gen_php_ini(self) -> str:
860+
"""Generate the content of the php.ini based on the charm configuration.
861+
862+
Returns:
863+
the content of the php.ini file.
864+
"""
865+
current = self._current_php_ini()
866+
php_configs = ["upload_max_filesize", "post_max_size", "max_execution_time"]
867+
new = current
868+
for php_config in php_configs:
869+
search = f"^{php_config}\\s*=\\s*[^\\s]+"
870+
php_config_value = self.config[php_config]
871+
new = re.sub(search, f"{php_config} = {php_config_value}", new, flags=re.MULTILINE)
872+
return new
873+
874+
def _current_php_ini(self) -> str:
875+
"""Retrieve the current version of the php.ini from the server.
876+
877+
Returns:
878+
The content of the current php.ini file.
879+
"""
880+
return self._container().pull(self._PHP_INI_PATH).read()
881+
882+
def _update_php_ini(self, config: str) -> None:
883+
"""Update the content of the php.ini file.
884+
885+
Args:
886+
config: the content of php.ini file.
887+
"""
888+
self._container().push(self._PHP_INI_PATH, source=config, permissions=0o644)
889+
858890
def _core_reconciliation(self) -> None:
859891
"""Reconciliation process for the WordPress core services, returns True if successful.
860892
@@ -866,6 +898,9 @@ def _core_reconciliation(self) -> None:
866898
It will check if the current wp-config.php file matches the desired config.
867899
If not, update the wp-config.php file.
868900
901+
It will check if the current php.ini file matches the desired config.
902+
If not, update the php.ini file.
903+
869904
It will also check if WordPress is installed (WordPress-related tables exist in db).
870905
If not, install WordPress (create WordPress required tables in db).
871906
@@ -890,6 +925,11 @@ def _core_reconciliation(self) -> None:
890925
logger.info("Changes detected in wp-config.php, updating")
891926
self._stop_server()
892927
self._push_wp_config(wp_config)
928+
php_ini = self._gen_php_ini()
929+
if php_ini != self._current_php_ini():
930+
logger.info("Changes detected in php.ini, updating")
931+
self._stop_server()
932+
self._update_php_ini(php_ini)
893933
self._start_server()
894934
logger.info("Wait until the pebble container exists")
895935

tests/integration/test_core.py

+33
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,39 @@ async def test_wordpress_functionality(wordpress: WordpressApp):
4444
)
4545

4646

47+
@pytest.mark.usefixtures("prepare_mysql")
48+
async def test_change_upload_limit(wordpress: WordpressApp):
49+
"""
50+
arrange: after WordPress charm has been deployed and db relation established.
51+
act: change upload limit related settings.
52+
assert: upload limit change should be reflected in the upload page.
53+
"""
54+
await wordpress.set_config({"upload_max_filesize": "16M"})
55+
await wordpress.model.wait_for_idle(status="active")
56+
password = await wordpress.get_default_admin_password()
57+
for unit_ip in await wordpress.get_unit_ips():
58+
wordpress_client = WordpressClient(
59+
host=unit_ip,
60+
username="admin",
61+
password=password,
62+
is_admin=True,
63+
)
64+
text = wordpress_client.get_post(f"http://{unit_ip}/wp-admin/upload.php")
65+
# upload limit = min(upload_max_filesize, post_max_size)
66+
assert "Maximum upload file size: 8 MB" in text
67+
await wordpress.set_config({"post_max_size": "16M"})
68+
await wordpress.model.wait_for_idle(status="active")
69+
for unit_ip in await wordpress.get_unit_ips():
70+
wordpress_client = WordpressClient(
71+
host=unit_ip,
72+
username="admin",
73+
password=password,
74+
is_admin=True,
75+
)
76+
text = wordpress_client.get_post(f"http://{unit_ip}/wp-admin/upload.php")
77+
assert "Maximum upload file size: 16 MB" in text
78+
79+
4780
@pytest.mark.usefixtures("prepare_mysql", "prepare_swift")
4881
async def test_openstack_object_storage_plugin(
4982
wordpress: WordpressApp,

tests/unit/test_charm.py

+25
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import json
99
import secrets
10+
import textwrap
1011
import typing
1112
import unittest.mock
1213

@@ -1085,3 +1086,27 @@ def test_wordpress_promtail_config(harness: ops.testing.Harness):
10851086
],
10861087
"server": {"grpc_listen_port": 9095, "http_listen_port": 9080},
10871088
}
1089+
1090+
1091+
def test_php_ini(
1092+
harness: ops.testing.Harness,
1093+
setup_replica_consensus: typing.Callable[[], dict],
1094+
):
1095+
"""
1096+
arrange: after WordPress application unit consensus has been reached.
1097+
act: update php.ini related charm configurations.
1098+
assert: updated php.ini should be valid.
1099+
"""
1100+
setup_replica_consensus()
1101+
charm: WordpressCharm = typing.cast(WordpressCharm, harness.charm)
1102+
harness.update_config(
1103+
{"upload_max_filesize": "16M", "post_max_size": "32M", "max_execution_time": 60}
1104+
)
1105+
assert charm._gen_php_ini() == textwrap.dedent(
1106+
"""
1107+
[PHP]
1108+
post_max_size = 32M
1109+
upload_max_filesize = 16M
1110+
max_execution_time = 60
1111+
"""
1112+
)

tests/unit/wordpress_mock.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import io
99
import json
1010
import re
11+
import textwrap
1112
import typing
1213
import unittest.mock
1314

@@ -357,7 +358,17 @@ def __init__(
357358
wordpress_database_mock: An instance of the WordPress database mock system.
358359
"""
359360
self.original_pebble = None
360-
self.fs: typing.Dict[str, str] = {"/proc/mounts": ""}
361+
self.fs: typing.Dict[str, str] = {
362+
"/proc/mounts": "",
363+
WordpressCharm._PHP_INI_PATH: textwrap.dedent(
364+
"""
365+
[PHP]
366+
post_max_size = 8M
367+
upload_max_filesize = 2M
368+
max_execution_time = 30
369+
"""
370+
),
371+
}
361372
self._wordpress_database_mock = wordpress_database_mock
362373
self.installed_plugins = set(WordpressCharm._WORDPRESS_DEFAULT_PLUGINS)
363374
self.installed_themes = set(WordpressCharm._WORDPRESS_DEFAULT_THEMES)

0 commit comments

Comments
 (0)