Skip to content

Commit bcbf7ef

Browse files
committed
feat: issue2550852 - support using a specified PostgreSQL db schema
Finally after 7 years this is closed. roundup/backends/back_postgresql.py: Support use of schema when specified in RDBMS_NAME. Stuart McGraws code is finally merged 8-). test/test_postgresql.py, test/conftest.py: Run all postgresql tests in the schema db as well. Also make sure that db_nuke raises an error when trying to delete the schema test database. Conftest defines pg_schema mark that can be used to exclude schema tests with pytest -m "not pg_schema". roundup/configuration.py: change doc on RDBMS_NAME to include db.schema form. .travis.yml, .github/workflows/ci-test.yml: create schema test db; add user for testing with schema; grant new user create privs for schema. doc/installation.txt: Reference to roundup-admin init deleting schema added. doc/mysql.txt doc/postgresql.txt: New documentation on psql/mysql commands to set up a production db. doc/upgrading.txt: mention schema support, also document service setting for selecting connection from pg_service.conf. doc/reference.txt: update config.ini documentation for RDBMS_NAME.
1 parent 1f05c65 commit bcbf7ef

File tree

12 files changed

+555
-75
lines changed

12 files changed

+555
-75
lines changed

.github/workflows/ci-test.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ jobs:
153153
sudo service postgresql restart; sleep 10
154154
# set up postgresql database
155155
sudo -u postgres psql -c "CREATE ROLE rounduptest WITH CREATEDB LOGIN PASSWORD 'rounduptest';" -U postgres
156+
sudo -u postgres psql -c "CREATE ROLE rounduptest_schema LOGIN PASSWORD 'rounduptest';" -U postgres
157+
sudo -u postgres psql -c "CREATE DATABASE rounduptest_schema;GRANT CREATE ON DATABASE rounduptest_schema TO rounduptest_schema;" -U postgres
156158
157159
- name: install redis
158160
run: |

.travis.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ before_script:
155155
- sudo service postgresql restart; sleep 30
156156
# set up postgresql database
157157
- psql -c "CREATE ROLE rounduptest WITH CREATEDB LOGIN PASSWORD 'rounduptest';" -U postgres
158+
- psql -c "CREATE ROLE rounduptest_schema LOGIN PASSWORD 'rounduptest';" -U postgres
159+
- psql -c "CREATE DATABASE rounduptest_schema;GRANT CREATE ON DATABASE rounduptest_schema TO rounduptest_schema;" -U postgres
158160

159161
# build the .mo translation files and install them into a tree
160162
# (locale/locale under roundup directory root)

CHANGES.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ Features:
104104
configuring database on a per-tracker basis. Also replaces use of
105105
PGSERVICE env variable for single instance trackers. (From ML
106106
question by ivanov. John Rouillard)
107+
- issue2550852 - support for specifying a PostgreSQL schema to use for
108+
the Roundup database. (Patch by Stuart McGraw; slight modifications,
109+
tests, docs: John Rouillard).
107110

108111
2023-07-13 2.3.0
109112

doc/installation.txt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -478,9 +478,10 @@ in the following steps.
478478
Admin Password:
479479
Confirm:
480480

481-
Note: running this command will *destroy any existing data in the
482-
database*. In the case of MySQL and PostgreSQL, any existing database
483-
will be dropped and re-created.
481+
Note: running this command will *destroy any existing data in
482+
the database*. In the case of MySQL and PostgreSQL, any existing
483+
database (or optionally database schema for PostgreSQL) will be
484+
dropped and re-created.
484485

485486
Once this is done, the tracker has been created. See the note in
486487
the `administration guide`_ on how to :ref:`initialise a

doc/mysql.txt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,32 @@ to install:
2121
2. Python MySQL interface - https://pypi.org/project/mysqlclient/
2222

2323

24+
Preparing the Database
25+
======================
26+
27+
The Roundup user expects to be able to create and drop its database
28+
when using ``roundup_admin init``.
29+
30+
In the examples below, replace ``roundupuser``, ``rounduppw`` and
31+
``roundupdb`` with suitable values.
32+
33+
This assumes you are running MySQL on the same host as you are
34+
running Roundup. If this is not the case, setting up remote
35+
credentials, SSL/TLS etc. is beyond the scope of this documentation.
36+
However examples are welcome on the wiki or mailing list.
37+
38+
These references may be helpful:
39+
https://dev.mysql.com/doc/refman/8.0/en/create-user.html and
40+
https://dev.mysql.com/doc/refman/8.0/en/grant.html.
41+
42+
Creating a Role/User
43+
--------------------
44+
45+
The following command will create a ``roundupuser`` with the ability
46+
to create the database::
47+
48+
mysql -u root -e 'CREATE USER "roundupuser"@"localhost" IDENTIFIED WITH mysql_native_password BY "rounduppw"; GRANT ALL on roundupuser.* TO "roundupuser"@"localhost";'
49+
2450
Other Configuration
2551
===================
2652

doc/postgresql.txt

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,30 +27,95 @@ suggest that you install into a python virtual environment.
2727
.. _PostgreSQL: https://www.postgresql.org/
2828

2929

30+
Preparing the Database
31+
======================
32+
33+
Roundup can use Postgres in one of two ways:
34+
35+
1. Roundup creates and uses a database
36+
2. Roundup uses a pre-created database and creates and uses a schema
37+
under the database.
38+
39+
In the examples below, replace ``roundupuser``, ``rounduppw`` and
40+
``roundupdb`` with suitable values.
41+
42+
This assumes that you are running Postgres on the same machine with
43+
Roundup. Using a remote database, setting up SSL/TLS and other
44+
authentication methods is beyond the scope of this
45+
documentation. However examples are welcome on the wiki or mailing
46+
list.
47+
48+
Creating a Role/User
49+
--------------------
50+
51+
For case 1 create a user using::
52+
53+
psql -c "CREATE ROLE roundupuser WITH CREATEDB LOGIN PASSWORD 'rounduppw';" -U postgres
54+
55+
After running ``roundup-admin init`` to create your databases, you can
56+
remove the CREATEDB permission using::
57+
58+
psql -c "ALTER ROLE roundupuser NOCREATEDB;"
59+
60+
If needed (e.g. you want to deploy a new tracker) you can use ``ALTER
61+
ROLE`` with ``CREATEDB`` to add the permission back.
62+
63+
For case 2 you need to create the user::
64+
65+
psql -c "CREATE ROLE roundupuser LOGIN PASSWORD 'rounduppw';" -U postgres
66+
67+
This psql command connects as the postgres database superuser. You may
68+
need to run this under sudo as the postgres user or provide a password
69+
to become an admin on the postgres db process.
70+
71+
72+
Creating a Database
73+
-------------------
74+
75+
For case 1, roundup will create the database on demand using the
76+
``roundup_admin init`` command. So there is nothing to do here.
77+
78+
For case 2, run::
79+
80+
psql -c "CREATE DATABASE roundupdb;GRANT CREATE ON DATABASE roundupdb TO roundupuser;" -U postgres
81+
82+
This creates the database and allows the roundup user to create a new
83+
schema when running ``roundup_admin init``.
84+
85+
3086
Running the PostgreSQL unit tests
3187
=================================
3288

3389
The user that you're running the tests as will need to be able to access
3490
the postgresql database on the local machine and create and drop
35-
databases. See the config values in 'test/db_test_base.py'
91+
databases and schemas. See the config values in 'test/db_test_base.py'
3692
about which database connection, name and user will be used.
3793

38-
At this time the following command will setup the user::
94+
At this time the following commands will setup the users and required
95+
databases::
3996

4097
sudo -u postgres psql -c "CREATE ROLE rounduptest WITH CREATEDB LOGIN PASSWORD 'rounduptest';" -U postgres
4198

42-
Note ``rounduptest`` is a well known account, so you should
43-
remove/disable the account after testing and set up a suitable
44-
production account. You need to remove any database owned by
45-
``rounduptest`` first. So something like this should work::
99+
sudo -u postgres psql -c "CREATE ROLE rounduptest_schema LOGIN PASSWORD 'rounduptest';" -U postgres
100+
sudo -u postgres psql -c "CREATE DATABASE rounduptest_schema;GRANT CREATE ON DATABASE rounduptest_schema TO rounduptest_schema;" -U postgres
101+
102+
Note ``rounduptest`` and ``rounduptest_schema`` are well known
103+
accounts, so you should remove/disable the accounts after testing and
104+
set up a suitable production account. You need to remove any database
105+
owned by ``rounduptest`` first. To clean everything up, something like
106+
this should work::
46107

47108

48109
sudo -u postgres psql -c "DROP DATABASE rounduptest;" -U postgres
49110
sudo -u postgres psql -c "DROP ROLE rounduptest;" -U postgres
111+
sudo -u postgres psql -c "DROP DATABASE rounduptest_schema;" -U postgres
112+
sudo -u postgres psql -c "DROP ROLE rounduptest_schema;" -U postgres
50113

51114
If the ``rounduptest`` database is left in a broken state
52115
(e.g. because of a crash during testing) dropping the database and
53-
restarting the tests should fix it.
116+
restarting the tests should fix it. If you have issues while running
117+
the schema test, you can drop the ``rounduptest` schema in the
118+
``rounduptest_schema`` database.
54119

55120
Credit
56121
======

doc/reference.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,9 @@ Section **rdbms**
277277
The database backend such as anydbm, sqlite, mysql or postgres.
278278

279279
name -- ``roundup``
280-
Name of the database to use.
280+
Name of the database to use. For Postgresql, this can
281+
be database.schema to use a specific schema within
282+
a Postgres database.
281283

282284
host -- ``localhost``
283285
Database server host.

doc/upgrading.txt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,39 @@ The removed columns are: severity, versions, keywords, dependencies.
153153
It is also missing the ``solves`` field which is added to match the
154154
schema.
155155

156+
New PostgreSQL Settings (optional)
157+
----------------------------------
158+
159+
With this release, you can specify a Postgresql database
160+
schema to use. By default Roundup creates a database when
161+
using ``roundup-admin init``. Setting the rdbms ``name``
162+
keyword to ``roundup_database.roundup_schema`` will create
163+
and use the ``roundup_schema`` in the pre-created
164+
``roundup_database``.
165+
166+
Also there is a new configuration keyword in the rdbms section of
167+
``config.ini``. The ``service`` keyword allows you to define the
168+
service name for Postgres that will be looked up in the Postgres
169+
`Connection Service File`_. Setting service to `roundup` with the
170+
following in the service file::
171+
172+
[roundup_roundup]
173+
host=127.0.0.1
174+
port=5432
175+
user=roundup
176+
password=roundup
177+
dbname=roundup
178+
179+
would use the roundup database with the specified credentials.
180+
181+
It is possible to define a service that connects to a specific
182+
schema. However this will require a little fiddling to get things
183+
working. A future enhancement may make using a schema via this
184+
mechanism easier. See https://issues.roundup-tracker.org/issue2551299
185+
for details.
186+
187+
.. _`Connection Service File`: https://www.postgresql.org/docs/current/libpq-pgservice.html
188+
156189
Bad Login Rate Limiting and Locking (info)
157190
------------------------------------------
158191

roundup/backends/back_postgresql.py

Lines changed: 61 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import logging
1111
import os
12+
import re
1213
import shutil
1314
import time
1415

@@ -47,28 +48,40 @@ def connection_dict(config, dbnamestr=None):
4748
del d['read_default_file']
4849
return d
4950

51+
def db_schema_split(database_name):
52+
''' Split database_name into database and schema parts'''
53+
if '.' in database_name:
54+
return database_name.split ('.')
55+
return [database_name, '']
5056

5157
def db_create(config):
5258
"""Clear all database contents and drop database itself"""
53-
command = ("CREATE DATABASE \"%s\" WITH ENCODING='UNICODE'" %
54-
get_database_name(config))
55-
if config.RDBMS_TEMPLATE:
56-
command = command + " TEMPLATE=%s" % config.RDBMS_TEMPLATE
57-
logging.getLogger('roundup.hyperdb').info(command)
58-
db_command(config, command)
59-
59+
db_name, schema_name = db_schema_split(config.RDBMS_NAME)
60+
if not schema_name:
61+
command = "CREATE DATABASE \"%s\" WITH ENCODING='UNICODE'" % db_name
62+
if config.RDBMS_TEMPLATE:
63+
command = command + " TEMPLATE=%s" % config.RDBMS_TEMPLATE
64+
logging.getLogger('roundup.hyperdb').info(command)
65+
db_command(config, command)
66+
else:
67+
command = "CREATE SCHEMA \"%s\" AUTHORIZATION \"%s\"" % (schema_name, config.RDBMS_USER)
68+
logging.getLogger('roundup.hyperdb').info(command)
69+
db_command(config, command, db_name)
6070

6171
def db_nuke(config):
62-
"""Clear all database contents and drop database itself"""
63-
command = 'DROP DATABASE "%s"' % get_database_name(config)
64-
65-
logging.getLogger('roundup.hyperdb').info(command)
66-
db_command(config, command)
67-
72+
"""Drop the database (and all its contents) or the schema."""
73+
db_name, schema_name = db_schema_split(config.RDBMS_NAME)
74+
if not schema_name:
75+
command = 'DROP DATABASE "%s"'% db_name
76+
logging.getLogger('roundup.hyperdb').info(command)
77+
db_command(config, command)
78+
else:
79+
command = 'DROP SCHEMA "%s" CASCADE' % schema_name
80+
logging.getLogger('roundup.hyperdb').info(command)
81+
db_command(config, command, db_name)
6882
if os.path.exists(config.DATABASE):
6983
shutil.rmtree(config.DATABASE)
7084

71-
7285
def get_database_name(config):
7386
'''Get database name using config.RDBMS_NAME or config.RDBMS_SERVICE.
7487
@@ -124,14 +137,16 @@ def db_command(config, command, database='postgres'):
124137
before "template1" seems to have been used, so we fall back to it.
125138
Compare to issue2550543.
126139
'''
127-
template1 = connection_dict(config)
140+
template1 = connection_dict(config, 'database')
141+
db_name, schema_name = db_schema_split(template1['database'])
128142
template1['database'] = database
129143

130144
try:
131145
conn = psycopg2.connect(**template1)
132146
except psycopg2.OperationalError as message:
133-
if str(message).find('database "postgres" does not exist') >= 0:
134-
return db_command(config, command, database='template1')
147+
if not schema_name:
148+
if re.search(r'database ".+" does not exist', str(message)):
149+
return db_command(config, command, database='template1')
135150
raise hyperdb.DatabaseError(message)
136151

137152
conn.set_isolation_level(0)
@@ -142,17 +157,17 @@ def db_command(config, command, database='postgres'):
142157
return
143158
finally:
144159
conn.close()
145-
raise RuntimeError('10 attempts to create database failed when running: %s' % command)
160+
raise RuntimeError('10 attempts to create database or schema failed when running: %s' % command)
146161

147162

148-
def pg_command(cursor, command):
163+
def pg_command(cursor, command, args=()):
149164
'''Execute the postgresql command, which may be blocked by some other
150165
user connecting to the database, and return a true value if it succeeds.
151166
152167
If there is a concurrent update, retry the command.
153168
'''
154169
try:
155-
cursor.execute(command)
170+
cursor.execute(command, args)
156171
except psycopg2.DatabaseError as err:
157172
response = str(err).split('\n')[0]
158173
if "FATAL" not in response:
@@ -164,19 +179,32 @@ def pg_command(cursor, command):
164179
if msg in response:
165180
time.sleep(0.1)
166181
return 0
167-
raise RuntimeError(response)
182+
raise RuntimeError(response, command, args)
168183
return 1
169184

170185

171186
def db_exists(config):
172-
"""Check if database already exists"""
187+
"""Check if database or schema already exists"""
173188
db = connection_dict(config, 'database')
189+
db_name, schema_name = db_schema_split(db['database'])
190+
if schema_name:
191+
db['database'] = db_name
174192
try:
175193
conn = psycopg2.connect(**db)
176-
conn.close()
177-
return 1
194+
if not schema_name:
195+
conn.close()
196+
return 1
178197
except Exception:
179198
return 0
199+
# <schema_name> will have a non-false value here; otherwise one
200+
# of the above returns would have returned.
201+
# Get a count of the number of schemas named <schema_name> (either 0 or 1).
202+
command = "SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name = %s"
203+
cursor = conn.cursor()
204+
pg_command(cursor, command, (schema_name,))
205+
count = cursor.fetchall()[0][0]
206+
conn.close()
207+
return count # 'count' will be 0 or 1.
180208

181209

182210
class Sessions(sessions_rdbms.Sessions):
@@ -225,6 +253,10 @@ class Database(rdbms_common.Database):
225253

226254
def sql_open_connection(self):
227255
db = connection_dict(self.config, 'database')
256+
db_name, schema_name = db_schema_split (db['database'])
257+
if schema_name:
258+
db['database'] = db_name
259+
228260
# database option always present: log it if not null
229261
if db['database']:
230262
logging.getLogger('roundup.hyperdb').info(
@@ -242,6 +274,11 @@ def sql_open_connection(self):
242274
lvl = isolation_levels[self.config.RDBMS_ISOLATION_LEVEL]
243275
conn.set_isolation_level(lvl)
244276

277+
if schema_name:
278+
self.sql ('SET search_path TO %s' % schema_name, cursor=cursor)
279+
# Commit is required so that a subsequent rollback
280+
# will not also rollback the search_path change.
281+
self.sql ('COMMIT', cursor=cursor)
245282
return (conn, cursor)
246283

247284
def sql_new_cursor(self, name='default', conn=None, *args, **kw):

0 commit comments

Comments
 (0)