diff --git a/pom.xml b/pom.xml index f044eec..8a29c31 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.jenkins-ci.plugins plugin - 3.43 + 3.50 @@ -14,7 +14,7 @@ Audit Trail 2.7-SNAPSHOT - 2.60.3 + 2.138.1 8 @@ -39,6 +39,17 @@ 3.0.2 true + + io.jenkins + configuration-as-code + test + + + io.jenkins + configuration-as-code + tests + test + @@ -55,7 +66,6 @@ org.jenkins-ci.tools maven-hpi-plugin - 2.5 FINE @@ -78,4 +88,17 @@ https://repo.jenkins-ci.org/public/ + + + + + io.jenkins.tools.bom + bom-2.138.x + 3 + import + pom + + + + diff --git a/src/main/java/hudson/plugins/audit_trail/AuditLogger.java b/src/main/java/hudson/plugins/audit_trail/AuditLogger.java index 8d3c407..00e9679 100644 --- a/src/main/java/hudson/plugins/audit_trail/AuditLogger.java +++ b/src/main/java/hudson/plugins/audit_trail/AuditLogger.java @@ -1,14 +1,15 @@ package hudson.plugins.audit_trail; +import hudson.DescriptorExtensionList; import hudson.ExtensionPoint; import hudson.model.Describable; import hudson.model.Descriptor; import jenkins.model.Jenkins; -import java.io.IOException; /** * @author Nicolas De Loof + * @author Pierre Beitz */ public abstract class AuditLogger implements Describable, ExtensionPoint { @@ -16,7 +17,7 @@ public abstract class AuditLogger implements Describable, Extension public abstract void log(String event); - public Descriptor getDescriptor() { + public Descriptor getDescriptor() { return Jenkins.getInstance().getDescriptorOrDie(getClass()); } @@ -31,4 +32,10 @@ public void cleanUp() throws SecurityException { // default does nothing } + /** + * Returns all the registered {@link AuditLogger} descriptors. + */ + public static DescriptorExtensionList> all() { + return Jenkins.getInstance().getDescriptorList(AuditLogger.class); + } } diff --git a/src/main/java/hudson/plugins/audit_trail/AuditTrailFilter.java b/src/main/java/hudson/plugins/audit_trail/AuditTrailFilter.java index 713e95a..5fcccfc 100644 --- a/src/main/java/hudson/plugins/audit_trail/AuditTrailFilter.java +++ b/src/main/java/hudson/plugins/audit_trail/AuditTrailFilter.java @@ -23,9 +23,14 @@ */ package hudson.plugins.audit_trail; +import com.google.inject.Injector; +import hudson.Extension; +import hudson.init.Initializer; import hudson.model.User; +import hudson.util.PluginServletFilter; import jenkins.model.Jenkins; +import javax.inject.Inject; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import java.io.IOException; @@ -34,21 +39,33 @@ import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; +import static hudson.init.InitMilestone.PLUGINS_PREPARED; /** * Servlet filter to watch requests and log those we are interested in. * @author Alan Harder + * @author Pierre Beitz */ +@Extension public class AuditTrailFilter implements Filter { private static final Logger LOGGER = Logger.getLogger(AuditTrailFilter.class.getName()); private static Pattern uriPattern = null; - private final AuditTrailPlugin plugin; + @Inject + private AuditTrailPlugin configuration; + /** + * @deprecated as of 2.6 + **/ + @Deprecated public AuditTrailFilter(AuditTrailPlugin plugin) { - this.plugin = plugin; + this.configuration = plugin; + } + + public AuditTrailFilter() { + // used by the injector } public void init(FilterConfig fc) { @@ -85,7 +102,7 @@ public void doFilter(ServletRequest request, ServletResponse res, FilterChain ch if(LOGGER.isLoggable(Level.FINE)) LOGGER.log(Level.FINE, "Audit request {0} by user {1}", new Object[]{uri, username}); - plugin.onRequest(uri, extra, username); + onRequest(uri, extra, username); } else { LOGGER.log(Level.FINEST, "Skip audit for request {0}", uri); } @@ -94,4 +111,27 @@ public void doFilter(ServletRequest request, ServletResponse res, FilterChain ch public void destroy() { } + + // the default milestone doesn't seem right, as the injector is not available yet (at least with the JenkinsRule) + @Initializer(after = PLUGINS_PREPARED) + public static void init() throws ServletException { + Injector injector = Jenkins.getInstance().getInjector(); + if (injector == null) { + return; + } + PluginServletFilter.addFilter(injector.getInstance(AuditTrailFilter.class)); + } + + private void onRequest(String uri, String extra, String username) { + if (configuration != null) { + if (configuration.isStarted()) { + for (AuditLogger logger : configuration.getLoggers()) { + logger.log(uri + extra + " by " + username); + } + } else { + LOGGER.warning("Plugin configuration not properly injected, please report an issue to the Audit Trail Plugin"); + } + } + + } } diff --git a/src/main/java/hudson/plugins/audit_trail/AuditTrailPlugin.java b/src/main/java/hudson/plugins/audit_trail/AuditTrailPlugin.java index 30be9d4..f0421f3 100644 --- a/src/main/java/hudson/plugins/audit_trail/AuditTrailPlugin.java +++ b/src/main/java/hudson/plugins/audit_trail/AuditTrailPlugin.java @@ -24,75 +24,104 @@ package hudson.plugins.audit_trail; import hudson.DescriptorExtensionList; -import hudson.Plugin; -import hudson.model.*; -import hudson.model.Descriptor.FormException; +import hudson.Extension; + +import hudson.model.AbstractBuild; +import hudson.model.Descriptor; +import hudson.model.Run; import hudson.util.FormValidation; -import hudson.util.PluginServletFilter; -import jenkins.model.Jenkins; +import jenkins.model.GlobalConfiguration; import net.sf.json.JSONObject; +import org.jenkinsci.Symbol; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.DoNotUse; +import org.kohsuke.stapler.DataBoundSetter; import org.kohsuke.stapler.QueryParameter; import org.kohsuke.stapler.StaplerRequest; +import javax.annotation.PostConstruct; import javax.servlet.ServletException; import java.io.IOException; import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Optional; +import java.util.logging.Logger; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; /** * Keep audit trail of particular Jenkins operations, such as configuring jobs. + * * @author Alan Harder + * @author Pierre Beitz */ -public class AuditTrailPlugin extends Plugin { +@Symbol("audit-trail") +@Extension +public class AuditTrailPlugin extends GlobalConfiguration { + + private static final Logger LOGGER = Logger.getLogger(AuditTrailPlugin.class.getName()); private String pattern = ".*/(?:configSubmit|doDelete|postBuildResult|enable|disable|" - + "cancelQueue|stop|toggleLogKeep|doWipeOutWorkspace|createItem|createView|toggleOffline|" - + "cancelQuietDown|quietDown|restart|exit|safeExit)"; + + "cancelQueue|stop|toggleLogKeep|doWipeOutWorkspace|createItem|createView|toggleOffline|" + + "cancelQuietDown|quietDown|restart|exit|safeExit)"; private boolean logBuildCause = true; - private List loggers = new ArrayList(); + private List loggers = new ArrayList<>(); private transient boolean started; private transient String log; - private transient int limit = 1, count = 1; public String getPattern() { return pattern; } public boolean getLogBuildCause() { return logBuildCause; } public List getLoggers() { return loggers; } - @Override public void start() throws Exception { - // Set a default value; will be overridden by load() once customized: + public AuditTrailPlugin() { load(); - applySettings(); - - // Add Filter to watch all requests and log matching ones - PluginServletFilter.addFilter(new AuditTrailFilter(this)); } - @Override public void configure(StaplerRequest req, JSONObject formData) - throws IOException, ServletException, FormException { - pattern = formData.optString("pattern"); - logBuildCause = formData.optBoolean("logBuildCause", true); + @Override + public boolean configure(StaplerRequest req, JSONObject formData) { // readResolve makes sure loggers is initialized, so it should never be null. + // TODO this should probably be moved somewhere else loggers.forEach(AuditLogger::cleanUp); - loggers = Descriptor.newInstancesFromHeteroList( - req, formData, "loggers", getLoggerDescriptors()); - save(); + req.bindJSON(this, formData); applySettings(); + return true; } + @DataBoundSetter + public void setPattern(String pattern) { + this.pattern = Optional.ofNullable(pattern).orElse(""); + save(); + } + + @DataBoundSetter + public void setLogBuildCause(boolean logBuildCause) { + this.logBuildCause = logBuildCause; + save(); + } + + /** + * @deprecated as of 2.6 + **/ + @Deprecated public DescriptorExtensionList> getLoggerDescriptors() { - return Jenkins.getInstance().getDescriptorList(AuditLogger.class); + return AuditLogger.all(); } + @DataBoundSetter + public void setLoggers(List loggers) { + this.loggers = Optional.ofNullable(loggers).orElse(Collections.emptyList()); + } + @PostConstruct private void applySettings() { try { AuditTrailFilter.setPattern(pattern); + } catch (PatternSyntaxException ex) { + ex.printStackTrace(); } - catch (PatternSyntaxException ex) { ex.printStackTrace(); } for (AuditLogger logger : loggers) { logger.configure(); @@ -100,82 +129,38 @@ private void applySettings() { started = true; } - /* package */ void onStarted(Run run) { - if (this.started) { - StringBuilder buf = new StringBuilder(100); - for (CauseAction action : run.getActions(CauseAction.class)) { - for (Cause cause : action.getCauses()) { - if (buf.length() > 0) buf.append(", "); - buf.append(cause.getShortDescription()); - } - } - if (buf.length() == 0) buf.append("Started"); - - for (AuditLogger logger : loggers) { - logger.log(run.getParent().getUrl() + " #" + run.getNumber() + ' ' + buf.toString()); - } - - } + // TODO keeping this logic while refactoring, I'm not sure this is necessary + boolean isStarted() { + return started; } + /** + * @deprecated as of 2.6 + **/ + @Restricted(DoNotUse.class) + @Deprecated public void onFinalized(Run run) { - if (run instanceof AbstractBuild) { - onFinalized((AbstractBuild) run); - } - + LOGGER.warning("AuditTrailPlugin#onFinalized does nothing anymore, please update your script"); } + /** + * @deprecated as of 2.6 + **/ + @Restricted(DoNotUse.class) + @Deprecated public void onFinalized(AbstractBuild build) { - if (this.started) { - StringBuilder causeBuilder = new StringBuilder(100); - for (CauseAction action : build.getActions(CauseAction.class)) { - for (Cause cause : action.getCauses()) { - if (causeBuilder.length() > 0) causeBuilder.append(", "); - causeBuilder.append(cause.getShortDescription()); - } - } - if (causeBuilder.length() == 0) causeBuilder.append("Started"); - - for (AuditLogger logger : loggers) { - String message = build.getFullDisplayName() + - " " + causeBuilder.toString() + - " on node " + buildNodeName(build) + - " started at " + build.getTimestampString2() + - " completed in " + build.getDuration() + "ms" + - " completed: " + build.getResult(); - logger.log(message); - } - - } - } - - private String buildNodeName(AbstractBuild build) { - Node node = build.getBuiltOn(); - if (node != null) { - return node.getDisplayName(); - } - - return "#unknown#"; + LOGGER.warning("AuditTrailPlugin#onFinalized does nothing anymore, please update your script"); } - /* package */ void onRequest(String uri, String extra, String username) { - if (this.started) { - for (AuditLogger logger : loggers) { - logger.log(uri + extra + " by " + username); - } - } - } - - /** * Backward compatibility */ private Object readResolve() { if (log != null) { if (loggers == null) { - loggers = new ArrayList(); + loggers = new ArrayList<>(); } - LogFileAuditLogger logger = new LogFileAuditLogger(log, limit, count); + LogFileAuditLogger logger = new LogFileAuditLogger(log, 1, 1); if (!loggers.contains(logger)) loggers.add(logger); log = null; @@ -192,11 +177,10 @@ public FormValidation doRegexCheck(@QueryParameter final String value) try { Pattern.compile(value); return FormValidation.ok(); - } - catch (Exception ex) { + } catch (Exception ex) { return FormValidation.errorWithMarkup("Invalid regular expression (" + ex.getMessage() + ")"); + + "http://docs.oracle.com/javase/7/docs/api/java/util/regex/Pattern.html" + + "\">regular expression (" + ex.getMessage() + ")"); } } diff --git a/src/main/java/hudson/plugins/audit_trail/AuditTrailRunListener.java b/src/main/java/hudson/plugins/audit_trail/AuditTrailRunListener.java index ce043eb..8631b38 100644 --- a/src/main/java/hudson/plugins/audit_trail/AuditTrailRunListener.java +++ b/src/main/java/hudson/plugins/audit_trail/AuditTrailRunListener.java @@ -1,34 +1,79 @@ package hudson.plugins.audit_trail; import hudson.Extension; +import hudson.model.AbstractBuild; +import hudson.model.Cause; +import hudson.model.CauseAction; +import hudson.model.Node; import hudson.model.Run; import hudson.model.TaskListener; import hudson.model.listeners.RunListener; -import jenkins.model.Jenkins; + +import javax.inject.Inject; /** * @author Nicolas De Loof + * @author Pierre Beitz */ @Extension public class AuditTrailRunListener extends RunListener { + @Inject + AuditTrailPlugin configuration; + public AuditTrailRunListener() { super(Run.class); } @Override public void onStarted(Run run, TaskListener listener) { - AuditTrailPlugin plugin = (AuditTrailPlugin) Jenkins.getInstance().getPlugin("audit-trail"); - if (plugin != null) { - plugin.onStarted(run); + if (configuration.isStarted()) { + StringBuilder buf = new StringBuilder(100); + for (CauseAction action : run.getActions(CauseAction.class)) { + for (Cause cause : action.getCauses()) { + if (buf.length() > 0) buf.append(", "); + buf.append(cause.getShortDescription()); + } + } + if (buf.length() == 0) buf.append("Started"); + + for (AuditLogger logger : configuration.getLoggers()) { + logger.log(run.getParent().getUrl() + " #" + run.getNumber() + ' ' + buf.toString()); + } } } @Override public void onFinalized(Run run) { - AuditTrailPlugin plugin = (AuditTrailPlugin) Jenkins.getInstance().getPlugin("audit-trail"); - if (plugin != null) { - plugin.onFinalized(run); + if (configuration.isStarted()) { + StringBuilder causeBuilder = new StringBuilder(100); + for (CauseAction action : run.getActions(CauseAction.class)) { + for (Cause cause : action.getCauses()) { + if (causeBuilder.length() > 0) causeBuilder.append(", "); + causeBuilder.append(cause.getShortDescription()); + } + } + if (causeBuilder.length() == 0) causeBuilder.append("Started"); + + for (AuditLogger logger : configuration.getLoggers()) { + String message = run.getFullDisplayName() + + " " + causeBuilder.toString() + + " on node " + buildNodeName(run) + + " started at " + run.getTimestampString2() + + " completed in " + run.getDuration() + "ms" + + " completed: " + run.getResult(); + logger.log(message); + } + } + } + + private String buildNodeName(Run run) { + if (run instanceof AbstractBuild) { + Node node = ((AbstractBuild) run).getBuiltOn(); + if (node != null) { + return node.getDisplayName(); + } } + return "#unknown#"; } } diff --git a/src/main/resources/hudson/plugins/audit_trail/AuditTrailPlugin/config.jelly b/src/main/resources/hudson/plugins/audit_trail/AuditTrailPlugin/config.jelly index c7e3d6d..e7bbff0 100644 --- a/src/main/resources/hudson/plugins/audit_trail/AuditTrailPlugin/config.jelly +++ b/src/main/resources/hudson/plugins/audit_trail/AuditTrailPlugin/config.jelly @@ -2,17 +2,18 @@ + - + - + diff --git a/src/test/java/hudson/plugins/audit_trail/AuditTrailTest.java b/src/test/java/hudson/plugins/audit_trail/AuditTrailTest.java index 4885b60..72c1050 100644 --- a/src/test/java/hudson/plugins/audit_trail/AuditTrailTest.java +++ b/src/test/java/hudson/plugins/audit_trail/AuditTrailTest.java @@ -30,7 +30,8 @@ import hudson.Util; import hudson.model.Cause; import hudson.model.FreeStyleProject; -import jenkins.model.Jenkins; +import jenkins.model.GlobalConfiguration; + import org.junit.After; import org.junit.Rule; import org.junit.Test; @@ -84,7 +85,7 @@ public void shouldGenerateTwoAuditLogs() throws Exception { form.getInputByName(LOG_FILE_COUNT_INPUT_NAME).setValueAttribute("2"); j.submit(form); - AuditTrailPlugin plugin = Jenkins.getInstance().getPlugin(AuditTrailPlugin.class); + AuditTrailPlugin plugin = GlobalConfiguration.all().get(AuditTrailPlugin.class); LogFileAuditLogger logger = (LogFileAuditLogger) plugin.getLoggers().get(0); assertEquals("log path", logFile.getPath(), logger.getLog()); assertEquals("log size", 1, logger.getLimit()); diff --git a/src/test/java/hudson/plugins/audit_trail/ConfigurationAsCodeTest.java b/src/test/java/hudson/plugins/audit_trail/ConfigurationAsCodeTest.java new file mode 100644 index 0000000..9c89b9e --- /dev/null +++ b/src/test/java/hudson/plugins/audit_trail/ConfigurationAsCodeTest.java @@ -0,0 +1,73 @@ +package hudson.plugins.audit_trail; + +import hudson.ExtensionList; +import io.jenkins.plugins.casc.ConfigurationContext; +import io.jenkins.plugins.casc.ConfiguratorRegistry; +import io.jenkins.plugins.casc.misc.ConfiguredWithCode; +import io.jenkins.plugins.casc.misc.JenkinsConfiguredWithCodeRule; +import io.jenkins.plugins.casc.model.CNode; +import org.junit.ClassRule; +import org.junit.Test; +import org.jvnet.hudson.test.Issue; + +import static io.jenkins.plugins.casc.misc.Util.*; +import static org.hamcrest.CoreMatchers.is; +import static org.junit.Assert.*; + +/** + * Created by Pierre Beitz + * on 2019-07-20. + */ +public class ConfigurationAsCodeTest { + + @ClassRule + @ConfiguredWithCode("jcasc.yml") + public static JenkinsConfiguredWithCodeRule r = new JenkinsConfiguredWithCodeRule(); + + @Issue("JENKINS-57232") + @Test + public void should_support_configuration_as_code() { + ExtensionList extensionList = r.jenkins.getExtensionList(AuditTrailPlugin.class); + AuditTrailPlugin plugin = extensionList.get(0); + assertEquals(".*/(?:configSubmit|doUninstall|doDelete|postBuildResult|enable|disable|cancelQueue|stop|toggleLogKeep|doWipeOutWorkspace|createItem|createView|toggleOffline|cancelQuietDown|quietDown|restart|exit|safeExit)", plugin.getPattern()); + assertTrue(plugin.getLogBuildCause()); + assertEquals(3, plugin.getLoggers().size()); + + //first logger + AuditLogger logger = plugin.getLoggers().get(0); + assertTrue(logger instanceof ConsoleAuditLogger); + assertEquals("test", ((ConsoleAuditLogger) logger).getLogPrefix()); + assertEquals(ConsoleAuditLogger.Output.STD_OUT, ((ConsoleAuditLogger) logger).getOutput()); + assertEquals("yyyy-MM-dd HH:mm:ss:SSS", ((ConsoleAuditLogger) logger).getDateFormat()); + + //second logger + logger = plugin.getLoggers().get(1); + assertTrue(logger instanceof LogFileAuditLogger); + assertEquals(10, ((LogFileAuditLogger) logger).getCount()); + assertEquals(666, ((LogFileAuditLogger) logger).getLimit()); + assertEquals("/log/location", ((LogFileAuditLogger) logger).getLog()); + + //third logger + logger = plugin.getLoggers().get(2); + assertEquals("jenkins", ((SyslogAuditLogger) logger).getAppName()); + assertEquals("DAEMON", ((SyslogAuditLogger) logger).getFacility()); + assertEquals("RFC_5424", ((SyslogAuditLogger) logger).getMessageFormat()); + assertEquals("hostname", ((SyslogAuditLogger) logger).getMessageHostname()); + assertEquals("syslog-server", ((SyslogAuditLogger) logger).getSyslogServerHostname()); + assertEquals(514, ((SyslogAuditLogger) logger).getSyslogServerPort()); + } + + @Issue("JENKINS-57232") + @Test + public void should_support_configuration_export() throws Exception { + ConfiguratorRegistry registry = ConfiguratorRegistry.get(); + ConfigurationContext context = new ConfigurationContext(registry); + CNode auditTrailAttribute = getUnclassifiedRoot(context).get("audit-trail"); + + String exported = toYamlString(auditTrailAttribute); + + String expected = toStringFromYamlFile(this, "expected.yml"); + + assertThat(exported, is(expected)); + } +} diff --git a/src/test/java/hudson/plugins/audit_trail/ConsoleAuditLoggerTest.java b/src/test/java/hudson/plugins/audit_trail/ConsoleAuditLoggerTest.java index 520dcf6..33a7a01 100644 --- a/src/test/java/hudson/plugins/audit_trail/ConsoleAuditLoggerTest.java +++ b/src/test/java/hudson/plugins/audit_trail/ConsoleAuditLoggerTest.java @@ -25,7 +25,8 @@ import com.gargoylesoftware.htmlunit.html.HtmlForm; import com.gargoylesoftware.htmlunit.html.HtmlPage; -import jenkins.model.Jenkins; +import jenkins.model.GlobalConfiguration; + import org.junit.Rule; import org.junit.Test; import org.jvnet.hudson.test.Issue; @@ -36,6 +37,7 @@ /** * @author Tomasz Sęk + * @author Pierre Beitz */ public class ConsoleAuditLoggerTest { @@ -58,7 +60,7 @@ public void shouldConfigureConsoleAuditLogger() throws Exception { // Then // submit configuration page without any errors - AuditTrailPlugin plugin = Jenkins.getInstance().getPlugin(AuditTrailPlugin.class); + AuditTrailPlugin plugin = GlobalConfiguration.all().get(AuditTrailPlugin.class); assertEquals("amount of loggers", 1, plugin.getLoggers().size()); AuditLogger logger = plugin.getLoggers().get(0); assertTrue("ConsoleAuditLogger should be configured", logger instanceof ConsoleAuditLogger); diff --git a/src/test/resources/hudson/plugins/audit_trail/expected.yml b/src/test/resources/hudson/plugins/audit_trail/expected.yml new file mode 100644 index 0000000..4443901 --- /dev/null +++ b/src/test/resources/hudson/plugins/audit_trail/expected.yml @@ -0,0 +1,18 @@ +logBuildCause: true +loggers: +- console: + dateFormat: "yyyy-MM-dd HH:mm:ss:SSS" + logPrefix: "test" + output: STD_OUT +- logFile: + count: 10 + limit: 666 + log: "/log/location" +- syslog: + appName: "jenkins" + facility: "DAEMON" + messageFormat: "RFC_5424" + messageHostname: "hostname" + syslogServerHostname: "syslog-server" + syslogServerPort: 514 +pattern: ".*/(?:configSubmit|doUninstall|doDelete|postBuildResult|enable|disable|cancelQueue|stop|toggleLogKeep|doWipeOutWorkspace|createItem|createView|toggleOffline|cancelQuietDown|quietDown|restart|exit|safeExit)" diff --git a/src/test/resources/hudson/plugins/audit_trail/jcasc.yml b/src/test/resources/hudson/plugins/audit_trail/jcasc.yml new file mode 100644 index 0000000..11da7cc --- /dev/null +++ b/src/test/resources/hudson/plugins/audit_trail/jcasc.yml @@ -0,0 +1,20 @@ +unclassified: + audit-trail: + logBuildCause: true + loggers: + - console: + dateFormat: "yyyy-MM-dd HH:mm:ss:SSS" + logPrefix: "test" + output: STD_OUT + - logFile: + count: 10 + limit: 666 + log: "/log/location" + - syslog: + appName: "jenkins" + facility: "DAEMON" + messageFormat: "RFC_5424" + messageHostname: "hostname" + syslogServerHostname: "syslog-server" + syslogServerPort: 514 + pattern: ".*/(?:configSubmit|doUninstall|doDelete|postBuildResult|enable|disable|cancelQueue|stop|toggleLogKeep|doWipeOutWorkspace|createItem|createView|toggleOffline|cancelQuietDown|quietDown|restart|exit|safeExit)"