Files
galaxy/scripts/grt/export.py
T
E Rasche 32b85ecb59 Only permit yaml.safe_loading of data
Event trusted data, belt + suspenders method.
2017-09-25 11:39:12 +02:00

387 lines
15 KiB
Python

#!/usr/bin/env python
"""Script for parsing Galaxy job information in preparation for submission to the Galactic radio telescope.
See doc/source/admin/grt.rst for more detailed usage information.
"""
import argparse
import tarfile
import json
import os
import sqlalchemy as sa
import subprocess
import sys
import time
import yaml
import logging
from collections import defaultdict
sys.path.insert(1, os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, 'lib')))
from galaxy.util.properties import load_app_properties
import galaxy
import galaxy.config
from galaxy.objectstore import build_object_store_from_config
from galaxy.model import mapping
sample_config = os.path.abspath(os.path.join(os.path.dirname(__file__), 'grt.yml.sample'))
default_config = os.path.abspath(os.path.join(os.path.dirname(__file__), 'grt.yml'))
def _init(config, need_app=False):
if config.startswith('/'):
config_file = os.path.abspath(config)
else:
config_file = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, config))
properties = load_app_properties(ini_file=config_file)
config = galaxy.config.Configuration(**properties)
object_store = build_object_store_from_config(config)
if not config.database_connection:
logging.warning("The database connection is empty. If you are using the default value, please uncomment that in your galaxy.ini")
if need_app:
app = galaxy.app.UniverseApplication(global_conf={'__file__': config_file, 'here': os.getcwd()})
else:
app = None
return (
mapping.init(
config.file_path,
config.database_connection,
create_tables=False,
object_store=object_store
),
object_store,
config.database_connection.split(':')[0],
config,
app
)
def kw_metrics(job):
return {
'%s_%s' % (metric.plugin, metric.metric_name): metric.metric_value
for metric in job.metrics
}
class Sanitization:
def __init__(self, sanitization_config, model, sa_session):
self.sanitization_config = sanitization_config
# SA Stuff
self.model = model
self.sa_session = sa_session
self.filesize_cache = {}
if 'tool_params' not in self.sanitization_config:
self.sanitization_config['tool_params'] = {}
def blacklisted_tree(self, path):
if path.lstrip('.') in self.sanitization_config['tool_params'][self.tool_id]:
return True
return False
def sanitize_data(self, tool_id, key, value):
# If the tool is blacklisted, skip it.
if tool_id in self.sanitization_config['tools']:
return 'null'
# Thus, all tools below here are not blacklisted at the top level.
# If it isn't in tool_params, there are no keys being sanitized for
# this tool so we can return quickly without parsing.
if tool_id not in self.sanitization_config['tool_params']:
return value
# If the key is listed precisely (not a sub-tree), we can also return slightly more quickly.
if key in self.sanitization_config['tool_params'][tool_id]:
return 'null'
# If the key isn't a prefix for any of the keys being sanitized, then this is safe.
if not any(san_key.startswith(key) for san_key in self.sanitization_config['tool_params'][tool_id]):
return value
# Slow path.
unsanitized = {key: json.loads(value)}
self.tool_id = tool_id
return json.dumps(self._sanitize_value(unsanitized))
def _file_dict(self, data):
key = '{src}-{id}'.format(**data)
if key in self.filesize_cache:
return self.filesize_cache[data]
if data['src'] == 'hda':
try:
dataset = self.sa_session.query(self.model.Dataset.id, self.model.Dataset.total_size) \
.filter_by(id=data['id']) \
.first()
if dataset and dataset[1]:
data['size'] = int(dataset[1])
else:
data['size'] = None
except sa.orm.exc.NoResultFound:
data['size'] = None
# Push to cache for later.
self.filesize_cache[data['id']] = data
return data
else:
raise Exception("Cannot handle {src} yet".format(data))
def _sanitize_dict(self, unsanitized_dict, path=""):
# if it is a file dictionary, handle specially.
if len(unsanitized_dict.keys()) == 2 and \
'id' in unsanitized_dict and \
'src' in unsanitized_dict and \
unsanitized_dict['src'] in ('hda', 'ldda'):
return self._file_dict(unsanitized_dict)
return {
k: self._sanitize_value(v, path=path + '.' + k)
for (k, v)
in unsanitized_dict.items()
}
def _sanitize_list(self, unsanitized_list, path=""):
return [
self._sanitize_value(v, path=path + '.*')
for v in unsanitized_list
]
def _sanitize_value(self, unsanitized_value, path=""):
logging.debug("%sSAN %s" % (' ' * path.count('.'), unsanitized_value))
if self.blacklisted_tree(path):
logging.debug("%sSAN ***REDACTED***" % (' ' * path.count('.')))
return None
if type(unsanitized_value) is dict:
return self._sanitize_dict(unsanitized_value, path=path)
elif type(unsanitized_value) is list:
return self._sanitize_list(unsanitized_value, path=path)
else:
logging.debug("%s> Sanitizing %s = %s" % (' ' * path.count('.'), path, unsanitized_value))
return unsanitized_value
def main(argv):
parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('-r', '--report-directory', help='Directory to store reports in',
default=os.path.abspath(os.path.join('.', 'reports')))
parser.add_argument('-c', '--config', help='Path to GRT config file',
default=default_config)
parser.add_argument("-l", "--loglevel", choices=['debug', 'info', 'warning', 'error', 'critical'],
help="Set the logging level", default='warning')
parser.add_argument("-b", "--batch-size", type=int, default=1000,
help="Batch size for sql queries")
parser.add_argument("-m", "--max-records", type=int, default=0,
help="Maximum number of records to include in a single report. This option should ONLY be used when reporting historical data. Setting this may require running GRT multiple times to capture all historical logs.")
args = parser.parse_args()
logging.getLogger().setLevel(getattr(logging, args.loglevel.upper()))
_times = []
_start_time = time.time()
def annotate(label, human_label=None):
if human_label:
logging.info(human_label)
_times.append((label, time.time() - _start_time))
annotate('init_start', 'Loading GRT configuration...')
try:
with open(args.config) as handle:
config = yaml.safe_load(handle)
except Exception:
logging.info('Using default GRT configuration')
with open(sample_config) as handle:
config = yaml.safe_load(handle)
annotate('init_end')
REPORT_DIR = args.report_directory
CHECK_POINT_FILE = os.path.join(REPORT_DIR, '.checkpoint')
REPORT_IDENTIFIER = str(time.time())
REPORT_BASE = os.path.join(REPORT_DIR, REPORT_IDENTIFIER)
if os.path.exists(CHECK_POINT_FILE):
with open(CHECK_POINT_FILE, 'r') as handle:
last_job_sent = int(handle.read())
else:
last_job_sent = -1
annotate('galaxy_init', 'Loading Galaxy...')
model, object_store, engine, gxconfig, app = _init(config['galaxy_config'], need_app=config['grt']['share_toolbox'])
# Galaxy overrides our logging level.
logging.getLogger().setLevel(getattr(logging, args.loglevel.upper()))
sa_session = model.context.current
annotate('galaxy_end')
# Fetch jobs COMPLETED with status OK that have not yet been sent.
# Set up our arrays
active_users = defaultdict(int)
job_state_data = defaultdict(int)
annotate('san_init', 'Building Sanitizer')
san = Sanitization(config['sanitization'], model, sa_session)
annotate('san_end')
if not os.path.exists(REPORT_DIR):
os.makedirs(REPORT_DIR)
# Pick an end point so our queries can return uniform data.
annotate('endpoint_start', 'Identifying a safe endpoint for SQL queries')
end_job_id = sa_session.query(model.Job.id) \
.order_by(model.Job.id.desc()) \
.first()[0]
# Allow users to only report N records at once.
if args.max_records > 0:
if end_job_id - last_job_sent > args.max_records:
end_job_id = last_job_sent + args.max_records
annotate('endpoint_end', 'Processing jobs (%s, %s]' % (last_job_sent, end_job_id))
# Remember the last job sent.
if end_job_id == last_job_sent:
logging.info("No new jobs to report")
# So we can just quit now.
sys.exit(0)
# Unfortunately we have to keep this mapping for the sanitizer to work properly.
job_tool_map = {}
blacklisted_tools = config['sanitization']['tools']
annotate('export_jobs_start', 'Exporting Jobs')
handle_job = open(REPORT_BASE + '.jobs.tsv', 'w')
handle_job.write('\t'.join(('id', 'tool_id', 'tool_version', 'state', 'create_time')) + '\n')
for offset_start in range(last_job_sent, end_job_id, args.batch_size):
logging.debug("Processing %s:%s", offset_start, min(end_job_id, offset_start + args.batch_size))
for job in sa_session.query(model.Job.id, model.Job.user_id, model.Job.tool_id, model.Job.tool_version, model.Job.state, model.Job.create_time) \
.filter(model.Job.id > offset_start) \
.filter(model.Job.id <= min(end_job_id, offset_start + args.batch_size)) \
.all():
# If the tool is blacklisted, exclude everywhere
if job[2] in blacklisted_tools:
continue
handle_job.write(str(job[0])) # id
handle_job.write('\t')
handle_job.write(job[2]) # tool_id
handle_job.write('\t')
handle_job.write(job[3]) # tool_version
handle_job.write('\t')
handle_job.write(job[4]) # state
handle_job.write('\t')
handle_job.write(str(job[5])) # create_time
handle_job.write('\n')
# meta counts
job_state_data[job[4]] += 1
active_users[job[1]] += 1
job_tool_map[job[0]] = job[2]
handle_job.close()
annotate('export_jobs_end')
annotate('export_metric_num_start', 'Exporting Metrics (Numeric)')
handle_metric_num = open(REPORT_BASE + '.metric_num.tsv', 'w')
handle_metric_num.write('\t'.join(('job_id', 'plugin', 'name', 'value')) + '\n')
for offset_start in range(last_job_sent, end_job_id, args.batch_size):
logging.debug("Processing %s:%s", offset_start, min(end_job_id, offset_start + args.batch_size))
for metric in sa_session.query(model.JobMetricNumeric.job_id, model.JobMetricNumeric.plugin, model.JobMetricNumeric.metric_name, model.JobMetricNumeric.metric_value) \
.filter(model.JobMetricNumeric.job_id > offset_start) \
.filter(model.JobMetricNumeric.job_id <= min(end_job_id, offset_start + args.batch_size)) \
.all():
# No associated job
if metric[0] not in job_tool_map:
continue
# If the tool is blacklisted, exclude everywhere
if job_tool_map[metric[0]] in blacklisted_tools:
continue
handle_metric_num.write(str(metric[0]))
handle_metric_num.write('\t')
handle_metric_num.write(metric[1])
handle_metric_num.write('\t')
handle_metric_num.write(metric[2])
handle_metric_num.write('\t')
handle_metric_num.write(str(metric[3]))
handle_metric_num.write('\n')
handle_metric_num.close()
annotate('export_metric_num_end')
annotate('export_params_start', 'Export Job Parameters')
handle_params = open(REPORT_BASE + '.params.tsv', 'w')
handle_params.write('\t'.join(('job_id', 'name', 'value')) + '\n')
for offset_start in range(last_job_sent, end_job_id, args.batch_size):
logging.debug("Processing %s:%s", offset_start, min(end_job_id, offset_start + args.batch_size))
for param in sa_session.query(model.JobParameter.job_id, model.JobParameter.name, model.JobParameter.value) \
.filter(model.JobParameter.job_id > offset_start) \
.filter(model.JobParameter.job_id <= min(end_job_id, offset_start + args.batch_size)) \
.all():
# No associated job
if param[0] not in job_tool_map:
continue
# If the tool is blacklisted, exclude everywhere
if job_tool_map[param[0]] in blacklisted_tools:
continue
sanitized = san.sanitize_data(job_tool_map[param[0]], param[1], param[2])
handle_params.write(str(param[0]))
handle_params.write('\t')
handle_params.write(param[1])
handle_params.write('\t')
handle_params.write(json.dumps(sanitized))
handle_params.write('\n')
handle_params.close()
annotate('export_params_end')
# Now on to outputs.
with tarfile.open(REPORT_BASE + '.tar.gz', 'w:gz') as handle:
for name in ('jobs', 'metric_num', 'params'):
handle.add(REPORT_BASE + '.' + name + '.tsv')
for name in ('jobs', 'metric_num', 'params'):
os.unlink(REPORT_BASE + '.' + name + '.tsv')
_times.append(('job_finish', time.time() - _start_time))
sha = subprocess.check_output(['sha256sum', REPORT_BASE + '.tar.gz'])
_times.append(('hash_finish', time.time() - _start_time))
# Strip out to space
sha = sha[0:sha.index(' ')]
# Now serialize the individual report data.
with open(REPORT_BASE + '.json', 'w') as handle:
if config['grt']['share_toolbox']:
toolbox = [
(tool.id, tool.name, tool.version, tool.tool_shed, tool.repository_id, tool.repository_name)
for tool_id, tool in app.toolbox._tools_by_id.items()
]
else:
toolbox = None
json.dump({
"version": 1,
"galaxy_version": gxconfig.version_major,
"generated": REPORT_IDENTIFIER,
"report_hash": "sha256:" + sha,
"metrics": {
"_times": _times,
},
"users": {
"active": len(active_users.keys()),
"total": sa_session.query(model.User.id).count(),
},
"jobs": job_state_data,
"tools": toolbox
}, handle)
# Write our checkpoint file so we know where to start next time.
with open(CHECK_POINT_FILE, 'w') as handle:
handle.write(str(end_job_id))
if __name__ == '__main__':
main(sys.argv)