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`.
241 for status in shell.eval("git", "ls-files", "--unmerged").splitlines():
242 files.add(status.split()[-1])
244 # check for newline mismatch
245 # HACK: using git diff to tell if files are binary or not
246 if not len(shell.eval("git", "diff", file).splitlines()) == 1 and util.mixed_newlines(file):
247 # this code only works on Unix
248 def get_newline(filename):
249 f = open(filename, "U")
250 # for some reason I need two
252 if s != "" and f.newlines is None:
254 if not isinstance(f.newlines, str):
255 raise Exception("Assert: expected newlines to be string, instead was %s in %s" % (repr(f.newlines), file))
257 def create_reference(id):
258 f = tempfile.NamedTemporaryFile(prefix="wizardResolve", delete=False)
259 shell.call("git", "cat-file", "blob", ":%d:%s" % (id, file), stdout=f)
261 return get_newline(f.name), f.name
262 def convert(filename, dest_nl):
263 contents = open(filename, "U").read().replace("\n", dest_nl)
264 open(filename, "wb").write(contents)
265 logging.info("Mixed newlines detected in %s", file)
266 common_nl, common_file = create_reference(1)
267 our_nl, our_file = create_reference(2)
268 their_nl, their_file = create_reference(3)
270 if common_nl != their_nl:
271 # upstream can't keep their newlines straight
272 logging.info("Converting common file (1) from %s to %s newlines", repr(common_nl), repr(their_nl))
273 convert(common_file, their_nl)
275 if our_nl != their_nl:
277 logging.info("Converting our file (2) from %s to %s newlines", repr(our_nl), repr(their_nl))
278 convert(our_file, their_nl)
281 logging.info("Remerging %s", file)
282 with open(file, "wb") as f:
284 shell.call("git", "merge-file", "--stdout", our_file, common_file, their_file, stdout=f)
285 logging.info("New merge was clean")
286 shell.call("git", "add", file)
288 except shell.CallError:
290 logging.info("Merge was still unclean")
292 logging.warning("Mixed newlines detected in %s, but no remerge possible", file)
294 if file in self.resolutions:
295 contents = open(file, "r").read()
296 for spec, result in self.resolutions[file]:
297 old_contents = contents
298 contents = resolve.resolve(contents, spec, result)
299 if old_contents != contents:
300 logging.info("Did resolution with spec:\n" + spec)
301 open(file, "w").write(contents)
302 if not resolve.is_conflict(contents):
303 shell.call("git", "add", file)
309 def prepareMerge(self, deployment):
311 Performs various edits to files in the current working directory in
312 order to make a merge go more smoothly. This is usually
313 used to fix botched line-endings. If you add new files,
314 you have to 'git add' them; this is not necessary for edits.
315 By default this is a no-op; subclasses should replace this
316 with useful behavior.
319 def prepareConfig(self, deployment):
321 Takes a deployment and replaces any explicit instances
322 of a configuration variable with generic ``WIZARD_*`` constants.
323 The default implementation uses :attr:`substitutions`, and
324 emits warnings when it encounters keys in :attr:`deprecated_keys`.
326 for key, subst in self.substitutions.items():
327 subs = subst(deployment)
328 if not subs and key not in self.deprecated_keys:
329 logging.warning("No substitutions for %s" % key)
330 def install(self, version, options):
332 Run for 'wizard configure' (and, by proxy, 'wizard install') to
333 configure an application. This assumes that the current working
334 directory is a deployment. (Unlike its kin, this function does not
335 take a :class:`wizard.deploy.Deployment` as a parameter.) Subclasses should
336 provide an implementation.
338 raise NotImplementedError
339 def upgrade(self, deployment, version, options):
341 Run for 'wizard upgrade' to upgrade database schemas and other
342 non-versioned data in an application after the filesystem has been
343 upgraded. This assumes that the current working directory is the
344 deployment. Subclasses should provide an implementation.
346 raise NotImplementedError
347 def backup(self, deployment, outdir, options):
349 Run for 'wizard backup' and upgrades to backup database schemas
350 and other non-versioned data in an application. ``outdir`` is
351 the directory that backup files should be placed. This assumes
352 that the current working directory is the deployment. Subclasses
353 should provide an implementation, even if it is a no-op.
356 Static user files may not need to be backed up, since in
357 many applications upgrades do not modify static files.
359 raise NotImplementedError
360 def restore(self, deployment, backup_dir, options):
362 Run for 'wizard restore' and failed upgrades to restore database
363 and other non-versioned data to a backed up version. This assumes
364 that the current working directory is the deployment. Subclasses
365 should provide an implementation.
367 raise NotImplementedError
368 def remove(self, deployment, options):
370 Run for 'wizard remove' to delete all database and non-local
371 file data. This assumes that the current working directory is
372 the deployment. Subclasses should provide an implementation.
374 raise NotImplementedError
375 def detectVersion(self, deployment):
377 Checks source files to determine the version manually. This assumes
378 that the current working directory is the deployment. Subclasses
379 should provide an implementation.
381 raise NotImplementedError
382 def detectVersionFromFile(self, filename, regex):
384 Helper method that detects a version by using a regular expression
385 from a file. The regexed value is passed through :mod:`shlex`.
386 This assumes that the current working directory is the deployment.
388 contents = open(filename).read()
389 match = regex.search(contents)
390 if not match: return None
391 return distutils.version.LooseVersion(shlex.split(match.group(2))[0])
392 def download(self, version):
394 Returns a URL that can be used to download a tarball of ``version`` of
397 raise NotImplementedError
398 def checkWeb(self, deployment):
400 Checks if the autoinstall is viewable from the web. Subclasses should
401 provide an implementation.
404 Finding a reasonable heuristic that works across skinning
405 choices can be difficult. We've had reasonable success
406 searching for metadata. Be sure that the standard error
407 page does not contain the features you search for. Try
408 not to depend on pages that are not the main page.
410 raise NotImplementedError
411 def checkWebPage(self, deployment, page, output):
413 Checks if a given page of an autoinstall contains a particular string.
415 page = deployment.fetch(page)
416 result = page.find(output) != -1
418 logging.debug("checkWebPage (passed):\n\n" + page)
420 logging.info("checkWebPage (failed):\n\n" + page)
422 def checkConfig(self, deployment):
424 Checks whether or not an autoinstall has been configured/installed
425 for use. Assumes that the current working directory is the deployment.
426 Subclasses should provide an implementation.
428 # XXX: Unfortunately, this doesn't quite work because we package
429 # bogus config files in the -scripts versions of installs. Maybe
430 # we should check a hash or something?
431 raise NotImplementedError
432 def researchFilter(self, filename, added, deleted):
434 Allows an application to selectively ignore certain diffstat signatures
435 during research; for example, configuration files will have a very
436 specific set of changes, so ignore them; certain installation files
437 may be removed, etc. Return ``True`` if a diffstat signature should be
441 def researchVerbose(self, filename):
443 Allows an application to exclude certain dirty files from the output
444 report; usually this will just be parametrized files, since those are
445 guaranteed to have changes. Return ``True`` if a file should only
446 be displayed in verbose mode.
448 return filename in self.parametrized_files
451 """Makes an application, but uses the correct subtype if available."""
453 __import__("wizard.app." + name)
454 return getattr(wizard.app, name).Application(name)
455 except ImportError as error:
456 # XXX ugly hack to check if the import error is from the top level
457 # module we care about or a submodule. should be an archetectural change.
458 if error.args[0].split()[-1]==name:
459 return Application(name)
463 class ApplicationVersion(object):
464 """Represents an abstract notion of a version for an application, where
465 ``version`` is a :class:`distutils.version.LooseVersion` and
466 ``application`` is a :class:`Application`."""
467 #: The :class:`distutils.version.LooseVersion` of this instance.
469 #: The :class:`Application` of this instance.
471 def __init__(self, version, application):
472 self.version = version
473 self.application = application
477 Returns the name of the git describe tag for the commit the user is
478 presently on, something like mediawiki-1.2.3-scripts-4-g123abcd
480 return "%s-%s" % (self.application, self.version)
482 def scripts_tag(self):
484 Returns the name of the Git tag for this version.
486 end = str(self.version).partition('-scripts')[2].partition('-')[0]
487 return "%s-scripts%s" % (self.pristine_tag, end)
489 def pristine_tag(self):
491 Returns the name of the Git tag for the pristine version corresponding
494 return "%s-%s" % (self.application.name, str(self.version).partition('-scripts')[0])
495 def __cmp__(self, y):
496 return cmp(self.version, y.version)
500 Parses a line from the :term:`versions store` and return
501 :class:`ApplicationVersion`.
503 Use this only for cases when speed is of primary importance;
504 the data in version is unreliable and when possible, you should
505 prefer directly instantiating a :class:`wizard.deploy.Deployment` and having it query
506 the autoinstall itself for information.
508 The `value` to parse will vary. For old style installs, it
511 /afs/athena.mit.edu/contrib/scripts/deploy/APP-x.y.z
513 For new style installs, it will look like::
517 name = value.split("/")[-1]
519 if name.find("-") != -1:
520 app, _, version = name.partition("-")
522 # kind of poor, maybe should error. Generally this
523 # will actually result in a not found error
527 raise DeploymentParseError(value)
528 return ApplicationVersion.make(app, version)
530 def make(app, version):
532 Makes/retrieves a singleton :class:`ApplicationVersion` from
533 a``app`` and ``version`` string.
536 # defer to the application for version creation to enforce
538 return applications()[app].makeVersion(version)
540 raise NoSuchApplication(app)
544 Takes a tree of values (implement using nested lists) and
545 transforms them into regular expressions.
549 >>> expand_re(['a', 'b'])
551 >>> expand_re(['*', ['b', 'c']])
554 if isinstance(val, str):
555 return re.escape(val)
557 return '(?:' + '|'.join(map(expand_re, val)) + ')'
559 def make_extractors(seed):
561 Take a dictionary of ``key`` to ``(file, regex)`` tuples and convert them into
562 extractor functions (which take a :class:`wizard.deploy.Deployment`
563 and return the value of the second subpattern of ``regex`` when matched
564 with the contents of ``file``).
566 return util.dictmap(lambda a: filename_regex_extractor(*a), seed)
568 def make_substitutions(seed):
570 Take a dictionary of ``key`` to ``(file, regex)`` tuples and convert them into substitution
571 functions (which take a :class:`wizard.deploy.Deployment`, replace the second subpattern
572 of ``regex`` with ``key`` in ``file``, and returns the number of substitutions made.)
574 return util.dictkmap(lambda k, v: filename_regex_substitution(k, *v), seed)
576 # The following two functions are *highly* functional, and I recommend
577 # not touching them unless you know what you're doing.
579 def filename_regex_extractor(file, regex):
581 .. highlight:: haskell
583 Given a relative file name ``file``, a regular expression ``regex``, and a
584 :class:`wizard.deploy.Deployment` extracts a value out of the file in that
585 deployment. This function is curried, so you pass just ``file`` and
586 ``regex``, and then pass ``deployment`` to the resulting function.
588 Its Haskell-style type signature would be::
590 Filename -> Regex -> (Deployment -> String)
592 The regular expression requires a very specific form, essentially ``()()()``
593 (with the second subgroup being the value to extract). These enables
594 the regular expression to be used equivalently with filename
596 .. highlight:: python
598 For convenience purposes, we also accept ``[Filename]``, in which case
599 we use the first entry (index 0). Passing an empty list is invalid.
601 >>> open("test-settings.extractor.ini", "w").write("config_var = 3\\n")
602 >>> f = filename_regex_extractor('test-settings.extractor.ini', re.compile('^(config_var\s*=\s*)(.*)()$'))
603 >>> f(deploy.Deployment("."))
605 >>> os.unlink("test-settings.extractor.ini")
608 The first application of ``regex`` and ``file`` is normally performed
609 at compile-time inside a submodule; the second application is
610 performed at runtime.
612 if not isinstance(file, str):
616 contents = deployment.read(file) # cached
619 match = regex.search(contents)
620 if not match: return None
621 # assumes that the second match is the one we want.
622 return match.group(2)
625 def filename_regex_substitution(key, files, regex):
627 .. highlight:: haskell
629 Given a Wizard ``key`` (``WIZARD_*``), a list of ``files``, a
630 regular expression ``regex``, and a :class:`wizard.deploy.Deployment`
631 performs a substitution of the second subpattern of ``regex``
632 with ``key``. Returns the number of replacements made. This function
633 is curried, so you pass just ``key``, ``files`` and ``regex``, and
634 then pass ``deployment`` to the resulting function.
636 Its Haskell-style type signature would be::
638 Key -> ([File], Regex) -> (Deployment -> IO Int)
640 .. highlight:: python
642 For convenience purposes, we also accept ``Filename``, in which case it is treated
643 as a single item list.
645 >>> open("test-settings.substitution.ini", "w").write("config_var = 3")
646 >>> f = filename_regex_substitution('WIZARD_KEY', 'test-settings.substitution.ini', re.compile('^(config_var\s*=\s*)(.*)()$'))
647 >>> f(deploy.Deployment("."))
649 >>> print open("test-settings.substitution.ini", "r").read()
650 config_var = WIZARD_KEY
651 >>> os.unlink("test-settings.substitution.ini")
653 if isinstance(files, str):
656 base = deployment.location
659 file = os.path.join(base, file)
661 contents = open(file, "r").read()
662 contents, n = regex.subn("\\1" + key + "\\3", contents)
664 open(file, "w").write(contents)
670 def backup_database(outdir, deployment):
672 Generic database backup function for MySQL.
674 # XXX: Change this once deployments support multiple dbs
675 if deployment.application.database == "mysql":
676 return backup_mysql_database(outdir, deployment)
678 raise NotImplementedError
680 def backup_mysql_database(outdir, deployment):
682 Database backups for MySQL using the :command:`mysqldump` utility.
684 outfile = os.path.join(outdir, "db.sql")
686 shell.call("mysqldump", "--compress", "-r", outfile, *get_mysql_args(deployment.dsn))
687 shell.call("gzip", "--best", outfile)
688 except shell.CallError as e:
689 raise BackupFailure(e.stderr)
691 def restore_database(backup_dir, deployment):
693 Generic database restoration function for MySQL.
695 # XXX: see backup_database
696 if deployment.application.database == "mysql":
697 return restore_mysql_database(backup_dir, deployment)
699 raise NotImplementedError
701 def restore_mysql_database(backup_dir, deployment):
703 Database restoration for MySQL by piping SQL commands into :command:`mysql`.
705 if not os.path.exists(backup_dir):
706 raise RestoreFailure("Backup %s doesn't exist", backup_dir.rpartition("/")[2])
707 sql = open(os.path.join(backup_dir, "db.sql"), 'w+')
708 shell.call("gunzip", "-c", os.path.join(backup_dir, "db.sql.gz"), stdout=sql)
710 shell.call("mysql", *get_mysql_args(deployment.dsn), stdin=sql)
713 def remove_database(deployment):
715 Generic database removal function. Actually, not so generic because we
716 go and check if we're on scripts and if we are run a different command.
718 if deployment.dsn.host == "sql.mit.edu":
720 shell.call("/mit/scripts/sql/bin/drop-database", deployment.dsn.database)
722 except shell.CallError:
724 engine = sqlalchemy.create_engine(deployment.dsn)
725 engine.execute("DROP DATABASE `%s`" % deployment.dsn.database)
727 def get_mysql_args(dsn):
729 Extracts arguments that would be passed to the command line mysql utility
734 args += ["-h", dsn.host]
736 args += ["-u", dsn.username]
738 args += ["-p" + dsn.password]
739 args += [dsn.database]
742 class Error(wizard.Error):
743 """Generic error class for this module."""
746 class NoRepositoryError(Error):
748 :class:`Application` does not appear to have a Git repository
749 in the normal location.
751 #: The name of the application that does not have a Git repository.
753 def __init__(self, app):
756 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
758 class DeploymentParseError(Error):
760 Could not parse ``value`` from :term:`versions store`.
762 #: The value that failed to parse.
764 #: The location of the autoinstall that threw this variable.
765 #: This should be set by error handling code when it is available.
767 def __init__(self, value):
770 class NoSuchApplication(Error):
772 You attempted to reference a :class:`Application` named
773 ``app``, which is not recognized by Wizard.
775 #: The name of the application that does not exist.
777 #: The location of the autoinstall that threw this variable.
778 #: This should be set by error handling code when it is availble.
780 def __init__(self, app):
783 class Failure(Error):
785 Represents a failure when performing some double-dispatched operation
786 such as an installation or an upgrade. Failure classes are postfixed
787 with Failure, not Error.
791 class InstallFailure(Error):
792 """Installation failed for unknown reason."""
796 ERROR: Installation failed for unknown reason. You can
797 retry the installation by appending --retry to the installation
800 class RecoverableInstallFailure(InstallFailure):
802 Installation failed, but we were able to determine what the
803 error was, and should give the user a second chance if we were
804 running interactively.
806 #: List of the errors that were found.
808 def __init__(self, errors):
813 ERROR: Installation failed due to the following errors: %s
815 You can retry the installation by appending --retry to the
816 installation command.""" % ", ".join(self.errors)
818 class UpgradeFailure(Failure):
819 """Upgrade script failed."""
820 #: String details of failure (possibly stdout or stderr output)
822 def __init__(self, details):
823 self.details = details
827 ERROR: Upgrade script failed, details:
831 class UpgradeVerificationFailure(Failure):
832 """Upgrade script passed, but website wasn't accessible afterwards"""
836 ERROR: Upgrade script passed, but website wasn't accessible afterwards. Check
837 the debug logs for the contents of the page."""
839 class BackupFailure(Failure):
840 """Backup script failed."""
841 #: String details of failure
843 def __init__(self, details):
844 self.details = details
848 ERROR: Backup script failed, details:
852 class RestoreFailure(Failure):
853 """Restore script failed."""
854 #: String details of failure
856 def __init__(self, details):
857 self.details = details
861 ERROR: Restore script failed, details:
865 class RemoveFailure(Failure):
866 """Remove script failed."""
867 #: String details of failure
869 def __init__(self, details):
870 self.details = details
874 ERROR: Remove script failed, details: