← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:charm-postgresql-relation into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:charm-postgresql-relation into launchpad:master.

Commit message:
charm: Implement PostgreSQL relations

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/430399

Rather than having to configure connection strings manually, this now allows simply using `juju relate launchpad-appserver:db postgresql:db; juju relate launchpad-appserver:session-db postgresql:db` to connect it to a PostgreSQL deployment.

The username/password arrangements are currently quite awkward, because Launchpad assumes that it will have a separate password for each role it needs to use, rather than having a password for a parent role and then using `SET ROLE` to switch.  We can update the charm once Launchpad has some improved mechanisms, though.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charm-postgresql-relation into launchpad:master.
diff --git a/charm/dependencies.txt b/charm/dependencies.txt
index a4a6420..26b54bc 100644
--- a/charm/dependencies.txt
+++ b/charm/dependencies.txt
@@ -1,2 +1,2 @@
-ols-layers		git+ssh://git.launchpad.net/~ubuntuone-pqm-team/ols-charm-deps/+git/ols-layers;revno=df20c87d
+ols-layers		git+ssh://git.launchpad.net/~ubuntuone-pqm-team/ols-charm-deps/+git/ols-layers;revno=8e3b4816
 charm-wheels		git+ssh://git.launchpad.net/~ubuntuone-hackers/ols-charm-deps/+git/wheels;revno=fe523e25
diff --git a/charm/launchpad-appserver/charmcraft.yaml b/charm/launchpad-appserver/charmcraft.yaml
index 2a40540..824480b 100644
--- a/charm/launchpad-appserver/charmcraft.yaml
+++ b/charm/launchpad-appserver/charmcraft.yaml
@@ -11,6 +11,7 @@ parts:
     source: .
     plugin: reactive
     build-snaps: [charm]
+    build-packages: [libpq-dev]
     build-environment:
       - CHARM_LAYERS_DIR: tmp/deps/ols-layers/layer
       - CHARM_INTERFACES_DIR: tmp/deps/ols-layers/interface
diff --git a/charm/launchpad-appserver/config.yaml b/charm/launchpad-appserver/config.yaml
index 8967f67..43f4a45 100644
--- a/charm/launchpad-appserver/config.yaml
+++ b/charm/launchpad-appserver/config.yaml
@@ -1,12 +1,4 @@
 options:
-  dbuser:
-    type: string
-    description: The database user used with the main database.
-    default: ""
-  dbuser_session:
-    type: string
-    description: The database user used with the session database.
-    default: ""
   devmode:
     type: boolean
     description: Is this server running in dev mode?
diff --git a/charm/launchpad-appserver/layer.yaml b/charm/launchpad-appserver/layer.yaml
index bcff867..ae77d24 100644
--- a/charm/launchpad-appserver/layer.yaml
+++ b/charm/launchpad-appserver/layer.yaml
@@ -1,3 +1,12 @@
 includes:
   - layer:launchpad-base
 repo: https://git.launchpad.net/launchpad
+options:
+  ols-pg:
+    databases:
+      db:
+        name: launchpad_dev
+        roles: launchpad_main
+      session-db:
+        name: session_dev
+        roles: session
diff --git a/charm/launchpad-appserver/metadata.yaml b/charm/launchpad-appserver/metadata.yaml
index c778bcf..7f7b48e 100644
--- a/charm/launchpad-appserver/metadata.yaml
+++ b/charm/launchpad-appserver/metadata.yaml
@@ -13,3 +13,6 @@ tags:
 series:
   - focal
 subordinate: false
+requires:
+  session-db:
+    interface: pgsql
diff --git a/charm/launchpad-appserver/reactive/launchpad-appserver.py b/charm/launchpad-appserver/reactive/launchpad-appserver.py
index 91745af..30b1f40 100644
--- a/charm/launchpad-appserver/reactive/launchpad-appserver.py
+++ b/charm/launchpad-appserver/reactive/launchpad-appserver.py
@@ -10,6 +10,8 @@ from charms.launchpad.base import (
     configure_lazr,
     get_service_config,
     lazr_config_files,
+    strip_dsn_authentication,
+    update_pgpass,
 )
 from charms.reactive import (
     clear_flag,
@@ -19,7 +21,8 @@ from charms.reactive import (
     when,
     when_not,
 )
-from ols import base
+from ols import base, postgres
+from psycopg2.extensions import parse_dsn
 
 
 def reload_or_restart(service):
@@ -84,10 +87,17 @@ def config_files():
     return files
 
 
-@when("launchpad.base.configured")
+@when("launchpad.base.configured", "session-db.master.available")
 @when_not("service.configured")
-def configure():
+def configure(session_db):
     config = get_service_config()
+    session_db_primary, _ = postgres.get_db_uris(session_db)
+    # XXX cjwatson 2022-09-23: Mangle the connection string into a form
+    # Launchpad understands.  In the long term it would be better to have
+    # Launchpad be able to consume unmodified connection strings.
+    update_pgpass(session_db_primary)
+    config["db_session"] = strip_dsn_authentication(session_db_primary)
+    config["db_session_user"] = parse_dsn(session_db_primary)["user"]
     # XXX cjwatson 2022-09-07: Some config items have no reasonable default.
     # We should set the workload status to blocked in that case.
     configure_lazr(
diff --git a/charm/launchpad-appserver/templates/launchpad-appserver-lazr.conf b/charm/launchpad-appserver/templates/launchpad-appserver-lazr.conf
index dfa56bd..bafc316 100644
--- a/charm/launchpad-appserver/templates/launchpad-appserver-lazr.conf
+++ b/charm/launchpad-appserver/templates/launchpad-appserver-lazr.conf
@@ -10,14 +10,11 @@
 extends: ../launchpad-base-lazr.conf
 
 [launchpad]
-{%- if dbuser %}
-dbuser: {{ dbuser }}
-{%- endif %}
 devmode: {{ devmode }}
 min_legitimate_account_age: {{ min_legitimate_account_age }}
 min_legitimate_karma: {{ min_legitimate_karma }}
 
 [launchpad_session]
-{%- if dbuser_session %}
-dbuser: {{ dbuser_session }}
-{%- endif %}
+database: {{ db_session }}
+dbuser: {{ db_session_user }}
+cookie: {{ session_cookie_name }}
diff --git a/charm/layer/launchpad-base/config.yaml b/charm/layer/launchpad-base/config.yaml
index cb665fe..c33c239 100644
--- a/charm/layer/launchpad-base/config.yaml
+++ b/charm/layer/launchpad-base/config.yaml
@@ -3,17 +3,12 @@ options:
     type: string
     description: URL of file used to control whether cron scripts run.
     default: "file:cronscripts.ini"
-  db_primary:
+  databases:
     type: string
-    description: Connection string for primary database.
-    default: ""
-  db_session:
-    type: string
-    description: Connection string for session database.
-    default: ""
-  db_standby:
-    type: string
-    description: Connection string for standby database, if any.
+    description: >
+      YAML-encoded information about database relations, overriding the
+      defaults.  For example, setting this to "db: {name: launchpad_prod}"
+      changes the name of the database used by the "db" relation.
     default: ""
   domain:
     type: string
diff --git a/charm/layer/launchpad-base/layer.yaml b/charm/layer/launchpad-base/layer.yaml
index ae16aa0..0a62526 100644
--- a/charm/layer/launchpad-base/layer.yaml
+++ b/charm/layer/launchpad-base/layer.yaml
@@ -1,6 +1,6 @@
 includes:
   - layer:basic
-  - layer:ols
+  - layer:ols-pg
 options:
   apt:
     packages:
diff --git a/charm/layer/launchpad-base/lib/charms/launchpad/base.py b/charm/layer/launchpad-base/lib/charms/launchpad/base.py
index 67bfdcb..da811fe 100644
--- a/charm/layer/launchpad-base/lib/charms/launchpad/base.py
+++ b/charm/layer/launchpad-base/lib/charms/launchpad/base.py
@@ -1,10 +1,15 @@
 # Copyright 2022 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
+import grp
 import os.path
+import pwd
+import re
+from dataclasses import dataclass
 
 from charmhelpers.core import hookenv, host, templating
 from ols import base
+from psycopg2.extensions import make_dsn, parse_dsn
 
 
 def home_dir():
@@ -80,3 +85,108 @@ def configure_rsync(config, template, name):
         templating.render(template, rsync_path, config, perms=0o644)
     elif os.path.exists(rsync_path):
         os.unlink(rsync_path)
+
+
+@dataclass
+class PgPassLine:
+    hostname: str
+    port: str
+    database: str
+    username: str
+    password: str
+
+
+def update_pgpass(dsn):
+    # See https://www.postgresql.org/docs/current/libpq-pgpass.html.
+
+    def unescape(entry):
+        return re.sub(r"\\(.)", r"\1", entry)
+
+    def escape(entry):
+        return re.sub(r"([:\\])", r"\\\1", entry)
+
+    parsed_dsn = parse_dsn(dsn)
+    pgpass_path = os.path.join(home_dir(), ".pgpass")
+    pgpass = []
+    try:
+        with open(pgpass_path) as f:
+            for line in f:
+                if line.startswith("#"):
+                    continue
+                match = re.match(
+                    r"""
+                        ^
+                        (?P<hostname>(?:[^:\\]|\\.)*):
+                        (?P<port>(?:[^:\\]|\\.)*):
+                        (?P<database>(?:[^:\\]|\\.)*):
+                        (?P<username>(?:[^:\\]|\\.)*):
+                        (?P<password>(?:[^:\\]|\\.)*)
+                        $
+                    """,
+                    line.rstrip("\n"),
+                    flags=re.X,
+                )
+                if match is None:
+                    continue
+                pgpass.append(
+                    PgPassLine(
+                        hostname=unescape(match.group("hostname")),
+                        port=unescape(match.group("port")),
+                        database=unescape(match.group("database")),
+                        username=unescape(match.group("username")),
+                        password=unescape(match.group("password")),
+                    )
+                )
+    except OSError:
+        pass
+
+    modified = False
+    for line in pgpass:
+        if (
+            line.hostname in ("*", parsed_dsn["host"])
+            and line.port in ("*", parsed_dsn["port"])
+            and line.database in ("*", parsed_dsn["dbname"])
+            and line.username in ("*", parsed_dsn["user"])
+        ):
+            if line.password != parsed_dsn["password"]:
+                line.password = parsed_dsn["password"]
+                modified = True
+            break
+    else:
+        pgpass.append(
+            PgPassLine(
+                hostname=parsed_dsn["host"],
+                port=parsed_dsn["port"],
+                database=parsed_dsn["dbname"],
+                username=parsed_dsn["user"],
+                password=parsed_dsn["password"],
+            )
+        )
+        modified = True
+
+    if modified:
+        uid = pwd.getpwnam(base.user()).pw_uid
+        gid = grp.getgrnam(base.user()).gr_gid
+        with open(pgpass_path, "w") as f:
+            for line in pgpass:
+                print(
+                    ":".join(
+                        [
+                            escape(line.hostname),
+                            escape(line.port),
+                            escape(line.database),
+                            escape(line.username),
+                            escape(line.password),
+                        ]
+                    ),
+                    file=f,
+                )
+            os.fchown(f.fileno(), uid, gid)
+            os.fchmod(f.fileno(), 0o600)
+
+
+def strip_dsn_authentication(dsn):
+    parsed_dsn = parse_dsn(dsn)
+    parsed_dsn.pop("user", None)
+    parsed_dsn.pop("password", None)
+    return make_dsn(**parsed_dsn)
diff --git a/charm/layer/launchpad-base/metadata.yaml b/charm/layer/launchpad-base/metadata.yaml
new file mode 100644
index 0000000..6de4a93
--- /dev/null
+++ b/charm/layer/launchpad-base/metadata.yaml
@@ -0,0 +1,3 @@
+requires:
+  db:
+    interface: pgsql
diff --git a/charm/layer/launchpad-base/reactive/launchpad-base.py b/charm/layer/launchpad-base/reactive/launchpad-base.py
index 8dd3b69..cd4bef1 100644
--- a/charm/layer/launchpad-base/reactive/launchpad-base.py
+++ b/charm/layer/launchpad-base/reactive/launchpad-base.py
@@ -8,9 +8,12 @@ from charms.launchpad.base import (
     configure_rsync,
     ensure_lp_directories,
     get_service_config,
+    strip_dsn_authentication,
+    update_pgpass,
 )
 from charms.reactive import hook, remove_state, set_state, when, when_not
-from ols import base
+from ols import base, postgres
+from psycopg2.extensions import parse_dsn
 
 
 # Monkey-patch layer:ols.
@@ -25,11 +28,25 @@ def create_virtualenv(wheels_dir, codedir, python_exe):
 base.create_virtualenv = create_virtualenv
 
 
-@when("ols.configured")
+@when("ols.configured", "db.master.available")
 @when_not("launchpad.base.configured")
-def configure():
+def configure(db):
     ensure_lp_directories()
     config = get_service_config()
+    db_primary, db_standby = postgres.get_db_uris(db)
+    # XXX cjwatson 2022-09-23: Mangle the connection strings into forms
+    # Launchpad understands.  In the long term it would be better to have
+    # Launchpad be able to consume unmodified connection strings.
+    for dsn in [db_primary] + db_standby:
+        update_pgpass(dsn)
+    config["db_primary"] = strip_dsn_authentication(db_primary)
+    config["db_standby"] = ",".join(
+        strip_dsn_authentication(dsn) for dsn in db_standby
+    )
+    # XXX cjwatson 2022-09-23: This is a layering violation, since it's
+    # specific to the appserver.  We need to teach Launchpad to be able to
+    # log in as one role and then switch to another.
+    config["db_user"] = parse_dsn(db_primary)["user"]
     # XXX cjwatson 2022-09-07: Some config items have no reasonable default.
     # We should set the workload status to blocked in that case.
     configure_lazr(
diff --git a/charm/layer/launchpad-base/templates/launchpad-base-lazr.conf b/charm/layer/launchpad-base/templates/launchpad-base-lazr.conf
index e66d6ec..4b50d48 100644
--- a/charm/layer/launchpad-base/templates/launchpad-base-lazr.conf
+++ b/charm/layer/launchpad-base/templates/launchpad-base-lazr.conf
@@ -25,14 +25,11 @@ oops_prefix: {{ oops_prefix }}
 
 [launchpad]
 config_overlay_dir: {{ secrets_dir }}
+dbuser: {{ db_user }}
 http_proxy: {{ http_proxy or "none" }}
 openid_alternate_provider_roots: {{ openid_alternate_provider_roots or "none" }}
 openid_provider_root: {{ openid_provider_root }}
 
-[launchpad_session]
-database: {{ db_session }}
-cookie: {{ session_cookie_name }}
-
 [vhost.mainsite]
 hostname: {{ domain }}
 althostnames: localhost, www.{{ domain }}