Merge lp:~free.ekanayaka/landscape-charm/enable-juju-sync into lp:~landscape/landscape-charm/trunk

Proposed by Free Ekanayaka
Status: Merged
Approved by: Free Ekanayaka
Approved revision: 248
Merged at revision: 243
Proposed branch: lp:~free.ekanayaka/landscape-charm/enable-juju-sync
Merge into: lp:~landscape/landscape-charm/trunk
Prerequisite: lp:~danilo/landscape-charm/enable-cronscripts
Diff against target: 469 lines (+281/-5)
11 files modified
hooks/hosted-relation-changed (+9/-0)
hooks/lib/callbacks/filesystem.py (+32/-0)
hooks/lib/callbacks/tests/test_filesystem.py (+29/-0)
hooks/lib/relations/hosted.py (+37/-0)
hooks/lib/relations/tests/test_hosted.py (+61/-0)
hooks/lib/services.py (+8/-1)
hooks/lib/tests/sample.py (+4/-0)
hooks/lib/tests/test_services.py (+38/-3)
hooks/lib/tests/test_templates.py (+58/-1)
templates/landscape-server (+4/-0)
templates/service.conf (+1/-0)
To merge this branch: bzr merge lp:~free.ekanayaka/landscape-charm/enable-juju-sync
Reviewer Review Type Date Requested Status
Alberto Donato (community) Approve
Adam Collard (community) Approve
🤖 Landscape Builder test results Approve
Review via email: mp+255182@code.launchpad.net

Commit message

This branch enables the juju-sync process on the leader unit, if
the deployment is a standalone deployment.

In order to fix the main bug, I also had to fix a side bug, i.e. handling
the relation with the landscape-hosted charm, so we know which deployment
mode we're deployed with (and hence whether to fire juju-sync or not). Since
the final branch is not too big, I'm pushing everything in one go.

Description of the change

This branch enables the juju-sync process on the leader unit, if
the deployment is a standalone deployment.

In order to fix the main bug, I also had to fix a side bug, i.e. handling
the relation with the landscape-hosted charm, so we know which deployment
mode we're deployed with (and hence whether to fire juju-sync or not). Since
the final branch is not too big, I'm pushing everything in one go.

To test this branch grab the juju-deployer config from

https://pastebin.canonical.com/128932/

and deploy with

juju-deployer -vdW -w180 -c landscape.yaml landscape

You'll see that the landscape-server unit has RUN_JUJU_SYNC=yes in
its /etc/default/landscape-server file and that the juju-sync
process is actually running on it.

If you now run "juju add-unit landscape-server" and wait, the new
unit won't be running juju-sync, as it's not the leader.

Furthermore if you grab the landscape-hosted charm (lp:landscape-hosted-charm),
deploy it and relate it to landscape-server you'll see that the
leader unit no longer runs juju-sync.

To post a comment you must log in.
Revision history for this message
🤖 Landscape Builder (landscape-builder) wrote :

Command: make ci-test
Result: Success
Revno: 243
Branch: lp:~free.ekanayaka/landscape-charm/enable-juju-sync
Jenkins: https://ci.lscape.net/job/latch-test/398/

review: Approve (test results)
Revision history for this message
Adam Collard (adam-collard) wrote :

I got a conflict when merging into trunk (r241)

Text conflict in hooks/lib/tests/test_templates.py
1 conflicts encountered.

review: Needs Fixing
244. By Free Ekanayaka

Merge from trunk, solve conflicts

Revision history for this message
Free Ekanayaka (free.ekanayaka) wrote :

> I got a conflict when merging into trunk (r241)
>
> Text conflict in hooks/lib/tests/test_templates.py
> 1 conflicts encountered.

Fixed.

Revision history for this message
🤖 Landscape Builder (landscape-builder) wrote :

Command: make ci-test
Result: Success
Revno: 244
Branch: lp:~free.ekanayaka/landscape-charm/enable-juju-sync
Jenkins: https://ci.lscape.net/job/latch-test/412/

review: Approve (test results)
Revision history for this message
Adam Collard (adam-collard) wrote :

3 comments inline

review: Needs Information
Revision history for this message
Alberto Donato (ack) wrote :

Code looks good, one question inline (and some minor comments).

review: Needs Information
Revision history for this message
Free Ekanayaka (free.ekanayaka) wrote :

Thanks Adam, I've fixed/replied to all comments.

245. By Free Ekanayaka

Use python to create symlinks

246. By Free Ekanayaka

Merge from trunk, fix conflicts

247. By Free Ekanayaka

Fix typo

Revision history for this message
Free Ekanayaka (free.ekanayaka) wrote :

Thanks Alberto, all fixed.

Revision history for this message
🤖 Landscape Builder (landscape-builder) wrote :

Command: make ci-test
Result: Success
Revno: 247
Branch: lp:~free.ekanayaka/landscape-charm/enable-juju-sync
Jenkins: https://ci.lscape.net/job/latch-test/415/

review: Approve (test results)
Revision history for this message
Adam Collard (adam-collard) wrote :

LGTM, +1 with a couple of docstring cleanups inline below.

review: Approve
Revision history for this message
Alberto Donato (ack) wrote :

Looks good, +1

minor comments inline

review: Approve
Revision history for this message
Free Ekanayaka (free.ekanayaka) wrote :

All fixed.

248. By Free Ekanayaka

Address review comments

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'hooks/hosted-relation-changed'
2--- hooks/hosted-relation-changed 1970-01-01 00:00:00 +0000
3+++ hooks/hosted-relation-changed 2015-04-08 12:40:27 +0000
4@@ -0,0 +1,9 @@
5+#!/usr/bin/python
6+import sys
7+
8+from lib.services import ServicesHook
9+
10+
11+if __name__ == "__main__":
12+ hook = ServicesHook()
13+ sys.exit(hook())
14
15=== added file 'hooks/lib/callbacks/filesystem.py'
16--- hooks/lib/callbacks/filesystem.py 1970-01-01 00:00:00 +0000
17+++ hooks/lib/callbacks/filesystem.py 2015-04-08 12:40:27 +0000
18@@ -0,0 +1,32 @@
19+# Filesystem-related callbacks
20+
21+import os
22+
23+from charmhelpers.core.services.base import ManagerCallback
24+
25+CONFIGS_DIR = "/opt/canonical/landscape/configs"
26+
27+
28+class EnsureConfigDir(ManagerCallback):
29+ """Ensure that the config dir for the configured deployment mode exists.
30+
31+ XXX This is a temporary workaround till we make the Landscape server
32+ code always look at the same location for configuration files.
33+ """
34+ def __init__(self, configs_dir=CONFIGS_DIR):
35+ self._configs_dir = configs_dir
36+
37+ def __call__(self, manager, service_name, event_name):
38+ service = manager.get_service(service_name)
39+
40+ # Lookup the deployment mode
41+ for data in service.get("required_data"):
42+ if "hosted" in data:
43+ deployment_mode = data["hosted"][0]["deployment-mode"]
44+ break
45+
46+ # Create a symlink for the config directory
47+ config_link = os.path.join(self._configs_dir, deployment_mode)
48+ if not os.path.exists(config_link):
49+ standalone_dir = os.path.join(self._configs_dir, "standalone")
50+ os.symlink(standalone_dir, config_link)
51
52=== added file 'hooks/lib/callbacks/tests/test_filesystem.py'
53--- hooks/lib/callbacks/tests/test_filesystem.py 1970-01-01 00:00:00 +0000
54+++ hooks/lib/callbacks/tests/test_filesystem.py 2015-04-08 12:40:27 +0000
55@@ -0,0 +1,29 @@
56+import os
57+
58+from fixtures import TempDir
59+
60+from charmhelpers.core.services.base import ServiceManager
61+
62+from lib.tests.helpers import HookenvTest
63+from lib.callbacks.filesystem import EnsureConfigDir
64+
65+
66+class EnsureConfigDirTest(HookenvTest):
67+
68+ with_hookenv_monkey_patch = True
69+
70+ def setUp(self):
71+ super(EnsureConfigDirTest, self).setUp()
72+ self.configs_dir = self.useFixture(TempDir())
73+ self.callback = EnsureConfigDir(self.configs_dir.path)
74+
75+ def test_options(self):
76+ """
77+ The callback creates a config dir symlink if needed.
78+ """
79+ manager = ServiceManager([{
80+ "service": "landscape",
81+ "required_data": [{"hosted": [{"deployment-mode": "edge"}]}],
82+ }])
83+ self.callback(manager, "landscape", None)
84+ self.assertIsNotNone(os.lstat(self.configs_dir.join("edge")))
85
86=== added file 'hooks/lib/relations/hosted.py'
87--- hooks/lib/relations/hosted.py 1970-01-01 00:00:00 +0000
88+++ hooks/lib/relations/hosted.py 2015-04-08 12:40:27 +0000
89@@ -0,0 +1,37 @@
90+from charmhelpers.core.services.helpers import RelationContext
91+
92+from lib.hook import HookError
93+
94+DEPLOYMENT_MODES = ("standalone", "edge", "staging", "production")
95+
96+
97+class HostedRequirer(RelationContext):
98+ """Relation data requirer for the 'hosted' interface.
99+
100+ This relation acquires information from the landscape-hosted subordinate,
101+ which will affect local configuration (for instance 'deployment-mode').
102+
103+ If we're not related to any landscape-hosted subordinate, then this data
104+ manager will simply fall back to stock data setting a 'standalone' mode.
105+ """
106+ name = "hosted"
107+ interface = "landscape-hosted"
108+ required_keys = [
109+ "deployment-mode", # Can be standalone/edge/staging/production.
110+ ]
111+
112+ def get_data(self):
113+ super(HostedRequirer, self).get_data()
114+ data = self.get(self.name)
115+ if data is None:
116+ # This means that we're not currently related to landscape-hosted,
117+ # so we set the deployment mode to standalone.
118+ self.update({self.name: [{"deployment-mode": "standalone"}]})
119+ else:
120+ # We're related to landscape-hosted, and it's safe to assume that
121+ # there's exactly one unit we're related to, since landscape-hosted
122+ # is a subordinate charm.
123+ deployment_mode = data[0]["deployment-mode"]
124+ if deployment_mode not in DEPLOYMENT_MODES:
125+ raise HookError(
126+ "Invalid deployment-mode '%s'" % deployment_mode)
127
128=== added file 'hooks/lib/relations/tests/test_hosted.py'
129--- hooks/lib/relations/tests/test_hosted.py 1970-01-01 00:00:00 +0000
130+++ hooks/lib/relations/tests/test_hosted.py 2015-04-08 12:40:27 +0000
131@@ -0,0 +1,61 @@
132+from lib.tests.helpers import HookenvTest
133+from lib.tests.sample import SAMPLE_HOSTED_DATA
134+from lib.relations.hosted import HostedRequirer
135+from lib.hook import HookError
136+
137+
138+class HostedRequirerTest(HookenvTest):
139+
140+ with_hookenv_monkey_patch = True
141+
142+ def test_required_keys(self):
143+ """
144+ The HostedRequirer class defines all keys that are required to
145+ be set on the cluster relation in order for the relation to be
146+ considered ready.
147+ """
148+ self.assertItemsEqual(
149+ ["deployment-mode"],
150+ HostedRequirer.required_keys)
151+
152+ def test_not_related(self):
153+ """
154+ When the landscape-server service is not related to landscape-hosted
155+ the deployment-mode is standalone.
156+ """
157+ relation = HostedRequirer()
158+ self.assertTrue(relation.is_ready())
159+ self.assertEqual("standalone", SAMPLE_HOSTED_DATA["deployment-mode"])
160+ self.assertEqual([SAMPLE_HOSTED_DATA], relation["hosted"])
161+
162+ def test_related(self):
163+ """
164+ When the landscape-server service is related to landscape-hosted
165+ the deployment-mode is the one set on the relation.
166+ """
167+ hosted_data = {"deployment-mode": "production"}
168+ self.hookenv.relations = {
169+ "hosted": {
170+ "hosted:1": {
171+ "landscape-hosted/1": hosted_data,
172+ }
173+ }
174+ }
175+ relation = HostedRequirer()
176+ self.assertTrue(relation.is_ready())
177+ self.assertEqual([hosted_data], relation["hosted"])
178+
179+ def test_invalid_deployment_mode(self):
180+ """
181+ The deployment mode set on the relation must be a valid one, otherwise
182+ an error is raised.
183+ """
184+ hosted_data = {"deployment-mode": "foo"}
185+ self.hookenv.relations = {
186+ "hosted": {
187+ "hosted:1": {
188+ "landscape-hosted/1": hosted_data,
189+ }
190+ }
191+ }
192+ self.assertRaises(HookError, HostedRequirer)
193
194=== modified file 'hooks/lib/services.py'
195--- hooks/lib/services.py 2015-04-07 13:54:01 +0000
196+++ hooks/lib/services.py 2015-04-08 12:40:27 +0000
197@@ -12,7 +12,10 @@
198 from lib.relations.haproxy import HAProxyProvider, OFFLINE_FOLDER
199 from lib.relations.landscape import (
200 LandscapeLeaderContext, LandscapeRequirer, LandscapeProvider)
201+from lib.relations.hosted import HostedRequirer
202 from lib.callbacks.scripts import SchemaBootstrap, LSCtl
203+from lib.callbacks.filesystem import CONFIGS_DIR, EnsureConfigDir
204+
205
206 SERVICE_CONF = "/etc/landscape/service.conf"
207 DEFAULT_FILE = "/etc/default/landscape-server"
208@@ -26,11 +29,13 @@
209 proceed with the configuration if ready.
210 """
211 def __init__(self, hookenv=hookenv, cluster=cluster, host=host,
212- subprocess=subprocess, offline_dir=OFFLINE_FOLDER):
213+ subprocess=subprocess, configs_dir=CONFIGS_DIR,
214+ offline_dir=OFFLINE_FOLDER):
215 super(ServicesHook, self).__init__(hookenv=hookenv)
216 self._cluster = cluster
217 self._host = host
218 self._subprocess = subprocess
219+ self._configs_dir = configs_dir
220 self._offline_dir = offline_dir
221
222 def _run(self):
223@@ -51,6 +56,7 @@
224 LandscapeRequirer(leader_context),
225 PostgreSQLRequirer(),
226 RabbitMQRequirer(),
227+ HostedRequirer(),
228 {"config": hookenv.config(),
229 "is_leader": is_leader},
230 ],
231@@ -61,6 +67,7 @@
232 render_template(
233 owner="landscape", group="root", perms=0o640,
234 source="landscape-server", target=DEFAULT_FILE),
235+ EnsureConfigDir(configs_dir=self._configs_dir),
236 SchemaBootstrap(subprocess=self._subprocess),
237 ],
238 "start": LSCtl(subprocess=self._subprocess),
239
240=== modified file 'hooks/lib/tests/sample.py'
241--- hooks/lib/tests/sample.py 2015-03-26 09:02:12 +0000
242+++ hooks/lib/tests/sample.py 2015-04-08 12:40:27 +0000
243@@ -22,3 +22,7 @@
244 "openid-provider-url": "http://openid-host/",
245 "openid-logout-url": "http://openid-host/logout",
246 }
247+
248+SAMPLE_HOSTED_DATA = {
249+ "deployment-mode": "standalone",
250+}
251
252=== modified file 'hooks/lib/tests/test_services.py'
253--- hooks/lib/tests/test_services.py 2015-04-07 13:54:01 +0000
254+++ hooks/lib/tests/test_services.py 2015-04-08 12:40:27 +0000
255@@ -1,10 +1,14 @@
256+import os
257+
258+from fixtures import TempDir
259+
260 from charmhelpers.core import templating
261
262 from lib.tests.helpers import HookenvTest
263 from lib.tests.stubs import ClusterStub, HostStub, SubprocessStub
264 from lib.tests.sample import (
265 SAMPLE_DB_UNIT_DATA, SAMPLE_LEADER_CONTEXT_DATA, SAMPLE_AMQP_UNIT_DATA,
266- SAMPLE_CONFIG_OPENID_DATA)
267+ SAMPLE_CONFIG_OPENID_DATA, SAMPLE_HOSTED_DATA)
268 from lib.services import ServicesHook, SERVICE_CONF, DEFAULT_FILE
269 from lib.tests.offline_fixture import OfflineDir
270
271@@ -18,10 +22,12 @@
272 self.cluster = ClusterStub()
273 self.host = HostStub()
274 self.subprocess = SubprocessStub()
275- self.offline_dir = self.useFixture(OfflineDir()).path
276+ self.offline_dir = self.useFixture(OfflineDir())
277+ self.configs_dir = self.useFixture(TempDir())
278 self.hook = ServicesHook(
279 hookenv=self.hookenv, cluster=self.cluster, host=self.host,
280- subprocess=self.subprocess, offline_dir=self.offline_dir)
281+ subprocess=self.subprocess, offline_dir=self.offline_dir.path,
282+ configs_dir=self.configs_dir.path)
283
284 # XXX Monkey patch the templating API, charmhelpers doesn't sport
285 # any dependency injection here as well.
286@@ -94,6 +100,7 @@
287 "db": [SAMPLE_DB_UNIT_DATA],
288 "leader": SAMPLE_LEADER_CONTEXT_DATA,
289 "amqp": [SAMPLE_AMQP_UNIT_DATA],
290+ "hosted": [SAMPLE_HOSTED_DATA],
291 "config": {},
292 "is_leader": True,
293 }
294@@ -111,6 +118,33 @@
295 self.assertEqual(
296 ["/usr/bin/lsctl", "restart"], call2[0])
297
298+ def test_ready_with_non_standalone_deployment_mode(self):
299+ """
300+ If deployment-mode is set to 'edge' an appropriate config symlink will
301+ be created
302+ """
303+ hosted_data = SAMPLE_HOSTED_DATA.copy()
304+ hosted_data["deployment-mode"] = "edge"
305+ self.hookenv.relations = {
306+ "db": {
307+ "db:1": {
308+ "postgresql/0": SAMPLE_DB_UNIT_DATA,
309+ },
310+ },
311+ "amqp": {
312+ "amqp:1": {
313+ "rabbitmq-server/0": SAMPLE_AMQP_UNIT_DATA,
314+ },
315+ },
316+ "hosted": {
317+ "hosted:1": {
318+ "landscape-hosted/0": hosted_data,
319+ },
320+ },
321+ }
322+ self.hook()
323+ self.assertIsNotNone(os.lstat(self.configs_dir.join("edge")))
324+
325 def test_ready_with_openid_configuration(self):
326 """
327 OpenID configuration is passed in to service.conf generation if
328@@ -135,6 +169,7 @@
329 "leader": SAMPLE_LEADER_CONTEXT_DATA,
330 "amqp": [SAMPLE_AMQP_UNIT_DATA],
331 "config": SAMPLE_CONFIG_OPENID_DATA,
332+ "hosted": [SAMPLE_HOSTED_DATA],
333 "is_leader": True,
334 }
335
336
337=== modified file 'hooks/lib/tests/test_templates.py'
338--- hooks/lib/tests/test_templates.py 2015-04-06 08:34:00 +0000
339+++ hooks/lib/tests/test_templates.py 2015-04-08 12:40:27 +0000
340@@ -4,7 +4,8 @@
341
342 from lib.tests.helpers import TemplateTest
343 from lib.tests.sample import (
344- SAMPLE_DB_UNIT_DATA, SAMPLE_LEADER_CONTEXT_DATA, SAMPLE_AMQP_UNIT_DATA)
345+ SAMPLE_DB_UNIT_DATA, SAMPLE_LEADER_CONTEXT_DATA, SAMPLE_AMQP_UNIT_DATA,
346+ SAMPLE_HOSTED_DATA)
347
348
349 class ServiceConfTest(TemplateTest):
350@@ -21,6 +22,7 @@
351 "db": [SAMPLE_DB_UNIT_DATA],
352 "amqp": [SAMPLE_AMQP_UNIT_DATA],
353 "leader": SAMPLE_LEADER_CONTEXT_DATA,
354+ "hosted": [SAMPLE_HOSTED_DATA],
355 "config": {},
356 }
357 buffer = StringIO(self.template.render(context))
358@@ -36,6 +38,7 @@
359 "landscape-token", config.get("landscape", "secret-token"))
360 self.assertFalse(config.has_option("landscape", "openid-provider-url"))
361 self.assertFalse(config.has_option("landscape", "openid-logout-url"))
362+ self.assertEqual("standalone", config.get("global", "deployment-mode"))
363
364 def test_render_with_openid(self):
365 """
366@@ -46,6 +49,7 @@
367 "db": [SAMPLE_DB_UNIT_DATA],
368 "amqp": [SAMPLE_AMQP_UNIT_DATA],
369 "leader": SAMPLE_LEADER_CONTEXT_DATA,
370+ "hosted": [SAMPLE_HOSTED_DATA],
371 "config": {
372 "openid-provider-url": "http://openid-host/",
373 "openid-logout-url": "http://openid-host/logout",
374@@ -73,6 +77,8 @@
375 on a particular unit.
376 """
377 context = {
378+ "hosted": [SAMPLE_HOSTED_DATA],
379+ "config": {},
380 "is_leader": True,
381 }
382 buffer = StringIO(self.template.render(context)).readlines()
383@@ -83,7 +89,58 @@
384 On a non-leader unit, cron scripts are not enabled by default.
385 """
386 context = {
387+ "hosted": [SAMPLE_HOSTED_DATA],
388+ "config": {},
389 "is_leader": False,
390 }
391 buffer = StringIO(self.template.render(context)).readlines()
392 self.assertIn('RUN_CRON="no"\n', buffer)
393+
394+ def test_render_juju_sync(self):
395+ """
396+ If the landscape-server unit is the leader and we're in standalone
397+ mode, juju-sync will be run.
398+ """
399+ context = {
400+ "db": [SAMPLE_DB_UNIT_DATA],
401+ "amqp": [SAMPLE_AMQP_UNIT_DATA],
402+ "leader": SAMPLE_LEADER_CONTEXT_DATA,
403+ "hosted": [SAMPLE_HOSTED_DATA],
404+ "config": {},
405+ "is_leader": True,
406+ }
407+ buffer = StringIO(self.template.render(context)).readlines()
408+ self.assertIn('RUN_JUJU_SYNC="yes"\n', buffer)
409+
410+ def test_render_juju_sync_not_leader(self):
411+ """
412+ If the landscape-server unit is not the leader, juju-sync
413+ won't be run.
414+ """
415+ context = {
416+ "db": [SAMPLE_DB_UNIT_DATA],
417+ "amqp": [SAMPLE_AMQP_UNIT_DATA],
418+ "leader": SAMPLE_LEADER_CONTEXT_DATA,
419+ "hosted": [SAMPLE_HOSTED_DATA],
420+ "config": {},
421+ "is_leader": False,
422+ }
423+ buffer = StringIO(self.template.render(context)).readlines()
424+ self.assertIn('RUN_JUJU_SYNC="no"\n', buffer)
425+
426+ def test_render_juju_sync_not_standalone(self):
427+ """
428+ If the deployment mode is not standalone, juju-sync won't be run.
429+ """
430+ hosted_data = SAMPLE_HOSTED_DATA.copy()
431+ hosted_data["deployment-mode"] = "production"
432+ context = {
433+ "db": [SAMPLE_DB_UNIT_DATA],
434+ "amqp": [SAMPLE_AMQP_UNIT_DATA],
435+ "leader": SAMPLE_LEADER_CONTEXT_DATA,
436+ "hosted": [hosted_data],
437+ "config": {},
438+ "is_leader": True,
439+ }
440+ buffer = StringIO(self.template.render(context)).readlines()
441+ self.assertIn('RUN_JUJU_SYNC="no"\n', buffer)
442
443=== modified file 'templates/landscape-server'
444--- templates/landscape-server 2015-04-06 08:34:00 +0000
445+++ templates/landscape-server 2015-04-08 12:40:27 +0000
446@@ -15,7 +15,11 @@
447 RUN_PINGSERVER="yes"
448 RUN_APISERVER="yes"
449 RUN_PACKAGEUPLOADSERVER="no"
450+{% if is_leader and hosted[0]["deployment-mode"] == "standalone" %}
451+RUN_JUJU_SYNC="yes"
452+{% else %}
453 RUN_JUJU_SYNC="no"
454+{% endif %}
455 RUN_PACKAGESEARCH="no"
456
457 # To run cron jobs on this server when RUN_ALL="no" set RUN_CRON to "yes".
458
459=== modified file 'templates/service.conf'
460--- templates/service.conf 2015-04-02 16:37:14 +0000
461+++ templates/service.conf 2015-04-08 12:40:27 +0000
462@@ -3,6 +3,7 @@
463 [global]
464 # Syslog address is either a socket path or server:port string.
465 syslog-address = /dev/log
466+deployment-mode = {{ hosted[0]["deployment-mode"] }}
467
468 [stores]
469 host = {{ postgres["host"] }}:{{ postgres["port"] }}

Subscribers

People subscribed via source and target branches