DumpImporter.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. #!/usr/bin/env python3
  2. # Contest Management System - http://cms-dev.github.io/
  3. # Copyright © 2010-2015 Giovanni Mascellani <mascellani@poisson.phc.unipi.it>
  4. # Copyright © 2010-2018 Stefano Maggiolo <s.maggiolo@gmail.com>
  5. # Copyright © 2010-2012 Matteo Boscariol <boscarim@hotmail.com>
  6. # Copyright © 2013-2018 Luca Wehrstedt <luca.wehrstedt@gmail.com>
  7. # Copyright © 2014 Artem Iglikov <artem.iglikov@gmail.com>
  8. # Copyright © 2014 Luca Versari <veluca93@gmail.com>
  9. # Copyright © 2014 William Di Luigi <williamdiluigi@gmail.com>
  10. #
  11. # This program is free software: you can redistribute it and/or modify
  12. # it under the terms of the GNU Affero General Public License as
  13. # published by the Free Software Foundation, either version 3 of the
  14. # License, or (at your option) any later version.
  15. #
  16. # This program is distributed in the hope that it will be useful,
  17. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  18. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  19. # GNU Affero General Public License for more details.
  20. #
  21. # You should have received a copy of the GNU Affero General Public License
  22. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  23. """This service imports data from a directory that has been the
  24. target of a DumpExport. The process of exporting and importing
  25. again should be idempotent.
  26. """
  27. # We enable monkey patching to make many libraries gevent-friendly
  28. # (for instance, urllib3, used by requests)
  29. import gevent.monkey
  30. gevent.monkey.patch_all() # noqa
  31. import argparse
  32. import ipaddress
  33. import json
  34. import logging
  35. import os
  36. import sys
  37. from datetime import datetime, timedelta
  38. from sqlalchemy.types import \
  39. Boolean, Integer, Float, String, Unicode, DateTime, Interval, Enum
  40. from sqlalchemy.dialects.postgresql import ARRAY, CIDR, JSONB
  41. import cms.db as class_hook
  42. from cms import utf8_decoder
  43. from cms.db import version as model_version, Codename, Filename, \
  44. FilenameSchema, FilenameSchemaArray, Digest, SessionGen, Contest, \
  45. Submission, SubmissionResult, User, Participation, UserTest, \
  46. UserTestResult, PrintJob, Announcement, init_db, drop_db, enumerate_files
  47. from cms.db.filecacher import FileCacher
  48. from cmscommon.archive import Archive
  49. from cmscommon.datetime import make_datetime
  50. from cmscommon.digest import path_digest
  51. logger = logging.getLogger(__name__)
  52. def find_root_of_archive(file_names):
  53. """Given a list of file names (the content of an archive) find the
  54. name of the root directory, i.e., the only file that would be
  55. created in a directory if we extract there the archive.
  56. file_names (list of strings): the list of file names in the
  57. archive
  58. return (string): the root directory, or None if unable to find
  59. (for example if there is more than one).
  60. """
  61. current_root = None
  62. for file_name in file_names:
  63. if '/' not in file_name or '/' not in file_name[0:-1]:
  64. if current_root is None:
  65. current_root = file_name
  66. else:
  67. return None
  68. return current_root
  69. def decode_value(type_, value):
  70. """Decode a given value in a JSON-compatible form to a given type.
  71. type_ (sqlalchemy.types.TypeEngine): the SQLAlchemy type of the
  72. column that will hold the value.
  73. value (object): the value, encoded as bool, int, float, string,
  74. list, dict or any other JSON-compatible format.
  75. return (object): the value, decoded.
  76. """
  77. if value is None:
  78. return None
  79. elif isinstance(type_, (
  80. Boolean, Integer, Float, String, Unicode, Enum, JSONB, Codename,
  81. Filename, FilenameSchema, Digest)):
  82. return value
  83. elif isinstance(type_, DateTime):
  84. try:
  85. return make_datetime(value)
  86. except OverflowError:
  87. logger.warning("The dump has a date too far in the future for "
  88. "your system. Changing to 2030-01-01.")
  89. return datetime(2030, 1, 1)
  90. elif isinstance(type_, Interval):
  91. return timedelta(seconds=value)
  92. elif isinstance(type_, (ARRAY, FilenameSchemaArray)):
  93. return list(decode_value(type_.item_type, item) for item in value)
  94. elif isinstance(type_, CIDR):
  95. return ipaddress.ip_network(value)
  96. else:
  97. raise RuntimeError(
  98. "Unknown SQLAlchemy column type: %s" % type_)
  99. class DumpImporter:
  100. """This service imports data from a directory that has been
  101. the target of a DumpExport. The process of exporting and
  102. importing again should be idempotent.
  103. """
  104. def __init__(self, drop, import_source,
  105. load_files, load_model, skip_generated,
  106. skip_submissions, skip_user_tests, skip_users, skip_print_jobs):
  107. self.drop = drop
  108. self.load_files = load_files
  109. self.load_model = load_model
  110. self.skip_generated = skip_generated
  111. self.skip_submissions = skip_submissions
  112. self.skip_user_tests = skip_user_tests
  113. self.skip_users = skip_users
  114. self.skip_print_jobs = skip_print_jobs
  115. self.import_source = import_source
  116. self.import_dir = import_source
  117. self.file_cacher = FileCacher()
  118. def do_import(self):
  119. """Run the actual import code."""
  120. logger.info("Starting import.")
  121. archive = None
  122. if Archive.is_supported(self.import_source):
  123. archive = Archive(self.import_source)
  124. self.import_dir = archive.unpack()
  125. file_names = os.listdir(self.import_dir)
  126. if len(file_names) != 1:
  127. logger.critical("Cannot find a root directory in %s.",
  128. self.import_source)
  129. archive.cleanup()
  130. return False
  131. self.import_dir = os.path.join(self.import_dir, file_names[0])
  132. if self.drop:
  133. logger.info("Dropping and recreating the database.")
  134. try:
  135. if not (drop_db() and init_db()):
  136. logger.critical("Unexpected error while dropping "
  137. "and recreating the database.",
  138. exc_info=True)
  139. return False
  140. except Exception:
  141. logger.critical("Unable to access DB.", exc_info=True)
  142. return False
  143. with SessionGen() as session:
  144. # Import the contest in JSON format.
  145. if self.load_model:
  146. logger.info("Importing the contest from a JSON file.")
  147. with open(os.path.join(self.import_dir,
  148. "contest.json"), "rb") as fin:
  149. # TODO - Throughout all the code we'll assume the
  150. # input is correct without actually doing any
  151. # validations. Thus, for example, we're not
  152. # checking that the decoded object is a dict...
  153. self.datas = json.load(fin)
  154. # If the dump has been exported using a data model
  155. # different than the current one (that is, a previous
  156. # one) we try to update it.
  157. # If no "_version" field is found we assume it's a v1.0
  158. # export (before the new dump format was introduced).
  159. dump_version = self.datas.get("_version", 0)
  160. if dump_version < model_version:
  161. logger.warning(
  162. "The dump you're trying to import has been created "
  163. "by an old version of CMS (it declares data model "
  164. "version %d). It may take a while to adapt it to "
  165. "the current data model (which is version %d). You "
  166. "can use cmsDumpUpdater to update the on-disk dump "
  167. "and speed up future imports.",
  168. dump_version, model_version)
  169. elif dump_version > model_version:
  170. logger.critical(
  171. "The dump you're trying to import has been created "
  172. "by a version of CMS newer than this one (it "
  173. "declares data model version %d) and there is no "
  174. "way to adapt it to the current data model (which "
  175. "is version %d). You probably need to update CMS to "
  176. "handle it. It is impossible to proceed with the "
  177. "importation.", dump_version, model_version)
  178. return False
  179. else:
  180. logger.info(
  181. "Importing dump with data model version %d.",
  182. dump_version)
  183. for version in range(dump_version, model_version):
  184. # Update from version to version+1
  185. updater = __import__(
  186. "cmscontrib.updaters.update_%d" % (version + 1),
  187. globals(), locals(), ["Updater"]).Updater(self.datas)
  188. self.datas = updater.run()
  189. self.datas["_version"] = version + 1
  190. assert self.datas["_version"] == model_version
  191. self.objs = dict()
  192. for id_, data in self.datas.items():
  193. if not id_.startswith("_"):
  194. self.objs[id_] = self.import_object(data)
  195. for k, v in list(self.objs.items()):
  196. # Skip submissions if requested
  197. if self.skip_submissions and isinstance(v, Submission):
  198. del self.objs[k]
  199. # Skip user_tests if requested
  200. elif self.skip_user_tests and isinstance(v, UserTest):
  201. del self.objs[k]
  202. # Skip users if requested
  203. elif self.skip_users and \
  204. isinstance(v, (User, Participation, Submission,
  205. UserTest, Announcement)):
  206. del self.objs[k]
  207. # Skip print jobs if requested
  208. elif self.skip_print_jobs and isinstance(v, PrintJob):
  209. del self.objs[k]
  210. # Skip generated data if requested
  211. elif self.skip_generated and \
  212. isinstance(v, (SubmissionResult, UserTestResult)):
  213. del self.objs[k]
  214. for id_, data in self.datas.items():
  215. if not id_.startswith("_") and id_ in self.objs:
  216. self.add_relationships(data, self.objs[id_])
  217. contest_id = list()
  218. contest_files = set()
  219. # We add explicitly only the top-level objects:
  220. # contests, and tasks and users not contained in any
  221. # contest. This will add on cascade all dependent
  222. # objects, and not add orphaned objects (like those
  223. # that depended on submissions or user tests that we
  224. # might have removed above).
  225. for id_ in self.datas["_objects"]:
  226. # It could have been removed by request
  227. if id_ not in self.objs:
  228. continue
  229. obj = self.objs[id_]
  230. session.add(obj)
  231. session.flush()
  232. if isinstance(obj, Contest):
  233. contest_id += [obj.id]
  234. contest_files |= enumerate_files(
  235. session, obj,
  236. skip_submissions=self.skip_submissions,
  237. skip_user_tests=self.skip_user_tests,
  238. skip_print_jobs=self.skip_print_jobs,
  239. skip_users=self.skip_users,
  240. skip_generated=self.skip_generated)
  241. session.commit()
  242. else:
  243. contest_id = None
  244. contest_files = None
  245. # Import files.
  246. if self.load_files:
  247. logger.info("Importing files.")
  248. files_dir = os.path.join(self.import_dir, "files")
  249. descr_dir = os.path.join(self.import_dir, "descriptions")
  250. files = set(os.listdir(files_dir))
  251. descr = set(os.listdir(descr_dir))
  252. if not descr <= files:
  253. logger.warning("Some files do not have an associated "
  254. "description.")
  255. if not files <= descr:
  256. logger.warning("Some descriptions do not have an "
  257. "associated file.")
  258. if not (contest_files is None or files <= contest_files):
  259. # FIXME Check if it's because this is a light import
  260. # or because we're skipping submissions or user_tests
  261. logger.warning("The dump contains some files that are "
  262. "not needed by the contest.")
  263. if not (contest_files is None or contest_files <= files):
  264. # The reason for this could be that it was a light
  265. # export that's not being reimported as such.
  266. logger.warning("The contest needs some files that are "
  267. "not contained in the dump.")
  268. # Limit import to files we actually need.
  269. if contest_files is not None:
  270. files &= contest_files
  271. for digest in files:
  272. file_ = os.path.join(files_dir, digest)
  273. desc = os.path.join(descr_dir, digest)
  274. if not self.safe_put_file(file_, desc):
  275. logger.critical("Unable to put file `%s' in the DB. "
  276. "Aborting. Please remove the contest "
  277. "from the database.", file_)
  278. # TODO: remove contest from the database.
  279. return False
  280. # Clean up, if an archive was used
  281. if archive is not None:
  282. archive.cleanup()
  283. if contest_id is not None:
  284. logger.info("Import finished (contest id: %s).",
  285. ", ".join("%d" % id_ for id_ in contest_id))
  286. else:
  287. logger.info("Import finished.")
  288. return True
  289. def import_object(self, data):
  290. """Import objects from the given data (without relationships).
  291. The given data is assumed to be a dict in the format produced by
  292. DumpExporter. This method reads the "_class" item and tries
  293. to find the corresponding class. Then it loads all column
  294. properties of that class (those that are present in the data)
  295. and uses them as keyword arguments in a call to the class
  296. constructor (if a required property is missing this call will
  297. raise an error).
  298. Relationships are not handled by this method, since we may not
  299. have all referenced objects available yet. Thus we prefer to add
  300. relationships in a later moment, using the add_relationships
  301. method.
  302. Note that both this method and add_relationships don't check if
  303. the given data has more items than the ones we understand and
  304. use.
  305. """
  306. cls = getattr(class_hook, data["_class"])
  307. args = dict()
  308. for prp in cls._col_props:
  309. if prp.key not in data:
  310. # We will let the __init__ of the class check if any
  311. # argument is missing, so it's safe to just skip here.
  312. continue
  313. col = prp.columns[0]
  314. val = data[prp.key]
  315. args[prp.key] = decode_value(col.type, val)
  316. return cls(**args)
  317. def add_relationships(self, data, obj):
  318. """Add the relationships to the given object, using the given data.
  319. Do what we didn't in import_objects: importing relationships.
  320. We already now the class of the object so we simply iterate over
  321. its relationship properties trying to load them from the data (if
  322. present), checking wheter they are IDs or collection of IDs,
  323. dereferencing them (i.e. getting the corresponding object) and
  324. reflecting all on the given object.
  325. Note that both this method and import_object don't check if the
  326. given data has more items than the ones we understand and use.
  327. """
  328. cls = type(obj)
  329. for prp in cls._rel_props:
  330. if prp.key not in data:
  331. # Relationships are always optional
  332. continue
  333. val = data[prp.key]
  334. if val is None:
  335. setattr(obj, prp.key, None)
  336. elif isinstance(val, str):
  337. setattr(obj, prp.key, self.objs.get(val))
  338. elif isinstance(val, list):
  339. setattr(obj, prp.key, list(self.objs[i] for i in val if i in self.objs))
  340. elif isinstance(val, dict):
  341. setattr(obj, prp.key,
  342. dict((k, self.objs[v]) for k, v in val.items() if v in self.objs))
  343. else:
  344. raise RuntimeError(
  345. "Unknown RelationshipProperty value: %s" % type(val))
  346. def safe_put_file(self, path, descr_path):
  347. """Put a file to FileCacher signaling every error (including
  348. digest mismatch).
  349. path (string): the path from which to load the file.
  350. descr_path (string): same for description.
  351. return (bool): True if all ok, False if something wrong.
  352. """
  353. # TODO - Probably this method could be merged in FileCacher
  354. # First read the description.
  355. try:
  356. with open(descr_path, 'rt', encoding='utf-8') as fin:
  357. description = fin.read()
  358. except OSError:
  359. description = ''
  360. # Put the file.
  361. try:
  362. digest = self.file_cacher.put_file_from_path(path, description)
  363. except Exception as error:
  364. logger.critical("File %s could not be put to file server (%r), "
  365. "aborting.", path, error)
  366. return False
  367. # Then check the digest.
  368. calc_digest = path_digest(path)
  369. if digest != calc_digest:
  370. logger.critical("File %s has hash %s, but the server returned %s, "
  371. "aborting.", path, calc_digest, digest)
  372. return False
  373. return True
  374. def main():
  375. """Parse arguments and launch process."""
  376. parser = argparse.ArgumentParser(description="Importer of CMS contests.")
  377. parser.add_argument("-d", "--drop", action="store_true",
  378. help="drop everything from the database "
  379. "before importing")
  380. group = parser.add_mutually_exclusive_group()
  381. group.add_argument("-f", "--files", action="store_true",
  382. help="only import files, ignore database structure")
  383. group.add_argument("-F", "--no-files", action="store_true",
  384. help="only import database structure, ignore files")
  385. parser.add_argument("-G", "--no-generated", action="store_true",
  386. help="don't import data and files that can be "
  387. "automatically generated")
  388. parser.add_argument("-S", "--no-submissions", action="store_true",
  389. help="don't import submissions")
  390. parser.add_argument("-U", "--no-user-tests", action="store_true",
  391. help="don't import user tests")
  392. parser.add_argument("-X", "--no-users", action="store_true",
  393. help="don't import users")
  394. parser.add_argument("-P", "--no-print-jobs", action="store_true",
  395. help="don't import print jobs")
  396. parser.add_argument("import_source", action="store", type=utf8_decoder,
  397. help="source directory or compressed file")
  398. args = parser.parse_args()
  399. importer = DumpImporter(drop=args.drop,
  400. import_source=args.import_source,
  401. load_files=not args.no_files,
  402. load_model=not args.files,
  403. skip_generated=args.no_generated,
  404. skip_submissions=args.no_submissions,
  405. skip_user_tests=args.no_user_tests,
  406. skip_users=args.no_users,
  407. skip_print_jobs=args.no_print_jobs)
  408. success = importer.do_import()
  409. return 0 if success is True else 1
  410. if __name__ == "__main__":
  411. sys.exit(main())