Edgewall Software

source: branches/1.4-stable/trac/env.py

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

1.4.4dev: set executable bit of CGI script files created by deploy command (refs #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
28from tempfile import mkdtemp
29from urlparse 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 Popen, 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 """This section is used to enable or disable components
89 provided by plugins, as well as by Trac itself. The component
90 to enable/disable is specified via the name of the
91 option. Whether its enabled is determined by the option value;
92 setting the value to `enabled` or `on` will enable the
93 component, any other value (typically `disabled` or `off`)
94 will disable the component.
95
96 The option name is either the fully qualified name of the
97 components or the module/package prefix of the component. The
98 former enables/disables a specific component, while the latter
99 enables/disables any component in the specified
100 package/module.
101
102 Consider the following configuration snippet:
103 {{{
104 [components]
105 trac.ticket.report.ReportModule = disabled
106 acct_mgr.* = enabled
107 }}}
108
109 The first option tells Trac to disable the
110 [wiki:TracReports report module].
111 The second option instructs Trac to enable all components in
112 the `acct_mgr` package. Note that the trailing wildcard is
113 required for module/package matching.
114
115 To view the list of active components, go to the ''Plugins''
116 page on ''About Trac'' (requires `CONFIG_VIEW`
117 [wiki:TracPermissions permissions]).
118
119 See also: TracPlugins
120 """)
121
122 shared_plugins_dir = PathOption('inherit', 'plugins_dir', '',
123 """Path to the //shared plugins directory//.
124
125 Plugins in that directory are loaded in addition to those in
126 the directory of the environment `plugins`, with this one
127 taking precedence.
128
129 Non-absolute paths are relative to the Environment `conf`
130 directory.
131 """)
132
133 base_url = Option('trac', 'base_url', '',
134 """Reference URL for the Trac deployment.
135
136 This is the base URL that will be used when producing
137 documents that will be used outside of the web browsing
138 context, like for example when inserting URLs pointing to Trac
139 resources in notification e-mails.""")
140
141 base_url_for_redirect = BoolOption('trac', 'use_base_url_for_redirect',
142 False,
143 """Optionally use `[trac] base_url` for redirects.
144
145 In some configurations, usually involving running Trac behind
146 a HTTP proxy, Trac can't automatically reconstruct the URL
147 that is used to access it. You may need to use this option to
148 force Trac to use the `base_url` setting also for
149 redirects. This introduces the obvious limitation that this
150 environment will only be usable when accessible from that URL,
151 as redirects are frequently used.
152 """)
153
154 secure_cookies = BoolOption('trac', 'secure_cookies', False,
155 """Restrict cookies to HTTPS connections.
156
157 When true, set the `secure` flag on all cookies so that they
158 are only sent to the server on HTTPS connections. Use this if
159 your Trac instance is only accessible through HTTPS.
160 """)
161
162 anonymous_session_lifetime = IntOption(
163 'trac', 'anonymous_session_lifetime', '90',
164 """Lifetime of the anonymous session, in days.
165
166 Set the option to 0 to disable purging old anonymous sessions.
167 (''since 1.0.17'')""")
168
169 project_name = Option('project', 'name', 'My Project',
170 """Name of the project.""")
171
172 project_description = Option('project', 'descr', 'My example project',
173 """Short description of the project.""")
174
175 project_url = Option('project', 'url', '',
176 """URL of the main project web site, usually the website in
177 which the `base_url` resides. This is used in notification
178 e-mails.""")
179
180 project_admin = Option('project', 'admin', '',
181 """E-Mail address of the project's administrator.""")
182
183 project_admin_trac_url = Option('project', 'admin_trac_url', '.',
184 """Base URL of a Trac instance where errors in this Trac
185 should be reported.
186
187 This can be an absolute or relative URL, or '.' to reference
188 this Trac instance. An empty value will disable the reporting
189 buttons.
190 """)
191
192 project_footer = Option('project', 'footer',
193 N_('Visit the Trac open source project at<br />'
194 '<a href="https://trac.edgewall.org/">'
195 'https://trac.edgewall.org/</a>'),
196 """Page footer text (right-aligned).""")
197
198 project_icon = Option('project', 'icon', 'common/trac.ico',
199 """URL of the icon of the project.""")
200
201 log_type = ChoiceOption('logging', 'log_type',
202 log.LOG_TYPES + log.LOG_TYPE_ALIASES,
203 """Logging facility to use.
204
205 Should be one of (`none`, `file`, `stderr`, `syslog`, `winlog`).""",
206 case_sensitive=False)
207
208 log_file = Option('logging', 'log_file', 'trac.log',
209 """If `log_type` is `file`, this should be a path to the
210 log-file. Relative paths are resolved relative to the `log`
211 directory of the environment.""")
212
213 log_level = ChoiceOption('logging', 'log_level',
214 log.LOG_LEVELS + log.LOG_LEVEL_ALIASES,
215 """Level of verbosity in log.
216
217 Should be one of (`CRITICAL`, `ERROR`, `WARNING`, `INFO`, `DEBUG`).
218 """, case_sensitive=False)
219
220 log_format = Option('logging', 'log_format', None,
221 """Custom logging format.
222
223 If nothing is set, the following will be used:
224
225 `Trac[$(module)s] $(levelname)s: $(message)s`
226
227 In addition to regular key names supported by the
228 [http://docs.python.org/library/logging.html Python logger library]
229 one could use:
230
231 - `$(path)s` the path for the current environment
232 - `$(basename)s` the last path component of the current environment
233 - `$(project)s` the project name
234
235 Note the usage of `$(...)s` instead of `%(...)s` as the latter form
236 would be interpreted by the !ConfigParser itself.
237
238 Example:
239 `($(thread)d) Trac[$(basename)s:$(module)s] $(levelname)s: $(message)s`
240 """)
241
242 def __init__(self, path, create=False, options=[]):
243 """Initialize the Trac environment.
244
245 :param path: the absolute path to the Trac environment
246 :param create: if `True`, the environment is created and
247 populated with default data; otherwise, the
248 environment is expected to already exist.
249 :param options: A list of `(section, name, value)` tuples that
250 define configuration options
251 """
252 ComponentManager.__init__(self)
253
254 self.path = os.path.normpath(os.path.normcase(path))
255 self.log = None
256 self.config = None
257
258 if create:
259 self.create(options)
260 for setup_participant in self.setup_participants:
261 setup_participant.environment_created()
262 else:
263 self.verify()
264 self.setup_config()
265
266 def __repr__(self):
267 return '<%s %r>' % (self.__class__.__name__, self.path)
268
269 @lazy
270 def name(self):
271 """The environment name.
272
273 :since: 1.2
274 """
275 return os.path.basename(self.path)
276
277 @property
278 def env(self):
279 """Property returning the `Environment` object, which is often
280 required for functions and methods that take a `Component` instance.
281 """
282 # The cached decorator requires the object have an `env` attribute.
283 return self
284
285 @property
286 def system_info(self):
287 """List of `(name, version)` tuples describing the name and
288 version information of external packages used by Trac and plugins.
289 """
290 info = []
291 for provider in self.system_info_providers:
292 info.extend(provider.get_system_info() or [])
293 return sorted(set(info),
294 key=lambda args: (args[0] != 'Trac', args[0].lower()))
295
296 def get_systeminfo(self):
297 """Return a list of `(name, version)` tuples describing the name
298 and version information of external packages used by Trac and plugins.
299
300 :since 1.3.1: deprecated and will be removed in 1.5.1. Use
301 system_info property instead.
302 """
303 return self.system_info
304
305 # ISystemInfoProvider methods
306
307 def get_system_info(self):
308 yield 'Trac', self.trac_version
309 yield 'Python', sys.version
310 yield 'setuptools', setuptools.__version__
311 if pytz is not None:
312 yield 'pytz', pytz.__version__
313 if hasattr(self, 'webfrontend_version'):
314 yield self.webfrontend, self.webfrontend_version
315
316 def component_activated(self, component):
317 """Initialize additional member variables for components.
318
319 Every component activated through the `Environment` object
320 gets three member variables: `env` (the environment object),
321 `config` (the environment configuration) and `log` (a logger
322 object)."""
323 component.env = self
324 component.config = self.config
325 component.log = self.log
326
327 def _component_name(self, name_or_class):
328 name = name_or_class
329 if not isinstance(name_or_class, basestring):
330 name = name_or_class.__module__ + '.' + name_or_class.__name__
331 return name.lower()
332
333 @lazy
334 def _component_rules(self):
335 _rules = {}
336 for name, value in self.components_section.options():
337 name = name.rstrip('.*').lower()
338 _rules[name] = as_bool(value)
339 return _rules
340
341 def is_component_enabled(self, cls):
342 """Implemented to only allow activation of components that are
343 not disabled in the configuration.
344
345 This is called by the `ComponentManager` base class when a
346 component is about to be activated. If this method returns
347 `False`, the component does not get activated. If it returns
348 `None`, the component only gets activated if it is located in
349 the `plugins` directory of the environment.
350 """
351 component_name = self._component_name(cls)
352
353 rules = self._component_rules
354 cname = component_name
355 while cname:
356 enabled = rules.get(cname)
357 if enabled is not None:
358 return enabled
359 idx = cname.rfind('.')
360 if idx < 0:
361 break
362 cname = cname[:idx]
363
364 # By default, all components in the trac package except
365 # in trac.test or trac.tests are enabled
366 return component_name.startswith('trac.') and \
367 not component_name.startswith('trac.test.') and \
368 not component_name.startswith('trac.tests.') or None
369
370 def enable_component(self, cls):
371 """Enable a component or module."""
372 self._component_rules[self._component_name(cls)] = True
373 super(Environment, self).enable_component(cls)
374
375 @contextmanager
376 def component_guard(self, component, reraise=False):
377 """Traps any runtime exception raised when working with a component
378 and logs the error.
379
380 :param component: the component responsible for any error that
381 could happen inside the context
382 :param reraise: if `True`, an error is logged but not suppressed.
383 By default, errors are suppressed.
384
385 """
386 try:
387 yield
388 except TracError as e:
389 self.log.warning("Component %s failed with %s",
390 component, exception_to_unicode(e))
391 if reraise:
392 raise
393 except Exception as e:
394 self.log.error("Component %s failed with %s", component,
395 exception_to_unicode(e, traceback=True))
396 if reraise:
397 raise
398
399 def verify(self):
400 """Verify that the provided path points to a valid Trac environment
401 directory."""
402 try:
403 with open(os.path.join(self.path, 'VERSION')) as f:
404 tag = f.readline().rstrip()
405 except Exception as e:
406 raise TracError(_("No Trac environment found at %(path)s\n"
407 "%(e)s",
408 path=self.path, e=exception_to_unicode(e)))
409 if tag != _VERSION:
410 raise TracError(_("Unknown Trac environment type '%(type)s'",
411 type=tag))
412
413 @lazy
414 def db_exc(self):
415 """Return an object (typically a module) containing all the
416 backend-specific exception types as attributes, named
417 according to the Python Database API
418 (http://www.python.org/dev/peps/pep-0249/).
419
420 To catch a database exception, use the following pattern::
421
422 try:
423 with env.db_transaction as db:
424 ...
425 except env.db_exc.IntegrityError as e:
426 ...
427 """
428 return DatabaseManager(self).get_exceptions()
429
430 @property
431 def db_query(self):
432 """Return a context manager
433 (`~trac.db.api.QueryContextManager`) which can be used to
434 obtain a read-only database connection.
435
436 Example::
437
438 with env.db_query as db:
439 cursor = db.cursor()
440 cursor.execute("SELECT ...")
441 for row in cursor.fetchall():
442 ...
443
444 Note that a connection retrieved this way can be "called"
445 directly in order to execute a query::
446
447 with env.db_query as db:
448 for row in db("SELECT ..."):
449 ...
450
451 :warning: after a `with env.db_query as db` block, though the
452 `db` variable is still defined, you shouldn't use it as it
453 might have been closed when exiting the context, if this
454 context was the outermost context (`db_query` or
455 `db_transaction`).
456
457 If you don't need to manipulate the connection itself, this
458 can even be simplified to::
459
460 for row in env.db_query("SELECT ..."):
461 ...
462
463 """
464 return QueryContextManager(self)
465
466 @property
467 def db_transaction(self):
468 """Return a context manager
469 (`~trac.db.api.TransactionContextManager`) which can be used
470 to obtain a writable database connection.
471
472 Example::
473
474 with env.db_transaction as db:
475 cursor = db.cursor()
476 cursor.execute("UPDATE ...")
477
478 Upon successful exit of the context, the context manager will
479 commit the transaction. In case of nested contexts, only the
480 outermost context performs a commit. However, should an
481 exception happen, any context manager will perform a rollback.
482 You should *not* call `commit()` yourself within such block,
483 as this will force a commit even if that transaction is part
484 of a larger transaction.
485
486 Like for its read-only counterpart, you can directly execute a
487 DML query on the `db`::
488
489 with env.db_transaction as db:
490 db("UPDATE ...")
491
492 :warning: after a `with env.db_transaction` as db` block,
493 though the `db` variable is still available, you shouldn't
494 use it as it might have been closed when exiting the
495 context, if this context was the outermost context
496 (`db_query` or `db_transaction`).
497
498 If you don't need to manipulate the connection itself, this
499 can also be simplified to::
500
501 env.db_transaction("UPDATE ...")
502
503 """
504 return TransactionContextManager(self)
505
506 def shutdown(self, tid=None):
507 """Close the environment."""
508 from trac.versioncontrol.api import RepositoryManager
509 RepositoryManager(self).shutdown(tid)
510 DatabaseManager(self).shutdown(tid)
511 if tid is None:
512 log.shutdown(self.log)
513
514 def create(self, options=[]):
515 """Create the basic directory structure of the environment,
516 initialize the database and populate the configuration file
517 with default values.
518
519 If options contains ('inherit', 'file'), default values will
520 not be loaded; they are expected to be provided by that file
521 or other options.
522
523 :raises TracError: if the base directory of `path` does not exist.
524 :raises TracError: if `path` exists and is not empty.
525 """
526 base_dir = os.path.dirname(self.path)
527 if not os.path.exists(base_dir):
528 raise TracError(_(
529 "Base directory '%(env)s' does not exist. Please create it "
530 "and retry.", env=base_dir))
531
532 if os.path.exists(self.path) and os.listdir(self.path):
533 raise TracError(_("Directory exists and is not empty."))
534
535 # Create the directory structure
536 if not os.path.exists(self.path):
537 os.mkdir(self.path)
538 os.mkdir(self.htdocs_dir)
539 os.mkdir(self.log_dir)
540 os.mkdir(self.plugins_dir)
541 os.mkdir(self.templates_dir)
542
543 # Create a few files
544 create_file(os.path.join(self.path, 'VERSION'), _VERSION + '\n')
545 create_file(os.path.join(self.path, 'README'),
546 'This directory contains a Trac environment.\n'
547 'Visit https://trac.edgewall.org/ for more information.\n')
548
549 # Setup the default configuration
550 os.mkdir(self.conf_dir)
551 config = Configuration(self.config_file_path)
552 for section, name, value in options:
553 config.set(section, name, value)
554 config.save()
555 self.setup_config()
556 if not any((section, option) == ('inherit', 'file')
557 for section, option, value in options):
558 self.config.set_defaults(self)
559 self.config.save()
560
561 # Create the sample configuration
562 create_file(self.config_file_path + '.sample')
563 self._update_sample_config()
564
565 # Create the database
566 DatabaseManager(self).init_db()
567
568 @lazy
569 def database_version(self):
570 """Returns the current version of the database.
571
572 :since 1.0.2:
573 """
574 return DatabaseManager(self) \
575 .get_database_version('database_version')
576
577 @lazy
578 def database_initial_version(self):
579 """Returns the version of the database at the time of creation.
580
581 In practice, for a database created before 0.11, this will
582 return `False` which is "older" than any db version number.
583
584 :since 1.0.2:
585 """
586 return DatabaseManager(self) \
587 .get_database_version('initial_database_version')
588
589 @lazy
590 def trac_version(self):
591 """Returns the version of Trac.
592 :since: 1.2
593 """
594 from trac import core, __version__
595 return get_pkginfo(core).get('version', __version__)
596
597 def setup_config(self):
598 """Load the configuration file."""
599 self.config = Configuration(self.config_file_path,
600 {'envname': self.name})
601 if not self.config.exists:
602 raise TracError(_("The configuration file is not found at "
603 "%(path)s", path=self.config_file_path))
604 self.setup_log()
605 plugins_dir = self.shared_plugins_dir
606 load_components(self, plugins_dir and (plugins_dir,))
607
608 @lazy
609 def config_file_path(self):
610 """Path of the trac.ini file."""
611 return os.path.join(self.conf_dir, 'trac.ini')
612
613 @lazy
614 def log_file_path(self):
615 """Path to the log file."""
616 if not os.path.isabs(self.log_file):
617 return os.path.join(self.log_dir, self.log_file)
618 return self.log_file
619
620 def _get_path_to_dir(self, *dirs):
621 path = self.path
622 for dir in dirs:
623 path = os.path.join(path, dir)
624 return os.path.realpath(path)
625
626 @lazy
627 def attachments_dir(self):
628 """Absolute path to the attachments directory.
629
630 :since: 1.3.1
631 """
632 return self._get_path_to_dir('files', 'attachments')
633
634 @lazy
635 def conf_dir(self):
636 """Absolute path to the conf directory.
637
638 :since: 1.0.11
639 """
640 return self._get_path_to_dir('conf')
641
642 @lazy
643 def files_dir(self):
644 """Absolute path to the files directory.
645
646 :since: 1.3.2
647 """
648 return self._get_path_to_dir('files')
649
650 @lazy
651 def htdocs_dir(self):
652 """Absolute path to the htdocs directory.
653
654 :since: 1.0.11
655 """
656 return self._get_path_to_dir('htdocs')
657
658 @lazy
659 def log_dir(self):
660 """Absolute path to the log directory.
661
662 :since: 1.0.11
663 """
664 return self._get_path_to_dir('log')
665
666 @lazy
667 def plugins_dir(self):
668 """Absolute path to the plugins directory.
669
670 :since: 1.0.11
671 """
672 return self._get_path_to_dir('plugins')
673
674 @lazy
675 def templates_dir(self):
676 """Absolute path to the templates directory.
677
678 :since: 1.0.11
679 """
680 return self._get_path_to_dir('templates')
681
682 def setup_log(self):
683 """Initialize the logging sub-system."""
684 self.log, log_handler = \
685 self.create_logger(self.log_type, self.log_file_path,
686 self.log_level, self.log_format)
687 self.log.addHandler(log_handler)
688 self.log.info('-' * 32 + ' environment startup [Trac %s] ' + '-' * 32,
689 self.trac_version)
690
691 def create_logger(self, log_type, log_file, log_level, log_format):
692 log_id = 'Trac.%s' % hashlib.sha1(self.path).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)
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')
1001 except:
1002 os.close(fd)
1003 raise
1004 with out:
1005 out.write(text.encode('utf-8'))
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)
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.iteritems())
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.