Edgewall Software

source: trunk/trac/env.py

Last change on this file was 17691, checked in by Jun Omae, 7 months ago

1.6dev: merge [17690] from 1.4-stable (fix for #13576)

  • Property svn:eol-style set to native
File size: 44.4 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2003-2023 Edgewall Software
4# Copyright (C) 2003-2007 Jonas Borgström <jonas@edgewall.com>
5# All rights reserved.
6#
7# This software is licensed as described in the file COPYING, which
8# you should have received as part of this distribution. The terms
9# are also available at https://trac.edgewall.org/wiki/TracLicense.
10#
11# This software consists of voluntary contributions made by many
12# individuals. For the exact contribution history, see the revision
13# history and logs, available at https://trac.edgewall.org/log/.
14#
15# Author: Jonas Borgström <jonas@edgewall.com>
16
17"""Trac Environment model and related APIs."""
18
19from contextlib import contextmanager
20import hashlib
21import os.path
22import setuptools
23import shutil
24import sys
25import time
26from configparser import RawConfigParser
27from subprocess import PIPE, Popen
28from tempfile import mkdtemp
29from urllib.parse import urlsplit
30
31from trac import log
32from trac.admin.api import (AdminCommandError, IAdminCommandProvider,
33 get_dir_list)
34from trac.api import IEnvironmentSetupParticipant, ISystemInfoProvider
35from trac.cache import CacheManager, cached
36from trac.config import BoolOption, ChoiceOption, ConfigSection, \
37 Configuration, IntOption, Option, PathOption
38from trac.core import Component, ComponentManager, ExtensionPoint, \
39 TracBaseError, TracError, implements
40from trac.db.api import (DatabaseManager, QueryContextManager,
41 TransactionContextManager, parse_connection_uri)
42from trac.db.convert import copy_tables
43from trac.loader import load_components
44from trac.util import as_bool, backup_config_file, copytree, create_file, \
45 get_pkginfo, is_path_below, lazy, makedirs
46from trac.util.compat import close_fds
47from trac.util.concurrency import threading
48from trac.util.datefmt import pytz
49from trac.util.text import exception_to_unicode, path_to_unicode, printerr, \
50 printferr, printfout, printout
51from trac.util.translation import _, N_
52from trac.web.chrome import Chrome
53from trac.web.href import Href
54
55__all__ = ['Environment', 'IEnvironmentSetupParticipant', 'open_environment']
56
57
58# Content of the VERSION file in the environment
59_VERSION = 'Trac Environment Version 1'
60
61
62class BackupError(TracBaseError, RuntimeError):
63 """Exception raised during an upgrade when the DB backup fails."""
64
65
66class Environment(Component, ComponentManager):
67 """Trac environment manager.
68
69 Trac stores project information in a Trac environment. It consists
70 of a directory structure containing among other things:
71
72 * a configuration file,
73 * project-specific templates and plugins,
74 * the wiki and ticket attachments files,
75 * the SQLite database file (stores tickets, wiki pages...)
76 in case the database backend is SQLite
77
78 """
79
80 implements(ISystemInfoProvider)
81
82 required = True
83
84 system_info_providers = ExtensionPoint(ISystemInfoProvider)
85 setup_participants = ExtensionPoint(IEnvironmentSetupParticipant)
86
87 components_section = ConfigSection('components',
88 """Enable or disable components provided by Trac and plugins.
89 The component to enable/disable is specified by the option name.
90 The enabled state is determined by the option value: setting
91 the value to `enabled` or `on` will enable the component, any
92 other value (typically `disabled` or `off`) will disable the
93 component.
94
95 The option name is either the fully qualified name of the
96 component or the module/package prefix of the component. The
97 former enables/disables a specific component, while the latter
98 enables/disables any component in the specified package/module.
99
100 Consider the following configuration snippet:
101 {{{#!ini
102 [components]
103 trac.ticket.report.ReportModule = disabled
104 acct_mgr.* = enabled
105 }}}
106
107 The first option tells Trac to disable the
108 [TracReports report module].
109 The second option instructs Trac to enable all components in
110 the `acct_mgr` package. The trailing wildcard is required for
111 module/package matching.
112
113 To view the list of active components, go to the ''Plugins''
114 section of ''About Trac'' (requires `CONFIG_VIEW`
115 [TracPermissions permission]).
116
117 See also: TracPlugins
118 """)
119
120 shared_plugins_dir = PathOption('inherit', 'plugins_dir', '',
121 """Path to the //shared plugins directory//.
122
123 Plugins in that directory are loaded in addition to those in
124 the directory of the environment `plugins`, with this one
125 taking precedence.
126
127 Non-absolute paths are relative to the Environment `conf`
128 directory.
129 """)
130
131 base_url = Option('trac', 'base_url', '',
132 """Base URL of the Trac site.
133
134 This is used to produce documents outside of the web browsing
135 context, such as URLs in notification e-mails that point to
136 Trac resources.
137 """)
138
139 base_url_for_redirect = BoolOption('trac', 'use_base_url_for_redirect',
140 False,
141 """Optionally use `[trac] base_url` for redirects.
142
143 In some configurations, usually involving running Trac behind
144 a HTTP proxy, Trac can't automatically reconstruct the URL
145 that is used to access it. You may need to use this option to
146 force Trac to use the `base_url` setting also for
147 redirects. This introduces the obvious limitation that this
148 environment will only be usable when accessible from that URL,
149 as redirects are frequently used.
150 """)
151
152 secure_cookies = BoolOption('trac', 'secure_cookies', False,
153 """Restrict cookies to HTTPS connections.
154
155 When true, set the `secure` flag on all cookies so that they
156 are only sent to the server on HTTPS connections. Use this if
157 your Trac instance is only accessible through HTTPS.
158 """)
159
160 anonymous_session_lifetime = IntOption(
161 'trac', 'anonymous_session_lifetime', '90',
162 """Lifetime of the anonymous session, in days.
163
164 Set the option to 0 to disable purging old anonymous sessions.
165 (''since 1.0.17'')""")
166
167 project_name = Option('project', 'name', 'My Project',
168 """Name of the project.""")
169
170 project_description = Option('project', 'descr', 'My example project',
171 """Short description of the project.""")
172
173 project_url = Option('project', 'url', '',
174 """URL of the project web site.
175
176 This is usually the domain in which the `base_url` resides.
177 For example, the project URL might be !https://myproject.com,
178 with the Trac site (`base_url`) residing at either
179 !https://trac.myproject.com or !https://myproject.com/trac.
180 The project URL is added to the footer of notification e-mails.
181 """)
182
183 project_admin = Option('project', 'admin', '',
184 """E-Mail address of the project's administrator.""")
185
186 project_admin_trac_url = Option('project', 'admin_trac_url', '.',
187 """Base URL of a Trac instance where errors in this Trac
188 should be reported.
189
190 This can be an absolute or relative URL, or '.' to reference
191 this Trac instance. An empty value will disable the reporting
192 buttons.
193 """)
194
195 project_footer = Option('project', 'footer',
196 N_('Visit the Trac open source project at<br />'
197 '<a href="https://trac.edgewall.org/">'
198 'https://trac.edgewall.org/</a>'),
199 """Page footer text (right-aligned).""")
200
201 project_icon = Option('project', 'icon', 'common/trac.ico',
202 """URL of the icon of the project.""")
203
204 log_type = ChoiceOption('logging', 'log_type',
205 log.LOG_TYPES + log.LOG_TYPE_ALIASES,
206 """Logging facility to use.
207
208 Should be one of (`none`, `file`, `stderr`, `syslog`, `winlog`).""",
209 case_sensitive=False)
210
211 log_file = Option('logging', 'log_file', 'trac.log',
212 """If `log_type` is `file`, this should be a path to the
213 log-file. Relative paths are resolved relative to the `log`
214 directory of the environment.""")
215
216 log_level = ChoiceOption('logging', 'log_level',
217 log.LOG_LEVELS + log.LOG_LEVEL_ALIASES,
218 """Level of verbosity in log.
219
220 Should be one of (`CRITICAL`, `ERROR`, `WARNING`, `INFO`, `DEBUG`).
221 """, case_sensitive=False)
222
223 log_format = Option('logging', 'log_format', None,
224 """Custom logging format.
225
226 If nothing is set, the following will be used:
227
228 `Trac[$(module)s] $(levelname)s: $(message)s`
229
230 In addition to regular key names supported by the
231 [http://docs.python.org/library/logging.html Python logger library]
232 one could use:
233
234 - `$(path)s` the path for the current environment
235 - `$(basename)s` the last path component of the current environment
236 - `$(project)s` the project name
237
238 Note the usage of `$(...)s` instead of `%(...)s` as the latter form
239 would be interpreted by the !ConfigParser itself.
240
241 Example:
242 `($(thread)d) Trac[$(basename)s:$(module)s] $(levelname)s: $(message)s`
243 """)
244
245 def __init__(self, path, create=False, options=[], default_data=True):
246 """Initialize the Trac environment.
247
248 :param path: the absolute path to the Trac environment
249 :param create: if `True`, the environment is created and otherwise,
250 the environment is expected to already exist.
251 :param options: A list of `(section, name, value)` tuples that
252 define configuration options
253 :param default_data: if `True` (the default), the environment is
254 populated with default data when created.
255 """
256 ComponentManager.__init__(self)
257
258 self.path = os.path.normpath(os.path.normcase(path))
259 self.log = None
260 self.config = None
261
262 if create:
263 self.create(options, default_data)
264 for setup_participant in self.setup_participants:
265 setup_participant.environment_created()
266 else:
267 self.verify()
268 self.setup_config()
269
270 def __repr__(self):
271 return '<%s %r>' % (self.__class__.__name__, self.path)
272
273 @lazy
274 def name(self):
275 """The environment name.
276
277 :since: 1.2
278 """
279 return os.path.basename(self.path)
280
281 @property
282 def env(self):
283 """Property returning the `Environment` object, which is often
284 required for functions and methods that take a `Component` instance.
285 """
286 # The cached decorator requires the object have an `env` attribute.
287 return self
288
289 @property
290 def system_info(self):
291 """List of `(name, version)` tuples describing the name and
292 version information of external packages used by Trac and plugins.
293 """
294 info = []
295 for provider in self.system_info_providers:
296 info.extend(provider.get_system_info() or [])
297 return sorted(set(info),
298 key=lambda args: (args[0] != 'Trac', args[0].lower()))
299
300 # ISystemInfoProvider methods
301
302 def get_system_info(self):
303 yield 'Trac', self.trac_version
304 yield 'Python', sys.version
305 yield 'setuptools', setuptools.__version__
306 if pytz is not None:
307 yield 'pytz', pytz.__version__
308 if hasattr(self, 'webfrontend_version'):
309 yield self.webfrontend, self.webfrontend_version
310
311 def component_activated(self, component):
312 """Initialize additional member variables for components.
313
314 Every component activated through the `Environment` object
315 gets three member variables: `env` (the environment object),
316 `config` (the environment configuration) and `log` (a logger
317 object)."""
318 component.env = self
319 component.config = self.config
320 component.log = self.log
321
322 def _component_name(self, name_or_class):
323 name = name_or_class
324 if not isinstance(name_or_class, str):
325 name = name_or_class.__module__ + '.' + name_or_class.__name__
326 return name.lower()
327
328 @lazy
329 def _component_rules(self):
330 _rules = {}
331 for name, value in self.components_section.options():
332 name = name.rstrip('.*').lower()
333 _rules[name] = as_bool(value)
334 return _rules
335
336 def is_component_enabled(self, cls):
337 """Implemented to only allow activation of components that are
338 not disabled in the configuration.
339
340 This is called by the `ComponentManager` base class when a
341 component is about to be activated. If this method returns
342 `False`, the component does not get activated. If it returns
343 `None`, the component only gets activated if it is located in
344 the `plugins` directory of the environment.
345 """
346 component_name = self._component_name(cls)
347
348 rules = self._component_rules
349 cname = component_name
350 while cname:
351 enabled = rules.get(cname)
352 if enabled is not None:
353 return enabled
354 idx = cname.rfind('.')
355 if idx < 0:
356 break
357 cname = cname[:idx]
358
359 # By default, all components in the trac package except
360 # in trac.test or trac.tests are enabled
361 return component_name.startswith('trac.') and \
362 not component_name.startswith('trac.test.') and \
363 not component_name.startswith('trac.tests.') or None
364
365 def enable_component(self, cls):
366 """Enable a component or module."""
367 self._component_rules[self._component_name(cls)] = True
368 super().enable_component(cls)
369
370 @contextmanager
371 def component_guard(self, component, reraise=False):
372 """Traps any runtime exception raised when working with a component
373 and logs the error.
374
375 :param component: the component responsible for any error that
376 could happen inside the context
377 :param reraise: if `True`, an error is logged but not suppressed.
378 By default, errors are suppressed.
379
380 """
381 try:
382 yield
383 except TracError as e:
384 self.log.warning("Component %s failed with %s",
385 component, exception_to_unicode(e))
386 if reraise:
387 raise
388 except Exception as e:
389 self.log.error("Component %s failed with %s", component,
390 exception_to_unicode(e, traceback=True))
391 if reraise:
392 raise
393
394 def verify(self):
395 """Verify that the provided path points to a valid Trac environment
396 directory."""
397 try:
398 with open(os.path.join(self.path, 'VERSION'),
399 encoding='utf-8') as f:
400 tag = f.readline().rstrip()
401 except Exception as e:
402 raise TracError(_("No Trac environment found at %(path)s\n"
403 "%(e)s",
404 path=self.path, e=exception_to_unicode(e)))
405 if tag != _VERSION:
406 raise TracError(_("Unknown Trac environment type '%(type)s'",
407 type=tag))
408
409 @lazy
410 def db_exc(self):
411 """Return an object (typically a module) containing all the
412 backend-specific exception types as attributes, named
413 according to the Python Database API
414 (http://www.python.org/dev/peps/pep-0249/).
415
416 To catch a database exception, use the following pattern::
417
418 try:
419 with env.db_transaction as db:
420 ...
421 except env.db_exc.IntegrityError as e:
422 ...
423 """
424 return DatabaseManager(self).get_exceptions()
425
426 @property
427 def db_query(self):
428 """Return a context manager
429 (`~trac.db.api.QueryContextManager`) which can be used to
430 obtain a read-only database connection.
431
432 Example::
433
434 with env.db_query as db:
435 cursor = db.cursor()
436 cursor.execute("SELECT ...")
437 for row in cursor.fetchall():
438 ...
439
440 Note that a connection retrieved this way can be "called"
441 directly in order to execute a query::
442
443 with env.db_query as db:
444 for row in db("SELECT ..."):
445 ...
446
447 :warning: after a `with env.db_query as db` block, though the
448 `db` variable is still defined, you shouldn't use it as it
449 might have been closed when exiting the context, if this
450 context was the outermost context (`db_query` or
451 `db_transaction`).
452
453 If you don't need to manipulate the connection itself, this
454 can even be simplified to::
455
456 for row in env.db_query("SELECT ..."):
457 ...
458
459 """
460 return QueryContextManager(self)
461
462 @property
463 def db_transaction(self):
464 """Return a context manager
465 (`~trac.db.api.TransactionContextManager`) which can be used
466 to obtain a writable database connection.
467
468 Example::
469
470 with env.db_transaction as db:
471 cursor = db.cursor()
472 cursor.execute("UPDATE ...")
473
474 Upon successful exit of the context, the context manager will
475 commit the transaction. In case of nested contexts, only the
476 outermost context performs a commit. However, should an
477 exception happen, any context manager will perform a rollback.
478 You should *not* call `commit()` yourself within such block,
479 as this will force a commit even if that transaction is part
480 of a larger transaction.
481
482 Like for its read-only counterpart, you can directly execute a
483 DML query on the `db`::
484
485 with env.db_transaction as db:
486 db("UPDATE ...")
487
488 :warning: after a `with env.db_transaction` as db` block,
489 though the `db` variable is still available, you shouldn't
490 use it as it might have been closed when exiting the
491 context, if this context was the outermost context
492 (`db_query` or `db_transaction`).
493
494 If you don't need to manipulate the connection itself, this
495 can also be simplified to::
496
497 env.db_transaction("UPDATE ...")
498
499 """
500 return TransactionContextManager(self)
501
502 def shutdown(self, tid=None):
503 """Close the environment."""
504 from trac.versioncontrol.api import RepositoryManager
505 RepositoryManager(self).shutdown(tid)
506 DatabaseManager(self).shutdown(tid)
507 if tid is None:
508 log.shutdown(self.log)
509
510 def create(self, options=[], default_data=True):
511 """Create the basic directory structure of the environment,
512 initialize the database and populate the configuration file
513 with default values.
514
515 If options contains ('inherit', 'file'), default values will
516 not be loaded; they are expected to be provided by that file
517 or other options.
518
519 :raises TracError: if the base directory of `path` does not exist.
520 :raises TracError: if `path` exists and is not empty.
521 """
522 base_dir = os.path.dirname(self.path)
523 if not os.path.exists(base_dir):
524 raise TracError(_(
525 "Base directory '%(env)s' does not exist. Please create it "
526 "and retry.", env=base_dir))
527
528 if os.path.exists(self.path) and os.listdir(self.path):
529 raise TracError(_("Directory exists and is not empty."))
530
531 # Create the directory structure
532 if not os.path.exists(self.path):
533 os.mkdir(self.path)
534 os.mkdir(self.htdocs_dir)
535 os.mkdir(self.log_dir)
536 os.mkdir(self.plugins_dir)
537 os.mkdir(self.templates_dir)
538
539 # Create a few files
540 create_file(os.path.join(self.path, 'VERSION'), _VERSION + '\n')
541 create_file(os.path.join(self.path, 'README'),
542 'This directory contains a Trac environment.\n'
543 'Visit https://trac.edgewall.org/ for more information.\n')
544
545 # Setup the default configuration
546 os.mkdir(self.conf_dir)
547 config = Configuration(self.config_file_path)
548 for section, name, value in options:
549 config.set(section, name, value)
550 config.save()
551 self.setup_config()
552 if not any((section, option) == ('inherit', 'file')
553 for section, option, value in options):
554 self.config.set_defaults(self)
555 self.config.save()
556
557 # Create the sample configuration
558 create_file(self.config_file_path + '.sample')
559 self._update_sample_config()
560
561 # Create the database
562 dbm = DatabaseManager(self)
563 dbm.init_db()
564 if default_data:
565 dbm.insert_default_data()
566
567 @lazy
568 def database_version(self):
569 """Returns the current version of the database.
570
571 :since 1.0.2:
572 """
573 return DatabaseManager(self) \
574 .get_database_version('database_version')
575
576 @lazy
577 def database_initial_version(self):
578 """Returns the version of the database at the time of creation.
579
580 In practice, for a database created before 0.11, this will
581 return `False` which is "older" than any db version number.
582
583 :since 1.0.2:
584 """
585 return DatabaseManager(self) \
586 .get_database_version('initial_database_version')
587
588 @lazy
589 def trac_version(self):
590 """Returns the version of Trac.
591 :since: 1.2
592 """
593 from trac import core, __version__
594 return get_pkginfo(core).get('version', __version__)
595
596 def setup_config(self):
597 """Load the configuration file."""
598 self.config = Configuration(self.config_file_path,
599 {'envname': self.name})
600 if not self.config.exists:
601 raise TracError(_("The configuration file is not found at "
602 "%(path)s", path=self.config_file_path))
603 self.setup_log()
604 plugins_dir = self.shared_plugins_dir
605 load_components(self, plugins_dir and (plugins_dir,))
606
607 @lazy
608 def config_file_path(self):
609 """Path of the trac.ini file."""
610 return os.path.join(self.conf_dir, 'trac.ini')
611
612 @lazy
613 def log_file_path(self):
614 """Path to the log file."""
615 if not os.path.isabs(self.log_file):
616 return os.path.join(self.log_dir, self.log_file)
617 return self.log_file
618
619 def _get_path_to_dir(self, *dirs):
620 path = self.path
621 for dir in dirs:
622 path = os.path.join(path, dir)
623 return os.path.normcase(os.path.realpath(path))
624
625 @lazy
626 def attachments_dir(self):
627 """Absolute path to the attachments directory.
628
629 :since: 1.3.1
630 """
631 return self._get_path_to_dir('files', 'attachments')
632
633 @lazy
634 def conf_dir(self):
635 """Absolute path to the conf directory.
636
637 :since: 1.0.11
638 """
639 return self._get_path_to_dir('conf')
640
641 @lazy
642 def files_dir(self):
643 """Absolute path to the files directory.
644
645 :since: 1.3.2
646 """
647 return self._get_path_to_dir('files')
648
649 @lazy
650 def htdocs_dir(self):
651 """Absolute path to the htdocs directory.
652
653 :since: 1.0.11
654 """
655 return self._get_path_to_dir('htdocs')
656
657 @lazy
658 def log_dir(self):
659 """Absolute path to the log directory.
660
661 :since: 1.0.11
662 """
663 return self._get_path_to_dir('log')
664
665 @lazy
666 def plugins_dir(self):
667 """Absolute path to the plugins directory.
668
669 :since: 1.0.11
670 """
671 return self._get_path_to_dir('plugins')
672
673 @lazy
674 def templates_dir(self):
675 """Absolute path to the templates directory.
676
677 :since: 1.0.11
678 """
679 return self._get_path_to_dir('templates')
680
681 def setup_log(self):
682 """Initialize the logging sub-system."""
683 self.log, log_handler = \
684 self.create_logger(self.log_type, self.log_file_path,
685 self.log_level, self.log_format)
686 self.log.addHandler(log_handler)
687 self.log.info('-' * 32 + ' environment startup [Trac %s] ' + '-' * 32,
688 self.trac_version)
689
690 def create_logger(self, log_type, log_file, log_level, log_format):
691 log_id = 'Trac.%s' % \
692 hashlib.sha1(self.path.encode('utf-8')).hexdigest()
693 if log_format:
694 log_format = log_format.replace('$(', '%(') \
695 .replace('%(path)s', self.path) \
696 .replace('%(basename)s', self.name) \
697 .replace('%(project)s', self.project_name)
698 return log.logger_handler_factory(log_type, log_file, log_level,
699 log_id, format=log_format)
700
701 def get_known_users(self, as_dict=False):
702 """Returns information about all known users, i.e. users that
703 have logged in to this Trac environment and possibly set their
704 name and email.
705
706 By default this function returns an iterator that yields one
707 tuple for every user, of the form (username, name, email),
708 ordered alpha-numerically by username. When `as_dict` is `True`
709 the function returns a dictionary mapping username to a
710 (name, email) tuple.
711
712 :since 1.2: the `as_dict` parameter is available.
713 """
714 return self._known_users_dict if as_dict else iter(self._known_users)
715
716 @cached
717 def _known_users(self):
718 return self.db_query("""
719 SELECT DISTINCT s.sid, n.value, e.value
720 FROM session AS s
721 LEFT JOIN session_attribute AS n ON (n.sid=s.sid
722 AND n.authenticated=1 AND n.name = 'name')
723 LEFT JOIN session_attribute AS e ON (e.sid=s.sid
724 AND e.authenticated=1 AND e.name = 'email')
725 WHERE s.authenticated=1 ORDER BY s.sid
726 """)
727
728 @cached
729 def _known_users_dict(self):
730 return {u[0]: (u[1], u[2]) for u in self._known_users}
731
732 def invalidate_known_users_cache(self):
733 """Clear the known_users cache."""
734 del self._known_users
735 del self._known_users_dict
736
737 def backup(self, dest=None):
738 """Create a backup of the database.
739
740 :param dest: Destination file; if not specified, the backup is
741 stored in a file called db_name.trac_version.bak
742 """
743 return DatabaseManager(self).backup(dest)
744
745 def needs_upgrade(self):
746 """Return whether the environment needs to be upgraded."""
747 for participant in self.setup_participants:
748 try:
749 with self.component_guard(participant, reraise=True):
750 if participant.environment_needs_upgrade():
751 self.log.warning(
752 "Component %s requires an environment upgrade",
753 participant)
754 return True
755 except Exception as e:
756 raise TracError(_("Unable to check for upgrade of "
757 "%(module)s.%(name)s: %(err)s",
758 module=participant.__class__.__module__,
759 name=participant.__class__.__name__,
760 err=exception_to_unicode(e)))
761 return False
762
763 def upgrade(self, backup=False, backup_dest=None):
764 """Upgrade database.
765
766 :param backup: whether or not to backup before upgrading
767 :param backup_dest: name of the backup file
768 :return: whether the upgrade was performed
769 """
770 upgraders = []
771 for participant in self.setup_participants:
772 with self.component_guard(participant, reraise=True):
773 if participant.environment_needs_upgrade():
774 upgraders.append(participant)
775 if not upgraders:
776 return
777
778 if backup:
779 try:
780 self.backup(backup_dest)
781 except Exception as e:
782 raise BackupError(e) from e
783
784 for participant in upgraders:
785 self.log.info("upgrading %s...", participant)
786 with self.component_guard(participant, reraise=True):
787 participant.upgrade_environment()
788 # Database schema may have changed, so close all connections
789 dbm = DatabaseManager(self)
790 if dbm.connection_uri != 'sqlite::memory:':
791 dbm.shutdown()
792
793 self._update_sample_config()
794 del self.database_version
795 return True
796
797 @lazy
798 def href(self):
799 """The application root path"""
800 return Href(urlsplit(self.abs_href.base).path)
801
802 @lazy
803 def abs_href(self):
804 """The application URL"""
805 if not self.base_url:
806 self.log.warning("[trac] base_url option not set in "
807 "configuration, generated links may be incorrect")
808 return Href(self.base_url)
809
810 def _update_sample_config(self):
811 filename = os.path.join(self.config_file_path + '.sample')
812 if not os.path.isfile(filename):
813 return
814 config = Configuration(filename)
815 config.set_defaults()
816 try:
817 config.save()
818 except EnvironmentError as e:
819 self.log.warning("Couldn't write sample configuration file (%s)%s",
820 e, exception_to_unicode(e, traceback=True))
821 else:
822 self.log.info("Wrote sample configuration file with the new "
823 "settings and their default values: %s",
824 filename)
825
826
827env_cache = {}
828env_cache_lock = threading.Lock()
829
830
831def open_environment(env_path=None, use_cache=False):
832 """Open an existing environment object, and verify that the database is up
833 to date.
834
835 :param env_path: absolute path to the environment directory; if
836 omitted, the value of the `TRAC_ENV` environment
837 variable is used
838 :param use_cache: whether the environment should be cached for
839 subsequent invocations of this function
840 :return: the `Environment` object
841 """
842 if not env_path:
843 env_path = os.getenv('TRAC_ENV')
844 if not env_path:
845 raise TracError(_('Missing environment variable "TRAC_ENV". '
846 'Trac requires this variable to point to a valid '
847 'Trac environment.'))
848
849 if use_cache:
850 with env_cache_lock:
851 env = env_cache.get(env_path)
852 if env and env.config.parse_if_needed():
853 # The environment configuration has changed, so shut it down
854 # and remove it from the cache so that it gets reinitialized
855 env.log.info('Reloading environment due to configuration '
856 'change')
857 env.shutdown()
858 del env_cache[env_path]
859 env = None
860 if env is None:
861 env = env_cache.setdefault(env_path,
862 open_environment(env_path))
863 else:
864 CacheManager(env).reset_metadata()
865 else:
866 env = Environment(env_path)
867 try:
868 needs_upgrade = env.needs_upgrade()
869 except TracError as e:
870 env.log.error("Exception caught while checking for upgrade: %s",
871 exception_to_unicode(e))
872 raise
873 except Exception as e: # e.g. no database connection
874 env.log.error("Exception caught while checking for upgrade: %s",
875 exception_to_unicode(e, traceback=True))
876 raise
877 else:
878 if needs_upgrade:
879 raise TracError(_('The Trac Environment needs to be upgraded. '
880 'Run:\n\n trac-admin "%(path)s" upgrade',
881 path=env_path))
882
883 return env
884
885
886class EnvironmentAdmin(Component):
887 """trac-admin command provider for environment administration."""
888
889 implements(IAdminCommandProvider)
890
891 # IAdminCommandProvider methods
892
893 def get_admin_commands(self):
894 yield ('convert_db', '<dburi> [new_env]',
895 """Convert database
896
897 Converts the database backend in the environment in which
898 the command is run (in-place), or in a new copy of the
899 environment. For an in-place conversion, the data is
900 copied to the database specified in <dburi> and the
901 [trac] database setting is changed to point to the new
902 database. The new database must be empty, which for an
903 SQLite database means the file should not exist. The data
904 in the existing database is left unmodified.
905
906 For a database conversion in a new copy of the environment,
907 the environment in which the command is executed is copied
908 and the [trac] database setting is changed in the new
909 environment. The existing environment is left unmodified.
910
911 Be sure to create a backup (see `hotcopy`) before converting
912 the database, particularly when doing an in-place conversion.
913 """,
914 self._complete_convert_db, self._do_convert_db)
915 yield ('deploy', '<directory>',
916 'Extract static resources from Trac and all plugins',
917 None, self._do_deploy)
918 yield ('hotcopy', '<backupdir> [--no-database]',
919 """Make a hot backup copy of an environment
920
921 The database is backed up to the 'db' directory of the
922 destination, unless the --no-database option is
923 specified.
924 """,
925 None, self._do_hotcopy)
926 yield ('upgrade', '[--no-backup]',
927 """Upgrade database to current version
928
929 The database is backed up to the directory specified by [trac]
930 backup_dir (the default is 'db'), unless the --no-backup
931 option is specified. The shorthand alias -b can also be used
932 to specify --no-backup.
933 """,
934 None, self._do_upgrade)
935
936 def _do_convert_db(self, dburi, env_path=None):
937 if env_path:
938 return self._do_convert_db_in_new_env(dburi, env_path)
939 else:
940 return self._do_convert_db_in_place(dburi)
941
942 def _complete_convert_db(self, args):
943 if len(args) == 2:
944 return get_dir_list(args[1])
945
946 def _do_deploy(self, dest):
947 target = os.path.normpath(dest)
948 chrome_target = os.path.join(target, 'htdocs')
949 script_target = os.path.join(target, 'cgi-bin')
950 chrome = Chrome(self.env)
951
952 # Check source and destination to avoid recursively copying files
953 for provider in chrome.template_providers:
954 paths = list(provider.get_htdocs_dirs() or [])
955 if not paths:
956 continue
957 for key, root in paths:
958 if not root:
959 continue
960 source = os.path.normpath(root)
961 dest = os.path.join(chrome_target, key)
962 if os.path.exists(source) and is_path_below(dest, source):
963 raise AdminCommandError(
964 _("Resources cannot be deployed to a target "
965 "directory that is equal to or below the source "
966 "directory '%(source)s'.\n\nPlease choose a "
967 "different target directory and try again.",
968 source=source))
969
970 # Copy static content
971 makedirs(target, overwrite=True)
972 makedirs(chrome_target, overwrite=True)
973 printout(_("Copying resources from:"))
974 for provider in chrome.template_providers:
975 paths = list(provider.get_htdocs_dirs() or [])
976 if not paths:
977 continue
978 printout(' %s.%s' % (provider.__module__,
979 provider.__class__.__name__))
980 for key, root in paths:
981 if not root:
982 continue
983 source = os.path.normpath(root)
984 printout(' ', source)
985 if os.path.exists(source):
986 dest = os.path.join(chrome_target, key)
987 copytree(source, dest, overwrite=True)
988
989 # Create and copy scripts
990 makedirs(script_target, overwrite=True)
991 printout(_("Creating scripts."))
992 data = {'env': self.env, 'executable': sys.executable, 'repr': repr}
993 for script in ('cgi', 'fcgi', 'wsgi'):
994 dest = os.path.join(script_target, 'trac.' + script)
995 template = chrome.load_template('deploy_trac.' + script, text=True)
996 text = chrome.render_template_string(template, data, text=True)
997
998 fd = os.open(dest, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o777)
999 try:
1000 out = os.fdopen(fd, 'w', encoding='utf-8')
1001 except:
1002 os.close(fd)
1003 raise
1004 with out:
1005 out.write(text)
1006
1007 def _do_hotcopy(self, dest, no_db=None):
1008 if no_db not in (None, '--no-database'):
1009 raise AdminCommandError(_("Invalid argument '%(arg)s'", arg=no_db),
1010 show_usage=True)
1011
1012 if os.path.exists(dest):
1013 raise TracError(_("hotcopy can't overwrite existing '%(dest)s'",
1014 dest=path_to_unicode(dest)))
1015
1016 printout(_("Hotcopying %(src)s to %(dst)s ...",
1017 src=path_to_unicode(self.env.path),
1018 dst=path_to_unicode(dest)))
1019 db_str = self.env.config.get('trac', 'database')
1020 prefix, db_path = db_str.split(':', 1)
1021 skip = []
1022
1023 if prefix == 'sqlite':
1024 db_path = os.path.join(self.env.path, os.path.normpath(db_path))
1025 # don't copy the journal (also, this would fail on Windows)
1026 skip = [db_path + '-journal', db_path + '-stmtjrnl',
1027 db_path + '-shm', db_path + '-wal']
1028 if no_db:
1029 skip.append(db_path)
1030
1031 # Bogus statement to lock the database while copying files
1032 with self.env.db_transaction as db:
1033 db("UPDATE " + db.quote('system') +
1034 " SET name=NULL WHERE name IS NULL")
1035 try:
1036 copytree(self.env.path, dest, symlinks=1, skip=skip)
1037 except shutil.Error as e:
1038 retval = 1
1039 printerr(_("The following errors happened while copying "
1040 "the environment:"))
1041 for src, dst, err in e.args[0]:
1042 if src in err:
1043 printerr(' %s' % err)
1044 else:
1045 printerr(" %s: '%s'" % (err, path_to_unicode(src)))
1046 else:
1047 retval = 0
1048
1049 # db backup for non-sqlite
1050 if prefix != 'sqlite' and not no_db:
1051 printout(_("Backing up database ..."))
1052 sql_backup = os.path.join(dest, 'db',
1053 '%s-db-backup.sql' % prefix)
1054 self.env.backup(sql_backup)
1055
1056 printout(_("Hotcopy done."))
1057 return retval
1058
1059 def _do_upgrade(self, no_backup=None):
1060 if no_backup not in (None, '-b', '--no-backup'):
1061 raise AdminCommandError(_("Invalid arguments"), show_usage=True)
1062
1063 if not self.env.needs_upgrade():
1064 printout(_("Database is up to date, no upgrade necessary."))
1065 return
1066
1067 try:
1068 self.env.upgrade(backup=no_backup is None)
1069 except BackupError as e:
1070 printerr(_("The pre-upgrade backup failed.\nUse '--no-backup' to "
1071 "upgrade without doing a backup.\n"))
1072 raise e.args[0]
1073 except Exception:
1074 printerr(_("The upgrade failed. Please fix the issue and try "
1075 "again.\n"))
1076 raise
1077
1078 printout(_('Upgrade done.\n\n'
1079 'You may want to upgrade the Trac documentation now by '
1080 'running:\n\n trac-admin "%(path)s" wiki upgrade',
1081 path=path_to_unicode(self.env.path)))
1082
1083 # Internal methods
1084
1085 def _do_convert_db_in_new_env(self, dst_dburi, env_path):
1086 try:
1087 os.rmdir(env_path) # remove directory if it's empty
1088 except OSError:
1089 pass
1090 if os.path.exists(env_path) or os.path.lexists(env_path):
1091 printferr("Cannot create Trac environment: %s: File exists",
1092 env_path)
1093 return 1
1094
1095 dst_env = self._create_env(env_path, dst_dburi)
1096 dbm = DatabaseManager(self.env)
1097 src_dburi = dbm.connection_uri
1098 src_db = dbm.get_connection()
1099 dst_db = DatabaseManager(dst_env).get_connection()
1100 self._copy_tables(dst_env, src_db, dst_db, src_dburi, dst_dburi)
1101 self._copy_directories(dst_env)
1102
1103 def _do_convert_db_in_place(self, dst_dburi):
1104 dbm = DatabaseManager(self.env)
1105 src_dburi = dbm.connection_uri
1106 if src_dburi == dst_dburi:
1107 printferr("Source database and destination database are the "
1108 "same: %s", dst_dburi)
1109 return 1
1110
1111 env_path = mkdtemp(prefix='convert_db-',
1112 dir=os.path.dirname(self.env.path))
1113 try:
1114 dst_env = self._create_env(env_path, dst_dburi)
1115 src_db = dbm.get_connection()
1116 dst_db = DatabaseManager(dst_env).get_connection()
1117 self._copy_tables(dst_env, src_db, dst_db, src_dburi, dst_dburi)
1118 del src_db
1119 del dst_db
1120 dst_env.shutdown()
1121 dst_env = None
1122 schema, params = parse_connection_uri(dst_dburi)
1123 if schema == 'sqlite':
1124 dbpath = os.path.join(self.env.path, params['path'])
1125 dbdir = os.path.dirname(dbpath)
1126 if not os.path.isdir(dbdir):
1127 os.makedirs(dbdir)
1128 shutil.copy(os.path.join(env_path, params['path']), dbpath)
1129 finally:
1130 shutil.rmtree(env_path)
1131
1132 backup_config_file(self.env, '.convert_db-%d' % int(time.time()))
1133 self.config.set('trac', 'database', dst_dburi)
1134 self.config.save()
1135
1136 def _create_env(self, env_path, dburi):
1137 parser = RawConfigParser()
1138 parser.read(self.env.config_file_path, 'utf-8')
1139 options = dict(((section, name), value)
1140 for section in parser.sections()
1141 for name, value in parser.items(section))
1142 options[('trac', 'database')] = dburi
1143 options = sorted((section, name, value) for (section, name), value
1144 in options.items())
1145
1146 class MigrateEnvironment(Environment):
1147 abstract = True
1148 required = False
1149
1150 def is_component_enabled(self, cls):
1151 name = self._component_name(cls)
1152 if not any(name.startswith(mod) for mod in
1153 ('trac.', 'tracopt.')):
1154 return False
1155 return Environment.is_component_enabled(self, cls)
1156
1157 # create an environment without plugins
1158 env = MigrateEnvironment(env_path, create=True, options=options)
1159 env.shutdown()
1160 # copy plugins directory
1161 os.rmdir(env.plugins_dir)
1162 shutil.copytree(self.env.plugins_dir, env.plugins_dir)
1163 # create tables for plugins to upgrade in other process
1164 with Popen((sys.executable, '-m', 'trac.admin.console', env_path,
1165 'upgrade'), stdin=PIPE, stdout=PIPE, stderr=PIPE,
1166 close_fds=close_fds) as proc:
1167 stdout, stderr = proc.communicate(input='')
1168 if proc.returncode != 0:
1169 raise TracError("upgrade command failed (stdout %r, stderr %r)" %
1170 (stdout, stderr))
1171 return Environment(env_path)
1172
1173 def _copy_tables(self, dst_env, src_db, dst_db, src_dburi, dst_dburi):
1174 copy_tables(self.env, dst_env, src_db, dst_db, src_dburi, dst_dburi)
1175
1176 def _copy_directories(self, dst_env):
1177 printfout("Copying directories:")
1178 for src in (self.env.files_dir, self.env.htdocs_dir,
1179 self.env.templates_dir, self.env.plugins_dir):
1180 name = os.path.basename(src)
1181 dst = os.path.join(dst_env.path, name)
1182 printfout(" %s directory... ", name, newline=False)
1183 if os.path.isdir(dst):
1184 shutil.rmtree(dst)
1185 if os.path.isdir(src):
1186 shutil.copytree(src, dst)
1187 printfout("done.")
Note: See TracBrowser for help on using the repository browser.