Merge ~arturo-seijas/charm-k8s-discourse:sidecar into charm-k8s-discourse:master

Proposed by Arturo Enrique Seijas Fernández
Status: Superseded
Proposed branch: ~arturo-seijas/charm-k8s-discourse:sidecar
Merge into: charm-k8s-discourse:master
Diff against target: 1109 lines (+662/-113) (has conflicts)
21 files modified
README.md (+10/-3)
config.yaml (+5/-2)
dev/null (+0/-4)
lib/charms/nginx_ingress_integrator/v0/ingress.py (+200/-0)
metadata.yaml (+12/-3)
pyproject.toml (+4/-0)
src/charm.py (+189/-70)
tests/__init__.py (+0/-0)
tests/unit/__init__.py (+6/-0)
tests/unit/_patched_charm.py (+59/-0)
tests/unit/fixtures/config_invalid_bad_throttle_mode.yaml (+3/-0)
tests/unit/fixtures/config_invalid_force_saml_no_saml_target.yaml (+3/-0)
tests/unit/fixtures/config_invalid_missing_cors.yaml (+3/-0)
tests/unit/fixtures/config_valid_complete.yaml (+3/-0)
tests/unit/fixtures/config_valid_no_saml_fingerprint.yaml (+3/-0)
tests/unit/fixtures/config_valid_no_saml_target_url.yaml (+3/-0)
tests/unit/fixtures/config_valid_no_tls.yaml (+3/-0)
tests/unit/fixtures/config_valid_with_tls.yaml (+3/-0)
tests/unit/fixtures/config_valid_without_tls.yaml (+3/-0)
tests/unit/test_charm.py (+140/-31)
tox.ini (+10/-0)
Conflict in config.yaml
Conflict in src/charm.py
Conflict in tests/unit/fixtures/config_invalid_bad_throttle_mode.yaml
Conflict in tests/unit/fixtures/config_invalid_force_saml_no_saml_target.yaml
Conflict in tests/unit/fixtures/config_invalid_missing_cors.yaml
Conflict in tests/unit/fixtures/config_invalid_missing_db_name.yaml
Conflict in tests/unit/fixtures/config_invalid_missing_required_s3_options.yaml
Conflict in tests/unit/fixtures/config_valid_complete.yaml
Conflict in tests/unit/fixtures/config_valid_no_saml_fingerprint.yaml
Conflict in tests/unit/fixtures/config_valid_no_saml_target_url.yaml
Conflict in tests/unit/fixtures/config_valid_no_tls.yaml
Conflict in tests/unit/fixtures/config_valid_with_tls.yaml
Conflict in tests/unit/fixtures/config_valid_without_tls.yaml
Conflict in tests/unit/test_charm.py
Conflict in tox.ini
Reviewer Review Type Date Requested Status
Johann David Krister Andersson (community) Needs Fixing
Canonical IS Reviewers Pending
Discourse Charm Maintainers Pending
Review via email: mp+429946@code.launchpad.net

This proposal has been superseded by a proposal from 2022-09-15.

Description of the change

To post a comment you must log in.
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

This merge proposal is being monitored by mergebot. Change the status to Approved to merge.

Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

Unable to determine commit message from repository - please click "Set commit message" and enter the commit message manually.

Revision history for this message
Johann David Krister Andersson (jdkandersson) wrote :

A few comments, it looks like there are a bunch of merge conflicts in here

review: Needs Fixing
Revision history for this message
Arturo Enrique Seijas Fernández (arturo-seijas) :
Revision history for this message
Mariyan Dimitrov (merkata) wrote (last edit ):

Good stuff, only thing is that there are some merge conflicts currently that need fixing.

*UPDATE* I was looking at a previous version, this looks good!

Unmerged commits

63a1dce... by Arturo Enrique Seijas Fernández

Code style

67dcce1... by Arturo Enrique Seijas Fernández

Code style

00a6ef9... by Arturo Enrique Seijas Fernández

Migrate to sidecar pattern

5ea251e... by Jay Kuri

Update readme with ingress details, update external_hostname to default to app_name

46439f2... by Jay Kuri

change test to use correct call for generating env. Comment old non-sidecar items until they can be rewritten.

c0471b7... by Jay Kuri

Updates to startup process + logging - WIP

cd00284... by Jay Kuri

Revisions for today. WIP

ddbdca5... by Jay Kuri

Add ingress integrator and attempt to integrate it. Still WIP.

070e47d... by Jay Kuri

Revisions for today - WIP.

f3cbf83... by Jay Kuri

Initial tentative steps at converting to sidecar

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/README.md b/README.md
2index b49813d..12c4d16 100644
3--- a/README.md
4+++ b/README.md
5@@ -23,8 +23,15 @@ To deploy into a Juju K8s model:
6 juju relate discourse-k8s postgresql-k8s:db-admin
7 juju relate discourse-k8s redis-k8s
8
9+Then expose Discourse via the Kubernetes ingress:
10+
11+ juju deploy nginx-ingress-integrator
12+ juju relate discourse-k8s nginx-ingress-integrator
13+
14 Once the deployment is completed and the "discourse" workload state in `juju
15-status` has changed to "active" you can visit http://{$discourse_ip}:3000 in a
16-browser and log in to your Discourse instance.
17+status` has changed to "active" you can add `discourse-k8s` to `/etc/hosts`
18+with the IP address of your Kubernetes cluster's ingress (127.0.0.1 if you're
19+using MicroK8s) and visit `http://discourse-k8s` in a browser and log in to
20+your Discourse instance.
21
22-For further details, [see here](https://charmhub.io/discourse-charmers-discourse-k8s/docs).
23+For further details, [see here](https://charmhub.io/discourse-k8s/docs).
24diff --git a/config.yaml b/config.yaml
25index e74a8c3..01704f1 100644
26--- a/config.yaml
27+++ b/config.yaml
28@@ -1,4 +1,5 @@
29 options:
30+<<<<<<< config.yaml
31 discourse_image:
32 type: string
33 description: "Discourse image to use"
34@@ -11,14 +12,16 @@ options:
35 type: string
36 description: "Private registry password"
37 default: ""
38+=======
39+>>>>>>> config.yaml
40 db_name:
41 type: string
42 description: "PostgreSQL database name. Defaults to Juju Application name."
43 default: ""
44 external_hostname:
45 type: string
46- description: "External hostname this discourse instance should respond to"
47- default: "foo.internal"
48+ description: "External hostname this discourse instance should respond to. Defaults to the deployed application name."
49+ default: ""
50 enable_cors:
51 type: boolean
52 description: "Enable Cross-origin Resource Sharing (CORS) at the application level (required for SSO)"
53diff --git a/lib/charms/nginx_ingress_integrator/v0/ingress.py b/lib/charms/nginx_ingress_integrator/v0/ingress.py
54new file mode 100644
55index 0000000..4d98ee5
56--- /dev/null
57+++ b/lib/charms/nginx_ingress_integrator/v0/ingress.py
58@@ -0,0 +1,200 @@
59+"""Library for the ingress relation.
60+
61+This library contains the Requires and Provides classes for handling
62+the ingress interface.
63+
64+Import `IngressRequires` in your charm, with two required options:
65+ - "self" (the charm itself)
66+ - config_dict
67+
68+`config_dict` accepts the following keys:
69+ - service-hostname (required)
70+ - service-name (required)
71+ - service-port (required)
72+ - limit-rps
73+ - limit-whitelist
74+ - max-body-size
75+ - path-routes
76+ - retry-errors
77+ - service-namespace
78+ - session-cookie-max-age
79+ - tls-secret-name
80+
81+See [the config section](https://charmhub.io/nginx-ingress-integrator/configure) for descriptions
82+of each, along with the required type.
83+
84+As an example, add the following to `src/charm.py`:
85+```
86+from charms.nginx_ingress_integrator.v0.ingress import IngressRequires
87+
88+# In your charm's `__init__` method.
89+self.ingress = IngressRequires(self, {"service-hostname": self.config["external_hostname"],
90+ "service-name": self.app.name,
91+ "service-port": 80})
92+
93+# In your charm's `config-changed` handler.
94+self.ingress.update_config({"service-hostname": self.config["external_hostname"]})
95+```
96+And then add the following to `metadata.yaml`:
97+```
98+requires:
99+ ingress:
100+ interface: ingress
101+```
102+"""
103+
104+import logging
105+
106+from ops.charm import CharmEvents
107+from ops.framework import EventBase, EventSource, Object
108+from ops.model import BlockedStatus
109+
110+# The unique Charmhub library identifier, never change it
111+LIBID = "db0af4367506491c91663468fb5caa4c"
112+
113+# Increment this major API version when introducing breaking changes
114+LIBAPI = 0
115+
116+# Increment this PATCH version before using `charmcraft publish-lib` or reset
117+# to 0 if you are raising the major API version
118+LIBPATCH = 7
119+
120+logger = logging.getLogger(__name__)
121+
122+REQUIRED_INGRESS_RELATION_FIELDS = {
123+ "service-hostname",
124+ "service-name",
125+ "service-port",
126+}
127+
128+OPTIONAL_INGRESS_RELATION_FIELDS = {
129+ "limit-rps",
130+ "limit-whitelist",
131+ "max-body-size",
132+ "retry-errors",
133+ "service-namespace",
134+ "session-cookie-max-age",
135+ "tls-secret-name",
136+ "path-routes",
137+}
138+
139+
140+class IngressAvailableEvent(EventBase):
141+ pass
142+
143+
144+class IngressCharmEvents(CharmEvents):
145+ """Custom charm events."""
146+
147+ ingress_available = EventSource(IngressAvailableEvent)
148+
149+
150+class IngressRequires(Object):
151+ """This class defines the functionality for the 'requires' side of the 'ingress' relation.
152+
153+ Hook events observed:
154+ - relation-changed
155+ """
156+
157+ def __init__(self, charm, config_dict):
158+ super().__init__(charm, "ingress")
159+
160+ self.framework.observe(charm.on["ingress"].relation_changed, self._on_relation_changed)
161+
162+ self.config_dict = config_dict
163+
164+ def _config_dict_errors(self, update_only=False):
165+ """Check our config dict for errors."""
166+ blocked_message = "Error in ingress relation, check `juju debug-log`"
167+ unknown = [
168+ x
169+ for x in self.config_dict
170+ if x not in REQUIRED_INGRESS_RELATION_FIELDS | OPTIONAL_INGRESS_RELATION_FIELDS
171+ ]
172+ if unknown:
173+ logger.error(
174+ "Ingress relation error, unknown key(s) in config dictionary found: %s",
175+ ", ".join(unknown),
176+ )
177+ self.model.unit.status = BlockedStatus(blocked_message)
178+ return True
179+ if not update_only:
180+ missing = [x for x in REQUIRED_INGRESS_RELATION_FIELDS if x not in self.config_dict]
181+ if missing:
182+ logger.error(
183+ "Ingress relation error, missing required key(s) in config dictionary: %s",
184+ ", ".join(missing),
185+ )
186+ self.model.unit.status = BlockedStatus(blocked_message)
187+ return True
188+ return False
189+
190+ def _on_relation_changed(self, event):
191+ """Handle the relation-changed event."""
192+ # `self.unit` isn't available here, so use `self.model.unit`.
193+ if self.model.unit.is_leader():
194+ if self._config_dict_errors():
195+ return
196+ for key in self.config_dict:
197+ event.relation.data[self.model.app][key] = str(self.config_dict[key])
198+
199+ def update_config(self, config_dict):
200+ """Allow for updates to relation."""
201+ if self.model.unit.is_leader():
202+ self.config_dict = config_dict
203+ if self._config_dict_errors(update_only=True):
204+ return
205+ relation = self.model.get_relation("ingress")
206+ if relation:
207+ for key in self.config_dict:
208+ relation.data[self.model.app][key] = str(self.config_dict[key])
209+
210+
211+class IngressProvides(Object):
212+ """This class defines the functionality for the 'provides' side of the 'ingress' relation.
213+
214+ Hook events observed:
215+ - relation-changed
216+ """
217+
218+ def __init__(self, charm):
219+ super().__init__(charm, "ingress")
220+ # Observe the relation-changed hook event and bind
221+ # self.on_relation_changed() to handle the event.
222+ self.framework.observe(charm.on["ingress"].relation_changed, self._on_relation_changed)
223+ self.charm = charm
224+
225+ def _on_relation_changed(self, event):
226+ """Handle a change to the ingress relation.
227+
228+ Confirm we have the fields we expect to receive."""
229+ # `self.unit` isn't available here, so use `self.model.unit`.
230+ if not self.model.unit.is_leader():
231+ return
232+
233+ ingress_data = {
234+ field: event.relation.data[event.app].get(field)
235+ for field in REQUIRED_INGRESS_RELATION_FIELDS | OPTIONAL_INGRESS_RELATION_FIELDS
236+ }
237+
238+ missing_fields = sorted(
239+ [
240+ field
241+ for field in REQUIRED_INGRESS_RELATION_FIELDS
242+ if ingress_data.get(field) is None
243+ ]
244+ )
245+
246+ if missing_fields:
247+ logger.error(
248+ "Missing required data fields for ingress relation: {}".format(
249+ ", ".join(missing_fields)
250+ )
251+ )
252+ self.model.unit.status = BlockedStatus(
253+ "Missing fields for ingress: {}".format(", ".join(missing_fields))
254+ )
255+
256+ # Create an event that our charm can use to decide it's okay to
257+ # configure the ingress.
258+ self.charm.on.ingress_available.emit()
259diff --git a/metadata.yaml b/metadata.yaml
260index 0c8beb2..b382d31 100644
261--- a/metadata.yaml
262+++ b/metadata.yaml
263@@ -12,12 +12,21 @@ maintainers:
264 tags:
265 - applications
266 - forum
267-series:
268- - kubernetes
269-min-juju-version: 2.8.0
270 requires:
271 redis:
272 interface: redis
273 db:
274 interface: pgsql
275 limit: 1
276+ ingress:
277+ interface: ingress
278+ limit: 1
279+
280+containers:
281+ discourse:
282+ resource: discourse-image
283+
284+resources:
285+ discourse-image:
286+ type: oci-image
287+ description: OCI image for discourse
288diff --git a/pyproject.toml b/pyproject.toml
289index d2f23b9..8d92e28 100644
290--- a/pyproject.toml
291+++ b/pyproject.toml
292@@ -1,3 +1,7 @@
293+[tool.coverage.report]
294+fail_under = 85
295+show_missing = true
296+
297 [tool.black]
298 skip-string-normalization = true
299 line-length = 120
300diff --git a/src/charm.py b/src/charm.py
301index 4710b2c..cc4c11f 100755
302--- a/src/charm.py
303+++ b/src/charm.py
304@@ -12,13 +12,11 @@ from charms.redis_k8s.v0.redis import (
305 from ops.charm import CharmBase
306 from ops.main import main
307 from ops.framework import StoredState
308-
309 from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, WaitingStatus
310+from charms.nginx_ingress_integrator.v0.ingress import IngressRequires
311
312
313 logger = logging.getLogger(__name__)
314-
315-
316 pgsql = ops.lib.use("pgsql", 1, "postgresql-charmers@lists.launchpad.net")
317
318 THROTTLE_LEVELS = {
319@@ -42,6 +40,7 @@ THROTTLE_LEVELS = {
320 }
321 REQUIRED_S3_SETTINGS = ['s3_access_key_id', 's3_bucket', 's3_region', 's3_secret_access_key']
322
323+<<<<<<< src/charm.py
324
325 def create_discourse_pod_config(config):
326 """Create the pod environment config from the juju config."""
327@@ -262,11 +261,14 @@ def check_for_missing_config_fields(config, stored):
328 missing_fields.append(key)
329
330 return sorted(missing_fields)
331+=======
332+SERVICE_NAME = "discourse"
333+>>>>>>> src/charm.py
334
335
336 class DiscourseCharm(CharmBase):
337 on = RedisRelationCharmEvents()
338- stored = StoredState()
339+ _stored = StoredState()
340
341 def __init__(self, *args):
342 """Initialization.
343@@ -276,27 +278,41 @@ class DiscourseCharm(CharmBase):
344 """
345 super().__init__(*args)
346
347- self.stored.set_default(
348+ self._stored.set_default(
349 db_name=None,
350 db_user=None,
351 db_password=None,
352 db_host=None,
353- has_db_relation=False,
354- has_db_credentials=False,
355 redis_relation={},
356 )
357- self.framework.observe(self.on.leader_elected, self.configure_pod)
358- self.framework.observe(self.on.config_changed, self.configure_pod)
359- self.framework.observe(self.on.upgrade_charm, self.configure_pod)
360+ self.ingress = IngressRequires(self, self._ingress_config())
361+ self.framework.observe(self.on.leader_elected, self.config_changed)
362+ self.framework.observe(self.on.discourse_pebble_ready, self.config_changed)
363+ self.framework.observe(self.on.config_changed, self.config_changed)
364+ self.framework.observe(self.on.upgrade_charm, self.config_changed)
365
366 self.db = pgsql.PostgreSQLClient(self, 'db')
367 self.framework.observe(self.db.on.database_relation_joined, self.on_database_relation_joined)
368 self.framework.observe(self.db.on.master_changed, self.on_database_changed)
369
370- self.redis = RedisRequires(self, self.stored)
371- self.framework.observe(self.on.redis_relation_updated, self.configure_pod)
372-
373- def check_config_is_valid(self, config):
374+ self.redis = RedisRequires(self, self._stored)
375+ self.framework.observe(self.on.redis_relation_updated, self.config_changed)
376+
377+ def _ingress_config(self):
378+ """Return a dict of our ingress config."""
379+ ingress_config = {
380+ "service-hostname": self.config['external_hostname'] or self.app.name,
381+ "service-name": self.app.name,
382+ "service-port": 3000,
383+ "session-cookie-max-age": 3600,
384+ }
385+ if self.config["tls_secret_name"]:
386+ ingress_config["tls-secret-name"] = self.config["tls_secret_name"]
387+ if self.config["max_body_size"]:
388+ ingress_config["max-body-size"] = self.config["max_body_size"]
389+ return ingress_config
390+
391+ def check_config_is_valid(self):
392 """Check that the provided config is valid.
393
394 - Returns True if config is valid, False otherwise.
395@@ -304,7 +320,7 @@ class DiscourseCharm(CharmBase):
396 - Sets model status as appropriate.
397 """
398 valid_config = True
399- errors = check_for_config_problems(config, self.stored)
400+ errors = self.check_for_config_problems()
401
402 # Set status if we have a bad config.
403 if errors:
404@@ -315,58 +331,164 @@ class DiscourseCharm(CharmBase):
405
406 return valid_config
407
408- def check_db_is_valid(self, state):
409- if not state.has_db_relation:
410- self.model.unit.status = BlockedStatus("db relation is required")
411- return False
412- if not state.has_db_credentials:
413- self.model.unit.status = WaitingStatus("db relation is setting up")
414- return False
415- self.model.unit.status = MaintenanceStatus("db relation is ready")
416- return True
417+ def get_saml_config(self):
418+ saml_fingerprints = {
419+ 'https://login.ubuntu.com/+saml': '32:15:20:9F:A4:3C:8E:3E:8E:47:72:62:9A:86:8D:0E:E6:CF:45:D5'
420+ }
421+ saml_config = {}
422+
423+ if self.config.get('saml_target_url'):
424+ saml_config['DISCOURSE_SAML_TARGET_URL'] = self.config['saml_target_url']
425+ saml_config['DISCOURSE_SAML_FULL_SCREEN_LOGIN'] = "true" if self.config['force_saml_login'] else "false"
426+ fingerprint = saml_fingerprints.get(self.config['saml_target_url'])
427+ if fingerprint:
428+ saml_config['DISCOURSE_SAML_CERT_FINGERPRINT'] = fingerprint
429+
430+ return saml_config
431+
432+ def check_for_config_problems(self):
433+ """Check if there are issues with the juju config.
434
435- def configure_pod(self, event=None):
436- """Configure pod.
437+ - Primarily looks for missing config options using check_for_missing_config_fields()
438
439- - Verifies config is valid and unit is leader.
440+ - Returns a list of errors if any were found.
441+ """
442+ errors = []
443+ missing_fields = self.check_for_missing_config_fields()
444+
445+ if missing_fields:
446+ errors.append('Required configuration missing: {}'.format(" ".join(missing_fields)))
447+
448+ if not THROTTLE_LEVELS.get(self.config['throttle_level']):
449+ errors.append('throttle_level must be one of: ' + ' '.join(THROTTLE_LEVELS.keys()))
450+
451+ if self.config['force_saml_login'] and self.config['saml_target_url'] == '':
452+ errors.append('force_saml_login can not be true without a saml_target_url')
453
454- - Configures pod using pod_spec generated from config.
455+ return errors
456+
457+ def check_for_missing_config_fields(self):
458+ """Check for missing fields in juju config.
459+
460+ - Returns a list of required fields that are either not present
461+ or are empty.
462 """
463+ missing_fields = []
464+
465+ needed_fields = [
466+ 'smtp_address',
467+ 'cors_origin',
468+ 'developer_emails',
469+ 'smtp_domain',
470+ 'external_hostname',
471+ ]
472+ # See if Redis connection information has been provided via a relation.
473+ redis_hostname = None
474+ for redis_unit in self._stored.redis_relation:
475+ redis_hostname = self._stored.redis_relation[redis_unit]["hostname"]
476+ if not redis_hostname:
477+ needed_fields.append("redis_host")
478+ for key in needed_fields:
479+ if not self.config.get(key):
480+ missing_fields.append(key)
481+
482+ return sorted(missing_fields)
483+
484+ def check_db_is_valid(self):
485+ return self._stored.db_name
486+
487+ def create_discourse_environment_settings(self):
488+ """Create the pod environment config from the existing config."""
489+
490 # Get redis connection information from config but allow overriding
491 # via a relation.
492 redis_hostname = self.config["redis_host"]
493 redis_port = 6379
494- for redis_unit in self.stored.redis_relation:
495- redis_hostname = self.stored.redis_relation[redis_unit]["hostname"]
496- redis_port = self.stored.redis_relation[redis_unit]["port"]
497- logging.debug("Got redis connection details from relation of %s:%s", redis_hostname, redis_port)
498+ for redis_unit in self._stored.redis_relation:
499+ redis_hostname = self._stored.redis_relation[redis_unit]["hostname"]
500+ redis_port = self._stored.redis_relation[redis_unit]["port"]
501+ logger.debug("Got redis connection details from relation of %s:%s", redis_hostname, redis_port)
502+
503+ pod_config = {
504+ 'DISCOURSE_CORS_ORIGIN': self.config['cors_origin'],
505+ 'DISCOURSE_DB_HOST': self._stored.db_host,
506+ 'DISCOURSE_DB_NAME': self._stored.db_name,
507+ 'DISCOURSE_DB_PASSWORD': self._stored.db_password,
508+ 'DISCOURSE_DB_USERNAME': self._stored.db_user,
509+ 'DISCOURSE_DEVELOPER_EMAILS': self.config['developer_emails'],
510+ 'DISCOURSE_ENABLE_CORS': self.config['enable_cors'],
511+ 'DISCOURSE_HOSTNAME': self.config['external_hostname'],
512+ 'DISCOURSE_REDIS_HOST': redis_hostname,
513+ 'DISCOURSE_REDIS_PORT': redis_port,
514+ 'DISCOURSE_REFRESH_MAXMIND_DB_DURING_PRECOMPILE_DAYS': "0",
515+ 'DISCOURSE_SERVE_STATIC_ASSETS': "true",
516+ 'DISCOURSE_SMTP_ADDRESS': self.config['smtp_address'],
517+ 'DISCOURSE_SMTP_AUTHENTICATION': self.config['smtp_authentication'],
518+ 'DISCOURSE_SMTP_DOMAIN': self.config['smtp_domain'],
519+ 'DISCOURSE_SMTP_OPENSSL_VERIFY_MODE': self.config['smtp_openssl_verify_mode'],
520+ 'DISCOURSE_SMTP_PASSWORD': self.config['smtp_password'],
521+ 'DISCOURSE_SMTP_PORT': self.config['smtp_port'],
522+ 'DISCOURSE_SMTP_USER_NAME': self.config['smtp_username'],
523+ }
524+
525+ saml_config = self.get_saml_config()
526+ for key in saml_config:
527+ pod_config[key] = saml_config[key]
528+
529+ # We only get valid throttle levels here, otherwise it would be caught
530+ # by `check_for_config_problems`, so we can be sure this won't raise a
531+ # KeyError.
532+ for key in THROTTLE_LEVELS[self.config['throttle_level']]:
533+ pod_config[key] = THROTTLE_LEVELS[self.config['throttle_level']][key]
534+
535+ return pod_config
536+
537+ def create_layer_config(self):
538+ """Create a layer config based on our current configuration.
539+
540+ - uses create_discourse_environment_settings to genreate the environment we need.
541+ """
542+ logger.info("Generating Layer config")
543+ layer_config = {
544+ "summary": "Discourse layer",
545+ "description": "Discourse layer",
546+ "services": {
547+ "discourse": {
548+ "override": "replace",
549+ "summary": "Discourse web application",
550+ "command": "sh -c '/srv/scripts/pod_start >>/srv/discourse/discourse.log 2&>1'",
551+ "startup": "enabled",
552+ "environment": self.create_discourse_environment_settings(),
553+ }
554+ },
555+ }
556+ return layer_config
557
558- # Set our status while we get configured.
559- self.model.unit.status = MaintenanceStatus('Configuring pod')
560+ def config_changed(self, event):
561+ """Configure service.
562
563- # Leader must set the pod spec.
564- if self.model.unit.is_leader():
565- if not self.check_db_is_valid(self.stored):
566- return
567-
568- # Merge our config and state into a single dict and set
569- # defaults here, because the helpers avoid dealing with
570- # the framework.
571- config = dict(self.model.config)
572- config["db_name"] = self.stored.db_name
573- config["db_user"] = self.stored.db_user
574- config["db_password"] = self.stored.db_password
575- config["db_host"] = self.stored.db_host
576- config["redis_host"] = redis_hostname
577- config["redis_port"] = redis_port
578- # Get our spec definition.
579- if self.check_config_is_valid(config):
580- # Get pod spec using our app name and config
581- pod_spec = get_pod_spec(self.framework.model.app.name, config)
582- # Set our pod spec.
583- self.model.pod.set_spec(pod_spec)
584- self.model.unit.status = ActiveStatus()
585- else:
586+ - Verifies config is valid
587+
588+ - Configures pod using pebble and layer generated from config.
589+ """
590+
591+ self.model.unit.status = MaintenanceStatus('Configuring service')
592+
593+ if not self.check_db_is_valid():
594+ self.model.unit.status = WaitingStatus("Waiting for database relation")
595+ event.defer()
596+ return
597+
598+ container = self.unit.get_container(SERVICE_NAME)
599+ if not container.can_connect():
600+ event.defer()
601+ return
602+
603+ if self.check_config_is_valid():
604+ layer_config = self.create_layer_config()
605+ container.add_layer(SERVICE_NAME, layer_config, combine=True)
606+ container.pebble.replan_services()
607+ self.ingress.update_config(self._ingress_config())
608 self.model.unit.status = ActiveStatus()
609
610 def on_database_relation_joined(self, event):
611@@ -377,7 +499,6 @@ class DiscourseCharm(CharmBase):
612 - Required because setting the database name is only possible
613 from inside the event handler per https://github.com/canonical/ops-lib-pgsql/issues/2
614 """
615- self.stored.has_db_relation = True
616 # Ensure event.database is always set to a non-empty string. PostgreSQL
617 # can infer this if it's in the same model as Discourse, but not if
618 # we're using cross-model relations.
619@@ -400,22 +521,20 @@ class DiscourseCharm(CharmBase):
620 in the relation event.
621 """
622 if event.master is None:
623- self.stored.db_name = None
624- self.stored.db_user = None
625- self.stored.db_password = None
626- self.stored.db_host = None
627- self.stored.has_db_credentials = False
628+ self._stored.db_name = None
629+ self._stored.db_user = None
630+ self._stored.db_password = None
631+ self._stored.db_host = None
632 self.model.unit.status = WaitingStatus("waiting for db relation")
633 return
634
635- self.stored.db_name = event.master.dbname
636- self.stored.db_user = event.master.user
637- self.stored.db_password = event.master.password
638- self.stored.db_host = event.master.host
639- self.stored.has_db_credentials = True
640+ self._stored.db_name = event.master.dbname
641+ self._stored.db_user = event.master.user
642+ self._stored.db_password = event.master.password
643+ self._stored.db_host = event.master.host
644
645- self.configure_pod()
646+ self.config_changed(event)
647
648
649 if __name__ == '__main__': # pragma: no cover
650- main(DiscourseCharm)
651+ main(DiscourseCharm, use_juju_for_storage=True)
652diff --git a/tests/__init__.py b/tests/__init__.py
653new file mode 100644
654index 0000000..e69de29
655--- /dev/null
656+++ b/tests/__init__.py
657diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py
658new file mode 100644
659index 0000000..0c77dd5
660--- /dev/null
661+++ b/tests/unit/__init__.py
662@@ -0,0 +1,6 @@
663+# Copyright 2022 Canonical Ltd.
664+# See LICENSE file for licensing details.
665+
666+import ops.testing
667+
668+ops.testing.SIMULATE_CAN_CONNECT = True
669diff --git a/tests/unit/_patched_charm.py b/tests/unit/_patched_charm.py
670new file mode 100644
671index 0000000..14a612b
672--- /dev/null
673+++ b/tests/unit/_patched_charm.py
674@@ -0,0 +1,59 @@
675+# Copyright 2022 Canonical Ltd.
676+# See LICENSE file for licensing details.
677+
678+"""Patch the ``ops-lib-pgsql`` library for unit testing.
679+
680+This script is used to monkey patch necessary code to allow running unit tests with
681+``ops-lib-pgsql``. This patch needs to be run prior to the importing of the main module since
682+the main module uses ``ops.lib.use`` which runs ``exec_module`` internally and the
683+``ops-lib-pgsql`` just happens to use ``from .client import *``. Combined, that makes patching
684+any private variables inside ``pgsql.client`` afterwards impossible.
685+"""
686+
687+from unittest.mock import MagicMock, patch
688+
689+import ops.lib
690+import pgsql.client
691+
692+__all__ = ["DiscourseCharm", "pgsql_patch"]
693+
694+_og_use = ops.lib.use
695+
696+
697+def _use(*args, **kwargs):
698+ print("use: ", args)
699+ if args == ("pgsql", 1, "postgresql-charmers@lists.launchpad.net"):
700+ return pgsql
701+ else:
702+ return _og_use(*args, **kwargs)
703+
704+
705+ops.lib.use = _use
706+
707+
708+class _PGSQLPatch:
709+ def __init__(self):
710+ # borrow some code from
711+ # https://github.com/canonical/ops-lib-pgsql/blob/master/tests/test_client.py
712+ self._leadership_data = {}
713+ self._patch = patch.multiple(
714+ pgsql.client,
715+ _is_ready=MagicMock(return_value=True),
716+ _get_pgsql_leader_data=self._leadership_data.copy,
717+ _set_pgsql_leader_data=self._leadership_data.update,
718+ )
719+
720+ def _reset_leadership_data(self):
721+ self._leadership_data.clear()
722+
723+ def start(self):
724+ self._reset_leadership_data()
725+ self._patch.start()
726+
727+ def stop(self):
728+ self._reset_leadership_data()
729+ self._patch.stop()
730+
731+
732+pgsql_patch = _PGSQLPatch()
733+DiscourseCharm = __import__("charm").DiscourseCharm
734diff --git a/tests/unit/fixtures/config_invalid_bad_throttle_mode.yaml b/tests/unit/fixtures/config_invalid_bad_throttle_mode.yaml
735index a8da6f4..501bc9a 100644
736--- a/tests/unit/fixtures/config_invalid_bad_throttle_mode.yaml
737+++ b/tests/unit/fixtures/config_invalid_bad_throttle_mode.yaml
738@@ -1,3 +1,4 @@
739+<<<<<<< tests/unit/fixtures/config_invalid_bad_throttle_mode.yaml
740 config:
741 cors_origin: '*'
742 db_name: discourse
743@@ -23,3 +24,5 @@ config:
744 config_problems:
745 - 'throttle_level must be one of: none permissive strict'
746 missing_fields: []
747+=======
748+>>>>>>> tests/unit/fixtures/config_invalid_bad_throttle_mode.yaml
749diff --git a/tests/unit/fixtures/config_invalid_force_saml_no_saml_target.yaml b/tests/unit/fixtures/config_invalid_force_saml_no_saml_target.yaml
750index 514f436..b158648 100644
751--- a/tests/unit/fixtures/config_invalid_force_saml_no_saml_target.yaml
752+++ b/tests/unit/fixtures/config_invalid_force_saml_no_saml_target.yaml
753@@ -1,3 +1,4 @@
754+<<<<<<< tests/unit/fixtures/config_invalid_force_saml_no_saml_target.yaml
755 config:
756 cors_origin: '*'
757 db_name: discourse
758@@ -25,3 +26,5 @@ config_problems:
759 - "'force_saml_login' cannot be true without a 'saml_target_url'"
760 - "'saml_sync_groups' cannot be specified without a 'saml_target_url'"
761 missing_fields: []
762+=======
763+>>>>>>> tests/unit/fixtures/config_invalid_force_saml_no_saml_target.yaml
764diff --git a/tests/unit/fixtures/config_invalid_missing_cors.yaml b/tests/unit/fixtures/config_invalid_missing_cors.yaml
765index 14019e0..7ba779c 100644
766--- a/tests/unit/fixtures/config_invalid_missing_cors.yaml
767+++ b/tests/unit/fixtures/config_invalid_missing_cors.yaml
768@@ -1,3 +1,4 @@
769+<<<<<<< tests/unit/fixtures/config_invalid_missing_cors.yaml
770 config:
771 cors_origin: ''
772 db_name: discourse
773@@ -23,3 +24,5 @@ config_problems:
774 - 'Required configuration missing: cors_origin'
775 missing_fields:
776 - 'cors_origin'
777+=======
778+>>>>>>> tests/unit/fixtures/config_invalid_missing_cors.yaml
779diff --git a/tests/unit/fixtures/config_valid_complete.yaml b/tests/unit/fixtures/config_valid_complete.yaml
780index 1d14364..33809df 100644
781--- a/tests/unit/fixtures/config_valid_complete.yaml
782+++ b/tests/unit/fixtures/config_valid_complete.yaml
783@@ -1,3 +1,4 @@
784+<<<<<<< tests/unit/fixtures/config_valid_complete.yaml
785 config:
786 cors_origin: '*'
787 db_name: discourse
788@@ -144,3 +145,5 @@ pod_spec:
789 - hosts:
790 - 'discourse.local'
791 secretName: 'discourse_local'
792+=======
793+>>>>>>> tests/unit/fixtures/config_valid_complete.yaml
794diff --git a/tests/unit/fixtures/config_valid_no_saml_fingerprint.yaml b/tests/unit/fixtures/config_valid_no_saml_fingerprint.yaml
795index b3f7f54..897377c 100644
796--- a/tests/unit/fixtures/config_valid_no_saml_fingerprint.yaml
797+++ b/tests/unit/fixtures/config_valid_no_saml_fingerprint.yaml
798@@ -1,3 +1,4 @@
799+<<<<<<< tests/unit/fixtures/config_valid_no_saml_fingerprint.yaml
800 config:
801 cors_origin: '*'
802 db_name: discourse
803@@ -44,3 +45,5 @@ pod_config:
804 DISCOURSE_SAML_FULL_SCREEN_LOGIN: "true"
805 DISCOURSE_MAX_REQS_PER_IP_MODE: "none"
806 DISCOURSE_MAX_REQS_RATE_LIMIT_ON_PRIVATE: "false"
807+=======
808+>>>>>>> tests/unit/fixtures/config_valid_no_saml_fingerprint.yaml
809diff --git a/tests/unit/fixtures/config_valid_no_saml_target_url.yaml b/tests/unit/fixtures/config_valid_no_saml_target_url.yaml
810index 0999b90..94878ff 100644
811--- a/tests/unit/fixtures/config_valid_no_saml_target_url.yaml
812+++ b/tests/unit/fixtures/config_valid_no_saml_target_url.yaml
813@@ -1,3 +1,4 @@
814+<<<<<<< tests/unit/fixtures/config_valid_no_saml_target_url.yaml
815 config:
816 cors_origin: '*'
817 db_name: discourse
818@@ -42,3 +43,5 @@ pod_config:
819 DISCOURSE_SERVE_STATIC_ASSETS: 'true'
820 DISCOURSE_MAX_REQS_PER_IP_MODE: "none"
821 DISCOURSE_MAX_REQS_RATE_LIMIT_ON_PRIVATE: "false"
822+=======
823+>>>>>>> tests/unit/fixtures/config_valid_no_saml_target_url.yaml
824diff --git a/tests/unit/fixtures/config_valid_no_tls.yaml b/tests/unit/fixtures/config_valid_no_tls.yaml
825index 2a57ded..d95507b 100644
826--- a/tests/unit/fixtures/config_valid_no_tls.yaml
827+++ b/tests/unit/fixtures/config_valid_no_tls.yaml
828@@ -1,3 +1,4 @@
829+<<<<<<< tests/unit/fixtures/config_valid_no_tls.yaml
830 config:
831 cors_origin: '*'
832 db_name: discourse
833@@ -107,3 +108,5 @@ pod_spec:
834 serviceName: discourse-k8s
835 servicePort: 3000
836 path: '/'
837+=======
838+>>>>>>> tests/unit/fixtures/config_valid_no_tls.yaml
839diff --git a/tests/unit/fixtures/config_valid_with_tls.yaml b/tests/unit/fixtures/config_valid_with_tls.yaml
840index 18c68bc..d18cdce 100644
841--- a/tests/unit/fixtures/config_valid_with_tls.yaml
842+++ b/tests/unit/fixtures/config_valid_with_tls.yaml
843@@ -1,3 +1,4 @@
844+<<<<<<< tests/unit/fixtures/config_valid_with_tls.yaml
845 config:
846 cors_origin: '*'
847 db_name: discourse
848@@ -49,3 +50,5 @@ pod_config:
849 DISCOURSE_MAX_USER_API_REQS_PER_MINUTE: 400
850 DISCOURSE_MAX_ASSET_REQS_PER_IP_PER_10_SECONDS: 400
851 DISCOURSE_MAX_REQS_RATE_LIMIT_ON_PRIVATE: "false"
852+=======
853+>>>>>>> tests/unit/fixtures/config_valid_with_tls.yaml
854diff --git a/tests/unit/fixtures/config_valid_without_tls.yaml b/tests/unit/fixtures/config_valid_without_tls.yaml
855index 5091acc..d757c76 100644
856--- a/tests/unit/fixtures/config_valid_without_tls.yaml
857+++ b/tests/unit/fixtures/config_valid_without_tls.yaml
858@@ -1,3 +1,4 @@
859+<<<<<<< tests/unit/fixtures/config_valid_without_tls.yaml
860 config:
861 cors_origin: '*'
862 db_name: discourse
863@@ -48,3 +49,5 @@ pod_config:
864 DISCOURSE_MAX_USER_API_REQS_PER_MINUTE: 100
865 DISCOURSE_MAX_ASSET_REQS_PER_IP_PER_10_SECONDS: 200
866 DISCOURSE_MAX_REQS_RATE_LIMIT_ON_PRIVATE: "false"
867+=======
868+>>>>>>> tests/unit/fixtures/config_valid_without_tls.yaml
869diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt
870deleted file mode 100644
871index 65431fc..0000000
872--- a/tests/unit/requirements.txt
873+++ /dev/null
874@@ -1,4 +0,0 @@
875-mock
876-pytest
877-pytest-cov
878-pyyaml
879diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py
880index 5342278..d4f73c8 100644
881--- a/tests/unit/test_charm.py
882+++ b/tests/unit/test_charm.py
883@@ -3,15 +3,12 @@
884 # Copyright 2020 Canonical Ltd.
885 # See LICENSE file for licensing details.
886
887-import copy
888-import glob
889-import mock
890-import os
891 import unittest
892-import yaml
893
894-from types import SimpleNamespace
895+from ops.model import ActiveStatus, BlockedStatus, WaitingStatus
896+from ops.testing import Harness
897
898+<<<<<<< tests/unit/test_charm.py
899 from charm import (
900 check_for_config_problems,
901 check_for_missing_config_fields,
902@@ -19,35 +16,18 @@ from charm import (
903 get_pod_spec,
904 DiscourseCharm,
905 )
906+=======
907+from tests.unit._patched_charm import DiscourseCharm, pgsql_patch
908+>>>>>>> tests/unit/test_charm.py
909
910-from ops import testing
911
912-
913-def load_configs(directory):
914- """Load configs for use by tests.
915-
916- Valid and invalid configs are present in the fixtures directory. The files
917- contain the juju config, along with the spec that that config should
918- produce in the case of valid configs. In the case of invalid configs, they
919- contain the juju config and the error that should be triggered by the
920- config. These scenarios are tested below. Additional config variations can
921- be created by creating an appropriately named config file in the fixtures
922- directory. Valid configs should be named: config_valid_###.yaml and invalid
923- configs should be named: config_invalid_###.yaml."""
924- configs = {}
925- for filename in glob.glob(os.path.join(directory, 'config*.yaml')):
926- with open(filename) as file:
927- name, _ = os.path.splitext(os.path.basename(filename))
928- configs[name] = yaml.full_load(file)
929- return configs
930-
931-
932-class TestDiscourseK8sCharmHooksDisabled(unittest.TestCase):
933+class TestDiscourseK8sCharm(unittest.TestCase):
934 def setUp(self):
935- self.harness = testing.Harness(DiscourseCharm)
936- self.harness.disable_hooks()
937- self.harness.set_leader(True)
938+ pgsql_patch.start()
939+ self.harness = Harness(DiscourseCharm)
940+ self.addCleanup(self.harness.cleanup)
941 self.harness.begin()
942+<<<<<<< tests/unit/test_charm.py
943 self.maxDiff = None
944 # Let's list fields we store in config dict() which are not actual charm options
945 self.not_actual_charm_config = ['db_user', 'db_password', 'db_host', 'redis_port']
946@@ -167,3 +147,132 @@ class TestDiscourseK8sCharmHooksDisabled(unittest.TestCase):
947 self.harness.update_config(self.configs['config_valid_complete']['config'], unset=self.not_actual_charm_config)
948 self.harness.charm.on_database_relation_joined(action_event)
949 self.assertEqual(action_event.database, "discourse")
950+=======
951+ self.harness.set_leader(True)
952+
953+ def tearDown(self):
954+ pgsql_patch.stop()
955+
956+ def test_db_relation_not_ready(self):
957+ self.harness.container_pebble_ready("discourse")
958+ self.assertEqual(
959+ self.harness.model.unit.status,
960+ WaitingStatus("Waiting for database relation"),
961+ )
962+
963+ def test_config_changed_when_no_saml_target(self):
964+ self.add_database_relations()
965+ self.harness.container_pebble_ready("discourse")
966+ self.harness.update_config(
967+ {
968+ "external_hostname": "discourse.local",
969+ "force_saml_login": True,
970+ }
971+ )
972+ self.assertEqual(
973+ self.harness.model.unit.status,
974+ BlockedStatus("force_saml_login can not be true without a saml_target_url"),
975+ )
976+
977+ def test_config_changed_when_no_cors(self):
978+ self.add_database_relations()
979+ self.harness.container_pebble_ready("discourse")
980+ self.harness.update_config(
981+ {
982+ "cors_origin": "",
983+ "external_hostname": "discourse.local",
984+ }
985+ )
986+ self.assertEqual(
987+ self.harness.model.unit.status,
988+ BlockedStatus("Required configuration missing: cors_origin"),
989+ )
990+
991+ def test_config_changed_when_throttle_mode_invalid(self):
992+ self.add_database_relations()
993+ self.harness.container_pebble_ready("discourse")
994+ self.harness.update_config(
995+ {
996+ "external_hostname": "discourse.local",
997+ "throttle_level": "Scream",
998+ }
999+ )
1000+ self.assertEqual(
1001+ self.harness.model.unit.status,
1002+ BlockedStatus("throttle_level must be one of: none permissive strict"),
1003+ )
1004+
1005+ def test_config_changed_when_valid(self):
1006+ self.add_database_relations()
1007+ self.harness.container_pebble_ready("discourse")
1008+ self.harness.update_config(
1009+ {
1010+ "enable_cors": True,
1011+ "external_hostname": "discourse.local",
1012+ "force_saml_login": True,
1013+ "saml_target_url": "https://login.ubuntu.com/+saml",
1014+ "smtp_password": "OBV10USLYF4K3",
1015+ "smtp_username": "apikey",
1016+ "tls_secret_name": "somesecret",
1017+ }
1018+ )
1019+
1020+ updated_plan = self.harness.get_container_pebble_plan("discourse").to_dict()
1021+ print(updated_plan)
1022+ updated_plan_env = updated_plan["services"]["discourse"]["environment"]
1023+
1024+ self.assertEqual("*", updated_plan_env["DISCOURSE_CORS_ORIGIN"])
1025+ self.assertEqual("dbhost", updated_plan_env["DISCOURSE_DB_HOST"])
1026+ self.assertEqual("discourse-k8s", updated_plan_env["DISCOURSE_DB_NAME"])
1027+ self.assertEqual("somepasswd", updated_plan_env["DISCOURSE_DB_PASSWORD"])
1028+ self.assertEqual("someuser", updated_plan_env["DISCOURSE_DB_USERNAME"])
1029+ self.assertEqual("user@foo.internal", updated_plan_env["DISCOURSE_DEVELOPER_EMAILS"])
1030+ self.assertTrue(updated_plan_env["DISCOURSE_ENABLE_CORS"])
1031+ self.assertEqual("discourse.local", updated_plan_env["DISCOURSE_HOSTNAME"])
1032+ self.assertEqual("redis-host", updated_plan_env["DISCOURSE_REDIS_HOST"])
1033+ self.assertEqual(1010, updated_plan_env["DISCOURSE_REDIS_PORT"])
1034+ self.assertEqual(
1035+ "32:15:20:9F:A4:3C:8E:3E:8E:47:72:62:9A:86:8D:0E:E6:CF:45:D5",
1036+ updated_plan_env["DISCOURSE_SAML_CERT_FINGERPRINT"],
1037+ )
1038+ self.assertEqual("true", updated_plan_env["DISCOURSE_SAML_FULL_SCREEN_LOGIN"])
1039+ self.assertEqual("https://login.ubuntu.com/+saml", updated_plan_env["DISCOURSE_SAML_TARGET_URL"])
1040+ self.assertTrue(updated_plan_env["DISCOURSE_SERVE_STATIC_ASSETS"])
1041+ self.assertEqual("127.0.0.1", updated_plan_env["DISCOURSE_SMTP_ADDRESS"])
1042+ self.assertEqual("none", updated_plan_env["DISCOURSE_SMTP_AUTHENTICATION"])
1043+ self.assertEqual("foo.internal", updated_plan_env["DISCOURSE_SMTP_DOMAIN"])
1044+ self.assertEqual("none", updated_plan_env["DISCOURSE_SMTP_OPENSSL_VERIFY_MODE"])
1045+ self.assertEqual("OBV10USLYF4K3", updated_plan_env["DISCOURSE_SMTP_PASSWORD"])
1046+ self.assertEqual(587, updated_plan_env["DISCOURSE_SMTP_PORT"])
1047+ self.assertEqual("apikey", updated_plan_env["DISCOURSE_SMTP_USER_NAME"])
1048+
1049+ self.assertEqual(self.harness.model.unit.status, ActiveStatus())
1050+
1051+ self.assertEqual("discourse.local", self.harness.charm.ingress.config_dict["service-hostname"])
1052+ self.assertEqual("somesecret", self.harness.charm.ingress.config_dict["tls-secret-name"])
1053+ self.assertEqual(20, self.harness.charm.ingress.config_dict["max-body-size"])
1054+
1055+ def test_db_relation(self):
1056+ self.add_database_relations()
1057+ self.harness.set_leader(True)
1058+ # testing harness not re-emits deferred events, manually trigger that
1059+ self.harness.framework.reemit()
1060+ db_relation_data = self.harness.get_relation_data(self.db_relation_id, self.harness.charm.app.name)
1061+ self.assertEqual(
1062+ db_relation_data.get("database"),
1063+ "discourse-k8s",
1064+ "database name should be set after relation joined",
1065+ )
1066+
1067+ def add_database_relations(self):
1068+ self.harness.charm._stored.db_name = "discourse-k8s"
1069+ self.harness.charm._stored.db_user = "someuser"
1070+ self.harness.charm._stored.db_password = "somepasswd"
1071+ self.harness.charm._stored.db_host = "dbhost"
1072+ self.db_relation_id = self.harness.add_relation("db", self.harness.charm.app.name)
1073+ self.harness.add_relation_unit(self.db_relation_id, "postgresql/0")
1074+
1075+ redis_relation_id = self.harness.add_relation("redis", self.harness.charm.app.name)
1076+ self.harness.add_relation_unit(redis_relation_id, "redis/0")
1077+ self.harness.charm._stored.redis_relation = {redis_relation_id: {"hostname": "redis-host", "port": 1010}}
1078+>>>>>>> tests/unit/test_charm.py
1079diff --git a/tox.ini b/tox.ini
1080index 6c0b24c..63e371f 100644
1081--- a/tox.ini
1082+++ b/tox.ini
1083@@ -5,6 +5,13 @@ skip_missing_interpreters = True
1084
1085 [testenv]
1086 basepython = python3
1087+<<<<<<< tox.ini
1088+=======
1089+setenv =
1090+ PYTHONPATH = {toxinidir}/lib:{toxinidir}/src:{toxinidir}/tests:{toxinidir}/build/lib:{toxinidir}/build/venv
1091+passenv =
1092+ PYTHONPATH
1093+>>>>>>> tox.ini
1094
1095 [testenv:unit]
1096 commands =
1097@@ -12,9 +19,12 @@ commands =
1098 {posargs:-v --cov=src --cov-report=term-missing --cov-branch}
1099 deps = -r{toxinidir}/tests/unit/requirements.txt
1100 -r{toxinidir}/requirements.txt
1101+<<<<<<< tox.ini
1102 setenv =
1103 PYTHONPATH = {toxinidir}/lib:{toxinidir}/src
1104 TZ=UTC
1105+=======
1106+>>>>>>> tox.ini
1107
1108 [testenv:black]
1109 commands = black src/ tests/

Subscribers

People subscribed via source and target branches