← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:charm-db-update into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:charm-db-update into launchpad:master.

Commit message:
charm: Add launchpad-db-update

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

This handles database schema updates, adding `preflight` and `db-update` actions.  It should allow us to replace another function for which we currently rely on non-Juju infrastructure.

On production, we can't do this from `launchpad-admin`, since the distinction between the `master`/`stable` and `db-devel`/`db-stable` branches means that we need to have different commits deployed for ordinary administrative functions and for schema updates.  I considered making the behaviour of this charm be optional behaviour in `launchpad-admin` rather than adding a new charm, but I ended up needing to turn most of `launchpad-admin` off, and if the two modes are going to have mostly disjoint charm code anyway then they might as well have different charms.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:charm-db-update into launchpad:master.
diff --git a/charm/launchpad-db-update/README.md b/charm/launchpad-db-update/README.md
new file mode 100644
index 0000000..1babbdd
--- /dev/null
+++ b/charm/launchpad-db-update/README.md
@@ -0,0 +1,74 @@
+# Launchpad database schema updates
+
+This charm provides tools for managing schema updates of Launchpad
+deployments.
+
+Launchpad has two separate "trunk" branches: the `master` branch (feeding
+`stable` after tests pass) and the `db-devel` branch (feeding `db-stable`
+after tests pass).  On production, database permissions are updated on each
+deployment from `stable`, and full database schema updates are applied
+separately from `db-stable`.
+
+For a simple local deployment, you will need the following relations:
+
+    juju relate launchpad-db-update:db postgresql:db
+    juju relate launchpad-db-update:db-admin postgresql:db-admin
+    juju relate launchpad-db-update rabbitmq-server
+
+An action is available to perform a schema update:
+
+    juju run-action --wait launchpad-db-update/leader db-update
+
+## pgbouncer management
+
+In deployments that use it, this charm can administer the `pgbouncer` load
+balancer to disable connections to the primary database for the duration of
+the update.
+
+To use this mode, you need to use the
+[external-services](https://code.launchpad.net/~ubuntuone-hackers/external-services/+git/external-services)
+proxy charm in place of relating directly to `postgresql`, in order to have
+greater control over connection strings.  `external-services` will need to
+be configured along the lines of the following:
+
+    options:
+      db_connections: |
+        launchpad_db_update:
+          master: "postgresql://user:password@host:port/dbname"
+          standbys: []
+          admin: "postgresql://user:password@host:port/dbname"
+        launchpad_pgbouncer:
+          master: "postgresql://user:password@host:port/dbname"
+
+`launchpad_db_update` and `launchpad_pgbouncer` may have other names if
+needed as long as they match the `databases` option below;
+`launchpad_db_update` must define a direct connection to the primary
+database, bypassing `pgbouncer`, while `launchpad_pgbouncer` must define a
+connection to `pgbouncer` itself.
+
+`launchpad-db-update` will need configuration similar to the following (the
+values of the entries in `databases` serve as keys into the `db_connections`
+option above):
+
+    options:
+      databases: |
+        db:
+          name: "launchpad_db_update"
+        pgbouncer:
+          name: "launchpad_pgbouncer"
+
+You will need the following relations:
+
+    juju relate launchpad-db-update:db external-services:db
+    juju relate launchpad-db-update:db-admin external-services:db-admin
+    juju relate launchpad-db-update:pgbouncer external-services:db
+    juju relate launchpad-db-update rabbitmq-server
+
+In this mode, an additional action is available:
+
+    juju run-action --wait launchpad-db-update/leader preflight
+
+This checks whether the system is ready for a database schema update (i.e.
+that no processes are connected that would have problems if they were
+interrupted).  The operator should ensure that it succeeds before running
+the `db-update` action.
diff --git a/charm/launchpad-db-update/actions.yaml b/charm/launchpad-db-update/actions.yaml
new file mode 100644
index 0000000..6eed53f
--- /dev/null
+++ b/charm/launchpad-db-update/actions.yaml
@@ -0,0 +1,7 @@
+preflight:
+  description: >
+    Confirm that the system is ready for a database schema update.  This
+    checks that no processes are connected that would have problems if they
+    were interrupted.
+db-update:
+  description: Perform a database schema update.
diff --git a/charm/launchpad-db-update/actions/actions.py b/charm/launchpad-db-update/actions/actions.py
new file mode 100755
index 0000000..4239cc3
--- /dev/null
+++ b/charm/launchpad-db-update/actions/actions.py
@@ -0,0 +1,63 @@
+#! /usr/bin/python3
+# Copyright 2023 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+import subprocess
+import sys
+import traceback
+from pathlib import Path
+
+sys.path.append("lib")
+
+from charms.layer import basic  # noqa: E402
+
+basic.bootstrap_charm_deps()
+basic.init_config_states()
+
+from charmhelpers.core import hookenv  # noqa: E402
+from charms.launchpad.payload import home_dir  # noqa: E402
+from ols import base  # noqa: E402
+
+
+def preflight():
+    hookenv.log("Running preflight checks.")
+    script = Path(home_dir(), "bin", "preflight")
+    if script.exists():
+        subprocess.run(
+            ["sudo", "-H", "-u", base.user(), script],
+            check=True,
+        )
+        hookenv.action_set({"result": "Preflight checks passed"})
+    else:
+        message = "Preflight checks not available; missing pgbouncer relation?"
+        hookenv.log(message)
+        hookenv.action_fail(message)
+
+
+def db_update():
+    hookenv.log("Running database schema update.")
+    script = Path(home_dir(), "bin", "db-update")
+    subprocess.run(["sudo", "-H", "-u", base.user(), script], check=True)
+    hookenv.action_set({"result": "Database schema update completed"})
+
+
+def main(argv):
+    action = Path(argv[0]).name
+    try:
+        if action == "preflight":
+            preflight()
+        elif action == "db-update":
+            db_update()
+        else:
+            hookenv.action_fail(f"Action {action} not implemented.")
+    except Exception:
+        hookenv.action_fail("Unhandled exception")
+        tb = traceback.format_exc()
+        hookenv.action_set(dict(traceback=tb))
+        hookenv.log(f"Unhandled exception in action {action}:")
+        for line in tb.splitlines():
+            hookenv.log(line)
+
+
+if __name__ == "__main__":
+    main(sys.argv)
diff --git a/charm/launchpad-db-update/actions/db-update b/charm/launchpad-db-update/actions/db-update
new file mode 120000
index 0000000..405a394
--- /dev/null
+++ b/charm/launchpad-db-update/actions/db-update
@@ -0,0 +1 @@
+actions.py
\ No newline at end of file
diff --git a/charm/launchpad-db-update/actions/preflight b/charm/launchpad-db-update/actions/preflight
new file mode 120000
index 0000000..405a394
--- /dev/null
+++ b/charm/launchpad-db-update/actions/preflight
@@ -0,0 +1 @@
+actions.py
\ No newline at end of file
diff --git a/charm/launchpad-db-update/charmcraft.yaml b/charm/launchpad-db-update/charmcraft.yaml
new file mode 100644
index 0000000..afb3e49
--- /dev/null
+++ b/charm/launchpad-db-update/charmcraft.yaml
@@ -0,0 +1,63 @@
+type: charm
+bases:
+  - build-on:
+    - name: ubuntu
+      channel: "20.04"
+      architectures: [amd64]
+    run-on:
+    - name: ubuntu
+      channel: "20.04"
+      architectures: [amd64]
+parts:
+  charm-wheels:
+    source: https://git.launchpad.net/~ubuntuone-hackers/ols-charm-deps/+git/wheels
+    source-commit: "42c89d9c66dbe137139b047fd54aed49b66d1a5e"
+    source-submodules: []
+    source-type: git
+    plugin: dump
+    organize:
+      "*": charm-wheels/
+    prime:
+      - "-charm-wheels"
+  ols-layers:
+    source: https://git.launchpad.net/ols-charm-deps
+    source-commit: "f63ae0386275bf9089b30c8abae252a0ea523633"
+    source-submodules: []
+    source-type: git
+    plugin: dump
+    organize:
+      "*": layers/
+    stage:
+      - layers
+    prime:
+      - "-layers"
+  launchpad-layers:
+    after:
+      - ols-layers
+    source: https://git.launchpad.net/launchpad-layers
+    source-commit: "6ca1d670f636e1abb8328d88fc5fda80cb75152a"
+    source-submodules: []
+    source-type: git
+    plugin: dump
+    organize:
+      launchpad-base: layers/layer/launchpad-base
+      launchpad-db: layers/layer/launchpad-db
+      launchpad-payload: layers/layer/launchpad-payload
+    stage:
+      - layers
+    prime:
+      - "-layers"
+  charm:
+    after:
+      - charm-wheels
+      - launchpad-layers
+    source: .
+    plugin: reactive
+    build-snaps: [charm]
+    build-packages: [libpq-dev, python3-dev]
+    build-environment:
+      - CHARM_LAYERS_DIR: $CRAFT_STAGE/layers/layer
+      - CHARM_INTERFACES_DIR: $CRAFT_STAGE/layers/interface
+      - PIP_NO_INDEX: "true"
+      - PIP_FIND_LINKS: $CRAFT_STAGE/charm-wheels
+    reactive-charm-build-arguments: [--binary-wheels-from-source]
diff --git a/charm/launchpad-db-update/layer.yaml b/charm/launchpad-db-update/layer.yaml
new file mode 100644
index 0000000..ce7034d
--- /dev/null
+++ b/charm/launchpad-db-update/layer.yaml
@@ -0,0 +1,10 @@
+includes:
+  - layer:launchpad-db
+repo: https://git.launchpad.net/launchpad
+options:
+  ols-pg:
+    databases:
+      db:
+        name: launchpad_dev
+      pgbouncer:
+        name: pgbouncer_dev
diff --git a/charm/launchpad-db-update/metadata.yaml b/charm/launchpad-db-update/metadata.yaml
new file mode 100644
index 0000000..0a18a3a
--- /dev/null
+++ b/charm/launchpad-db-update/metadata.yaml
@@ -0,0 +1,25 @@
+name: launchpad-db-update
+display-name: launchpad-db-update
+summary: Launchpad database schema updates
+maintainer: Launchpad Developers <launchpad-dev@xxxxxxxxxxxxxxxxxxx>
+description: |
+  Launchpad is an open source suite of tools that help people and teams
+  to work together on software projects.
+
+  This charm provides tools for managing schema updates of Launchpad
+  deployments.
+subordinate: false
+requires:
+  db:
+    interface: pgsql
+  # A direct connection to the primary database, bypassing pgbouncer.
+  # (full-update.py disables access via pgbouncer to the primary database
+  # for the duration of the update, so we must have direct access.)
+  db-admin:
+    interface: pgsql
+  # A connection to the pgbouncer load balancer.  The schema update process
+  # uses this to check for long-running connections and to disable access to
+  # the primary database for the duration of the update.
+  pgbouncer:
+    interface: pgsql
+    optional: true
diff --git a/charm/launchpad-db-update/reactive/launchpad-db-update.py b/charm/launchpad-db-update/reactive/launchpad-db-update.py
new file mode 100644
index 0000000..e9c4b06
--- /dev/null
+++ b/charm/launchpad-db-update/reactive/launchpad-db-update.py
@@ -0,0 +1,127 @@
+# Copyright 2023 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+import os.path
+
+from charmhelpers.core import hookenv, host, templating
+from charms.launchpad.base import get_service_config
+from charms.launchpad.db import strip_dsn_authentication, update_pgpass
+from charms.launchpad.payload import configure_lazr, home_dir
+from charms.reactive import (
+    clear_flag,
+    endpoint_from_flag,
+    is_flag_set,
+    set_flag,
+    when,
+    when_any,
+    when_not,
+    when_not_all,
+)
+from ols import base, postgres
+from psycopg2.extensions import make_dsn, parse_dsn
+
+
+def any_dbname(dsn):
+    parsed_dsn = parse_dsn(dsn)
+    parsed_dsn["dbname"] = "*"
+    return make_dsn(**parsed_dsn)
+
+
+@when(
+    "launchpad.db.configured",
+    "db.master.available",
+    "db-admin.master.available",
+)
+@when_not("service.configured")
+def configure():
+    config = get_service_config()
+
+    db = endpoint_from_flag("db.master.available")
+    db_primary, _ = postgres.get_db_uris(db)
+    config["db_primary"] = strip_dsn_authentication(db_primary)
+
+    db_admin = endpoint_from_flag("db-admin.master.available")
+    db_admin_primary, _ = postgres.get_db_uris(db_admin)
+    # We assume that this admin user works for any database on this host,
+    # which seems to be true in practice.
+    update_pgpass(any_dbname(db_admin_primary))
+    config["db_admin_primary"] = strip_dsn_authentication(db_admin_primary)
+
+    if is_flag_set("pgbouncer.master.available"):
+        pgbouncer = endpoint_from_flag("pgbouncer.master.available")
+        pgbouncer_primary, _ = postgres.get_db_uris(pgbouncer)
+        update_pgpass(pgbouncer_primary)
+        config["pgbouncer_primary"] = strip_dsn_authentication(
+            pgbouncer_primary
+        )
+    else:
+        pgbouncer = None
+
+    configure_lazr(
+        config,
+        "launchpad-db-update-lazr.conf",
+        "launchpad-db-update/launchpad-lazr.conf",
+    )
+    bin_dir = os.path.join(home_dir(), "bin")
+    host.mkdir(bin_dir, owner=base.user(), group=base.user(), perms=0o755)
+    scripts = {
+        "db-update": True,
+        "preflight": pgbouncer is not None,
+    }
+    for script, enable in scripts.items():
+        script_path = os.path.join(bin_dir, script)
+        if enable:
+            templating.render(
+                f"{script}.j2",
+                script_path,
+                config,
+                owner=base.user(),
+                group=base.user(),
+                perms=0o755,
+            )
+        elif os.path.exists(script_path):
+            os.unlink(script_path)
+
+    set_flag("service.configured")
+    if pgbouncer is not None:
+        set_flag("service.pgbouncer.configured")
+    hookenv.status_set("active", "Ready")
+
+
+@when("service.configured")
+@when_not_all(
+    "launchpad.db.configured",
+    "db.master.available",
+    "db-admin.master.available",
+)
+def deconfigure():
+    clear_flag("service.configured")
+
+
+@when("service.pgbouncer.configured")
+@when_not("service.configured")
+def deconfigure_optional_services():
+    clear_flag("service.pgbouncer.configured")
+
+
+@when_any(
+    "db-admin.database.changed",
+    "pgbouncer.database.changed",
+)
+@when("service.configured")
+def any_db_changed():
+    clear_flag("service.configured")
+    clear_flag("db-admin.database.changed")
+    clear_flag("pgbouncer.database.changed")
+
+
+@when("pgbouncer.master.available", "service.configured")
+@when_not("service.pgbouncer.configured")
+def pgbouncer_available():
+    clear_flag("service.configured")
+
+
+@when("service.pgbouncer.configured")
+@when_not("pgbouncer.master.available")
+def pgbouncer_unavailable():
+    clear_flag("service.configured")
diff --git a/charm/launchpad-db-update/templates/db-update.j2 b/charm/launchpad-db-update/templates/db-update.j2
new file mode 100755
index 0000000..4ca8e87
--- /dev/null
+++ b/charm/launchpad-db-update/templates/db-update.j2
@@ -0,0 +1,22 @@
+#! /bin/sh
+# Copyright 2023 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+# Part of the launchpad-db-update Juju charm.
+
+set -e
+
+export LPCONFIG=launchpad-db-update
+
+{% if pgbouncer_primary -%}
+# Fastdowntime update, managing connections using pgbouncer.
+{{ code_dir }}/database/schema/full-update.py \
+    --pgbouncer='{{ pgbouncer_primary }}'
+{% else -%}
+# We can't manage connections using pgbouncer in this environment.  Attempt
+# a simple schema upgrade, which may fail if anything has an active database
+# connection.
+{{ code_dir }}/database/schema/upgrade.py
+{{ code_dir }}/database/schema/security.py
+{% endif %}
+
diff --git a/charm/launchpad-db-update/templates/launchpad-db-update-lazr.conf b/charm/launchpad-db-update/templates/launchpad-db-update-lazr.conf
new file mode 100644
index 0000000..ad354a9
--- /dev/null
+++ b/charm/launchpad-db-update/templates/launchpad-db-update-lazr.conf
@@ -0,0 +1,17 @@
+# Public configuration data.  The contents of this file may be freely shared
+# with developers if needed for debugging.
+
+# A schema's sections, keys, and values are automatically inherited, except
+# for '.optional' sections.  Update this config to override key values.
+# Values are strings, except for numbers that look like ints.  The tokens
+# true, false, and none are treated as True, False, and None.
+
+[meta]
+extends: ../launchpad-db-lazr.conf
+
+[database]
+rw_main_primary: {{ db_admin_primary }}
+rw_main_standby: None
+db_statement_timeout: None
+soft_request_timeout: None
+
diff --git a/charm/launchpad-db-update/templates/preflight.j2 b/charm/launchpad-db-update/templates/preflight.j2
new file mode 100755
index 0000000..ef2a88f
--- /dev/null
+++ b/charm/launchpad-db-update/templates/preflight.j2
@@ -0,0 +1,11 @@
+#! /bin/sh
+# Copyright 2023 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+# Part of the launchpad-db-update Juju charm.
+
+set -e
+
+LPCONFIG=launchpad-db-update {{ code_dir }}/database/schema/preflight.py \
+    --skip-connection-check --pgbouncer='{{ pgbouncer_primary }}'
+