2 Plumbing object model for representing applications we want to
3 install. This module does the heavy lifting, but you probably
4 want to use :class:`wizard.deploy.Deployment` which is more user-friendly.
5 You'll need to know how to overload the :class:`Application` class
6 and use some of the functions in this module in order to specify
9 There are some submodules for programming languages that define common
10 functions and data that may be used by applications in that language. See:
12 * :mod:`wizard.app.php`
19 from wizard import deploy, util
20 from wizard.app import *
25 import distutils.version
37 from wizard import resolve, scripts, shell, util
40 "mediawiki", "wordpress", "joomla", "e107", "gallery2",
41 "phpBB", "advancedbook", "phpical", "trac", "turbogears", "django",
42 # these are technically deprecated
43 "advancedpoll", "gallery",
48 """Hash table for looking up string application name to instance"""
51 _applications = dict([(n,Application.make(n)) for n in _application_list ])
55 class Application(object):
57 Represents an application, i.e. mediawiki or phpbb.
60 Many of these methods assume a specific working
61 directory; prefer using the corresponding methods
62 in :class:`wizard.deploy.Deployment` and its subclasses.
64 #: String name of the application
66 #: Dictionary of version strings to :class:`ApplicationVersion`.
67 #: See also :meth:`makeVersion`.
69 #: List of files that need to be modified when parametrizing.
70 #: This is a class-wide constant, and should not normally be modified.
71 parametrized_files = []
72 #: Keys that are used in older versions of the application, but
73 #: not for the most recent version.
74 deprecated_keys = set()
75 #: Keys that we can simply generate random strings for if they're missing
77 #: Dictionary of variable names to extractor functions. These functions
78 #: take a :class:`wizard.deploy.Deployment` as an argument and return the value of
79 #: the variable, or ``None`` if it could not be found.
80 #: See also :func:`filename_regex_extractor`.
82 #: Dictionary of variable names to substitution functions. These functions
83 #: take a :class:`wizard.deploy.Deployment` as an argument and modify the deployment such
84 #: that an explicit instance of the variable is released with the generic
85 #: ``WIZARD_*`` constant. See also :func:`filename_regex_substitution`.
87 #: Dictionary of file names to a list of resolutions, which are tuples of
88 #: a conflict marker string and a result list. See :mod:`wizard.resolve`
89 #: for more information.
91 #: Instance of :class:`wizard.install.ArgSchema` that defines the arguments
92 #: this application requires.
94 #: Name of the database that this application uses, i.e. ``mysql`` or
95 #: ``postgres``. If we end up supporting multiple databases for a single
96 #: application, there should also be a value for this in
97 #: :class:`wizard.deploy.Deployment`; the value here is merely the preferred
100 def __init__(self, name):
104 self._extractors = {}
105 self._substitutions = {}
106 def repository(self, srv_path):
108 Returns the Git repository that would contain this application.
109 ``srv_path`` corresponds to ``options.srv_path`` from the global baton.
111 repo = os.path.join(srv_path, self.name + ".git")
112 if not os.path.isdir(repo):
113 repo = os.path.join(srv_path, self.name, ".git")
114 if not os.path.isdir(repo):
115 raise NoRepositoryError(self.name)
117 def makeVersion(self, version):
119 Creates or retrieves the :class:`ApplicationVersion` singleton for the
122 if version not in self.versions:
123 self.versions[version] = ApplicationVersion(distutils.version.LooseVersion(version), self)
124 return self.versions[version]
125 def extract(self, deployment):
127 Extracts wizard variables from a deployment. Default implementation
128 uses :attr:`extractors`.
131 for k,extractor in self.extractors.items():
132 result[k] = extractor(deployment)
133 # XXX: ugh... we have to do quoting
134 for k in self.random_keys:
135 if result[k] is None:
136 result[k] = "'%s'" % ''.join(random.choice(string.letters + string.digits) for i in xrange(30))
138 def dsn(self, deployment):
140 Returns the deployment specific database URL. Uses the override file
141 in :file:`.scripts` if it exists, and otherwise attempt to extract the
142 variables from the source files.
144 Under some cases, the database URL will contain only the database
145 property, and no other values. This indicates that the actual DSN
146 should be determined from the environment.
148 This function might return ``None``.
152 We are allowed to batch these two together, because the full precedence
153 chain for determining the database of an application combines these
154 two together. If this was not the case, we would have to call
155 :meth:`databaseUrlFromOverride` and :meth:`databaseUrlFromExtract` manually.
157 url = self.dsnFromOverride(deployment)
160 return self.dsnFromExtract(deployment)
161 def dsnFromOverride(self, deployment):
163 Extracts database URL from an explicit dsn override file.
166 return sqlalchemy.engine.url.make_url(open(deployment.dsn_file).read().strip())
169 def dsnFromExtract(self, deployment):
171 Extracts database URL from a deployment, and returns them as
172 a :class:`sqlalchemy.engine.url.URL`. Returns ``None`` if we
173 can't figure it out: i.e. the conventional variables are not defined
174 for this application.
176 if not self.database:
178 vars = self.extract(deployment)
179 names = ("WIZARD_DBSERVER", "WIZARD_DBUSER", "WIZARD_DBPASSWORD", "WIZARD_DBNAME")
180 host, user, password, database = (shlex.split(vars[x])[0] if vars[x] is not None else None for x in names)
181 # XXX: You'd have to put support for an explicit different database
183 return sqlalchemy.engine.url.URL(self.database, username=user, password=password, host=host, database=database)
184 def url(self, deployment):
186 Returns the deployment specific web URL. Uses the override file
187 in :file:`.scripts` if it exists, and otherwise attempt to extract
188 the variables from the source files.
190 This function might return ``None``, which indicates we couldn't figure
193 url = self.urlFromOverride(deployment)
196 return self.urlFromExtract(deployment)
197 def urlFromOverride(self, deployment):
199 Extracts URL from explicit url override file.
202 return urlparse.urlparse(open(deployment.url_file).read().strip())
205 def urlFromExtract(self, deployment):
207 Extracts URL from a deployment, and returns ``None`` if we can't
208 figure it out. Default implementation is to fail; we might
209 do something clever with extractable variables in the future.
212 def parametrize(self, deployment, ref_deployment):
214 Takes a generic source checkout and parametrizes it according to the
215 values of ``deployment``. This function operates on the current
216 working directory. ``deployment`` should **not** be the same as the
217 current working directory. Default implementation uses
218 :attr:`parametrized_files` and a simple search and replace on those
221 variables = ref_deployment.extract()
222 for file in self.parametrized_files:
224 contents = open(file, "r").read()
227 for key, value in variables.items():
228 if value is None: continue
229 contents = contents.replace(key, value)
232 def resolveConflicts(self, deployment):
234 Resolves conflicted files in the current working directory. Returns
235 whether or not all conflicted files were resolved or not. Fully
236 resolved files are added to the index, but no commit is made. The
237 default implementation uses :attr:`resolutions`.
242 for status in sh.eval("git", "ls-files", "--unmerged").splitlines():
243 files.add(status.split()[-1])
245 # check for newline mismatch
246 # HACK: using git diff to tell if files are binary or not
247 if not len(sh.eval("git", "diff", file).splitlines()) == 1 and util.mixed_newlines(file):
248 # this code only works on Unix
249 def get_newline(filename):
250 f = open(filename, "U")
251 # for some reason I need two
253 if s != "" and f.newlines is None:
255 if not isinstance(f.newlines, str):
256 raise Exception("Assert: expected newlines to be string, instead was %s in %s" % (repr(f.newlines), file))
258 def create_reference(id):
259 f = tempfile.NamedTemporaryFile(prefix="wizardResolve", delete=False)
260 sh.call("git", "cat-file", "blob", ":%d:%s" % (id, file), stdout=f)
262 return get_newline(f.name), f.name
263 def convert(filename, dest_nl):
264 contents = open(filename, "U").read().replace("\n", dest_nl)
265 open(filename, "wb").write(contents)
266 logging.info("Mixed newlines detected in %s", file)
267 common_nl, common_file = create_reference(1)
268 our_nl, our_file = create_reference(2)
269 their_nl, their_file = create_reference(3)
271 if common_nl != their_nl:
272 # upstream can't keep their newlines straight
273 logging.info("Converting common file (1) from %s to %s newlines", repr(common_nl), repr(their_nl))
274 convert(common_file, their_nl)
276 if our_nl != their_nl:
278 logging.info("Converting our file (2) from %s to %s newlines", repr(our_nl), repr(their_nl))
279 convert(our_file, their_nl)
282 logging.info("Remerging %s", file)
283 with open(file, "wb") as f:
285 sh.call("git", "merge-file", "--stdout", our_file, common_file, their_file, stdout=f)
286 logging.info("New merge was clean")
287 sh.call("git", "add", file)
289 except shell.CallError:
291 logging.info("Merge was still unclean")
293 logging.warning("Mixed newlines detected in %s, but no remerge possible", file)
295 if file in self.resolutions:
296 contents = open(file, "r").read()
297 for spec, result in self.resolutions[file]:
298 old_contents = contents
299 contents = resolve.resolve(contents, spec, result)
300 if old_contents != contents:
301 logging.info("Did resolution with spec:\n" + spec)
302 open(file, "w").write(contents)
303 if not resolve.is_conflict(contents):
304 sh.call("git", "add", file)
310 def prepareMerge(self, deployment):
312 Performs various edits to files in the current working directory in
313 order to make a merge go more smoothly. This is usually
314 used to fix botched line-endings. If you add new files,
315 you have to 'git add' them; this is not necessary for edits.
316 By default this is a no-op; subclasses should replace this
317 with useful behavior.
320 def prepareConfig(self, deployment):
322 Takes a deployment and replaces any explicit instances
323 of a configuration variable with generic ``WIZARD_*`` constants.
324 The default implementation uses :attr:`substitutions`, and
325 emits warnings when it encounters keys in :attr:`deprecated_keys`.
327 for key, subst in self.substitutions.items():
328 subs = subst(deployment)
329 if not subs and key not in self.deprecated_keys:
330 logging.warning("No substitutions for %s" % key)
331 def install(self, version, options):
333 Run for 'wizard configure' (and, by proxy, 'wizard install') to
334 configure an application. This assumes that the current working
335 directory is a deployment. (Unlike its kin, this function does not
336 take a :class:`wizard.deploy.Deployment` as a parameter.) Subclasses should
337 provide an implementation.
339 raise NotImplementedError
340 def upgrade(self, deployment, version, options):
342 Run for 'wizard upgrade' to upgrade database schemas and other
343 non-versioned data in an application after the filesystem has been
344 upgraded. This assumes that the current working directory is the
345 deployment. Subclasses should provide an implementation.
347 raise NotImplementedError
348 def backup(self, deployment, outdir, options):
350 Run for 'wizard backup' and upgrades to backup database schemas
351 and other non-versioned data in an application. ``outdir`` is
352 the directory that backup files should be placed. This assumes
353 that the current working directory is the deployment. Subclasses
354 should provide an implementation, even if it is a no-op.
357 Static user files may not need to be backed up, since in
358 many applications upgrades do not modify static files.
360 raise NotImplementedError
361 def restore(self, deployment, backup_dir, options):
363 Run for 'wizard restore' and failed upgrades to restore database
364 and other non-versioned data to a backed up version. This assumes
365 that the current working directory is the deployment. Subclasses
366 should provide an implementation.
368 raise NotImplementedError
369 def remove(self, deployment, options):
371 Run for 'wizard remove' to delete all database and non-local
372 file data. This assumes that the current working directory is
373 the deployment. Subclasses should provide an implementation.
375 raise NotImplementedError
376 def detectVersion(self, deployment):
378 Checks source files to determine the version manually. This assumes
379 that the current working directory is the deployment. Subclasses
380 should provide an implementation.
382 raise NotImplementedError
383 def detectVersionFromFile(self, filename, regex):
385 Helper method that detects a version by using a regular expression
386 from a file. The regexed value is passed through :mod:`shlex`.
387 This assumes that the current working directory is the deployment.
389 contents = open(filename).read()
390 match = regex.search(contents)
391 if not match: return None
392 return distutils.version.LooseVersion(shlex.split(match.group(2))[0])
393 def download(self, version):
395 Returns a URL that can be used to download a tarball of ``version`` of
398 raise NotImplementedError
399 def checkWeb(self, deployment):
401 Checks if the autoinstall is viewable from the web. Subclasses should
402 provide an implementation.
405 Finding a reasonable heuristic that works across skinning
406 choices can be difficult. We've had reasonable success
407 searching for metadata. Be sure that the standard error
408 page does not contain the features you search for. Try
409 not to depend on pages that are not the main page.
411 raise NotImplementedError
412 def checkWebPage(self, deployment, page, output):
414 Checks if a given page of an autoinstall contains a particular string.
416 page = deployment.fetch(page)
417 result = page.find(output) != -1
419 logging.debug("checkWebPage (passed):\n\n" + page)
421 logging.info("checkWebPage (failed):\n\n" + page)
423 def checkConfig(self, deployment):
425 Checks whether or not an autoinstall has been configured/installed
426 for use. Assumes that the current working directory is the deployment.
427 Subclasses should provide an implementation.
429 # XXX: Unfortunately, this doesn't quite work because we package
430 # bogus config files in the -scripts versions of installs. Maybe
431 # we should check a hash or something?
432 raise NotImplementedError
433 def researchFilter(self, filename, added, deleted):
435 Allows an application to selectively ignore certain diffstat signatures
436 during research; for example, configuration files will have a very
437 specific set of changes, so ignore them; certain installation files
438 may be removed, etc. Return ``True`` if a diffstat signature should be
442 def researchVerbose(self, filename):
444 Allows an application to exclude certain dirty files from the output
445 report; usually this will just be parametrized files, since those are
446 guaranteed to have changes. Return ``True`` if a file should only
447 be displayed in verbose mode.
449 return filename in self.parametrized_files
452 """Makes an application, but uses the correct subtype if available."""
454 __import__("wizard.app." + name)
455 return getattr(wizard.app, name).Application(name)
456 except ImportError as error:
457 # XXX ugly hack to check if the import error is from the top level
458 # module we care about or a submodule. should be an archetectural change.
459 if error.args[0].split()[-1]==name:
460 return Application(name)
464 class ApplicationVersion(object):
465 """Represents an abstract notion of a version for an application, where
466 ``version`` is a :class:`distutils.version.LooseVersion` and
467 ``application`` is a :class:`Application`."""
468 #: The :class:`distutils.version.LooseVersion` of this instance.
470 #: The :class:`Application` of this instance.
472 def __init__(self, version, application):
473 self.version = version
474 self.application = application
478 Returns the name of the git describe tag for the commit the user is
479 presently on, something like mediawiki-1.2.3-scripts-4-g123abcd
481 return "%s-%s" % (self.application, self.version)
483 def scripts_tag(self):
485 Returns the name of the Git tag for this version.
487 end = str(self.version).partition('-scripts')[2].partition('-')[0]
488 return "%s-scripts%s" % (self.pristine_tag, end)
490 def pristine_tag(self):
492 Returns the name of the Git tag for the pristine version corresponding
495 return "%s-%s" % (self.application.name, str(self.version).partition('-scripts')[0])
496 def __cmp__(self, y):
497 return cmp(self.version, y.version)
501 Parses a line from the :term:`versions store` and return
502 :class:`ApplicationVersion`.
504 Use this only for cases when speed is of primary importance;
505 the data in version is unreliable and when possible, you should
506 prefer directly instantiating a :class:`wizard.deploy.Deployment` and having it query
507 the autoinstall itself for information.
509 The `value` to parse will vary. For old style installs, it
512 /afs/athena.mit.edu/contrib/scripts/deploy/APP-x.y.z
514 For new style installs, it will look like::
518 name = value.split("/")[-1]
520 if name.find("-") != -1:
521 app, _, version = name.partition("-")
523 # kind of poor, maybe should error. Generally this
524 # will actually result in a not found error
528 raise DeploymentParseError(value)
529 return ApplicationVersion.make(app, version)
531 def make(app, version):
533 Makes/retrieves a singleton :class:`ApplicationVersion` from
534 a``app`` and ``version`` string.
537 # defer to the application for version creation to enforce
539 return applications()[app].makeVersion(version)
541 raise NoSuchApplication(app)
545 Takes a tree of values (implement using nested lists) and
546 transforms them into regular expressions.
550 >>> expand_re(['a', 'b'])
552 >>> expand_re(['*', ['b', 'c']])
555 if isinstance(val, str):
556 return re.escape(val)
558 return '(?:' + '|'.join(map(expand_re, val)) + ')'
560 def make_extractors(seed):
562 Take a dictionary of ``key`` to ``(file, regex)`` tuples and convert them into
563 extractor functions (which take a :class:`wizard.deploy.Deployment`
564 and return the value of the second subpattern of ``regex`` when matched
565 with the contents of ``file``).
567 return util.dictmap(lambda a: filename_regex_extractor(*a), seed)
569 def make_substitutions(seed):
571 Take a dictionary of ``key`` to ``(file, regex)`` tuples and convert them into substitution
572 functions (which take a :class:`wizard.deploy.Deployment`, replace the second subpattern
573 of ``regex`` with ``key`` in ``file``, and returns the number of substitutions made.)
575 return util.dictkmap(lambda k, v: filename_regex_substitution(k, *v), seed)
577 # The following two functions are *highly* functional, and I recommend
578 # not touching them unless you know what you're doing.
580 def filename_regex_extractor(file, regex):
582 .. highlight:: haskell
584 Given a relative file name ``file``, a regular expression ``regex``, and a
585 :class:`wizard.deploy.Deployment` extracts a value out of the file in that
586 deployment. This function is curried, so you pass just ``file`` and
587 ``regex``, and then pass ``deployment`` to the resulting function.
589 Its Haskell-style type signature would be::
591 Filename -> Regex -> (Deployment -> String)
593 The regular expression requires a very specific form, essentially ``()()()``
594 (with the second subgroup being the value to extract). These enables
595 the regular expression to be used equivalently with filename
597 .. highlight:: python
599 For convenience purposes, we also accept ``[Filename]``, in which case
600 we use the first entry (index 0). Passing an empty list is invalid.
602 >>> open("test-settings.extractor.ini", "w").write("config_var = 3\\n")
603 >>> f = filename_regex_extractor('test-settings.extractor.ini', re.compile('^(config_var\s*=\s*)(.*)()$'))
604 >>> f(deploy.Deployment("."))
606 >>> os.unlink("test-settings.extractor.ini")
609 The first application of ``regex`` and ``file`` is normally performed
610 at compile-time inside a submodule; the second application is
611 performed at runtime.
613 if not isinstance(file, str):
617 contents = deployment.read(file) # cached
620 match = regex.search(contents)
621 if not match: return None
622 # assumes that the second match is the one we want.
623 return match.group(2)
626 def filename_regex_substitution(key, files, regex):
628 .. highlight:: haskell
630 Given a Wizard ``key`` (``WIZARD_*``), a list of ``files``, a
631 regular expression ``regex``, and a :class:`wizard.deploy.Deployment`
632 performs a substitution of the second subpattern of ``regex``
633 with ``key``. Returns the number of replacements made. This function
634 is curried, so you pass just ``key``, ``files`` and ``regex``, and
635 then pass ``deployment`` to the resulting function.
637 Its Haskell-style type signature would be::
639 Key -> ([File], Regex) -> (Deployment -> IO Int)
641 .. highlight:: python
643 For convenience purposes, we also accept ``Filename``, in which case it is treated
644 as a single item list.
646 >>> open("test-settings.substitution.ini", "w").write("config_var = 3")
647 >>> f = filename_regex_substitution('WIZARD_KEY', 'test-settings.substitution.ini', re.compile('^(config_var\s*=\s*)(.*)()$'))
648 >>> f(deploy.Deployment("."))
650 >>> print open("test-settings.substitution.ini", "r").read()
651 config_var = WIZARD_KEY
652 >>> os.unlink("test-settings.substitution.ini")
654 if isinstance(files, str):
657 base = deployment.location
660 file = os.path.join(base, file)
662 contents = open(file, "r").read()
663 contents, n = regex.subn("\\1" + key + "\\3", contents)
665 open(file, "w").write(contents)
671 def backup_database(outdir, deployment):
673 Generic database backup function for MySQL.
675 # XXX: Change this once deployments support multiple dbs
676 if deployment.application.database == "mysql":
677 return backup_mysql_database(outdir, deployment)
679 raise NotImplementedError
681 def backup_mysql_database(outdir, deployment):
683 Database backups for MySQL using the :command:`mysqldump` utility.
686 outfile = os.path.join(outdir, "db.sql")
688 sh.call("mysqldump", "--compress", "-r", outfile, *get_mysql_args(deployment.dsn))
689 sh.call("gzip", "--best", outfile)
690 except shell.CallError as e:
691 raise BackupFailure(e.stderr)
693 def restore_database(backup_dir, deployment):
695 Generic database restoration function for MySQL.
697 # XXX: see backup_database
698 if deployment.application.database == "mysql":
699 return restore_mysql_database(backup_dir, deployment)
701 raise NotImplementedError
703 def restore_mysql_database(backup_dir, deployment):
705 Database restoration for MySQL by piping SQL commands into :command:`mysql`.
708 if not os.path.exists(backup_dir):
709 raise RestoreFailure("Backup %s doesn't exist", backup_dir.rpartition("/")[2])
710 sql = open(os.path.join(backup_dir, "db.sql"), 'w+')
711 sh.call("gunzip", "-c", os.path.join(backup_dir, "db.sql.gz"), stdout=sql)
713 sh.call("mysql", *get_mysql_args(deployment.dsn), stdin=sql)
716 def remove_database(deployment):
718 Generic database removal function. Actually, not so generic because we
719 go and check if we're on scripts and if we are run a different command.
722 if deployment.dsn.host == "sql.mit.edu":
724 sh.call("/mit/scripts/sql/bin/drop-database", deployment.dsn.database)
726 except shell.CallError:
728 engine = sqlalchemy.create_engine(deployment.dsn)
729 engine.execute("DROP DATABASE `%s`" % deployment.dsn.database)
731 def get_mysql_args(dsn):
733 Extracts arguments that would be passed to the command line mysql utility
738 args += ["-h", dsn.host]
740 args += ["-u", dsn.username]
742 args += ["-p" + dsn.password]
743 args += [dsn.database]
746 class Error(wizard.Error):
747 """Generic error class for this module."""
750 class NoRepositoryError(Error):
752 :class:`Application` does not appear to have a Git repository
753 in the normal location.
755 #: The name of the application that does not have a Git repository.
757 def __init__(self, app):
760 return """Could not find Git repository for '%s'. If you would like to use a local version, try specifying --srv-path or WIZARD_SRV_PATH.""" % self.app
762 class DeploymentParseError(Error):
764 Could not parse ``value`` from :term:`versions store`.
766 #: The value that failed to parse.
768 #: The location of the autoinstall that threw this variable.
769 #: This should be set by error handling code when it is available.
771 def __init__(self, value):
774 class NoSuchApplication(Error):
776 You attempted to reference a :class:`Application` named
777 ``app``, which is not recognized by Wizard.
779 #: The name of the application that does not exist.
781 #: The location of the autoinstall that threw this variable.
782 #: This should be set by error handling code when it is availble.
784 def __init__(self, app):
787 class Failure(Error):
789 Represents a failure when performing some double-dispatched operation
790 such as an installation or an upgrade. Failure classes are postfixed
791 with Failure, not Error.
795 class InstallFailure(Error):
796 """Installation failed for unknown reason."""
800 ERROR: Installation failed for unknown reason. You can
801 retry the installation by appending --retry to the installation
804 class RecoverableInstallFailure(InstallFailure):
806 Installation failed, but we were able to determine what the
807 error was, and should give the user a second chance if we were
808 running interactively.
810 #: List of the errors that were found.
812 def __init__(self, errors):
817 ERROR: Installation failed due to the following errors: %s
819 You can retry the installation by appending --retry to the
820 installation command.""" % ", ".join(self.errors)
822 class UpgradeFailure(Failure):
823 """Upgrade script failed."""
824 #: String details of failure (possibly stdout or stderr output)
826 def __init__(self, details):
827 self.details = details
831 ERROR: Upgrade script failed, details:
835 class UpgradeVerificationFailure(Failure):
836 """Upgrade script passed, but website wasn't accessible afterwards"""
840 ERROR: Upgrade script passed, but website wasn't accessible afterwards. Check
841 the debug logs for the contents of the page."""
843 class BackupFailure(Failure):
844 """Backup script failed."""
845 #: String details of failure
847 def __init__(self, details):
848 self.details = details
852 ERROR: Backup script failed, details:
856 class RestoreFailure(Failure):
857 """Restore script failed."""
858 #: String details of failure
860 def __init__(self, details):
861 self.details = details
865 ERROR: Restore script failed, details:
869 class RemoveFailure(Failure):
870 """Remove script failed."""
871 #: String details of failure
873 def __init__(self, details):
874 self.details = details
878 ERROR: Remove script failed, details: