Edgewall Software

source: trunk/trac/env.py

Last change on this file was 17761, checked in by Jun Omae, 4 months ago

1.7.1dev: merge [17760] from 1.6-stable (fix for #13634)

  • Property svn:eol-style set to native
File size: 44.6 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 # Use sorted() instead of "ORDER BY s.sid" in order to avoid filesort
719 # caused by indexing only a prefix of column values on MySQL.
720 users = self.db_query("""
721 SELECT s.sid, n.value, e.value
722 FROM session AS s
723 LEFT JOIN session_attribute AS n ON (n.sid=s.sid
724 AND n.authenticated=1 AND n.name = 'name')
725 LEFT JOIN session_attribute AS e ON (e.sid=s.sid
726 AND e.authenticated=1 AND e.name = 'email')
727 WHERE s.authenticated=1
728 """)
729 return sorted(users, key=lambda u: u[0])
730
731 @cached
732 def _known_users_dict(self):
733 return {u[0]: (u[1], u[2]) for u in self._known_users}
734
735 def invalidate_known_users_cache(self):
736 """Clear the known_users cache."""
737 del self._known_users
738 del self._known_users_dict
739
740 def backup(self, dest=None):
741 """Create a backup of the database.
742
743 :param dest: Destination file; if not specified, the backup is
744 stored in a file called db_name.trac_version.bak
745 """
746 return DatabaseManager(self).backup(dest)
747
748 def needs_upgrade(self):
749 """Return whether the environment needs to be upgraded."""
750 for participant in self.setup_participants:
751 try:
752 with self.component_guard(participant, reraise=True):
753 if participant.environment_needs_upgrade():
754 self.log.warning(
755 "Component %s requires an environment upgrade",
756 participant)
757 return True
758 except Exception as e:
759 raise TracError(_("Unable to check for upgrade of "
760 "%(module)s.%(name)s: %(err)s",
761 module=participant.__class__.__module__,
762 name=participant.__class__.__name__,
763 err=exception_to_unicode(e)))
764 return False
765
766 def upgrade(self, backup=False, backup_dest=None):
767 """Upgrade database.
768
769 :param backup: whether or not to backup before upgrading
770 :param backup_dest: name of the backup file
771 :return: whether the upgrade was performed
772 """
773 upgraders = []
774 for participant in self.setup_participants:
775 with self.component_guard(participant, reraise=True):
776 if participant.environment_needs_upgrade():
777 upgraders.append(participant)
778 if not upgraders:
779 return
780
781 if backup:
782 try:
783 self.backup(backup_dest)
784 except Exception as e:
785 raise BackupError(e) from e
786
787 for participant in upgraders:
788 self.log.info("upgrading %s...", participant)
789 with self.component_guard(participant, reraise=True):
790 participant.upgrade_environment()
791 # Database schema may have changed, so close all connections
792 dbm = DatabaseManager(self)
793 if dbm.connection_uri != 'sqlite::memory:':
794 dbm.shutdown()
795
796 self._update_sample_config()
797 del self.database_version
798 return True
799
800 @lazy
801 def href(self):
802 """The application root path"""
803 return Href(urlsplit(self.abs_href.base).path)
804
805 @lazy
806 def abs_href(self):
807 """The application URL"""
808 if not self.base_url:
809 self.log.warning("[trac] base_url option not set in "
810 "configuration, generated links may be incorrect")
811 return Href(self.base_url)
812
813 def _update_sample_config(self):
814 filename = os.path.join(self.config_file_path + '.sample')
815 if not os.path.isfile(filename):
816 return
817 config = Configuration(filename)
818 config.set_defaults()
819 try:
820 config.save()
821 except EnvironmentError as e:
822 self.log.warning("Couldn't write sample configuration file (%s)%s",
823 e, exception_to_unicode(e, traceback=True))
824 else:
825 self.log.info("Wrote sample configuration file with the new "
826 "settings and their default values: %s",
827 filename)
828
829
830env_cache = {}
831env_cache_lock = threading.Lock()
832
833
834def open_environment(env_path=None, use_cache=False):
835 """Open an existing environment object, and verify that the database is up
836 to date.
837
838 :param env_path: absolute path to the environment directory; if
839 omitted, the value of the `TRAC_ENV` environment
840 variable is used
841 :param use_cache: whether the environment should be cached for
842 subsequent invocations of this function
843 :return: the `Environment` object
844 """
845 if not env_path:
846 env_path = os.getenv('TRAC_ENV')
847 if not env_path:
848 raise TracError(_('Missing environment variable "TRAC_ENV". '
849 'Trac requires this variable to point to a valid '
850 'Trac environment.'))
851
852 if use_cache:
853 with env_cache_lock:
854 env = env_cache.get(env_path)
855 if env and env.config.parse_if_needed():
856 # The environment configuration has changed, so shut it down
857 # and remove it from the cache so that it gets reinitialized
858 env.log.info('Reloading environment due to configuration '
859 'change')
860 env.shutdown()
861 del env_cache[env_path]
862 env = None
863 if env is None:
864 env = env_cache.setdefault(env_path,
865 open_environment(env_path))
866 else:
867 CacheManager(env).reset_metadata()
868 else:
869 env = Environment(env_path)
870 try:
871 needs_upgrade = env.needs_upgrade()
872 except TracError as e:
873 env.log.error("Exception caught while checking for upgrade: %s",
874 exception_to_unicode(e))
875 raise
876 except Exception as e: # e.g. no database connection
877 env.log.error("Exception caught while checking for upgrade: %s",
878 exception_to_unicode(e, traceback=True))
879 raise
880 else:
881 if needs_upgrade:
882 raise TracError(_('The Trac Environment needs to be upgraded. '
883 'Run:\n\n trac-admin "%(path)s" upgrade',
884 path=env_path))
885
886 return env
887
888
889class EnvironmentAdmin(Component):
890 """trac-admin command provider for environment administration."""
891
892 implements(IAdminCommandProvider)
893
894 # IAdminCommandProvider methods
895
896 def get_admin_commands(self):
897 yield ('convert_db', '<dburi> [new_env]',
898 """Convert database
899
900 Converts the database backend in the environment in which
901 the command is run (in-place), or in a new copy of the
902 environment. For an in-place conversion, the data is
903 copied to the database specified in <dburi> and the
904 [trac] database setting is changed to point to the new
905 database. The new database must be empty, which for an
906 SQLite database means the file should not exist. The data
907 in the existing database is left unmodified.
908
909 For a database conversion in a new copy of the environment,
910 the environment in which the command is executed is copied
911 and the [trac] database setting is changed in the new
912 environment. The existing environment is left unmodified.
913
914 Be sure to create a backup (see `hotcopy`) before converting
915 the database, particularly when doing an in-place conversion.
916 """,
917 self._complete_convert_db, self._do_convert_db)
918 yield ('deploy', '<directory>',
919 'Extract static resources from Trac and all plugins',
920 None, self._do_deploy)
921 yield ('hotcopy', '<backupdir> [--no-database]',
922 """Make a hot backup copy of an environment
923
924 The database is backed up to the 'db' directory of the
925 destination, unless the --no-database option is
926 specified.
927 """,
928 None, self._do_hotcopy)
929 yield ('upgrade', '[--no-backup]',
930 """Upgrade database to current version
931
932 The database is backed up to the directory specified by [trac]
933 backup_dir (the default is 'db'), unless the --no-backup
934 option is specified. The shorthand alias -b can also be used
935 to specify --no-backup.
936 """,
937 None, self._do_upgrade)
938
939 def _do_convert_db(self, dburi, env_path=None):
940 if env_path:
941 return self._do_convert_db_in_new_env(dburi, env_path)
942 else:
943 return self._do_convert_db_in_place(dburi)
944
945 def _complete_convert_db(self, args):
946 if len(args) == 2:
947 return get_dir_list(args[1])
948
949 def _do_deploy(self, dest):
950 target = os.path.normpath(dest)
951 chrome_target = os.path.join(target, 'htdocs')
952 script_target = os.path.join(target, 'cgi-bin')
953 chrome = Chrome(self.env)
954
955 # Check source and destination to avoid recursively copying files
956 for provider in chrome.template_providers:
957 paths = list(provider.get_htdocs_dirs() or [])
958 if not paths:
959 continue
960 for key, root in paths:
961 if not root:
962 continue
963 source = os.path.normpath(root)
964 dest = os.path.join(chrome_target, key)
965 if os.path.exists(source) and is_path_below(dest, source):
966 raise AdminCommandError(
967 _("Resources cannot be deployed to a target "
968 "directory that is equal to or below the source "
969 "directory '%(source)s'.\n\nPlease choose a "
970 "different target directory and try again.",
971 source=source))
972
973 # Copy static content
974 makedirs(target, overwrite=True)
975 makedirs(chrome_target, overwrite=True)
976 printout(_("Copying resources from:"))
977 for provider in chrome.template_providers:
978 paths = list(provider.get_htdocs_dirs() or [])
979 if not paths:
980 continue
981 printout(' %s.%s' % (provider.__module__,
982 provider.__class__.__name__))
983 for key, root in paths:
984 if not root:
985 continue
986 source = os.path.normpath(root)
987 printout(' ', source)
988 if os.path.exists(source):
989 dest = os.path.join(chrome_target, key)
990 copytree(source, dest, overwrite=True)
991
992 # Create and copy scripts
993 makedirs(script_target, overwrite=True)
994 printout(_("Creating scripts."))
995 data = {'env': self.env, 'executable': sys.executable, 'repr': repr}
996 for script in ('cgi', 'fcgi', 'wsgi'):
997 dest = os.path.join(script_target, 'trac.' + script)
998 template = chrome.load_template('deploy_trac.' + script, text=True)
999 text = chrome.render_template_string(template, data, text=True)
1000
1001 fd = os.open(dest, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o777)
1002 try:
1003 out = os.fdopen(fd, 'w', encoding='utf-8')
1004 except:
1005 os.close(fd)
1006 raise
1007 with out:
1008 out.write(text)
1009
1010 def _do_hotcopy(self, dest, no_db=None):
1011 if no_db not in (None, '--no-database'):
1012 raise AdminCommandError(_("Invalid argument '%(arg)s'", arg=no_db),
1013 show_usage=True)
1014
1015 if os.path.exists(dest):
1016 raise TracError(_("hotcopy can't overwrite existing '%(dest)s'",
1017 dest=path_to_unicode(dest)))
1018
1019 printout(_("Hotcopying %(src)s to %(dst)s ...",
1020 src=path_to_unicode(self.env.path),
1021 dst=path_to_unicode(dest)))
1022 db_str = self.env.config.get('trac', 'database')
1023 prefix, db_path = db_str.split(':', 1)
1024 skip = []
1025
1026 if prefix == 'sqlite':
1027 db_path = os.path.join(self.env.path, os.path.normpath(db_path))
1028 # don't copy the journal (also, this would fail on Windows)
1029 skip = [db_path + '-journal', db_path + '-stmtjrnl',
1030 db_path + '-shm', db_path + '-wal']
1031 if no_db:
1032 skip.append(db_path)
1033
1034 # Bogus statement to lock the database while copying files
1035 with self.env.db_transaction as db:
1036 db("UPDATE " + db.quote('system') +
1037 " SET name=NULL WHERE name IS NULL")
1038 try:
1039 copytree(self.env.path, dest, symlinks=1, skip=skip)
1040 except shutil.Error as e:
1041 retval = 1
1042 printerr(_("The following errors happened while copying "
1043 "the environment:"))
1044 for src, dst, err in e.args[0]:
1045 if src in err:
1046 printerr(' %s' % err)
1047 else:
1048 printerr(" %s: '%s'" % (err, path_to_unicode(src)))
1049 else:
1050 retval = 0
1051
1052 # db backup for non-sqlite
1053 if prefix != 'sqlite' and not no_db:
1054 printout(_("Backing up database ..."))
1055 sql_backup = os.path.join(dest, 'db',
1056 '%s-db-backup.sql' % prefix)
1057 self.env.backup(sql_backup)
1058
1059 printout(_("Hotcopy done."))
1060 return retval
1061
1062 def _do_upgrade(self, no_backup=None):
1063 if no_backup not in (None, '-b', '--no-backup'):
1064 raise AdminCommandError(_("Invalid arguments"), show_usage=True)
1065
1066 if not self.env.needs_upgrade():
1067 printout(_("Database is up to date, no upgrade necessary."))
1068 return
1069
1070 try:
1071 self.env.upgrade(backup=no_backup is None)
1072 except BackupError as e:
1073 printerr(_("The pre-upgrade backup failed.\nUse '--no-backup' to "
1074 "upgrade without doing a backup.\n"))
1075 raise e.args[0]
1076 except Exception:
1077 printerr(_("The upgrade failed. Please fix the issue and try "
1078 "again.\n"))
1079 raise
1080
1081 printout(_('Upgrade done.\n\n'
1082 'You may want to upgrade the Trac documentation now by '
1083 'running:\n\n trac-admin "%(path)s" wiki upgrade',
1084 path=path_to_unicode(self.env.path)))
1085
1086 # Internal methods
1087
1088 def _do_convert_db_in_new_env(self, dst_dburi, env_path):
1089 try:
1090 os.rmdir(env_path) # remove directory if it's empty
1091 except OSError:
1092 pass
1093 if os.path.exists(env_path) or os.path.lexists(env_path):
1094 printferr("Cannot create Trac environment: %s: File exists",
1095 env_path)
1096 return 1
1097
1098 dst_env = self._create_env(env_path, dst_dburi)
1099 dbm = DatabaseManager(self.env)
1100 src_dburi = dbm.connection_uri
1101 src_db = dbm.get_connection()
1102 dst_db = DatabaseManager(dst_env).get_connection()
1103 self._copy_tables(dst_env, src_db, dst_db, src_dburi, dst_dburi)
1104 self._copy_directories(dst_env)
1105
1106 def _do_convert_db_in_place(self, dst_dburi):
1107 dbm = DatabaseManager(self.env)
1108 src_dburi = dbm.connection_uri
1109 if src_dburi == dst_dburi:
1110 printferr("Source database and destination database are the "
1111 "same: %s", dst_dburi)
1112 return 1
1113
1114 env_path = mkdtemp(prefix='convert_db-',
1115 dir=os.path.dirname(self.env.path))
1116 try:
1117 dst_env = self._create_env(env_path, dst_dburi)
1118 src_db = dbm.get_connection()
1119 dst_db = DatabaseManager(dst_env).get_connection()
1120 self._copy_tables(dst_env, src_db, dst_db, src_dburi, dst_dburi)
1121 del src_db
1122 del dst_db
1123 dst_env.shutdown()
1124 dst_env = None
1125 schema, params = parse_connection_uri(dst_dburi)
1126 if schema == 'sqlite':
1127 dbpath = os.path.join(self.env.path, params['path'])
1128 dbdir = os.path.dirname(dbpath)
1129 if not os.path.isdir(dbdir):
1130 os.makedirs(dbdir)
1131 shutil.copy(os.path.join(env_path, params['path']), dbpath)
1132 finally:
1133 shutil.rmtree(env_path)
1134
1135 backup_config_file(self.env, '.convert_db-%d' % int(time.time()))
1136 self.config.set('trac', 'database', dst_dburi)
1137 self.config.save()
1138
1139 def _create_env(self, env_path, dburi):
1140 parser = RawConfigParser()
1141 parser.read(self.env.config_file_path, 'utf-8')
1142 options = dict(((section, name), value)
1143 for section in parser.sections()
1144 for name, value in parser.items(section))
1145 options[('trac', 'database')] = dburi
1146 options = sorted((section, name, value) for (section, name), value
1147 in options.items())
1148
1149 class MigrateEnvironment(Environment):
1150 abstract = True
1151 required = False
1152
1153 def is_component_enabled(self, cls):
1154 name = self._component_name(cls)
1155 if not any(name.startswith(mod) for mod in
1156 ('trac.', 'tracopt.')):
1157 return False
1158 return Environment.is_component_enabled(self, cls)
1159
1160 # create an environment without plugins
1161 env = MigrateEnvironment(env_path, create=True, options=options)
1162 env.shutdown()
1163 # copy plugins directory
1164 os.rmdir(env.plugins_dir)
1165 shutil.copytree(self.env.plugins_dir, env.plugins_dir)
1166 # create tables for plugins to upgrade in other process
1167 with Popen((sys.executable, '-m', 'trac.admin.console', env_path,
1168 'upgrade'), stdin=PIPE, stdout=PIPE, stderr=PIPE,
1169 close_fds=close_fds) as proc:
1170 stdout, stderr = proc.communicate(input='')
1171 if proc.returncode != 0:
1172 raise TracError("upgrade command failed (stdout %r, stderr %r)" %
1173 (stdout, stderr))
1174 return Environment(env_path)
1175
1176 def _copy_tables(self, dst_env, src_db, dst_db, src_dburi, dst_dburi):
1177 copy_tables(self.env, dst_env, src_db, dst_db, src_dburi, dst_dburi)
1178
1179 def _copy_directories(self, dst_env):
1180 printfout("Copying directories:")
1181 for src in (self.env.files_dir, self.env.htdocs_dir,
1182 self.env.templates_dir, self.env.plugins_dir):
1183 name = os.path.basename(src)
1184 dst = os.path.join(dst_env.path, name)
1185 printfout(" %s directory... ", name, newline=False)
1186 if os.path.isdir(dst):
1187 shutil.rmtree(dst)
1188 if os.path.isdir(src):
1189 shutil.copytree(src, dst)
1190 printfout("done.")
Note: See TracBrowser for help on using the repository browser.