Compare commits

...

19 Commits

Author SHA1 Message Date
D. Berge
195741a768 Merge branch '173-do-not-use-inodes-as-part-of-a-file-s-fingerprint' into 'devel'
Resolve "Do not use inodes as part of a file's fingerprint"

Closes #173

See merge request wgp/dougal/software!19
2022-02-07 16:08:04 +00:00
D. Berge
0ca44c3861 Add database upgrade file 10.
NOTE: this is the first time we modify the actual data
in the database, as opposed to adding to the schema.
2022-02-07 17:05:19 +01:00
D. Berge
53ed096e1b Modify file hashing function.
We remove the inode from the hash as it is unstable when the
files are on an SMB filesystem, and replace it with an MD5
of the absolute file path.
2022-02-07 17:03:10 +01:00
D. Berge
75f91a9553 Increment schema wanted version 2022-02-07 17:02:59 +01:00
D. Berge
40b07c9169 Merge branch '175-add-database-versioning-and-migration-mechanism' into 'devel'
Resolve "Add database versioning and migration mechanism"

Closes #175

See merge request wgp/dougal/software!18
2022-02-07 14:43:50 +00:00
D. Berge
36e7b1fe21 Add database upgrade file 09 2022-02-06 23:26:57 +01:00
D. Berge
e7fa74326d Add README to database upgrades directory 2022-02-06 23:24:24 +01:00
D. Berge
83be83e4bd Check database schema compatibility.
The server will not start unless it satisfies itself that we're
running against a compatible database schema.
2022-02-06 22:52:45 +01:00
D. Berge
81ce6346b9 Add database schema information to package.json.
Used to determine if the actual schema on the database
is compatible with the version of the server we're
attempting to run.
2022-02-06 22:51:25 +01:00
D. Berge
923ff1acea Add more details to package.json 2022-02-06 22:50:44 +01:00
D. Berge
8ec479805a Add version reporting library.
This reports the current server version, from Git by
default.

Also, and of more interest, it reports whether the
current database schema is compatible with the
server code.
2022-02-06 22:48:20 +01:00
D. Berge
f10103d396 Enfore info key access restrictions on the API.
Obviously, those keys can be edited freely at the database
level. This is intended.
2022-02-06 22:40:53 +01:00
D. Berge
774bde7c00 Reserve certain keys on info tables 2022-02-06 22:39:11 +01:00
D. Berge
b4569c14df Update database README.
Document how to create a Dougal database from scratch
and how to update PostgreSQL.
2022-02-06 22:28:21 +01:00
D. Berge
54eea62e4a Fix require path 2022-02-06 14:24:25 +01:00
D. Berge
69c4f2dd9e Merge branch '161-transfer-files-to-asaqc' into 'devel'
Resolve "Transfer files to ASAQC"

Closes #161

See merge request wgp/dougal/software!16
2021-10-09 09:23:54 +00:00
D. Berge
acc829b978 Switch to production URL in ASAQC configuration 2021-10-06 04:16:17 +02:00
D. Berge
ff4913c0a5 Instrument getLineName to monitor probable cause of #165 2021-10-06 02:12:05 +02:00
D. Berge
51452c978a Add ASAQC task to runner 2021-10-04 21:26:13 +02:00
20 changed files with 460 additions and 32 deletions

View File

@@ -4,6 +4,7 @@ import psycopg2
import configuration
import preplots
import p111
from hashlib import md5 # Because it's good enough
"""
Interface to the PostgreSQL database.
@@ -11,13 +12,16 @@ Interface to the PostgreSQL database.
def file_hash(file):
"""
Calculate a file hash based on its size, inode, modification and creation times.
Calculate a file hash based on its name, size, modification and creation times.
The hash is used to uniquely identify files in the database and detect if they
have changed.
"""
h = md5()
h.update(file.encode())
name_digest = h.hexdigest()[:16]
st = os.stat(file)
return ":".join([str(v) for v in [st.st_size, st.st_mtime, st.st_ctime, st.st_ino]])
return ":".join([str(v) for v in [st.st_size, st.st_mtime, st.st_ctime, name_digest]])
class Datastore:
"""

View File

@@ -114,6 +114,9 @@ run $BINDIR/human_exports_qc.py
print_log "Export sequence data"
run $BINDIR/human_exports_seis.py
print_log "Process ASAQC queue"
run $DOUGAL_ROOT/lib/www/server/queues/asaqc/index.js
rm "$LOCKFILE"
print_info "End run"

View File

@@ -35,7 +35,7 @@ imports:
queues:
asaqc:
request:
url: "https://localhost:3077/vt/v1/api/upload-file-encoded"
url: "https://api.gateway.equinor.com/vt/v1/api/upload-file-encoded"
args:
method: POST
headers:

View File

@@ -19,3 +19,79 @@ Created with:
```bash
SCHEMA_NAME=survey_X EPSG_CODE=XXXXX $DOUGAL_ROOT/sbin/dump_schema.sh
```
## To create a new Dougal database
Ensure that the following packages are installed:
* `postgresql*-postgis-utils`
* `postgresql*-postgis`
* `postgresql*-contrib` # For B-trees
```bash
psql -U postgres <./database-template.sql
```
---
# Upgrading PostgreSQL
The following is based on https://en.opensuse.org/SDB:PostgreSQL#Upgrading_major_PostgreSQL_version
```bash
# The following bash code should be checked and executed
# line for line whenever you do an upgrade. The example
# shows the upgrade process from an original installation
# of version 12 up to version 14.
# install the new server as well as the required postgresql-contrib packages:
zypper in postgresql14-server postgresql14-contrib postgresql12-contrib
# If not yet done, copy the configuration create a new PostgreSQL configuration directory...
mkdir /etc/postgresql
# and copy the original file to this global directory
cd /srv/pgsql/data
for i in pg_hba.conf pg_ident.conf postgresql.conf postgresql.auto.conf ; do cp -a $i /etc/postgresql/$i ; done
# Now create a new data-directory and initialize it for usage with the new server
install -d -m 0700 -o postgres -g postgres /srv/pgsql/data14
cd /srv/pgsql/data14
sudo -u postgres /usr/lib/postgresql14/bin/initdb .
# replace the newly generated files by a symlink to the global files.
# After doing so, you may check the difference of the created backup files and
# the files from the former installation
for i in pg_hba.conf pg_ident.conf postgresql.conf postgresql.auto.conf ; do old $i ; ln -s /etc/postgresql/$i .; done
# Copy over special thesaurus files if some exists.
#cp -a /usr/share/postgresql12/tsearch_data/my_thesaurus_german.ths /usr/share/postgresql14/tsearch_data/
# Now it's time to disable the service...
systemctl stop postgresql.service
# And to start the migration. Please ensure, the directories fit to your upgrade path
sudo -u postgres /usr/lib/postgresql14/bin/pg_upgrade --link \
--old-bindir="/usr/lib/postgresql12/bin" \
--new-bindir="/usr/lib/postgresql14/bin" \
--old-datadir="/srv/pgsql/data/" \
--new-datadir="/srv/pgsql/data14/"
# After successfully migrating the data...
cd ..
# if not already symlinked move the old data to a versioned directory matching
# your old installation...
mv data data12
# and set a symlink to the new data directory
ln -sf data14/ data
# Now start the new service
systemctl start postgresql.service
# If everything has been sucessful, you should uninstall old packages...
#zypper rm -u postgresql12 postgresql13
# and remove old data directories
#rm -rf /srv/pgsql/data_OLD_POSTGRES_VERSION_NUMBER
# For good measure:
sudo -u postgres /usr/lib/postgresql14/bin/vacuumdb --all --analyze-in-stages
```

34
etc/db/upgrades/README.md Normal file
View File

@@ -0,0 +1,34 @@
# Database schema upgrades
When the database schema needs to be upgraded in order to provide new functionality, fix errors, etc., an upgrade script should be added to this directory.
The script can be SQL (preferred) or anything else (Bash, Python, …) in the event of complex upgrades.
The script itself should:
* document what the intended changes are;
* contain instructions on how to run it;
* make the user aware of any non-obvious side effects; and
* say if it is safe to run the script multiple times on the
* same schema / database.
## Naming
Script files should be named `upgrade-<index>-<commit-id-old>-<commit-id-new>-v<schema-version>.sql`, where:
* `<index>` is a correlative two-digit index. When reaching 99, existing files will be renamed to a three digit index (001-099) and new files will use three digits.
* `<commit-id-old>` is the ID of the Git commit that last introduced a schema change.
* `<commit-id-new>` is the ID of the first Git commit expecting the updated schema.
* `<schema-version>` is the version of the schema.
Note: the `<schema-version>` value should be updated with every change and it should be the same as reported by:
```sql
select value->>'db_schema' as db_schema from public.info where key = 'version';
```
If necessary, the wanted schema version must also be updated in `package.json`.
## Running
Schema upgrades are always run manually.

View File

@@ -0,0 +1,24 @@
-- Upgrade the database from commit 74b3de5c to commit 83be83e4.
--
-- NOTE: This upgrade only affects the `public` schema.
--
-- This inserts a database schema version into the database.
-- Note that we are not otherwise changing the schema, so older
-- server code will continue to run against this version.
--
-- ATTENTION!
--
-- This value should be incremented every time that the database
-- schema changes (either `public` or any of the survey schemas)
-- and is used by the server at start-up to detect if it is
-- running against a compatible schema version.
--
-- To apply, run as the dougal user:
--
-- psql < $THIS_FILE
--
-- NOTE: It can be applied multiple times without ill effect.
INSERT INTO public.info VALUES ('version', '{"db_schema": "0.1.0"}')
ON CONFLICT (key) DO UPDATE
SET value = public.info.value || '{"db_schema": "0.1.0"}' WHERE public.info.key = 'version';

View File

@@ -0,0 +1,84 @@
-- Upgrade the database from commit 83be83e4 to 53ed096e.
--
-- New schema version: 0.2.0
--
-- ATTENTION:
--
-- ENSURE YOU HAVE BACKED UP THE DATABASE BEFORE RUNNING THIS SCRIPT.
--
--
-- NOTE: This upgrade affects all schemas in the database.
-- NOTE: Each application starts a transaction, which must be committed
-- or rolled back.
--
-- This migrates the file hashes to address issue #173.
-- The new hashes use size, modification time, creation time and the
-- first half of the MD5 hex digest of the file's absolute path.
--
-- It's a minor (rather than patch) version number increment because
-- changes to `bin/datastore.py` mean that the data is no longer
-- compatible with the hashing function.
--
-- To apply, run as the dougal user:
--
-- psql <<EOF
-- \i $THIS_FILE
-- COMMIT;
-- EOF
--
-- NOTE: It can take a while if run on a large database.
-- NOTE: It can be applied multiple times without ill effect.
BEGIN;
CREATE OR REPLACE PROCEDURE show_notice (notice text) AS $$
BEGIN
RAISE NOTICE '%', notice;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE PROCEDURE migrate_hashes (schema_name text) AS $$
BEGIN
RAISE NOTICE 'Migrating schema %', schema_name;
-- We need to set the search path because some of the trigger
-- functions reference other tables in survey schemas assuming
-- they are in the search path.
EXECUTE format('SET search_path TO %I,public', schema_name);
EXECUTE format('UPDATE %I.files SET hash = array_to_string(array_append(trim_array(string_to_array(hash, '':''), 1), left(md5(path), 16)), '':'')', schema_name);
EXECUTE 'SET search_path TO public'; -- Back to the default search path for good measure
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE PROCEDURE upgrade_10 () AS $$
DECLARE
row RECORD;
BEGIN
FOR row IN
SELECT schema_name FROM information_schema.schemata
WHERE schema_name LIKE 'survey_%'
ORDER BY schema_name
LOOP
CALL migrate_hashes(row.schema_name);
END LOOP;
END;
$$ LANGUAGE plpgsql;
CALL upgrade_10();
CALL show_notice('Cleaning up');
DROP PROCEDURE migrate_hashes (schema_name text);
DROP PROCEDURE upgrade_10 ();
CALL show_notice('Updating db_schema version');
INSERT INTO public.info VALUES ('version', '{"db_schema": "0.2.0"}')
ON CONFLICT (key) DO UPDATE
SET value = public.info.value || '{"db_schema": "0.2.0"}' WHERE public.info.key = 'version';
CALL show_notice('All done. You may now run "COMMIT;" to persist the changes');
DROP PROCEDURE show_notice (notice text);
--
--NOTE Run `COMMIT;` now if all went well
--

View File

@@ -4,7 +4,7 @@ const { info } = require('../../../lib/db');
module.exports = async function (req, res, next) {
try {
await info.delete(req.params.project, req.params.path);
await info.delete(req.params.project, req.params.path, undefined, req.user.role);
res.status(204).send();
next();
} catch (err) {

View File

@@ -4,7 +4,7 @@ const { info } = require('../../../lib/db');
module.exports = async function (req, res, next) {
try {
res.status(200).json(await info.get(req.params.project, req.params.path, req.query));
res.status(200).json(await info.get(req.params.project, req.params.path, req.query, req.user.role));
} catch (err) {
if (err instanceof TypeError) {
res.status(404).json(null);

View File

@@ -6,7 +6,7 @@ module.exports = async function (req, res, next) {
try {
const payload = req.body;
await info.post(req.params.project, req.params.path, payload);
await info.post(req.params.project, req.params.path, payload, undefined, req.user.role);
res.status(201).send();
next();
} catch (err) {

View File

@@ -6,7 +6,7 @@ module.exports = async function (req, res, next) {
try {
const payload = req.body;
await info.put(req.params.project, req.params.path, payload);
await info.put(req.params.project, req.params.path, payload, undefined, req.user.role);
res.status(201).send();
next();
} catch (err) {

View File

@@ -1,12 +1,16 @@
#!/usr/bin/node
async function main () {
// Check that we're running against the correct database version
const version = require('./lib/version');
console.log("Running version", await version.describe());
version.compatible()
.then( () => {
const api = require('./api');
const ws = require('./ws');
const { fork } = require('child_process');
// const em = require('./events');
const server = api.start(process.env.HTTP_PORT || 3000, process.env.HTTP_PATH);
ws.start(server);
@@ -14,5 +18,11 @@ const eventManagerPath = [__dirname, "events"].join("/");
const eventManager = fork(eventManagerPath, /*{ stdio: 'ignore' }*/);
process.on('exit', () => eventManager.kill());
})
.catch( ({current, wanted}) => {
console.error(`Fatal error: incompatible database schema version ${current} (wanted: ${wanted})`);
process.exit(1);
});
}
// em.start();
main();

View File

@@ -0,0 +1,83 @@
/** Check permission to read or write certain keys.
*
* The global and survey `info` tables can be used to
* store and retrieve arbitrary data, but it is also
* used by the software, with some keys being reserved
* for specific purposes.
*
* This module lists those keys which are in some way
* reserved and reports on who should be allowed what
* type of access to them.
*/
/** Reserved keys.
*
* The structure of this dictionary is
* object.verb.subject = Boolean.
*
* The special value `_` is a wildcard
* denoting the default condition for
* a verb or a subject.
*/
const dictionary = {
version: {
// Database or schema version string.
// Everyone can read, nobody can alter.
get: { _: true },
_ : { _: false }
},
config: {
// Configuration (site-wide or survey)
// Nobody except admin can access
_: { _: false, admin: true }
},
qc: {
// QC results (survey)
// Everyone can read, nobody can write
get: { _: true },
_ : { _: false }
},
equipment: {
// Equipment info (site)
// Everyone can read, user + admin can alter
get: { _: true },
_ : { _: false, user: true, admin: true }
},
contact: {
// Contact details (basically an example entry)
// Everyone can read, admin can alter
get: { _: true },
_ : { _: false, admin: true },
}
}
/** Check if access is allowed to an info entry.
*
* @a key {String} is the object of the action.
* @a verb {String} is the action.
* @a role {String} is the subject of the action.
*
* @returns {Boolean} `true` is the action is allowed,
* `false` if it is not.
*
* By default, all actions are allowed on a key that's
* not listed in the dictionary. For a key that is listed,
* the result for a default action or subject is denoted
* by `_`, others are entered explicitly.
*
*/
function checkPermission (key, verb, role) {
const entry = dictionary[key]
if (entry) {
const action = entry[verb] ?? entry._
if (action) {
return action[role] ?? action._ ?? false;
}
return false;
}
return true;
}
module.exports = checkPermission;

View File

@@ -1,9 +1,15 @@
const { setSurvey, transaction } = require('../connection');
const checkPermission = require('./check-permission');
async function del (projectId, path, opts = {}) {
async function del (projectId, path, opts = {}, role) {
const client = await setSurvey(projectId);
const [key, ...jsonpath] = (path||"").split("/").filter(i => i.length);
if (!checkPermission(key, "delete", role)) {
throw {status: 403, message: "Forbidden"};
return;
}
try {
const text = jsonpath.length
? `

View File

@@ -1,9 +1,15 @@
const { setSurvey } = require('../connection');
const checkPermission = require('./check-permission');
async function get (projectId, path, opts = {}) {
async function get (projectId, path, opts = {}, role) {
const client = await setSurvey(projectId);
const [key, ...subkey] = path.split("/").filter(i => i.trim().length);
if (!checkPermission(key, "get", role)) {
throw {status: 403, message: "Forbidden"};
return;
}
const text = `
SELECT value
FROM info

View File

@@ -1,9 +1,15 @@
const { setSurvey, transaction } = require('../connection');
const checkPermission = require('./check-permission');
async function post (projectId, path, payload, opts = {}) {
async function post (projectId, path, payload, opts = {}, role) {
const client = await setSurvey(projectId);
const [key, ...jsonpath] = (path||"").split("/").filter(i => i.length);
if (!checkPermission(key, "post", role)) {
throw {status: 403, message: "Forbidden"};
return;
}
try {
const text = jsonpath.length
? `

View File

@@ -1,9 +1,15 @@
const { setSurvey, transaction } = require('../connection');
const checkPermission = require('./check-permission');
async function put (projectId, path, payload, opts = {}) {
async function put (projectId, path, payload, opts = {}, role) {
const client = await setSurvey(projectId);
const [key, ...jsonpath] = (path||"").split("/").filter(i => i.length);
if (!checkPermission(key, "put", role)) {
throw {status: 403, message: "Forbidden"};
return;
}
try {
const text = jsonpath.length
? `

View File

@@ -1,3 +1,4 @@
const alert = require("../../../alerts");
const configuration = require('../../configuration');
async function getDistance (client, payload) {
@@ -88,6 +89,13 @@ async function getPlanned (client) {
async function getLineName (client, projectId, payload) {
// FIXME TODO Get line name script from configuration
// Ref.: https://gitlab.com/wgp/dougal/software/-/issues/129
// This is to monitor #165
// https://gitlab.com/wgp/dougal/software/-/issues/incident/165
if (!payload?.line) {
alert({function: "getLineName", client, projectId, payload});
}
const planned = await getPlanned(client);
const previous = await getSequencesForLine(client, payload.line);

View File

@@ -0,0 +1,68 @@
const semver = require("semver");
const { exec } = require("child_process");
const pkg = require("../package.json");
/** Report whether the database schema version is
* compatible with the version required by this server.
*
* The current schema version is retrieved from the
* public.info table.
*
* The wanted version is retrieved from package.json
* (config.db_schema).
*
* @returns true if the versions are compatible,
* false otherwise.
*/
function compatible () {
const { info } = require('./db');
return new Promise ( async (resolve, reject) => {
const current = await info.get(null, "version/db_schema");
const wanted = pkg.config.db_schema;
if (semver.satisfies(current, wanted)) {
resolve({current, wanted});
} else {
reject({current, wanted});
}
});
}
/** Return software name.
*
*/
function name () {
const pkg = require("../package.json");
return pkg.name ?? pkg.description ?? "Unknown";
}
/** Return software version, from Git if possible.
*
*/
async function describe () {
return new Promise( (resolve, reject) => {
const cmd = `git describe || echo git+$(git describe --always);`;
exec(cmd, {cwd: __dirname}, (error, stdout, stderr) => {
if (error) {
reject(error);
}
if (stdout) {
resolve(stdout.trim());
} else {
// Most likely not installed from Git, use the
// version number in package.json.
resolve(pkg.version ?? "Unknown")
}
})
});
}
function version () {
return pkg.version;
}
version.compatible = compatible;
version.name = name;
version.describe = describe;
module.exports = version;

View File

@@ -9,6 +9,16 @@
},
"author": "Aaltronav s.r.o.",
"license": "UNLICENSED",
"private": true,
"config": {
"db_schema": "^0.2.0"
},
"engines": {
"node": ">=14.0.0"
},
"os": [
"linux"
],
"dependencies": {
"cookie-parser": "^1.4.5",
"express": "^4.17.1",